From e6b90a06abe1cacfd46fa141b9659aec6c13a31c Mon Sep 17 00:00:00 2001
From: fuenmao <980740792@qq.com>
Date: Fri, 9 Oct 2026 16:46:30 +0800
Subject: [PATCH] 1
---
Admin.NET.Test/Admin.NET.Test.csproj | 5 +
.../Health/ClimateAdviceBindingTests.cs | 42 +
.../Health/ClimateCurtainIntegrationTests.cs | 408 +++++
.../Health/DeviceArchitectureTests.cs | 310 ++++
Admin.NET.Test/Health/DeviceTestDoubles.cs | 163 ++
...omeAssistantAttributeSerializationTests.cs | 144 ++
.../HomeAssistantTemperatureUnitTests.cs | 72 +
.../Health/OutletIntegrationTests.cs | 187 +++
.../Health/SafetyAndReservedWorkflowTests.cs | 291 ++++
.../Health/SystemServiceFilteringTests.cs | 116 ++
Admin.NET.Web.Core/Startup.cs | 30 +-
.../Controllers/HomeController.cs | 29 -
Evergreen.Health.Api/Program.cs | 4 +-
Evergreen.Health.Api/Views/Home/Index.cshtml | 7 -
.../Views/Shared/_Layout.cshtml | 11 -
.../Views/_ViewImports.cshtml | 2 -
Evergreen.Health.Api/Views/_ViewStart.cshtml | 3 -
Evergreen.Health.Api/wwwroot/images/logo.png | Bin 8024 -> 0 bytes
.../Configuration/HomeAssistant.json | 3 +
.../Health/Devices/Audio/AudioModule.cs | 61 +
.../Health/Devices/Climate/ClimateModule.cs | 229 +++
.../Health/Devices/Contracts/DeviceCommand.cs | 27 +
.../Health/Devices/Contracts/DeviceValues.cs | 52 +
.../Devices/Contracts/IHealthDeviceModule.cs | 25 +
.../Health/Devices/Curtain/CurtainModule.cs | 76 +
.../Dehumidifier/DehumidifierModule.cs | 52 +
.../Health/Devices/DeviceModuleRegistry.cs | 35 +
.../Discovery/HomeAssistantDeviceCatalog.cs | 335 ++++
.../Devices/Environment/EnvironmentModule.cs | 23 +
.../Environment/IndoorEnvironmentReader.cs | 34 +
.../Devices/Fragrance/FragranceModule.cs | 68 +
.../Health/Devices/HealthDeviceModule.cs | 52 +
.../Devices/HealthDeviceRegistration.cs | 77 +
.../Health/Devices/Lighting/LightingModule.cs | 76 +
.../Health/Devices/Mattress/MattressModule.cs | 120 ++
.../Health/Devices/Outlet/OutletModule.cs | 44 +
.../HomeAssistant/HomeAssistantClient.cs | 55 +-
.../HomeAssistantLightTransitionService.cs | 72 +-
.../HomeAssistant/HomeAssistantModels.cs | 15 +-
.../HomeAssistant/HomeAssistantOptions.cs | 3 +
.../HomeAssistantRegistryService.cs | 6 +-
.../HomeAssistant/IHomeAssistantGateway.cs | 8 +
.../HomeAssistant/ILightTransitionRuntime.cs | 11 +
.../MiAirX/IAudioPlaybackGateway.cs | 21 +
.../Integrations/MiAirX/MiAirXClient.cs | 12 +-
.../MiAirX/TcmMusicTransitionService.cs | 42 +-
.../Health/Integrations/Tuya/ITuyaGateway.cs | 19 +
.../Health/Models/HealthModels.cs | 16 +
.../Health/Models/HomeAssistantModels.cs | 8 +-
.../Health/Policies/BmiFirmnessPolicy.cs | 22 +
.../Health/Policies/RoomTemperaturePolicy.cs | 44 +
.../Health/Scenes/DeviceActionRunner.cs | 160 ++
.../Health/Scenes/DeviceActionSafety.cs | 49 +
.../Health/Scenes/DeviceSceneContext.cs | 34 +
.../Health/Scenes/HealthSceneCoordinator.cs | 152 ++
.../Health/Scenes/LightOffDispatcher.cs | 25 +
.../Services/HealthHomeAssistantService.cs | 1392 ++---------------
.../Services/HealthTcmAssessmentService.cs | 4 +-
.../Health/Services/MattressComfortService.cs | 134 +-
.../Health/Sleep/AnomalyExtension.cs | 26 +
.../Health/Sleep/GuidanceContracts.cs | 31 +
.../Health/Sleep/ReservedSleepWorkflow.cs | 76 +
.../Health/Sleep/SleepContracts.cs | 38 +
63 files changed, 4170 insertions(+), 1518 deletions(-)
create mode 100644 Admin.NET.Test/Health/ClimateAdviceBindingTests.cs
create mode 100644 Admin.NET.Test/Health/ClimateCurtainIntegrationTests.cs
create mode 100644 Admin.NET.Test/Health/DeviceArchitectureTests.cs
create mode 100644 Admin.NET.Test/Health/DeviceTestDoubles.cs
create mode 100644 Admin.NET.Test/Health/HomeAssistantAttributeSerializationTests.cs
create mode 100644 Admin.NET.Test/Health/HomeAssistantTemperatureUnitTests.cs
create mode 100644 Admin.NET.Test/Health/OutletIntegrationTests.cs
create mode 100644 Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs
create mode 100644 Admin.NET.Test/Health/SystemServiceFilteringTests.cs
delete mode 100644 Evergreen.Health.Api/Controllers/HomeController.cs
delete mode 100644 Evergreen.Health.Api/Views/Home/Index.cshtml
delete mode 100644 Evergreen.Health.Api/Views/Shared/_Layout.cshtml
delete mode 100644 Evergreen.Health.Api/Views/_ViewImports.cshtml
delete mode 100644 Evergreen.Health.Api/Views/_ViewStart.cshtml
delete mode 100644 Evergreen.Health.Api/wwwroot/images/logo.png
create mode 100644 Evergreen.Health.Application/Health/Devices/Audio/AudioModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Climate/ClimateModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Contracts/DeviceValues.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Contracts/IHealthDeviceModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Curtain/CurtainModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/DeviceModuleRegistry.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Environment/EnvironmentModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Environment/IndoorEnvironmentReader.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Fragrance/FragranceModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/HealthDeviceModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Mattress/MattressModule.cs
create mode 100644 Evergreen.Health.Application/Health/Devices/Outlet/OutletModule.cs
create mode 100644 Evergreen.Health.Application/Health/Integrations/HomeAssistant/IHomeAssistantGateway.cs
create mode 100644 Evergreen.Health.Application/Health/Integrations/HomeAssistant/ILightTransitionRuntime.cs
create mode 100644 Evergreen.Health.Application/Health/Integrations/MiAirX/IAudioPlaybackGateway.cs
create mode 100644 Evergreen.Health.Application/Health/Integrations/Tuya/ITuyaGateway.cs
create mode 100644 Evergreen.Health.Application/Health/Policies/BmiFirmnessPolicy.cs
create mode 100644 Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs
create mode 100644 Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs
create mode 100644 Evergreen.Health.Application/Health/Scenes/DeviceActionSafety.cs
create mode 100644 Evergreen.Health.Application/Health/Scenes/DeviceSceneContext.cs
create mode 100644 Evergreen.Health.Application/Health/Scenes/HealthSceneCoordinator.cs
create mode 100644 Evergreen.Health.Application/Health/Scenes/LightOffDispatcher.cs
create mode 100644 Evergreen.Health.Application/Health/Sleep/AnomalyExtension.cs
create mode 100644 Evergreen.Health.Application/Health/Sleep/GuidanceContracts.cs
create mode 100644 Evergreen.Health.Application/Health/Sleep/ReservedSleepWorkflow.cs
create mode 100644 Evergreen.Health.Application/Health/Sleep/SleepContracts.cs
diff --git a/Admin.NET.Test/Admin.NET.Test.csproj b/Admin.NET.Test/Admin.NET.Test.csproj
index 819da23..832536f 100644
--- a/Admin.NET.Test/Admin.NET.Test.csproj
+++ b/Admin.NET.Test/Admin.NET.Test.csproj
@@ -18,9 +18,14 @@
+
+ all
+ runtime; build; native; contentfiles; analyzers
+
+
diff --git a/Admin.NET.Test/Health/ClimateAdviceBindingTests.cs b/Admin.NET.Test/Health/ClimateAdviceBindingTests.cs
new file mode 100644
index 0000000..c6cd9cf
--- /dev/null
+++ b/Admin.NET.Test/Health/ClimateAdviceBindingTests.cs
@@ -0,0 +1,42 @@
+using Evergreen.Health.Application.Health.Devices.Environment;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Services;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class ClimateAdviceBindingTests
+{
+ [Theory]
+ [InlineData("", "15", "heat", true)]
+ [InlineData("climate.room", "15", "heat", false)]
+ [InlineData("climate.room", "28", "cool", false)]
+ [InlineData("", "24", "off", false)]
+ public void AdviceUsesBindingNotPowerStateToReportPendingIntegration(
+ string climate, string temperature, string mode, bool pending)
+ {
+ var options = Options.Create(new HomeAssistantOptions
+ {
+ Entities = new() { Climate = climate, Temperature = "sensor.room" }
+ });
+ var gateway = new FakeHaGateway();
+ var states = new Dictionary
+ {
+ ["sensor.room"] = new()
+ {
+ EntityId = "sensor.room", State = temperature,
+ LastReported = DateTimeOffset.UtcNow,
+ Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
+ }
+ };
+ var service = new MattressComfortService(gateway,
+ new IndoorEnvironmentReader(options, TimeProvider.System), options);
+ var output = service.Get(null, states);
+ Assert.Equal(mode, output.ClimateAdvice.Mode);
+ Assert.Equal(pending, output.ClimateAdvice.PendingIntegration);
+ Assert.Null(output.ClimateAdvice.TargetTemperatureCelsius);
+ Assert.Empty(gateway.Calls);
+ }
+}
diff --git a/Admin.NET.Test/Health/ClimateCurtainIntegrationTests.cs b/Admin.NET.Test/Health/ClimateCurtainIntegrationTests.cs
new file mode 100644
index 0000000..9083f0a
--- /dev/null
+++ b/Admin.NET.Test/Health/ClimateCurtainIntegrationTests.cs
@@ -0,0 +1,408 @@
+using Evergreen.Health.Application.Health.Devices;
+using Evergreen.Health.Application.Health.Devices.Climate;
+using Evergreen.Health.Application.Health.Devices.Contracts;
+using Evergreen.Health.Application.Health.Devices.Curtain;
+using Evergreen.Health.Application.Health.Devices.Discovery;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Models;
+using Evergreen.Health.Application.Health.Scenes;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+/// 只使用内存网关,不启动后端、不连接 HA、不触碰实际设备。
+public sealed class ClimateCurtainIntegrationTests
+{
+ private const string ClimateId = "climate.test_room", CurtainId = "cover.test_curtain";
+ private static HomeAssistantOptions Settings() => new() { Entities = new() { Climate = ClimateId, Curtain = CurtainId } };
+ private static DeviceActionRunner Runner(Gateway gateway) => new(gateway, TimeProvider.System,
+ new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 });
+ private static ClimateModule Climate(Gateway gateway) => new(Runner(gateway), Options.Create(Settings()));
+ private static CurtainModule Curtain(Gateway gateway) => new(Runner(gateway), Options.Create(Settings()));
+ private static HomeAssistantState ClimateState(string state = "off", int features = 393,
+ double minimum = 5, double maximum = 35, double step = .5) => new()
+ {
+ EntityId = ClimateId, State = state, Attributes = JsonSerializer.SerializeToElement(new Dictionary
+ {
+ ["hvac_modes"] = new[] { "cool", "heat", "fan_only", "off" }, ["supported_features"] = features,
+ ["temperature"] = 23m, ["current_temperature"] = 24.1m, ["temperature_unit"] = "°C",
+ ["min_temp"] = minimum, ["max_temp"] = maximum, ["target_temp_step"] = step,
+ ["fan_modes"] = new[] { "自动", "低风", "中风", "高风" }, ["fan_mode"] = "低风"
+ })
+ };
+ private static HomeAssistantState CurtainState(string state = "open", int features = 15) => new()
+ {
+ EntityId = CurtainId, State = state,
+ Attributes = JsonSerializer.SerializeToElement(new { supported_features = features, current_position = 40, device_class = "curtain" })
+ };
+ private static PersonalizedSceneConfig Scene(string mode = "heat", int target = 24) => new()
+ {
+ ClimateAdvice = new() { Mode = mode, RecommendedMinimumCelsius = 22, RecommendedMaximumCelsius = 25,
+ TargetTemperatureCelsius = target, Reason = "测试室温建议" }
+ };
+
+ [Theory]
+ [InlineData(5)]
+ [InlineData(23.5)]
+ [InlineData(35)]
+ public async Task TemperatureUsesActualBoundsHalfDegreeAndAttributeReadback(double value)
+ {
+ var gateway = new Gateway(ClimateState());
+ var module = Climate(gateway);
+ await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_temperature", value), default);
+ Assert.Equal("off", gateway.States[ClimateId].State); // 顶层不是温度,但属性读回成功。
+ Assert.Equal(value, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
+ Assert.Equal("set_temperature", Assert.Single(gateway.Calls).Service);
+ }
+
+ [Theory]
+ [InlineData(4.5)]
+ [InlineData(35.5)]
+ [InlineData(23.25)]
+ public async Task InvalidTemperatureNeverSends(double value)
+ {
+ var gateway = new Gateway(ClimateState());
+ var module = Climate(gateway);
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("climate_temperature", value), default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Theory]
+ [InlineData("NaN")]
+ [InlineData("Infinity")]
+ [InlineData("not-a-number")]
+ public void NonfiniteOrMalformedTemperatureIsRejected(string value)
+ => Assert.Throws(() => Climate(new(ClimateState()))
+ .BuildParameterCommand("climate_temperature", value));
+
+ [Fact]
+ public async Task ActualModeAndFanOptionsAreValidatedAndReadBack()
+ {
+ var gateway = new Gateway(ClimateState());
+ var module = Climate(gateway);
+ await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_mode", "cool"), default);
+ await module.ExecuteParameterAsync(module.BuildParameterCommand("climate_fan_mode", "中风"), default);
+ Assert.Equal("cool", gateway.States[ClimateId].State);
+ Assert.Equal("中风", gateway.States[ClimateId].GetStringAttribute("fan_mode"));
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("climate_mode", "auto"), default));
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("climate_fan_mode", "超强风"), default));
+ Assert.Equal(2, gateway.Calls.Count);
+ }
+
+ [Fact]
+ public async Task FreshCapabilityValidationCannotBeBypassedBySendingCommandDirectly()
+ {
+ var gateway = new Gateway(ClimateState());
+ var runner = Runner(gateway);
+ var module = new ClimateModule(runner, Options.Create(Settings()));
+ var command = module.BuildParameterCommand("climate_temperature", 25);
+ gateway.States[ClimateId] = ClimateState(features: 392); // 构造命令后设备失去目标温度能力。
+ await Assert.ThrowsAsync(() => runner.SendAsync(command, default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task MissingTemperatureCapabilitiesAndWrongUnitDoNotUseGuessedDefaults()
+ {
+ var gateway = new Gateway(ClimateState());
+ var module = Climate(gateway);
+ foreach (var attribute in new[] { "min_temp", "max_temp", "target_temp_step", "temperature_unit" })
+ {
+ var attributes = ClimateState().Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
+ attributes.Remove(attribute);
+ gateway.States[ClimateId] = new() { EntityId = ClimateId, State = "off", Attributes = JsonSerializer.SerializeToElement(attributes) };
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("climate_temperature", 24), default));
+ }
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task UnchangedAttributeFailsConfirmationWithoutRepeatedWrites()
+ {
+ var gateway = new Gateway(ClimateState()) { ApplyCommands = false };
+ var module = Climate(gateway);
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("climate_temperature", 25), default));
+ Assert.Single(gateway.Calls);
+ Assert.Equal(4, gateway.Reads); // 发送前能力校验一次,随后三轮只读确认。
+ }
+
+ [Fact]
+ public async Task UnavailableEntityCannotConfirmEvenIfCachedAttributeMatches()
+ {
+ var gateway = new Gateway(ClimateState()) { BecomeUnavailableAfterSend = true };
+ var module = Climate(gateway);
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("climate_temperature", 24), default));
+ Assert.Single(gateway.Calls);
+ }
+
+ [Theory]
+ [InlineData("unavailable")]
+ [InlineData("unknown")]
+ public async Task OfflineClimateDoesNotSendEvenWhenModeIsKnown(string state)
+ {
+ var gateway = new Gateway(ClimateState(state));
+ var module = Climate(gateway);
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("climate_mode", "heat"), default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task SceneUsesPreparedTargetAndConfirmsBothModeAndTemperature()
+ {
+ var gateway = new Gateway(ClimateState());
+ var module = Climate(gateway);
+ var config = Scene();
+ var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
+ await module.ExecuteSleepAsync(context, config, "balanced", default);
+ Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
+ Assert.Equal("heat", gateway.States[ClimateId].State);
+ Assert.Equal(24, gateway.States[ClimateId].GetDoubleAttribute("temperature"));
+ Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, gateway.Calls.Select(command => command.Service));
+ Assert.All(context.Results, result => Assert.Equal("Succeeded", result.Status));
+ }
+
+ [Fact]
+ public async Task SceneDeterministicallyFitsPreparedTargetToActualIntersection()
+ {
+ var gateway = new Gateway(ClimateState(minimum: 24, maximum: 25));
+ var module = Climate(gateway);
+ var config = Scene(target: 22);
+ var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
+ await module.ExecuteSleepAsync(context, config, "balanced", default);
+ Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
+ await module.ExecuteSleepAsync(context, config, "balanced", default);
+ Assert.Equal(24, config.ClimateAdvice.TargetTemperatureCelsius);
+ }
+
+ [Theory]
+ [InlineData("off")]
+ [InlineData("unavailable")]
+ public async Task ComfortableOrUnknownRoomTemperatureLeavesExistingClimateUntouched(string mode)
+ {
+ var gateway = new Gateway(ClimateState("cool"));
+ var module = Climate(gateway);
+ var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
+ await module.ExecuteSleepAsync(context, Scene(mode), "balanced", default);
+ Assert.Empty(gateway.Calls);
+ Assert.Equal("cool", gateway.States[ClimateId].State);
+ Assert.Equal("Skipped", Assert.Single(context.Results).Status);
+ }
+
+ [Fact]
+ public async Task SceneNeverStartsWhenRecommendationAndDeviceHaveNoTemperatureIntersection()
+ {
+ var gateway = new Gateway(ClimateState(minimum: 30));
+ var module = Climate(gateway);
+ var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
+ await Assert.ThrowsAsync(() => module.ExecuteSleepAsync(context, Scene(), "balanced", default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task UnsupportedSuggestedModeIsFailureNotFakeSuccessfulScene()
+ {
+ var gateway = new Gateway(ClimateState());
+ var attributes = gateway.States[ClimateId].Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
+ attributes["hvac_modes"] = new[] { "cool", "off" };
+ gateway.States[ClimateId].Attributes = JsonSerializer.SerializeToElement(attributes);
+ var runner = Runner(gateway);
+ var coordinator = new HealthSceneCoordinator(new([Climate(gateway)]), runner, gateway, new FakeLight(), new FakeMusic());
+ var result = await coordinator.ExecuteAsync("sleep", Scene(), "balanced", default);
+ Assert.Equal("Failed", result.Status);
+ Assert.Contains(result.Actions, action => action.Status == "Failed" && action.Message.Contains("不支持"));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task SceneStopsBeforeTemperatureWriteIfModeWasNotConfirmed()
+ {
+ var gateway = new Gateway(ClimateState()) { ApplyCommands = false };
+ var module = Climate(gateway);
+ var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
+ await module.ExecuteSleepAsync(context, Scene(), "balanced", default);
+ Assert.Equal("set_hvac_mode", Assert.Single(gateway.Calls).Service);
+ Assert.Equal("Failed", Assert.Single(context.Results).Status);
+ }
+
+ [Fact]
+ public async Task AllOffClosesClimateButDoesNotMoveCurtain()
+ {
+ var gateway = new Gateway(ClimateState("heat"), CurtainState());
+ var runner = Runner(gateway);
+ var modules = new DeviceModuleRegistry([Climate(gateway), Curtain(gateway)]);
+ var coordinator = new HealthSceneCoordinator(modules, runner, gateway, new FakeLight(), new FakeMusic());
+ var output = await coordinator.ExecuteAsync("all-off", null, "", default);
+ Assert.Equal("Succeeded", output.Status);
+ Assert.Equal("off", gateway.States[ClimateId].State);
+ Assert.Equal(ClimateId, Assert.Single(gateway.Calls).EntityId);
+ Assert.Equal("turn_off", gateway.Calls[0].Service);
+ }
+
+ [Fact]
+ public async Task UnsupportedPowerFeaturesRejectManualPowerCommands()
+ {
+ var gateway = new Gateway(ClimateState(features: 1));
+ var module = Climate(gateway);
+ await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default));
+ await Assert.ThrowsAsync(() => module.SetPowerAsync(false, default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Theory]
+ [InlineData("open", "open_cover")]
+ [InlineData("stop", "stop_cover")]
+ [InlineData("close", "close_cover")]
+ public async Task CurtainSendsOneSupportedMotionCommandWithoutClaimingArrival(string action, string service)
+ {
+ var gateway = new Gateway(CurtainState());
+ var module = Curtain(gateway);
+ var command = module.BuildParameterCommand("curtain_action", action);
+ await module.ExecuteParameterAsync(command, default);
+ Assert.Null(command.ExpectedState);
+ Assert.Equal(service, Assert.Single(gateway.Calls).Service);
+ Assert.Equal(1, gateway.Reads);
+ }
+
+ [Fact]
+ public async Task CurtainPositionIsIntegerPercentAndRequiresPositionFeature()
+ {
+ var gateway = new Gateway(CurtainState());
+ var module = Curtain(gateway);
+ await module.ExecuteParameterAsync(module.BuildParameterCommand("curtain_position", 0), default);
+ await module.ExecuteParameterAsync(module.BuildParameterCommand("curtain_position", 100), default);
+ Assert.Equal(new[] { 0, 100 }, gateway.Calls.Select(command => (int)command.Data!["position"]!));
+ Assert.Throws(() => module.BuildParameterCommand("curtain_position", 100.5));
+ gateway.States[CurtainId] = CurtainState(features: 11);
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("curtain_position", 50), default));
+ Assert.Equal(2, gateway.Calls.Count);
+ }
+
+ [Theory]
+ [InlineData("unavailable")]
+ [InlineData("unknown")]
+ public async Task OfflineCurtainRejectsCommandsAndCannotPretendOpen(string state)
+ {
+ var gateway = new Gateway(CurtainState(state));
+ var module = Curtain(gateway);
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("curtain_action", "open"), default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task CurtainStopIsDisabledWhenDeviceOnlySupportsOpenAndClose()
+ {
+ var gateway = new Gateway(CurtainState(features: 3));
+ var module = Curtain(gateway);
+ await Assert.ThrowsAsync(() => module.ExecuteParameterAsync(
+ module.BuildParameterCommand("curtain_action", "stop"), default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task CurtainHasNoPowerAndNoAutomaticSceneMotion()
+ {
+ var gateway = new Gateway(CurtainState());
+ var module = Curtain(gateway);
+ await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default));
+ var context = new DeviceSceneContext(Runner(gateway), gateway.States, []);
+ await module.ExecuteSleepAsync(context, Scene(), "balanced", default);
+ Assert.Equal("Skipped", Assert.Single(context.Results).Status);
+ Assert.Empty(module.GetShutdownCommands());
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task UnconfiguredModulesRemainSkippedAndAbsentFromFallbackDevices()
+ {
+ var gateway = new Gateway();
+ var runner = Runner(gateway);
+ var options = Options.Create(new HomeAssistantOptions());
+ var climate = new ClimateModule(runner, options);
+ var curtain = new CurtainModule(runner, options);
+ var context = new DeviceSceneContext(runner, gateway.States, []);
+ await climate.ExecuteSleepAsync(context, Scene(), "balanced", default);
+ await curtain.ExecuteSleepAsync(context, Scene(), "balanced", default);
+ Assert.All(context.Results, result => Assert.Equal("Skipped", result.Status));
+ Assert.Empty(climate.GetShutdownCommands());
+ Assert.Empty(new HomeAssistantDeviceCatalog(options, new([climate, curtain])).BuildDiscoveredDevices(null, gateway.States));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public void RegistryBindsEachDeviceAndSafeAttributesExposeActualCapabilities()
+ {
+ var gateway = new Gateway(ClimateState(), CurtainState("unavailable"));
+ var options = Options.Create(Settings());
+ var catalog = new HomeAssistantDeviceCatalog(options, new([Climate(gateway), Curtain(gateway)]));
+ var registry = new HomeAssistantRegistries
+ {
+ Devices = [new() { Id = "ac", Name = "空调" }, new() { Id = "curtain", Name = "窗帘" }],
+ Entities = [new() { EntityId = ClimateId, DeviceId = "ac" }, new() { EntityId = CurtainId, DeviceId = "curtain" }]
+ };
+ var devices = catalog.BuildDiscoveredDevices(registry, gateway.States);
+ Assert.Equal("climate", devices[0].ModuleKey);
+ Assert.Equal(ClimateId, devices[0].PrimaryControlEntityId);
+ Assert.Equal("curtain", devices[1].ModuleKey);
+ Assert.Equal(CurtainId, devices[1].PrimaryControlEntityId);
+ Assert.False(devices[1].Online);
+ Assert.Contains("target_temp_step", devices[0].Entities[0].Attributes!.Keys);
+ Assert.Contains("current_temperature", devices[0].Entities[0].Attributes!.Keys);
+ Assert.Contains("fan_modes", devices[0].Entities[0].Attributes!.Keys);
+ Assert.Contains("current_position", devices[1].Entities[0].Attributes!.Keys);
+ }
+
+ [Fact]
+ public async Task CancellationBeforeSendPreventsEveryNewDeviceWrite()
+ {
+ var gateway = new Gateway(ClimateState(), CurtainState());
+ using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
+ var climate = Climate(gateway);
+ var curtain = Curtain(gateway);
+ await Assert.ThrowsAnyAsync(() => climate.ExecuteParameterAsync(
+ climate.BuildParameterCommand("climate_mode", "heat"), cancellation.Token));
+ await Assert.ThrowsAnyAsync(() => curtain.ExecuteParameterAsync(
+ curtain.BuildParameterCommand("curtain_action", "open"), cancellation.Token));
+ Assert.Empty(gateway.Calls);
+ }
+
+ private sealed class Gateway(params HomeAssistantState[] states) : IHomeAssistantGateway
+ {
+ public Dictionary States { get; } = states.ToDictionary(state => state.EntityId, StringComparer.OrdinalIgnoreCase);
+ public List Calls { get; } = [];
+ public int Reads { get; private set; }
+ public bool ApplyCommands { get; set; } = true;
+ public bool BecomeUnavailableAfterSend { get; set; }
+ public Task> GetStatesAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested(); Reads++;
+ return Task.FromResult>(States.Values.ToArray());
+ }
+ public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var entityId = (string)data["entity_id"]!;
+ Calls.Add(new(service, entityId, domain, service, new Dictionary(data)));
+ if (!ApplyCommands) return Task.CompletedTask;
+ var previous = States[entityId];
+ var attributes = previous.Attributes.EnumerateObject().ToDictionary(item => item.Name, item => (object?)item.Value.Clone());
+ var current = service switch { "set_hvac_mode" => (string)data["hvac_mode"]!, "turn_on" => "heat", "turn_off" => "off", _ => previous.State };
+ if (data.TryGetValue("temperature", out var temperature)) attributes["temperature"] = temperature;
+ if (data.TryGetValue("fan_mode", out var fan)) attributes["fan_mode"] = fan;
+ States[entityId] = new() { EntityId = entityId, State = BecomeUnavailableAfterSend ? "unavailable" : current,
+ Attributes = JsonSerializer.SerializeToElement(attributes) };
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Admin.NET.Test/Health/DeviceArchitectureTests.cs b/Admin.NET.Test/Health/DeviceArchitectureTests.cs
new file mode 100644
index 0000000..d822712
--- /dev/null
+++ b/Admin.NET.Test/Health/DeviceArchitectureTests.cs
@@ -0,0 +1,310 @@
+using Evergreen.Health.Application.Health.Devices;
+using Evergreen.Health.Application.Health.Devices.Audio;
+using Evergreen.Health.Application.Health.Devices.Contracts;
+using Evergreen.Health.Application.Health.Devices.Discovery;
+using Evergreen.Health.Application.Health.Devices.Environment;
+using Evergreen.Health.Application.Health.Devices.Lighting;
+using Evergreen.Health.Application.Health.Devices.Mattress;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Integrations.MiAirX;
+using Evergreen.Health.Application.Health.Models;
+using Evergreen.Health.Application.Health.Policies;
+using Evergreen.Health.Application.Health.Scenes;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class DeviceArchitectureTests
+{
+ private static DeviceActionRunner Runner(FakeHaGateway gateway)
+ => new(gateway, TimeProvider.System, new DeviceExecutionOptions { VerificationDelay = TimeSpan.Zero });
+ private static HomeAssistantOptions Configuration() => new()
+ {
+ Entities = new()
+ {
+ AmbientLight = "light.bedroom", Speaker = "media_player.speaker", SpeakerSleepMode = "switch.sleep",
+ Temperature = "sensor.room_temperature", MattressLeftMode = "select.left_mode", MattressRightMode = "select.right_mode",
+ MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat",
+ MattressLeftMassagePart = "select.left_massage", MattressRightMassagePart = "select.right_massage",
+ MattressMassageLevel = "select.massage_level", MattressFirmness = new() { ["left_shoulder"] = "select.left_shoulder" }
+ }, SleepScene = new() { EnableMattress = true }
+ };
+ private static HomeAssistantState Temperature(double value, DateTimeOffset? observed) => new()
+ {
+ EntityId = "sensor.room_temperature", State = value.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ LastReported = observed, Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" })
+ };
+
+ [Theory]
+ [InlineData(18.4, "很软")]
+ [InlineData(18.5, "稍软")]
+ [InlineData(24.0, "稍硬")]
+ [InlineData(28.0, "很硬")]
+ [InlineData(31.1, "很硬")]
+ [InlineData(32.0, "最硬")]
+ public void BmiUsesStableFirmnessBoundaries(double bmi, string expected)
+ => Assert.Equal(expected, BmiFirmnessPolicy.Firmness((decimal)bmi));
+
+ [Fact]
+ public void MissingOrInvalidBodyDataNeverProducesFirmness()
+ {
+ Assert.Null(BmiFirmnessPolicy.Calculate(null, 80));
+ Assert.Null(BmiFirmnessPolicy.Calculate(0, 80));
+ Assert.Null(BmiFirmnessPolicy.Firmness(null));
+ Assert.Equal(31.1m, BmiFirmnessPolicy.Calculate(180, 100.8m));
+ }
+
+ [Theory]
+ [InlineData(21.9, "heat")]
+ [InlineData(22.0, "off")]
+ [InlineData(23.5, "off")]
+ [InlineData(25.0, "off")]
+ [InlineData(25.1, "cool")]
+ public void RoomRangeIncludesBothBoundaries(double temperature, string expected)
+ {
+ var advice = RoomTemperaturePolicy.Evaluate(new(temperature, DateTimeOffset.UtcNow, true, ""), new(22, 25));
+ Assert.Equal(expected, advice.Mode);
+ Assert.Null(advice.TargetTemperatureCelsius);
+ RoomTemperaturePolicy.PickTargetForSession(advice);
+ if (expected is "heat" or "cool")
+ Assert.InRange(advice.TargetTemperatureCelsius!.Value, 22, 25);
+ else Assert.Null(advice.TargetTemperatureCelsius);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(31)]
+ public void MissingOrStaleIndoorTimestampIsNotUsable(int ageMinutes)
+ {
+ var reader = new IndoorEnvironmentReader(Options.Create(Configuration()), TimeProvider.System);
+ var state = Temperature(10, ageMinutes == 0 ? null : DateTimeOffset.UtcNow.AddMinutes(-ageMinutes));
+ var sample = reader.Read(new Dictionary { [state.EntityId] = state });
+ Assert.False(sample.Available);
+ Assert.Equal("unavailable", RoomTemperaturePolicy.Evaluate(sample, new(22, 25)).Mode);
+ }
+
+ [Fact]
+ public void FreshIndoorDataAndConstitutionRangeAreUsed()
+ {
+ var reader = new IndoorEnvironmentReader(Options.Create(Configuration()), TimeProvider.System);
+ var state = Temperature(23, DateTimeOffset.UtcNow);
+ var sample = reader.Read(new Dictionary { [state.EntityId] = state });
+ Assert.True(sample.Available);
+ Assert.Equal("heat", RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution("yang_deficient")).Mode);
+ Assert.Equal("off", RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution("balanced")).Mode);
+ }
+
+ [Fact]
+ public async Task NewDeviceWorksThroughRegistryAndSceneWithoutCoreBranches()
+ {
+ var gateway = new FakeHaGateway();
+ gateway.States["number.synthetic"] = new() { EntityId = "number.synthetic", State = "0" };
+ var runner = Runner(gateway);
+ var module = new SyntheticDeviceModule(runner);
+ var registry = new DeviceModuleRegistry([module]);
+ Assert.Same(module, registry.ForParameter("synthetic_value"));
+ await registry.ForParameter("synthetic_value").ExecuteParameterAsync(module.BuildParameterCommand("synthetic_value", 4), default);
+ var coordinator = new HealthSceneCoordinator(registry, runner, gateway, new FakeLight(), new FakeMusic());
+ var output = await coordinator.ExecuteAsync("sleep", new(), "balanced", default);
+ Assert.Equal("Succeeded", output.Status);
+ Assert.Contains(gateway.Calls, command => command.EntityId == "number.synthetic" && (int)command.Data!["value"]! == 3);
+ }
+
+ [Fact]
+ public void DuplicateParameterRegistrationsFailEarly()
+ {
+ var runner = Runner(new());
+ Assert.Throws(() => new DeviceModuleRegistry(
+ [new SyntheticDeviceModule(runner), new SyntheticDeviceModule(runner, key: "other")]));
+ }
+
+ [Fact]
+ public void DeviceParametersValidateRangesAndTrustedEntity()
+ {
+ var runner = Runner(new());
+ var light = new LightingModule(runner, Options.Create(Configuration()), new FakeLight(), new LightOffDispatcher([]));
+ Assert.Throws(() => light.BuildParameterCommand("color_temp", 6500));
+ Assert.Equal("light.bedroom", light.BuildParameterCommand("brightness", 0).EntityId);
+ Assert.Equal("turn_off", light.BuildParameterCommand("brightness", 0).Service);
+ var mattress = new MattressModule(runner, Options.Create(Configuration()));
+ Assert.Equal("全身", mattress.BuildParameterCommand("mattress_massage_right_part", "全身").Data!["option"]);
+ Assert.Equal("轻柔", mattress.BuildParameterCommand("mattress_massage_level", "轻柔").Data!["option"]);
+ Assert.Throws(() => mattress.BuildParameterCommand("mattress_right_mode", "随意模式"));
+ }
+
+ [Fact]
+ public void DiscoveryAcceptsOtherBrandsAndFiltersDisabledEntities()
+ {
+ var gateway = new FakeHaGateway();
+ var registry = new DeviceModuleRegistry([]);
+ var catalog = new HomeAssistantDeviceCatalog(Options.Create(Configuration()), registry);
+ var haRegistry = new HomeAssistantRegistries
+ {
+ Devices = [new() { Id = "tuya-device", Name = "窗帘", Manufacturer = "Tuya" }],
+ Entities =
+ [
+ new() { DeviceId = "tuya-device", EntityId = "cover.curtain", Platform = "tuya" },
+ new() { DeviceId = "tuya-device", EntityId = "switch.disabled", DisabledBy = "user" }
+ ]
+ };
+ var devices = catalog.BuildDiscoveredDevices(haRegistry, new Dictionary
+ {
+ ["cover.curtain"] = new() { EntityId = "cover.curtain", State = "open" }
+ });
+ Assert.Single(devices);
+ Assert.Equal("Curtain", devices[0].Category);
+ Assert.True(devices[0].Online);
+ Assert.Single(devices[0].Entities);
+ }
+
+ [Fact]
+ public void FallbackOnlyContainsConfiguredModulesAndIncludesMattressEntities()
+ {
+ var runner = Runner(new());
+ var options = Options.Create(Configuration());
+ var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)]));
+ var device = Assert.Single(catalog.BuildDiscoveredDevices(null, new Dictionary()));
+ Assert.Equal("mattress", device.ModuleKey);
+ Assert.Contains(device.Entities, entity => entity.EntityId == "select.right_massage");
+ Assert.Null(device.PrimaryControlEntityId);
+ }
+
+ [Fact]
+ public async Task ShutdownContinuesAfterAnIndividualDeviceFailure()
+ {
+ var gateway = new FakeHaGateway { FailEntity = "switch.bad" };
+ var runner = Runner(gateway);
+ var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner, twoOff: true)]),
+ runner, gateway, new FakeLight(), new FakeMusic());
+ var output = await coordinator.ExecuteAsync("all-off", null, "", default);
+ Assert.Equal("Partial", output.Status);
+ Assert.Contains(output.Actions, action => action.EntityId == "switch.bad" && action.Status == "Failed");
+ Assert.Contains(output.Actions, action => action.EntityId == "switch.synthetic" && action.Status == "Succeeded");
+ }
+
+ [Fact]
+ public async Task ShutdownStillSendsWhenInitialStateReadFails()
+ {
+ var gateway = new FakeHaGateway { ReadsToFail = 1 };
+ var runner = Runner(gateway);
+ var music = new FakeMusic();
+ var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner)]),
+ runner, gateway, new FakeLight(), music);
+ var output = await coordinator.ExecuteAsync("all-off", null, "", default);
+ Assert.True(music.StopCount > 0);
+ Assert.Contains(gateway.Calls, command => command.EntityId == "switch.synthetic" && command.Service == "turn_off");
+ Assert.Contains(output.Actions, action => action.EntityId == "switch.synthetic" && action.Status == "Succeeded");
+ }
+
+ [Fact]
+ public async Task AllOffCancelsOldStartBeforeItCanSendDelayedCommands()
+ {
+ var gateway = new FakeHaGateway();
+ var runner = Runner(gateway);
+ var module = new SyntheticDeviceModule(runner, block: true);
+ var coordinator = new HealthSceneCoordinator(new([module]), runner, gateway, new FakeLight(), new FakeMusic());
+ var start = coordinator.ExecuteAsync("sleep", new(), "balanced", default);
+ await module.Started.Task.WaitAsync(TimeSpan.FromSeconds(3));
+ var off = await coordinator.ExecuteAsync("all-off", null, "", default);
+ Assert.Equal("Cancelled", (await start).Status);
+ Assert.False(module.ContinuedAfterWait);
+ Assert.Equal("Succeeded", off.Status);
+ Assert.DoesNotContain(gateway.Calls, command => command.Service == "set_value");
+ }
+
+ [Fact]
+ public async Task ManualLightOffUsesSceneLinkageToStopBothMassageSides()
+ {
+ var gateway = new FakeHaGateway();
+ var runner = Runner(gateway);
+ var options = Options.Create(Configuration());
+ var mattress = new MattressModule(runner, options);
+ var light = new LightingModule(runner, options, new FakeLight(), new LightOffDispatcher([mattress]));
+ await light.ExecuteParameterAsync(light.BuildParameterCommand("brightness", 0), default);
+ Assert.Equal("off", gateway.States["light.bedroom"].State);
+ Assert.Equal("无", gateway.States["select.right_massage"].State);
+ Assert.Equal("无", gateway.States["select.left_massage"].State);
+ }
+
+ [Fact]
+ public void UnknownButtonIsUsableButUnknownSensorIsNot()
+ {
+ Assert.True(DeviceActionRunner.IsAvailable(new() { EntityId = "button.release", State = "unknown" }));
+ Assert.False(DeviceActionRunner.IsAvailable(new() { EntityId = "sensor.temperature", State = "unknown" }));
+ Assert.Null(new HomeAssistantState().GetDoubleAttribute("brightness"));
+ }
+
+ [Fact]
+ public void ModuleRegistrationResolvesWithoutCircularOrScopedDependencies()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging(); services.AddOptions(); services.AddHttpClient();
+ services.AddHealthDevices();
+ using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
+ Assert.NotNull(provider.GetRequiredService());
+ Assert.Equal(2, provider.GetServices().Count());
+ Assert.Contains(provider.GetRequiredService().Modules, module => module.Key == "mattress");
+ }
+
+ [Fact]
+ public async Task ShortPlaylistInheritsFadeVolumeAndStopsAtZero()
+ {
+ var time = new ManualTestTimeProvider();
+ var audio = new FakeAudioGateway();
+ var options = Options.Create(new TcmMusicOptions
+ {
+ StartVolume = 70, FadeStepSeconds = 1, // 本版固定规则不能被历史设置覆盖。
+ Tracks = new() { ["balanced"] =
+ [
+ new() { Title = "A", Url = "https://example.invalid/a.mp3", DurationSeconds = 20 },
+ new() { Title = "B", Url = "https://example.invalid/b.mp3", DurationSeconds = 20 }
+ ] }
+ });
+ var music = new TcmMusicTransitionService(audio, options, NullLogger.Instance, time);
+ await music.StartForConstitutionAsync("balanced", default);
+ Assert.Equal(50, music.GetStatus().VolumePercent);
+ for (var step = 1; step <= 5; step++)
+ {
+ time.Advance(TimeSpan.FromSeconds(5));
+ var expected = 50 - step;
+ await WaitUntilAsync(() => music.GetStatus().VolumePercent == expected);
+ }
+ Assert.Equal(2, audio.Tracks.Count);
+ Assert.Equal(new[] { 50, 49, 48, 47, 46, 45 }, audio.Volumes.ToArray());
+ time.Advance(TimeSpan.FromSeconds(225));
+ await WaitUntilAsync(() => !music.GetStatus().Running);
+ Assert.Equal(0, music.GetStatus().VolumePercent);
+ Assert.True(audio.Stops > 0);
+ await music.StopMusicAsync(default);
+ }
+
+ [Fact]
+ public async Task ManualVolumeTakesOwnershipAndEveryRestartBeginsAtFifty()
+ {
+ var time = new ManualTestTimeProvider();
+ var audio = new FakeAudioGateway();
+ var music = new TcmMusicTransitionService(audio, Options.Create(new TcmMusicOptions
+ {
+ Tracks = new() { ["balanced"] = [new() { Url = "https://example.invalid/a.mp3", DurationSeconds = 20 }] }
+ }), NullLogger.Instance, time);
+ await music.StartForConstitutionAsync("balanced", default);
+ await music.SetManualVolumeAsync(30, default);
+ time.Advance(TimeSpan.FromSeconds(50));
+ Assert.False(music.GetStatus().Running);
+ Assert.Equal(30, music.GetStatus().VolumePercent);
+ await music.StartForConstitutionAsync("balanced", default);
+ Assert.Equal(50, music.GetStatus().VolumePercent);
+ await music.StopMusicAsync(default);
+ }
+
+ private static async Task WaitUntilAsync(Func predicate)
+ {
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
+ while (!predicate()) await Task.Delay(5, timeout.Token);
+ }
+}
diff --git a/Admin.NET.Test/Health/DeviceTestDoubles.cs b/Admin.NET.Test/Health/DeviceTestDoubles.cs
new file mode 100644
index 0000000..2a810c7
--- /dev/null
+++ b/Admin.NET.Test/Health/DeviceTestDoubles.cs
@@ -0,0 +1,163 @@
+using Evergreen.Health.Application.Health.Devices.Contracts;
+using Evergreen.Health.Application.Health.Devices;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Integrations.MiAirX;
+using Evergreen.Health.Application.Health.Models;
+using Evergreen.Health.Application.Health.Scenes;
+using System.Collections.Concurrent;
+using System.Text.Json;
+
+namespace Admin.NET.Test.Health;
+
+internal sealed class FakeHaGateway : IHomeAssistantGateway
+{
+ public ConcurrentDictionary States { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public ConcurrentQueue Calls { get; } = new();
+ public string? FailEntity { get; set; }
+ public int ReadsToFail { get; set; }
+ public TimeProvider Time { get; set; } = TimeProvider.System;
+
+ public Task> GetStatesAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (ReadsToFail > 0) { ReadsToFail--; throw new IOException("模拟状态读取失败"); }
+ return Task.FromResult>(States.Values.ToArray());
+ }
+
+ public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var entity = (string)data["entity_id"]!;
+ Calls.Enqueue(new(service, entity, domain, service, new Dictionary(data)));
+ if (entity == FailEntity) throw new IOException("模拟设备命令失败");
+ var state = service switch
+ {
+ "turn_on" => "on", "turn_off" => "off",
+ "select_option" => data["option"]!.ToString()!,
+ "set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown"
+ };
+ var attributes = new Dictionary();
+ if (States.TryGetValue(entity, out var previous) && previous.Attributes.ValueKind == JsonValueKind.Object)
+ foreach (var property in previous.Attributes.EnumerateObject()) attributes[property.Name] = property.Value.Clone();
+ if (data.TryGetValue("brightness_pct", out var brightness))
+ attributes["brightness"] = Convert.ToDouble(brightness) / 100 * 255;
+ States[entity] = new() { EntityId = entity, State = state, LastReported = Time.GetUtcNow(), LastUpdated = Time.GetUtcNow(),
+ Attributes = JsonSerializer.SerializeToElement(attributes) };
+ return Task.CompletedTask;
+ }
+}
+
+internal sealed class FakeMusic : ITcmMusicRuntime
+{
+ public int StopCount { get; private set; }
+ public TcmMusicTransitionOutput GetStatus() => new();
+ public bool HasPlayableTracks(string code) => true;
+ public Task StartForConstitutionAsync(string code, CancellationToken ct) => Task.CompletedTask;
+ public Task PlayTestMusicAsync(CancellationToken ct) => Task.CompletedTask;
+ public Task SetManualVolumeAsync(int volume, CancellationToken ct) => Task.CompletedTask;
+ public Task StopMusicAsync(CancellationToken ct) { StopCount++; return Task.CompletedTask; }
+}
+
+internal sealed class FakeLight : ILightTransitionRuntime
+{
+ public HomeAssistantLightTransitionOutput GetStatus() => new();
+ public Task StartSleepAsync(int kelvin, CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
+ public Task StartWakeAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
+ public Task CancelAsync(CancellationToken ct) => Task.FromResult(new HomeAssistantLightTransitionOutput());
+}
+
+internal sealed class SyntheticDeviceModule : HealthDeviceModule
+{
+ private readonly bool _block;
+ private readonly bool _twoOff;
+ private readonly string _key;
+ public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public bool ContinuedAfterWait { get; private set; }
+ public SyntheticDeviceModule(DeviceActionRunner runner, bool block = false, bool twoOff = false, string key = "synthetic")
+ : base(runner) => (_block, _twoOff, _key) = (block, twoOff, key);
+ public override string Key => _key;
+ public override string Category => nameof(HealthDeviceCategory.Other);
+ public override string DisplayName => "测试新增家具";
+ public override string PrimaryEntityId => "switch.synthetic";
+ public override IReadOnlyList Parameters =>
+ [DeviceValues.Number("synthetic_value", "number.synthetic", 0, 5, "测试参数")];
+ public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
+ string constitutionCode, CancellationToken ct)
+ {
+ Started.TrySetResult();
+ if (_block) await Task.Delay(Timeout.InfiniteTimeSpan, ct);
+ ContinuedAfterWait = true;
+ await context.RunAsync(BuildParameterCommand("synthetic_value", 3), ct);
+ }
+ public override IEnumerable GetShutdownCommands()
+ {
+ if (_twoOff) yield return new("失败设备关闭", "switch.bad", "switch", "turn_off", ExpectedState: "off");
+ yield return new("测试家具关闭", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off");
+ }
+}
+
+internal sealed class FakeAudioGateway : IAudioPlaybackGateway
+{
+ public bool IsConfigured => true;
+ public ConcurrentQueue Volumes { get; } = new();
+ public ConcurrentQueue Tracks { get; } = new();
+ public int Stops;
+ public Task PlayAsync(CancellationToken ct = default) => Task.CompletedTask;
+ public Task PlayUrlAsync(string url, CancellationToken ct = default) { Tracks.Enqueue(url); return Task.CompletedTask; }
+ public Task StopAsync(CancellationToken ct = default) { Interlocked.Increment(ref Stops); return Task.CompletedTask; }
+ public Task SetVolumeAsync(int volume, CancellationToken ct = default) { Volumes.Enqueue(volume); return Task.CompletedTask; }
+}
+
+/// 只推进测试时间,不等待真实 250 秒,也不依赖额外测试包。
+internal sealed class ManualTestTimeProvider : TimeProvider
+{
+ private readonly object _lock = new();
+ private readonly List _timers = new();
+ private readonly DateTimeOffset _origin = DateTimeOffset.UtcNow;
+ private long _ticks;
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+ public override long GetTimestamp() { lock (_lock) return _ticks; }
+ public override DateTimeOffset GetUtcNow() { lock (_lock) return _origin.AddTicks(_ticks); }
+
+ public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
+ {
+ var timer = new ManualTimer(this, callback, state);
+ lock (_lock) { _timers.Add(timer); timer.Change(dueTime, period); }
+ return timer;
+ }
+ public void Advance(TimeSpan duration)
+ {
+ List due;
+ lock (_lock)
+ {
+ _ticks += duration.Ticks;
+ due = _timers.Where(timer => !timer.Disposed && timer.Due <= _ticks).ToList();
+ foreach (var timer in due) timer.Due = timer.Period > 0 ? _ticks + timer.Period : long.MaxValue;
+ }
+ foreach (var timer in due) timer.Callback(timer.State);
+ }
+ private sealed class ManualTimer : ITimer
+ {
+ private readonly ManualTestTimeProvider _owner;
+ public TimerCallback Callback { get; }
+ public object? State { get; }
+ public bool Disposed;
+ public long Due;
+ public long Period;
+ public ManualTimer(ManualTestTimeProvider owner, TimerCallback callback, object? state)
+ => (_owner, Callback, State) = (owner, callback, state);
+ public bool Change(TimeSpan dueTime, TimeSpan period)
+ {
+ lock (_owner._lock)
+ {
+ if (Disposed) return false;
+ Due = dueTime == Timeout.InfiniteTimeSpan ? long.MaxValue : _owner._ticks + dueTime.Ticks;
+ Period = period == Timeout.InfiniteTimeSpan ? 0 : period.Ticks;
+ return true;
+ }
+ }
+ public void Dispose() { lock (_owner._lock) { Disposed = true; _owner._timers.Remove(this); } }
+ public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; }
+ }
+}
diff --git a/Admin.NET.Test/Health/HomeAssistantAttributeSerializationTests.cs b/Admin.NET.Test/Health/HomeAssistantAttributeSerializationTests.cs
new file mode 100644
index 0000000..856aa7f
--- /dev/null
+++ b/Admin.NET.Test/Health/HomeAssistantAttributeSerializationTests.cs
@@ -0,0 +1,144 @@
+using Evergreen.Health.Application.Health.Devices;
+using Evergreen.Health.Application.Health.Devices.Climate;
+using Evergreen.Health.Application.Health.Devices.Discovery;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Models;
+using Evergreen.Health.Application.Health.Scenes;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Newtonsoft.Json.Serialization;
+using System.Text.Json;
+using Xunit;
+using SystemJson = System.Text.Json.JsonSerializer;
+
+namespace Admin.NET.Test.Health;
+
+/// 检查 HA JSON 到 API JSON 的完整转换,不连接 HA、不发送家具指令。
+public sealed class HomeAssistantAttributeSerializationTests
+{
+ private const string ClimateId = "climate.test_room";
+ private static HomeAssistantState State(string state = "off") => new()
+ {
+ EntityId = ClimateId,
+ State = state,
+ Attributes = SystemJson.SerializeToElement(new
+ {
+ hvac_modes = new[] { "cool", "heat", "fan_only", "off" },
+ fan_modes = new[] { "自动", "低风", "中风", "高风" },
+ swing_modes = new[] { "off", "vertical" },
+ options = new[] { "智能适配", "轻柔", "全身" },
+ current_temperature = 24.9,
+ temperature = 23,
+ temperature_unit = "°C",
+ min_temp = 5,
+ max_temp = 35,
+ target_temp_step = 0.5,
+ supported_features = 393,
+ fan_mode = "低风"
+ })
+ };
+
+ private static string SerializeApi(object value) => JsonConvert.SerializeObject(value, new JsonSerializerSettings
+ {
+ ContractResolver = new CamelCasePropertyNamesContractResolver(),
+ DateFormatString = "yyyy-MM-dd HH:mm:ss"
+ });
+
+ [Theory]
+ [InlineData("hvac_modes")]
+ [InlineData("fan_modes")]
+ [InlineData("swing_modes")]
+ [InlineData("options")]
+ public void ApiSerializesChoiceArraysAsStringsNotJsonElementMetadata(string key)
+ {
+ var state = State();
+ var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)!;
+ var json = JObject.Parse(SerializeApi(safe));
+ var values = Assert.IsType(json[key]);
+ Assert.All(values, value => Assert.Equal(JTokenType.String, value.Type));
+ Assert.Equal(state.Attributes.GetProperty(key).EnumerateArray().Select(value => value.GetString()),
+ values.Select(value => value.Value()));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void DiscoveredClimateAndParameterReadingsKeepCapabilitiesAcrossApiSerialization(bool useSystemJson)
+ {
+ var state = State();
+ var states = new Dictionary { [ClimateId] = state };
+ var options = Options.Create(new HomeAssistantOptions { Entities = new() { Climate = ClimateId } });
+ var module = new ClimateModule(new DeviceActionRunner(new NoWritesGateway(), TimeProvider.System, new()), options);
+ var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([module]));
+ var devices = catalog.BuildDiscoveredDevices(new HomeAssistantRegistries
+ {
+ Devices = [new() { Id = "ac", Name = "空调" }],
+ Entities = [new() { EntityId = ClimateId, DeviceId = "ac" }]
+ }, states);
+ var status = new HomeAssistantStatusOutput
+ {
+ Connected = true,
+ Devices = devices,
+ ControlParameters = module.Parameters.ToDictionary(parameter => parameter.Key, parameter =>
+ new HomeAssistantParameterStateOutput
+ {
+ EntityId = parameter.EntityId,
+ State = state.State,
+ Available = true,
+ Attributes = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)
+ })
+ };
+ var wire = JObject.Parse(useSystemJson
+ ? SystemJson.Serialize(status, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })
+ : SerializeApi(status));
+ var device = wire["devices"]![0]!;
+ Assert.Equal("climate", device["moduleKey"]!.Value());
+ Assert.True(device["online"]!.Value());
+ Assert.Equal(ClimateId, device["primaryControlEntityId"]!.Value());
+ var entity = device["entities"]![0]!;
+ Assert.True(entity["available"]!.Value());
+ Assert.Equal("off", entity["state"]!.Value()); // 关闭不等于不可用。
+ foreach (var reading in new[] { entity, wire["controlParameters"]!["climate_mode"]!,
+ wire["controlParameters"]!["climate_temperature"]!, wire["controlParameters"]!["climate_fan_mode"]! })
+ {
+ var attributes = reading["attributes"]!;
+ Assert.Equal(new[] { "cool", "heat", "fan_only", "off" }, attributes["hvac_modes"]!.Values());
+ Assert.Equal(new[] { "自动", "低风", "中风", "高风" }, attributes["fan_modes"]!.Values());
+ Assert.Equal(24.9, attributes["current_temperature"]!.Value());
+ Assert.Equal(23, attributes["temperature"]!.Value());
+ Assert.Equal("°C", attributes["temperature_unit"]!.Value());
+ Assert.Equal(0.5, attributes["target_temp_step"]!.Value());
+ Assert.Equal(393, attributes["supported_features"]!.Value());
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void ConversionPreservesJsonTypesAndDoesNotExposeUnapprovedRootAttributes(bool useSystemJson)
+ {
+ using var document = JsonDocument.Parse("""
+ {"options":["off",null,true,12,0.5,["nested"],{"choice":"cool","flag":false}],
+ "temperature":null,"is_volume_muted":false,"volume_level":0.5,
+ "access_token":"private","unapproved_attribute":{"secret":"private"}}
+ """);
+ var state = new HomeAssistantState { Attributes = document.RootElement };
+ var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)!;
+ var wire = JObject.Parse(useSystemJson ? SystemJson.Serialize(safe) : SerializeApi(safe));
+ var expected = JObject.Parse("""
+ {"options":["off",null,true,12,0.5,["nested"],{"choice":"cool","flag":false}],
+ "temperature":null,"is_volume_muted":false,"volume_level":0.5}
+ """);
+ Assert.True(JToken.DeepEquals(expected, wire), wire.ToString());
+ }
+
+ private sealed class NoWritesGateway : IHomeAssistantGateway
+ {
+ public Task> GetStatesAsync(CancellationToken cancellationToken = default)
+ => throw new InvalidOperationException("Serialization tests must not read a device");
+ public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data,
+ CancellationToken cancellationToken = default)
+ => throw new InvalidOperationException("Serialization tests must not control a device");
+ }
+}
diff --git a/Admin.NET.Test/Health/HomeAssistantTemperatureUnitTests.cs b/Admin.NET.Test/Health/HomeAssistantTemperatureUnitTests.cs
new file mode 100644
index 0000000..5847c5e
--- /dev/null
+++ b/Admin.NET.Test/Health/HomeAssistantTemperatureUnitTests.cs
@@ -0,0 +1,72 @@
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Microsoft.Extensions.Options;
+using System.Net;
+using System.Text;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class HomeAssistantTemperatureUnitTests
+{
+ [Theory]
+ [InlineData("°C")]
+ [InlineData("°F")]
+ public async Task NativeClimateWithoutUnitUsesHaUnitSystemWithoutGuessing(string unit)
+ {
+ var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature":23,"min_temp":5,"max_temp":35,"target_temp_step":0.5,"supported_features":393}}]""",
+ unit);
+ var states = await Client(handler).GetStatesAsync();
+ var state = Assert.Single(states);
+ Assert.Equal(unit, state.GetStringAttribute("temperature_unit"));
+ Assert.Equal(23d, state.GetDoubleAttribute("temperature"));
+ Assert.Equal(393d, state.GetDoubleAttribute("supported_features"));
+ Assert.Equal(new[] { "/api/states", "/api/config" }, handler.Paths);
+ }
+
+ [Fact]
+ public async Task ExplicitDeviceUnitIsNotOverwritten()
+ {
+ var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature_unit":"°F","temperature":73}}]""", "°C");
+ var state = Assert.Single(await Client(handler).GetStatesAsync());
+ Assert.Equal("°F", state.GetStringAttribute("temperature_unit"));
+ Assert.Equal(new[] { "/api/states" }, handler.Paths);
+ }
+
+ [Fact]
+ public async Task MissingConfigKeepsUnitUnknownAndDoesNotHideOtherDevices()
+ {
+ var handler = new UnitHandler("""[{"entity_id":"climate.room","state":"off","attributes":{"temperature":23}},{"entity_id":"switch.room","state":"on","attributes":{}}]""",
+ "°C", HttpStatusCode.ServiceUnavailable);
+ var states = await Client(handler).GetStatesAsync();
+ Assert.Equal(2, states.Count);
+ Assert.Null(states[0].GetStringAttribute("temperature_unit"));
+ Assert.Equal("on", states[1].State);
+ Assert.Equal(23d, states[0].GetDoubleAttribute("temperature"));
+ }
+
+ [Fact]
+ public async Task OtherDevicesDoNotRequireUnitProbe()
+ {
+ var handler = new UnitHandler("""[{"entity_id":"cover.room","state":"unavailable","attributes":{"supported_features":15}}]""", "°C");
+ Assert.Single(await Client(handler).GetStatesAsync());
+ Assert.Equal(new[] { "/api/states" }, handler.Paths);
+ }
+
+ private static HomeAssistantClient Client(UnitHandler handler) => new(new HttpClient(handler),
+ Options.Create(new HomeAssistantOptions { BaseUrl = "https://ha.example.invalid", AccessToken = "fake-test-token" }));
+
+ private sealed class UnitHandler(string states, string unit, HttpStatusCode configStatus = HttpStatusCode.OK) : HttpMessageHandler
+ {
+ public List Paths { get; } = [];
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Assert.Equal(HttpMethod.Get, request.Method);
+ var path = request.RequestUri!.AbsolutePath;
+ Paths.Add(path);
+ var content = path == "/api/states" ? states : System.Text.Json.JsonSerializer.Serialize(new { unit_system = new { temperature = unit } });
+ return Task.FromResult(new HttpResponseMessage(path == "/api/states" ? HttpStatusCode.OK : configStatus)
+ { Content = new StringContent(content, Encoding.UTF8, "application/json") });
+ }
+ }
+}
diff --git a/Admin.NET.Test/Health/OutletIntegrationTests.cs b/Admin.NET.Test/Health/OutletIntegrationTests.cs
new file mode 100644
index 0000000..29026bb
--- /dev/null
+++ b/Admin.NET.Test/Health/OutletIntegrationTests.cs
@@ -0,0 +1,187 @@
+using Evergreen.Health.Application.Health.Devices;
+using Evergreen.Health.Application.Health.Devices.Contracts;
+using Evergreen.Health.Application.Health.Devices.Discovery;
+using Evergreen.Health.Application.Health.Devices.Outlet;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Models;
+using Evergreen.Health.Application.Health.Scenes;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+/// 仅内存假网关,不连接 HA、不使实际插座通电。
+public sealed class OutletIntegrationTests
+{
+ private const string MainId = "switch.test_outlet_power", ServiceId = "switch.test_outlet_service";
+ private static HomeAssistantState State(string id, string value) => new() { EntityId = id, State = value };
+ private static DeviceActionRunner Runner(IHomeAssistantGateway gateway) => new(gateway, TimeProvider.System,
+ new() { VerificationDelay = TimeSpan.Zero, VerificationAttempts = 3 });
+ private static IOptions Settings(string id = MainId) => Options.Create(new HomeAssistantOptions
+ { Entities = new() { Outlet = id } });
+ private static HomeAssistantRegistries Registry(params string[] entityIds) => new()
+ {
+ Devices = [new() { Id = "outlet-device", Name = "红灯插座", Model = "ym001.plug.ymcz" }],
+ Entities = entityIds.Select(id => new HomeAssistantEntityRegistryItem
+ { DeviceId = "outlet-device", EntityId = id }).ToList()
+ };
+
+ [Theory]
+ [InlineData(true, "off", "turn_on", "on")]
+ [InlineData(false, "on", "turn_off", "off")]
+ public async Task ManualPowerUsesOnlyTrustedPrimaryAndConfirmsReadback(bool turnOn, string current, string service, string expected)
+ {
+ var gateway = new FakeHaGateway();
+ gateway.States[MainId] = State(MainId, current);
+ gateway.States[ServiceId] = State(ServiceId, "off");
+ var module = new OutletModule(Runner(gateway), Settings());
+ await module.SetPowerAsync(turnOn, default);
+ var call = Assert.Single(gateway.Calls);
+ Assert.Equal("switch", call.Domain);
+ Assert.Equal(service, call.Service);
+ Assert.Equal(MainId, call.EntityId);
+ Assert.Equal(MainId, call.Data!["entity_id"]);
+ Assert.Single(call.Data);
+ Assert.Equal(expected, gateway.States[MainId].State);
+ Assert.Equal("off", gateway.States[ServiceId].State);
+ }
+
+ [Theory]
+ [InlineData("unavailable")]
+ [InlineData("unknown")]
+ [InlineData("")]
+ [InlineData("idle")]
+ public async Task UnconfirmedPrimaryNeverBorrowsServiceSwitchAvailability(string state)
+ {
+ var gateway = new FakeHaGateway();
+ gateway.States[MainId] = State(MainId, state);
+ gateway.States[ServiceId] = State(ServiceId, "on");
+ var module = new OutletModule(Runner(gateway), Settings());
+ await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default));
+ await Assert.ThrowsAsync(() => module.SetPowerAsync(false, default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task MissingPrimaryAndReadFailuresNeverSendPowerCommands()
+ {
+ var gateway = new FakeHaGateway();
+ gateway.States[ServiceId] = State(ServiceId, "on");
+ var module = new OutletModule(Runner(gateway), Settings());
+ await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default));
+ gateway.States[MainId] = State(MainId, "off");
+ gateway.ReadsToFail = 1;
+ await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("button.toggle")]
+ [InlineData("number.protection")]
+ public async Task InvalidBindingCannotFallBackToToggleOrEngineeringParameters(string id)
+ {
+ var gateway = new FakeHaGateway();
+ var module = new OutletModule(Runner(gateway), Settings(id));
+ await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default));
+ Assert.Throws(() => module.BuildParameterCommand("outlet_service", true));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task CancellationPreventsManualPower()
+ {
+ var gateway = new FakeHaGateway(); gateway.States[MainId] = State(MainId, "off");
+ using var cancellation = new CancellationTokenSource(); cancellation.Cancel();
+ var module = new OutletModule(Runner(gateway), Settings());
+ await Assert.ThrowsAnyAsync(() => module.SetPowerAsync(true, cancellation.Token));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task AcceptedButUnconfirmedCommandIsNotSuccessAndDoesNotRepeatEnergizing()
+ {
+ var gateway = new NonApplyingGateway();
+ var module = new OutletModule(Runner(gateway), Settings());
+ var error = await Assert.ThrowsAsync(() => module.SetPowerAsync(true, default));
+ Assert.Contains("未确认目标状态", error.Message);
+ Assert.Equal(1, gateway.Writes);
+ Assert.Equal(4, gateway.Reads); // 发送前校验 + 三轮回读,不再次发送。
+ }
+
+ [Fact]
+ public void DiscoverySelectsExplicitPowerEvenWhenServiceSwitchComesFirst()
+ {
+ var gateway = new FakeHaGateway();
+ gateway.States[MainId] = State(MainId, "off"); gateway.States[ServiceId] = State(ServiceId, "on");
+ var options = Settings();
+ var module = new OutletModule(Runner(gateway), options);
+ var modules = new DeviceModuleRegistry([module]);
+ var catalog = new HomeAssistantDeviceCatalog(options, modules);
+ var item = Assert.Single(catalog.BuildDiscoveredDevices(Registry(ServiceId, "button.toggle", MainId), gateway.States));
+ Assert.Equal("outlet", item.ModuleKey);
+ Assert.Equal("Outlet", item.Category);
+ Assert.Equal(MainId, item.PrimaryControlEntityId);
+ Assert.True(item.Online);
+ Assert.Equal("off", item.Entities.Single(entity => entity.EntityId == item.PrimaryControlEntityId).State);
+ Assert.Null(modules.ForEntities([ServiceId, "button.toggle"]));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public void UnconfiguredOutletHasNoGuessedPrimaryOrFallbackDevice()
+ {
+ var gateway = new FakeHaGateway(); gateway.States[ServiceId] = State(ServiceId, "on");
+ var options = Settings("");
+ var catalog = new HomeAssistantDeviceCatalog(options,
+ new DeviceModuleRegistry([new OutletModule(Runner(gateway), options)]));
+ var item = Assert.Single(catalog.BuildDiscoveredDevices(Registry(ServiceId), gateway.States));
+ Assert.Equal("", item.ModuleKey);
+ Assert.Null(item.PrimaryControlEntityId);
+ Assert.Empty(catalog.BuildDiscoveredDevices(null, gateway.States));
+ }
+
+ [Fact]
+ public async Task SleepAndAllOffDoNotChangeOutletPowerOrExposeScenePreset()
+ {
+ var gateway = new FakeHaGateway(); gateway.States[MainId] = State(MainId, "on");
+ var runner = Runner(gateway);
+ var module = new OutletModule(runner, Settings());
+ var coordinator = new HealthSceneCoordinator(new DeviceModuleRegistry([module]), runner, gateway, new FakeLight(), new FakeMusic());
+ await coordinator.ExecuteAsync("sleep", new PersonalizedSceneConfig(), "balanced", default);
+ await coordinator.ExecuteAsync("all-off", null, "", default);
+ Assert.Empty(module.GetShutdownCommands());
+ Assert.Null(module.BuildPreview(new(), "balanced"));
+ Assert.Empty(gateway.Calls);
+ Assert.Equal("on", gateway.States[MainId].State);
+ }
+
+ [Fact]
+ public void OutletIsRegisteredAsItsOwnModuleWithoutHostedAutomation()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging(); services.AddOptions(); services.AddHttpClient(); services.AddHealthDevices();
+ using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true });
+ var module = provider.GetRequiredService();
+ Assert.Same(module, provider.GetRequiredService().Modules.Single(item => item.Key == "outlet"));
+ Assert.Empty(module.Parameters);
+ }
+
+ private sealed class NonApplyingGateway : IHomeAssistantGateway
+ {
+ public int Writes { get; private set; }
+ public int Reads { get; private set; }
+ public Task> GetStatesAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested(); Reads++;
+ return Task.FromResult>([State(MainId, "off")]);
+ }
+ public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested(); Writes++;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs b/Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs
new file mode 100644
index 0000000..559a6dc
--- /dev/null
+++ b/Admin.NET.Test/Health/SafetyAndReservedWorkflowTests.cs
@@ -0,0 +1,291 @@
+using Evergreen.Health.Application.Health.Devices;
+using Evergreen.Health.Application.Health.Devices.Contracts;
+using Evergreen.Health.Application.Health.Devices.Mattress;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Integrations.MiAirX;
+using Evergreen.Health.Application.Health.Integrations.Tuya;
+using Evergreen.Health.Application.Health.Policies;
+using Evergreen.Health.Application.Health.Scenes;
+using Evergreen.Health.Application.Health.Sleep;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class SafetyAndReservedWorkflowTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 10, 8, 0, 0, 0, TimeSpan.Zero);
+ private static SleepObservation Reading(DateTimeOffset at, BedPresence presence = BedPresence.Occupied,
+ SleepDataAvailability availability = SleepDataAvailability.Available) => new("fake", at, availability, presence);
+ private static HomeAssistantOptions Configuration() => new()
+ {
+ Entities = new() { AmbientLight = "light.bedroom", MattressRightMassagePart = "select.right",
+ MattressLeftMassagePart = "select.left", MattressLeftHeatingLevel = "number.heat" }
+ };
+ private static HomeAssistantState Light(string state, double brightness, DateTimeOffset updated) => new()
+ {
+ EntityId = "light.bedroom", State = state, LastUpdated = updated,
+ Attributes = JsonSerializer.SerializeToElement(new { brightness })
+ };
+ private static DeviceCommand Massage() => new("按摩", "select.right", "select", "select_option",
+ new Dictionary { ["option"] = "全身" }) { Intent = DeviceCommandIntent.StartMassage };
+
+ [Fact]
+ public async Task UnconfiguredInputsAndPlaceholderAudioCannotPretendToWork()
+ {
+ var observation = await new ReservedJe01Source().ReadAsync(default);
+ Assert.Equal(BedPresence.Unknown, observation.Presence);
+ Assert.Equal(SleepDataAvailability.Unconfigured, observation.Availability);
+ var clip = new GuidanceCatalog().InitialRelaxation;
+ Assert.False(clip.Playable);
+ Assert.False((await new ReservedGuidancePlayer().PlayAsync(clip, 50, default)).Completed);
+ var tuya = new ReservedTuyaGateway();
+ Assert.False(tuya.IsConfigured);
+ await Assert.ThrowsAsync(() => tuya.WriteAsync(new("fake", "switch", true), default));
+ }
+
+ [Fact]
+ public void DefaultWorkflowNeverPlansBedOrDeviceActions()
+ {
+ var workflow = new ReservedSleepWorkflow(new(), new());
+ Assert.Empty(workflow.Start());
+ Assert.Empty(workflow.Observe(Reading(Now), Now.AddMinutes(1)));
+ Assert.Empty(workflow.Tick(Now.AddMinutes(10)));
+ Assert.Equal(ReservedSleepPhase.Disabled, workflow.Phase);
+ }
+
+ [Theory]
+ [InlineData(BedPresence.Unknown, SleepDataAvailability.Available, 0)]
+ [InlineData(BedPresence.Empty, SleepDataAvailability.Available, 0)]
+ [InlineData(BedPresence.Occupied, SleepDataAvailability.Offline, 0)]
+ [InlineData(BedPresence.Occupied, SleepDataAvailability.Stale, 0)]
+ [InlineData(BedPresence.Occupied, SleepDataAvailability.Available, 121)]
+ public void UnknownOfflineOrStaleInputResetsContinuousBedWait(BedPresence presence, SleepDataAvailability availability, int age)
+ {
+ var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new());
+ Assert.Equal(SleepWorkflowActionKind.PrepareEnvironment, Assert.Single(workflow.Start()).Kind);
+ Assert.Empty(workflow.Observe(Reading(Now), Now));
+ Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29 - age), presence, availability), Now.AddSeconds(29)));
+ Assert.Equal(ReservedSleepPhase.WaitingForBed, workflow.Phase);
+ Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30)));
+ Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(59)), Now.AddSeconds(59)));
+ Assert.Equal(2, workflow.Observe(Reading(Now.AddSeconds(60)), Now.AddSeconds(60)).Count);
+ }
+
+ [Fact]
+ public void PlaceholderCompletionCannotStartMusicOrLightFade()
+ {
+ var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new());
+ workflow.Start(); workflow.Observe(Reading(Now), Now);
+ workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30));
+ Assert.Empty(workflow.GuidanceCompleted("initial-relaxation", true, Now.AddSeconds(40)));
+ Assert.Empty(workflow.Tick(Now.AddMinutes(5)));
+ Assert.Equal(ReservedSleepPhase.Guiding, workflow.Phase);
+ }
+
+ [Fact]
+ public void RealFutureGuideRestartsAtFiftyAndReminderDoesNotRestartCompletedLampFade()
+ {
+ var clip = new GuidanceClip("guide", "测试", "https://example.invalid/guide.mp3", 20);
+ var workflow = new ReservedSleepWorkflow(new() { Enabled = true }, new(clip));
+ workflow.Start(); workflow.Observe(Reading(Now), Now);
+ Assert.Empty(workflow.Observe(Reading(Now.AddSeconds(29)), Now.AddSeconds(29)));
+ var actions = workflow.Observe(Reading(Now.AddSeconds(30)), Now.AddSeconds(30));
+ Assert.Contains(actions, action => action.Kind == SleepWorkflowActionKind.StartGentleRightMassage);
+ Assert.Equal(50, actions.Single(action => action.Kind == SleepWorkflowActionKind.RequestGuidance).VolumePercent);
+ Assert.Empty(workflow.GuidanceCompleted("wrong", true, Now.AddSeconds(50)));
+ var music = Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddSeconds(50)));
+ Assert.Equal(SleepWorkflowActionKind.RestartMusicAtFifty, music.Kind);
+ Assert.Equal(50, music.VolumePercent);
+ Assert.Empty(workflow.Tick(Now.AddSeconds(79)));
+ Assert.Equal(SleepWorkflowActionKind.StartLightFade, Assert.Single(workflow.Tick(Now.AddSeconds(80))).Kind);
+ Assert.Empty(workflow.Tick(Now.AddSeconds(81)));
+ Assert.Single(workflow.RequestReminder(clip));
+ Assert.Single(workflow.GuidanceCompleted("guide", true, Now.AddMinutes(6)));
+ Assert.Empty(workflow.Tick(Now.AddMinutes(7)));
+ workflow.Stop();
+ Assert.Empty(workflow.Observe(Reading(Now.AddMinutes(8)), Now.AddMinutes(8)));
+ Assert.Empty(workflow.RequestReminder(clip));
+ }
+
+ [Fact]
+ public void SameAnomalyCooldownOnlyStartsAfterActualDelivery()
+ {
+ var gate = new AnomalyReminderGate(new());
+ Assert.True(gate.CanRemind("restless", Now));
+ Assert.True(gate.CanRemind("restless", Now.AddMinutes(1)));
+ gate.MarkDelivered("restless", Now);
+ Assert.False(gate.CanRemind("RESTLESS", Now.AddMinutes(29)));
+ Assert.True(gate.CanRemind("restless", Now.AddMinutes(30)));
+ Assert.True(gate.CanRemind("another", Now));
+ }
+
+ [Theory]
+ [InlineData("off", 255)]
+ [InlineData("on", 0)]
+ [InlineData("unavailable", 255)]
+ public async Task OffOrUnconfirmedLampBlocksMassage(string state, double brightness)
+ {
+ var gateway = new FakeHaGateway();
+ gateway.States["light.bedroom"] = Light(state, brightness, Now);
+ var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new());
+ var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]);
+ await Assert.ThrowsAsync(() => runner.SendAsync(Massage(), default));
+ Assert.Empty(gateway.Calls);
+ }
+
+ [Fact]
+ public async Task KnownLampOffRejectsOlderCloudOnCacheButAllowsFreshOnReading()
+ {
+ var gateway = new FakeHaGateway();
+ var state = new SceneLightSafetyState(); state.MarkOff(Now);
+ var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), state);
+ gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(-1));
+ await Assert.ThrowsAsync(() => guard.ValidateAsync(Massage(), default));
+ gateway.States["light.bedroom"] = Light("on", 100, Now.AddSeconds(1));
+ await guard.ValidateAsync(Massage(), default);
+ }
+
+ [Fact]
+ public async Task LateMassageAfterAnOffCommandCannotUndoShutdown()
+ {
+ var gateway = new FakeHaGateway();
+ var guard = new LightMassageSafetyRule(gateway, Options.Create(Configuration()), new());
+ var runner = new DeviceActionRunner(gateway, TimeProvider.System, new(), [guard]);
+ gateway.States["light.bedroom"] = Light("on", 100, DateTimeOffset.UtcNow);
+ await runner.SendAsync(new("关灯", "light.bedroom", "light", "turn_off")
+ { Intent = DeviceCommandIntent.TurnRelaxationLightOff }, default);
+ await Assert.ThrowsAsync(() => runner.SendAsync(Massage(), default));
+ Assert.DoesNotContain(gateway.Calls, command => command.EntityId == "select.right");
+ }
+
+ [Fact]
+ public async Task NaturalFadeToZeroStopsBothMassageSides()
+ {
+ var time = new ManualTestTimeProvider();
+ var gateway = new FakeHaGateway { Time = time };
+ gateway.States["light.bedroom"] = Light("on", 100, time.GetUtcNow());
+ var runner = new DeviceActionRunner(gateway, time, new() { VerificationDelay = TimeSpan.Zero });
+ var mattress = new MattressModule(runner, Options.Create(Configuration()));
+ var safety = new SceneLightSafetyState();
+ var light = new HomeAssistantLightTransitionService(gateway, new([mattress]), Options.Create(Configuration()),
+ NullLogger.Instance, safety, time);
+ await light.StartSleepAsync(2700, default);
+ for (var step = 1; step <= 50; step++)
+ {
+ time.Advance(TimeSpan.FromSeconds(5));
+ var expected = 50 - step;
+ await WaitUntilAsync(() => light.GetStatus().BrightnessPercent == expected && (step != 50 || !light.GetStatus().Running));
+ }
+ await WaitUntilAsync(() => gateway.States.GetValueOrDefault("select.left")?.State == "无");
+ Assert.Equal("off", gateway.States["light.bedroom"].State);
+ Assert.Equal("无", gateway.States["select.right"].State);
+ Assert.NotNull(safety.OffAt);
+ await light.StopAsync(default);
+ }
+
+ [Fact]
+ public async Task TaskCancellationAloneCannotCountAsDeviceSceneSuccess()
+ {
+ var gateway = new FakeHaGateway();
+ var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
+ var coordinator = new HealthSceneCoordinator(new([]), runner, gateway, new FakeLight(), new FakeMusic());
+ Assert.Equal("Failed", (await coordinator.ExecuteAsync("sleep", new(), "balanced", default)).Status);
+ }
+
+ [Fact]
+ public void ManuallyEnabledHeatIsIncludedEvenIfAutomaticMattressStartIsDisabled()
+ {
+ var gateway = new FakeHaGateway();
+ var module = new MattressModule(new(gateway, TimeProvider.System, new()), Options.Create(Configuration()));
+ Assert.Contains(module.GetShutdownCommands(), command => command.EntityId == "number.heat" && command.ExpectedState == "0");
+ }
+
+ [Fact]
+ public void SessionClimateTargetIsIdempotentAndPreviewIsNotRandomized()
+ {
+ var advice = RoomTemperaturePolicy.Evaluate(new(15, Now, true, ""), new(22, 25));
+ Assert.Null(advice.TargetTemperatureCelsius);
+ RoomTemperaturePolicy.PickTargetForSession(advice);
+ var initial = advice.TargetTemperatureCelsius;
+ for (var i = 0; i < 20; i++) RoomTemperaturePolicy.PickTargetForSession(advice);
+ Assert.Equal(initial, advice.TargetTemperatureCelsius);
+ }
+
+ [Fact]
+ public async Task GatewayTimeoutIsFailureNotUserCancellationSoOtherDevicesCanContinue()
+ {
+ var ha = new HomeAssistantClient(new HttpClient(new TimedOutHandler()), Options.Create(new HomeAssistantOptions
+ { Enabled = true, BaseUrl = "https://example.invalid", AccessToken = "fake-test-token" }));
+ await Assert.ThrowsAsync(() => ha.GetStatesAsync());
+ var audio = new MiAirXClient(new HttpClient(new TimedOutHandler()), Options.Create(new MiAirXOptions
+ { Enabled = true, BaseUrl = "https://example.invalid", DeviceId = "fake-test-device", RequireWebPassword = false }));
+ await Assert.ThrowsAsync(() => audio.StopAsync());
+ using var cancelled = new CancellationTokenSource(); cancelled.Cancel();
+ await Assert.ThrowsAnyAsync(() => ha.GetStatesAsync(cancelled.Token));
+ await Assert.ThrowsAnyAsync(() => audio.StopAsync(cancelled.Token));
+ }
+
+ [Fact]
+ public async Task UnintegratedCurtainNeverSendsOrReportsSuccessfulControl()
+ {
+ var gateway = new FakeHaGateway();
+ var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
+ var module = new Evergreen.Health.Application.Health.Devices.Curtain.CurtainModule(runner,
+ Options.Create(new HomeAssistantOptions()));
+ var context = new DeviceSceneContext(runner, new Dictionary(), []);
+ await module.ExecuteSleepAsync(context, new(), "balanced", default);
+ Assert.Equal("Skipped", Assert.Single(context.Results).Status);
+ Assert.Empty(gateway.Calls);
+ Assert.Empty(module.GetShutdownCommands());
+ }
+
+ [Fact]
+ public async Task AllOffAlsoCancelsOldPreparationBeforeAnyDeviceCanStart()
+ {
+ var gateway = new FakeHaGateway();
+ var runner = new DeviceActionRunner(gateway, TimeProvider.System, new() { VerificationDelay = TimeSpan.Zero });
+ var coordinator = new HealthSceneCoordinator(new([new SyntheticDeviceModule(runner)]), runner, gateway, new FakeLight(), new FakeMusic());
+ var preparing = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var start = coordinator.ExecutePreparedSleepAsync(async token =>
+ {
+ preparing.SetResult();
+ await Task.Delay(Timeout.InfiniteTimeSpan, token);
+ return new(new(), "balanced");
+ }, default);
+ await preparing.Task;
+ var off = await coordinator.ExecuteAsync("all-off", null, "", default);
+ Assert.Equal("Cancelled", (await start).Status);
+ Assert.Equal("Succeeded", off.Status);
+ Assert.DoesNotContain(gateway.Calls, command => command.Service == "set_value");
+ }
+
+ [Fact]
+ public async Task MissingConstitutionInPreparationDoesNotExecuteOrCountCancellationAsSuccess()
+ {
+ var gateway = new FakeHaGateway();
+ var runner = new DeviceActionRunner(gateway, TimeProvider.System, new());
+ var music = new FakeMusic();
+ var coordinator = new HealthSceneCoordinator(new([]), runner, gateway, new FakeLight(), music);
+ var output = await coordinator.ExecutePreparedSleepAsync(_ => throw new ScenePreparationException("先完成四诊"), default);
+ Assert.False(output.Success);
+ Assert.Equal("AssessmentRequired", output.Status);
+ Assert.Equal("先完成四诊", output.Message);
+ Assert.Empty(gateway.Calls);
+ Assert.Equal(0, music.StopCount);
+ }
+
+ private sealed class TimedOutHandler : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromException(new TaskCanceledException("模拟网络超时"));
+ }
+
+ private static async Task WaitUntilAsync(Func predicate)
+ {
+ using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
+ while (!predicate()) await Task.Delay(5, timeout.Token);
+ }
+}
diff --git a/Admin.NET.Test/Health/SystemServiceFilteringTests.cs b/Admin.NET.Test/Health/SystemServiceFilteringTests.cs
new file mode 100644
index 0000000..51a6c98
--- /dev/null
+++ b/Admin.NET.Test/Health/SystemServiceFilteringTests.cs
@@ -0,0 +1,116 @@
+using Evergreen.Health.Application.Health.Devices;
+using Evergreen.Health.Application.Health.Devices.Discovery;
+using Evergreen.Health.Application.Health.Devices.Mattress;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Scenes;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+using Xunit;
+
+namespace Admin.NET.Test.Health;
+
+public sealed class SystemServiceFilteringTests
+{
+ private static HomeAssistantDeviceCatalog Catalog() => new(Options.Create(new HomeAssistantOptions()), new DeviceModuleRegistry([]));
+
+ [Fact]
+ public void RegistryDeserializationRetainsSystemServiceMarker()
+ {
+ var device = JsonSerializer.Deserialize(
+ """{"id":"system-sun","name":"Sun","entry_type":"service"}""");
+ Assert.NotNull(device);
+ Assert.Equal("service", device.EntryType);
+ }
+
+ [Fact]
+ public void SystemServicesAreExcludedFromListAndAllDeviceCounts()
+ {
+ var registry = new HomeAssistantRegistries
+ {
+ Devices = [new() { Id = "sun", Name = "Sun", EntryType = "service" },
+ new() { Id = "backup", Name = "Backup", EntryType = "service" }],
+ Entities = [new() { DeviceId = "sun", EntityId = "sensor.sun_next_rising", Platform = "sun" },
+ new() { DeviceId = "backup", EntityId = "sensor.backup_backup_manager_state", Platform = "backup" }]
+ };
+ var states = new Dictionary
+ {
+ ["sensor.sun_next_rising"] = new() { EntityId = "sensor.sun_next_rising", State = "2026-10-10T06:00:00Z" },
+ ["sensor.backup_backup_manager_state"] = new() { EntityId = "sensor.backup_backup_manager_state", State = "idle" }
+ };
+ for (var i = 0; i < 9; i++)
+ {
+ var deviceId = $"furniture-{i}";
+ var entityId = i == 0 ? "cover.curtain" : $"switch.furniture_{i}";
+ registry.Devices.Add(new() { Id = deviceId, Name = $"家具{i}" });
+ registry.Entities.Add(new() { DeviceId = deviceId, EntityId = entityId, Platform = "xiaomi_home" });
+ states[entityId] = new() { EntityId = entityId, State = i == 0 ? "unavailable" : "on" };
+ }
+ var devices = Catalog().BuildDiscoveredDevices(registry, states);
+ Assert.Equal(9, devices.Count);
+ Assert.DoesNotContain(devices, device => device.DeviceId is "sun" or "backup");
+ Assert.Contains(devices, device => device.DeviceId == "furniture-0" && !device.Online);
+ var summary = HomeAssistantDeviceCatalog.BuildSummary(devices);
+ Assert.Equal(9, summary.TotalDevices);
+ Assert.Equal(8, summary.OnlineDevices);
+ Assert.Equal(1, summary.OfflineDevices);
+ Assert.Equal(9, summary.TotalEntities);
+ Assert.Equal(8, summary.AvailableEntities);
+ }
+
+ [Theory]
+ [InlineData("service")]
+ [InlineData("SERVICE")]
+ public void RenamedSystemServiceStillDoesNotBecomeFurniture(string entryType)
+ {
+ var registry = new HomeAssistantRegistries
+ {
+ Devices = [new() { Id = "service", Name = "系统服务", NameByUser = "我的灯", EntryType = entryType }],
+ Entities = [new() { DeviceId = "service", EntityId = "sensor.service" }]
+ };
+ Assert.Empty(Catalog().BuildDiscoveredDevices(registry, new Dictionary
+ {
+ ["sensor.service"] = new() { EntityId = "sensor.service", State = "on" }
+ }));
+ }
+
+ [Theory]
+ [InlineData("Sun", null)]
+ [InlineData("Backup", "")]
+ public void PhysicalDevicesAreNotHiddenJustBecauseTheyShareServiceNames(string name, string? entryType)
+ {
+ var registry = new HomeAssistantRegistries
+ {
+ Devices = [new() { Id = "physical", Name = name, EntryType = entryType, Manufacturer = "Other brand" }],
+ Entities = [new() { DeviceId = "physical", EntityId = "sensor.physical", Platform = "other_integration" }]
+ };
+ var device = Assert.Single(Catalog().BuildDiscoveredDevices(registry, new Dictionary
+ {
+ ["sensor.physical"] = new() { EntityId = "sensor.physical", State = "20" }
+ }));
+ Assert.Equal(name, device.Name);
+ Assert.True(device.Online);
+ }
+
+ [Fact]
+ public void ValidServiceOnlyRegistryDoesNotCreateFallbackFurniture()
+ {
+ var options = Options.Create(new HomeAssistantOptions
+ {
+ Entities = new() { MattressLeftHeatingLevel = "number.heating" }
+ });
+ var runner = new DeviceActionRunner(new FakeHaGateway(), TimeProvider.System, new());
+ var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([new MattressModule(runner, options)]));
+ var registry = new HomeAssistantRegistries
+ {
+ Devices = [new() { Id = "backup", Name = "Backup", EntryType = "service" }],
+ Entities = [new() { DeviceId = "backup", EntityId = "sensor.backup", Platform = "backup" }]
+ };
+ var states = new Dictionary
+ {
+ ["sensor.backup"] = new() { EntityId = "sensor.backup", State = "idle" },
+ ["number.heating"] = new() { EntityId = "number.heating", State = "0" }
+ };
+ Assert.Empty(catalog.BuildDiscoveredDevices(registry, states));
+ Assert.Equal("mattress", Assert.Single(catalog.BuildDiscoveredDevices(null, states)).ModuleKey);
+ }
+}
diff --git a/Admin.NET.Web.Core/Startup.cs b/Admin.NET.Web.Core/Startup.cs
index ea33e01..462d423 100644
--- a/Admin.NET.Web.Core/Startup.cs
+++ b/Admin.NET.Web.Core/Startup.cs
@@ -9,6 +9,7 @@ using Admin.NET.Core.ElasticSearch;
using Admin.NET.Core.Service;
using AspNetCoreRateLimit;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Devices;
using Evergreen.Health.Application.Health.Integrations.MiAirX;
using Furion;
using Furion.Logging;
@@ -17,12 +18,14 @@ using Furion.VirtualFileServer;
using IPTools.Core;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using OnceMi.AspNetCore.OSS;
using Scalar.AspNetCore;
@@ -78,10 +81,7 @@ public class Startup : AppStartup
services.AddHttpRemote();
services.AddHttpClient();
services.AddHttpClient();
- services.AddSingleton();
- services.AddHostedService(provider => provider.GetRequiredService());
- services.AddSingleton();
- services.AddHostedService(provider => provider.GetRequiredService());
+ services.AddHealthDevices();
// 任务队列
services.AddTaskQueue();
// 脱敏检测
@@ -104,7 +104,8 @@ public class Startup : AppStartup
}
;
- services.AddControllersWithViews()
+ // 后端只提供 API;移动端页面位于独立的 Mobile 项目。
+ services.AddControllers()
.AddAppLocalization()
.AddNewtonsoftJson(options => SetNewtonsoftJsonSetting(options.SerializerSettings))
//.AddXmlSerializerFormatters()
@@ -287,7 +288,19 @@ public class Startup : AppStartup
}
else
{
- app.UseExceptionHandler("/Home/Error");
+ // API 未处理异常保持 JSON 响应;原 /Home/Error 没有对应处理入口。
+ app.UseExceptionHandler(errorApp => errorApp.Run(async context =>
+ {
+ var exception = context.Features.Get()?.Error;
+ context.RequestServices.GetRequiredService>()
+ .LogError(exception, "请求发生未处理异常,TraceId={TraceId}", context.TraceIdentifier);
+ context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ await context.Response.WriteAsJsonAsync(new
+ {
+ code = 500, type = "error", message = "服务暂时不可用,请稍后重试",
+ result = (object)null, extras = new { traceId = context.TraceIdentifier }
+ }, context.RequestAborted);
+ }));
app.UseHsts();
}
@@ -373,9 +386,8 @@ public class Startup : AppStartup
// 注册集线器
endpoints.MapHubs();
- endpoints.MapControllerRoute(
- name: "default",
- pattern: "{controller=Home}/{action=Index}/{id?}");
+ // 健康接口和后台接口均使用特性路由,不再保留默认欢迎页。
+ endpoints.MapControllers();
});
}
}
diff --git a/Evergreen.Health.Api/Controllers/HomeController.cs b/Evergreen.Health.Api/Controllers/HomeController.cs
deleted file mode 100644
index 66aad03..0000000
--- a/Evergreen.Health.Api/Controllers/HomeController.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
-//
-// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
-//
-// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
-
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-
-namespace Evergreen.Health.Api.Controllers
-{
- [AllowAnonymous]
- public class HomeController : Controller
- {
- //private readonly ISystemService _systemService;
-
- //public HomeController(ISystemService systemService)
- //{
- // _systemService = systemService;
- //}
-
- public IActionResult Index()
- {
- //ViewBag.Description = _systemService.GetDescription();
-
- return View();
- }
- }
-}
\ No newline at end of file
diff --git a/Evergreen.Health.Api/Program.cs b/Evergreen.Health.Api/Program.cs
index e67ee4c..054f7bc 100644
--- a/Evergreen.Health.Api/Program.cs
+++ b/Evergreen.Health.Api/Program.cs
@@ -13,7 +13,9 @@ public class WebComponent : IWebComponent
// 设置日志过滤
builder.Logging.AddFilter((provider, category, logLevel) =>
{
- return !new[] { "Microsoft.Hosting", "Microsoft.AspNetCore" }.Any(u => category.StartsWith(u)) && logLevel >= LogLevel.Information;
+ var frameworkNoise = new[] { "Microsoft.Hosting", "Microsoft.AspNetCore" }
+ .Any(prefix => category?.StartsWith(prefix, StringComparison.Ordinal) == true);
+ return logLevel >= LogLevel.Warning || (!frameworkNoise && logLevel >= LogLevel.Information);
});
// 设置接口超时时间和上传大小-Kestrel
diff --git a/Evergreen.Health.Api/Views/Home/Index.cshtml b/Evergreen.Health.Api/Views/Home/Index.cshtml
deleted file mode 100644
index 0abb7d0..0000000
--- a/Evergreen.Health.Api/Views/Home/Index.cshtml
+++ /dev/null
@@ -1,7 +0,0 @@
-@{
- ViewData["Title"] = ViewBag.Description;
-}
-
-
-

