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 da79429..0000000
Binary files a/Evergreen.Health.Api/wwwroot/images/logo.png and /dev/null differ
diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json
index 71e8f92..78ebef1 100644
--- a/Evergreen.Health.Application/Configuration/HomeAssistant.json
+++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json
@@ -18,6 +18,9 @@
"AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light",
"Speaker": "media_player.xiaomi_cn_1221084723_l17a",
"SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3",
+ "Climate": "climate.fawad_cn_974720212_3010",
+ "Curtain": "cover.czmydz_cn_2251706154_mym1_s_2_curtain",
+ "Outlet": "switch.ym001_cn_2108613283_ymcz_on_p_2_1",
"MattressLeftHeatingLevel": "number.qushui_cn_2108064504_zerox_left_level_p_6_1",
"MattressRightHeatingLevel": "number.qushui_cn_2108064504_zerox_right_level_p_6_2",
"MattressLeftMode": "select.qushui_cn_2108064504_zerox_left_mode_p_5_1",
diff --git a/Evergreen.Health.Application/Health/Devices/Audio/AudioModule.cs b/Evergreen.Health.Application/Health/Devices/Audio/AudioModule.cs
new file mode 100644
index 0000000..5c1103d
--- /dev/null
+++ b/Evergreen.Health.Application/Health/Devices/Audio/AudioModule.cs
@@ -0,0 +1,61 @@
+using Evergreen.Health.Application.Health.Devices.Contracts;
+using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
+using Evergreen.Health.Application.Health.Models;
+using Evergreen.Health.Application.Health.Scenes;
+using Microsoft.Extensions.Options;
+using Evergreen.Health.Application.Health.Integrations.MiAirX;
+
+namespace Evergreen.Health.Application.Health.Devices.Audio;
+
+public sealed class AudioModule : HealthDeviceModule
+{
+ private readonly HomeAssistantEntityOptions _entities;
+ private readonly ITcmMusicRuntime _music;
+ public AudioModule(DeviceActionRunner runner, IOptions 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