去掉老床垫相关功能

This commit is contained in:
fuenmao
2026-11-09 16:10:05 +08:00
parent 2d44cc3966
commit 3ca90c4cf3
25 changed files with 227 additions and 466 deletions
@@ -31,7 +31,7 @@ public sealed class ClimateAdviceBindingTests
Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
} }
}; };
var service = new MattressComfortService(gateway, var service = new EnvironmentAdviceService(gateway,
new IndoorEnvironmentReader(options, TimeProvider.System), options); new IndoorEnvironmentReader(options, TimeProvider.System), options);
var output = service.Get(null, states); var output = service.Get(null, states);
Assert.Equal(mode, output.ClimateAdvice.Mode); Assert.Equal(mode, output.ClimateAdvice.Mode);
@@ -4,7 +4,6 @@ using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices.Discovery; using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Devices.Environment;
using Evergreen.Health.Application.Health.Devices.Lighting; 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.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Models;
@@ -28,11 +27,8 @@ public sealed class DeviceArchitectureTests
Entities = new() Entities = new()
{ {
AmbientLight = "light.bedroom", Speaker = "media_player.speaker", SpeakerSleepMode = "switch.sleep", AmbientLight = "light.bedroom", Speaker = "media_player.speaker", SpeakerSleepMode = "switch.sleep",
Temperature = "sensor.room_temperature", MattressLeftMode = "select.left_mode", MattressRightMode = "select.right_mode", Temperature = "sensor.room_temperature"
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 }
}; };
private static HomeAssistantState Temperature(double value, DateTimeOffset? observed) => new() 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" }) 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] [Theory]
[InlineData(21.9, "heat")] [InlineData(21.9, "heat")]
[InlineData(22.0, "off")] [InlineData(22.0, "off")]
@@ -131,10 +108,6 @@ public sealed class DeviceArchitectureTests
Assert.Throws<InvalidOperationException>(() => light.BuildParameterCommand("color_temp", 6500)); Assert.Throws<InvalidOperationException>(() => light.BuildParameterCommand("color_temp", 6500));
Assert.Equal("light.bedroom", light.BuildParameterCommand("brightness", 0).EntityId); Assert.Equal("light.bedroom", light.BuildParameterCommand("brightness", 0).EntityId);
Assert.Equal("turn_off", light.BuildParameterCommand("brightness", 0).Service); 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<InvalidOperationException>(() => mattress.BuildParameterCommand("mattress_right_mode", "随意模式"));
} }
[Fact] [Fact]
@@ -163,15 +136,15 @@ public sealed class DeviceArchitectureTests
} }
[Fact] [Fact]
public void FallbackOnlyContainsConfiguredModulesAndIncludesMattressEntities() public void FallbackOnlyContainsConfiguredModules()
{ {
var runner = Runner(new()); var runner = Runner(new());
var options = Options.Create(Configuration()); 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<string, HomeAssistantState>())); var device = Assert.Single(catalog.BuildDiscoveredDevices(null, new Dictionary<string, HomeAssistantState>()));
Assert.Equal("mattress", device.ModuleKey); Assert.Equal("lighting", device.ModuleKey);
Assert.Contains(device.Entities, entity => entity.EntityId == "select.right_massage"); Assert.Contains(device.Entities, entity => entity.EntityId == "light.bedroom");
Assert.Null(device.PrimaryControlEntityId); Assert.Equal("light.bedroom", device.PrimaryControlEntityId);
} }
[Fact] [Fact]
@@ -218,17 +191,15 @@ public sealed class DeviceArchitectureTests
} }
[Fact] [Fact]
public async Task ManualLightOffUsesSceneLinkageToStopBothMassageSides() public async Task ManualLightOffOnlyControlsTheLight()
{ {
var gateway = new FakeHaGateway(); var gateway = new FakeHaGateway();
var runner = Runner(gateway); var runner = Runner(gateway);
var options = Options.Create(Configuration()); var options = Options.Create(Configuration());
var mattress = new MattressModule(runner, options); var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([]));
var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([mattress]));
await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default); await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default);
Assert.Equal("off", gateway.States["light.bedroom"].State); Assert.Equal("off", gateway.States["light.bedroom"].State);
Assert.Equal("无", gateway.States["select.right_massage"].State); Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId));
Assert.Equal("无", gateway.States["select.left_massage"].State);
} }
[Fact] [Fact]
@@ -248,7 +219,9 @@ public sealed class DeviceArchitectureTests
using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
Assert.NotNull(provider.GetRequiredService<HealthSceneCoordinator>()); Assert.NotNull(provider.GetRequiredService<HealthSceneCoordinator>());
Assert.Equal(2, provider.GetServices<IHostedService>().Count()); Assert.Equal(2, provider.GetServices<IHostedService>().Count());
Assert.Contains(provider.GetRequiredService<DeviceModuleRegistry>().Modules, module => module.Key == "mattress"); Assert.DoesNotContain(provider.GetRequiredService<DeviceModuleRegistry>().Modules, module => module.Key == "mattress");
Assert.Empty(provider.GetServices<ILightOffHandler>());
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<DeviceModuleRegistry>().ForParameter("mattress_left_heating"));
} }
[Fact] [Fact]
@@ -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<string, HomeAssistantState>
{
["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));
}
}
@@ -1,6 +1,5 @@
using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Contracts; 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.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Integrations.Tuya; using Evergreen.Health.Application.Health.Integrations.Tuya;
@@ -21,16 +20,13 @@ public sealed class SafetyAndReservedWorkflowTests
SleepDataAvailability availability = SleepDataAvailability.Available) => new("fake", at, availability, presence); SleepDataAvailability availability = SleepDataAvailability.Available) => new("fake", at, availability, presence);
private static HomeAssistantOptions Configuration() => new() private static HomeAssistantOptions Configuration() => new()
{ {
Entities = new() { AmbientLight = "light.bedroom", MattressRightMassagePart = "select.right", Entities = new() { AmbientLight = "light.bedroom" }
MattressLeftMassagePart = "select.left", MattressLeftHeatingLevel = "number.heat" }
}; };
private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new() private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new()
{ {
EntityId = "light.bedroom", State = state, LastUpdated = updated, EntityId = "light.bedroom", State = state, LastUpdated = updated,
Attributes = JsonSerializer.SerializeToElement(new { brightness }) Attributes = JsonSerializer.SerializeToElement(new { brightness })
}; };
private static DeviceCommand Massage() => new("按摩", "select.right", "select", "select_option",
new Dictionary<string, object?> { ["option"] = "全身" }) { Intent = DeviceCommandIntent.StartMassage };
[Fact] [Fact]
public async Task UnconfiguredInputsAndPlaceholderAudioCannotPretendToWork() public async Task UnconfiguredInputsAndPlaceholderAudioCannotPretendToWork()
@@ -71,7 +67,7 @@ public sealed class SafetyAndReservedWorkflowTests
Assert.Equal(ReservedSleepPhase.WaitingForBed, workflow.Phase); Assert.Equal(ReservedSleepPhase.WaitingForBed, workflow.Phase);
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30))); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)));
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(59)), Now.AddSeconds(59))); 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] [Fact]
@@ -93,7 +89,7 @@ public sealed class SafetyAndReservedWorkflowTests
workflow.Start(); workflow.Observe(Reading(Now), Now); workflow.Start(); workflow.Observe(Reading(Now), Now);
Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29)), Now.AddSeconds(29))); Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29)), Now.AddSeconds(29)));
var actions = workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)); 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.Equal(50, actions.Single(action => action.Kind == SleepWorkflowActionKind.RequestGuidance).VolumePercent);
Assert.Empty(workflow.GuidanceCompleted("wrong", true, Now.AddSeconds(50))); Assert.Empty(workflow.GuidanceCompleted("wrong", true, Now.AddSeconds(50)));
var music = Assert.Single(workflow.GuidanceCompleted("guide", 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)); 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<InvalidOperationException>(() => runner.SendAsync(Massage(), default));
Assert.Empty(gateway.Calls);
}
[Fact] [Fact]
public async Task KnownLampOffRejectsOlderCloudOnCacheButAllowsFreshOnReading() public async Task NaturalFadeToZeroOnlyControlsTheLight()
{
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => runner.SendAsync(Massage(), default));
Assert.DoesNotContain(gateway.Calls, command => command.EntityId == "select.right");
}
[Fact]
public async Task NaturalFadeToZeroStopsBothMassageSides()
{ {
var time = new ManualTestTimeProvider(); var time = new ManualTestTimeProvider();
var gateway = new FakeHaGateway { Time = time }; var gateway = new FakeHaGateway { Time = time };
gateway.States["light.bedroom"] = Light("on", 100, time.GetUtcNow()); gateway.States["light.bedroom"] = Light("on", 100, time.GetUtcNow());
var runner = new DeviceActionRunner(gateway, time, new() { VerificationDelay = TimeSpan.Zero }); var runner = new DeviceActionRunner(gateway, time, new() { VerificationDelay = TimeSpan.Zero });
var mattress = new MattressModule(runner, Options.Create(Configuration()));
var safety = new SceneLightSafetyState(); var safety = new SceneLightSafetyState();
var light = new HomeAssistantLightTransitionService(gateway, new([mattress]), Options.Create(Configuration()), var light = new HomeAssistantLightTransitionService(gateway, new([]), Options.Create(Configuration()),
NullLogger<HomeAssistantLightTransitionService>.Instance, safety, time); NullLogger<HomeAssistantLightTransitionService>.Instance, safety, time);
await light.StartSleepAsync(2700, default); await light.StartSleepAsync(2700, default);
for (var step = 1; step <= 50; step++) for (var step = 1; step <= 50; step++)
@@ -179,10 +135,9 @@ public sealed class SafetyAndReservedWorkflowTests
var expected = 50 - step; var expected = 50 - step;
await WaitUntilAsync(() => light.GetStatus().BrightnessPercent == expected && (step != 50 || !light.GetStatus().Running)); 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("off", gateway.States["light.bedroom"].State);
Assert.Equal("无", gateway.States["select.right"].State);
Assert.NotNull(safety.OffAt); Assert.NotNull(safety.OffAt);
Assert.All(gateway.Calls, command => Assert.Equal("light.bedroom", command.EntityId));
await light.StopAsync(default); await light.StopAsync(default);
} }
@@ -195,14 +150,6 @@ public sealed class SafetyAndReservedWorkflowTests
Assert.Equal("Failed", (await coordinator.ExecuteAsync("sleep", new(), "balanced", default)).Status); 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] [Fact]
public void SessionClimateTargetIsIdempotentAndPreviewIsNotRandomized() public void SessionClimateTargetIsIdempotentAndPreviewIsNotRandomized()
{ {
@@ -4,7 +4,6 @@ using Evergreen.Health.Application.Health.Devices.Contracts;
using Evergreen.Health.Application.Health.Devices.Discovery; using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Devices.Environment;
using Evergreen.Health.Application.Health.Devices.Lighting; 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.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Evergreen.Health.Application.Health.Models; 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 SceneLightOptions StripSettings() => new() { Key = "sleep-light-strip", Name = "测试助眠灯带", EntityId = Strip, SleepEffect = "助眠模式" };
private static HomeAssistantOptions Settings() => new() private static HomeAssistantOptions Settings() => new()
{ {
Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature, Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature },
MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat" }, AdditionalSleepLights = [StripSettings()]
AdditionalSleepLights = [StripSettings()], SleepScene = new() { EnableMattress = true }
}; };
private static HomeAssistantState Light(string id, string state = "off", string[]? effects = null) => new() 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 DeviceActionRunner Runner { get; }
public DeviceModuleRegistry Modules { get; } public DeviceModuleRegistry Modules { get; }
public HealthSceneCoordinator Coordinator { get; } public HealthSceneCoordinator Coordinator { get; }
public Fixture(bool mattress = false) public Fixture()
{ {
Gateway = new() { Time = Time }; Gateway = new() { Time = Time };
Gateway.States[Primary] = Light(Primary); Gateway.States[Strip] = Light(Strip); 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 ConfiguredSleepLightModule(Runner, StripSettings(), Lights.ForEntity(Strip)),
new ClimateModule(Runner, Options) new ClimateModule(Runner, Options)
}; };
if (mattress) modules.Add(new MattressModule(Runner, Options));
Modules = new(modules); Modules = new(modules);
Coordinator = new(Modules, Runner, Gateway, Lights, new FakeMusic()); 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" }) LastReported = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
}; };
else Gateway.States.TryRemove(Temperature, out _); 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); .Get(null, Gateway.States);
var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 }; var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 };
MattressComfortService.ApplyTo(config, comfort); EnvironmentAdviceService.ApplyTo(config, comfort);
RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice); RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice);
return new(config, "balanced"); return new(config, "balanced");
} }
@@ -134,7 +131,7 @@ public sealed class SceneLightingIntegrationTests
} }
[Fact] [Fact]
public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsMassageShutdownHook() public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsLightOffEvent()
{ {
var f = new Fixture(); var f = new Fixture();
try try
@@ -171,21 +168,21 @@ public sealed class SceneLightingIntegrationTests
} }
[Theory] [Theory]
[InlineData(15, "heat", 1)] [InlineData(15, "heat")]
[InlineData(30, "cool", 0)] [InlineData(30, "cool")]
[InlineData(22, "off", 0)] [InlineData(22, "off")]
[InlineData(24, "off", 0)] [InlineData(24, "off")]
[InlineData(25, "off", 0)] [InlineData(25, "off")]
public async Task EnvironmentSensorDrivesActualSceneClimateAndBothHeatingLevels(double room, string mode, int heating) public async Task EnvironmentSensorDrivesActualSceneClimateWithoutRetiredHeating(double room, string mode)
{ {
var f = new Fixture(mattress: true); var f = new Fixture();
try try
{ {
var prepared = f.Prepare(room); var prepared = f.Prepare(room);
var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time); var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time);
Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode); Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode);
Assert.False(result.ClimateAdvice.PendingIntegration); 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") if (mode == "off")
{ {
Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius); Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius);
@@ -211,11 +208,10 @@ public sealed class SceneLightingIntegrationTests
var prepared = f.Prepare(null); var prepared = f.Prepare(null);
var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default); var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default);
Assert.Equal("unavailable", result.ClimateAdvice!.Mode); Assert.Equal("unavailable", result.ClimateAdvice!.Mode);
Assert.Equal(0, prepared.Config.MattressHeatingLevel);
Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate); Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate);
f.Prepare(15); f.Time.Advance(TimeSpan.FromMinutes(31)); 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); 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); Assert.Equal(0, comfort.HeatingLevel); Assert.Equal("unavailable", comfort.ClimateAdvice.Mode);
} }
finally { await f.Lights.StopAsync(default); } finally { await f.Lights.StopAsync(default); }
} }
@@ -1,6 +1,6 @@
using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Devices.Discovery; 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.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Scenes; using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -96,10 +96,10 @@ public sealed class SystemServiceFilteringTests
{ {
var options = Options.Create(new HomeAssistantOptions 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 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 var registry = new HomeAssistantRegistries
{ {
Devices = [new() { Id = "backup", Name = "Backup", EntryType = "service" }], Devices = [new() { Id = "backup", Name = "Backup", EntryType = "service" }],
@@ -108,9 +108,9 @@ public sealed class SystemServiceFilteringTests
var states = new Dictionary<string, HomeAssistantState> var states = new Dictionary<string, HomeAssistantState>
{ {
["sensor.backup"] = new() { EntityId = "sensor.backup", State = "idle" }, ["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.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);
} }
} }
@@ -21,27 +21,6 @@
"Climate": "climate.fawad_cn_974720212_3010", "Climate": "climate.fawad_cn_974720212_3010",
"Curtain": "cover.czmydz_cn_2251706154_mym1_s_2_curtain", "Curtain": "cover.czmydz_cn_2251706154_mym1_s_2_curtain",
"Outlet": "switch.ym001_cn_2108613283_ymcz_on_p_2_1", "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", "Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1",
"Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2", "Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2",
"Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3", "Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3",
@@ -65,10 +44,28 @@
"LightColorTemperatureKelvin": 2700, "LightColorTemperatureKelvin": 2700,
"EnableFragrance": true, "EnableFragrance": true,
"EnableSpeakerSleepMode": true, "EnableSpeakerSleepMode": true,
"EnableMattress": true,
"MattressHeatingLevel": 1,
"MattressMode": "智能适配",
"EnableUv": false "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"
]
} }
} }
@@ -2,7 +2,7 @@ using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
namespace Evergreen.Health.Application.Health.Devices.Contracts; namespace Evergreen.Health.Application.Health.Devices.Contracts;
public enum DeviceCommandIntent { Normal, StartMassage, TurnRelaxationLightOn, TurnRelaxationLightOff } public enum DeviceCommandIntent { Normal = 0, TurnRelaxationLightOn = 2, TurnRelaxationLightOff = 3 }
/// <summary>实体由后端模块提供,不接受客户端任意实体或任意服务名。</summary> /// <summary>实体由后端模块提供,不接受客户端任意实体或任意服务名。</summary>
public sealed record DeviceCommand(string Name, string EntityId, string Domain, string Service, public sealed record DeviceCommand(string Name, string EntityId, string Domain, string Service,
@@ -29,9 +29,13 @@ public sealed class HomeAssistantDeviceCatalog
.GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase) .GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), 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,不属于家具或实体传感器。 // HA 将 Sun、Backup 等虚拟系统服务标记为 service,不属于家具或实体传感器。
foreach (var device in registries.Devices.Where(device => string.IsNullOrWhiteSpace(device.DisabledBy) && 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) ?? []; var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? [];
if (regEntities.Count == 0) continue; if (regEntities.Count == 0) continue;
@@ -291,7 +295,6 @@ public sealed class HomeAssistantDeviceCatalog
if (id.Contains("tvoc")) metrics["tvoc"] = entity.State; if (id.Contains("tvoc")) metrics["tvoc"] = entity.State;
if (id.Contains("weight")) metrics["weight"] = 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("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; if (id.Contains("sleep_state") || id.Contains("sleep_status")) metrics["sleepStatus"] = entity.State;
} }
@@ -7,7 +7,6 @@ using Evergreen.Health.Application.Health.Devices.Discovery;
using Evergreen.Health.Application.Health.Devices.Dehumidifier; using Evergreen.Health.Application.Health.Devices.Dehumidifier;
using Evergreen.Health.Application.Health.Devices.Fragrance; using Evergreen.Health.Application.Health.Devices.Fragrance;
using Evergreen.Health.Application.Health.Devices.Lighting; 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.Devices.Outlet;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Integrations.MiAirX;
@@ -41,7 +40,7 @@ public static class HealthDeviceRegistration
provider.GetRequiredService<IOptions<MiAirXOptions>>())); provider.GetRequiredService<IOptions<MiAirXOptions>>()));
services.AddSingleton<DeviceActionRunner>(); services.AddSingleton<DeviceActionRunner>();
services.AddSingleton<SceneLightSafetyState>(); services.AddSingleton<SceneLightSafetyState>();
services.AddSingleton<IDeviceActionSafetyRule, LightMassageSafetyRule>(); services.AddSingleton<IDeviceActionSafetyRule, PrimaryLightStateRule>();
services.AddSingleton<IndoorEnvironmentReader>(); services.AddSingleton<IndoorEnvironmentReader>();
services.AddSingleton<EnvironmentModule>(); services.AddSingleton<EnvironmentModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<EnvironmentModule>()); services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<EnvironmentModule>());
@@ -69,9 +68,6 @@ public static class HealthDeviceRegistration
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>()); services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>());
services.AddSingleton<AudioModule>(); services.AddSingleton<AudioModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>()); services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
services.AddSingleton<MattressModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<MattressModule>());
services.AddSingleton<ILightOffHandler>(provider => provider.GetRequiredService<MattressModule>());
services.AddSingleton<LightOffDispatcher>(); services.AddSingleton<LightOffDispatcher>();
services.AddSingleton<DeviceModuleRegistry>(provider => new( services.AddSingleton<DeviceModuleRegistry>(provider => new(
provider.GetServices<IHealthDeviceModule>().Concat( provider.GetServices<IHealthDeviceModule>().Concat(
@@ -5,7 +5,7 @@ using Evergreen.Health.Application.Health.Scenes;
namespace Evergreen.Health.Application.Health.Devices.Lighting; namespace Evergreen.Health.Application.Health.Devices.Lighting;
/// <summary>额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的按摩/唤醒钩子。</summary> /// <summary>额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的关灯事件/唤醒钩子。</summary>
public sealed class ConfiguredSleepLightModule : HealthDeviceModule public sealed class ConfiguredSleepLightModule : HealthDeviceModule
{ {
private readonly SceneLightOptions _light; private readonly SceneLightOptions _light;
@@ -38,7 +38,7 @@ public sealed class LightingModule : HealthDeviceModule
public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken) public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
{ {
// 标准手动面板复用本灯的专属安全钩子;其他灯不会触发助眠灯/床垫联动。 // 标准手动面板复用本灯的专属安全钩子;其他灯不会触发主灯关灯事件。
command = command with { Intent = command.Service == "turn_off" command = command with { Intent = command.Service == "turn_off"
? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn }; ? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn };
await _transition.CancelAsync(cancellationToken); await _transition.CancelAsync(cancellationToken);
@@ -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<HomeAssistantOptions> options) : base(runner)
{
_options = options.Value;
var modes = new[] { "无", "瑜伽助眠", "智能适配", "智能腰托" };
var parts = new[] { "无", "背部", "腰部", "牵引", "腿部", "全身" };
var parameters = new List<DeviceParameterDefinition>
{
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<DeviceParameterDefinition> 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<DeviceCommand> MassageOffCommands() =>
[
new("关闭床垫右侧按摩", Entities.MattressRightMassagePart, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "无" }, "无"),
new("关闭床垫左侧按摩", Entities.MattressLeftMassagePart, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "无" }, "无")
];
public override IEnumerable<DeviceCommand> GetShutdownCommands()
{
foreach (var command in MassageOffCommands()) yield return command;
// 未参与自动启动不等于无需关闭:手动开启的加热也要随一键关闭停止。
yield return new("关闭床垫左侧加热", Entities.MattressLeftHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = 0 }, "0");
yield return new("关闭床垫右侧加热", Entities.MattressRightHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = 0 }, "0");
}
public async Task HandleAsync(CancellationToken cancellationToken)
{
var commands = MassageOffCommands().Where(command => !string.IsNullOrWhiteSpace(command.EntityId)).ToArray();
var results = new List<HomeAssistantActionResult>();
foreach (var command in commands)
await Runner.RunAsync(new Dictionary<string, HomeAssistantState>(), 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
};
}
@@ -8,6 +8,8 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
public string BaseUrl { get; set; } = string.Empty; public string BaseUrl { get; set; } = string.Empty;
public string AccessToken { get; set; } = string.Empty; public string AccessToken { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 15; public int TimeoutSeconds { get; set; } = 15;
// 匹配已退役设备的明确实体绑定,不按名称或品牌隐藏设备。
public List<string> ExcludedDeviceEntityIds { get; set; } = [];
public HomeAssistantEntityOptions Entities { get; set; } = new(); public HomeAssistantEntityOptions Entities { get; set; } = new();
public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new(); public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new();
public List<SceneLightOptions> AdditionalSleepLights { get; set; } = []; public List<SceneLightOptions> AdditionalSleepLights { get; set; } = [];
@@ -41,14 +43,6 @@ public sealed class HomeAssistantEntityOptions
public string AmbientLight { get; set; } = string.Empty; public string AmbientLight { get; set; } = string.Empty;
public string Speaker { get; set; } = string.Empty; public string Speaker { get; set; } = string.Empty;
public string SpeakerSleepMode { 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<string, string> 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 Temperature { get; set; } = string.Empty;
public string Humidity { get; set; } = string.Empty; public string Humidity { get; set; } = string.Empty;
public string Pm25 { 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 int LightColorTemperatureKelvin { get; set; } = 2700;
public bool EnableFragrance { get; set; } = true; public bool EnableFragrance { get; set; } = true;
public bool EnableSpeakerSleepMode { get; set; } 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; } public bool EnableUv { get; set; }
} }
@@ -20,7 +20,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
private readonly TimeProvider _time; private readonly TimeProvider _time;
private readonly string _entityId; private readonly string _entityId;
private readonly string? _sleepEffect; private readonly string? _sleepEffect;
private readonly bool _controlsMassage; private readonly bool _isPrimaryLight;
private readonly ILogger<HomeAssistantLightTransitionService> _logger; private readonly ILogger<HomeAssistantLightTransitionService> _logger;
private readonly SemaphoreSlim _operationLock = new(1, 1); private readonly SemaphoreSlim _operationLock = new(1, 1);
private readonly object _statusLock = new(); private readonly object _statusLock = new();
@@ -38,7 +38,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
{ {
_entityId = light?.EntityId ?? options.Value.Entities.AmbientLight; _entityId = light?.EntityId ?? options.Value.Entities.AmbientLight;
_sleepEffect = light?.SleepEffect; _sleepEffect = light?.SleepEffect;
_controlsMassage = light is null; _isPrimaryLight = light is null;
_logger = logger; _logger = logger;
_homeAssistant = homeAssistant; _homeAssistant = homeAssistant;
_lightOff = lightOff; _lightOff = lightOff;
@@ -80,7 +80,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
var data = BuildSleepStartData(light, colorTemperatureKelvin); var data = BuildSleepStartData(light, colorTemperatureKelvin);
await _homeAssistant.CallServiceAsync("light", "turn_on", data, cancellationToken); await _homeAssistant.CallServiceAsync("light", "turn_on", data, cancellationToken);
if (_controlsMassage) _safety.MarkOn(); if (_isPrimaryLight) _safety.MarkOn();
SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度"); SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度");
Launch("sleep", SleepStartPercent); Launch("sleep", SleepStartPercent);
return GetStatus(); return GetStatus();
@@ -95,7 +95,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
{ {
await StopCurrentAsync(); await StopCurrentAsync();
var light = await ReadAvailableLightAsync(cancellationToken); 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) || var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase) ||
(knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt)); (knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt));
var brightness = light.GetDoubleAttribute("brightness"); var brightness = light.GetDoubleAttribute("brightness");
@@ -105,7 +105,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
if (current >= WakeTargetPercent && !isOff) if (current >= WakeTargetPercent && !isOff)
{ {
if (_controlsMassage) _safety.MarkOn(); if (_isPrimaryLight) _safety.MarkOn();
SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度"); SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度");
return GetStatus(); return GetStatus();
} }
@@ -119,7 +119,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
}, cancellationToken); }, cancellationToken);
} }
if (_controlsMassage) _safety.MarkOn(); if (_isPrimaryLight) _safety.MarkOn();
SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度"); SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度");
Launch("wake", current); Launch("wake", current);
return GetStatus(); return GetStatus();
@@ -151,7 +151,7 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
JsonElement modes = default; JsonElement modes = default;
var hasModes = state.Attributes.ValueKind == JsonValueKind.Object && var hasModes = state.Attributes.ValueKind == JsonValueKind.Object &&
state.Attributes.TryGetProperty("supported_color_modes", out modes) && modes.ValueKind == JsonValueKind.Array; 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); data["color_temp_kelvin"] = Math.Clamp(kelvin, 2200, 5000);
return data; return data;
@@ -208,17 +208,17 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
var light = await ReadAvailableLightAsync(cancellationToken); var light = await ReadAvailableLightAsync(cancellationToken);
if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase))
{ {
if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow()); if (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow());
if (mode == "sleep" && _controlsMassage) if (mode == "sleep" && _isPrimaryLight)
{ {
try try
{ {
await _lightOff.NotifyAsync(cancellationToken); await _lightOff.NotifyAsync(cancellationToken);
SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止"); SetStatus(true, false, mode, 0, 0, "灯已关闭,渐变已停止");
} }
catch (Exception exception) when (exception is not OperationCanceledException) catch (Exception exception) when (exception is not OperationCanceledException)
{ {
_logger.LogWarning(exception, "灯已关闭,但床垫按摩停止失败"); _logger.LogWarning(exception, "灯已关闭,但关灯事件处理失败");
SetStatus(false, false, mode, 0, 0, exception.Message); SetStatus(false, false, mode, 0, 0, exception.Message);
} }
} }
@@ -245,16 +245,16 @@ internal sealed class LightTransitionWorker : ILightTransitionRuntime
{ {
["entity_id"] = LightEntityId ["entity_id"] = LightEntityId
}, cancellationToken); }, cancellationToken);
if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow()); if (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow());
SetStatus(true, false, mode, 0, 0, _controlsMassage ? "灯光已关闭,正在停止床垫按摩" : "助眠渐暗已完成,灯光已关闭"); SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭");
try try
{ {
if (_controlsMassage) await _lightOff.NotifyAsync(cancellationToken); if (_isPrimaryLight) await _lightOff.NotifyAsync(cancellationToken);
SetStatus(true, false, mode, 0, 0, _controlsMassage ? "助眠渐暗已完成,灯光与床垫按摩已关闭" : "助眠渐暗已完成,灯光已关闭"); SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭");
} }
catch (Exception exception) when (exception is not OperationCanceledException) catch (Exception exception) when (exception is not OperationCanceledException)
{ {
_logger.LogWarning(exception, "助眠渐暗已完成,但床垫按摩停止失败"); _logger.LogWarning(exception, "助眠渐暗已完成,但关灯事件处理失败");
SetStatus(false, false, mode, 0, 0, exception.Message); SetStatus(false, false, mode, 0, 0, exception.Message);
} }
return; return;
@@ -99,25 +99,14 @@ public sealed class HealthDashboardOutput
public string BmiStatus { get; set; } = string.Empty; public string BmiStatus { get; set; } = string.Empty;
public PersonalizedSceneConfig? SceneConfig { get; set; } public PersonalizedSceneConfig? SceneConfig { get; set; }
public TcmAssessmentResult? TcmResult { 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 double? IndoorTemperatureCelsius { get; set; }
public bool IndoorTemperatureAvailable { get; set; } public bool IndoorTemperatureAvailable { get; set; }
public DateTimeOffset? IndoorTemperatureObservedAt { get; set; } public DateTimeOffset? IndoorTemperatureObservedAt { get; set; }
public string HeatingReason { get; set; } = string.Empty;
public ClimateAdviceOutput ClimateAdvice { get; set; } = new(); public ClimateAdviceOutput ClimateAdvice { get; set; } = new();
} }
@@ -154,11 +143,6 @@ public sealed class PersonalizedSceneConfig
public int LightBrightnessPercent { get; set; } = 20; public int LightBrightnessPercent { get; set; } = 20;
public int LightColorTemperatureKelvin { get; set; } = 2700; 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 bool EnableFragrance { get; set; } = true;
public int FragranceLevel { get; set; } = 1; public int FragranceLevel { get; set; } = 1;
@@ -1,22 +0,0 @@
namespace Evergreen.Health.Application.Health.Policies;
/// <summary>Demo 起点,可由用户手动修改,不是医学或人体工学判断。</summary>
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
};
}
@@ -25,10 +25,10 @@ public static class RoomTemperaturePolicy
Mode = mode, RecommendedMinimumCelsius = range.Minimum, RecommendedMaximumCelsius = range.Maximum, Mode = mode, RecommendedMinimumCelsius = range.Minimum, RecommendedMaximumCelsius = range.Maximum,
Reason = mode switch Reason = mode switch
{ {
"heat" => $"室温低于建议 {range.Display}:床垫低档加热,建议空调制热", "heat" => $"室温低于建议 {range.Display}:建议空调制热",
"cool" => $"室温高于建议 {range.Display}:床垫不加热,建议空调制冷", "cool" => $"室温高于建议 {range.Display}:建议空调制冷",
"off" => $"室温在建议 {range.Display} 内:床垫不加热,空调不开启", "off" => $"室温在建议 {range.Display} 内:空调保持当前运行状态",
_ => $"{sample.Reason},不自动加热或开启空调" _ => $"{sample.Reason},不自动开启空调"
}, },
PendingIntegration = mode is "heat" or "cool" PendingIntegration = mode is "heat" or "cool"
}; };
@@ -19,31 +19,13 @@ public sealed class SceneLightSafetyState
public void MarkOn() { lock (_lock) _offAt = null; } public void MarkOn() { lock (_lock) _offAt = null; }
} }
/// <summary>只依赖“启动按摩”的动作意图,不知道床垫实体。防止灯灭后较晚的启动指令再开启按摩。</summary> /// <summary>记录主灯的已发送开关状态,供晨起渐亮处理滞后的云端回读。</summary>
public sealed class LightMassageSafetyRule : IDeviceActionSafetyRule public sealed class PrimaryLightStateRule(SceneLightSafetyState state) : IDeviceActionSafetyRule
{ {
private readonly IHomeAssistantGateway _gateway; public Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken) => Task.CompletedTask;
private readonly string _lightEntity;
private readonly SceneLightSafetyState _state;
public LightMassageSafetyRule(IHomeAssistantGateway gateway, IOptions<HomeAssistantOptions> 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 void Accepted(DeviceCommand command, DateTimeOffset now) public void Accepted(DeviceCommand command, DateTimeOffset now)
{ {
if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) _state.MarkOff(now); if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) state.MarkOff(now);
else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) _state.MarkOn(); else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) state.MarkOn();
} }
} }
@@ -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;
/// <summary>依据室温与体质提供空调建议,不负责发送家具控制命令。</summary>
public sealed class EnvironmentAdviceService : ITransient
{
private readonly IHomeAssistantGateway _gateway;
private readonly IndoorEnvironmentReader _environment;
private readonly HomeAssistantEntityOptions _entities;
public EnvironmentAdviceService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment,
IOptions<HomeAssistantOptions> options)
=> (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities);
public async Task<EnvironmentAdviceOutput> GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default)
{
IReadOnlyDictionary<string, HomeAssistantState> 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<string, HomeAssistantState>();
}
return Get(profile, states);
}
public EnvironmentAdviceOutput Get(HealthProfile? profile, IReadOnlyDictionary<string, HomeAssistantState> 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;
}
}
@@ -24,7 +24,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
private readonly SqlSugarRepository<HealthProfile> _profileRepository; private readonly SqlSugarRepository<HealthProfile> _profileRepository;
private readonly SqlSugarRepository<HealthModuleSelection> _moduleRepository; private readonly SqlSugarRepository<HealthModuleSelection> _moduleRepository;
private readonly SqlSugarRepository<HealthPlan> _planRepository; private readonly SqlSugarRepository<HealthPlan> _planRepository;
private readonly MattressComfortService _mattressComfort; private readonly EnvironmentAdviceService _environmentAdvice;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
public HealthAppService( public HealthAppService(
@@ -32,14 +32,14 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
SqlSugarRepository<HealthProfile> profileRepository, SqlSugarRepository<HealthProfile> profileRepository,
SqlSugarRepository<HealthModuleSelection> moduleRepository, SqlSugarRepository<HealthModuleSelection> moduleRepository,
SqlSugarRepository<HealthPlan> planRepository, SqlSugarRepository<HealthPlan> planRepository,
MattressComfortService mattressComfort, EnvironmentAdviceService environmentAdvice,
IHttpContextAccessor httpContextAccessor) IHttpContextAccessor httpContextAccessor)
{ {
_userRepository = userRepository; _userRepository = userRepository;
_profileRepository = profileRepository; _profileRepository = profileRepository;
_moduleRepository = moduleRepository; _moduleRepository = moduleRepository;
_planRepository = planRepository; _planRepository = planRepository;
_mattressComfort = mattressComfort; _environmentAdvice = environmentAdvice;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
} }
@@ -118,11 +118,11 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
} }
var tcmResult = TcmConstitutionRules.Read(profile); var tcmResult = TcmConstitutionRules.Read(profile);
MattressComfortOutput? mattressComfort = null; EnvironmentAdviceOutput? environmentAdvice = null;
if (tcmResult.HasCompleted) if (tcmResult.HasCompleted)
{ {
mattressComfort = await _mattressComfort.GetAsync(profile); environmentAdvice = await _environmentAdvice.GetAsync(profile);
MattressComfortService.ApplyTo(tcmResult.SceneConfig, mattressComfort); EnvironmentAdviceService.ApplyTo(tcmResult.SceneConfig, environmentAdvice);
} }
return new HealthDashboardOutput return new HealthDashboardOutput
@@ -136,7 +136,7 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
BmiStatus = bmiStatus, BmiStatus = bmiStatus,
SceneConfig = tcmResult.HasCompleted ? tcmResult.SceneConfig : null, SceneConfig = tcmResult.HasCompleted ? tcmResult.SceneConfig : null,
TcmResult = tcmResult, TcmResult = tcmResult,
MattressComfort = mattressComfort EnvironmentAdvice = environmentAdvice
}; };
} }
@@ -30,7 +30,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
private readonly HomeAssistantOptions _options; private readonly HomeAssistantOptions _options;
private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository; private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository;
private readonly SqlSugarRepository<HealthProfile> _profileRepository; private readonly SqlSugarRepository<HealthProfile> _profileRepository;
private readonly MattressComfortService _mattressComfort; private readonly EnvironmentAdviceService _environmentAdvice;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly DeviceModuleRegistry _modules; private readonly DeviceModuleRegistry _modules;
private readonly DeviceParameterController _parameters; private readonly DeviceParameterController _parameters;
@@ -46,7 +46,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
IOptions<HomeAssistantOptions> options, IOptions<HomeAssistantOptions> options,
SqlSugarRepository<HealthSceneExecution> executionRepository, SqlSugarRepository<HealthSceneExecution> executionRepository,
SqlSugarRepository<HealthProfile> profileRepository, SqlSugarRepository<HealthProfile> profileRepository,
MattressComfortService mattressComfort, EnvironmentAdviceService environmentAdvice,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
DeviceModuleRegistry modules, DeviceModuleRegistry modules,
DeviceParameterController parameters, DeviceParameterController parameters,
@@ -61,7 +61,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
_options = options.Value; _options = options.Value;
_executionRepository = executionRepository; _executionRepository = executionRepository;
_profileRepository = profileRepository; _profileRepository = profileRepository;
_mattressComfort = mattressComfort; _environmentAdvice = environmentAdvice;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_modules = modules; _modules = modules;
_parameters = parameters; _parameters = parameters;
@@ -128,8 +128,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
{ {
constitutionCode = tcmResult.ConstitutionCode; constitutionCode = tcmResult.ConstitutionCode;
userConfig = tcmResult.SceneConfig; userConfig = tcmResult.SceneConfig;
var comfort = _mattressComfort.Get(profile, stateMap); var comfort = _environmentAdvice.Get(profile, stateMap);
MattressComfortService.ApplyTo(userConfig, comfort); EnvironmentAdviceService.ApplyTo(userConfig, comfort);
} }
} }
catch (Exception exception) when (exception is not OperationCanceledException) catch (Exception exception) when (exception is not OperationCanceledException)
@@ -340,8 +340,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
if (!tcmResult.HasCompleted) if (!tcmResult.HasCompleted)
throw new ScenePreparationException("请先完成中医四诊,生成体质倾向后再一键启动"); throw new ScenePreparationException("请先完成中医四诊,生成体质倾向后再一键启动");
var config = tcmResult.SceneConfig; var config = tcmResult.SceneConfig;
var mattressComfort = await _mattressComfort.GetAsync(profile, token); var environmentAdvice = await _environmentAdvice.GetAsync(profile, token);
MattressComfortService.ApplyTo(config, mattressComfort); EnvironmentAdviceService.ApplyTo(config, environmentAdvice);
RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice); RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice);
return new PreparedSleepScene(config, tcmResult.ConstitutionCode); return new PreparedSleepScene(config, tcmResult.ConstitutionCode);
}); });
@@ -213,8 +213,6 @@ public static class TcmConstitutionRules
{ {
TargetHumidity = humidity, DehumidifierMode = "睡眠", FanLevel = "1档", TargetHumidity = humidity, DehumidifierMode = "睡眠", FanLevel = "1档",
LightBrightnessPercent = 20, LightColorTemperatureKelvin = kelvin, LightBrightnessPercent = 20, LightColorTemperatureKelvin = kelvin,
MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0,
MattressMode = "智能适配",
EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1, EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1,
EnableSpeakerSleepMode = true, SpeakerVolumePercent = 50 EnableSpeakerSleepMode = true, SpeakerVolumePercent = 50
} }
@@ -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;
/// <summary>组合独立的 BMI 和室温策略,不负责发送家具控制命令。</summary>
public sealed class MattressComfortService : ITransient
{
private readonly IHomeAssistantGateway _gateway;
private readonly IndoorEnvironmentReader _environment;
private readonly HomeAssistantEntityOptions _entities;
public MattressComfortService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment,
IOptions<HomeAssistantOptions> options)
=> (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities);
public async Task<MattressComfortOutput> GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default)
{
IReadOnlyDictionary<string, HomeAssistantState> 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<string, HomeAssistantState>();
}
return Get(profile, states);
}
public MattressComfortOutput Get(HealthProfile? profile, IReadOnlyDictionary<string, HomeAssistantState> 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
};
}
@@ -1,7 +1,7 @@
namespace Evergreen.Health.Application.Health.Sleep; namespace Evergreen.Health.Application.Health.Sleep;
public enum ReservedSleepPhase { Disabled, WaitingForBed, Settling, Guiding, Music, Stopped } 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); public sealed record SleepWorkflowAction(SleepWorkflowActionKind Kind, GuidanceClip? Clip = null, int? VolumePercent = null);
/// <summary> /// <summary>
@@ -44,8 +44,7 @@ public sealed class ReservedSleepWorkflow
_pendingClip = _guidance.InitialRelaxation; _pendingClip = _guidance.InitialRelaxation;
_initialGuide = true; _initialGuide = true;
Phase = ReservedSleepPhase.Guiding; Phase = ReservedSleepPhase.Guiding;
return [new(SleepWorkflowActionKind.StartGentleRightMassage), return [new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)];
new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)];
} }
public IReadOnlyList<SleepWorkflowAction> GuidanceCompleted(string clipKey, bool completed, DateTimeOffset now) public IReadOnlyList<SleepWorkflowAction> GuidanceCompleted(string clipKey, bool completed, DateTimeOffset now)