-
\ No newline at end of file
diff --git a/Evergreen.Health.Api/Views/Shared/_Layout.cshtml b/Evergreen.Health.Api/Views/Shared/_Layout.cshtml
deleted file mode 100644
index b56f063..0000000
--- a/Evergreen.Health.Api/Views/Shared/_Layout.cshtml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
- @ViewData["Title"] - Furion
-
-
- @RenderBody()
-
-
\ No newline at end of file
diff --git a/Evergreen.Health.Api/Views/_ViewImports.cshtml b/Evergreen.Health.Api/Views/_ViewImports.cshtml
deleted file mode 100644
index a55228d..0000000
--- a/Evergreen.Health.Api/Views/_ViewImports.cshtml
+++ /dev/null
@@ -1,2 +0,0 @@
-@using Evergreen.Health.Api
-@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
\ No newline at end of file
diff --git a/Evergreen.Health.Api/Views/_ViewStart.cshtml b/Evergreen.Health.Api/Views/_ViewStart.cshtml
deleted file mode 100644
index d641c67..0000000
--- a/Evergreen.Health.Api/Views/_ViewStart.cshtml
+++ /dev/null
@@ -1,3 +0,0 @@
-@{
- Layout = "_Layout";
-}
\ No newline at end of file
diff --git a/Evergreen.Health.Api/wwwroot/images/logo.png b/Evergreen.Health.Api/wwwroot/images/logo.png
deleted file mode 100644
index da7942977438b43b1e5cc8aa10d56d82c840613a..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 8024
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
a00RI3bIck_|1Z-30000 options, ITcmMusicRuntime music) : base(runner)
+ {
+ _entities = options.Value.Entities; _music = music;
+ Parameters =
+ [
+ new("volume", PrimaryEntityId, value => new("设置音箱音量", PrimaryEntityId, "audio", "volume",
+ new Dictionary { ["volume"] = DeviceValues.Integer(value, 0, 100, "音箱音量") }),
+ "音箱音量", 0, 100),
+ DeviceValues.Switch("speaker_sleep_mode", _entities.SpeakerSleepMode, "switch", "音箱睡眠模式")
+ ];
+ }
+ public override string Key => "audio";
+ public override string Category => nameof(HealthDeviceCategory.Media);
+ public override string DisplayName => "助眠音箱";
+ public override string PrimaryEntityId => _entities.Speaker;
+ public override bool PowerUsesIndependentGateway => true;
+ public override bool ParameterUsesIndependentGateway(DeviceCommand command) => command.Domain == "audio";
+ public override Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
+ => turnOn ? _music.PlayTestMusicAsync(cancellationToken) : _music.StopMusicAsync(cancellationToken);
+ public override IReadOnlyList Parameters { get; }
+
+ public override Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken)
+ => command.Domain == "audio"
+ ? _music.SetManualVolumeAsync((int)command.Data!["volume"]!, cancellationToken)
+ : base.ExecuteParameterAsync(command, cancellationToken);
+
+ public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
+ string constitutionCode, CancellationToken cancellationToken)
+ {
+ await context.RunAsync(BuildParameterCommand("speaker_sleep_mode", config.EnableSpeakerSleepMode), cancellationToken);
+ await context.ExternalAsync("播放体质助眠音乐并渐弱", PrimaryEntityId,
+ token => _music.StartForConstitutionAsync(constitutionCode, token), cancellationToken);
+ }
+
+ public override IEnumerable GetShutdownCommands()
+ => [new("关闭音箱睡眠模式", _entities.SpeakerSleepMode, "switch", "turn_off", ExpectedState: "off")];
+
+ public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
+ {
+ if (string.IsNullOrWhiteSpace(PrimaryEntityId)) return null;
+ var preview = Preview(config.EnableSpeakerSleepMode ? "播放体质音乐并开启睡眠模式" : "播放体质音乐",
+ "体质曲目循环播放 · 音量50%每5秒降1%",
+ new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = config.EnableSpeakerSleepMode ? "开启" : "关闭", ["volume"] = "50%→0%" });
+ preview.Available = _music.HasPlayableTracks(constitutionCode);
+ preview.AvailabilityMessage = preview.Available ? "体质音乐已配置,执行时验证音箱" : "该体质的 OSS 音乐 URL 尚未配置";
+ return preview;
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Devices/Climate/ClimateModule.cs b/Evergreen.Health.Application/Health/Devices/Climate/ClimateModule.cs
new file mode 100644
index 0000000..52af2d7
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Devices/Climate/ClimateModule.cs
@@ -0,0 +1,229 @@
+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 System.Globalization;
+using System.Text.Json;
+
+namespace Evergreen.Health.Application.Health.Devices.Climate;
+
+/// 空调只使用已绑定的 climate 实体;每条命令按设备当前公开能力校验。
+public sealed class ClimateModule : HealthDeviceModule
+{
+ private readonly string _entityId;
+ private const int TargetTemperatureFeature = 1, FanModeFeature = 8, TurnOffFeature = 128, TurnOnFeature = 256;
+ private static readonly string[] KnownModes = ["off", "heat", "cool", "fan_only", "auto", "dry", "heat_cool"];
+
+ public ClimateModule(DeviceActionRunner runner, IOptions options) : base(runner)
+ {
+ _entityId = options.Value.Entities.Climate;
+ Parameters =
+ [
+ new("climate_mode", _entityId, BuildModeCommand, "空调模式"),
+ new("climate_temperature", _entityId, BuildTemperatureCommand, "目标温度"),
+ new("climate_fan_mode", _entityId, BuildFanCommand, "风速")
+ ];
+ }
+
+ public override string Key => "climate";
+ public override string Category => nameof(HealthDeviceCategory.Climate);
+ public override string DisplayName => "空调";
+ public override string PrimaryEntityId => _entityId;
+ public override IReadOnlyList Parameters { get; }
+
+ public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken)
+ {
+ var command = PowerCommand(turnOn);
+ await Runner.SendAsync(command, cancellationToken);
+ if (!turnOn) await Runner.VerifyParameterAsync(command, cancellationToken);
+ }
+
+ private DeviceCommand PowerCommand(bool turnOn) => new(turnOn ? "开启空调" : "关闭空调", _entityId,
+ "climate", turnOn ? "turn_on" : "turn_off", ExpectedState: turnOn ? null : "off")
+ {
+ ValidateCurrentState = state =>
+ {
+ ValidateIdentity(state);
+ RequireFeature(state, turnOn ? TurnOnFeature : TurnOffFeature, turnOn ? "开启" : "关闭");
+ },
+ RetryOnVerificationMismatch = !turnOn
+ };
+
+ private DeviceCommand BuildModeCommand(object? value)
+ {
+ var mode = DeviceValues.Choice(value, KnownModes, "空调模式");
+ return new("设置空调模式", _entityId, "climate", "set_hvac_mode",
+ new Dictionary { ["hvac_mode"] = mode }, mode)
+ {
+ ValidateCurrentState = state =>
+ {
+ ValidateIdentity(state);
+ RequireOption(state, "hvac_modes", mode, "空调模式");
+ },
+ RetryOnVerificationMismatch = false
+ };
+ }
+
+ private DeviceCommand BuildTemperatureCommand(object? value)
+ {
+ if (!decimal.TryParse(DeviceValues.Text(value), NumberStyles.Float, CultureInfo.InvariantCulture, out var temperature))
+ throw new InvalidOperationException("目标温度必须是有效数值");
+ return new("设置空调目标温度", _entityId, "climate", "set_temperature",
+ new Dictionary { ["temperature"] = temperature }, temperature.ToString(CultureInfo.InvariantCulture))
+ {
+ ExpectedAttribute = "temperature",
+ ValidateCurrentState = state => ValidateTemperature(state, temperature),
+ RetryOnVerificationMismatch = false
+ };
+ }
+
+ private DeviceCommand BuildFanCommand(object? value)
+ {
+ var fan = DeviceValues.Text(value);
+ if (string.IsNullOrWhiteSpace(fan)) throw new InvalidOperationException("风速选项不能为空");
+ return new("设置空调风速", _entityId, "climate", "set_fan_mode",
+ new Dictionary { ["fan_mode"] = fan }, fan)
+ {
+ ExpectedAttribute = "fan_mode",
+ ValidateCurrentState = state =>
+ {
+ ValidateIdentity(state);
+ RequireFeature(state, FanModeFeature, "风速设置");
+ RequireOption(state, "fan_modes", fan, "风速");
+ },
+ RetryOnVerificationMismatch = false
+ };
+ }
+
+ public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config,
+ string constitutionCode, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var advice = config.ClimateAdvice;
+ if (string.IsNullOrWhiteSpace(_entityId))
+ {
+ Skip(context, "空调实体尚未配置,仅保留室温建议"); return;
+ }
+ if (advice.Mode is not ("heat" or "cool"))
+ {
+ Skip(context, advice.Mode == "off" ? "室温在建议范围内,本次不额外开启空调,保持现有状态" : advice.Reason); return;
+ }
+ if (!context.States.TryGetValue(_entityId, out var state) || !DeviceActionRunner.IsAvailable(state))
+ {
+ Skip(context, "空调当前离线或不可用,本次不发送启动指令"); return;
+ }
+
+ // 先校验所有参数,禁止在温度无效时先开启空调。随机值由场景准备阶段只抽一次。
+ var modeCommand = BuildModeCommand(advice.Mode);
+ modeCommand.ValidateCurrentState!(state);
+ var target = ResolveSceneTarget(state, advice);
+ advice.TargetTemperatureCelsius = target;
+ var temperatureCommand = BuildTemperatureCommand(target);
+ temperatureCommand.ValidateCurrentState!(state);
+ await context.ExternalAsync(modeCommand.Name, _entityId,
+ token => ExecuteParameterAsync(modeCommand, token), cancellationToken);
+ if (context.Results.Last().Status != "Succeeded") return;
+ await context.ExternalAsync(temperatureCommand.Name, _entityId,
+ token => ExecuteParameterAsync(temperatureCommand, token), cancellationToken);
+ }
+
+ private int ResolveSceneTarget(HomeAssistantState state, ClimateAdviceOutput advice)
+ {
+ var (minimum, maximum, step) = TemperatureBounds(state);
+ if (advice.TargetTemperatureCelsius is not int preparedTarget)
+ throw new InvalidOperationException("本次空调目标温度尚未生成,不自动使用默认温度");
+ var low = Math.Max((decimal)advice.RecommendedMinimumCelsius, minimum);
+ var high = Math.Min((decimal)advice.RecommendedMaximumCelsius, maximum);
+ if (low > high || high - low > 1000)
+ throw new InvalidOperationException("设备温度范围与本次室温建议没有有效交集");
+ var candidates = new List();
+ for (var candidate = decimal.Ceiling(low); candidate <= decimal.Floor(high); candidate++)
+ if (IsOnStep(candidate, minimum, step) && candidate >= int.MinValue && candidate <= int.MaxValue)
+ candidates.Add((int)candidate);
+ if (candidates.Count == 0)
+ throw new InvalidOperationException("室温建议范围内没有符合设备步进的整数目标温度");
+ // 只做确定性范围/步进校正,不因重试或状态刷新重新随机。
+ return candidates.OrderBy(candidate => Math.Abs((long)candidate - preparedTarget)).ThenBy(candidate => candidate).First();
+ }
+
+ private void ValidateIdentity(HomeAssistantState state)
+ {
+ if (!_entityId.StartsWith("climate.", StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("空调主控实体绑定无效");
+ }
+
+ private void ValidateTemperature(HomeAssistantState state, decimal temperature)
+ {
+ ValidateIdentity(state);
+ var (minimum, maximum, step) = TemperatureBounds(state);
+ if (temperature < minimum || temperature > maximum)
+ throw new InvalidOperationException($"该设备目标温度应在 {minimum} 到 {maximum} 之间");
+ if (!IsOnStep(temperature, minimum, step))
+ throw new InvalidOperationException($"目标温度必须符合设备 {step}℃ 的调节步进");
+ }
+
+ private static (decimal Minimum, decimal Maximum, decimal Step) TemperatureBounds(HomeAssistantState state)
+ {
+ RequireFeature(state, TargetTemperatureFeature, "目标温度设置");
+ if (state.GetStringAttribute("temperature_unit") != "°C")
+ throw new InvalidOperationException("尚未确认该空调使用摄氏温度,暂不发送目标温度");
+ var min = state.GetDoubleAttribute("min_temp");
+ var max = state.GetDoubleAttribute("max_temp");
+ var step = state.GetDoubleAttribute("target_temp_step");
+ if (min is null || max is null || step is null || step <= 0 || min > max ||
+ min < (double)decimal.MinValue || max > (double)decimal.MaxValue || step > (double)decimal.MaxValue)
+ throw new InvalidOperationException("空调未提供有效温度范围或调节步进");
+ var decimalStep = (decimal)step.Value;
+ if (decimalStep <= 0) throw new InvalidOperationException("空调调节步进无效");
+ return ((decimal)min.Value, (decimal)max.Value, decimalStep);
+ }
+
+ private static bool IsOnStep(decimal value, decimal minimum, decimal step)
+ {
+ var steps = (value - minimum) / step;
+ return Math.Abs(steps - decimal.Round(steps)) <= 0.00000001m;
+ }
+
+ private static void RequireFeature(HomeAssistantState state, int feature, string label)
+ {
+ var features = state.GetDoubleAttribute("supported_features");
+ if (features is null || features < 0 || features > int.MaxValue || features != Math.Truncate(features.Value) ||
+ (((int)features.Value & feature) != feature))
+ throw new InvalidOperationException($"该空调未声明支持{label}");
+ }
+
+ private static void RequireOption(HomeAssistantState state, string attribute, string option, string label)
+ {
+ if (state.Attributes.ValueKind != JsonValueKind.Object || !state.Attributes.TryGetProperty(attribute, out var list) ||
+ list.ValueKind != JsonValueKind.Array || !list.EnumerateArray().Any(item =>
+ item.ValueKind == JsonValueKind.String && string.Equals(item.GetString(), option, StringComparison.Ordinal)))
+ throw new InvalidOperationException($"该空调不支持{label}:{option}");
+ }
+
+ private void Skip(DeviceSceneContext context, string message) => context.Results.Add(new()
+ { Name = "空调舒睡设置", EntityId = _entityId, Status = "Skipped", Message = message });
+
+ public override IEnumerable GetShutdownCommands()
+ => string.IsNullOrWhiteSpace(_entityId) ? [] : [PowerCommand(false)];
+
+ public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode)
+ {
+ var advice = config.ClimateAdvice;
+ var preview = Preview(advice.Mode switch
+ {
+ "heat" => "按室温开启制热", "cool" => "按室温开启制冷", "off" => "不额外开启空调", _ => "等待有效室温"
+ }, advice.Reason, new()
+ {
+ ["mode"] = advice.Mode, ["temperature_range"] = $"{advice.RecommendedMinimumCelsius}–{advice.RecommendedMaximumCelsius}℃",
+ ["target_temperature"] = advice.TargetTemperatureCelsius
+ });
+ if (string.IsNullOrWhiteSpace(_entityId))
+ {
+ preview.Available = false;
+ preview.AvailabilityMessage = "空调实体尚未配置,仅显示建议";
+ }
+ return preview;
+ }
+}
diff --git a/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs b/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs
new file mode 100644
index 0000000..b36b404
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Devices/Contracts/DeviceCommand.cs
@@ -0,0 +1,27 @@
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+
+namespace Evergreen.Health.Application.Health.Devices.Contracts;
+
+public enum DeviceCommandIntent { Normal, StartMassage, TurnRelaxationLightOn, TurnRelaxationLightOff }
+
+/// 实体由后端模块提供,不接受客户端任意实体或任意服务名。
+public sealed record DeviceCommand(string Name, string EntityId, string Domain, string Service,
+ IReadOnlyDictionary? Data = null, string? ExpectedState = null)
+{
+ public DeviceCommandIntent Intent { get; init; } = DeviceCommandIntent.Normal;
+ // 新设备的能力校验由模块提供,所有发送路径(含场景关闭)都不可绕过。
+ public Action? ValidateCurrentState { get; init; }
+ // 不为空时 ExpectedState 表示此属性的期望值,而不是实体的顶层状态。
+ public string? ExpectedAttribute { get; init; }
+ public bool RetryOnVerificationMismatch { get; init; } = true;
+ public Dictionary ToServiceData()
+ {
+ var result = Data is null ? new Dictionary() : new Dictionary(Data);
+ result["entity_id"] = EntityId;
+ return result;
+ }
+}
+
+public sealed record DeviceParameterDefinition(string Key, string EntityId,
+ Func