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 Build, string Label, + int? Minimum = null, int? Maximum = null, IReadOnlyList? Options = null); diff --git a/Evergreen.Health.Application/Health/Devices/Contracts/DeviceValues.cs b/Evergreen.Health.Application/Health/Devices/Contracts/DeviceValues.cs new file mode 100644 index 0000000..856aacd --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Contracts/DeviceValues.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Devices.Contracts; + +public static class DeviceValues +{ + public static string Text(object? value) + => value is JsonElement element + ? (element.ValueKind == JsonValueKind.String ? element.GetString() ?? "" : element.ToString()).Trim() + : Convert.ToString(value, CultureInfo.InvariantCulture)?.Trim() ?? ""; + + public static int Integer(object? value, int minimum, int maximum, string label) + { + if (!int.TryParse(Text(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) || + number < minimum || number > maximum) + throw new InvalidOperationException($"{label}应在 {minimum} 到 {maximum} 之间"); + return number; + } + + public static bool Boolean(object? value) + { + if (bool.TryParse(Text(value), out var result)) return result; + throw new InvalidOperationException("开关参数无效"); + } + + public static string Choice(object? value, IReadOnlyList options, string label) + { + var text = Text(value); + if (!options.Contains(text, StringComparer.Ordinal)) + throw new InvalidOperationException($"{label}选项无效"); + return text; + } + + public static DeviceParameterDefinition Switch(string key, string entity, string domain, string label) + => new(key, entity, value => new(label, entity, domain, + Boolean(value) ? "turn_on" : "turn_off"), label); + + public static DeviceParameterDefinition Number(string key, string entity, int min, int max, string label) + => new(key, entity, value => new(label, entity, "number", "set_value", + new Dictionary { ["value"] = Integer(value, min, max, label) }), label, min, max); + + public static DeviceParameterDefinition Select(string key, string entity, IReadOnlyList options, string label, + bool verify = false, DeviceCommandIntent intent = DeviceCommandIntent.Normal) + => new(key, entity, value => + { + var option = Choice(value, options, label); + return new(label, entity, "select", "select_option", + new Dictionary { ["option"] = option }, verify ? option : null) + { Intent = option == "无" ? DeviceCommandIntent.Normal : intent }; + }, label, Options: options); +} diff --git a/Evergreen.Health.Application/Health/Devices/Contracts/IHealthDeviceModule.cs b/Evergreen.Health.Application/Health/Devices/Contracts/IHealthDeviceModule.cs new file mode 100644 index 0000000..c87ac7e --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Contracts/IHealthDeviceModule.cs @@ -0,0 +1,25 @@ +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Models; +using Evergreen.Health.Application.Health.Scenes; + +namespace Evergreen.Health.Application.Health.Devices.Contracts; + +/// 新增家具实现此契约并注册即可;场景和 API 不枚举具体家具类型。 +public interface IHealthDeviceModule +{ + string Key { get; } + string Category { get; } + string DisplayName { get; } + string PrimaryEntityId { get; } + bool PowerUsesIndependentGateway { get; } + IReadOnlyList Parameters { get; } + IEnumerable EntityIds { get; } + DeviceCommand BuildParameterCommand(string parameterType, object? value); + Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken); + bool ParameterUsesIndependentGateway(DeviceCommand command); + Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken); + Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, + string constitutionCode, CancellationToken cancellationToken); + IEnumerable GetShutdownCommands(); + HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode); +} diff --git a/Evergreen.Health.Application/Health/Devices/Curtain/CurtainModule.cs b/Evergreen.Health.Application/Health/Devices/Curtain/CurtainModule.cs new file mode 100644 index 0000000..7483bb8 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Curtain/CurtainModule.cs @@ -0,0 +1,76 @@ +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; + +namespace Evergreen.Health.Application.Health.Devices.Curtain; + +/// 窗帘开合不是电源开关;本版只提供单独手动控制,不擅自加入舒睡/全关。 +public sealed class CurtainModule : HealthDeviceModule +{ + private readonly string _entityId; + public CurtainModule(DeviceActionRunner runner, IOptions options) : base(runner) + { + _entityId = options.Value.Entities.Curtain; + Parameters = + [ + new("curtain_action", _entityId, BuildAction, "窗帘动作", Options: ["open", "stop", "close"]), + new("curtain_position", _entityId, BuildPosition, "窗帘开度", 0, 100) + ]; + } + public override string Key => "curtain"; + public override string Category => nameof(HealthDeviceCategory.Curtain); + public override string DisplayName => "窗帘"; + public override string PrimaryEntityId => _entityId; + public override IReadOnlyList Parameters { get; } + + private DeviceCommand BuildAction(object? value) + { + var action = DeviceValues.Choice(value, ["open", "stop", "close"], "窗帘动作"); + var (service, feature, label) = action switch + { + "open" => ("open_cover", 1, "打开窗帘"), "close" => ("close_cover", 2, "关闭窗帘"), + _ => ("stop_cover", 8, "停止窗帘") + }; + return new(label, _entityId, "cover", service) + { + ValidateCurrentState = state => ValidateFeature(state, feature, label), + RetryOnVerificationMismatch = false + }; + } + + private DeviceCommand BuildPosition(object? value) + { + var position = DeviceValues.Integer(value, 0, 100, "窗帘开度"); + return new("设置窗帘开度", _entityId, "cover", "set_cover_position", + new Dictionary { ["position"] = position }) + { + ValidateCurrentState = state => ValidateFeature(state, 4, "设置窗帘开度"), + RetryOnVerificationMismatch = false + }; + } + + private void ValidateFeature(HomeAssistantState state, int feature, string label) + { + if (!_entityId.StartsWith("cover.", StringComparison.OrdinalIgnoreCase) || + !string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("窗帘主控实体绑定无效"); + 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}"); + } + + public override Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken) + => throw new InvalidOperationException("窗帘没有电源开关,请使用打开、停止或关闭控制"); + + public override Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, + string constitutionCode, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + context.Results.Add(new() { Name = "窗帘场景联动", EntityId = _entityId, Status = "Skipped", + Message = string.IsNullOrWhiteSpace(_entityId) ? "窗帘实体尚未配置" : "本版窗帘只支持手动控制,舒睡不自动改变开合位置" }); + return Task.CompletedTask; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierModule.cs b/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierModule.cs new file mode 100644 index 0000000..7e23855 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Dehumidifier/DehumidifierModule.cs @@ -0,0 +1,52 @@ +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; + +namespace Evergreen.Health.Application.Health.Devices.Dehumidifier; + +public sealed class DehumidifierModule : HealthDeviceModule +{ + private readonly HomeAssistantEntityOptions _entities; + public DehumidifierModule(DeviceActionRunner runner, IOptions options) : base(runner) + { + _entities = options.Value.Entities; + Parameters = + [ + new("humidity", PrimaryEntityId, value => new("设置目标湿度", PrimaryEntityId, "humidifier", "set_humidity", + new Dictionary { ["humidity"] = DeviceValues.Integer(value, 40, 70, "目标湿度") }), "目标湿度", 40, 70), + new("fan_level", _entities.DehumidifierFanLevel, value => new("设置风速", _entities.DehumidifierFanLevel, "select", "select_option", + new Dictionary { ["option"] = DeviceValues.Choice(DeviceValues.Text(value).Replace('档', '挡'), + ["1挡", "2挡", "3挡"], "风速档位") }), "风速档位", Options: ["1挡", "2挡", "3挡"]), + new("dehumidifier_mode", PrimaryEntityId, value => new("设置除湿模式", PrimaryEntityId, "humidifier", "set_mode", + new Dictionary { ["mode"] = DeviceValues.Choice(value, ["睡眠", "智能"], "除湿模式") }), + "除湿模式", Options: ["睡眠", "智能"]), + DeviceValues.Switch("uv", _entities.DehumidifierUv, "switch", "UV 杀菌") + ]; + } + public override string Key => "dehumidifier"; + public override string Category => nameof(HealthDeviceCategory.EnvironmentControl); + public override string DisplayName => "除湿机"; + public override string PrimaryEntityId => _entities.Dehumidifier; + public override IReadOnlyList Parameters { get; } + + public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, + string constitutionCode, CancellationToken cancellationToken) + { + await context.RunAsync(new("开启除湿机", PrimaryEntityId, "humidifier", "turn_on"), cancellationToken); + await context.RunAsync(BuildParameterCommand("humidity", config.TargetHumidity), cancellationToken); + await context.RunAsync(BuildParameterCommand("dehumidifier_mode", config.DehumidifierMode), cancellationToken); + await context.RunAsync(BuildParameterCommand("fan_level", config.FanLevel), cancellationToken); + await context.RunAsync(BuildParameterCommand("uv", config.EnableUv), cancellationToken); + } + + public override IEnumerable GetShutdownCommands() + => [new("关闭除湿机", PrimaryEntityId, "humidifier", "turn_off", ExpectedState: "off")]; + + public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) + => string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview("开启恒湿舒睡", + $"目标湿度 {config.TargetHumidity}% · {config.DehumidifierMode}模式 · 风速{config.FanLevel} · UV{(config.EnableUv ? "开" : "关")}", + new() { ["target_humidity"] = $"{config.TargetHumidity}%", ["mode"] = config.DehumidifierMode, + ["fan_level"] = config.FanLevel, ["uv"] = config.EnableUv ? "开启" : "关闭" }); +} diff --git a/Evergreen.Health.Application/Health/Devices/DeviceModuleRegistry.cs b/Evergreen.Health.Application/Health/Devices/DeviceModuleRegistry.cs new file mode 100644 index 0000000..3611d1c --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/DeviceModuleRegistry.cs @@ -0,0 +1,35 @@ +using Evergreen.Health.Application.Health.Devices.Contracts; + +namespace Evergreen.Health.Application.Health.Devices; + +public sealed class DeviceModuleRegistry +{ + public IReadOnlyList Modules { get; } + private readonly IReadOnlyDictionary _parameterOwners; + + public DeviceModuleRegistry(IEnumerable modules) + { + Modules = modules.ToArray(); + if (Modules.Select(module => module.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count() != Modules.Count) + throw new InvalidOperationException("设备模块 Key 重复"); + var owners = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var module in Modules) + foreach (var parameter in module.Parameters) + if (!owners.TryAdd(parameter.Key, module)) + throw new InvalidOperationException($"设备参数重复注册:{parameter.Key}"); + _parameterOwners = owners; + } + + public IHealthDeviceModule ForParameter(string parameterType) + => _parameterOwners.TryGetValue(parameterType, out var module) + ? module : throw new InvalidOperationException($"不支持的参数类型:{parameterType}"); + + public IHealthDeviceModule? ForEntities(IEnumerable entityIds) + { + var ids = entityIds.ToHashSet(StringComparer.OrdinalIgnoreCase); + return Modules.Select(module => new { Module = module, + Score = module.EntityIds.Count(id => !string.IsNullOrWhiteSpace(id) && ids.Contains(id)) }) + .Where(item => item.Score > 0).OrderByDescending(item => item.Score) + .Select(item => item.Module).FirstOrDefault(); + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs new file mode 100644 index 0000000..89b349c --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs @@ -0,0 +1,335 @@ +using Evergreen.Health.Application.Health.Devices; +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.Discovery; + +public sealed class HomeAssistantDeviceCatalog +{ + private readonly HomeAssistantOptions _options; + private readonly DeviceModuleRegistry _modules; + public HomeAssistantDeviceCatalog(IOptions options, DeviceModuleRegistry modules) + => (_options, _modules) = (options.Value, modules); + + public List BuildDiscoveredDevices( + HomeAssistantRegistries? registries, + IReadOnlyDictionary stateMap) + { + var discovered = new List(); + + if (registries is not null && registries.Devices.Count > 0) + { + var areaMap = registries.Areas.ToDictionary(a => a.AreaId, a => a.Name, StringComparer.OrdinalIgnoreCase); + var entitiesByDevice = registries.Entities + .Where(e => !string.IsNullOrWhiteSpace(e.DeviceId) && string.IsNullOrWhiteSpace(e.DisabledBy)) + .GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + + // HA 将 Sun、Backup 等虚拟系统服务标记为 service,不属于家具或实体传感器。 + foreach (var device in registries.Devices.Where(device => string.IsNullOrWhiteSpace(device.DisabledBy) && + !string.Equals(device.EntryType, "service", StringComparison.OrdinalIgnoreCase))) + { + var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? []; + if (regEntities.Count == 0) continue; + + var entityOutputs = new List(); + foreach (var regEntity in regEntities) + { + stateMap.TryGetValue(regEntity.EntityId, out var state); + var available = state is not null && IsAvailable(state); + var domain = regEntity.EntityId.Contains('.') ? regEntity.EntityId.Split('.')[0] : ""; + var name = !string.IsNullOrWhiteSpace(regEntity.Name) + ? regEntity.Name + : (!string.IsNullOrWhiteSpace(regEntity.OriginalName) ? regEntity.OriginalName : regEntity.EntityId); + + entityOutputs.Add(new HomeAssistantEntityOutput + { + EntityId = regEntity.EntityId, + Domain = domain, + Name = name, + State = state?.State ?? "unavailable", + Available = available, + Unit = state?.GetStringAttribute("unit_of_measurement"), + Attributes = ExtractSafeAttributes(state) + }); + } + + var isOnline = regEntities.Any(e => + { + if (!string.IsNullOrWhiteSpace(e.DisabledBy)) return false; + return stateMap.TryGetValue(e.EntityId, out var s) && IsAvailable(s); + }); + + var displayName = !string.IsNullOrWhiteSpace(device.NameByUser) + ? device.NameByUser + : (!string.IsNullOrWhiteSpace(device.Name) ? device.Name : "智能设备"); + + var areaName = !string.IsNullOrWhiteSpace(device.AreaId) && areaMap.TryGetValue(device.AreaId, out var an) + ? an + : null; + + var module = _modules.ForEntities(entityOutputs.Select(entity => entity.EntityId)); + var category = module?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs); + var metrics = ExtractDeviceMetrics(category, entityOutputs); + var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs); + + discovered.Add(new HomeAssistantDiscoveredDeviceOutput + { + DeviceId = device.Id, + ModuleKey = module?.Key ?? "", + Name = displayName, + Manufacturer = device.Manufacturer ?? "", + Model = device.Model ?? "", + AreaName = areaName, + Category = category, + Online = isOnline, + PrimaryControlEntityId = primaryControlEntityId, + Entities = entityOutputs, + Metrics = metrics + }); + } + } + + // 有效注册表被过滤后为空,不代表注册表读取失败,不能重新生成占位设备。 + if (registries is null || registries.Devices.Count == 0) + { + discovered = BuildFallbackDevices(stateMap); + } + + return discovered; + } + + private List BuildFallbackDevices(IReadOnlyDictionary stateMap) + { + var list = new List(); + // 注册信息暂不可读时只展示真实配置过的模块,不凭空创建床垫/体脂秤。 + foreach (var module in _modules.Modules) + { + var entityOutputs = module.EntityIds.Select(id => + { + stateMap.TryGetValue(id, out var state); + return new HomeAssistantEntityOutput + { + EntityId = id, Domain = id.Split('.')[0], Name = state?.GetStringAttribute("friendly_name") ?? module.DisplayName, + State = state?.State ?? "unavailable", Available = state is not null && IsAvailable(state), + Unit = state?.GetStringAttribute("unit_of_measurement"), Attributes = ExtractSafeAttributes(state) + }; + }).ToList(); + if (entityOutputs.Count == 0) continue; + list.Add(new() + { + DeviceId = $"fallback_{module.Key}", ModuleKey = module.Key, Name = module.DisplayName, + Category = module.Category, Online = entityOutputs.Any(entity => entity.Available), + PrimaryControlEntityId = FindPrimaryControlEntityId(module.Category, entityOutputs), + Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs) + }); + } + return list; + } + + private string? FindPrimaryControlEntityId( + string category, + IReadOnlyList entities) + { + var module = _modules.ForEntities(entities.Select(entity => entity.EntityId)); + if (module is not null) + return !string.IsNullOrWhiteSpace(module.PrimaryEntityId) && + entities.Any(entity => string.Equals(entity.EntityId, module.PrimaryEntityId, StringComparison.OrdinalIgnoreCase)) + ? module.PrimaryEntityId : null; + + return category switch + { + nameof(HealthDeviceCategory.EnvironmentControl) => entities.FirstOrDefault(e => e.Domain == "humidifier")?.EntityId, + nameof(HealthDeviceCategory.Lighting) => entities.FirstOrDefault(e => e.Domain == "light")?.EntityId, + nameof(HealthDeviceCategory.Media) => entities.FirstOrDefault(e => e.Domain == "media_player")?.EntityId, + nameof(HealthDeviceCategory.Climate) => entities.FirstOrDefault(e => e.Domain == "climate")?.EntityId, + _ => null + }; + } + + public static HomeAssistantSummaryOutput BuildSummary( + IReadOnlyList devices) + { + var totalDevices = devices.Count; + var onlineDevices = devices.Count(d => d.Online); + var offlineDevices = totalDevices - onlineDevices; + var totalEntities = devices.Sum(d => d.Entities.Count); + var availableEntities = devices.Sum(d => d.Entities.Count(e => e.Available)); + + return new HomeAssistantSummaryOutput + { + TotalDevices = totalDevices, + OnlineDevices = onlineDevices, + OfflineDevices = offlineDevices, + TotalEntities = totalEntities, + AvailableEntities = availableEntities + }; + } + + public HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary stateMap) + { + var configured = _modules.Modules.Where(module => module.EntityIds.Any()).ToArray(); + var sceneAvailableCount = configured.Count(module => module.EntityIds.Any(entityId => + stateMap.TryGetValue(entityId, out var state) && IsAvailable(state))); + + return new HomeAssistantSceneDevicesSummaryOutput + { + Total = configured.Length, + Available = sceneAvailableCount + }; + } + + public HomeAssistantEnvironmentOutput BuildEnvironmentOutput( + IReadOnlyDictionary stateMap, + IReadOnlyList devices) + { + var entities = _options.Entities; + var envDevice = devices.FirstOrDefault(d => d.Category == nameof(HealthDeviceCategory.EnvironmentSensor)); + + double? GetVal(string entityId, string metricKey) + { + if (!string.IsNullOrWhiteSpace(entityId)) + { + return stateMap.TryGetValue(entityId, out var state) && IsAvailable(state) && + double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && + double.IsFinite(value) ? value : null; + } + if (envDevice?.Metrics.TryGetValue(metricKey, out var metric) == true && metric is not null && + double.TryParse(Convert.ToString(metric, CultureInfo.InvariantCulture), NumberStyles.Float, + CultureInfo.InvariantCulture, out var fallback) && double.IsFinite(fallback)) return fallback; + return null; + } + + var humidity = GetVal(entities.Humidity, "humidity"); + if (humidity == null && !string.IsNullOrWhiteSpace(entities.Dehumidifier) && + stateMap.TryGetValue(entities.Dehumidifier, out var dehumState) && IsAvailable(dehumState)) + { + humidity = dehumState.GetDoubleAttribute("current_humidity"); + } + + return new HomeAssistantEnvironmentOutput + { + Temperature = GetVal(entities.Temperature, "temperature"), + Humidity = humidity, + Pm25 = GetVal(entities.Pm25, "pm25"), + Pm10 = GetVal(string.Empty, "pm10"), + Co2 = GetVal(entities.Co2, "co2"), + Hcho = GetVal(entities.Formaldehyde, "hcho"), + Tvoc = GetVal(entities.Tvoc, "tvoc") + }; + } + + private static string CategorizeDevice( + string name, + string model, + string manufacturer, + IReadOnlyList entities) + { + var text = $"{name} {model} {manufacturer}".ToLowerInvariant(); + + if (text.Contains("je01") || text.Contains("睡眠监测") || text.Contains("睡眠检测") || text.Contains("雷达")) + return nameof(HealthDeviceCategory.SleepMonitoring); + if (entities.Any(entity => entity.Domain == "climate")) return nameof(HealthDeviceCategory.Climate); + if (entities.Any(entity => entity.Domain == "cover")) return nameof(HealthDeviceCategory.Curtain); + + if (text.Contains("除湿") || text.Contains("derh") || entities.Any(e => e.Domain == "humidifier")) + return nameof(HealthDeviceCategory.EnvironmentControl); + + if (text.Contains("空气检测") || text.Contains("airm") || text.Contains("et71")) + return nameof(HealthDeviceCategory.EnvironmentSensor); + + if (text.Contains("床垫") || text.Contains("mattress")) + return nameof(HealthDeviceCategory.Sleep); + + if (text.Contains("体脂") || text.Contains("scale")) + return nameof(HealthDeviceCategory.BodyComposition); + + if (text.Contains("香氛") || text.Contains("diffuser") || text.Contains("xw002")) + return nameof(HealthDeviceCategory.Fragrance); + + if (text.Contains("灯") || text.Contains("light") || text.Contains("lemesh")) + return nameof(HealthDeviceCategory.Lighting); + + if (text.Contains("sound") || text.Contains("音箱") || text.Contains("wifispeaker") || text.Contains("l17a") || entities.Any(e => e.Domain == "media_player")) + return nameof(HealthDeviceCategory.Media); + + return nameof(HealthDeviceCategory.Other); + } + + private static Dictionary ExtractDeviceMetrics(string category, IReadOnlyList entities) + { + var metrics = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entity in entities) + { + if (!entity.Available) continue; + var id = entity.EntityId.ToLowerInvariant(); + + if (id.Contains("relative_humidity") || (entity.Domain == "humidifier" && entity.Attributes?.ContainsKey("current_humidity") == true)) + { + metrics["humidity"] = entity.Attributes?.GetValueOrDefault("current_humidity") ?? entity.State; + } + if (entity.Domain == "humidifier") + { + if (entity.Attributes?.TryGetValue("humidity", out var th) == true) metrics["targetHumidity"] = th; + if (entity.Attributes?.TryGetValue("mode", out var mode) == true) metrics["mode"] = mode; + } + if (id.Contains("temperature")) metrics["temperature"] = entity.State; + if (id.Contains("pm2_5") || id.Contains("pm25")) metrics["pm25"] = entity.State; + if (id.Contains("pm10")) metrics["pm10"] = entity.State; + if (id.Contains("co2")) metrics["co2"] = entity.State; + if (id.Contains("hcho") || id.Contains("formaldehyde")) metrics["hcho"] = entity.State; + if (id.Contains("tvoc")) metrics["tvoc"] = entity.State; + if (id.Contains("weight")) metrics["weight"] = entity.State; + if (id.Contains("body_fat") || id.Contains("fat_rate")) metrics["bodyFatRate"] = entity.State; + if (id.Contains("firmness") || id.Contains("soft")) metrics["firmness"] = entity.State; + if (id.Contains("sleep_state") || id.Contains("sleep_status")) metrics["sleepStatus"] = entity.State; + } + + return metrics; + } + + public static Dictionary? ExtractSafeAttributes(HomeAssistantState? state) + { + if (state == null || state.Attributes.ValueKind != JsonValueKind.Object) return null; + var safe = new Dictionary(StringComparer.OrdinalIgnoreCase); + var safeKeys = new[] { + "friendly_name", "unit_of_measurement", "device_class", + "current_humidity", "humidity", "mode", "fan_mode", "percentage", + "brightness", "color_temp", "color_temp_kelvin", + "volume_level", "is_volume_muted", "media_title", + "battery_level", "temperature", "options", "min", "max", "step", "hvac_modes", "supported_features", + "current_temperature", "temperature_unit", "min_temp", "max_temp", "target_temp_step", + "fan_modes", "swing_modes", "swing_mode", "hvac_action", "current_position" + }; + + foreach (var key in safeKeys) + { + if (state.Attributes.TryGetProperty(key, out var prop)) + { + safe[key] = ToApiAttributeValue(prop); + } + } + return safe.Count > 0 ? safe : null; + } + + // HA 用 System.Text.Json 读取,API 用 Newtonsoft.Json 输出。不能把 JsonElement + // 留在 object[] 中,否则选项被输出为 ValueKind 对象,而不是原始字符串。 + private static object? ToApiAttributeValue(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out var integer) ? (object)integer : value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Array => value.EnumerateArray().Select(ToApiAttributeValue).ToArray(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary(property => property.Name, + property => ToApiAttributeValue(property.Value), StringComparer.Ordinal), + _ => null + }; + + private static bool IsAvailable(HomeAssistantState state) => DeviceActionRunner.IsAvailable(state); +} diff --git a/Evergreen.Health.Application/Health/Devices/Environment/EnvironmentModule.cs b/Evergreen.Health.Application/Health/Devices/Environment/EnvironmentModule.cs new file mode 100644 index 0000000..bafd73b --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Environment/EnvironmentModule.cs @@ -0,0 +1,23 @@ +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; + +namespace Evergreen.Health.Application.Health.Devices.Environment; + +public sealed class EnvironmentModule : HealthDeviceModule +{ + private readonly HomeAssistantEntityOptions _entities; + public EnvironmentModule(DeviceActionRunner runner, IOptions options) : base(runner) + => _entities = options.Value.Entities; + public override string Key => "environment"; + public override string Category => nameof(HealthDeviceCategory.EnvironmentSensor); + public override string DisplayName => "室内环境检测仪"; + public override string PrimaryEntityId => ""; + public override IReadOnlyList Parameters => []; + public override IEnumerable EntityIds => new[] + { + _entities.Temperature, _entities.Humidity, _entities.Pm25, _entities.Co2, _entities.Formaldehyde, _entities.Tvoc + }.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase); +} diff --git a/Evergreen.Health.Application/Health/Devices/Environment/IndoorEnvironmentReader.cs b/Evergreen.Health.Application/Health/Devices/Environment/IndoorEnvironmentReader.cs new file mode 100644 index 0000000..b4d175f --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Environment/IndoorEnvironmentReader.cs @@ -0,0 +1,34 @@ +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Policies; +using Evergreen.Health.Application.Health.Scenes; +using Microsoft.Extensions.Options; +using System.Globalization; + +namespace Evergreen.Health.Application.Health.Devices.Environment; + +public sealed class IndoorEnvironmentReader +{ + private readonly HomeAssistantEntityOptions _entities; + private readonly TimeProvider _time; + public IndoorEnvironmentReader(IOptions options, TimeProvider time) + => (_entities, _time) = (options.Value.Entities, time); + + public IndoorTemperatureSample Read(IReadOnlyDictionary states) + { + if (string.IsNullOrWhiteSpace(_entities.Temperature) || + !states.TryGetValue(_entities.Temperature, out var state) || !DeviceActionRunner.IsAvailable(state)) + return new(null, null, false, "室内温度实体未配置或不可用"); + var observedAt = state.LastReported ?? state.LastUpdated; + if (observedAt is null || _time.GetUtcNow() - observedAt > TimeSpan.FromMinutes(30) || + observedAt > _time.GetUtcNow().AddMinutes(1)) + return new(null, observedAt, false, "室内温度数据缺少有效时间或已过期"); + if (!double.TryParse(state.State, NumberStyles.Float, CultureInfo.InvariantCulture, out var celsius) || + !double.IsFinite(celsius)) return new(null, observedAt, false, "室内温度数据无效"); + var unit = state.GetStringAttribute("unit_of_measurement"); + if (unit is "°F" or "F") celsius = (celsius - 32) * 5 / 9; + else if (!string.IsNullOrWhiteSpace(unit) && unit is not ("°C" or "C" or "℃")) + return new(null, observedAt, false, "室内温度单位未识别"); + if (celsius is < -20 or > 60) return new(null, observedAt, false, "室内温度超出有效范围"); + return new(celsius, observedAt, true, "室内温度数据有效"); + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Fragrance/FragranceModule.cs b/Evergreen.Health.Application/Health/Devices/Fragrance/FragranceModule.cs new file mode 100644 index 0000000..9ced09b --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Fragrance/FragranceModule.cs @@ -0,0 +1,68 @@ +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; + +namespace Evergreen.Health.Application.Health.Devices.Fragrance; + +public sealed class FragranceModule : HealthDeviceModule +{ + private readonly HomeAssistantEntityOptions _entities; + public FragranceModule(DeviceActionRunner runner, IOptions options) : base(runner) + { + _entities = options.Value.Entities; + Parameters = + [ + DeviceValues.Number("fragrance_duration", _entities.FragranceDuration, 2, 6, "出香时长"), + new("fragrance_interval", _entities.FragranceInterval, value => + { + var interval = DeviceValues.Integer(value, 10, 20, "出香间隔"); + if (interval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟"); + return new("设置出香间隔", _entities.FragranceInterval, "number", "set_value", + new Dictionary { ["value"] = interval }); + }, "出香间隔", 10, 20), + DeviceValues.Switch("fragrance_light", _entities.FragranceLight, "light", "香氛灯"), + new("fragrance_quick_release", _entities.FragranceQuickRelease, _ => new("快速出香", + _entities.FragranceQuickRelease, "button", "press"), "快速出香"), + DeviceValues.Switch("fragrance_auto_release", _entities.FragranceAutoRelease, "switch", "人来增香"), + DeviceValues.Switch("fragrance_auto_light", _entities.FragranceAutoLight, "switch", "感应夜灯"), + DeviceValues.Switch("fragrance_light_auto_off", _entities.FragranceLightAutoOff, "switch", "夜灯自动关闭") + ]; + } + public override string Key => "fragrance"; + public override string Category => nameof(HealthDeviceCategory.Fragrance); + public override string DisplayName => "智能香氛机"; + public override string PrimaryEntityId => _entities.FragranceSwitch; + public override IReadOnlyList Parameters { get; } + + public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, + string constitutionCode, CancellationToken cancellationToken) + { + await context.RunAsync(new(config.EnableFragrance ? "开启香氛机" : "关闭香氛机", + PrimaryEntityId, "switch", config.EnableFragrance ? "turn_on" : "turn_off"), cancellationToken); + if (config.EnableFragrance) + { + var level = Math.Clamp(config.FragranceLevel, 1, 3); + await context.RunAsync(BuildParameterCommand("fragrance_duration", level * 2), cancellationToken); + await context.RunAsync(BuildParameterCommand("fragrance_interval", 25 - level * 5), cancellationToken); + } + await context.RunAsync(new(config.EnableFragrance ? "开启香氛灯" : "关闭香氛灯", + _entities.FragranceLight, "light", config.EnableFragrance ? "turn_on" : "turn_off", + config.EnableFragrance ? new Dictionary { ["brightness_pct"] = config.LightBrightnessPercent } : null), + cancellationToken); + } + + public override IEnumerable GetShutdownCommands() => + [ + new("关闭香氛机", PrimaryEntityId, "switch", "turn_off", ExpectedState: "off"), + new("关闭香氛灯", _entities.FragranceLight, "light", "turn_off", ExpectedState: "off"), + new("关闭感应夜灯", _entities.FragranceAutoLight, "switch", "turn_off", ExpectedState: "off"), + new("关闭人来增香", _entities.FragranceAutoRelease, "switch", "turn_off", ExpectedState: "off") + ]; + + public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) + => string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview(config.EnableFragrance ? "开启香氛释放" : "关闭香氛释放", + config.EnableFragrance ? "开启香氛机;香氛灯可用时同步开启柔光" : "当前体质预设不启动香氛", + new() { ["power"] = config.EnableFragrance ? "开启" : "关闭" }); +} diff --git a/Evergreen.Health.Application/Health/Devices/HealthDeviceModule.cs b/Evergreen.Health.Application/Health/Devices/HealthDeviceModule.cs new file mode 100644 index 0000000..d8395a0 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/HealthDeviceModule.cs @@ -0,0 +1,52 @@ +using Evergreen.Health.Application.Health.Devices.Contracts; +using Evergreen.Health.Application.Health.Models; +using Evergreen.Health.Application.Health.Scenes; + +namespace Evergreen.Health.Application.Health.Devices; + +public abstract class HealthDeviceModule : IHealthDeviceModule +{ + protected DeviceActionRunner Runner { get; } + protected HealthDeviceModule(DeviceActionRunner runner) => Runner = runner; + public abstract string Key { get; } + public abstract string Category { get; } + public abstract string DisplayName { get; } + public abstract string PrimaryEntityId { get; } + public virtual bool PowerUsesIndependentGateway => false; + public virtual bool ParameterUsesIndependentGateway(DeviceCommand command) => false; + public abstract IReadOnlyList Parameters { get; } + public virtual IEnumerable EntityIds => Parameters.Select(parameter => parameter.EntityId) + .Append(PrimaryEntityId).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase); + + public DeviceCommand BuildParameterCommand(string parameterType, object? value) + => (Parameters.FirstOrDefault(parameter => string.Equals(parameter.Key, parameterType, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"不支持的参数类型:{parameterType}")).Build(value); + + public virtual async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken) + { + await Runner.SendAsync(command, cancellationToken); + if (command.ExpectedState is not null) + await Runner.VerifyParameterAsync(command, cancellationToken); + } + + public virtual Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken) + { + var domain = PrimaryEntityId.Split('.')[0]; + if (domain is not ("switch" or "humidifier" or "light" or "media_player" or "climate")) + throw new InvalidOperationException("该模块没有安全的主控开关"); + return Runner.SendAsync(new(turnOn ? "开启设备" : "关闭设备", PrimaryEntityId, domain, + turnOn ? "turn_on" : "turn_off"), cancellationToken); + } + + public virtual Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, + string constitutionCode, CancellationToken cancellationToken) => Task.CompletedTask; + public virtual IEnumerable GetShutdownCommands() => []; + public virtual HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) => null; + + protected HomeAssistantScenePresetItem Preview(string action, string detail, + Dictionary? parameters = null) => new() + { + DeviceName = DisplayName, Category = Category, EntityId = PrimaryEntityId, + Action = action, Detail = detail, Parameters = parameters ?? new() + }; +} diff --git a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs new file mode 100644 index 0000000..2549308 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs @@ -0,0 +1,77 @@ +using Evergreen.Health.Application.Health.Devices.Audio; +using Evergreen.Health.Application.Health.Devices.Contracts; +using Evergreen.Health.Application.Health.Devices.Environment; +using Evergreen.Health.Application.Health.Devices.Climate; +using Evergreen.Health.Application.Health.Devices.Curtain; +using Evergreen.Health.Application.Health.Devices.Discovery; +using Evergreen.Health.Application.Health.Devices.Dehumidifier; +using Evergreen.Health.Application.Health.Devices.Fragrance; +using Evergreen.Health.Application.Health.Devices.Lighting; +using Evergreen.Health.Application.Health.Devices.Mattress; +using Evergreen.Health.Application.Health.Devices.Outlet; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Integrations.MiAirX; +using Evergreen.Health.Application.Health.Scenes; +using Evergreen.Health.Application.Health.Sleep; +using Evergreen.Health.Application.Health.Integrations.Tuya; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Evergreen.Health.Application.Health.Devices; + +public static class HealthDeviceRegistration +{ + public static IServiceCollection AddHealthDevices(this IServiceCollection services) + { + services.AddSingleton(TimeProvider.System); + services.AddSingleton(new DeviceExecutionOptions()); + // 仅注册扩展契约,不启动传感器轮询、上床流程或占位音频播放。 + services.AddSingleton(new SleepWorkflowOptions()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddSingleton(provider => new HomeAssistantClient( + provider.GetRequiredService().CreateClient(nameof(HomeAssistantClient)), + provider.GetRequiredService>())); + services.AddSingleton(provider => new MiAirXClient( + provider.GetRequiredService().CreateClient(nameof(MiAirXClient)), + provider.GetRequiredService>())); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddHostedService(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs b/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs new file mode 100644 index 0000000..ce23e97 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs @@ -0,0 +1,76 @@ +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; + +namespace Evergreen.Health.Application.Health.Devices.Lighting; + +public sealed class LightingModule : HealthDeviceModule +{ + private readonly HomeAssistantEntityOptions _entities; + private readonly ILightTransitionRuntime _transition; + private readonly LightOffDispatcher _lightOff; + public LightingModule(DeviceActionRunner runner, IOptions options, + ILightTransitionRuntime transition, LightOffDispatcher lightOff) : base(runner) + { + _entities = options.Value.Entities; _transition = transition; _lightOff = lightOff; + Parameters = + [ + new("brightness", PrimaryEntityId, value => + { + var brightness = DeviceValues.Integer(value, 0, 100, "灯光亮度"); + return new("设置灯光亮度", PrimaryEntityId, "light", brightness == 0 ? "turn_off" : "turn_on", + brightness == 0 ? null : new Dictionary { ["brightness_pct"] = brightness }) + { Intent = brightness == 0 ? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn }; + }, "灯光亮度", 0, 100), + new("color_temp", PrimaryEntityId, value => new("设置灯光色温", PrimaryEntityId, "light", "turn_on", + new Dictionary { ["color_temp_kelvin"] = DeviceValues.Integer(value, 2200, 5000, "灯光色温") }) + { Intent = DeviceCommandIntent.TurnRelaxationLightOn }, + "灯光色温", 2200, 5000) + ]; + } + public override string Key => "lighting"; + public override string Category => nameof(HealthDeviceCategory.Lighting); + public override string DisplayName => "卧室灯光"; + public override string PrimaryEntityId => _entities.AmbientLight; + public override IReadOnlyList Parameters { get; } + + public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken) + { + await _transition.CancelAsync(cancellationToken); + await base.ExecuteParameterAsync(command, cancellationToken); + if (command.Service == "turn_off") await _lightOff.NotifyAsync(cancellationToken); + } + + public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken) + { + if (turnOn) { await _transition.StartSleepAsync(2700, cancellationToken); return; } + await _transition.CancelAsync(cancellationToken); + await Runner.SendAsync(new("关闭助眠灯光", PrimaryEntityId, "light", "turn_off") + { Intent = DeviceCommandIntent.TurnRelaxationLightOff }, cancellationToken); + await _lightOff.NotifyAsync(cancellationToken); + } + + public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, + string constitutionCode, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(PrimaryEntityId) || !context.States.TryGetValue(PrimaryEntityId, out var state) || + !DeviceActionRunner.IsAvailable(state)) + { + context.Results.Add(new() { Name = "开启助眠渐暗灯光", EntityId = PrimaryEntityId, + Status = "Skipped", Message = "灯光未配置或离线" }); return; + } + await context.ExternalAsync("开启助眠渐暗灯光", PrimaryEntityId, + async token => { await _transition.StartSleepAsync(config.LightColorTemperatureKelvin, token); }, cancellationToken); + } + + public override IEnumerable GetShutdownCommands() + => [new("关闭助眠灯光", PrimaryEntityId, "light", "turn_off", ExpectedState: "off") + { Intent = DeviceCommandIntent.TurnRelaxationLightOff }]; + + public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) + => string.IsNullOrWhiteSpace(PrimaryEntityId) ? null : Preview("开启助眠渐暗灯光", + $"从50%开始,每5秒降低1%,最终关灯 · 色温 {config.LightColorTemperatureKelvin}K", + new() { ["brightness"] = "50%→关闭", ["color_temp"] = $"{config.LightColorTemperatureKelvin}K" }); +} diff --git a/Evergreen.Health.Application/Health/Devices/Mattress/MattressModule.cs b/Evergreen.Health.Application/Health/Devices/Mattress/MattressModule.cs new file mode 100644 index 0000000..7570368 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Mattress/MattressModule.cs @@ -0,0 +1,120 @@ +using Evergreen.Health.Application.Health.Devices.Contracts; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Models; +using Evergreen.Health.Application.Health.Scenes; +using Microsoft.Extensions.Options; +using Evergreen.Health.Application.Health.Services; + +namespace Evergreen.Health.Application.Health.Devices.Mattress; + +public sealed class MattressModule : HealthDeviceModule, ILightOffHandler +{ + private readonly HomeAssistantOptions _options; + private HomeAssistantEntityOptions Entities => _options.Entities; + public MattressModule(DeviceActionRunner runner, IOptions options) : base(runner) + { + _options = options.Value; + var modes = new[] { "无", "瑜伽助眠", "智能适配", "智能腰托" }; + var parts = new[] { "无", "背部", "腰部", "牵引", "腿部", "全身" }; + var parameters = new List + { + DeviceValues.Number("mattress_left_heating", Entities.MattressLeftHeatingLevel, 0, 4, "床垫左侧加热档位"), + DeviceValues.Number("mattress_right_heating", Entities.MattressRightHeatingLevel, 0, 4, "床垫右侧加热档位"), + DeviceValues.Select("mattress_left_mode", Entities.MattressLeftMode, modes, "床垫左侧承托模式", true), + DeviceValues.Select("mattress_right_mode", Entities.MattressRightMode, modes, "床垫右侧承托模式", true), + DeviceValues.Select("mattress_massage_left_part", Entities.MattressLeftMassagePart, parts, "左侧按摩部位", + intent: DeviceCommandIntent.StartMassage), + DeviceValues.Select("mattress_massage_right_part", Entities.MattressRightMassagePart, parts, "右侧按摩部位", + intent: DeviceCommandIntent.StartMassage), + DeviceValues.Select("mattress_massage_level", Entities.MattressMassageLevel, ["轻柔", "强劲"], "按摩力度") + }; + foreach (var (zone, entityId) in Entities.MattressFirmness) + parameters.Add(DeviceValues.Select($"mattress_firmness_{zone}", entityId, + ["最软", "很软", "稍软", "稍硬", "很硬", "最硬"], $"床垫{ZoneName(zone)}软硬度")); + Parameters = parameters; + } + public override string Key => "mattress"; + public override string Category => nameof(HealthDeviceCategory.Sleep); + public override string DisplayName => "智能床垫"; + public override string PrimaryEntityId => ""; // 床垫没有单一总开关,不能把加热实体冒充开关。 + public override IReadOnlyList Parameters { get; } + + public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, + string constitutionCode, CancellationToken cancellationToken) + { + if (!_options.SleepScene.EnableMattress) return; + await context.RunAsync(BuildParameterCommand("mattress_left_heating", config.MattressHeatingLevel), cancellationToken); + await context.RunAsync(BuildParameterCommand("mattress_right_heating", config.MattressHeatingLevel), cancellationToken); + var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness); + if (firmness is null) + context.Results.Add(new() { Name = "按 BMI 调节床垫软硬度", Status = "Skipped", + Message = "健康档案缺少身高或体重,未自动修改软硬度" }); + else + foreach (var zone in Entities.MattressFirmness.Keys) + await context.RunAsync(BuildParameterCommand($"mattress_firmness_{zone}", firmness), cancellationToken); + var modeCommand = BuildParameterCommand("mattress_left_mode", "智能适配"); + await context.RunAsync(modeCommand, cancellationToken); + if (context.Results.Last().Status == "Succeeded") + await Runner.VerifyBatchAsync([modeCommand], context.Results, cancellationToken); + // 气泵调节结束后再启动按摩,保持真实设备所需的顺序。 + await Runner.DelayAsync(TimeSpan.FromSeconds(2), cancellationToken); + await context.RunAsync(BuildParameterCommand("mattress_massage_left_part", "无"), cancellationToken); + await context.RunAsync(BuildParameterCommand("mattress_massage_level", "轻柔"), cancellationToken); + var massage = BuildParameterCommand("mattress_massage_right_part", "全身") with { ExpectedState = "全身" }; + await context.RunAsync(massage, cancellationToken); + if (context.Results.Last().Status == "Succeeded") + { + await Runner.DelayAsync(TimeSpan.FromSeconds(3), cancellationToken); + await Runner.VerifyBatchAsync([massage], context.Results, cancellationToken); + } + } + + public IEnumerable MassageOffCommands() => + [ + new("关闭床垫右侧按摩", Entities.MattressRightMassagePart, "select", "select_option", + new Dictionary { ["option"] = "无" }, "无"), + new("关闭床垫左侧按摩", Entities.MattressLeftMassagePart, "select", "select_option", + new Dictionary { ["option"] = "无" }, "无") + ]; + + public override IEnumerable GetShutdownCommands() + { + foreach (var command in MassageOffCommands()) yield return command; + // 未参与自动启动不等于无需关闭:手动开启的加热也要随一键关闭停止。 + yield return new("关闭床垫左侧加热", Entities.MattressLeftHeatingLevel, "number", "set_value", + new Dictionary { ["value"] = 0 }, "0"); + yield return new("关闭床垫右侧加热", Entities.MattressRightHeatingLevel, "number", "set_value", + new Dictionary { ["value"] = 0 }, "0"); + } + + public async Task HandleAsync(CancellationToken cancellationToken) + { + var commands = MassageOffCommands().Where(command => !string.IsNullOrWhiteSpace(command.EntityId)).ToArray(); + var results = new List(); + foreach (var command in commands) + await Runner.RunAsync(new Dictionary(), results, command, cancellationToken, false); + await Runner.VerifyBatchAsync(commands, results, cancellationToken); + if (results.Any(result => result.Status == "Failed")) + throw new InvalidOperationException("床垫按摩未确认关闭:" + + string.Join(";", results.Where(result => result.Status == "Failed").Select(result => result.Message))); + } + + public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) + { + if (!_options.SleepScene.EnableMattress || !EntityIds.Any()) return null; + var firmness = MattressComfortService.FirmnessName(config.MattressLeftFirmness) ?? "未自动调节"; + var preview = Preview("按室温和 BMI 调节床垫并开启右侧按摩", + $"左右加热 {config.MattressHeatingLevel} 档 · 软硬度{firmness} · 右侧全身轻柔按摩", + new() { ["heating_level"] = config.MattressHeatingLevel, ["firmness"] = firmness, + ["right_massage_part"] = "全身", ["massage_level"] = "轻柔" }); + preview.EntityId = EntityIds.First(); + return preview; + } + + public static string ZoneName(string zone) => zone switch + { + "left_shoulder" => "左肩", "right_shoulder" => "右肩", "left_back" => "左背", "right_back" => "右背", + "left_waist" => "左腰", "right_waist" => "右腰", "left_lumbar" => "左腰椎", "right_lumbar" => "右腰椎", + "left_hip" => "左臀", "right_hip" => "右臀", "left_leg" => "左腿", "right_leg" => "右腿", _ => zone + }; +} diff --git a/Evergreen.Health.Application/Health/Devices/Outlet/OutletModule.cs b/Evergreen.Health.Application/Health/Devices/Outlet/OutletModule.cs new file mode 100644 index 0000000..bc3e515 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Outlet/OutletModule.cs @@ -0,0 +1,44 @@ +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; + +namespace Evergreen.Health.Application.Health.Devices.Outlet; + +/// 仅单独控制明确绑定的插座主开关;不使用服务开关、切换按钮或内部保护参数。 +public sealed class OutletModule : HealthDeviceModule +{ + private readonly string _entityId; + public OutletModule(DeviceActionRunner runner, IOptions options) : base(runner) + => _entityId = options.Value.Entities.Outlet; + + public override string Key => "outlet"; + public override string Category => nameof(HealthDeviceCategory.Outlet); + public override string DisplayName => "智能插座"; + public override string PrimaryEntityId => _entityId; + public override IReadOnlyList Parameters => []; + + public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_entityId.StartsWith("switch.", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("插座主开关尚未正确绑定"); + var command = new DeviceCommand(turnOn ? "开启插座电源" : "关闭插座电源", _entityId, "switch", + turnOn ? "turn_on" : "turn_off", ExpectedState: turnOn ? "on" : "off") + { + ValidateCurrentState = state => + { + if (!string.Equals(state.EntityId, _entityId, StringComparison.OrdinalIgnoreCase) || + state.State is not ("on" or "off")) + throw new InvalidOperationException("插座主开关状态未确认,请刷新后再试"); + }, + // 等待有限次数回读,不因读数延迟再次通电,也不使用 toggle 反转电源。 + RetryOnVerificationMismatch = false + }; + await Runner.SendAsync(command, cancellationToken); + await Runner.VerifyParameterAsync(command, cancellationToken); + } + + // 本版不接入舒睡/唤醒/一键关闭:沿用基类的无场景动作、无关闭命令、无场景预览。 +} diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantClient.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantClient.cs index 8aca8ad..a70e71e 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantClient.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantClient.cs @@ -5,7 +5,7 @@ using System.Text.Json; namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; -public sealed class HomeAssistantClient +public sealed class HomeAssistantClient : IHomeAssistantGateway { private readonly HttpClient _httpClient; private readonly HomeAssistantOptions _options; @@ -20,9 +20,47 @@ public sealed class HomeAssistantClient public async Task> GetStatesAsync(CancellationToken cancellationToken = default) { using var request = CreateRequest(HttpMethod.Get, "api/states"); - using var response = await _httpClient.SendAsync(request, cancellationToken); + using var response = await SendAsync(request, cancellationToken); await EnsureSuccessAsync(response, cancellationToken); - return await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken) ?? []; + var states = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken) ?? []; + await EnrichTemperatureUnitAsync(states, cancellationToken); + return states; + } + + // 原生 climate 状态通常省略温度单位,它使用 HA 全局单位系统;不能把缺失直接当作摄氏。 + private async Task EnrichTemperatureUnitAsync(List states, CancellationToken cancellationToken) + { + var targets = states.Where(state => state.EntityId.StartsWith("climate.", StringComparison.OrdinalIgnoreCase) && + state.Attributes.ValueKind == JsonValueKind.Object && + !state.Attributes.TryGetProperty("temperature_unit", out _)).ToArray(); + if (targets.Length == 0) return; + using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + bounded.CancelAfter(TimeSpan.FromSeconds(2)); + try + { + using var request = CreateRequest(HttpMethod.Get, "api/config"); + using var response = await SendAsync(request, bounded.Token); + await EnsureSuccessAsync(response, bounded.Token); + using var config = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(bounded.Token), + cancellationToken: bounded.Token); + if (!config.RootElement.TryGetProperty("unit_system", out var units) || + units.ValueKind != JsonValueKind.Object || !units.TryGetProperty("temperature", out var temperature) || + temperature.ValueKind != JsonValueKind.String) return; + var unit = temperature.GetString(); + if (unit is not ("°C" or "°F")) return; + foreach (var state in targets) + { + var attributes = state.Attributes.EnumerateObject().ToDictionary(property => property.Name, + property => property.Value.Clone(), StringComparer.Ordinal); + attributes["temperature_unit"] = JsonSerializer.SerializeToElement(unit); + state.Attributes = JsonSerializer.SerializeToElement(attributes); + } + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // 单位接口不可读时仍保留其他真实状态;空调温度功能按单位未知禁用,不猜测。 + } + cancellationToken.ThrowIfCancellationRequested(); } public async Task CallServiceAsync( @@ -33,10 +71,19 @@ public sealed class HomeAssistantClient { using var request = CreateRequest(HttpMethod.Post, $"api/services/{domain}/{service}"); request.Content = JsonContent.Create(data); - using var response = await _httpClient.SendAsync(request, cancellationToken); + using var response = await SendAsync(request, cancellationToken); await EnsureSuccessAsync(response, cancellationToken); } + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + try { return await _httpClient.SendAsync(request, cancellationToken); } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("Home Assistant 请求超时,未确认操作完成", exception); + } + } + private HttpRequestMessage CreateRequest(HttpMethod method, string path) { if (!_options.Enabled) diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs index da882ca..a2914c7 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs @@ -1,4 +1,5 @@ using Evergreen.Health.Application.Health.Models; +using Evergreen.Health.Application.Health.Scenes; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -6,13 +7,16 @@ using Microsoft.Extensions.Options; namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; /// 在服务端驱动卧室灯光渐变,浏览器关闭后任务仍继续。 -public sealed class HomeAssistantLightTransitionService : IHostedService +public sealed class HomeAssistantLightTransitionService : IHostedService, ILightTransitionRuntime { private const int SleepStartPercent = 50; private const int WakeTargetPercent = 70; private static readonly TimeSpan StepInterval = TimeSpan.FromSeconds(5); - private readonly HomeAssistantClient _homeAssistant; + private readonly IHomeAssistantGateway _homeAssistant; + private readonly LightOffDispatcher _lightOff; + private readonly SceneLightSafetyState _safety; + private readonly TimeProvider _time; private readonly HomeAssistantOptions _options; private readonly ILogger _logger; private readonly SemaphoreSlim _operationLock = new(1, 1); @@ -22,13 +26,19 @@ public sealed class HomeAssistantLightTransitionService : IHostedService private HomeAssistantLightTransitionOutput _status = new(); public HomeAssistantLightTransitionService( - IHttpClientFactory httpClientFactory, + IHomeAssistantGateway homeAssistant, + LightOffDispatcher lightOff, IOptions options, - ILogger logger) + ILogger logger, + SceneLightSafetyState safety, + TimeProvider time) { _options = options.Value; _logger = logger; - _homeAssistant = new HomeAssistantClient(httpClientFactory.CreateClient(nameof(HomeAssistantLightTransitionService)), options); + _homeAssistant = homeAssistant; + _lightOff = lightOff; + _safety = safety; + _time = time; } public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; @@ -70,6 +80,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService ["color_temp_kelvin"] = Math.Clamp(colorTemperatureKelvin, 2200, 5000) }, cancellationToken); + _safety.MarkOn(); SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度"); Launch("sleep", SleepStartPercent); return GetStatus(); @@ -84,7 +95,9 @@ public sealed class HomeAssistantLightTransitionService : IHostedService { await StopCurrentAsync(); var light = await ReadAvailableLightAsync(cancellationToken); - var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase); + var knownOffAt = _safety.OffAt; + var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase) || + (knownOffAt is not null && (light.LastUpdated is null || light.LastUpdated <= knownOffAt)); var brightness = light.GetDoubleAttribute("brightness"); if (!isOff && brightness is null) throw new InvalidOperationException("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒"); @@ -92,6 +105,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService if (current >= WakeTargetPercent && !isOff) { + _safety.MarkOn(); SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度"); return GetStatus(); } @@ -105,6 +119,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService }, cancellationToken); } + _safety.MarkOn(); SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度"); Launch("wake", current); return GetStatus(); @@ -151,15 +166,16 @@ public sealed class HomeAssistantLightTransitionService : IHostedService { while (true) { - await Task.Delay(StepInterval, cancellationToken); + await Task.Delay(StepInterval, _time, cancellationToken); var light = await ReadAvailableLightAsync(cancellationToken); if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase)) { + _safety.MarkOff(_time.GetUtcNow()); if (mode == "sleep") { try { - await StopMattressMassageAsync(cancellationToken); + await _lightOff.NotifyAsync(cancellationToken); SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止"); } catch (Exception exception) when (exception is not OperationCanceledException) @@ -191,10 +207,11 @@ public sealed class HomeAssistantLightTransitionService : IHostedService { ["entity_id"] = LightEntityId }, cancellationToken); + _safety.MarkOff(_time.GetUtcNow()); SetStatus(true, false, mode, 0, 0, "灯光已关闭,正在停止床垫按摩"); try { - await StopMattressMassageAsync(cancellationToken); + await _lightOff.NotifyAsync(cancellationToken); SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光与床垫按摩已关闭"); } catch (Exception exception) when (exception is not OperationCanceledException) @@ -227,43 +244,6 @@ public sealed class HomeAssistantLightTransitionService : IHostedService } } - private async Task StopMattressMassageAsync(CancellationToken cancellationToken) - { - var massageEntities = new[] - { - _options.Entities.MattressLeftMassagePart, - _options.Entities.MattressRightMassagePart - }.Where(entityId => !string.IsNullOrWhiteSpace(entityId)).Distinct().ToArray(); - var failures = new List(); - foreach (var entityId in massageEntities) - { - try - { - var confirmed = false; - for (var attempt = 0; attempt < 2 && !confirmed; attempt++) - { - await _homeAssistant.CallServiceAsync("select", "select_option", new Dictionary - { - ["entity_id"] = entityId, - ["option"] = "无" - }, cancellationToken); - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - var current = (await _homeAssistant.GetStatesAsync(cancellationToken)) - .FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase)); - confirmed = current?.State == "无"; - } - if (!confirmed) failures.Add(entityId); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "灯光熄灭后未能关闭床垫按摩 {EntityId}", entityId); - failures.Add(entityId); - } - } - if (failures.Count > 0) - throw new InvalidOperationException($"灯光已关闭,但床垫按摩未确认关闭:{string.Join("、", failures)}"); - } - private async Task StopCurrentAsync() { if (_activeCancellation is null) return; diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs index 785717d..b16dca4 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Globalization; using System.Text.Json.Serialization; namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; @@ -17,17 +18,20 @@ public sealed class HomeAssistantState [JsonPropertyName("last_updated")] public DateTimeOffset? LastUpdated { get; set; } + [JsonPropertyName("last_reported")] + public DateTimeOffset? LastReported { get; set; } + public string? GetStringAttribute(string name) { - if (!Attributes.TryGetProperty(name, out var value)) return null; + if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null; return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); } public double? GetDoubleAttribute(string name) { - if (!Attributes.TryGetProperty(name, out var value)) return null; - if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d)) return d; - if (double.TryParse(value.ToString(), out var parsed)) return parsed; + if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null; + if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d) && double.IsFinite(d)) return d; + if (double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) && double.IsFinite(parsed)) return parsed; return null; } } @@ -54,6 +58,9 @@ public sealed class HomeAssistantDeviceRegistryItem [JsonPropertyName("disabled_by")] public string? DisabledBy { get; set; } + + [JsonPropertyName("entry_type")] + public string? EntryType { get; set; } } public sealed class HomeAssistantEntityRegistryItem diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs index 46e6add..20f43fe 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs @@ -14,6 +14,9 @@ public sealed class HomeAssistantOptions : IConfigurableOptions public sealed class HomeAssistantEntityOptions { + public string Climate { get; set; } = string.Empty; + public string Curtain { get; set; } = string.Empty; + public string Outlet { get; set; } = string.Empty; public string Dehumidifier { get; set; } = string.Empty; public string DehumidifierFanLevel { get; set; } = string.Empty; public string DehumidifierUv { get; set; } = string.Empty; diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantRegistryService.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantRegistryService.cs index 5bb2187..bd226c8 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantRegistryService.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantRegistryService.cs @@ -45,7 +45,7 @@ public sealed class HomeAssistantRegistryService : ISingleton } return _cachedRegistries; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { _logger.LogWarning("通过 WebSocket 读取 Home Assistant 注册表失败:{Message}", ex.Message); return _cachedRegistries; @@ -119,7 +119,7 @@ public sealed class HomeAssistantRegistryService : ISingleton try { - await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", CancellationToken.None); + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", cts.Token); } catch { @@ -134,7 +134,7 @@ public sealed class HomeAssistantRegistryService : ISingleton CachedAt = DateTimeOffset.UtcNow }; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { _logger.LogWarning("连接 Home Assistant WebSocket 发生异常:{Message}", ex.Message); return null; diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/IHomeAssistantGateway.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/IHomeAssistantGateway.cs new file mode 100644 index 0000000..cb8c1f9 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/IHomeAssistantGateway.cs @@ -0,0 +1,8 @@ +namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; + +public interface IHomeAssistantGateway +{ + Task> GetStatesAsync(CancellationToken cancellationToken = default); + Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data, + CancellationToken cancellationToken = default); +} diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/ILightTransitionRuntime.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/ILightTransitionRuntime.cs new file mode 100644 index 0000000..2518d10 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/ILightTransitionRuntime.cs @@ -0,0 +1,11 @@ +using Evergreen.Health.Application.Health.Models; + +namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; + +public interface ILightTransitionRuntime +{ + HomeAssistantLightTransitionOutput GetStatus(); + Task StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken); + Task StartWakeAsync(CancellationToken cancellationToken); + Task CancelAsync(CancellationToken cancellationToken); +} diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/IAudioPlaybackGateway.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/IAudioPlaybackGateway.cs new file mode 100644 index 0000000..4b04d49 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/IAudioPlaybackGateway.cs @@ -0,0 +1,21 @@ +namespace Evergreen.Health.Application.Health.Integrations.MiAirX; + +/// 播放能力契约,未来音箱接入不要求是 MiAirX。 +public interface IAudioPlaybackGateway +{ + bool IsConfigured { get; } + Task PlayAsync(CancellationToken cancellationToken = default); + Task PlayUrlAsync(string musicUrl, CancellationToken cancellationToken = default); + Task StopAsync(CancellationToken cancellationToken = default); + Task SetVolumeAsync(int volume, CancellationToken cancellationToken = default); +} + +public interface ITcmMusicRuntime +{ + TcmMusicTransitionOutput GetStatus(); + bool HasPlayableTracks(string constitutionCode); + Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken); + Task PlayTestMusicAsync(CancellationToken cancellationToken); + Task SetManualVolumeAsync(int volume, CancellationToken cancellationToken); + Task StopMusicAsync(CancellationToken cancellationToken); +} diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXClient.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXClient.cs index 5a4d051..02589d5 100644 --- a/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXClient.cs +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/MiAirXClient.cs @@ -6,7 +6,7 @@ using System.Text.Json; namespace Evergreen.Health.Application.Health.Integrations.MiAirX; -public sealed class MiAirXClient +public sealed class MiAirXClient : IAudioPlaybackGateway { private readonly HttpClient _httpClient; private readonly MiAirXOptions _options; @@ -23,7 +23,6 @@ public sealed class MiAirXClient public bool IsConfigured => _options.Enabled && !string.IsNullOrWhiteSpace(_options.BaseUrl) && !string.IsNullOrWhiteSpace(_options.DeviceId) && - !string.IsNullOrWhiteSpace(_options.MusicUrl) && (!_options.RequireWebPassword || !string.IsNullOrWhiteSpace(_options.WebPassword)); public Task PlayAsync(CancellationToken cancellationToken = default) @@ -48,6 +47,15 @@ public sealed class MiAirXClient } private async Task SendCommandAsync(string path, object payload, CancellationToken cancellationToken) + { + try { await SendCommandCoreAsync(path, payload, cancellationToken); } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("MiAirX 请求超时,未确认音箱操作完成", exception); + } + } + + private async Task SendCommandCoreAsync(string path, object payload, CancellationToken cancellationToken) { var baseUri = GetBaseUri(); if (!string.IsNullOrWhiteSpace(_options.WebPassword)) diff --git a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs index 86b513a..82d8689 100644 --- a/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs +++ b/Evergreen.Health.Application/Health/Integrations/MiAirX/TcmMusicTransitionService.cs @@ -6,11 +6,14 @@ using System.Diagnostics; namespace Evergreen.Health.Application.Health.Integrations.MiAirX; /// Demo 音乐任务由服务端持续驱动,页面关闭后仍按体质曲目循环渐弱播放。 -public sealed class TcmMusicTransitionService : IHostedService +public sealed class TcmMusicTransitionService : IHostedService, ITcmMusicRuntime { - private readonly MiAirXClient _miAirX; + private const int DemoStartVolume = 50; + private const int DemoFadeStepSeconds = 5; + private readonly IAudioPlaybackGateway _miAirX; private readonly TcmMusicOptions _options; private readonly ILogger _logger; + private readonly TimeProvider _time; private readonly SemaphoreSlim _operationLock = new(1, 1); private readonly object _statusLock = new(); private TcmMusicTransitionOutput _status = new(); @@ -18,14 +21,15 @@ public sealed class TcmMusicTransitionService : IHostedService private Task? _activeTask; public TcmMusicTransitionService( - IHttpClientFactory httpClientFactory, - IOptions miAirXOptions, + IAudioPlaybackGateway audioGateway, IOptions musicOptions, - ILogger logger) + ILogger logger, + TimeProvider time) { - _miAirX = new MiAirXClient(httpClientFactory.CreateClient(nameof(TcmMusicTransitionService)), miAirXOptions); + _miAirX = audioGateway; _options = musicOptions.Value; _logger = logger; + _time = time; } public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; @@ -53,24 +57,22 @@ public sealed class TcmMusicTransitionService : IHostedService public bool HasPlayableTracks(string constitutionCode) => _miAirX.IsConfigured && - _options.Tracks.TryGetValue(constitutionCode, out var tracks) && tracks.Count > 0 && + _options.Tracks is not null && _options.Tracks.TryGetValue(constitutionCode, out var tracks) && + tracks is not null && tracks.Count > 0 && tracks.All(track => IsValidTrack(track)); public async Task StartForConstitutionAsync(string constitutionCode, CancellationToken cancellationToken) { if (!HasPlayableTracks(constitutionCode)) throw new InvalidOperationException($"{constitutionCode} 的体质音乐 OSS URL 尚未配置完整"); - if (_options.StartVolume is < 1 or > 100 || _options.FadeStepSeconds is < 1 or > 60) - throw new InvalidOperationException("体质音乐渐弱参数无效"); - var tracks = _options.Tracks[constitutionCode].ToArray(); await _operationLock.WaitAsync(cancellationToken); try { await CancelCurrentLoopAsync(); - await _miAirX.SetVolumeAsync(_options.StartVolume, cancellationToken); + await _miAirX.SetVolumeAsync(DemoStartVolume, cancellationToken); await _miAirX.PlayUrlAsync(tracks[0].Url, cancellationToken); - UpdateStatus(true, _options.StartVolume, "体质音乐正在自动渐弱"); + UpdateStatus(true, DemoStartVolume, "体质音乐正在自动渐弱"); _activeCancellation = new CancellationTokenSource(); _activeTask = RunFadeAsync(tracks, constitutionCode, _activeCancellation.Token); } @@ -119,23 +121,23 @@ public sealed class TcmMusicTransitionService : IHostedService string constitutionCode, CancellationToken cancellationToken) { - var clock = Stopwatch.StartNew(); - var currentVolume = _options.StartVolume; - var nextFadeSecond = (double)_options.FadeStepSeconds; + var start = _time.GetTimestamp(); + var currentVolume = DemoStartVolume; + var nextFadeSecond = (double)DemoFadeStepSeconds; var trackStartSecond = 0d; var trackIndex = 0; try { while (true) { - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - var elapsed = clock.Elapsed.TotalSeconds; + await Task.Delay(TimeSpan.FromSeconds(1), _time, cancellationToken); + var elapsed = _time.GetElapsedTime(start).TotalSeconds; while (elapsed >= nextFadeSecond && currentVolume > 0) { currentVolume--; await _miAirX.SetVolumeAsync(currentVolume, cancellationToken); UpdateStatus(true, currentVolume, "体质音乐正在自动渐弱"); - nextFadeSecond += _options.FadeStepSeconds; + nextFadeSecond += DemoFadeStepSeconds; } if (currentVolume == 0) @@ -149,7 +151,7 @@ public sealed class TcmMusicTransitionService : IHostedService if (elapsed - trackStartSecond < tracks[trackIndex].DurationSeconds) continue; trackIndex = (trackIndex + 1) % tracks.Count; await _miAirX.PlayUrlAsync(tracks[trackIndex].Url, cancellationToken); - trackStartSecond = clock.Elapsed.TotalSeconds; + trackStartSecond = _time.GetElapsedTime(start).TotalSeconds; _logger.LogInformation("体质 {ConstitutionCode} 循环播放 {Track},继承音量 {Volume}", constitutionCode, tracks[trackIndex].Title, currentVolume); } @@ -192,7 +194,7 @@ public sealed class TcmMusicTransitionService : IHostedService } private static bool IsValidTrack(TcmMusicTrackOptions track) - => track.DurationSeconds > 0 && + => track is not null && double.IsFinite(track.DurationSeconds) && track.DurationSeconds > 0 && Uri.TryCreate(track.Url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; } diff --git a/Evergreen.Health.Application/Health/Integrations/Tuya/ITuyaGateway.cs b/Evergreen.Health.Application/Health/Integrations/Tuya/ITuyaGateway.cs new file mode 100644 index 0000000..50ba2e3 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/Tuya/ITuyaGateway.cs @@ -0,0 +1,19 @@ +namespace Evergreen.Health.Application.Health.Integrations.Tuya; + +/// 未来独立涂鸦模块使用本接口,不要求经过 Home Assistant。 +public sealed record TuyaCapabilityWrite(string DeviceId, string Code, object? Value); +public interface ITuyaGateway +{ + bool IsConfigured { get; } + Task WriteAsync(TuyaCapabilityWrite command, CancellationToken cancellationToken); +} + +public sealed class ReservedTuyaGateway : ITuyaGateway +{ + public bool IsConfigured => false; + public Task WriteAsync(TuyaCapabilityWrite command, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new NotSupportedException("涂鸦接入仅预留,本版没有发送控制命令"); + } +} diff --git a/Evergreen.Health.Application/Health/Models/HealthModels.cs b/Evergreen.Health.Application/Health/Models/HealthModels.cs index 576a1b0..d02b82e 100644 --- a/Evergreen.Health.Application/Health/Models/HealthModels.cs +++ b/Evergreen.Health.Application/Health/Models/HealthModels.cs @@ -114,6 +114,11 @@ public sealed class MattressComfortOutput public string? WeatherObservedAt { get; set; } public string WeatherReason { get; set; } = string.Empty; public string FirmnessReason { get; set; } = string.Empty; + public double? IndoorTemperatureCelsius { get; set; } + public bool IndoorTemperatureAvailable { get; set; } + public DateTimeOffset? IndoorTemperatureObservedAt { get; set; } + public string HeatingReason { get; set; } = string.Empty; + public ClimateAdviceOutput ClimateAdvice { get; set; } = new(); } public sealed class TcmAssessmentInput @@ -161,6 +166,17 @@ public sealed class PersonalizedSceneConfig public bool EnableSpeakerSleepMode { get; set; } = true; public string SpeakerAudioMode { get; set; } = "助眠白噪音"; public int SpeakerVolumePercent { get; set; } = 50; + public ClimateAdviceOutput ClimateAdvice { get; set; } = new(); +} + +public sealed class ClimateAdviceOutput +{ + public string Mode { get; set; } = "unavailable"; + public int RecommendedMinimumCelsius { get; set; } + public int RecommendedMaximumCelsius { get; set; } + public int? TargetTemperatureCelsius { get; set; } + public bool PendingIntegration { get; set; } + public string Reason { get; set; } = "等待有效室温"; } public sealed class HealthAssessmentAnswers diff --git a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs index 0723054..604e6c3 100644 --- a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs +++ b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs @@ -9,7 +9,11 @@ public enum HealthDeviceCategory Fragrance, Lighting, Media, - Other + Other, + Climate, + Curtain, + SleepMonitoring, + Outlet } public sealed class HomeAssistantEntityOutput @@ -25,6 +29,7 @@ public sealed class HomeAssistantEntityOutput public sealed class HomeAssistantDiscoveredDeviceOutput { + public string ModuleKey { get; set; } = string.Empty; public string DeviceId { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Manufacturer { get; set; } = string.Empty; @@ -141,6 +146,7 @@ public sealed class HomeAssistantSetParameterOutput public sealed class HomeAssistantSceneOutput { + public ClimateAdviceOutput? ClimateAdvice { get; set; } public string SceneCode { get; set; } = string.Empty; public string SceneName { get; set; } = string.Empty; public bool Success { get; set; } diff --git a/Evergreen.Health.Application/Health/Policies/BmiFirmnessPolicy.cs b/Evergreen.Health.Application/Health/Policies/BmiFirmnessPolicy.cs new file mode 100644 index 0000000..eade9b6 --- /dev/null +++ b/Evergreen.Health.Application/Health/Policies/BmiFirmnessPolicy.cs @@ -0,0 +1,22 @@ +namespace Evergreen.Health.Application.Health.Policies; + +/// Demo 起点,可由用户手动修改,不是医学或人体工学判断。 +public static class BmiFirmnessPolicy +{ + public static decimal? Calculate(decimal? heightCm, decimal? weightKg) + { + if (heightCm is null or <= 0 || weightKg is null or <= 0) return null; + try + { + var height = heightCm.Value / 100m; + return Math.Round(weightKg.Value / (height * height), 1); + } + catch (ArithmeticException) { return null; } + } + + public static string? Firmness(decimal? bmi) => bmi switch + { + < 18.5m => "很软", < 24m => "稍软", < 28m => "稍硬", + < 32m => "很硬", >= 32m => "最硬", _ => null + }; +} diff --git a/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs b/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs new file mode 100644 index 0000000..b46dc88 --- /dev/null +++ b/Evergreen.Health.Application/Health/Policies/RoomTemperaturePolicy.cs @@ -0,0 +1,44 @@ +using Evergreen.Health.Application.Health.Models; + +namespace Evergreen.Health.Application.Health.Policies; + +public sealed record RecommendedRoomRange(int Minimum, int Maximum) +{ + public string Display => $"{Minimum}–{Maximum}℃"; + public static RecommendedRoomRange ForConstitution(string code) => code switch + { + "yang_deficient" => new(24, 26), "yin_deficient" or "damp_heat" => new(21, 24), + "phlegm_damp" => new(22, 24), "blood_stasis" => new(23, 25), _ => new(22, 25) + }; +} + +public sealed record IndoorTemperatureSample(double? Celsius, DateTimeOffset? ObservedAt, bool Available, string Reason); + +public static class RoomTemperaturePolicy +{ + public static ClimateAdviceOutput Evaluate(IndoorTemperatureSample sample, RecommendedRoomRange range) + { + var mode = !sample.Available || sample.Celsius is null ? "unavailable" + : sample.Celsius < range.Minimum ? "heat" : sample.Celsius > range.Maximum ? "cool" : "off"; + return new() + { + Mode = mode, RecommendedMinimumCelsius = range.Minimum, RecommendedMaximumCelsius = range.Maximum, + Reason = mode switch + { + "heat" => $"室温低于建议 {range.Display}:床垫低档加热,建议空调制热", + "cool" => $"室温高于建议 {range.Display}:床垫不加热,建议空调制冷", + "off" => $"室温在建议 {range.Display} 内:床垫不加热,空调不开启", + _ => $"{sample.Reason},不自动加热或开启空调" + }, + PendingIntegration = mode is "heat" or "cool" + }; + } + + // 仅在启动新舒睡场景时调用,状态查询和预览不随机更改目标值。 + public static void PickTargetForSession(ClimateAdviceOutput advice) + { + if (advice.TargetTemperatureCelsius is null && (advice.Mode is "heat" or "cool")) + advice.TargetTemperatureCelsius = Random.Shared.Next(advice.RecommendedMinimumCelsius, + advice.RecommendedMaximumCelsius + 1); + } +} diff --git a/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs b/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs new file mode 100644 index 0000000..593089a --- /dev/null +++ b/Evergreen.Health.Application/Health/Scenes/DeviceActionRunner.cs @@ -0,0 +1,160 @@ +using Evergreen.Health.Application.Health.Devices.Contracts; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using System.Globalization; + +namespace Evergreen.Health.Application.Health.Scenes; + +/// 共用发送、取消、回读和有限重试,任何家具都可复用。 +public sealed class DeviceActionRunner +{ + private readonly IHomeAssistantGateway _gateway; + private readonly TimeProvider _time; + private readonly DeviceExecutionOptions _options; + private readonly IReadOnlyList _safety; + private readonly SemaphoreSlim _commandGate = new(1, 1); + public DeviceActionRunner(IHomeAssistantGateway gateway, TimeProvider time, DeviceExecutionOptions options, + IEnumerable? safety = null) + => (_gateway, _time, _options, _safety) = (gateway, time, options, safety?.ToArray() ?? []); + + public static bool IsAvailable(HomeAssistantState state) + => !string.IsNullOrWhiteSpace(state.State) && + !string.Equals(state.State, "unavailable", StringComparison.OrdinalIgnoreCase) && + (!string.Equals(state.State, "unknown", StringComparison.OrdinalIgnoreCase) || + state.EntityId.StartsWith("button.", StringComparison.OrdinalIgnoreCase)); + + public async Task ReadAvailableStateAsync(string entityId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(entityId)) throw new InvalidOperationException("参数实体尚未配置"); + var state = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item => + string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase)); + if (state is null || !IsAvailable(state)) throw new InvalidOperationException("目标参数实体当前离线或不可用"); + return state; + } + + public async Task SendAsync(DeviceCommand command, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(command.EntityId)) + throw new InvalidOperationException("参数实体尚未配置"); + await _commandGate.WaitAsync(cancellationToken); + try + { + using var timeout = new CancellationTokenSource(_options.CommandTimeout, _time); + using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + try + { + if (command.ValidateCurrentState is not null) + command.ValidateCurrentState(await ReadAvailableStateAsync(command.EntityId, bounded.Token)); + foreach (var rule in _safety) await rule.ValidateAsync(command, bounded.Token); + await _gateway.CallServiceAsync(command.Domain, command.Service, command.ToServiceData(), bounded.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && timeout.IsCancellationRequested) + { + throw new TimeoutException($"{command.Name}发送超时,未确认完成"); + } + foreach (var rule in _safety) rule.Accepted(command, _time.GetUtcNow()); + } + finally { _commandGate.Release(); } + } + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + => Task.Delay(delay, _time, cancellationToken); + + public async Task RunAsync(IReadOnlyDictionary states, + List results, DeviceCommand command, CancellationToken cancellationToken, + bool requireAvailable = true) + { + cancellationToken.ThrowIfCancellationRequested(); + var action = new HomeAssistantActionResult { Name = command.Name, EntityId = command.EntityId }; + results.Add(action); + if (string.IsNullOrWhiteSpace(command.EntityId)) + { + action.Status = "Skipped"; action.Message = "实体未配置"; return; + } + if (requireAvailable && (!states.TryGetValue(command.EntityId, out var state) || !IsAvailable(state))) + { + action.Status = "Skipped"; action.Message = "实体不存在或当前离线"; return; + } + try + { + await SendAsync(command, cancellationToken); + action.Status = "Succeeded"; action.Message = "控制指令已发送"; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + action.Status = "Failed"; action.Message = exception.Message; + } + } + + public async Task VerifyParameterAsync(DeviceCommand command, CancellationToken cancellationToken) + { + var results = new List + { + new() { Name = command.Name, EntityId = command.EntityId, Status = "Succeeded" } + }; + await VerifyBatchAsync([command], results, cancellationToken); + if (results[0].Status != "Succeeded") throw new InvalidOperationException(results[0].Message); + } + + public async Task VerifyBatchAsync(IReadOnlyList commands, + List results, CancellationToken cancellationToken) + { + var pending = commands.Where(command => !string.IsNullOrWhiteSpace(command.EntityId) && + command.ExpectedState is not null).ToList(); + for (var round = 0; round < _options.VerificationAttempts && pending.Count > 0; round++) + { + await DelayAsync(_options.VerificationDelay, cancellationToken); + IReadOnlyList states; + try { states = await _gateway.GetStatesAsync(cancellationToken); } + catch (Exception exception) when (exception is not OperationCanceledException) + { + foreach (var command in pending) + { + var action = FindResult(results, command); + action.Status = "Failed"; action.Message = $"无法回读确认:{exception.Message}"; + } + return; + } + foreach (var command in pending.ToArray()) + { + var action = FindResult(results, command); + var state = states.FirstOrDefault(item => string.Equals(item.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)); + var actual = state is null ? null : command.ExpectedAttribute is null + ? state.State : state.GetStringAttribute(command.ExpectedAttribute); + if (state is not null && IsAvailable(state) && actual is not null && Matches(actual, command.ExpectedState!)) + { + action.Status = "Succeeded"; action.Message = round == 0 ? "已回读确认目标状态" : "重试后已回读确认目标状态"; + pending.Remove(command); continue; + } + if (round == _options.VerificationAttempts - 1) + { + action.Status = "Failed"; action.Message = $"未确认目标{(command.ExpectedAttribute is null ? "状态" : command.ExpectedAttribute)} {command.ExpectedState}(当前:{actual ?? "未读取到"})"; + pending.Remove(command); continue; + } + if (!command.RetryOnVerificationMismatch) continue; + // 关闭属于尽力而为:缓存离线不能阻止发出重试,发送失败也不影响其他家具。 + try { await SendAsync(command, cancellationToken); } + catch (Exception exception) when (exception is not OperationCanceledException) + { + action.Status = "Failed"; action.Message = $"重试失败:{exception.Message}"; + } + } + } + } + + private static HomeAssistantActionResult FindResult(List results, DeviceCommand command) + => results.Last(item => item.Name == command.Name && item.EntityId == command.EntityId); + + private static bool Matches(string actual, string expected) + => string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase) || + (decimal.TryParse(actual, NumberStyles.Number, CultureInfo.InvariantCulture, out var actualNumber) && + decimal.TryParse(expected, NumberStyles.Number, CultureInfo.InvariantCulture, out var expectedNumber) && + actualNumber == expectedNumber); +} + +public sealed class DeviceExecutionOptions +{ + public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(10); + public int VerificationAttempts { get; init; } = 3; + public TimeSpan VerificationDelay { get; init; } = TimeSpan.FromSeconds(2); +} diff --git a/Evergreen.Health.Application/Health/Scenes/DeviceActionSafety.cs b/Evergreen.Health.Application/Health/Scenes/DeviceActionSafety.cs new file mode 100644 index 0000000..a43c4bf --- /dev/null +++ b/Evergreen.Health.Application/Health/Scenes/DeviceActionSafety.cs @@ -0,0 +1,49 @@ +using Evergreen.Health.Application.Health.Devices.Contracts; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Microsoft.Extensions.Options; + +namespace Evergreen.Health.Application.Health.Scenes; + +public interface IDeviceActionSafetyRule +{ + Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken); + void Accepted(DeviceCommand command, DateTimeOffset now); +} + +public sealed class SceneLightSafetyState +{ + private readonly object _lock = new(); + private DateTimeOffset? _offAt; + public DateTimeOffset? OffAt { get { lock (_lock) return _offAt; } } + public void MarkOff(DateTimeOffset now) { lock (_lock) _offAt = now; } + public void MarkOn() { lock (_lock) _offAt = null; } +} + +/// 只依赖“启动按摩”的动作意图,不知道床垫实体。防止灯灭后较晚的启动指令再开启按摩。 +public sealed class LightMassageSafetyRule : IDeviceActionSafetyRule +{ + private readonly IHomeAssistantGateway _gateway; + private readonly string _lightEntity; + private readonly SceneLightSafetyState _state; + public LightMassageSafetyRule(IHomeAssistantGateway gateway, IOptions options, + SceneLightSafetyState state) => (_gateway, _lightEntity, _state) = (gateway, options.Value.Entities.AmbientLight, state); + + public async Task ValidateAsync(DeviceCommand command, CancellationToken cancellationToken) + { + if (command.Intent != DeviceCommandIntent.StartMassage || string.IsNullOrWhiteSpace(_lightEntity)) return; + var light = (await _gateway.GetStatesAsync(cancellationToken)).FirstOrDefault(item => + string.Equals(item.EntityId, _lightEntity, StringComparison.OrdinalIgnoreCase)); + var offAt = _state.OffAt; + if (light is null || !DeviceActionRunner.IsAvailable(light) || + !string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase) || + light.GetDoubleAttribute("brightness") is <= 0 || + (offAt is not null && (light.LastUpdated is null || light.LastUpdated <= offAt))) + throw new InvalidOperationException("卧室灯光已关闭或状态未确认,不启动床垫按摩"); + } + + public void Accepted(DeviceCommand command, DateTimeOffset now) + { + if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOff) _state.MarkOff(now); + else if (command.Intent == DeviceCommandIntent.TurnRelaxationLightOn) _state.MarkOn(); + } +} diff --git a/Evergreen.Health.Application/Health/Scenes/DeviceSceneContext.cs b/Evergreen.Health.Application/Health/Scenes/DeviceSceneContext.cs new file mode 100644 index 0000000..80b9f45 --- /dev/null +++ b/Evergreen.Health.Application/Health/Scenes/DeviceSceneContext.cs @@ -0,0 +1,34 @@ +using Evergreen.Health.Application.Health.Devices.Contracts; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; + +namespace Evergreen.Health.Application.Health.Scenes; + +public sealed class DeviceSceneContext +{ + public IReadOnlyDictionary States { get; } + public List Results { get; } + public DeviceActionRunner Runner { get; } + public DeviceSceneContext(DeviceActionRunner runner, IReadOnlyDictionary states, + List results) => (Runner, States, Results) = (runner, states, results); + + public Task RunAsync(DeviceCommand command, CancellationToken cancellationToken) + => Runner.RunAsync(States, Results, command, cancellationToken); + + public async Task ExternalAsync(string name, string entityId, Func operation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var result = new HomeAssistantActionResult { Name = name, EntityId = entityId }; + Results.Add(result); + try + { + await operation(cancellationToken); + result.Status = string.IsNullOrWhiteSpace(entityId) ? "Completed" : "Succeeded"; + result.Message = string.IsNullOrWhiteSpace(entityId) ? "内部任务已处理" : "控制指令已发送"; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + result.Status = "Failed"; result.Message = exception.Message; + } + } +} diff --git a/Evergreen.Health.Application/Health/Scenes/HealthSceneCoordinator.cs b/Evergreen.Health.Application/Health/Scenes/HealthSceneCoordinator.cs new file mode 100644 index 0000000..5390d56 --- /dev/null +++ b/Evergreen.Health.Application/Health/Scenes/HealthSceneCoordinator.cs @@ -0,0 +1,152 @@ +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; + +namespace Evergreen.Health.Application.Health.Scenes; + +/// 同一房间串行执行,后来的开启/关闭取消旧启动,避免延迟指令重新打开家具。 +public sealed class HealthSceneCoordinator +{ + private readonly DeviceModuleRegistry _modules; + private readonly DeviceActionRunner _runner; + private readonly IHomeAssistantGateway _gateway; + private readonly ILightTransitionRuntime _light; + private readonly ITcmMusicRuntime _music; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly object _stateLock = new(); + private CancellationTokenSource? _active; + + public HealthSceneCoordinator(DeviceModuleRegistry modules, DeviceActionRunner runner, + IHomeAssistantGateway gateway, ILightTransitionRuntime light, ITcmMusicRuntime music) + => (_modules, _runner, _gateway, _light, _music) = (modules, runner, gateway, light, music); + + public Task ExecuteAsync(string sceneCode, PersonalizedSceneConfig? config, + string constitutionCode, CancellationToken cancellationToken) + => ExecuteCoreAsync(sceneCode, config, constitutionCode, cancellationToken, null); + + // 先登记本次操作,再读取档案/室温。否则较慢的旧准备请求可能在全关之后才开始开家具。 + public Task ExecutePreparedSleepAsync( + Func> prepare, CancellationToken cancellationToken) + => ExecuteCoreAsync("sleep", null, "", cancellationToken, prepare); + + private async Task ExecuteCoreAsync(string sceneCode, PersonalizedSceneConfig? config, + string constitutionCode, CancellationToken cancellationToken, + Func>? prepare) + { + if (sceneCode is not ("sleep" or "all-off")) throw new InvalidOperationException("不支持的场景"); + if (sceneCode == "sleep" && config is null && prepare is null) throw new InvalidOperationException("舒睡场景缺少体质参数"); + CancellationTokenSource operation; + lock (_stateLock) + { + _active?.Cancel(); + operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _active = operation; + } + var results = new List(); + var entered = false; + var cancelled = false; + string? preparationFailureStatus = null; + try + { + await _gate.WaitAsync(operation.Token); entered = true; + if (prepare is not null) + { + var prepared = await prepare(operation.Token); + operation.Token.ThrowIfCancellationRequested(); + config = prepared.Config; constitutionCode = prepared.ConstitutionCode; + } + var context = new DeviceSceneContext(_runner, new Dictionary(), results); + await context.ExternalAsync("停止旧灯光渐变任务", "", + async token => { await _light.CancelAsync(token); }, operation.Token); + await context.ExternalAsync("停止旧助眠音乐", _modules.Modules.FirstOrDefault(module => module.Key == "audio")?.PrimaryEntityId ?? "", + _music.StopMusicAsync, operation.Token); + IReadOnlyDictionary states; + try + { + states = (await _gateway.GetStatesAsync(operation.Token)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + // HA 不可达不能阻止 MiAirX 停止或尝试发送其他关闭指令。 + results.Add(new() { Name = "读取设备状态", Status = "Failed", Message = exception.Message }); + states = new Dictionary(); + } + context = new DeviceSceneContext(_runner, states, results); + if (sceneCode == "sleep") + { + foreach (var module in _modules.Modules) + { + operation.Token.ThrowIfCancellationRequested(); + try { await module.ExecuteSleepAsync(context, config!, constitutionCode, operation.Token); } + catch (Exception exception) when (exception is not OperationCanceledException) + { + results.Add(new() { Name = $"{module.DisplayName}舒睡设置", Status = "Failed", Message = exception.Message }); + } + } + } + else + { + var shutdown = _modules.Modules.SelectMany(module => module.GetShutdownCommands()).ToArray(); + foreach (var command in shutdown) + await _runner.RunAsync(states, results, command, operation.Token, requireAvailable: false); + await _runner.VerifyBatchAsync(shutdown, results, operation.Token); + } + } + catch (OperationCanceledException) when (operation.IsCancellationRequested) + { + cancelled = true; + if (entered) + { + var context = new DeviceSceneContext(_runner, new Dictionary(), results); + await context.ExternalAsync("取消灯光渐变", "", async token => { await _light.CancelAsync(token); }, CancellationToken.None); + await context.ExternalAsync("取消音乐渐弱", "", _music.StopMusicAsync, CancellationToken.None); + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + preparationFailureStatus = exception is ScenePreparationException ? "AssessmentRequired" : "Failed"; + results.Add(new() { Name = "准备或协调场景", Status = "Failed", Message = exception.Message }); + } + finally + { + if (entered) _gate.Release(); + lock (_stateLock) + { + if (ReferenceEquals(_active, operation)) _active = null; + operation.Dispose(); + } + } + + // 取消内部计时任务不能冒充家具执行成功。 + var successCount = results.Count(result => result.Status == "Succeeded" && !string.IsNullOrWhiteSpace(result.EntityId)); + var failed = results.Where(result => result.Status == "Failed").ToArray(); + var skippedCount = results.Count(result => result.Status == "Skipped"); + var status = preparationFailureStatus ?? (cancelled ? "Cancelled" : failed.Length == 0 && successCount > 0 + ? "Succeeded" : successCount > 0 ? "Partial" : "Failed"); + var message = status switch + { + "Cancelled" => "当前场景已被更新的操作取消", + "AssessmentRequired" => failed.First().Message, + "Succeeded" => $"场景已执行:{successCount} 项成功,{skippedCount} 项未配置或离线而跳过", + "Partial" => $"场景部分完成:{failed.Length} 项失败,{skippedCount} 项跳过;未完成:{string.Join("、", failed.Select(result => result.Name).Distinct().Take(3))}", + _ => failed.Length == 0 ? "没有已执行的家具控制;设备未配置或当前不可用" + : $"场景未完成:{string.Join(";", failed.Select(result => result.Message).Take(3))}" + }; + return new() + { + SceneCode = sceneCode, SceneName = sceneCode == "sleep" ? "舒睡模式" : "一键关闭", + Success = status is "Succeeded" or "Partial", Status = status, Message = message, + ClimateAdvice = sceneCode == "sleep" ? config?.ClimateAdvice : null, + Actions = results.Select(result => new HomeAssistantSceneActionOutput + { Name = result.Name, EntityId = result.EntityId, Status = result.Status, Message = result.Message }).ToList() + }; + } +} + +public sealed record PreparedSleepScene(PersonalizedSceneConfig Config, string ConstitutionCode); +public sealed class ScenePreparationException : InvalidOperationException +{ + public ScenePreparationException(string message) : base(message) { } +} diff --git a/Evergreen.Health.Application/Health/Scenes/LightOffDispatcher.cs b/Evergreen.Health.Application/Health/Scenes/LightOffDispatcher.cs new file mode 100644 index 0000000..538c52c --- /dev/null +++ b/Evergreen.Health.Application/Health/Scenes/LightOffDispatcher.cs @@ -0,0 +1,25 @@ +namespace Evergreen.Health.Application.Health.Scenes; + +public interface ILightOffHandler +{ + Task HandleAsync(CancellationToken cancellationToken); +} + +/// 跨设备联动由场景层分发;灯具不需要知道其他家具实体。 +public sealed class LightOffDispatcher +{ + private readonly IReadOnlyList _handlers; + public LightOffDispatcher(IEnumerable handlers) => _handlers = handlers.ToArray(); + public async Task NotifyAsync(CancellationToken cancellationToken) + { + var failures = new List(); + foreach (var handler in _handlers) + { + cancellationToken.ThrowIfCancellationRequested(); + try { await handler.HandleAsync(cancellationToken); } + catch (Exception exception) when (exception is not OperationCanceledException) { failures.Add(exception); } + } + if (failures.Count > 0) + throw new AggregateException("灯光已关闭,但部分安全联动未完成", failures); + } +} diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index 76f84d0..6bb6da2 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -1,10 +1,17 @@ using Evergreen.Health.Application.Health.Entities; +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.Scenes; +using Evergreen.Health.Application.Health.Policies; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Integrations.MiAirX; using Evergreen.Health.Application.Health.Models; using Furion.DatabaseAccessor; +using Furion.FriendlyException; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; using SqlSugar; using System.Globalization; using System.Security.Claims; @@ -25,6 +32,10 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie private readonly SqlSugarRepository _profileRepository; private readonly MattressComfortService _mattressComfort; private readonly IHttpContextAccessor _httpContextAccessor; + private readonly DeviceModuleRegistry _modules; + private readonly HealthSceneCoordinator _scenes; + private readonly HomeAssistantDeviceCatalog _catalog; + private readonly ILogger _logger; public HealthHomeAssistantService( HomeAssistantClient homeAssistant, @@ -35,7 +46,11 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie SqlSugarRepository executionRepository, SqlSugarRepository profileRepository, MattressComfortService mattressComfort, - IHttpContextAccessor httpContextAccessor) + IHttpContextAccessor httpContextAccessor, + DeviceModuleRegistry modules, + HealthSceneCoordinator scenes, + HomeAssistantDeviceCatalog catalog, + ILogger logger) { _homeAssistant = homeAssistant; _musicTransition = musicTransition; @@ -46,6 +61,10 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie _profileRepository = profileRepository; _mattressComfort = mattressComfort; _httpContextAccessor = httpContextAccessor; + _modules = modules; + _scenes = scenes; + _catalog = catalog; + _logger = logger; } [Authorize] @@ -61,7 +80,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie public async Task StartSleepLight(CancellationToken cancellationToken) { try { return await _lightTransition.StartSleepAsync(_options.SleepScene.LightColorTemperatureKelvin, cancellationToken); } - catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; } + catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; } } [Authorize] @@ -69,7 +88,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie public async Task StartWakeLight(CancellationToken cancellationToken) { try { return await _lightTransition.StartWakeAsync(cancellationToken); } - catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; } + catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; } } [Authorize] @@ -77,41 +96,42 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie public async Task StopLightTransition(CancellationToken cancellationToken) { try { return await _lightTransition.CancelAsync(cancellationToken); } - catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; } + catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; } } [Authorize] [HttpGet("/api/health/home-assistant/status")] public async Task GetStatus(CancellationToken cancellationToken) { + var userId = GetCurrentUserId(); try { var states = await _homeAssistant.GetStatesAsync(cancellationToken); var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); var registries = await _registryService.GetRegistriesAsync(cancellationToken); - var discoveredDevices = BuildDiscoveredDevices(registries, stateMap); - var summary = BuildSummary(discoveredDevices); - var sceneDevices = BuildSceneDevicesSummary(stateMap); - var environment = BuildEnvironmentOutput(stateMap, discoveredDevices); + var discoveredDevices = _catalog.BuildDiscoveredDevices(registries, stateMap); + var summary = HomeAssistantDeviceCatalog.BuildSummary(discoveredDevices); + var sceneDevices = _catalog.BuildSceneDevicesSummary(stateMap); + var environment = _catalog.BuildEnvironmentOutput(stateMap, discoveredDevices); PersonalizedSceneConfig? userConfig = null; var constitutionCode = string.Empty; try { - var userId = GetCurrentUserId(); var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); var tcmResult = TcmConstitutionRules.Read(profile); if (tcmResult.HasCompleted) { constitutionCode = tcmResult.ConstitutionCode; userConfig = tcmResult.SceneConfig; - var comfort = await _mattressComfort.GetAsync(profile, cancellationToken); + var comfort = _mattressComfort.Get(profile, stateMap); MattressComfortService.ApplyTo(userConfig, comfort); } } - catch + catch (Exception exception) when (exception is not OperationCanceledException) { + _logger.LogWarning(exception, "读取用户 {UserId} 的设备参数预览失败", userId); } var scenePresets = userConfig is null @@ -131,7 +151,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie ControlParameters = BuildControlParameterStates(stateMap) }; } - catch (Exception exception) + catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantStatusOutput { @@ -140,7 +160,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie CheckedAt = DateTimeOffset.Now, Summary = new HomeAssistantSummaryOutput(), Devices = [], - SceneDevices = BuildSceneDevicesSummary(new Dictionary(StringComparer.OrdinalIgnoreCase)), + SceneDevices = _catalog.BuildSceneDevicesSummary(new Dictionary(StringComparer.OrdinalIgnoreCase)), Environment = new HomeAssistantEnvironmentOutput(), ScenePresets = [], ControlParameters = [] @@ -169,7 +189,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie var stateMap = states.ToDictionary(s => s.EntityId, StringComparer.OrdinalIgnoreCase); var registries = await _registryService.GetRegistriesAsync(cancellationToken); - var discovered = BuildDiscoveredDevices(registries, stateMap); + var discovered = _catalog.BuildDiscoveredDevices(registries, stateMap); var targetDevice = discovered.FirstOrDefault(d => string.Equals(d.DeviceId, input.DeviceId, StringComparison.OrdinalIgnoreCase)); @@ -209,73 +229,32 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie }; } - var domain = candidate.Domain; + var module = _modules.ForEntities(targetDevice.Entities.Select(entity => entity.EntityId)); + if (module is not null && !string.Equals(module.PrimaryEntityId, targetEntityId, StringComparison.OrdinalIgnoreCase)) + module = null; + if (module?.PowerUsesIndependentGateway != true && + (!stateMap.TryGetValue(targetEntityId, out var currentState) || !IsAvailable(currentState))) + throw new InvalidOperationException("目标设备当前处于离线状态"); + if (module is not null) + await module.SetPowerAsync(input.TurnOn, cancellationToken); + else + await _homeAssistant.CallServiceAsync(candidate.Domain, input.TurnOn ? "turn_on" : "turn_off", + new Dictionary { ["entity_id"] = targetEntityId }, cancellationToken); - if (domain == "media_player") + var reportedState = ""; + try { - if (!string.Equals(targetEntityId, _options.Entities.Speaker, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("该音箱不是已配置的助眠音箱"); - if (input.TurnOn) - await _musicTransition.PlayTestMusicAsync(cancellationToken); - else - await _musicTransition.StopMusicAsync(cancellationToken); - return new HomeAssistantDeviceSwitchOutput - { - Success = true, - DeviceId = input.DeviceId, - EntityId = targetEntityId, - Message = input.TurnOn ? "播放指令已发送" : "停止指令已发送" - }; + reportedState = (await _homeAssistant.GetStatesAsync(cancellationToken)).FirstOrDefault(state => + string.Equals(state.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))?.State ?? ""; } - - if (!stateMap.TryGetValue(targetEntityId, out var currentState) || !IsAvailable(currentState)) - { - return new HomeAssistantDeviceSwitchOutput - { - Success = false, - DeviceId = input.DeviceId, - EntityId = targetEntityId, - Message = "目标设备当前处于离线状态" - }; - } - - if (domain == "light" && string.Equals(targetEntityId, _options.Entities.AmbientLight, StringComparison.OrdinalIgnoreCase)) - { - if (input.TurnOn) - { - var transition = await _lightTransition.StartSleepAsync(2700, cancellationToken); - return new HomeAssistantDeviceSwitchOutput - { - Success = transition.Success, - DeviceId = input.DeviceId, - EntityId = targetEntityId, - State = "on", - Message = transition.Message - }; - } - - await _lightTransition.CancelAsync(cancellationToken); - } - - var targetOn = input.TurnOn; - var service = targetOn ? "turn_on" : "turn_off"; - var serviceData = new Dictionary - { - ["entity_id"] = targetEntityId - }; - - await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken); - + catch (Exception exception) when (exception is not OperationCanceledException) { } return new HomeAssistantDeviceSwitchOutput { - Success = true, - DeviceId = input.DeviceId, - EntityId = targetEntityId, - State = targetOn ? "on" : "off", - Message = $"已成功{(targetOn ? "开启" : "关闭")}设备" + Success = true, DeviceId = input.DeviceId, EntityId = targetEntityId, State = reportedState, + Message = input.TurnOn ? "开启指令已发送" : "关闭指令已发送" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return new HomeAssistantDeviceSwitchOutput { @@ -309,7 +288,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie var states = await _homeAssistant.GetStatesAsync(cancellationToken); var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); var registries = await _registryService.GetRegistriesAsync(cancellationToken); - var discovered = BuildDiscoveredDevices(registries, stateMap); + var discovered = _catalog.BuildDiscoveredDevices(registries, stateMap); var targetDevice = discovered.FirstOrDefault(d => string.Equals(d.DeviceId, input.DeviceId, StringComparison.OrdinalIgnoreCase)); if (targetDevice is null) @@ -324,210 +303,27 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie } var parameterType = input.ParameterType.Trim().ToLowerInvariant(); - string targetEntityId; - string domain; - string service; - var serviceData = new Dictionary(); - switch (parameterType) - { - case "humidity": - targetEntityId = _options.Entities.Dehumidifier; - domain = "humidifier"; - service = "set_humidity"; - serviceData["humidity"] = ReadInt(input.Value, 40, 70, "目标湿度"); - break; - case "fan_level": - targetEntityId = _options.Entities.DehumidifierFanLevel; - domain = "select"; - service = "select_option"; - var fanLevel = ReadString(input.Value).Replace('档', '挡'); - if (fanLevel is not ("1挡" or "2挡" or "3挡")) throw new InvalidOperationException("风速档位无效"); - serviceData["option"] = fanLevel; - break; - case "dehumidifier_mode": - targetEntityId = _options.Entities.Dehumidifier; - domain = "humidifier"; - service = "set_mode"; - var dehumidifierMode = ReadString(input.Value); - if (dehumidifierMode is not ("睡眠" or "智能")) throw new InvalidOperationException("除湿模式无效"); - serviceData["mode"] = dehumidifierMode; - break; - case "brightness": - targetEntityId = _options.Entities.AmbientLight; - domain = "light"; - service = "turn_on"; - serviceData["brightness_pct"] = ReadInt(input.Value, 1, 100, "灯光亮度"); - break; - case "color_temp": - targetEntityId = _options.Entities.AmbientLight; - domain = "light"; - service = "turn_on"; - serviceData["color_temp_kelvin"] = ReadInt(input.Value, 2200, 5000, "灯光色温"); - break; - case "volume": - targetEntityId = _options.Entities.Speaker; - domain = "media_player"; - service = ""; - serviceData["volume"] = ReadInt(input.Value, 0, 100, "音箱音量"); - break; - case "speaker_sleep_mode": - targetEntityId = _options.Entities.SpeakerSleepMode; - domain = "switch"; - service = ReadBool(input.Value) ? "turn_on" : "turn_off"; - break; - case "fragrance_duration": - targetEntityId = _options.Entities.FragranceDuration; - domain = "number"; - service = "set_value"; - serviceData["value"] = ReadInt(input.Value, 2, 6, "出香时长"); - break; - case "fragrance_interval": - targetEntityId = _options.Entities.FragranceInterval; - domain = "number"; - service = "set_value"; - var fragranceInterval = ReadInt(input.Value, 10, 20, "出香间隔"); - if (fragranceInterval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟"); - serviceData["value"] = fragranceInterval; - break; - case "fragrance_light": - targetEntityId = _options.Entities.FragranceLight; - domain = "light"; - service = ReadBool(input.Value) ? "turn_on" : "turn_off"; - break; - case "fragrance_quick_release": - targetEntityId = _options.Entities.FragranceQuickRelease; - domain = "button"; - service = "press"; - break; - case "fragrance_auto_release": - targetEntityId = _options.Entities.FragranceAutoRelease; - domain = "switch"; - service = ReadBool(input.Value) ? "turn_on" : "turn_off"; - break; - case "fragrance_auto_light": - targetEntityId = _options.Entities.FragranceAutoLight; - domain = "switch"; - service = ReadBool(input.Value) ? "turn_on" : "turn_off"; - break; - case "fragrance_light_auto_off": - targetEntityId = _options.Entities.FragranceLightAutoOff; - domain = "switch"; - service = ReadBool(input.Value) ? "turn_on" : "turn_off"; - break; - case "mattress_left_heating": - targetEntityId = _options.Entities.MattressLeftHeatingLevel; - domain = "number"; - service = "set_value"; - serviceData["value"] = ReadInt(input.Value, 0, 4, "床垫左侧加热档位"); - break; - case "mattress_right_heating": - targetEntityId = _options.Entities.MattressRightHeatingLevel; - domain = "number"; - service = "set_value"; - serviceData["value"] = ReadInt(input.Value, 0, 4, "床垫右侧加热档位"); - break; - case "mattress_left_mode": - targetEntityId = _options.Entities.MattressLeftMode; - domain = "select"; - service = "select_option"; - serviceData["option"] = ReadMattressMode(input.Value); - break; - case "mattress_right_mode": - targetEntityId = _options.Entities.MattressRightMode; - domain = "select"; - service = "select_option"; - serviceData["option"] = ReadMattressMode(input.Value); - break; - case "mattress_massage_left_part": - targetEntityId = _options.Entities.MattressLeftMassagePart; - domain = "select"; - service = "select_option"; - serviceData["option"] = ReadMassagePart(input.Value); - break; - case "mattress_massage_right_part": - targetEntityId = _options.Entities.MattressRightMassagePart; - domain = "select"; - service = "select_option"; - serviceData["option"] = ReadMassagePart(input.Value); - break; - case "mattress_massage_level": - targetEntityId = _options.Entities.MattressMassageLevel; - domain = "select"; - service = "select_option"; - serviceData["option"] = ReadMassageLevel(input.Value); - break; - case string firmnessType when firmnessType.StartsWith("mattress_firmness_", StringComparison.Ordinal): - var zone = firmnessType["mattress_firmness_".Length..]; - if (!_options.Entities.MattressFirmness.TryGetValue(zone, out targetEntityId)) - throw new InvalidOperationException("不支持该床垫分区"); - domain = "select"; - service = "select_option"; - serviceData["option"] = ReadFirmness(input.Value); - break; - case "uv": - targetEntityId = _options.Entities.DehumidifierUv; - domain = "switch"; - service = ReadBool(input.Value) ? "turn_on" : "turn_off"; - break; - default: - return new HomeAssistantSetParameterOutput - { - Success = false, - DeviceId = input.DeviceId, - ParameterType = input.ParameterType, - Message = $"不支持的参数类型: {input.ParameterType}" - }; - } - - if (string.IsNullOrWhiteSpace(targetEntityId)) + var module = _modules.ForParameter(parameterType); + var command = module.BuildParameterCommand(parameterType, input.Value); + if (string.IsNullOrWhiteSpace(command.EntityId)) throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体"); if (!string.IsNullOrWhiteSpace(input.EntityId) && - !string.Equals(input.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase)) + !string.Equals(input.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配"); - if (!targetDevice.Entities.Any(e => string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))) + if (!targetDevice.Entities.Any(entity => string.Equals(entity.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("该参数不属于指定设备"); - if (parameterType == "volume") - { - await _musicTransition.SetManualVolumeAsync((int)serviceData["volume"]!, cancellationToken); - return new HomeAssistantSetParameterOutput - { - Success = true, - DeviceId = input.DeviceId, - ParameterType = input.ParameterType, - Message = "音箱音量指令已发送" - }; - } - if (!stateMap.TryGetValue(targetEntityId, out var targetState) || !IsAvailable(targetState)) + // 外部音频网关不依赖 HA 媒体实体是否在线,但仍校验实体归属。 + if (!module.ParameterUsesIndependentGateway(command) && + (!stateMap.TryGetValue(command.EntityId, out var state) || !IsAvailable(state))) throw new InvalidOperationException("目标参数实体当前离线或不可用"); - - if (parameterType is "brightness" or "color_temp") - await _lightTransition.CancelAsync(cancellationToken); - - serviceData["entity_id"] = targetEntityId; - await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken); - if (parameterType is "mattress_left_mode" or "mattress_right_mode") - await VerifyMattressModeAsync(targetEntityId, serviceData["option"]?.ToString() ?? "", serviceData, - cancellationToken); - - var successMessage = parameterType switch - { - "fragrance_quick_release" => "快速出香指令已发送", - "mattress_left_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) => - "左侧承托模式已启动;床垫共用气泵,右侧会自动停止", - "mattress_right_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) => - "右侧承托模式已启动;床垫共用气泵,左侧会自动停止", - _ => "参数调节成功" - }; - + await module.ExecuteParameterAsync(command, cancellationToken); return new HomeAssistantSetParameterOutput { - Success = true, - DeviceId = input.DeviceId, - ParameterType = input.ParameterType ?? "", - Message = successMessage + Success = true, DeviceId = input.DeviceId, ParameterType = parameterType, + Message = command.ExpectedState is null ? "参数调节指令已发送" : "已回读确认参数" }; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { return new HomeAssistantSetParameterOutput { @@ -543,1059 +339,93 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie [HttpPost("/api/health/home-assistant/scenes/sleep")] public async Task ExecuteSleepScene(CancellationToken cancellationToken) { - PersonalizedSceneConfig? customConfig = null; var userId = GetCurrentUserId(); - var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); - var tcmResult = TcmConstitutionRules.Read(profile); - if (!tcmResult.HasCompleted) + return await ExecuteScene("sleep", null, "", cancellationToken, async token => { - return new HomeAssistantSceneOutput - { - SceneCode = "sleep", SceneName = "舒睡模式", Success = false, - Status = "AssessmentRequired", Message = "请先完成中医四诊,生成体质倾向后再一键启动" - }; - } - customConfig = tcmResult.SceneConfig; - var mattressComfort = await _mattressComfort.GetAsync(profile, cancellationToken); - MattressComfortService.ApplyTo(customConfig, mattressComfort); - - return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) => - ExecuteSleepActions(states, results, customConfig, tcmResult.ConstitutionCode, ct), cancellationToken); + token.ThrowIfCancellationRequested(); + var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); + token.ThrowIfCancellationRequested(); + var tcmResult = TcmConstitutionRules.Read(profile); + if (!tcmResult.HasCompleted) + throw new ScenePreparationException("请先完成中医四诊,生成体质倾向后再一键启动"); + var config = tcmResult.SceneConfig; + var mattressComfort = await _mattressComfort.GetAsync(profile, token); + MattressComfortService.ApplyTo(config, mattressComfort); + RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice); + return new PreparedSleepScene(config, tcmResult.ConstitutionCode); + }); } [Authorize] [HttpPost("/api/health/home-assistant/scenes/all-off")] public Task ExecuteAllOffScene(CancellationToken cancellationToken) - => ExecuteScene("all-off", "一键关闭", ExecuteAllOffActions, cancellationToken); + => ExecuteScene("all-off", null, "", cancellationToken); - private async Task ExecuteScene( - string sceneCode, - string sceneName, - Func, List, CancellationToken, Task> executor, - CancellationToken cancellationToken) + private async Task ExecuteScene(string sceneCode, + PersonalizedSceneConfig? config, string constitutionCode, CancellationToken cancellationToken, + Func>? prepare = null) { var userId = GetCurrentUserId(); - var actions = new List(); - string status; - string message; - + var output = prepare is null + ? await _scenes.ExecuteAsync(sceneCode, config, constitutionCode, cancellationToken) + : await _scenes.ExecutePreparedSleepAsync(prepare, cancellationToken); try { - // 关闭场景先停止音乐;即使 HA 暂时不可用,也要尽量避免音箱继续播放。 - if (sceneCode == "all-off") - await RunMiAirXAction(actions, "停止助眠音乐", _options.Entities.Speaker, - _musicTransition.StopMusicAsync, cancellationToken); - var states = await _homeAssistant.GetStatesAsync(cancellationToken); - var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); - await executor(stateMap, actions, cancellationToken); - - var successCount = actions.Count(item => item.Status == "Succeeded"); - var failedCount = actions.Count(item => item.Status == "Failed"); - var skippedCount = actions.Count(item => item.Status == "Skipped"); - var failedNames = string.Join("、", actions.Where(item => item.Status == "Failed") - .Select(item => item.Name).Distinct().Take(3)); - status = failedCount == 0 && successCount > 0 ? "Succeeded" : successCount > 0 ? "Partial" : "Failed"; - message = status switch + await _executionRepository.InsertAsync(new HealthSceneExecution { - "Succeeded" when skippedCount > 0 => $"场景已执行,{successCount} 项成功,{skippedCount} 项因设备离线或未配置而跳过", - "Succeeded" => $"场景已执行,{successCount} 项成功", - "Partial" => $"场景部分执行成功:{successCount} 项成功,{failedCount} 项失败,{skippedCount} 项跳过;未完成:{failedNames}", - _ => "当前没有可执行的在线设备" - }; - } - catch (Exception exception) - { - status = "Failed"; - message = $"场景执行失败:{exception.Message}"; - } - - await _executionRepository.InsertAsync(new HealthSceneExecution - { - UserId = userId, - SceneCode = sceneCode, - SceneName = sceneName, - Status = status, - Detail = JsonSerializer.Serialize(new { message, actions }) - }); - - return new HomeAssistantSceneOutput - { - SceneCode = sceneCode, - SceneName = sceneName, - Success = status is "Succeeded" or "Partial", - Status = status, - Message = message, - Actions = actions.Select(item => new HomeAssistantSceneActionOutput - { - Name = item.Name, - EntityId = item.EntityId, - Status = item.Status, - Message = item.Message - }).ToList() - }; - } - - private async Task ExecuteSleepActions( - IReadOnlyDictionary states, - List results, - PersonalizedSceneConfig? customConfig, - string constitutionCode, - CancellationToken cancellationToken) - { - var entities = _options.Entities; - var scene = _options.SleepScene; - - var targetHumidity = customConfig?.TargetHumidity ?? scene.TargetHumidity; - var dehumMode = customConfig?.DehumidifierMode ?? scene.DehumidifierMode; - var fanLevel = customConfig?.FanLevel ?? scene.FanLevel; - var enableUv = customConfig?.EnableUv ?? scene.EnableUv; - var lightBrightness = customConfig?.LightBrightnessPercent ?? scene.LightBrightnessPercent; - var lightColorTemp = customConfig?.LightColorTemperatureKelvin ?? scene.LightColorTemperatureKelvin; - var enableFragrance = customConfig?.EnableFragrance ?? scene.EnableFragrance; - var fragranceLevel = Math.Clamp(customConfig?.FragranceLevel ?? 1, 1, 3); - var fragranceDuration = fragranceLevel switch { 1 => 2, 2 => 4, _ => 6 }; - var fragranceInterval = fragranceLevel switch { 1 => 20, 2 => 15, _ => 10 }; - var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode; - var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel; - var mattressFirmness = MattressComfortService.FirmnessName(customConfig?.MattressLeftFirmness ?? 0); - - await RunAction(states, results, "开启除湿机", entities.Dehumidifier, "humidifier", "turn_on", null, cancellationToken); - await RunAction(states, results, "设置目标湿度", entities.Dehumidifier, "humidifier", "set_humidity", - new Dictionary { ["humidity"] = targetHumidity }, cancellationToken); - await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode", - new Dictionary { ["mode"] = dehumMode }, cancellationToken); - await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option", - new Dictionary { ["option"] = fanLevel }, cancellationToken); - await RunAction(states, results, enableUv ? "开启 UV 杀菌" : "关闭 UV 杀菌", entities.DehumidifierUv, - "switch", enableUv ? "turn_on" : "turn_off", null, cancellationToken); - - if (enableFragrance) - { - await RunAction(states, results, "开启香氛机", entities.FragranceSwitch, "switch", "turn_on", null, cancellationToken); - await RunAction(states, results, "设置出香时长", entities.FragranceDuration, "number", "set_value", - new Dictionary { ["value"] = fragranceDuration }, cancellationToken); - await RunAction(states, results, "设置出香间隔", entities.FragranceInterval, "number", "set_value", - new Dictionary { ["value"] = fragranceInterval }, cancellationToken); - await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on", - new Dictionary { ["brightness_pct"] = lightBrightness }, cancellationToken); - } - else - { - await RunAction(states, results, "关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", null, cancellationToken); - await RunAction(states, results, "关闭香氛灯", entities.FragranceLight, "light", "turn_off", null, cancellationToken); - } - - if (string.IsNullOrWhiteSpace(entities.AmbientLight) || - !states.TryGetValue(entities.AmbientLight, out var lightState) || !IsAvailable(lightState)) - { - results.Add(new HomeAssistantActionResult - { - Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight, - Status = "Skipped", Message = "设备未配置或离线" + UserId = userId, SceneCode = output.SceneCode, SceneName = output.SceneName, + Status = output.Status, Detail = JsonSerializer.Serialize(new { output.Message, output.Actions, output.ClimateAdvice }) }); } - else - { - try - { - await _lightTransition.StartSleepAsync(lightColorTemp, cancellationToken); - results.Add(new HomeAssistantActionResult - { - Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight, - Status = "Succeeded", Message = "已从50%开始,每5秒降低1%,最终关灯" - }); - } - catch (Exception exception) - { - results.Add(new HomeAssistantActionResult - { - Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight, - Status = "Failed", Message = exception.Message - }); - } - } - - if (enableSpeakerSleepMode) - { - await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken); - } - else - { - await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken); - } - // 睡眠模式仍由 Home Assistant 控制;体质选曲和渐弱在 MiAirX 后台任务中进行。 - await RunMiAirXAction(results, "播放体质助眠音乐并渐弱", entities.Speaker, - token => _musicTransition.StartForConstitutionAsync(constitutionCode, token), cancellationToken); - - if (scene.EnableMattress) - { - await RunAction(states, results, "设置床垫左侧加热档位", entities.MattressLeftHeatingLevel, "number", "set_value", - new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); - await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value", - new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); - // 左右承托模式共用气泵;一键场景只实际启动左侧,右侧留作页面预选。 - if (mattressFirmness == null) - { - results.Add(new HomeAssistantActionResult - { - Name = "按 BMI 调节床垫软硬度", Status = "Skipped", - Message = "健康档案缺少身高或体重,未自动修改软硬度" - }); - } - else - { - foreach (var (zone, entityId) in entities.MattressFirmness) - { - await RunAction(states, results, $"设置床垫{MattressZoneName(zone)}软硬度", - entityId, "select", "select_option", - new Dictionary { ["option"] = mattressFirmness }, cancellationToken); - } - } - const string leftModeActionName = "开启床垫左侧智能适配"; - await RunAction(states, results, leftModeActionName, entities.MattressLeftMode, "select", "select_option", - new Dictionary { ["option"] = "智能适配" }, cancellationToken); - var leftModeAction = results.LastOrDefault(item => item.Name == leftModeActionName); - if (leftModeAction?.Status == "Succeeded") - { - try - { - await VerifyMattressModeAsync(entities.MattressLeftMode, "智能适配", - new Dictionary { ["entity_id"] = entities.MattressLeftMode, ["option"] = "智能适配" }, - cancellationToken); - leftModeAction.Message = "已确认左侧智能适配运行,右侧保持待机预选"; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - leftModeAction.Status = "Failed"; - leftModeAction.Message = exception.Message; - } - } - // 软硬度调节可能占用床垫气泵;等这些指令完成后,最后启动按摩。 - await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); - await RunAction(states, results, "停止床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", - new Dictionary { ["option"] = "无" }, cancellationToken); - await RunAction(states, results, "设置床垫轻柔按摩", entities.MattressMassageLevel, "select", "select_option", - new Dictionary { ["option"] = "轻柔" }, cancellationToken); - const string rightMassageActionName = "开启床垫右侧全身按摩"; - await RunAction(states, results, rightMassageActionName, entities.MattressRightMassagePart, "select", "select_option", - new Dictionary { ["option"] = "全身" }, cancellationToken); - - // HA 接受指令不等于设备保持该状态;此前曾在两秒后自动复位为“无”。 - var rightMassageAction = results.LastOrDefault(item => item.Name == rightMassageActionName); - if (rightMassageAction?.Status == "Succeeded") - { - await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); - try - { - var currentStates = await _homeAssistant.GetStatesAsync(cancellationToken); - var currentRightMassage = currentStates.FirstOrDefault(item => - string.Equals(item.EntityId, entities.MattressRightMassagePart, StringComparison.OrdinalIgnoreCase)); - if (currentRightMassage?.State != "全身") - { - rightMassageAction.Status = "Failed"; - rightMassageAction.Message = $"指令已发送,但右侧按摩未保持全身模式(当前:{currentRightMassage?.State ?? "未读取到状态"})"; - } - else - { - rightMassageAction.Message = "已确认右侧全身按摩保持开启"; - } - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - rightMassageAction.Status = "Failed"; - rightMassageAction.Message = $"指令已发送,但无法确认右侧按摩状态:{exception.Message}"; - } - } - } - } - - private async Task ExecuteAllOffActions( - IReadOnlyDictionary states, - List results, - CancellationToken cancellationToken) - { - var entities = _options.Entities; - await _lightTransition.CancelAsync(cancellationToken); - var commands = new List - { - new("关闭除湿机", entities.Dehumidifier, "humidifier", "turn_off", "off", null), - new("关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", "off", null), - new("关闭香氛灯", entities.FragranceLight, "light", "turn_off", "off", null), - new("关闭感应夜灯", entities.FragranceAutoLight, "switch", "turn_off", "off", null), - new("关闭人来增香", entities.FragranceAutoRelease, "switch", "turn_off", "off", null), - new("关闭助眠灯光", entities.AmbientLight, "light", "turn_off", "off", null), - new("关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", "off", null), - new("关闭床垫右侧按摩", entities.MattressRightMassagePart, "select", "select_option", "无", - new Dictionary { ["option"] = "无" }), - new("关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", "无", - new Dictionary { ["option"] = "无" }) - }; - if (_options.SleepScene.EnableMattress) - { - commands.Add(new("关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value", "0", - new Dictionary { ["value"] = 0 })); - commands.Add(new("关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value", "0", - new Dictionary { ["value"] = 0 })); - } - - foreach (var command in commands) - await RunAction(states, results, command.Name, command.EntityId, command.Domain, command.Service, - command.ExtraData, cancellationToken); - await VerifyOffActionsAsync(commands, results, cancellationToken); - } - - private sealed record OffCommand(string Name, string EntityId, string Domain, string Service, - string ExpectedState, IReadOnlyDictionary? ExtraData); - - private async Task VerifyOffActionsAsync( - IReadOnlyList commands, - List results, - CancellationToken cancellationToken) - { - var pending = commands.Where(item => !string.IsNullOrWhiteSpace(item.EntityId)).ToList(); - for (var round = 0; round < 3 && pending.Count > 0; round++) - { - // HA 接受指令并不代表设备已变更,尤其是云端控制的香氛机。 - await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken); - IReadOnlyDictionary freshStates; - try - { - freshStates = (await _homeAssistant.GetStatesAsync(cancellationToken)) - .ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - foreach (var command in pending) - { - var action = results.Last(item => item.Name == command.Name); - action.Status = "Failed"; - action.Message = $"关闭指令已发送,但无法回读确认:{exception.Message}"; - } - return; - } - - foreach (var command in pending.ToArray()) - { - var action = results.Last(item => item.Name == command.Name); - freshStates.TryGetValue(command.EntityId, out var state); - if (state is not null && IsOffState(state.State, command.ExpectedState)) - { - action.Status = "Succeeded"; - action.Message = round == 0 ? "已回读确认关闭" : "重试后已回读确认关闭"; - pending.Remove(command); - continue; - } - - if (round == 2) - { - action.Status = "Failed"; - action.Message = $"关闭后仍未确认目标状态(当前:{state?.State ?? "未读取到"})"; - pending.Remove(command); - continue; - } - - if (state is null || !IsAvailable(state)) continue; - try - { - var data = new Dictionary { ["entity_id"] = command.EntityId }; - if (command.ExtraData is not null) - foreach (var item in command.ExtraData) data[item.Key] = item.Value; - await _homeAssistant.CallServiceAsync(command.Domain, command.Service, data, cancellationToken); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - action.Status = "Failed"; - action.Message = $"重试关闭失败:{exception.Message}"; - } - } - } - } - - private static bool IsOffState(string actual, string expected) - => expected == "0" - ? decimal.TryParse(actual, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) && value == 0 - : string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase); - - private async Task VerifyMattressModeAsync(string entityId, string expected, - IReadOnlyDictionary serviceData, CancellationToken cancellationToken) - { - for (var attempt = 0; attempt < 3; attempt++) - { - await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); - var actual = (await _homeAssistant.GetStatesAsync(cancellationToken)) - .FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.State; - if (string.Equals(actual, expected, StringComparison.Ordinal)) return; - if (attempt < 2) - await _homeAssistant.CallServiceAsync("select", "select_option", serviceData, cancellationToken); - else - throw new InvalidOperationException($"床垫承托模式未确认切换到{expected}(当前:{actual ?? "未读取到"})"); - } - } - - private static async Task RunMiAirXAction( - List results, - string name, - string entityId, - Func command, - CancellationToken cancellationToken) - { - try - { - await command(cancellationToken); - results.Add(new HomeAssistantActionResult - { - Name = name, EntityId = entityId, Status = "Succeeded", Message = "MiAirX 指令已发送" - }); - return true; - } catch (Exception exception) when (exception is not OperationCanceledException) { - results.Add(new HomeAssistantActionResult - { - Name = name, EntityId = entityId, Status = "Failed", Message = exception.Message - }); - return false; - } - } - - private async Task RunAction( - IReadOnlyDictionary states, - List results, - string name, - string entityId, - string domain, - string service, - IReadOnlyDictionary? extraData, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(entityId) || !states.TryGetValue(entityId, out var state)) - { - results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Skipped", Message = "实体未配置或不存在" }); - return; - } - - if (!IsAvailable(state)) - { - results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Skipped", Message = "设备当前离线" }); - return; - } - - var data = new Dictionary { ["entity_id"] = entityId }; - if (extraData is not null) - { - foreach (var item in extraData) data[item.Key] = item.Value; - } - - try - { - await _homeAssistant.CallServiceAsync(domain, service, data, cancellationToken); - results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Succeeded", Message = "执行成功" }); - } - catch (Exception exception) - { - results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Failed", Message = exception.Message }); + _logger.LogError(exception, "场景 {SceneCode} 已执行,但用户 {UserId} 的执行记录未保存", sceneCode, userId); + output.Message += "(执行记录暂未保存)"; } + return output; } private long GetCurrentUserId() { var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(HealthUserIdClaim); - if (!long.TryParse(value, out var userId)) - throw new UnauthorizedAccessException("登录状态已失效"); + if (!long.TryParse(value, out var userId) || userId <= 0) + throw Oops.Oh("登录状态已失效,请重新登录").StatusCode(401); return userId; } - private List BuildDiscoveredDevices( - HomeAssistantRegistries? registries, - IReadOnlyDictionary stateMap) - { - var discovered = new List(); - - if (registries is not null && registries.Devices.Count > 0) - { - var areaMap = registries.Areas.ToDictionary(a => a.AreaId, a => a.Name, StringComparer.OrdinalIgnoreCase); - var entitiesByDevice = registries.Entities - .Where(e => !string.IsNullOrWhiteSpace(e.DeviceId)) - .GroupBy(e => e.DeviceId!, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); - - foreach (var device in registries.Devices) - { - var regEntities = entitiesByDevice.GetValueOrDefault(device.Id) ?? []; - var isXiaomi = (device.Manufacturer?.Contains("Xiaomi", StringComparison.OrdinalIgnoreCase) == true) - || (device.Model?.Contains("xiaomi", StringComparison.OrdinalIgnoreCase) == true) - || regEntities.Any(e => string.Equals(e.Platform, "xiaomi_home", StringComparison.OrdinalIgnoreCase)); - - if (!isXiaomi) - { - continue; - } - - var entityOutputs = new List(); - foreach (var regEntity in regEntities) - { - stateMap.TryGetValue(regEntity.EntityId, out var state); - var available = state is not null && IsAvailable(state); - var domain = regEntity.EntityId.Contains('.') ? regEntity.EntityId.Split('.')[0] : ""; - var name = !string.IsNullOrWhiteSpace(regEntity.Name) - ? regEntity.Name - : (!string.IsNullOrWhiteSpace(regEntity.OriginalName) ? regEntity.OriginalName : regEntity.EntityId); - - entityOutputs.Add(new HomeAssistantEntityOutput - { - EntityId = regEntity.EntityId, - Domain = domain, - Name = name, - State = state?.State ?? "unavailable", - Available = available, - Unit = state?.GetStringAttribute("unit_of_measurement"), - Attributes = ExtractSafeAttributes(state) - }); - } - - var isOnline = regEntities.Any(e => - { - if (!string.IsNullOrWhiteSpace(e.DisabledBy)) return false; - return stateMap.TryGetValue(e.EntityId, out var s) && IsAvailable(s); - }); - - var displayName = !string.IsNullOrWhiteSpace(device.NameByUser) - ? device.NameByUser - : (!string.IsNullOrWhiteSpace(device.Name) ? device.Name : "小米智能设备"); - - var areaName = !string.IsNullOrWhiteSpace(device.AreaId) && areaMap.TryGetValue(device.AreaId, out var an) - ? an - : null; - - var category = CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs); - var metrics = ExtractDeviceMetrics(category, entityOutputs); - var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs); - - discovered.Add(new HomeAssistantDiscoveredDeviceOutput - { - DeviceId = device.Id, - Name = displayName, - Manufacturer = device.Manufacturer ?? "Xiaomi", - Model = device.Model ?? "", - AreaName = areaName, - Category = category, - Online = isOnline, - PrimaryControlEntityId = primaryControlEntityId, - Entities = entityOutputs, - Metrics = metrics - }); - } - } - - if (discovered.Count == 0) - { - discovered = BuildFallbackDevices(stateMap); - } - - return discovered; - } - - private List BuildFallbackDevices(IReadOnlyDictionary stateMap) - { - var entities = _options.Entities; - var list = new List(); - - void AddFallback(string id, string name, string model, string category, string area, params string[] entityIds) - { - var entityOutputs = new List(); - foreach (var eid in entityIds.Where(s => !string.IsNullOrWhiteSpace(s))) - { - stateMap.TryGetValue(eid, out var state); - var available = state is not null && IsAvailable(state); - var domain = eid.Contains('.') ? eid.Split('.')[0] : ""; - entityOutputs.Add(new HomeAssistantEntityOutput - { - EntityId = eid, - Domain = domain, - Name = name, - State = state?.State ?? "unavailable", - Available = available, - Unit = state?.GetStringAttribute("unit_of_measurement"), - Attributes = ExtractSafeAttributes(state) - }); - } - - var online = entityOutputs.Any(e => e.Available); - var metrics = ExtractDeviceMetrics(category, entityOutputs); - var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs); - - list.Add(new HomeAssistantDiscoveredDeviceOutput - { - DeviceId = id, - Name = name, - Manufacturer = "Xiaomi", - Model = model, - AreaName = area, - Category = category, - Online = online, - PrimaryControlEntityId = primaryControlEntityId, - Entities = entityOutputs, - Metrics = metrics - }); - } - - AddFallback("fallback_dehumidifier", "米家变频净化除湿机 Max", "xiaomi.derh.60l", nameof(HealthDeviceCategory.EnvironmentControl), "睡眠空间", - entities.Dehumidifier, entities.DehumidifierFanLevel, entities.DehumidifierUv); - AddFallback("fallback_fragrance", "米家智能香氛机", "xiaomi.diffuser.xw002", nameof(HealthDeviceCategory.Fragrance), "睡眠空间", - entities.FragranceSwitch, entities.FragranceLight, entities.FragranceDuration, entities.FragranceInterval, - entities.FragranceQuickRelease, entities.FragranceAutoRelease, entities.FragranceAutoLight, entities.FragranceLightAutoOff); - AddFallback("fallback_ambient_light", "米家夜灯/氛围灯", "lemesh.light.wy0d02", nameof(HealthDeviceCategory.Lighting), "睡眠空间", - entities.AmbientLight); - AddFallback("fallback_speaker", "Xiaomi Sound Pro 智能音箱", "xiaomi.wifispeaker.l17a", nameof(HealthDeviceCategory.Media), "睡眠空间", - entities.Speaker, entities.SpeakerSleepMode); - AddFallback("fallback_air_monitor", "米家空气检测仪", "mirsz.airm.et71", nameof(HealthDeviceCategory.EnvironmentSensor), "环境监测", - entities.Temperature, entities.Humidity, entities.Pm25, entities.Co2, entities.Formaldehyde, entities.Tvoc); - AddFallback("fallback_mattress", "8H 智能调温睡眠床垫", "8h.mattress.v1", nameof(HealthDeviceCategory.Sleep), "睡眠空间"); - AddFallback("fallback_scale", "小米体脂秤 S400", "xiaomi.scale.v2", nameof(HealthDeviceCategory.BodyComposition), "卫浴空间"); - - return list; - } - - private string? FindPrimaryControlEntityId( - string category, - IReadOnlyList entities) - { - var configuredEntityIds = category switch - { - nameof(HealthDeviceCategory.EnvironmentControl) => new[] { _options.Entities.Dehumidifier }, - nameof(HealthDeviceCategory.Fragrance) => new[] { _options.Entities.FragranceSwitch }, - nameof(HealthDeviceCategory.Lighting) => new[] { _options.Entities.AmbientLight, _options.Entities.FragranceLight }, - nameof(HealthDeviceCategory.Media) => new[] { _options.Entities.Speaker }, - _ => Array.Empty() - }; - - foreach (var configuredEntityId in configuredEntityIds.Where(id => !string.IsNullOrWhiteSpace(id))) - { - if (entities.Any(e => string.Equals(e.EntityId, configuredEntityId, StringComparison.OrdinalIgnoreCase))) - return configuredEntityId; - } - - return category switch - { - nameof(HealthDeviceCategory.EnvironmentControl) => entities.FirstOrDefault(e => e.Domain == "humidifier")?.EntityId, - nameof(HealthDeviceCategory.Lighting) => entities.FirstOrDefault(e => e.Domain == "light")?.EntityId, - nameof(HealthDeviceCategory.Media) => entities.FirstOrDefault(e => e.Domain == "media_player")?.EntityId, - _ => null - }; - } - - private static HomeAssistantSummaryOutput BuildSummary( - IReadOnlyList devices) - { - var totalDevices = devices.Count; - var onlineDevices = devices.Count(d => d.Online); - var offlineDevices = totalDevices - onlineDevices; - var totalEntities = devices.Sum(d => d.Entities.Count); - var availableEntities = devices.Sum(d => d.Entities.Count(e => e.Available)); - - return new HomeAssistantSummaryOutput - { - TotalDevices = totalDevices, - OnlineDevices = onlineDevices, - OfflineDevices = offlineDevices, - TotalEntities = totalEntities, - AvailableEntities = availableEntities - }; - } - - private HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary stateMap) - { - var entities = _options.Entities; - var sceneEntityIds = new List(); - - if (!string.IsNullOrWhiteSpace(entities.Dehumidifier)) - sceneEntityIds.Add(entities.Dehumidifier); - if (_options.SleepScene.EnableFragrance && !string.IsNullOrWhiteSpace(entities.FragranceSwitch)) - sceneEntityIds.Add(entities.FragranceSwitch); - if (!string.IsNullOrWhiteSpace(entities.AmbientLight)) - sceneEntityIds.Add(entities.AmbientLight); - if (!string.IsNullOrWhiteSpace(entities.Speaker)) - sceneEntityIds.Add(entities.Speaker); - else if (_options.SleepScene.EnableSpeakerSleepMode && !string.IsNullOrWhiteSpace(entities.SpeakerSleepMode)) - sceneEntityIds.Add(entities.SpeakerSleepMode); - if (_options.SleepScene.EnableMattress && !string.IsNullOrWhiteSpace(entities.MattressLeftHeatingLevel)) - sceneEntityIds.Add(entities.MattressLeftHeatingLevel); - - sceneEntityIds = sceneEntityIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - var sceneAvailableCount = sceneEntityIds.Count(entityId => - stateMap.TryGetValue(entityId, out var state) && IsAvailable(state)); - - return new HomeAssistantSceneDevicesSummaryOutput - { - Total = sceneEntityIds.Count, - Available = sceneAvailableCount - }; - } - - private HomeAssistantEnvironmentOutput BuildEnvironmentOutput( - IReadOnlyDictionary stateMap, - IReadOnlyList devices) - { - var entities = _options.Entities; - var envDevice = devices.FirstOrDefault(d => d.Category == nameof(HealthDeviceCategory.EnvironmentSensor)); - - double? GetVal(string entityId, string metricKey) - { - if (envDevice?.Metrics.TryGetValue(metricKey, out var mv) == true && mv != null) - { - if (double.TryParse(mv.ToString(), out var dVal)) return dVal; - } - if (!string.IsNullOrWhiteSpace(entityId) && stateMap.TryGetValue(entityId, out var state) && IsAvailable(state)) - { - if (double.TryParse(state.State, out var dVal)) return dVal; - } - return null; - } - - var humidity = GetVal(entities.Humidity, "humidity"); - if (humidity == null && !string.IsNullOrWhiteSpace(entities.Dehumidifier) && - stateMap.TryGetValue(entities.Dehumidifier, out var dehumState) && IsAvailable(dehumState)) - { - humidity = dehumState.GetDoubleAttribute("current_humidity"); - } - - return new HomeAssistantEnvironmentOutput - { - Temperature = GetVal(entities.Temperature, "temperature"), - Humidity = humidity, - Pm25 = GetVal(entities.Pm25, "pm25"), - Pm10 = GetVal(string.Empty, "pm10"), - Co2 = GetVal(entities.Co2, "co2"), - Hcho = GetVal(entities.Formaldehyde, "hcho"), - Tvoc = GetVal(entities.Tvoc, "tvoc") - }; - } - - private static string CategorizeDevice( - string name, - string model, - string manufacturer, - IReadOnlyList entities) - { - var text = $"{name} {model} {manufacturer}".ToLowerInvariant(); - - if (text.Contains("除湿") || text.Contains("derh") || entities.Any(e => e.Domain == "humidifier")) - return nameof(HealthDeviceCategory.EnvironmentControl); - - if (text.Contains("空气检测") || text.Contains("airm") || text.Contains("et71")) - return nameof(HealthDeviceCategory.EnvironmentSensor); - - if (text.Contains("床垫") || text.Contains("mattress")) - return nameof(HealthDeviceCategory.Sleep); - - if (text.Contains("体脂") || text.Contains("scale")) - return nameof(HealthDeviceCategory.BodyComposition); - - if (text.Contains("香氛") || text.Contains("diffuser") || text.Contains("xw002")) - return nameof(HealthDeviceCategory.Fragrance); - - if (text.Contains("灯") || text.Contains("light") || text.Contains("lemesh")) - return nameof(HealthDeviceCategory.Lighting); - - if (text.Contains("sound") || text.Contains("音箱") || text.Contains("wifispeaker") || text.Contains("l17a") || entities.Any(e => e.Domain == "media_player")) - return nameof(HealthDeviceCategory.Media); - - return nameof(HealthDeviceCategory.Other); - } - - private static Dictionary ExtractDeviceMetrics(string category, IReadOnlyList entities) - { - var metrics = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var entity in entities) - { - if (!entity.Available) continue; - var id = entity.EntityId.ToLowerInvariant(); - - if (id.Contains("relative_humidity") || (entity.Domain == "humidifier" && entity.Attributes?.ContainsKey("current_humidity") == true)) - { - metrics["humidity"] = entity.Attributes?.GetValueOrDefault("current_humidity") ?? entity.State; - } - if (entity.Domain == "humidifier") - { - if (entity.Attributes?.TryGetValue("humidity", out var th) == true) metrics["targetHumidity"] = th; - if (entity.Attributes?.TryGetValue("mode", out var mode) == true) metrics["mode"] = mode; - } - if (id.Contains("temperature")) metrics["temperature"] = entity.State; - if (id.Contains("pm2_5") || id.Contains("pm25")) metrics["pm25"] = entity.State; - if (id.Contains("pm10")) metrics["pm10"] = entity.State; - if (id.Contains("co2")) metrics["co2"] = entity.State; - if (id.Contains("hcho") || id.Contains("formaldehyde")) metrics["hcho"] = entity.State; - if (id.Contains("tvoc")) metrics["tvoc"] = entity.State; - if (id.Contains("weight")) metrics["weight"] = entity.State; - if (id.Contains("body_fat") || id.Contains("fat_rate")) metrics["bodyFatRate"] = entity.State; - if (id.Contains("firmness") || id.Contains("soft")) metrics["firmness"] = entity.State; - if (id.Contains("sleep_state") || id.Contains("sleep_status")) metrics["sleepStatus"] = entity.State; - } - - return metrics; - } - - private static Dictionary? ExtractSafeAttributes(HomeAssistantState? state) - { - if (state == null || state.Attributes.ValueKind != JsonValueKind.Object) return null; - var safe = new Dictionary(StringComparer.OrdinalIgnoreCase); - var safeKeys = new[] { - "friendly_name", "unit_of_measurement", "device_class", - "current_humidity", "humidity", "mode", "fan_mode", "percentage", - "brightness", "color_temp", "color_temp_kelvin", - "volume_level", "is_volume_muted", "media_title", - "battery_level", "temperature" - }; - - foreach (var key in safeKeys) - { - if (state.Attributes.TryGetProperty(key, out var prop)) - { - safe[key] = prop.ValueKind switch - { - JsonValueKind.String => prop.GetString(), - JsonValueKind.Number => prop.TryGetInt64(out var l) ? l : prop.GetDouble(), - JsonValueKind.True => true, - JsonValueKind.False => false, - _ => prop.ToString() - }; - } - } - return safe.Count > 0 ? safe : null; - } - private Dictionary BuildControlParameterStates( IReadOnlyDictionary stateMap) { - var configured = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["humidity"] = _options.Entities.Dehumidifier, - ["fan_level"] = _options.Entities.DehumidifierFanLevel, - ["uv"] = _options.Entities.DehumidifierUv, - ["brightness"] = _options.Entities.AmbientLight, - ["color_temp"] = _options.Entities.AmbientLight, - ["volume"] = _options.Entities.Speaker, - ["speaker_sleep_mode"] = _options.Entities.SpeakerSleepMode, - ["fragrance_duration"] = _options.Entities.FragranceDuration, - ["fragrance_interval"] = _options.Entities.FragranceInterval, - ["fragrance_light"] = _options.Entities.FragranceLight, - ["fragrance_quick_release"] = _options.Entities.FragranceQuickRelease, - ["fragrance_auto_release"] = _options.Entities.FragranceAutoRelease, - ["fragrance_auto_light"] = _options.Entities.FragranceAutoLight, - ["fragrance_light_auto_off"] = _options.Entities.FragranceLightAutoOff, - ["mattress_left_heating"] = _options.Entities.MattressLeftHeatingLevel, - ["mattress_right_heating"] = _options.Entities.MattressRightHeatingLevel, - ["mattress_left_mode"] = _options.Entities.MattressLeftMode, - ["mattress_right_mode"] = _options.Entities.MattressRightMode, - ["mattress_massage_left_part"] = _options.Entities.MattressLeftMassagePart, - ["mattress_massage_right_part"] = _options.Entities.MattressRightMassagePart, - ["mattress_massage_level"] = _options.Entities.MattressMassageLevel - }; - foreach (var (zone, entityId) in _options.Entities.MattressFirmness) - configured[$"mattress_firmness_{zone}"] = entityId; var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (parameterType, entityId) in configured) + foreach (var parameter in _modules.Modules.SelectMany(module => module.Parameters)) { - if (string.IsNullOrWhiteSpace(entityId) || !stateMap.TryGetValue(entityId, out var state)) continue; - result[parameterType] = new HomeAssistantParameterStateOutput + if (string.IsNullOrWhiteSpace(parameter.EntityId) || !stateMap.TryGetValue(parameter.EntityId, out var state)) continue; + result[parameter.Key] = new() { - EntityId = entityId, - State = state.State, - Available = IsAvailable(state), - Attributes = ExtractSafeAttributes(state) + EntityId = parameter.EntityId, State = state.State, Available = IsAvailable(state), + Attributes = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state) }; } return result; } - private static string ReadString(object? value) - { - if (value is JsonElement element) - { - return element.ValueKind == JsonValueKind.String - ? element.GetString()?.Trim() ?? string.Empty - : element.ToString().Trim(); - } - return value?.ToString()?.Trim() ?? string.Empty; - } - - private static int ReadInt(object? value, int min, int max, string label) - { - if (!int.TryParse(ReadString(value), out var parsed) || parsed < min || parsed > max) - throw new InvalidOperationException($"{label}应在 {min} 到 {max} 之间"); - return parsed; - } - - private static bool ReadBool(object? value) - { - if (value is JsonElement element && element.ValueKind is JsonValueKind.True or JsonValueKind.False) - return element.GetBoolean(); - if (bool.TryParse(ReadString(value), out var parsed)) return parsed; - throw new InvalidOperationException("开关参数无效"); - } - - private static string ReadMattressMode(object? value) - { - var mode = ReadString(value); - if (mode is not ("无" or "瑜伽助眠" or "智能适配" or "智能腰托")) - throw new InvalidOperationException("床垫模式无效"); - return mode; - } - - private static string ReadFirmness(object? value) - { - var firmness = ReadString(value); - if (firmness is not ("最软" or "很软" or "稍软" or "稍硬" or "很硬" or "最硬")) - throw new InvalidOperationException("床垫软硬度选项无效"); - return firmness; - } - - private static string MattressZoneName(string zone) => zone switch - { - "left_shoulder" => "左肩", "right_shoulder" => "右肩", - "left_back" => "左背", "right_back" => "右背", - "left_waist" => "左腰", "right_waist" => "右腰", - "left_lumbar" => "左腰椎", "right_lumbar" => "右腰椎", - "left_hip" => "左臀", "right_hip" => "右臀", - "left_leg" => "左腿", "right_leg" => "右腿", - _ => zone - }; - - private static string ReadMassagePart(object? value) - { - var part = ReadString(value); - if (part is not ("无" or "背部" or "腰部" or "牵引" or "腿部" or "全身")) - throw new InvalidOperationException("按摩部位选项无效"); - return part; - } - - private static string ReadMassageLevel(object? value) - { - var level = ReadString(value); - if (level is not ("轻柔" or "强劲")) - throw new InvalidOperationException("按摩力度选项无效"); - return level; - } - private List BuildScenePresets( - IReadOnlyDictionary stateMap, - PersonalizedSceneConfig? userConfig, - string constitutionCode) + IReadOnlyDictionary states, + PersonalizedSceneConfig? config, string constitutionCode) { - var defaultScene = _options.SleepScene; - var targetHumidity = userConfig?.TargetHumidity ?? defaultScene.TargetHumidity; - var dehumMode = userConfig?.DehumidifierMode ?? defaultScene.DehumidifierMode; - var fanLevel = userConfig?.FanLevel ?? defaultScene.FanLevel; - var enableUv = userConfig?.EnableUv ?? defaultScene.EnableUv; - var lightColorTemp = userConfig?.LightColorTemperatureKelvin ?? defaultScene.LightColorTemperatureKelvin; - var enableFragrance = userConfig?.EnableFragrance ?? defaultScene.EnableFragrance; - var enableSpeaker = userConfig?.EnableSpeakerSleepMode ?? defaultScene.EnableSpeakerSleepMode; - var presets = new List(); - - if (!string.IsNullOrWhiteSpace(_options.Entities.Dehumidifier)) + if (config is null) return []; + var previews = _modules.Modules.Select(module => module.BuildPreview(config, constitutionCode)) + .Where(preview => preview is not null).ToList(); + foreach (var preview in previews) { - presets.Add(new HomeAssistantScenePresetItem - { - DeviceName = "米家变频净化除湿机 Max", - Category = nameof(HealthDeviceCategory.EnvironmentControl), - EntityId = _options.Entities.Dehumidifier, - Action = "开启恒湿舒睡", - Detail = $"目标湿度 {targetHumidity}% · {dehumMode}模式 · 风速{fanLevel} · UV{(enableUv ? "开" : "关")}", - Parameters = new() - { - ["target_humidity"] = $"{targetHumidity}%", - ["mode"] = dehumMode, - ["fan_level"] = fanLevel, - ["uv"] = enableUv ? "开启" : "关闭" - } - }); + if (!string.IsNullOrWhiteSpace(preview!.AvailabilityMessage)) continue; + preview.Available = !string.IsNullOrWhiteSpace(preview.EntityId) && + states.TryGetValue(preview.EntityId, out var state) && IsAvailable(state); + preview.AvailabilityMessage = preview.Available ? "当前可执行" : "当前不可用,执行时跳过"; } - - if (enableFragrance && !string.IsNullOrWhiteSpace(_options.Entities.FragranceSwitch)) - { - presets.Add(new HomeAssistantScenePresetItem - { - DeviceName = "米家智能香氛机", - Category = nameof(HealthDeviceCategory.Fragrance), - EntityId = _options.Entities.FragranceSwitch, - Action = "开启香氛释放", - Detail = "开启香氛机;香氛灯可用时同步开启柔光", - Parameters = new() { ["power"] = "开启" } - }); - } - - if (!string.IsNullOrWhiteSpace(_options.Entities.AmbientLight)) - { - presets.Add(new HomeAssistantScenePresetItem - { - DeviceName = "Lemesh 色温灯", - Category = nameof(HealthDeviceCategory.Lighting), - EntityId = _options.Entities.AmbientLight, - Action = "开启助眠渐暗灯光", - Detail = $"从50%开始,每5秒降低1%,最终关灯 · 色温 {lightColorTemp}K", - Parameters = new() - { - ["brightness"] = "50%→关闭", - ["color_temp"] = $"{lightColorTemp}K" - } - }); - } - - if (!string.IsNullOrWhiteSpace(_options.Entities.Speaker)) - { - presets.Add(new HomeAssistantScenePresetItem - { - DeviceName = "Xiaomi Sound Pro", - Category = nameof(HealthDeviceCategory.Media), - EntityId = _options.Entities.Speaker, - Action = enableSpeaker ? "播放体质音乐并开启睡眠模式" : "播放体质音乐", - Detail = enableSpeaker ? "体质曲目循环播放 · 音量50%每5秒降1% · 开启睡眠模式" : "体质曲目循环播放 · 音量50%每5秒降1%", - Parameters = new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = "50%→0%" } - }); - } - - if (defaultScene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel)) - { - var heatingLevel = userConfig?.MattressHeatingLevel ?? defaultScene.MattressHeatingLevel; - var firmness = MattressComfortService.FirmnessName(userConfig?.MattressLeftFirmness ?? 0); - presets.Add(new HomeAssistantScenePresetItem - { - DeviceName = "8H AI智能好睡眠床垫X", - Category = nameof(HealthDeviceCategory.Sleep), - EntityId = _options.Entities.MattressLeftHeatingLevel, - Action = "按天气和 BMI 调节床垫并开启右侧按摩", - Detail = $"左右加热 {heatingLevel} 档 · 软硬度{firmness ?? "待填写 BMI"} · 右侧全身轻柔按摩", - Parameters = new() - { - ["heating_level"] = heatingLevel, - ["firmness"] = firmness ?? "未自动调节", - ["right_massage_part"] = "全身", - ["massage_level"] = "轻柔" - } - }); - } - - foreach (var preset in presets) - { - if (preset.Category == nameof(HealthDeviceCategory.Media)) - { - preset.Available = _musicTransition.HasPlayableTracks(constitutionCode); - preset.AvailabilityMessage = preset.Available - ? "体质音乐已配置,执行时验证音箱" - : "该体质的 OSS 音乐 URL 尚未配置"; - continue; - } - preset.Available = !string.IsNullOrWhiteSpace(preset.EntityId) && - stateMap.TryGetValue(preset.EntityId, out var state) && - IsAvailable(state); - preset.AvailabilityMessage = preset.Available ? "当前可执行" : "当前不可用,执行时跳过"; - } - - return presets; + return previews!; } private static bool IsAvailable(HomeAssistantState state) - => state.State != "unavailable" && - (state.State != "unknown" || state.EntityId.StartsWith("button.", StringComparison.OrdinalIgnoreCase)); + => DeviceActionRunner.IsAvailable(state); } diff --git a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs index 9a7649c..4fff542 100644 --- a/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthTcmAssessmentService.cs @@ -1,5 +1,6 @@ using Evergreen.Health.Application.Health.Entities; using Evergreen.Health.Application.Health.Models; +using Evergreen.Health.Application.Health.Policies; using Furion.DatabaseAccessor; using Furion.FriendlyException; using Microsoft.AspNetCore.Http; @@ -144,6 +145,7 @@ public static class TcmConstitutionRules result.MusicRecommendation = MusicFor(result.ConstitutionCode); result.SceneConfig ??= new PersonalizedSceneConfig(); result.SceneConfig.SpeakerVolumePercent = 50; + result.RoomTemperatureRecommendation = RecommendedRoomRange.ForConstitution(result.ConstitutionCode).Display; } return result; } @@ -202,7 +204,7 @@ public static class TcmConstitutionRules AromaRecommendation = input.Answers["allergy"] == "often" ? "建议不使用香氛,避免过敏原" : aroma, MusicRecommendation = music, - RoomTemperatureRecommendation = room, + RoomTemperatureRecommendation = RecommendedRoomRange.ForConstitution(primary).Display, Scores = scores, Answers = Choices.Keys.ToDictionary(key => key, key => input.Answers[key]), Notes = notes, diff --git a/Evergreen.Health.Application/Health/Services/MattressComfortService.cs b/Evergreen.Health.Application/Health/Services/MattressComfortService.cs index aad0a8e..d0c7217 100644 --- a/Evergreen.Health.Application/Health/Services/MattressComfortService.cs +++ b/Evergreen.Health.Application/Health/Services/MattressComfortService.cs @@ -1,65 +1,54 @@ +using Evergreen.Health.Application.Health.Devices.Environment; using Evergreen.Health.Application.Health.Entities; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Models; -using System.Text.Json; +using Evergreen.Health.Application.Health.Policies; +using Microsoft.Extensions.Options; namespace Evergreen.Health.Application.Health.Services; -// 重庆演示场景:BMI 只给出可手动修改的软硬起点,不是医学或人体工学诊断。 +/// 组合独立的 BMI 和室温策略,不负责发送家具控制命令。 public sealed class MattressComfortService : ITransient { - // 演示使用 Open-Meteo 接口;正式商用前应确认许可或替换为有授权的天气源。 - private const string WeatherUrl = - "https://api.open-meteo.com/v1/forecast?latitude=29.56&longitude=106.55¤t=temperature_2m%2Capparent_temperature&timezone=Asia%2FShanghai"; - private static readonly HttpClient WeatherClient = new() { Timeout = TimeSpan.FromSeconds(6) }; - private static readonly SemaphoreSlim WeatherLock = new(1, 1); - private static WeatherSample? CachedWeather; + private readonly IHomeAssistantGateway _gateway; + private readonly IndoorEnvironmentReader _environment; + private readonly HomeAssistantEntityOptions _entities; + public MattressComfortService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment, + IOptions options) + => (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities); public async Task GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default) { - decimal? bmi = null; - if (profile?.HeightCm > 0 && profile.WeightKg > 0) + IReadOnlyDictionary states; + try { - var height = profile.HeightCm.Value / 100m; - bmi = Math.Round(profile.WeightKg.Value / (height * height), 1); + states = (await _gateway.GetStatesAsync(cancellationToken)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase); } - - var firmness = bmi switch + catch (Exception exception) when (exception is not OperationCanceledException) { - < 18.5m => "很软", - < 24m => "稍软", - < 28m => "稍硬", - < 32m => "很硬", - >= 32m => "最硬", - _ => null - }; - var chinaNow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8)); - var winter = chinaNow.Month is 12 or 1 or 2; - var weather = await ReadWeatherAsync(cancellationToken); - var heating = 0; - if (winter && weather?.FeelsLikeCelsius is double feelsLike) - heating = feelsLike < 8 ? 2 : feelsLike < 16 ? 1 : 0; + states = new Dictionary(); + } + return Get(profile, states); + } - var weatherReason = !winter - ? "重庆非冬季,床垫加热关闭" - : weather == null - ? "重庆天气暂不可用,为安全起见不自动加热" - : heating == 0 - ? "重庆处于冬季,但当前体感温度较高,不自动加热" - : $"重庆冬季体感 {weather.FeelsLikeCelsius:0.#}℃,建议加热 {heating} 档"; - - return new MattressComfortOutput + public MattressComfortOutput Get(HealthProfile? profile, IReadOnlyDictionary states) + { + var bmi = BmiFirmnessPolicy.Calculate(profile?.HeightCm, profile?.WeightKg); + var firmness = BmiFirmnessPolicy.Firmness(bmi); + var tcm = TcmConstitutionRules.Read(profile); + var sample = _environment.Read(states); + var advice = RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution(tcm.ConstitutionCode)); + advice.PendingIntegration = (advice.Mode is "heat" or "cool") && string.IsNullOrWhiteSpace(_entities.Climate); + return new() { - Bmi = bmi, - Firmness = firmness, - HeatingLevel = heating, - IsWinter = winter, - OutdoorTemperatureCelsius = weather?.TemperatureCelsius, - FeelsLikeCelsius = weather?.FeelsLikeCelsius, - WeatherAvailable = weather != null, - WeatherObservedAt = weather?.ObservedAt, - WeatherReason = weatherReason, - FirmnessReason = firmness == null - ? "健康档案尚无身高和体重,暂不自动调节软硬度" + Bmi = bmi, Firmness = firmness, HeatingLevel = advice.Mode == "heat" ? 1 : 0, + IndoorTemperatureCelsius = sample.Celsius, IndoorTemperatureAvailable = sample.Available, + IndoorTemperatureObservedAt = sample.ObservedAt, HeatingReason = advice.Reason, + WeatherReason = advice.Reason, // 旧客户端兼容字段,不再读取或依赖室外天气。 + ClimateAdvice = advice, + FirmnessReason = firmness is null + ? "健康档案尚无有效身高和体重,暂不自动调节软硬度" : $"按 BMI {bmi:0.0} 给出“{firmness}”演示起点,可在设备详情手动调整" }; } @@ -67,61 +56,18 @@ public sealed class MattressComfortService : ITransient public static void ApplyTo(PersonalizedSceneConfig config, MattressComfortOutput recommendation) { config.MattressHeatingLevel = recommendation.HeatingLevel; - config.MattressLeftFirmness = recommendation.Firmness == null ? 0 : FirmnessLevel(recommendation.Firmness); + config.MattressLeftFirmness = recommendation.Firmness is null ? 0 : FirmnessLevel(recommendation.Firmness); config.MattressRightFirmness = config.MattressLeftFirmness; + config.ClimateAdvice = recommendation.ClimateAdvice; } public static int FirmnessLevel(string firmness) => firmness switch { - "最软" => 1, "很软" => 2, "稍软" => 3, - "稍硬" => 4, "很硬" => 5, "最硬" => 6, - _ => 0 + "最软" => 1, "很软" => 2, "稍软" => 3, "稍硬" => 4, "很硬" => 5, "最硬" => 6, _ => 0 }; public static string? FirmnessName(int level) => level switch { - 1 => "最软", 2 => "很软", 3 => "稍软", - 4 => "稍硬", 5 => "很硬", 6 => "最硬", - _ => null + 1 => "最软", 2 => "很软", 3 => "稍软", 4 => "稍硬", 5 => "很硬", 6 => "最硬", _ => null }; - - private static async Task ReadWeatherAsync(CancellationToken cancellationToken) - { - if (CachedWeather is { } cached && DateTimeOffset.UtcNow - cached.FetchedAt < TimeSpan.FromMinutes(30)) - return cached; - - await WeatherLock.WaitAsync(cancellationToken); - try - { - if (CachedWeather is { } fresh && DateTimeOffset.UtcNow - fresh.FetchedAt < TimeSpan.FromMinutes(30)) - return fresh; - - using var response = await WeatherClient.GetAsync(WeatherUrl, cancellationToken); - response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); - var current = json.RootElement.GetProperty("current"); - var temperature = current.GetProperty("temperature_2m").GetDouble(); - var feelsLike = current.GetProperty("apparent_temperature").GetDouble(); - var observedAt = current.GetProperty("time").GetString(); - CachedWeather = new WeatherSample(temperature, feelsLike, observedAt, DateTimeOffset.UtcNow); - return CachedWeather; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // 天气失败时不使用过期值自动开启加热,冬季也安全关闭。 - return null; - } - finally - { - WeatherLock.Release(); - } - } - - private sealed record WeatherSample( - double TemperatureCelsius, double FeelsLikeCelsius, string? ObservedAt, DateTimeOffset FetchedAt); } diff --git a/Evergreen.Health.Application/Health/Sleep/AnomalyExtension.cs b/Evergreen.Health.Application/Health/Sleep/AnomalyExtension.cs new file mode 100644 index 0000000..fb23fab --- /dev/null +++ b/Evergreen.Health.Application/Health/Sleep/AnomalyExtension.cs @@ -0,0 +1,26 @@ +namespace Evergreen.Health.Application.Health.Sleep; + +/// 本版没有异常判定算法;未来规则只产生事件,不直接操作音箱或家具。 +public sealed record SleepAnomaly(string Code, string Description, DateTimeOffset ObservedAt); +public interface ISleepAnomalyRule +{ + string Key { get; } + SleepAnomaly? Evaluate(IReadOnlyList observations); +} + +/// 每个舒睡会话独立持有;只有语音实际播报后才记录提醒时间。 +public sealed class AnomalyReminderGate +{ + private readonly TimeSpan _interval; + private readonly Dictionary _delivered = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + public AnomalyReminderGate(SleepWorkflowOptions options) => _interval = options.SameAnomalyReminderInterval; + public bool CanRemind(string code, DateTimeOffset now) + { + lock (_lock) return !_delivered.TryGetValue(code, out var last) || now - last >= _interval; + } + public void MarkDelivered(string code, DateTimeOffset now) + { + lock (_lock) _delivered[code] = now; + } +} diff --git a/Evergreen.Health.Application/Health/Sleep/GuidanceContracts.cs b/Evergreen.Health.Application/Health/Sleep/GuidanceContracts.cs new file mode 100644 index 0000000..0d7f7d0 --- /dev/null +++ b/Evergreen.Health.Application/Health/Sleep/GuidanceContracts.cs @@ -0,0 +1,31 @@ +namespace Evergreen.Health.Application.Health.Sleep; + +public sealed record GuidanceClip(string Key, string Title, string? Url, double? DurationSeconds) +{ + public bool Playable => DurationSeconds is > 0 && double.IsFinite(DurationSeconds.Value) && + Uri.TryCreate(Url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; +} + +/// 假数据仅是目录元数据,没有伪造 OSS 地址或播放成功状态。 +public sealed class GuidanceCatalog +{ + public GuidanceCatalog(GuidanceClip? initialRelaxation = null) + => InitialRelaxation = initialRelaxation ?? new("initial-relaxation", "首次上床放松引导(待上传)", null, null); + public GuidanceClip InitialRelaxation { get; } + public GuidanceClip AnomalyReminder { get; } = new("anomaly-reminder", "睡眠状态语音提醒(预留)", null, null); +} + +public sealed record GuidancePlaybackResult(bool Completed, string Message); +public interface IGuidancePlayer +{ + Task PlayAsync(GuidanceClip clip, int volumePercent, CancellationToken cancellationToken); +} + +public sealed class ReservedGuidancePlayer : IGuidancePlayer +{ + public Task PlayAsync(GuidanceClip clip, int volumePercent, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new GuidancePlaybackResult(false, "引导音频尚未接入,本版不执行播放")); + } +} diff --git a/Evergreen.Health.Application/Health/Sleep/ReservedSleepWorkflow.cs b/Evergreen.Health.Application/Health/Sleep/ReservedSleepWorkflow.cs new file mode 100644 index 0000000..e017acb --- /dev/null +++ b/Evergreen.Health.Application/Health/Sleep/ReservedSleepWorkflow.cs @@ -0,0 +1,76 @@ +namespace Evergreen.Health.Application.Health.Sleep; + +public enum ReservedSleepPhase { Disabled, WaitingForBed, Settling, Guiding, Music, Stopped } +public enum SleepWorkflowActionKind { PrepareEnvironment, StartGentleRightMassage, RequestGuidance, RestartMusicAtFifty, StartLightFade } +public sealed record SleepWorkflowAction(SleepWorkflowActionKind Kind, GuidanceClip? Clip = null, int? VolumePercent = null); + +/// +/// 只规划未来动作:没有 HostedService、设备命令或 API 路由,本版默认 Disabled。 +/// 实际接入时由场景协调层消费动作,不能从传感器适配器直接控制家具。 +/// +public sealed class ReservedSleepWorkflow +{ + private readonly SleepWorkflowOptions _options; + private readonly GuidanceCatalog _guidance; + private DateTimeOffset? _occupiedSince; + private DateTimeOffset? _fadeAt; + private GuidanceClip? _pendingClip; + private bool _fadeIssued; + private bool _initialGuide; + public ReservedSleepPhase Phase { get; private set; } = ReservedSleepPhase.Disabled; + + public ReservedSleepWorkflow(SleepWorkflowOptions options, GuidanceCatalog guidance) + => (_options, _guidance) = (options, guidance); + + public IReadOnlyList Start() + { + _occupiedSince = null; _fadeAt = null; _pendingClip = null; _fadeIssued = false; _initialGuide = false; + Phase = _options.Enabled ? ReservedSleepPhase.WaitingForBed : ReservedSleepPhase.Disabled; + return _options.Enabled ? [new(SleepWorkflowActionKind.PrepareEnvironment)] : []; + } + + public IReadOnlyList Observe(SleepObservation observation, DateTimeOffset now) + { + if (Phase is not (ReservedSleepPhase.WaitingForBed or ReservedSleepPhase.Settling)) return []; + var fresh = observation.Availability == SleepDataAvailability.Available && observation.ObservedAt is not null && + now - observation.ObservedAt <= _options.MaximumObservationAge && observation.ObservedAt <= now.AddMinutes(1); + if (!fresh || observation.Presence != BedPresence.Occupied) + { + _occupiedSince = null; Phase = ReservedSleepPhase.WaitingForBed; return []; + } + _occupiedSince ??= now; + Phase = ReservedSleepPhase.Settling; + if (now - _occupiedSince < _options.StableBedDuration) return []; + _pendingClip = _guidance.InitialRelaxation; + _initialGuide = true; + Phase = ReservedSleepPhase.Guiding; + return [new(SleepWorkflowActionKind.StartGentleRightMassage), + new(SleepWorkflowActionKind.RequestGuidance, _pendingClip, _options.GuidanceVolumePercent)]; + } + + public IReadOnlyList GuidanceCompleted(string clipKey, bool completed, DateTimeOffset now) + { + if (Phase != ReservedSleepPhase.Guiding || !completed || _pendingClip?.Key != clipKey || !_pendingClip.Playable) return []; + Phase = ReservedSleepPhase.Music; + if (_initialGuide) _fadeAt = now + _options.PostGuidanceFadeDelay; + return [new(SleepWorkflowActionKind.RestartMusicAtFifty, VolumePercent: 50)]; + } + + public IReadOnlyList RequestReminder(GuidanceClip clip) + { + if (!_options.Enabled || Phase != ReservedSleepPhase.Music) return []; + _initialGuide = false; _pendingClip = clip; Phase = ReservedSleepPhase.Guiding; + return [new(SleepWorkflowActionKind.RequestGuidance, clip, _options.GuidanceVolumePercent)]; + } + + public IReadOnlyList Tick(DateTimeOffset now) + { + if (Phase != ReservedSleepPhase.Music || _fadeIssued || _fadeAt is null || now < _fadeAt) return []; + _fadeIssued = true; return [new(SleepWorkflowActionKind.StartLightFade)]; + } + + public void Stop() + { + Phase = ReservedSleepPhase.Stopped; _occupiedSince = null; _fadeAt = null; _pendingClip = null; + } +} diff --git a/Evergreen.Health.Application/Health/Sleep/SleepContracts.cs b/Evergreen.Health.Application/Health/Sleep/SleepContracts.cs new file mode 100644 index 0000000..633f4fc --- /dev/null +++ b/Evergreen.Health.Application/Health/Sleep/SleepContracts.cs @@ -0,0 +1,38 @@ +namespace Evergreen.Health.Application.Health.Sleep; + +public enum BedPresence { Unknown, Empty, Occupied } +public enum SleepDataAvailability { Unconfigured, Offline, Stale, Available } +public enum ObservedSleepStage { Unknown, Awake, Light, Deep } + +/// 设备读数归一化入口;不凭心率、睡眠阶段 0 或未知值推断有人/无人。 +public sealed record SleepObservation(string SourceId, DateTimeOffset? ObservedAt, + SleepDataAvailability Availability, BedPresence Presence, + ObservedSleepStage Stage = ObservedSleepStage.Unknown, + double? HeartRate = null, double? RespiratoryRate = null, bool? Movement = null); + +public interface ISleepObservationSource +{ + string Key { get; } + Task ReadAsync(CancellationToken cancellationToken); +} + +/// JE01 尚未接入,不启动轮询,也不将假数据视作“无人”。 +public sealed class ReservedJe01Source : ISleepObservationSource +{ + public string Key => "je01"; + public Task ReadAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new SleepObservation(Key, null, SleepDataAvailability.Unconfigured, BedPresence.Unknown)); + } +} + +public sealed class SleepWorkflowOptions +{ + public bool Enabled { get; init; } = false; + public TimeSpan StableBedDuration { get; init; } = TimeSpan.FromSeconds(30); + public TimeSpan PostGuidanceFadeDelay { get; init; } = TimeSpan.FromSeconds(30); + public TimeSpan MaximumObservationAge { get; init; } = TimeSpan.FromMinutes(2); + public TimeSpan SameAnomalyReminderInterval { get; init; } = TimeSpan.FromMinutes(30); + public int GuidanceVolumePercent { get; init; } = 50; +}