From 3ca90c4cf3ba14df6d09e006d11a5d2281826445 Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Mon, 9 Nov 2026 16:10:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E8=80=81=E5=BA=8A=E5=9E=AB?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Health/ClimateAdviceBindingTests.cs | 2 +- .../Health/DeviceArchitectureTests.cs | 53 ++------ Admin.NET.Test/Health/RetiredDeviceTests.cs | 56 ++++++++ .../Health/SafetyAndReservedWorkflowTests.cs | 65 +--------- .../Health/SceneLightingIntegrationTests.cs | 36 +++--- .../Health/SystemServiceFilteringTests.cs | 10 +- .../Configuration/HomeAssistant.json | 47 ++++--- .../Health/Devices/Contracts/DeviceCommand.cs | 2 +- .../Discovery/HomeAssistantDeviceCatalog.cs | 7 +- .../Devices/HealthDeviceRegistration.cs | 6 +- .../Lighting/ConfiguredSleepLightModule.cs | 2 +- .../Health/Devices/Lighting/LightingModule.cs | 2 +- .../Health/Devices/Mattress/MattressModule.cs | 120 ------------------ .../HomeAssistant/HomeAssistantOptions.cs | 13 +- .../HomeAssistant/LightTransitionWorker.cs | 32 ++--- .../Health/Models/HealthModels.cs | 20 +-- .../Health/Policies/BmiFirmnessPolicy.cs | 22 ---- .../Health/Policies/RoomTemperaturePolicy.cs | 8 +- .../Health/Scenes/DeviceActionSafety.cs | 28 +--- .../Services/EnvironmentAdviceService.cs | 54 ++++++++ .../Health/Services/HealthAppService.cs | 14 +- .../Services/HealthHomeAssistantService.cs | 14 +- .../Services/HealthTcmAssessmentService.cs | 2 - .../Health/Services/MattressComfortService.cs | 73 ----------- .../Health/Sleep/ReservedSleepWorkflow.cs | 5 +- 25 files changed, 227 insertions(+), 466 deletions(-) create mode 100644 Admin.NET.Test/Health/RetiredDeviceTests.cs delete mode 100644 Evergreen.Health.Application/Health/Devices/Mattress/MattressModule.cs delete mode 100644 Evergreen.Health.Application/Health/Policies/BmiFirmnessPolicy.cs create mode 100644 Evergreen.Health.Application/Health/Services/EnvironmentAdviceService.cs delete mode 100644 Evergreen.Health.Application/Health/Services/MattressComfortService.cs diff --git a/Admin.NET.Test/Health/ClimateAdviceBindingTests.cs b/Admin.NET.Test/Health/ClimateAdviceBindingTests.cs index c6cd9cf..2236c85 100644 --- a/Admin.NET.Test/Health/ClimateAdviceBindingTests.cs +++ b/Admin.NET.Test/Health/ClimateAdviceBindingTests.cs @@ -31,7 +31,7 @@ public sealed class ClimateAdviceBindingTests Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) } }; - var service = new MattressComfortService(gateway, + var service = new EnvironmentAdviceService(gateway, new IndoorEnvironmentReader(options, TimeProvider.System), options); var output = service.Get(null, states); Assert.Equal(mode, output.ClimateAdvice.Mode); diff --git a/Admin.NET.Test/Health/DeviceArchitectureTests.cs b/Admin.NET.Test/Health/DeviceArchitectureTests.cs index d822712..e564612 100644 --- a/Admin.NET.Test/Health/DeviceArchitectureTests.cs +++ b/Admin.NET.Test/Health/DeviceArchitectureTests.cs @@ -4,7 +4,6 @@ using Evergreen.Health.Application.Health.Devices.Contracts; using Evergreen.Health.Application.Health.Devices.Discovery; using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Devices.Lighting; -using Evergreen.Health.Application.Health.Devices.Mattress; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Models; @@ -28,11 +27,8 @@ public sealed class DeviceArchitectureTests Entities = new() { AmbientLight = "light.bedroom", Speaker = "media_player.speaker", SpeakerSleepMode = "switch.sleep", - Temperature = "sensor.room_temperature", MattressLeftMode = "select.left_mode", MattressRightMode = "select.right_mode", - MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat", - MattressLeftMassagePart = "select.left_massage", MattressRightMassagePart = "select.right_massage", - MattressMassageLevel = "select.massage_level", MattressFirmness = new() { ["left_shoulder"] = "select.left_shoulder" } - }, SleepScene = new() { EnableMattress = true } + Temperature = "sensor.room_temperature" + } }; private static HomeAssistantState Temperature(double value, DateTimeOffset? observed) => new() { @@ -40,25 +36,6 @@ public sealed class DeviceArchitectureTests LastReported = observed, Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) }; - [Theory] - [InlineData(18.4, "很软")] - [InlineData(18.5, "稍软")] - [InlineData(24.0, "稍硬")] - [InlineData(28.0, "很硬")] - [InlineData(31.1, "很硬")] - [InlineData(32.0, "最硬")] - public void BmiUsesStableFirmnessBoundaries(double bmi, string expected) - => Assert.Equal(expected, BmiFirmnessPolicy.Firmness((decimal)bmi)); - - [Fact] - public void MissingOrInvalidBodyDataNeverProducesFirmness() - { - Assert.Null(BmiFirmnessPolicy.Calculate(null, 80)); - Assert.Null(BmiFirmnessPolicy.Calculate(0, 80)); - Assert.Null(BmiFirmnessPolicy.Firmness(null)); - Assert.Equal(31.1m, BmiFirmnessPolicy.Calculate(180, 100.8m)); - } - [Theory] [InlineData(21.9, "heat")] [InlineData(22.0, "off")] @@ -131,10 +108,6 @@ public sealed class DeviceArchitectureTests Assert.Throws(() => light.BuildParameterCommand("color_temp", 6500)); Assert.Equal("light.bedroom", light.BuildParameterCommand("brightness", 0).EntityId); Assert.Equal("turn_off", light.BuildParameterCommand("brightness", 0).Service); - var mattress = new MattressModule(runner, Options.Create(Configuration())); - Assert.Equal("全身", mattress.BuildParameterCommand("mattress_massage_right_part", "全身").Data!["option"]); - Assert.Equal("轻柔", mattress.BuildParameterCommand("mattress_massage_level", "轻柔").Data!["option"]); - Assert.Throws(() => mattress.BuildParameterCommand("mattress_right_mode", "随意模式")); } [Fact] @@ -163,15 +136,15 @@ public sealed class DeviceArchitectureTests } [Fact] - public void FallbackOnlyContainsConfiguredModulesAndIncludesMattressEntities() + public void FallbackOnlyContainsConfiguredModules() { var runner = Runner(new()); var options = Options.Create(Configuration()); - var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)])); + var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]))])); var device = Assert.Single(catalog.BuildDiscoveredDevices(null, new Dictionary())); - Assert.Equal("mattress", device.ModuleKey); - Assert.Contains(device.Entities, entity => entity.EntityId == "select.right_massage"); - Assert.Null(device.PrimaryControlEntityId); + Assert.Equal("lighting", device.ModuleKey); + Assert.Contains(device.Entities, entity => entity.EntityId == "light.bedroom"); + Assert.Equal("light.bedroom", device.PrimaryControlEntityId); } [Fact] @@ -218,17 +191,15 @@ public sealed class DeviceArchitectureTests } [Fact] - public async Task ManualLightOffUsesSceneLinkageToStopBothMassageSides() + public async Task ManualLightOffOnlyControlsTheLight() { var gateway = new FakeHaGateway(); var runner = Runner(gateway); var options = Options.Create(Configuration()); - var mattress = new MattressModule(runner, options); - var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([mattress])); + var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([])); await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default); Assert.Equal("off", gateway.States["light.bedroom"].State); - Assert.Equal("无", gateway.States["select.right_massage"].State); - Assert.Equal("无", gateway.States["select.left_massage"].State); + Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId)); } [Fact] @@ -248,7 +219,9 @@ public sealed class DeviceArchitectureTests using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); Assert.NotNull(provider.GetRequiredService()); Assert.Equal(2, provider.GetServices().Count()); - Assert.Contains(provider.GetRequiredService().Modules, module => module.Key == "mattress"); + Assert.DoesNotContain(provider.GetRequiredService().Modules, module => module.Key == "mattress"); + Assert.Empty(provider.GetServices()); + Assert.Throws(() => provider.GetRequiredService().ForParameter("mattress_left_heating")); } [Fact] diff --git a/Admin.NET.Test/Health/RetiredDeviceTests.cs b/Admin.NET.Test/Health/RetiredDeviceTests.cs new file mode 100644 index 0000000..acebc90 --- /dev/null +++ b/Admin.NET.Test/Health/RetiredDeviceTests.cs @@ -0,0 +1,56 @@ +using Evergreen.Health.Application.Health.Devices; +using Evergreen.Health.Application.Health.Devices.Discovery; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Scenes; +using Evergreen.Health.Application.Health.Devices.Contracts; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Admin.NET.Test.Health; + +public sealed class RetiredDeviceTests +{ + [Theory] + [InlineData(null)] + [InlineData("user")] + public void ExplicitRetiredBindingExcludesEntireDeviceEvenWhenBindingIsDisabled(string? disabledBy) + { + var catalog = new HomeAssistantDeviceCatalog(Options.Create(new HomeAssistantOptions + { + ExcludedDeviceEntityIds = ["number.old_heat"] + }), new DeviceModuleRegistry([])); + var registry = new HomeAssistantRegistries + { + Devices = [new() { Id = "retired", Name = "智能床垫" }, new() { Id = "new", Name = "智能床垫" }], + Entities = [new() { DeviceId = "retired", EntityId = "NUMBER.OLD_HEAT", DisabledBy = disabledBy }, + new() { DeviceId = "retired", EntityId = "switch.old" }, + new() { DeviceId = "new", EntityId = "sensor.presence" }] + }; + var states = new Dictionary + { + ["switch.old"] = new() { EntityId = "switch.old", State = "on" }, + ["sensor.presence"] = new() { EntityId = "sensor.presence", State = "on" } + }; + var remaining = Assert.Single(catalog.BuildDiscoveredDevices(registry, states)); + Assert.Equal("new", remaining.DeviceId); + Assert.Equal("", remaining.ModuleKey); + registry.Devices.RemoveAt(1); + Assert.Empty(catalog.BuildDiscoveredDevices(registry, states)); + Assert.Empty(catalog.BuildDiscoveredDevices(null, states)); + } + + [Fact] + public async Task PrimaryLightCommandsStillTrackKnownOffStateWithoutMattressRules() + { + var state = new SceneLightSafetyState(); + var gateway = new FakeHaGateway(); + var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [new PrimaryLightStateRule(state)]); + await runner.SendAsync(new("关灯", "light.room", "light", "turn_off") + { Intent = DeviceCommandIntent.TurnRelaxationLightOff }, default); + Assert.NotNull(state.OffAt); + await runner.SendAsync(new("开灯", "light.room", "light", "turn_on") + { Intent = DeviceCommandIntent.TurnRelaxationLightOn }, default); + Assert.Null(state.OffAt); + Assert.All(gateway.Calls, call => Assert.Equal("light.room", call.EntityId)); + } +} diff --git a/Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs b/Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs index 559a6dc..2670736 100644 --- a/Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs +++ b/Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs @@ -1,6 +1,5 @@ using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Devices.Contracts; -using Evergreen.Health.Application.Health.Devices.Mattress; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Integrations.Tuya; @@ -21,16 +20,13 @@ public sealed class SafetyAndReservedWorkflowTests SleepDataAvailability availability = SleepDataAvailability.Available) => new("fake", at, availability, presence); private static HomeAssistantOptions Configuration() => new() { - Entities = new() { AmbientLight = "light.bedroom", MattressRightMassagePart = "select.right", - MattressLeftMassagePart = "select.left", MattressLeftHeatingLevel = "number.heat" } + Entities = new() { AmbientLight = "light.bedroom" } }; private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new() { EntityId = "light.bedroom", State = state, LastUpdated = updated, Attributes = JsonSerializer.SerializeToElement(new { brightness }) }; - private static DeviceCommand Massage() => new("按摩", "select.right", "select", "select_option", - new Dictionary { ["option"] = "全身" }) { Intent = DeviceCommandIntent.StartMassage }; [Fact] public async Task UnconfiguredInputsAndPlaceholderAudioCannotPretendToWork() @@ -71,7 +67,7 @@ public sealed class SafetyAndReservedWorkflowTests Assert.Equal(ReservedSleepPhase.WaitingForBed, workflow.Phase); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30))); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(59)), Now.AddSeconds(59))); - Assert.Equal(2, workflow.Observe(Reading(Now.AddSeconds(60)), Now.AddSeconds(60)).Count); + Assert.Equal(1, workflow.Observe(Reading(Now.AddSeconds(60)), Now.AddSeconds(60)).Count); } [Fact] @@ -93,7 +89,7 @@ public sealed class SafetyAndReservedWorkflowTests workflow.Start(); workflow.Observe(Reading(Now), Now); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29)), Now.AddSeconds(29))); var actions = workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)); - Assert.Contains(actions, action => action.Kind == SleepWorkflowActionKind.StartGentleRightMassage); + Assert.Single(actions); Assert.Equal(50, actions.Single(action => action.Kind == SleepWorkflowActionKind.RequestGuidance).VolumePercent); Assert.Empty(workflow.GuidanceCompleted("wrong", true, Now.AddSeconds(50))); var music = Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddSeconds(50))); @@ -122,55 +118,15 @@ public sealed class SafetyAndReservedWorkflowTests Assert.True(gate.CanRemind("another", Now)); } - [Theory] - [InlineData("off", 255)] - [InlineData("on", 0)] - [InlineData("unavailable", 255)] - public async Task OffOrUnconfirmedLampBlocksMassage(string state, double brightness) - { - var gateway = new FakeHaGateway(); - gateway.States["light.bedroom"] = Light(state, brightness, Now); - var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new()); - var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]); - await Assert.ThrowsAsync(() => runner.SendAsync(Massage(), default)); - Assert.Empty(gateway.Calls); - } - [Fact] - public async Task KnownLampOffRejectsOlderCloudOnCacheButAllowsFreshOnReading() - { - var gateway = new FakeHaGateway(); - var state = new SceneLightSafetyState(); state.MarkOff(Now); - var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), state); - gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(-1)); - await Assert.ThrowsAsync(() => guard.ValidateAsync(Massage(), default)); - gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(1)); - await guard.ValidateAsync(Massage(), default); - } - - [Fact] - public async Task LateMassageAfterAnOffCommandCannotUndoShutdown() - { - var gateway = new FakeHaGateway(); - var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new()); - var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]); - gateway.States["light.bedroom"] = Light("on", 100, DateTimeOffset.UtcNow); - await runner.SendAsync(new("关灯", "light.bedroom", "light", "turn_off") - { Intent = DeviceCommandIntent.TurnRelaxationLightOff }, default); - await Assert.ThrowsAsync(() => runner.SendAsync(Massage(), default)); - Assert.DoesNotContain(gateway.Calls, command => command.EntityId == "select.right"); - } - - [Fact] - public async Task NaturalFadeToZeroStopsBothMassageSides() + public async Task NaturalFadeToZeroOnlyControlsTheLight() { var time = new ManualTestTimeProvider(); var gateway = new FakeHaGateway { Time = time }; gateway.States["light.bedroom"] = Light("on", 100, time.GetUtcNow()); var runner = new DeviceActionRunner(gateway, time, new() { VerificationDelay = TimeSpan.Zero }); - var mattress = new MattressModule(runner, Options.Create(Configuration())); var safety = new SceneLightSafetyState(); - var light = new HomeAssistantLightTransitionService(gateway, new([mattress]), Options.Create(Configuration()), + var light = new HomeAssistantLightTransitionService(gateway, new([]), Options.Create(Configuration()), NullLogger.Instance, safety, time); await light.StartSleepAsync(2700, default); for (var step = 1; step <= 50; step++) @@ -179,10 +135,9 @@ public sealed class SafetyAndReservedWorkflowTests var expected = 50 - step; await WaitUntilAsync(() => light.GetStatus().BrightnessPercent == expected && (step != 50 || !light.GetStatus().Running)); } - await WaitUntilAsync(() => gateway.States.GetValueOrDefault("select.left")?.State == "无"); Assert.Equal("off", gateway.States["light.bedroom"].State); - Assert.Equal("无", gateway.States["select.right"].State); Assert.NotNull(safety.OffAt); + Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId)); await light.StopAsync(default); } @@ -195,14 +150,6 @@ public sealed class SafetyAndReservedWorkflowTests Assert.Equal("Failed", (await coordinator.ExecuteAsync("sleep", new(), "balanced", default)).Status); } - [Fact] - public void ManuallyEnabledHeatIsIncludedEvenIfAutomaticMattressStartIsDisabled() - { - var gateway = new FakeHaGateway(); - var module = new MattressModule(new(gateway, TimeProvider.System, new()), Options.Create(Configuration())); - Assert.Contains(module.GetShutdownCommands(), command => command.EntityId == "number.heat" && command.ExpectedState == "0"); - } - [Fact] public void SessionClimateTargetIsIdempotentAndPreviewIsNotRandomized() { diff --git a/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs b/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs index 902fa3f..07250f8 100644 --- a/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs +++ b/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs @@ -4,7 +4,6 @@ using Evergreen.Health.Application.Health.Devices.Contracts; using Evergreen.Health.Application.Health.Devices.Discovery; using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Devices.Lighting; -using Evergreen.Health.Application.Health.Devices.Mattress; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Models; @@ -26,9 +25,8 @@ public sealed class SceneLightingIntegrationTests private static SceneLightOptions StripSettings() => new() { Key = "sleep-light-strip", Name = "测试助眠灯带", EntityId = Strip, SleepEffect = "助眠模式" }; private static HomeAssistantOptions Settings() => new() { - Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature, - MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat" }, - AdditionalSleepLights = [StripSettings()], SleepScene = new() { EnableMattress = true } + Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature }, + AdditionalSleepLights = [StripSettings()] }; private static HomeAssistantState Light(string id, string state = "off", string[]? effects = null) => new() { @@ -62,7 +60,7 @@ public sealed class SceneLightingIntegrationTests public DeviceActionRunner Runner { get; } public DeviceModuleRegistry Modules { get; } public HealthSceneCoordinator Coordinator { get; } - public Fixture(bool mattress = false) + public Fixture() { Gateway = new() { Time = Time }; Gateway.States[Primary] = Light(Primary); Gateway.States[Strip] = Light(Strip); @@ -77,7 +75,6 @@ public sealed class SceneLightingIntegrationTests new ConfiguredSleepLightModule(Runner, StripSettings(), Lights.ForEntity(Strip)), new ClimateModule(Runner, Options) }; - if (mattress) modules.Add(new MattressModule(Runner, Options)); Modules = new(modules); Coordinator = new(Modules, Runner, Gateway, Lights, new FakeMusic()); } @@ -89,10 +86,10 @@ public sealed class SceneLightingIntegrationTests LastReported = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) }; else Gateway.States.TryRemove(Temperature, out _); - var comfort = new MattressComfortService(Gateway, new IndoorEnvironmentReader(Options, Time), Options) + var comfort = new EnvironmentAdviceService(Gateway, new IndoorEnvironmentReader(Options, Time), Options) .Get(null, Gateway.States); var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 }; - MattressComfortService.ApplyTo(config, comfort); + EnvironmentAdviceService.ApplyTo(config, comfort); RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice); return new(config, "balanced"); } @@ -134,7 +131,7 @@ public sealed class SceneLightingIntegrationTests } [Fact] - public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsMassageShutdownHook() + public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsLightOffEvent() { var f = new Fixture(); try @@ -171,21 +168,21 @@ public sealed class SceneLightingIntegrationTests } [Theory] - [InlineData(15, "heat", 1)] - [InlineData(30, "cool", 0)] - [InlineData(22, "off", 0)] - [InlineData(24, "off", 0)] - [InlineData(25, "off", 0)] - public async Task EnvironmentSensorDrivesActualSceneClimateAndBothHeatingLevels(double room, string mode, int heating) + [InlineData(15, "heat")] + [InlineData(30, "cool")] + [InlineData(22, "off")] + [InlineData(24, "off")] + [InlineData(25, "off")] + public async Task EnvironmentSensorDrivesActualSceneClimateWithoutRetiredHeating(double room, string mode) { - var f = new Fixture(mattress: true); + var f = new Fixture(); try { var prepared = f.Prepare(room); var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time); Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode); Assert.False(result.ClimateAdvice.PendingIntegration); - foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Assert.Equal(heating.ToString(), f.Gateway.States[id].State); + Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId is "number.left_heat" or "number.right_heat"); if (mode == "off") { Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius); @@ -211,11 +208,10 @@ public sealed class SceneLightingIntegrationTests var prepared = f.Prepare(null); var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default); Assert.Equal("unavailable", result.ClimateAdvice!.Mode); - Assert.Equal(0, prepared.Config.MattressHeatingLevel); Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate); f.Prepare(15); f.Time.Advance(TimeSpan.FromMinutes(31)); - var comfort = new MattressComfortService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States); - Assert.Equal("unavailable", comfort.ClimateAdvice.Mode); Assert.Equal(0, comfort.HeatingLevel); + var comfort = new EnvironmentAdviceService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States); + Assert.Equal("unavailable", comfort.ClimateAdvice.Mode); } finally { await f.Lights.StopAsync(default); } } diff --git a/Admin.NET.Test/Health/SystemServiceFilteringTests.cs b/Admin.NET.Test/Health/SystemServiceFilteringTests.cs index 51a6c98..3fe2051 100644 --- a/Admin.NET.Test/Health/SystemServiceFilteringTests.cs +++ b/Admin.NET.Test/Health/SystemServiceFilteringTests.cs @@ -1,6 +1,6 @@ using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Devices.Discovery; -using Evergreen.Health.Application.Health.Devices.Mattress; +using Evergreen.Health.Application.Health.Devices.Lighting; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Scenes; using Microsoft.Extensions.Options; @@ -96,10 +96,10 @@ public sealed class SystemServiceFilteringTests { var options = Options.Create(new HomeAssistantOptions { - Entities = new() { MattressLeftHeatingLevel = "number.heating" } + Entities = new() { AmbientLight = "light.bedroom" } }); var runner = new DeviceActionRunner(new FakeHaGateway(), TimeProvider.System, new()); - var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)])); + var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]))])); var registry = new HomeAssistantRegistries { Devices = [new() { Id = "backup", Name = "Backup", EntryType = "service" }], @@ -108,9 +108,9 @@ public sealed class SystemServiceFilteringTests var states = new Dictionary { ["sensor.backup"] = new() { EntityId = "sensor.backup", State = "idle" }, - ["number.heating"] = new() { EntityId = "number.heating", State = "0" } + ["light.bedroom"] = new() { EntityId = "light.bedroom", State = "0" } }; Assert.Empty(catalog.BuildDiscoveredDevices(registry, states)); - Assert.Equal("mattress", Assert.Single(catalog.BuildDiscoveredDevices(null, states)).ModuleKey); + Assert.Equal("lighting", Assert.Single(catalog.BuildDiscoveredDevices(null, states)).ModuleKey); } } diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json index b40b227..df6a95b 100644 --- a/Evergreen.Health.Application/Configuration/HomeAssistant.json +++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json @@ -21,27 +21,6 @@ "Climate": "climate.fawad_cn_974720212_3010", "Curtain": "cover.czmydz_cn_2251706154_mym1_s_2_curtain", "Outlet": "switch.ym001_cn_2108613283_ymcz_on_p_2_1", - "MattressLeftHeatingLevel": "number.qushui_cn_2108064504_zerox_left_level_p_6_1", - "MattressRightHeatingLevel": "number.qushui_cn_2108064504_zerox_right_level_p_6_2", - "MattressLeftMode": "select.qushui_cn_2108064504_zerox_left_mode_p_5_1", - "MattressRightMode": "select.qushui_cn_2108064504_zerox_right_mode_p_5_2", - "MattressFirmness": { - "left_shoulder": "select.qushui_cn_2108064504_zerox_left_shoulder_level_p_5_3", - "right_shoulder": "select.qushui_cn_2108064504_zerox_right_shoulder_level_p_5_4", - "left_back": "select.qushui_cn_2108064504_zerox_left_back_level_p_5_5", - "right_back": "select.qushui_cn_2108064504_zerox_right_back_level_p_5_6", - "left_waist": "select.qushui_cn_2108064504_zerox_left_waist_level_p_5_7", - "right_waist": "select.qushui_cn_2108064504_zerox_right_waist_level_p_5_8", - "left_lumbar": "select.qushui_cn_2108064504_zerox_left_lumbar_level_p_5_9", - "right_lumbar": "select.qushui_cn_2108064504_zerox_right_lumbar_level_p_5_10", - "left_hip": "select.qushui_cn_2108064504_zerox_left_hip_level_p_5_11", - "right_hip": "select.qushui_cn_2108064504_zerox_right_hip_level_p_5_12", - "left_leg": "select.qushui_cn_2108064504_zerox_left_leg_level_p_5_13", - "right_leg": "select.qushui_cn_2108064504_zerox_right_leg_level_p_5_14" - }, - "MattressLeftMassagePart": "select.qushui_cn_2108064504_zerox_left_part_p_4_1", - "MattressRightMassagePart": "select.qushui_cn_2108064504_zerox_right_part_p_4_2", - "MattressMassageLevel": "select.qushui_cn_2108064504_zerox_level_p_4_3", "Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1", "Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2", "Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3", @@ -65,10 +44,28 @@ "LightColorTemperatureKelvin": 2700, "EnableFragrance": true, "EnableSpeakerSleepMode": true, - "EnableMattress": true, - "MattressHeatingLevel": 1, - "MattressMode": "智能适配", "EnableUv": false - } + }, + "ExcludedDeviceEntityIds": [ + "number.qushui_cn_2108064504_zerox_left_level_p_6_1", + "number.qushui_cn_2108064504_zerox_right_level_p_6_2", + "select.qushui_cn_2108064504_zerox_left_mode_p_5_1", + "select.qushui_cn_2108064504_zerox_right_mode_p_5_2", + "select.qushui_cn_2108064504_zerox_left_shoulder_level_p_5_3", + "select.qushui_cn_2108064504_zerox_right_shoulder_level_p_5_4", + "select.qushui_cn_2108064504_zerox_left_back_level_p_5_5", + "select.qushui_cn_2108064504_zerox_right_back_level_p_5_6", + "select.qushui_cn_2108064504_zerox_left_waist_level_p_5_7", + "select.qushui_cn_2108064504_zerox_right_waist_level_p_5_8", + "select.qushui_cn_2108064504_zerox_left_lumbar_level_p_5_9", + "select.qushui_cn_2108064504_zerox_right_lumbar_level_p_5_10", + "select.qushui_cn_2108064504_zerox_left_hip_level_p_5_11", + "select.qushui_cn_2108064504_zerox_right_hip_level_p_5_12", + "select.qushui_cn_2108064504_zerox_left_leg_level_p_5_13", + "select.qushui_cn_2108064504_zerox_right_leg_level_p_5_14", + "select.qushui_cn_2108064504_zerox_left_part_p_4_1", + "select.qushui_cn_2108064504_zerox_right_part_p_4_2", + "select.qushui_cn_2108064504_zerox_level_p_4_3" + ] } } diff --git a/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs b/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs index b36b404..54be018 100644 --- a/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs +++ b/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs @@ -2,7 +2,7 @@ using Evergreen.Health.Application.Health.Integrations.HomeAssistant; namespace Evergreen.Health.Application.Health.Devices.Contracts; -public enum DeviceCommandIntent { Normal, StartMassage, TurnRelaxationLightOn, TurnRelaxationLightOff } +public enum DeviceCommandIntent { Normal = 0, TurnRelaxationLightOn = 2, TurnRelaxationLightOff = 3 } /// 实体由后端模块提供,不接受客户端任意实体或任意服务名。 public sealed record DeviceCommand(string Name, string EntityId, string Domain, string Service, diff --git a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs index e1ed7da..f2f29fc 100644 --- a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs +++ b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs @@ -29,9 +29,13 @@ public sealed class HomeAssistantDeviceCatalog .GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + // 使用完整注册表识别退役设备;即使用于识别的实体被禁用,也不能重新发现同一设备。 + var excludedDevices = registries.Entities.Where(entity => + _options.ExcludedDeviceEntityIds.Contains(entity.EntityId, StringComparer.OrdinalIgnoreCase)) + .Select(entity => entity.DeviceId).ToHashSet(StringComparer.OrdinalIgnoreCase); // HA 将 Sun、Backup 等虚拟系统服务标记为 service,不属于家具或实体传感器。 foreach (var device in registries.Devices.Where(device => string.IsNullOrWhiteSpace(device.DisabledBy) && - !string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase))) + !string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase) && !excludedDevices.Contains(device.Id))) { var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? []; if (regEntities.Count == 0) continue; @@ -291,7 +295,6 @@ public sealed class HomeAssistantDeviceCatalog if (id.Contains("tvoc")) metrics["tvoc"] = entity.State; if (id.Contains("weight")) metrics["weight"] = entity.State; if (id.Contains("body_fat") || id.Contains("fat_rate")) metrics["bodyFatRate"] = entity.State; - if (id.Contains("firmness") || id.Contains("soft")) metrics["firmness"] = entity.State; if (id.Contains("sleep_state") || id.Contains("sleep_status")) metrics["sleepStatus"] = entity.State; } diff --git a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs index 8b0a877..c319a53 100644 --- a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs +++ b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs @@ -7,7 +7,6 @@ using Evergreen.Health.Application.Health.Devices.Discovery; using Evergreen.Health.Application.Health.Devices.Dehumidifier; using Evergreen.Health.Application.Health.Devices.Fragrance; using Evergreen.Health.Application.Health.Devices.Lighting; -using Evergreen.Health.Application.Health.Devices.Mattress; using Evergreen.Health.Application.Health.Devices.Outlet; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.MiAirX; @@ -41,7 +40,7 @@ public static class HealthDeviceRegistration provider.GetRequiredService>())); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); @@ -69,9 +68,6 @@ public static class HealthDeviceRegistration services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(provider => new( provider.GetServices().Concat( diff --git a/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs b/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs index 29c6573..d429493 100644 --- a/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs +++ b/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs @@ -5,7 +5,7 @@ using Evergreen.Health.Application.Health.Scenes; namespace Evergreen.Health.Application.Health.Devices.Lighting; -/// 额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的按摩/唤醒钩子。 +/// 额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的关灯事件/唤醒钩子。 public sealed class ConfiguredSleepLightModule : HealthDeviceModule { private readonly SceneLightOptions _light; diff --git a/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs b/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs index 7d6020e..52f049c 100644 --- a/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs +++ b/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs @@ -38,7 +38,7 @@ public sealed class LightingModule : HealthDeviceModule public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken) { - // 标准手动面板复用本灯的专属安全钩子;其他灯不会触发助眠灯/床垫联动。 + // 标准手动面板复用本灯的专属安全钩子;其他灯不会触发主灯关灯事件。 command = command with { Intent = command.Service == "turn_off" ? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn }; await _transition.CancelAsync(cancellationToken); diff --git a/Evergreen.Health.Application/Health/Devices/Mattress/MattressModule.cs b/Evergreen.Health.Application/Health/Devices/Mattress/MattressModule.cs deleted file mode 100644 index 7570368..0000000 --- a/Evergreen.Health.Application/Health/Devices/Mattress/MattressModule.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Evergreen.Health.Application.Health.Devices.Contracts; -using Evergreen.Health.Application.Health.Integrations.HomeAssistant; -using Evergreen.Health.Application.Health.Models; -using Evergreen.Health.Application.Health.Scenes; -using Microsoft.Extensions.Options; -using Evergreen.Health.Application.Health.Services; - -namespace Evergreen.Health.Application.Health.Devices.Mattress; - -public sealed class MattressModule : HealthDeviceModule, ILightOffHandler -{ - private readonly HomeAssistantOptions _options; - private HomeAssistantEntityOptions Entities => _options.Entities; - public MattressModule(DeviceActionRunner runner, IOptions options) : base(runner) - { - _options = options.Value; - var modes = new[] { "无", "瑜伽助眠", "智能适配", "智能腰托" }; - var parts = new[] { "无", "背部", "腰部", "牵引", "腿部", "全身" }; - var parameters = new List - { - DeviceValues.Number("mattress_left_heating", Entities.MattressLeftHeatingLevel, 0, 4, "床垫左侧加热档位"), - DeviceValues.Number("mattress_right_heating", Entities.MattressRightHeatingLevel, 0, 4, "床垫右侧加热档位"), - DeviceValues.Select("mattress_left_mode", Entities.MattressLeftMode, modes, "床垫左侧承托模式", true), - DeviceValues.Select("mattress_right_mode", Entities.MattressRightMode, modes, "床垫右侧承托模式", true), - DeviceValues.Select("mattress_massage_left_part", Entities.MattressLeftMassagePart, parts, "左侧按摩部位", - intent: DeviceCommandIntent.StartMassage), - DeviceValues.Select("mattress_massage_right_part", Entities.MattressRightMassagePart, parts, "右侧按摩部位", - intent: DeviceCommandIntent.StartMassage), - DeviceValues.Select("mattress_massage_level", Entities.MattressMassageLevel, ["轻柔", "强劲"], "按摩力度") - }; - foreach (var (zone, entityId) in Entities.MattressFirmness) - parameters.Add(DeviceValues.Select($"mattress_firmness_{zone}", entityId, - ["最软", "很软", "稍软", "稍硬", "很硬", "最硬"], $"床垫{ZoneName(zone)}软硬度")); - Parameters = parameters; - } - public override string Key => "mattress"; - public override string Category => nameof(HealthDeviceCategory.Sleep); - public override string DisplayName => "智能床垫"; - public override string PrimaryEntityId => ""; // 床垫没有单一总开关,不能把加热实体冒充开关。 - public override IReadOnlyList Parameters { get; } - - public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, - string constitutionCode, CancellationToken cancellationToken) - { - if (!_options.SleepScene.EnableMattress) return; - await context.RunAsync(BuildParameterCommand("mattress_left_heating", config.MattressHeatingLevel), cancellationToken); - await context.RunAsync(BuildParameterCommand("mattress_right_heating", config.MattressHeatingLevel), cancellationToken); - var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness); - if (firmness is null) - context.Results.Add(new() { Name = "按 BMI 调节床垫软硬度", Status = "Skipped", - Message = "健康档案缺少身高或体重,未自动修改软硬度" }); - else - foreach (var zone in Entities.MattressFirmness.Keys) - await context.RunAsync(BuildParameterCommand($"mattress_firmness_{zone}", firmness), cancellationToken); - var modeCommand = BuildParameterCommand("mattress_left_mode", "智能适配"); - await context.RunAsync(modeCommand, cancellationToken); - if (context.Results.Last().Status == "Succeeded") - await Runner.VerifyBatchAsync([modeCommand], context.Results, cancellationToken); - // 气泵调节结束后再启动按摩,保持真实设备所需的顺序。 - await Runner.DelayAsync(TimeSpan.FromSeconds(2), cancellationToken); - await context.RunAsync(BuildParameterCommand("mattress_massage_left_part", "无"), cancellationToken); - await context.RunAsync(BuildParameterCommand("mattress_massage_level", "轻柔"), cancellationToken); - var massage = BuildParameterCommand("mattress_massage_right_part", "全身") with { ExpectedState = "全身" }; - await context.RunAsync(massage, cancellationToken); - if (context.Results.Last().Status == "Succeeded") - { - await Runner.DelayAsync(TimeSpan.FromSeconds(3), cancellationToken); - await Runner.VerifyBatchAsync([massage], context.Results, cancellationToken); - } - } - - public IEnumerable MassageOffCommands() => - [ - new("关闭床垫右侧按摩", Entities.MattressRightMassagePart, "select", "select_option", - new Dictionary { ["option"] = "无" }, "无"), - new("关闭床垫左侧按摩", Entities.MattressLeftMassagePart, "select", "select_option", - new Dictionary { ["option"] = "无" }, "无") - ]; - - public override IEnumerable GetShutdownCommands() - { - foreach (var command in MassageOffCommands()) yield return command; - // 未参与自动启动不等于无需关闭:手动开启的加热也要随一键关闭停止。 - yield return new("关闭床垫左侧加热", Entities.MattressLeftHeatingLevel, "number", "set_value", - new Dictionary { ["value"] = 0 }, "0"); - yield return new("关闭床垫右侧加热", Entities.MattressRightHeatingLevel, "number", "set_value", - new Dictionary { ["value"] = 0 }, "0"); - } - - public async Task HandleAsync(CancellationToken cancellationToken) - { - var commands = MassageOffCommands().Where(command => !string.IsNullOrWhiteSpace(command.EntityId)).ToArray(); - var results = new List(); - foreach (var command in commands) - await Runner.RunAsync(new Dictionary(), results, command, cancellationToken, false); - await Runner.VerifyBatchAsync(commands, results, cancellationToken); - if (results.Any(result => result.Status == "Failed")) - throw new InvalidOperationException("床垫按摩未确认关闭:" + - string.Join(";", results.Where(result => result.Status == "Failed").Select(result => result.Message))); - } - - public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) - { - if (!_options.SleepScene.EnableMattress || !EntityIds.Any()) return null; - var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness) ?? "未自动调节"; - var preview = Preview("按室温和 BMI 调节床垫并开启右侧按摩", - $"左右加热 {config.MattressHeatingLevel} 档 · 软硬度{firmness} · 右侧全身轻柔按摩", - new() { ["heating_level"] = config.MattressHeatingLevel, ["firmness"] = firmness, - ["right_massage_part"] = "全身", ["massage_level"] = "轻柔" }); - preview.EntityId = EntityIds.First(); - return preview; - } - - public static string ZoneName(string zone) => zone switch - { - "left_shoulder" => "左肩", "right_shoulder" => "右肩", "left_back" => "左背", "right_back" => "右背", - "left_waist" => "左腰", "right_waist" => "右腰", "left_lumbar" => "左腰椎", "right_lumbar" => "右腰椎", - "left_hip" => "左臀", "right_hip" => "右臀", "left_leg" => "左腿", "right_leg" => "右腿", _ => zone - }; -} diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs index 2bb0272..ed2e2be 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs @@ -8,6 +8,8 @@ public sealed class HomeAssistantOptions : IConfigurableOptions public string BaseUrl { get; set; } = string.Empty; public string AccessToken { get; set; } = string.Empty; public int TimeoutSeconds { get; set; } = 15; + // 匹配已退役设备的明确实体绑定,不按名称或品牌隐藏设备。 + public List ExcludedDeviceEntityIds { get; set; } = []; public HomeAssistantEntityOptions Entities { get; set; } = new(); public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new(); public List AdditionalSleepLights { get; set; } = []; @@ -41,14 +43,6 @@ public sealed class HomeAssistantEntityOptions public string AmbientLight { get; set; } = string.Empty; public string Speaker { get; set; } = string.Empty; public string SpeakerSleepMode { get; set; } = string.Empty; - public string MattressLeftHeatingLevel { get; set; } = string.Empty; - public string MattressRightHeatingLevel { get; set; } = string.Empty; - public string MattressLeftMode { get; set; } = string.Empty; - public string MattressRightMode { get; set; } = string.Empty; - public Dictionary MattressFirmness { get; set; } = new(); - public string MattressLeftMassagePart { get; set; } = string.Empty; - public string MattressRightMassagePart { get; set; } = string.Empty; - public string MattressMassageLevel { get; set; } = string.Empty; public string Temperature { get; set; } = string.Empty; public string Humidity { get; set; } = string.Empty; public string Pm25 { get; set; } = string.Empty; @@ -66,8 +60,5 @@ public sealed class HomeAssistantSleepSceneOptions public int LightColorTemperatureKelvin { get; set; } = 2700; public bool EnableFragrance { get; set; } = true; public bool EnableSpeakerSleepMode { get; set; } - public bool EnableMattress { get; set; } - public int MattressHeatingLevel { get; set; } = 1; - public string MattressMode { get; set; } = "智能适配"; public bool EnableUv { get; set; } } diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs index 399ecbc..4855568 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs @@ -20,7 +20,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime private readonly TimeProvider _time; private readonly string _entityId; private readonly string? _sleepEffect; - private readonly bool _controlsMassage; + private readonly bool _isPrimaryLight; private readonly ILogger _logger; private readonly SemaphoreSlim _operationLock = new(1, 1); private readonly object _statusLock = new(); @@ -38,7 +38,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime { _entityId = light?.EntityId ?? options.Value.Entities.AmbientLight; _sleepEffect = light?.SleepEffect; - _controlsMassage = light is null; + _isPrimaryLight = light is null; _logger = logger; _homeAssistant = homeAssistant; _lightOff = lightOff; @@ -80,7 +80,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime var data = BuildSleepStartData(light, colorTemperatureKelvin); await _homeAssistant.CallServiceAsync("light", "turn_on", data, cancellationToken); - if (_controlsMassage) _safety.MarkOn(); + if (_isPrimaryLight) _safety.MarkOn(); SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度"); Launch("sleep", SleepStartPercent); return GetStatus(); @@ -95,7 +95,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime { await StopCurrentAsync(); var light = await ReadAvailableLightAsync(cancellationToken); - var knownOffAt = _controlsMassage ? _safety.OffAt : null; + var knownOffAt = _isPrimaryLight ? _safety.OffAt : null; var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase) || (knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt)); var brightness = light.GetDoubleAttribute("brightness"); @@ -105,7 +105,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime if (current >= WakeTargetPercent && !isOff) { - if (_controlsMassage) _safety.MarkOn(); + if (_isPrimaryLight) _safety.MarkOn(); SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度"); return GetStatus(); } @@ -119,7 +119,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime }, cancellationToken); } - if (_controlsMassage) _safety.MarkOn(); + if (_isPrimaryLight) _safety.MarkOn(); SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度"); Launch("wake", current); return GetStatus(); @@ -151,7 +151,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime JsonElement modes = default; var hasModes = state.Attributes.ValueKind == JsonValueKind.Object && state.Attributes.TryGetProperty("supported_color_modes", out modes) && modes.ValueKind == JsonValueKind.Array; - if (_controlsMassage && !hasModes) + if (_isPrimaryLight && !hasModes) { data["color_temp_kelvin"] = Math.Clamp(kelvin, 2200, 5000); return data; @@ -208,17 +208,17 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime var light = await ReadAvailableLightAsync(cancellationToken); if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase)) { - if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow()); - if (mode == "sleep" && _controlsMassage) + if (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow()); + if (mode == "sleep" && _isPrimaryLight) { try { await _lightOff.NotifyAsync(cancellationToken); - SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止"); + SetStatus(true, false, mode, 0, 0, "灯已关闭,渐变已停止"); } catch (Exception exception) when (exception is not OperationCanceledException) { - _logger.LogWarning(exception, "灯已关闭,但床垫按摩停止失败"); + _logger.LogWarning(exception, "灯已关闭,但关灯事件处理失败"); SetStatus(false, false, mode, 0, 0, exception.Message); } } @@ -245,16 +245,16 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime { ["entity_id"] = LightEntityId }, cancellationToken); - if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow()); - SetStatus(true, false, mode, 0, 0, _controlsMassage ? "灯光已关闭,正在停止床垫按摩" : "助眠渐暗已完成,灯光已关闭"); + if (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow()); + SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭"); try { - if (_controlsMassage) await _lightOff.NotifyAsync(cancellationToken); - SetStatus(true, false, mode, 0, 0, _controlsMassage ? "助眠渐暗已完成,灯光与床垫按摩已关闭" : "助眠渐暗已完成,灯光已关闭"); + if (_isPrimaryLight) await _lightOff.NotifyAsync(cancellationToken); + SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭"); } catch (Exception exception) when (exception is not OperationCanceledException) { - _logger.LogWarning(exception, "助眠渐暗已完成,但床垫按摩停止失败"); + _logger.LogWarning(exception, "助眠渐暗已完成,但关灯事件处理失败"); SetStatus(false, false, mode, 0, 0, exception.Message); } return; diff --git a/Evergreen.Health.Application/Health/Models/HealthModels.cs b/Evergreen.Health.Application/Health/Models/HealthModels.cs index d02b82e..22f52d5 100644 --- a/Evergreen.Health.Application/Health/Models/HealthModels.cs +++ b/Evergreen.Health.Application/Health/Models/HealthModels.cs @@ -99,25 +99,14 @@ public sealed class HealthDashboardOutput public string BmiStatus { get; set; } = string.Empty; public PersonalizedSceneConfig? SceneConfig { get; set; } public TcmAssessmentResult? TcmResult { get; set; } - public MattressComfortOutput? MattressComfort { get; set; } + public EnvironmentAdviceOutput? EnvironmentAdvice { get; set; } } -public sealed class MattressComfortOutput +public sealed class EnvironmentAdviceOutput { - public decimal? Bmi { get; set; } - public string? Firmness { get; set; } - public int HeatingLevel { get; set; } - public bool IsWinter { get; set; } - public double? OutdoorTemperatureCelsius { get; set; } - public double? FeelsLikeCelsius { get; set; } - public bool WeatherAvailable { get; set; } - public string? WeatherObservedAt { get; set; } - public string WeatherReason { get; set; } = string.Empty; - public string FirmnessReason { get; set; } = string.Empty; public double? IndoorTemperatureCelsius { get; set; } public bool IndoorTemperatureAvailable { get; set; } public DateTimeOffset? IndoorTemperatureObservedAt { get; set; } - public string HeatingReason { get; set; } = string.Empty; public ClimateAdviceOutput ClimateAdvice { get; set; } = new(); } @@ -154,11 +143,6 @@ public sealed class PersonalizedSceneConfig public int LightBrightnessPercent { get; set; } = 20; public int LightColorTemperatureKelvin { get; set; } = 2700; - public decimal MattressTargetTemp { get; set; } = 36m; - public int MattressLeftFirmness { get; set; } = 5; - public int MattressRightFirmness { get; set; } = 6; - public int MattressHeatingLevel { get; set; } = 1; - public string MattressMode { get; set; } = "智能适配"; public bool EnableFragrance { get; set; } = true; public int FragranceLevel { get; set; } = 1; diff --git a/Evergreen.Health.Application/Health/Policies/BmiFirmnessPolicy.cs b/Evergreen.Health.Application/Health/Policies/BmiFirmnessPolicy.cs deleted file mode 100644 index eade9b6..0000000 --- a/Evergreen.Health.Application/Health/Policies/BmiFirmnessPolicy.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Evergreen.Health.Application.Health.Policies; - -/// Demo 起点,可由用户手动修改,不是医学或人体工学判断。 -public static class BmiFirmnessPolicy -{ - public static decimal? Calculate(decimal? heightCm, decimal? weightKg) - { - if (heightCm is null or <= 0 || weightKg is null or <= 0) return null; - try - { - var height = heightCm.Value / 100m; - return Math.Round(weightKg.Value / (height * height), 1); - } - catch (ArithmeticException) { return null; } - } - - public static string? Firmness(decimal? bmi) => bmi switch - { - < 18.5m => "很软", < 24m => "稍软", < 28m => "稍硬", - < 32m => "很硬", >= 32m => "最硬", _ => null - }; -} diff --git a/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs b/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs index b46dc88..3d604d4 100644 --- a/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs +++ b/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs @@ -25,10 +25,10 @@ public static class RoomTemperaturePolicy Mode = mode, RecommendedMinimumCelsius = range.Minimum, RecommendedMaximumCelsius = range.Maximum, Reason = mode switch { - "heat" => $"室温低于建议 {range.Display}:床垫低档加热,建议空调制热", - "cool" => $"室温高于建议 {range.Display}:床垫不加热,建议空调制冷", - "off" => $"室温在建议 {range.Display} 内:床垫不加热,空调不开启", - _ => $"{sample.Reason},不自动加热或开启空调" + "heat" => $"室温低于建议 {range.Display}:建议空调制热", + "cool" => $"室温高于建议 {range.Display}:建议空调制冷", + "off" => $"室温在建议 {range.Display} 内:空调保持当前运行状态", + _ => $"{sample.Reason},不自动开启空调" }, PendingIntegration = mode is "heat" or "cool" }; diff --git a/Evergreen.Health.Application/Health/Scenes/DeviceActionSafety.cs b/Evergreen.Health.Application/Health/Scenes/DeviceActionSafety.cs index a43c4bf..491ca9c 100644 --- a/Evergreen.Health.Application/Health/Scenes/DeviceActionSafety.cs +++ b/Evergreen.Health.Application/Health/Scenes/DeviceActionSafety.cs @@ -19,31 +19,13 @@ public sealed class SceneLightSafetyState public void MarkOn() { lock (_lock) _offAt = null; } } -/// 只依赖“启动按摩”的动作意图,不知道床垫实体。防止灯灭后较晚的启动指令再开启按摩。 -public sealed class LightMassageSafetyRule : IDeviceActionSafetyRule +/// 记录主灯的已发送开关状态,供晨起渐亮处理滞后的云端回读。 +public sealed class PrimaryLightStateRule(SceneLightSafetyState state) : IDeviceActionSafetyRule { - private readonly IHomeAssistantGateway _gateway; - private readonly string _lightEntity; - private readonly SceneLightSafetyState _state; - public LightMassageSafetyRule(IHomeAssistantGateway gateway, IOptions options, - SceneLightSafetyState state) => (_gateway, _lightEntity, _state) = (gateway, options.Value.Entities.AmbientLight, state); - - public async Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken) - { - if (command.Intent != DeviceCommandIntent.StartMassage || string.IsNullOrWhiteSpace(_lightEntity)) return; - var light = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item => - string.Equals(item.EntityId, _lightEntity, StringComparison.OrdinalIgnoreCase)); - var offAt = _state.OffAt; - if (light is null || !DeviceActionRunner.IsAvailable(light) || - !string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase) || - light.GetDoubleAttribute("brightness") is <= 0 || - (offAt is not null && (light.LastUpdated is null || light.LastUpdated <= offAt))) - throw new InvalidOperationException("卧室灯光已关闭或状态未确认,不启动床垫按摩"); - } - + public Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken) => Task.CompletedTask; public void Accepted(DeviceCommand command, DateTimeOffset now) { - if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) _state.MarkOff(now); - else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) _state.MarkOn(); + if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) state.MarkOff(now); + else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) state.MarkOn(); } } diff --git a/Evergreen.Health.Application/Health/Services/EnvironmentAdviceService.cs b/Evergreen.Health.Application/Health/Services/EnvironmentAdviceService.cs new file mode 100644 index 0000000..2717e88 --- /dev/null +++ b/Evergreen.Health.Application/Health/Services/EnvironmentAdviceService.cs @@ -0,0 +1,54 @@ +using Evergreen.Health.Application.Health.Devices.Environment; +using Evergreen.Health.Application.Health.Entities; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Models; +using Evergreen.Health.Application.Health.Policies; +using Microsoft.Extensions.Options; + +namespace Evergreen.Health.Application.Health.Services; + +/// 依据室温与体质提供空调建议,不负责发送家具控制命令。 +public sealed class EnvironmentAdviceService : ITransient +{ + private readonly IHomeAssistantGateway _gateway; + private readonly IndoorEnvironmentReader _environment; + private readonly HomeAssistantEntityOptions _entities; + public EnvironmentAdviceService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment, + IOptions options) + => (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities); + + public async Task GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default) + { + IReadOnlyDictionary states; + try + { + states = (await _gateway.GetStatesAsync(cancellationToken)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + states = new Dictionary(); + } + return Get(profile, states); + } + + public EnvironmentAdviceOutput Get(HealthProfile? profile, IReadOnlyDictionary states) + { + var tcm = TcmConstitutionRules.Read(profile); + var sample = _environment.Read(states); + var advice = RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution(tcm.ConstitutionCode)); + advice.PendingIntegration = (advice.Mode is "heat" or "cool") && string.IsNullOrWhiteSpace(_entities.Climate); + return new() + { + IndoorTemperatureCelsius = sample.Celsius, IndoorTemperatureAvailable = sample.Available, + IndoorTemperatureObservedAt = sample.ObservedAt, + ClimateAdvice = advice + }; + } + + public static void ApplyTo(PersonalizedSceneConfig config, EnvironmentAdviceOutput recommendation) + { + config.ClimateAdvice = recommendation.ClimateAdvice; + } + +} diff --git a/Evergreen.Health.Application/Health/Services/HealthAppService.cs b/Evergreen.Health.Application/Health/Services/HealthAppService.cs index 92f814a..ffa8566 100644 --- a/Evergreen.Health.Application/Health/Services/HealthAppService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthAppService.cs @@ -24,7 +24,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient private readonly SqlSugarRepository _profileRepository; private readonly SqlSugarRepository _moduleRepository; private readonly SqlSugarRepository _planRepository; - private readonly MattressComfortService _mattressComfort; + private readonly EnvironmentAdviceService _environmentAdvice; private readonly IHttpContextAccessor _httpContextAccessor; public HealthAppService( @@ -32,14 +32,14 @@ public sealed class HealthAppService : IDynamicApiController, ITransient SqlSugarRepository profileRepository, SqlSugarRepository moduleRepository, SqlSugarRepository planRepository, - MattressComfortService mattressComfort, + EnvironmentAdviceService environmentAdvice, IHttpContextAccessor httpContextAccessor) { _userRepository = userRepository; _profileRepository = profileRepository; _moduleRepository = moduleRepository; _planRepository = planRepository; - _mattressComfort = mattressComfort; + _environmentAdvice = environmentAdvice; _httpContextAccessor = httpContextAccessor; } @@ -118,11 +118,11 @@ public sealed class HealthAppService : IDynamicApiController, ITransient } var tcmResult = TcmConstitutionRules.Read(profile); - MattressComfortOutput? mattressComfort = null; + EnvironmentAdviceOutput? environmentAdvice = null; if (tcmResult.HasCompleted) { - mattressComfort = await _mattressComfort.GetAsync(profile); - MattressComfortService.ApplyTo(tcmResult.SceneConfig, mattressComfort); + environmentAdvice = await _environmentAdvice.GetAsync(profile); + EnvironmentAdviceService.ApplyTo(tcmResult.SceneConfig, environmentAdvice); } return new HealthDashboardOutput @@ -136,7 +136,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient BmiStatus = bmiStatus, SceneConfig = tcmResult.HasCompleted ? tcmResult.SceneConfig : null, TcmResult = tcmResult, - MattressComfort = mattressComfort + EnvironmentAdvice = environmentAdvice }; } diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index 272a968..f76e3c5 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -30,7 +30,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie private readonly HomeAssistantOptions _options; private readonly SqlSugarRepository _executionRepository; private readonly SqlSugarRepository _profileRepository; - private readonly MattressComfortService _mattressComfort; + private readonly EnvironmentAdviceService _environmentAdvice; private readonly IHttpContextAccessor _httpContextAccessor; private readonly DeviceModuleRegistry _modules; private readonly DeviceParameterController _parameters; @@ -46,7 +46,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie IOptions options, SqlSugarRepository executionRepository, SqlSugarRepository profileRepository, - MattressComfortService mattressComfort, + EnvironmentAdviceService environmentAdvice, IHttpContextAccessor httpContextAccessor, DeviceModuleRegistry modules, DeviceParameterController parameters, @@ -61,7 +61,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie _options = options.Value; _executionRepository = executionRepository; _profileRepository = profileRepository; - _mattressComfort = mattressComfort; + _environmentAdvice = environmentAdvice; _httpContextAccessor = httpContextAccessor; _modules = modules; _parameters = parameters; @@ -128,8 +128,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie { constitutionCode = tcmResult.ConstitutionCode; userConfig = tcmResult.SceneConfig; - var comfort = _mattressComfort.Get(profile, stateMap); - MattressComfortService.ApplyTo(userConfig, comfort); + var comfort = _environmentAdvice.Get(profile, stateMap); + EnvironmentAdviceService.ApplyTo(userConfig, comfort); } } catch (Exception exception) when (exception is not OperationCanceledException) @@ -340,8 +340,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie if (!tcmResult.HasCompleted) throw new ScenePreparationException("请先完成中医四诊,生成体质倾向后再一键启动"); var config = tcmResult.SceneConfig; - var mattressComfort = await _mattressComfort.GetAsync(profile, token); - MattressComfortService.ApplyTo(config, mattressComfort); + var environmentAdvice = await _environmentAdvice.GetAsync(profile, token); + EnvironmentAdviceService.ApplyTo(config, environmentAdvice); RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice); return new PreparedSleepScene(config, tcmResult.ConstitutionCode); }); diff --git a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs index 4fff542..14b6841 100644 --- a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs @@ -213,8 +213,6 @@ public static class TcmConstitutionRules { TargetHumidity = humidity, DehumidifierMode = "睡眠", FanLevel = "1档", LightBrightnessPercent = 20, LightColorTemperatureKelvin = kelvin, - MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0, - MattressMode = "智能适配", EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1, EnableSpeakerSleepMode = true, SpeakerVolumePercent = 50 } diff --git a/Evergreen.Health.Application/Health/Services/MattressComfortService.cs b/Evergreen.Health.Application/Health/Services/MattressComfortService.cs deleted file mode 100644 index d0c7217..0000000 --- a/Evergreen.Health.Application/Health/Services/MattressComfortService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using Evergreen.Health.Application.Health.Devices.Environment; -using Evergreen.Health.Application.Health.Entities; -using Evergreen.Health.Application.Health.Integrations.HomeAssistant; -using Evergreen.Health.Application.Health.Models; -using Evergreen.Health.Application.Health.Policies; -using Microsoft.Extensions.Options; - -namespace Evergreen.Health.Application.Health.Services; - -/// 组合独立的 BMI 和室温策略,不负责发送家具控制命令。 -public sealed class MattressComfortService : ITransient -{ - private readonly IHomeAssistantGateway _gateway; - private readonly IndoorEnvironmentReader _environment; - private readonly HomeAssistantEntityOptions _entities; - public MattressComfortService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment, - IOptions options) - => (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities); - - public async Task GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default) - { - IReadOnlyDictionary states; - try - { - states = (await _gateway.GetStatesAsync(cancellationToken)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - states = new Dictionary(); - } - return Get(profile, states); - } - - public MattressComfortOutput Get(HealthProfile? profile, IReadOnlyDictionary states) - { - var bmi = BmiFirmnessPolicy.Calculate(profile?.HeightCm, profile?.WeightKg); - var firmness = BmiFirmnessPolicy.Firmness(bmi); - var tcm = TcmConstitutionRules.Read(profile); - var sample = _environment.Read(states); - var advice = RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution(tcm.ConstitutionCode)); - advice.PendingIntegration = (advice.Mode is "heat" or "cool") && string.IsNullOrWhiteSpace(_entities.Climate); - return new() - { - Bmi = bmi, Firmness = firmness, HeatingLevel = advice.Mode == "heat" ? 1 : 0, - IndoorTemperatureCelsius = sample.Celsius, IndoorTemperatureAvailable = sample.Available, - IndoorTemperatureObservedAt = sample.ObservedAt, HeatingReason = advice.Reason, - WeatherReason = advice.Reason, // 旧客户端兼容字段,不再读取或依赖室外天气。 - ClimateAdvice = advice, - FirmnessReason = firmness is null - ? "健康档案尚无有效身高和体重,暂不自动调节软硬度" - : $"按 BMI {bmi:0.0} 给出“{firmness}”演示起点,可在设备详情手动调整" - }; - } - - public static void ApplyTo(PersonalizedSceneConfig config, MattressComfortOutput recommendation) - { - config.MattressHeatingLevel = recommendation.HeatingLevel; - config.MattressLeftFirmness = recommendation.Firmness is null ? 0 : FirmnessLevel(recommendation.Firmness); - config.MattressRightFirmness = config.MattressLeftFirmness; - config.ClimateAdvice = recommendation.ClimateAdvice; - } - - public static int FirmnessLevel(string firmness) => firmness switch - { - "最软" => 1, "很软" => 2, "稍软" => 3, "稍硬" => 4, "很硬" => 5, "最硬" => 6, _ => 0 - }; - - public static string? FirmnessName(int level) => level switch - { - 1 => "最软", 2 => "很软", 3 => "稍软", 4 => "稍硬", 5 => "很硬", 6 => "最硬", _ => null - }; -} diff --git a/Evergreen.Health.Application/Health/Sleep/ReservedSleepWorkflow.cs b/Evergreen.Health.Application/Health/Sleep/ReservedSleepWorkflow.cs index e017acb..10de407 100644 --- a/Evergreen.Health.Application/Health/Sleep/ReservedSleepWorkflow.cs +++ b/Evergreen.Health.Application/Health/Sleep/ReservedSleepWorkflow.cs @@ -1,7 +1,7 @@ namespace Evergreen.Health.Application.Health.Sleep; public enum ReservedSleepPhase { Disabled, WaitingForBed, Settling, Guiding, Music, Stopped } -public enum SleepWorkflowActionKind { PrepareEnvironment, StartGentleRightMassage, RequestGuidance, RestartMusicAtFifty, StartLightFade } +public enum SleepWorkflowActionKind { PrepareEnvironment = 0, RequestGuidance = 2, RestartMusicAtFifty = 3, StartLightFade = 4 } public sealed record SleepWorkflowAction(SleepWorkflowActionKind Kind, GuidanceClip? Clip = null, int? VolumePercent = null); /// @@ -44,8 +44,7 @@ public sealed class ReservedSleepWorkflow _pendingClip = _guidance.InitialRelaxation; _initialGuide = true; Phase = ReservedSleepPhase.Guiding; - return [new(SleepWorkflowActionKind.StartGentleRightMassage), - new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)]; + return [new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)]; } public IReadOnlyList GuidanceCompleted(string clipKey, bool completed, DateTimeOffset now)