去掉老床垫相关功能
This commit is contained in:
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+2
-11
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-16
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user