From 2d44cc39666c879880d24e63df4acdc805d1c33e Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Fri, 9 Oct 2026 18:06:11 +0800 Subject: [PATCH] 11 --- Admin.NET.Test/Health/DeviceTestDoubles.cs | 3 + ...omeAssistantAttributeSerializationTests.cs | 4 + .../Health/SceneLightingIntegrationTests.cs | 315 ++++++++++++++++++ .../Health/StandardLightControlTests.cs | 221 ++++++++++++ .../Configuration/HomeAssistant.json | 8 + .../Contracts/IStandardDeviceControl.cs | 12 + .../Devices/DeviceParameterController.cs | 66 ++++ .../Discovery/HomeAssistantDeviceCatalog.cs | 6 + .../Devices/HealthDeviceRegistration.cs | 14 +- .../Lighting/ConfiguredSleepLightModule.cs | 51 +++ .../Health/Devices/Lighting/LightingModule.cs | 6 +- .../Devices/Lighting/StandardLightControl.cs | 79 +++++ .../HomeAssistantLightTransitionService.cs | 293 +++------------- .../HomeAssistant/HomeAssistantOptions.cs | 10 + .../HomeAssistant/LightTransitionWorker.cs | 313 +++++++++++++++++ .../Health/Models/HomeAssistantModels.cs | 3 + .../Services/HealthHomeAssistantService.cs | 21 +- 17 files changed, 1161 insertions(+), 264 deletions(-) create mode 100644 Admin.NET.Test/Health/SceneLightingIntegrationTests.cs create mode 100644 Admin.NET.Test/Health/StandardLightControlTests.cs create mode 100644 Evergreen.Health.Application/Health/Devices/Contracts/IStandardDeviceControl.cs create mode 100644 Evergreen.Health.Application/Health/Devices/DeviceParameterController.cs create mode 100644 Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs create mode 100644 Evergreen.Health.Application/Health/Devices/Lighting/StandardLightControl.cs create mode 100644 Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs diff --git a/Admin.NET.Test/Health/DeviceTestDoubles.cs b/Admin.NET.Test/Health/DeviceTestDoubles.cs index 2a810c7..4452a9f 100644 --- a/Admin.NET.Test/Health/DeviceTestDoubles.cs +++ b/Admin.NET.Test/Health/DeviceTestDoubles.cs @@ -37,11 +37,14 @@ internal sealed class FakeHaGateway : IHomeAssistantGateway "select_option" => data["option"]!.ToString()!, "set_value" => data["value"]!.ToString()!, _ => States.GetValueOrDefault(entity)?.State ?? "unknown" }; + if (service == "set_hvac_mode") state = (string)data["hvac_mode"]!; 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; + foreach (var key in new[] { "effect", "color_temp_kelvin", "temperature", "fan_mode" }) + if (data.TryGetValue(key, out var value)) attributes[key] = value; States[entity] = new() { EntityId = entity, State = state, LastReported = Time.GetUtcNow(), LastUpdated = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(attributes) }; return Task.CompletedTask; diff --git a/Admin.NET.Test/Health/HomeAssistantAttributeSerializationTests.cs b/Admin.NET.Test/Health/HomeAssistantAttributeSerializationTests.cs index 856aa7f..414a955 100644 --- a/Admin.NET.Test/Health/HomeAssistantAttributeSerializationTests.cs +++ b/Admin.NET.Test/Health/HomeAssistantAttributeSerializationTests.cs @@ -28,6 +28,8 @@ public sealed class HomeAssistantAttributeSerializationTests fan_modes = new[] { "自动", "低风", "中风", "高风" }, swing_modes = new[] { "off", "vertical" }, options = new[] { "智能适配", "轻柔", "全身" }, + supported_color_modes = new[] { "color_temp" }, + effect_list = new[] { "阅读模式", "助眠模式" }, current_temperature = 24.9, temperature = 23, temperature_unit = "°C", @@ -50,6 +52,8 @@ public sealed class HomeAssistantAttributeSerializationTests [InlineData("fan_modes")] [InlineData("swing_modes")] [InlineData("options")] + [InlineData("supported_color_modes")] + [InlineData("effect_list")] public void ApiSerializesChoiceArraysAsStringsNotJsonElementMetadata(string key) { var state = State(); diff --git a/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs b/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs new file mode 100644 index 0000000..902fa3f --- /dev/null +++ b/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs @@ -0,0 +1,315 @@ +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.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 Evergreen.Health.Application.Health.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using System.Text.Json; +using Xunit; + +namespace Admin.NET.Test.Health; + +/// 两盏灯和空调的真实模块组合,全部使用内存网关与可推进时钟,绝不控制实机。 +public sealed class SceneLightingIntegrationTests +{ + private const string Primary = "light.bedroom", Strip = "light.strip", Climate = "climate.room", Temperature = "sensor.indoor"; + private static SceneLightOptions StripSettings() => new() { Key = "sleep-light-strip", Name = "测试助眠灯带", EntityId = Strip, SleepEffect = "助眠模式" }; + private static HomeAssistantOptions Settings() => new() + { + Entities = new() { AmbientLight = Primary, Climate = Climate, Temperature = Temperature, + MattressLeftHeatingLevel = "number.left_heat", MattressRightHeatingLevel = "number.right_heat" }, + AdditionalSleepLights = [StripSettings()], SleepScene = new() { EnableMattress = true } + }; + private static HomeAssistantState Light(string id, string state = "off", string[]? effects = null) => new() + { + EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new + { + supported_color_modes = new[] { "color_temp" }, min_color_temp_kelvin = 2700, max_color_temp_kelvin = 6500, + supported_features = 4, effect_list = effects ?? ["阅读模式", "助眠模式"], brightness = 128, color_temp_kelvin = 3000 + }) + }; + private static HomeAssistantState ClimateState(string mode = "off") => new() + { + EntityId = Climate, State = mode, Attributes = JsonSerializer.SerializeToElement(new + { + hvac_modes = new[] { "heat", "cool", "off", "fan_only" }, supported_features = 393, + min_temp = 5, max_temp = 35, target_temp_step = .5, temperature_unit = "°C", temperature = 23, + current_temperature = 99 // 故意与环境仪不同,不能误用空调内置温度进行舒睡判断。 + }) + }; + private sealed class Handler : ILightOffHandler + { + public int Calls; + public Task HandleAsync(CancellationToken token) { Calls++; return Task.CompletedTask; } + } + private sealed class Fixture + { + public ManualTestTimeProvider Time { get; } = new(); + public FakeHaGateway Gateway { get; } + public IOptions Options { get; } = Microsoft.Extensions.Options.Options.Create(Settings()); + public Handler Off { get; } = new(); + public HomeAssistantLightTransitionService Lights { get; } + public DeviceActionRunner Runner { get; } + public DeviceModuleRegistry Modules { get; } + public HealthSceneCoordinator Coordinator { get; } + public Fixture(bool mattress = false) + { + Gateway = new() { Time = Time }; + Gateway.States[Primary] = Light(Primary); Gateway.States[Strip] = Light(Strip); + Gateway.States[Climate] = ClimateState(); + foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Gateway.States[id] = new() { EntityId = id, State = "0" }; + Runner = new(Gateway, Time, new() { VerificationDelay = TimeSpan.Zero }); + var dispatcher = new LightOffDispatcher([Off]); + Lights = new(Gateway, dispatcher, Options, NullLogger.Instance, new(), Time); + var modules = new List + { + new LightingModule(Runner, Options, Lights.ForEntity(Primary), dispatcher), + new ConfiguredSleepLightModule(Runner, StripSettings(), Lights.ForEntity(Strip)), + new ClimateModule(Runner, Options) + }; + if (mattress) modules.Add(new MattressModule(Runner, Options)); + Modules = new(modules); + Coordinator = new(Modules, Runner, Gateway, Lights, new FakeMusic()); + } + public PreparedSleepScene Prepare(double? temperature) + { + if (temperature is not null) Gateway.States[Temperature] = new() + { + EntityId = Temperature, State = temperature.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + LastReported = Time.GetUtcNow(), Attributes = JsonSerializer.SerializeToElement(new { unit_of_measurement = "°C" }) + }; + else Gateway.States.TryRemove(Temperature, out _); + var comfort = new MattressComfortService(Gateway, new IndoorEnvironmentReader(Options, Time), Options) + .Get(null, Gateway.States); + var config = new PersonalizedSceneConfig { LightColorTemperatureKelvin = 3000 }; + MattressComfortService.ApplyTo(config, comfort); + RoomTemperaturePolicy.PickTargetForSession(config.ClimateAdvice); + return new(config, "balanced"); + } + } + private static async Task WaitUntilAsync(Func predicate) + { + for (var attempt = 0; attempt < 1000 && !predicate(); attempt++) await Task.Delay(1); + Assert.True(predicate(), "模拟渐变任务未按时推进"); + } + private static async Task CompleteAsync(Task task, ManualTestTimeProvider time) + { + for (var attempt = 0; attempt < 1000 && !task.IsCompleted; attempt++) + { + time.Advance(TimeSpan.FromMilliseconds(100)); await Task.Delay(1); + } + Assert.True(task.IsCompleted, "场景未在模拟时间中完成"); + return await task; + } + + [Fact] + public async Task SceneStartsBothLightsAtFiftyAndSetsOnlyStripsOwnSleepEffect() + { + var f = new Fixture(); + try + { + var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); + Assert.True(result.Success); + Assert.Equal(new[] { Primary, Strip }, f.Gateway.Calls.Where(call => call.Domain == "light").Select(call => call.EntityId)); + var strip = f.Gateway.Calls.Single(call => call.EntityId == Strip); + Assert.Equal("助眠模式", strip.Data!["effect"]); Assert.Equal(50, strip.Data["brightness_pct"]); + Assert.Equal(3000, strip.Data["color_temp_kelvin"]); + Assert.False(f.Gateway.Calls.Single(call => call.EntityId == Primary).Data!.ContainsKey("effect")); + f.Time.Advance(TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == 49)); + Assert.Equal(2, f.Lights.GetStatus().Lights.Count); + Assert.All(f.Lights.GetStatus().Lights, light => Assert.True(light.Running)); + } + finally { await f.Lights.StopAsync(default); } + } + + [Fact] + public async Task BothLightsReachZeroAndOnlyOriginalLightKeepsMassageShutdownHook() + { + var f = new Fixture(); + try + { + await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); + for (var step = 1; step <= 50; step++) + { + f.Time.Advance(TimeSpan.FromSeconds(5)); + var expected = 50 - step; + await WaitUntilAsync(() => f.Lights.GetStatus().Lights.All(light => light.BrightnessPercent == expected && + (expected != 0 || !light.Running))); + } + Assert.Equal("off", f.Gateway.States[Primary].State); Assert.Equal("off", f.Gateway.States[Strip].State); + Assert.Equal(1, f.Off.Calls); + } + finally { await f.Lights.StopAsync(default); } + } + + [Fact] + public async Task AllOffCancelsBothTimersAndShutsClimateWithoutLateReopening() + { + var f = new Fixture(); + try + { + await f.Coordinator.ExecuteAsync("sleep", f.Prepare(30).Config, "balanced", default); + var result = await f.Coordinator.ExecuteAsync("all-off", null, "", default); + Assert.True(result.Success); + Assert.All(new[] { Primary, Strip, Climate }, id => Assert.Equal("off", f.Gateway.States[id].State)); + Assert.All(f.Lights.GetStatus().Lights, light => Assert.False(light.Running)); + var count = f.Gateway.Calls.Count; f.Time.Advance(TimeSpan.FromMinutes(5)); await Task.Delay(5); + Assert.Equal(count, f.Gateway.Calls.Count); + } + finally { await f.Lights.StopAsync(default); } + } + + [Theory] + [InlineData(15, "heat", 1)] + [InlineData(30, "cool", 0)] + [InlineData(22, "off", 0)] + [InlineData(24, "off", 0)] + [InlineData(25, "off", 0)] + public async Task EnvironmentSensorDrivesActualSceneClimateAndBothHeatingLevels(double room, string mode, int heating) + { + var f = new Fixture(mattress: true); + try + { + var prepared = f.Prepare(room); + var result = await CompleteAsync(f.Coordinator.ExecutePreparedSleepAsync(_ => Task.FromResult(prepared), default), f.Time); + Assert.True(result.Success); Assert.Equal(mode, result.ClimateAdvice!.Mode); + Assert.False(result.ClimateAdvice.PendingIntegration); + foreach (var id in new[] { "number.left_heat", "number.right_heat" }) Assert.Equal(heating.ToString(), f.Gateway.States[id].State); + if (mode == "off") + { + Assert.Null(result.ClimateAdvice.TargetTemperatureCelsius); + Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate); + } + else + { + Assert.Equal(mode, f.Gateway.States[Climate].State); + Assert.InRange(result.ClimateAdvice.TargetTemperatureCelsius!.Value, 22, 25); + Assert.Equal(result.ClimateAdvice.TargetTemperatureCelsius.Value, f.Gateway.States[Climate].GetDoubleAttribute("temperature")); + Assert.Equal(new[] { "set_hvac_mode", "set_temperature" }, f.Gateway.Calls.Where(call => call.EntityId == Climate).Select(call => call.Service)); + } + } + finally { await f.Lights.StopAsync(default); } + } + + [Fact] + public async Task MissingOrStaleEnvironmentNeverStartsClimateOrHeating() + { + var f = new Fixture(); + try + { + var prepared = f.Prepare(null); + var result = await f.Coordinator.ExecuteAsync("sleep", prepared.Config, "balanced", default); + Assert.Equal("unavailable", result.ClimateAdvice!.Mode); + Assert.Equal(0, prepared.Config.MattressHeatingLevel); + Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Climate); + f.Prepare(15); f.Time.Advance(TimeSpan.FromMinutes(31)); + var comfort = new MattressComfortService(f.Gateway, new IndoorEnvironmentReader(f.Options, f.Time), f.Options).Get(null, f.Gateway.States); + Assert.Equal("unavailable", comfort.ClimateAdvice.Mode); Assert.Equal(0, comfort.HeatingLevel); + } + finally { await f.Lights.StopAsync(default); } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ManualStandardControlCancelsOnlyThatLightsFade(bool strip) + { + var f = new Fixture(); + try + { + await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); + var id = strip ? Strip : Primary; + var other = strip ? Primary : Strip; + var device = new HomeAssistantDiscoveredDeviceOutput + { + DeviceId = "manual", Online = true, PrimaryControlEntityId = id, + Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }] + }; + var parameters = new DeviceParameterController(f.Modules, f.Runner, [new StandardLightControl()]); + await parameters.ExecuteAsync(device, f.Gateway.States, "light_brightness", 77, id, default); + Assert.False(f.Lights.ForEntity(id).GetStatus().Running); Assert.True(f.Lights.ForEntity(other).GetStatus().Running); + f.Time.Advance(TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => f.Lights.ForEntity(other).GetStatus().BrightnessPercent == 49); + Assert.Equal(77d / 100 * 255, f.Gateway.States[id].GetDoubleAttribute("brightness")); + } + finally { await f.Lights.StopAsync(default); } + } + + [Theory] + [InlineData(Primary, Strip)] + [InlineData(Strip, Primary)] + public async Task OfflineLightDoesNotBlockOtherSceneLight(string offline, string active) + { + var f = new Fixture(); f.Gateway.States[offline] = Light(offline, "unavailable"); + try + { + var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); + Assert.True(result.Success); Assert.True(f.Lights.ForEntity(active).GetStatus().Running); + Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == offline); + Assert.Contains(result.Actions, action => action.EntityId == offline && action.Status == "Skipped"); + } + finally { await f.Lights.StopAsync(default); } + } + + [Fact] + public async Task UnsupportedDefaultEffectFailsBeforeStripTurnsOnAndDoesNotStopOtherLight() + { + var f = new Fixture(); f.Gateway.States[Strip] = Light(Strip, effects: ["阅读模式"]); + try + { + var result = await f.Coordinator.ExecuteAsync("sleep", f.Prepare(24).Config, "balanced", default); + Assert.Equal("Partial", result.Status); + Assert.True(f.Lights.ForEntity(Primary).GetStatus().Running); + Assert.DoesNotContain(f.Gateway.Calls, call => call.EntityId == Strip); + Assert.Contains(result.Actions, action => action.EntityId == Strip && action.Status == "Failed"); + } + finally { await f.Lights.StopAsync(default); } + } + + [Fact] + public void ModuleDiscoveryAndPreviewExposeOwnBindingAndDefaultWithoutHardwareWrites() + { + var f = new Fixture(); + var catalog = new HomeAssistantDeviceCatalog(f.Options, f.Modules); + var discovered = catalog.BuildDiscoveredDevices(null, f.Gateway.States); + var strip = Assert.Single(discovered.Where(device => device.PrimaryControlEntityId == Strip)); + Assert.Equal("sleep-light-strip", strip.ModuleKey); Assert.Equal("助眠模式", strip.DefaultLightEffect); + Assert.Null(discovered.Single(device => device.PrimaryControlEntityId == Primary).DefaultLightEffect); + var preview = f.Modules.ForEntities([Strip])!.BuildPreview(new(), "balanced")!; + Assert.Equal(Strip, preview.EntityId); Assert.Contains("每5秒降低1%", preview.Detail); + Assert.Empty(f.Gateway.Calls); + } + + [Fact] + public void ProductionRegistrationAddsConfiguredStripWithoutCreatingASecondDeviceControlImplementation() + { + var services = new ServiceCollection(); services.AddOptions(); services.AddLogging(); services.AddHttpClient(); + services.AddSingleton>(Options.Create(Settings())); + services.AddHealthDevices(); services.AddSingleton(new FakeHaGateway()); + services.AddSingleton(new FakeAudioGateway()); + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + Assert.IsType(registry.ForEntities([Strip])); + Assert.IsType(registry.ForEntities([Primary])); + Assert.IsType(registry.ForEntities([Climate])); + } + + [Fact] + public void DuplicateSceneLightEntityCannotCreateCompetingTimers() + { + var options = Settings(); options.AdditionalSleepLights.Add(new() { Key = "duplicate", Name = "重复灯", EntityId = Strip }); + Assert.Throws(() => new HomeAssistantLightTransitionService(new FakeHaGateway(), new([]), + Options.Create(options), NullLogger.Instance, new(), TimeProvider.System)); + } +} diff --git a/Admin.NET.Test/Health/StandardLightControlTests.cs b/Admin.NET.Test/Health/StandardLightControlTests.cs new file mode 100644 index 0000000..56257d6 --- /dev/null +++ b/Admin.NET.Test/Health/StandardLightControlTests.cs @@ -0,0 +1,221 @@ +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.Lighting; +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.Linq; +using System.Text.Json; +using Xunit; + +namespace Admin.NET.Test.Health; + +/// 标准能力、实例隔离和专属钩子全用假网关验证,不操作真实灯具。 +public sealed class StandardLightControlTests +{ + private const string LightA = "light.test_a", LightB = "light.test_b"; + private static HomeAssistantState State(string id = LightA, string state = "on", string mode = "color_temp", + int min = 2700, int max = 6500, string effect = "阅读") => new() + { + EntityId = id, State = state, Attributes = JsonSerializer.SerializeToElement(new + { + supported_color_modes = new[] { mode }, min_color_temp_kelvin = min, max_color_temp_kelvin = max, + supported_features = 4, effect_list = new[] { effect, "日光" }, brightness = 128, color_temp_kelvin = 3000, + effect, internal_config = 842138120 + }) + }; + private static HomeAssistantDiscoveredDeviceOutput Device(string id = LightA) => new() + { + DeviceId = id, PrimaryControlEntityId = id, Online = true, + Entities = [new() { EntityId = id, Domain = "light", State = "on", Available = true }] + }; + private static DeviceActionRunner Runner(IHomeAssistantGateway gateway) => new(gateway, TimeProvider.System, + new() { VerificationDelay = TimeSpan.Zero }); + private static DeviceParameterController Controller(FakeHaGateway gateway, params IHealthDeviceModule[] modules) + => new(new DeviceModuleRegistry(modules), Runner(gateway), [new StandardLightControl()]); + + [Theory] + [InlineData("light_brightness", "0", "turn_off", null)] + [InlineData("light_brightness", "50", "turn_on", "brightness_pct")] + [InlineData("light_brightness", "100", "turn_on", "brightness_pct")] + [InlineData("light_color_temperature", "2700", "turn_on", "color_temp_kelvin")] + [InlineData("light_color_temperature", "6500", "turn_on", "color_temp_kelvin")] + [InlineData("light_effect", "阅读", "turn_on", "effect")] + public async Task NewLightNeedsNoModelModuleAndUsesOnlySupportedStandardServices(string parameter, string value, string service, string? field) + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(state: "off"); + await Controller(gateway).ExecuteAsync(Device(), gateway.States, parameter, value, LightA, default); + var call = Assert.Single(gateway.Calls); + Assert.Equal("light", call.Domain); Assert.Equal(service, call.Service); Assert.Equal(LightA, call.EntityId); + Assert.Equal(LightA, call.Data!["entity_id"]); + if (field is null) Assert.Single(call.Data); + else { Assert.Equal(2, call.Data.Count); Assert.Equal(value, call.Data[field]!.ToString()); } + } + + [Theory] + [InlineData("light_brightness", "-1")] + [InlineData("light_brightness", "101")] + [InlineData("light_brightness", "10.5")] + [InlineData("light_color_temperature", "2200")] + [InlineData("light_color_temperature", "6501")] + [InlineData("light_color_temperature", "3000.5")] + [InlineData("light_effect", "删除所有遥控器")] + [InlineData("button.press", "anything")] + public async Task InvalidOrEngineeringParametersDoNotSendAnyCommand(string parameter, string value) + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); + await Assert.ThrowsAsync(() => Controller(gateway).ExecuteAsync( + Device(), gateway.States, parameter, value, null, default)); + Assert.Empty(gateway.Calls); + } + + [Theory] + [InlineData("onoff")] + [InlineData("unknown")] + public async Task OnOffOnlyOrUnknownModeDoesNotReceiveBrightnessOrColorTemperature(string mode) + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: mode); + var controller = Controller(gateway); + await Assert.ThrowsAsync(() => controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default)); + await Assert.ThrowsAsync(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default)); + Assert.Empty(gateway.Calls); + } + + [Theory] + [InlineData("brightness")] + [InlineData("rgb")] + [InlineData("rgbww")] + public async Task DimmableLightWithoutColorTemperatureDoesNotInheritAnotherLampsTemperature(string mode) + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: mode); + var controller = Controller(gateway); + await controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 50, null, default); + await Assert.ThrowsAsync(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default)); + Assert.Single(gateway.Calls); + } + + [Fact] + public async Task TwoLightsUseIndependentRangesAndEffectLists() + { + var gateway = new FakeHaGateway(); + gateway.States[LightA] = State(min: 2200, max: 4500, effect: "阅读"); + gateway.States[LightB] = State(LightB, min: 3000, max: 6000, effect: "助眠"); + var controller = Controller(gateway); + await controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 2200, null, default); + await Assert.ThrowsAsync(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_color_temperature", 2200, null, default)); + await Assert.ThrowsAsync(() => controller.ExecuteAsync(Device(LightB), gateway.States, "light_effect", "阅读", null, default)); + await controller.ExecuteAsync(Device(LightB), gateway.States, "light_effect", "助眠", null, default); + Assert.Equal(new[] { LightA, LightB }, gateway.Calls.Select(call => call.EntityId)); + } + + [Theory] + [InlineData("unknown")] + [InlineData("unavailable")] + [InlineData("")] + public async Task MainLightAvailabilityCannotBeBorrowedFromAnotherEntity(string state) + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(state: state); gateway.States[LightB] = State(LightB); + var device = Device(); device.Entities.Add(new() { EntityId = LightB, Domain = "light", Available = true }); + await Assert.ThrowsAsync(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default)); + Assert.Empty(gateway.Calls); + } + + [Fact] + public async Task ForeignEntityEvenAnotherLightOnSameDeviceCannotOverrideTrustedPrimary() + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); gateway.States[LightB] = State(LightB); + var device = Device(); device.Entities.Add(new() { EntityId = LightB, Domain = "light", Available = true }); + await Assert.ThrowsAsync(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, LightB, default)); + device.Entities.RemoveAt(0); + await Assert.ThrowsAsync(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default)); + device = Device(); device.Entities[0].Domain = "switch"; + await Assert.ThrowsAsync(() => Controller(gateway).ExecuteAsync(device, gateway.States, "light_brightness", 50, null, default)); + Assert.Empty(gateway.Calls); + } + + [Fact] + public async Task CapabilityChangeAfterRequestSnapshotIsRecheckedBeforeSend() + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(mode: "onoff"); + var snapshot = new Dictionary { [LightA] = State() }; + await Assert.ThrowsAsync(() => Controller(gateway).ExecuteAsync(Device(), snapshot, "light_brightness", 50, null, default)); + Assert.Empty(gateway.Calls); + } + + [Fact] + public async Task MissingRangeOrEffectFlagDoesNotEnableTheseControls() + { + var gateway = new FakeHaGateway(); + gateway.States[LightA] = new() { EntityId = LightA, State = "on", Attributes = JsonSerializer.SerializeToElement(new + { supported_color_modes = new[] { "color_temp" }, effect_list = new[] { "阅读" }, supported_features = 0 }) }; + var controller = Controller(gateway); + await Assert.ThrowsAsync(() => controller.ExecuteAsync(Device(), gateway.States, "light_color_temperature", 3000, null, default)); + await Assert.ThrowsAsync(() => controller.ExecuteAsync(Device(), gateway.States, "light_effect", "阅读", null, default)); + Assert.Empty(gateway.Calls); + } + + [Fact] + public async Task CancellationPreventsStandardWrite() + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); + using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => Controller(gateway).ExecuteAsync( + Device(), gateway.States, "light_brightness", 50, null, cancellation.Token)); + Assert.Empty(gateway.Calls); + } + + [Fact] + public async Task BoundSceneLightKeepsItsHooksButNewLightDoesNotTriggerThem() + { + var gateway = new FakeHaGateway(); gateway.States[LightA] = State(); gateway.States[LightB] = State(LightB); + var runtime = new CountingLight(); var handler = new CountingOffHandler(); + var module = new LightingModule(Runner(gateway), Options.Create(new HomeAssistantOptions { Entities = new() { AmbientLight = LightA } }), + runtime, new LightOffDispatcher([handler])); + var controller = Controller(gateway, module); + await controller.ExecuteAsync(Device(LightB), gateway.States, "light_brightness", 0, null, default); + Assert.Equal(0, runtime.Cancels); Assert.Equal(0, handler.Calls); + await controller.ExecuteAsync(Device(), gateway.States, "light_brightness", 0, null, default); + Assert.Equal(1, runtime.Cancels); Assert.Equal(1, handler.Calls); + Assert.Empty(gateway.Calls.Where(call => call.EntityId is not (LightA or LightB))); + } + + [Fact] + public async Task ExistingSpecialParametersStillRouteOnlyToTheirOwningModule() + { + var gateway = new FakeHaGateway(); gateway.States["number.synthetic"] = new() { EntityId = "number.synthetic", State = "0" }; + var module = new SyntheticDeviceModule(Runner(gateway)); + var device = new HomeAssistantDiscoveredDeviceOutput { DeviceId = "special", Entities = [new() { EntityId = "number.synthetic", Domain = "number" }] }; + await Controller(gateway, module).ExecuteAsync(device, gateway.States, "synthetic_value", 3, null, default); + Assert.Equal("number.synthetic", Assert.Single(gateway.Calls).EntityId); + await Assert.ThrowsAsync(() => Controller(gateway, module).ExecuteAsync(Device(), gateway.States, "synthetic_value", 3, null, default)); + Assert.Single(gateway.Calls); + } + + [Fact] + public void LightCapabilitiesSurviveActualApiSerializerWithoutExposingPackedInternalFields() + { + var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(State())!; + var json = JObject.Parse(Newtonsoft.Json.JsonConvert.SerializeObject(safe)); + Assert.Equal(2700, json["min_color_temp_kelvin"]!.Value()); + Assert.Equal(6500, json["max_color_temp_kelvin"]!.Value()); + Assert.Equal("color_temp", json["supported_color_modes"]![0]!.Value()); + Assert.Equal("阅读", json["effect_list"]![0]!.Value()); + Assert.Null(json["internal_config"]); + } + + private sealed class CountingOffHandler : ILightOffHandler + { + public int Calls; + public Task HandleAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); Calls++; return Task.CompletedTask; } + } + private sealed class CountingLight : ILightTransitionRuntime + { + public int Cancels; + 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) { Cancels++; return Task.FromResult(new HomeAssistantLightTransitionOutput()); } + } +} diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json index 78ebef1..b40b227 100644 --- a/Evergreen.Health.Application/Configuration/HomeAssistant.json +++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json @@ -49,6 +49,14 @@ "Formaldehyde": "sensor.mirsz_cn_2027039858_et71_hcho_density_p_3_7", "Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8" }, + "AdditionalSleepLights": [ + { + "Key": "sleep-light-strip", + "Name": "烛照智能灯带Ultra2.0", + "EntityId": "light.zhunen_cn_2205208916_wy0a01_s_2_light", + "SleepEffect": "助眠模式" + } + ], "SleepScene": { "TargetHumidity": 55, "DehumidifierMode": "睡眠", diff --git a/Evergreen.Health.Application/Health/Devices/Contracts/IStandardDeviceControl.cs b/Evergreen.Health.Application/Health/Devices/Contracts/IStandardDeviceControl.cs new file mode 100644 index 0000000..91113d6 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Contracts/IStandardDeviceControl.cs @@ -0,0 +1,12 @@ +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Models; + +namespace Evergreen.Health.Application.Health.Devices.Contracts; + +/// 按设备实例实际能力处理标准手动参数,不绑定单一型号,也不登记自动场景。 +public interface IStandardDeviceControl +{ + IReadOnlyList ParameterKeys { get; } + DeviceCommand Build(HomeAssistantDiscoveredDeviceOutput device, HomeAssistantState state, + string parameterType, object? value); +} diff --git a/Evergreen.Health.Application/Health/Devices/DeviceParameterController.cs b/Evergreen.Health.Application/Health/Devices/DeviceParameterController.cs new file mode 100644 index 0000000..51296ad --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/DeviceParameterController.cs @@ -0,0 +1,66 @@ +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; + +namespace Evergreen.Health.Application.Health.Devices; + +/// 共用归属校验和路由;标准能力处理普通参数,专属模块保留自己的执行与场景逻辑。 +public sealed class DeviceParameterController +{ + private readonly DeviceModuleRegistry _modules; + private readonly DeviceActionRunner _runner; + private readonly Dictionary _standard = new(StringComparer.OrdinalIgnoreCase); + public DeviceParameterController(DeviceModuleRegistry modules, DeviceActionRunner runner, + IEnumerable standard) + { + (_modules, _runner) = (modules, runner); + foreach (var adapter in standard) + foreach (var key in adapter.ParameterKeys) + if (!_standard.TryAdd(key, adapter)) throw new InvalidOperationException($"标准设备参数重复:{key}"); + } + + public async Task ExecuteAsync(HomeAssistantDiscoveredDeviceOutput device, + IReadOnlyDictionary states, string parameterType, object? value, + string? requestedEntityId, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + IHealthDeviceModule? module; + DeviceCommand command; + if (_standard.TryGetValue(parameterType, out var adapter)) + { + var primary = device.PrimaryControlEntityId; + if (string.IsNullOrWhiteSpace(primary) || !device.Entities.Any(entity => + string.Equals(entity.EntityId, primary, StringComparison.OrdinalIgnoreCase)) || + !states.TryGetValue(primary, out var state)) + throw new InvalidOperationException("设备主控实体未确认"); + command = adapter.Build(device, state, parameterType, value); + module = _modules.ForEntities(device.Entities.Select(entity => entity.EntityId)); + // 附属灯不能借用另一主控的执行钩子;仅复用该实例所属的专属模块。 + if (module is not null && !string.Equals(module.PrimaryEntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)) + module = null; + } + else + { + module = _modules.ForParameter(parameterType); + command = module.BuildParameterCommand(parameterType, value); + } + if (string.IsNullOrWhiteSpace(command.EntityId)) + throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体"); + if (!string.IsNullOrWhiteSpace(requestedEntityId) && + !string.Equals(requestedEntityId, command.EntityId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配"); + if (!device.Entities.Any(entity => string.Equals(entity.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException("该参数不属于指定设备"); + if (module?.ParameterUsesIndependentGateway(command) != true && + (!states.TryGetValue(command.EntityId, out var current) || !DeviceActionRunner.IsAvailable(current))) + throw new InvalidOperationException("目标参数实体当前离线或不可用"); + if (module is not null) await module.ExecuteParameterAsync(command, cancellationToken); + else + { + await _runner.SendAsync(command, cancellationToken); + if (command.ExpectedState is not null) await _runner.VerifyParameterAsync(command, cancellationToken); + } + return command; + } +} diff --git a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs index 89b349c..e1ed7da 100644 --- a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs +++ b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs @@ -88,6 +88,7 @@ public sealed class HomeAssistantDeviceCatalog Category = category, Online = isOnline, PrimaryControlEntityId = primaryControlEntityId, + DefaultLightEffect = DefaultLightEffect(primaryControlEntityId), Entities = entityOutputs, Metrics = metrics }); @@ -125,12 +126,16 @@ public sealed class HomeAssistantDeviceCatalog DeviceId = $"fallback_{module.Key}", ModuleKey = module.Key, Name = module.DisplayName, Category = module.Category, Online = entityOutputs.Any(entity => entity.Available), PrimaryControlEntityId = FindPrimaryControlEntityId(module.Category, entityOutputs), + DefaultLightEffect = DefaultLightEffect(module.PrimaryEntityId), Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs) }); } return list; } + private string? DefaultLightEffect(string? entityId) => _options.AdditionalSleepLights.FirstOrDefault(light => + string.Equals(light.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.SleepEffect; + private string? FindPrimaryControlEntityId( string category, IReadOnlyList entities) @@ -301,6 +306,7 @@ public sealed class HomeAssistantDeviceCatalog "friendly_name", "unit_of_measurement", "device_class", "current_humidity", "humidity", "mode", "fan_mode", "percentage", "brightness", "color_temp", "color_temp_kelvin", + "supported_color_modes", "color_mode", "min_color_temp_kelvin", "max_color_temp_kelvin", "effect_list", "effect", "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", diff --git a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs index 2549308..8b0a877 100644 --- a/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs +++ b/Evergreen.Health.Application/Health/Devices/HealthDeviceRegistration.cs @@ -61,7 +61,11 @@ public static class HealthDeviceRegistration services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); - services.AddSingleton(); + services.AddSingleton(provider => new( + provider.GetRequiredService(), provider.GetRequiredService>(), + provider.GetRequiredService().ForEntity( + provider.GetRequiredService>().Value.Entities.AmbientLight), + provider.GetRequiredService())); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); @@ -69,7 +73,13 @@ public static class HealthDeviceRegistration services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(provider => new( + provider.GetServices().Concat( + provider.GetRequiredService>().Value.AdditionalSleepLights.Select(light => + new ConfiguredSleepLightModule(provider.GetRequiredService(), light, + provider.GetRequiredService().ForEntity(light.EntityId)))))); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; diff --git a/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs b/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs new file mode 100644 index 0000000..29c6573 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs @@ -0,0 +1,51 @@ +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; + +namespace Evergreen.Health.Application.Health.Devices.Lighting; + +/// 额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的按摩/唤醒钩子。 +public sealed class ConfiguredSleepLightModule : HealthDeviceModule +{ + private readonly SceneLightOptions _light; + private readonly ILightTransitionRuntime _transition; + public ConfiguredSleepLightModule(DeviceActionRunner runner, SceneLightOptions light, ILightTransitionRuntime transition) : base(runner) + => (_light, _transition) = (light, transition); + public override string Key => _light.Key; + public override string Category => nameof(HealthDeviceCategory.Lighting); + public override string DisplayName => _light.Name; + public override string PrimaryEntityId => _light.EntityId; + public override IReadOnlyList Parameters => []; + + public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken token) + { + await _transition.CancelAsync(token); + await base.ExecuteParameterAsync(command, token); + } + public override async Task SetPowerAsync(bool turnOn, CancellationToken token) + { + if (turnOn) { await _transition.StartSleepAsync(2700, token); return; } + await _transition.CancelAsync(token); + var command = GetShutdownCommands().Single(); + await Runner.SendAsync(command, token); + await Runner.VerifyParameterAsync(command, token); + } + public override async Task ExecuteSleepAsync(DeviceSceneContext context, PersonalizedSceneConfig config, + string constitutionCode, CancellationToken token) + { + if (!context.States.TryGetValue(PrimaryEntityId, out var state) || !DeviceActionRunner.IsAvailable(state)) + { + context.Results.Add(new() { Name = $"开启{DisplayName}助眠渐暗", EntityId = PrimaryEntityId, + Status = "Skipped", Message = "场景灯当前离线或不可用" }); + return; + } + await context.ExternalAsync($"开启{DisplayName}助眠渐暗", PrimaryEntityId, + async cancellation => { await _transition.StartSleepAsync(config.LightColorTemperatureKelvin, cancellation); }, token); + } + public override IEnumerable GetShutdownCommands() + => [new($"关闭{DisplayName}", PrimaryEntityId, "light", "turn_off", ExpectedState: "off")]; + public override HomeAssistantScenePresetItem? BuildPreview(PersonalizedSceneConfig config, string constitutionCode) + => Preview("开启助眠渐暗灯光", $"{_light.SleepEffect ?? "助眠渐暗"} · 从50%开始,每5秒降低1%,最终关灯", + new() { ["brightness"] = "50%→关闭", ["effect"] = _light.SleepEffect }); +} diff --git a/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs b/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs index ce23e97..7d6020e 100644 --- a/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs +++ b/Evergreen.Health.Application/Health/Devices/Lighting/LightingModule.cs @@ -38,9 +38,13 @@ public sealed class LightingModule : HealthDeviceModule public override async Task ExecuteParameterAsync(DeviceCommand command, CancellationToken cancellationToken) { + // 标准手动面板复用本灯的专属安全钩子;其他灯不会触发助眠灯/床垫联动。 + command = command with { Intent = command.Service == "turn_off" + ? DeviceCommandIntent.TurnRelaxationLightOff : DeviceCommandIntent.TurnRelaxationLightOn }; await _transition.CancelAsync(cancellationToken); - await base.ExecuteParameterAsync(command, cancellationToken); + await Runner.SendAsync(command, cancellationToken); if (command.Service == "turn_off") await _lightOff.NotifyAsync(cancellationToken); + if (command.ExpectedState is not null) await Runner.VerifyParameterAsync(command, cancellationToken); } public override async Task SetPowerAsync(bool turnOn, CancellationToken cancellationToken) diff --git a/Evergreen.Health.Application/Health/Devices/Lighting/StandardLightControl.cs b/Evergreen.Health.Application/Health/Devices/Lighting/StandardLightControl.cs new file mode 100644 index 0000000..26b3ec3 --- /dev/null +++ b/Evergreen.Health.Application/Health/Devices/Lighting/StandardLightControl.cs @@ -0,0 +1,79 @@ +using Evergreen.Health.Application.Health.Devices.Contracts; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Models; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Devices.Lighting; + +/// 标准灯光参数依赖 HA 上报能力,不能将一个型号的色温范围或模式复制给另一盏灯。 +public sealed class StandardLightControl : IStandardDeviceControl +{ + public IReadOnlyList ParameterKeys => ["light_brightness", "light_color_temperature", "light_effect"]; + private static readonly string[] DimmableModes = ["brightness", "color_temp", "hs", "rgb", "rgbw", "rgbww", "xy", "white"]; + + public DeviceCommand Build(HomeAssistantDiscoveredDeviceOutput device, HomeAssistantState state, + string parameterType, object? value) + { + var entityId = device.PrimaryControlEntityId ?? ""; + if (!entityId.StartsWith("light.", StringComparison.Ordinal) || + !device.Entities.Any(entity => entity.EntityId == entityId && entity.Domain == "light")) + throw new InvalidOperationException("该设备没有受信任的灯光主控能力"); + Validate(state, entityId, parameterType, value); + var command = parameterType switch + { + "light_brightness" => Brightness(entityId, value), + "light_color_temperature" => new DeviceCommand("设置灯光色温", entityId, "light", "turn_on", + new Dictionary { ["color_temp_kelvin"] = DeviceValues.Integer(value, 1, int.MaxValue, "灯光色温") }), + "light_effect" => new DeviceCommand("设置灯光模式", entityId, "light", "turn_on", + new Dictionary { ["effect"] = DeviceValues.Text(value) }), + _ => throw new InvalidOperationException("不支持的标准灯光参数") + }; + return command with { ValidateCurrentState = current => Validate(current, entityId, parameterType, value), + RetryOnVerificationMismatch = false }; + } + + private static DeviceCommand Brightness(string entityId, object? value) + { + var brightness = DeviceValues.Integer(value, 0, 100, "灯光亮度"); + return new("设置灯光亮度", entityId, "light", brightness == 0 ? "turn_off" : "turn_on", + brightness == 0 ? null : new Dictionary { ["brightness_pct"] = brightness }, + brightness == 0 ? "off" : null); + } + + private static void Validate(HomeAssistantState state, string entityId, string parameterType, object? value) + { + if (state.EntityId != entityId || state.State is not ("on" or "off")) + throw new InvalidOperationException("灯光主控当前离线或状态未确认"); + var modes = Strings(state, "supported_color_modes"); + switch (parameterType) + { + case "light_brightness": + if (!modes.Any(mode => DimmableModes.Contains(mode, StringComparer.Ordinal))) + throw new InvalidOperationException("这盏灯未声明支持亮度调节"); + DeviceValues.Integer(value, 0, 100, "灯光亮度"); + break; + case "light_color_temperature": + var min = state.GetDoubleAttribute("min_color_temp_kelvin"); + var max = state.GetDoubleAttribute("max_color_temp_kelvin"); + if (!modes.Contains("color_temp") || min is null || max is null || min <= 0 || max <= min || + max > int.MaxValue || min != Math.Truncate(min.Value) || max != Math.Truncate(max.Value)) + throw new InvalidOperationException("这盏灯的色温能力或范围未确认"); + DeviceValues.Integer(value, (int)min.Value, (int)max.Value, "灯光色温"); + break; + case "light_effect": + var features = state.GetDoubleAttribute("supported_features"); + if (features is null || features < 0 || features > int.MaxValue || features != Math.Truncate(features.Value) || + (((int)features.Value & 4) == 0)) throw new InvalidOperationException("这盏灯未声明支持灯光模式"); + DeviceValues.Choice(value, Strings(state, "effect_list"), "灯光模式"); + break; + default: throw new InvalidOperationException("不支持的标准灯光参数"); + } + } + + private static string[] Strings(HomeAssistantState state, string key) + => state.Attributes.ValueKind == JsonValueKind.Object && state.Attributes.TryGetProperty(key, out var values) && + values.ValueKind == JsonValueKind.Array + ? values.EnumerateArray().Where(value => value.ValueKind == JsonValueKind.String) + .Select(value => value.GetString()!).Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray() + : []; +} diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs index a2914c7..8a4bd6b 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantLightTransitionService.cs @@ -6,268 +6,69 @@ using Microsoft.Extensions.Options; namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; -/// 在服务端驱动卧室灯光渐变,浏览器关闭后任务仍继续。 +/// 管理明确配置的场景灯;共享单灯渐变实现,但不共享亮度、计时或取消状态。 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 LightTransitionWorker _primary; + private readonly Dictionary _lights = new(StringComparer.OrdinalIgnoreCase); - 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); - private readonly object _statusLock = new(); - private CancellationTokenSource? _activeCancellation; - private Task? _activeTask; - private HomeAssistantLightTransitionOutput _status = new(); - - public HomeAssistantLightTransitionService( - IHomeAssistantGateway homeAssistant, - LightOffDispatcher lightOff, - IOptions options, - ILogger logger, - SceneLightSafetyState safety, - TimeProvider time) + public HomeAssistantLightTransitionService(IHomeAssistantGateway gateway, LightOffDispatcher lightOff, + IOptions options, ILogger logger, + SceneLightSafetyState safety, TimeProvider time) { - _options = options.Value; - _logger = logger; - _homeAssistant = homeAssistant; - _lightOff = lightOff; - _safety = safety; - _time = time; + _primary = new(gateway, lightOff, options, logger, safety, time); + var primaryId = options.Value.Entities.AmbientLight; + if (!string.IsNullOrWhiteSpace(primaryId)) _lights.Add(primaryId, _primary); + foreach (var light in options.Value.AdditionalSleepLights) + { + if (string.IsNullOrWhiteSpace(light.Key) || string.IsNullOrWhiteSpace(light.Name) || + !light.EntityId.StartsWith("light.", StringComparison.Ordinal)) + throw new InvalidOperationException("额外场景灯需要有效的模块键、名称和 light 实体"); + if (!_lights.TryAdd(light.EntityId, new(gateway, lightOff, options, logger, safety, time, light))) + throw new InvalidOperationException($"场景灯实体重复配置:{light.EntityId}"); + } } - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public async Task StopAsync(CancellationToken cancellationToken) - { - await _operationLock.WaitAsync(cancellationToken); - try { await StopCurrentAsync(); } - finally { _operationLock.Release(); } - } + public ILightTransitionRuntime ForEntity(string entityId) + => _lights.TryGetValue(entityId, out var light) ? light + : entityId == _primary.GetStatus().EntityId ? _primary + : throw new InvalidOperationException("该灯未配置为场景灯"); public HomeAssistantLightTransitionOutput GetStatus() { - lock (_statusLock) - { - return new HomeAssistantLightTransitionOutput - { - Success = _status.Success, - Running = _status.Running, - Mode = _status.Mode, - BrightnessPercent = _status.BrightnessPercent, - TargetPercent = _status.TargetPercent, - Message = _status.Message - }; - } + var output = _primary.GetStatus(); + output.Lights = _lights.Values.Select(light => light.GetStatus()).ToList(); + return output; } - public async Task StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken) + // 旧助眠/晨起接口只作用于原卧室灯;额外灯由各自场景模块调用。 + public async Task StartSleepAsync(int kelvin, CancellationToken token) { - await _operationLock.WaitAsync(cancellationToken); - try - { - await StopCurrentAsync(); - await ReadAvailableLightAsync(cancellationToken); - await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary - { - ["entity_id"] = LightEntityId, - ["brightness_pct"] = SleepStartPercent, - ["color_temp_kelvin"] = Math.Clamp(colorTemperatureKelvin, 2200, 5000) - }, cancellationToken); - - _safety.MarkOn(); - SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度"); - Launch("sleep", SleepStartPercent); - return GetStatus(); - } - finally { _operationLock.Release(); } + await _primary.StartSleepAsync(kelvin, token); + return GetStatus(); + } + public async Task StartWakeAsync(CancellationToken token) + { + await _primary.StartWakeAsync(token); + return GetStatus(); + } + public async Task CancelPrimaryAsync(CancellationToken token) + { + await _primary.CancelAsync(token); + return GetStatus(); } - public async Task StartWakeAsync(CancellationToken cancellationToken) + // 一键关闭/场景替换必须取消全部灯的计时任务,防止后续 tick 重新点亮灯。 + public async Task CancelAsync(CancellationToken token) { - await _operationLock.WaitAsync(cancellationToken); - try - { - await StopCurrentAsync(); - var light = await ReadAvailableLightAsync(cancellationToken); - 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("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒"); - var current = isOff ? 1 : Math.Clamp((int)Math.Round(brightness!.Value / 255d * 100), 1, 100); - - if (current >= WakeTargetPercent && !isOff) - { - _safety.MarkOn(); - SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度"); - return GetStatus(); - } - - if (isOff) - { - await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary - { - ["entity_id"] = LightEntityId, - ["brightness_pct"] = 1 - }, cancellationToken); - } - - _safety.MarkOn(); - SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度"); - Launch("wake", current); - return GetStatus(); - } - finally { _operationLock.Release(); } + foreach (var light in _lights.Values) await light.CancelAsync(token); + if (_lights.Count == 0) await _primary.CancelAsync(token); + return GetStatus(); } - - public async Task CancelAsync(CancellationToken cancellationToken) + public Task StartAsync(CancellationToken token) => Task.CompletedTask; + public async Task StopAsync(CancellationToken token) { - await _operationLock.WaitAsync(cancellationToken); - try - { - await StopCurrentAsync(); - var brightness = GetStatus().BrightnessPercent; - SetStatus(true, false, "none", brightness, 0, "灯光渐变已停止"); - return GetStatus(); - } - finally { _operationLock.Release(); } - } - - private string LightEntityId => !string.IsNullOrWhiteSpace(_options.Entities.AmbientLight) - ? _options.Entities.AmbientLight - : throw new InvalidOperationException("卧室灯光实体尚未配置"); - - private async Task ReadAvailableLightAsync(CancellationToken cancellationToken) - { - var light = (await _homeAssistant.GetStatesAsync(cancellationToken)) - .FirstOrDefault(item => string.Equals(item.EntityId, LightEntityId, StringComparison.OrdinalIgnoreCase)); - if (light is null || light.State is "unknown" or "unavailable") - throw new InvalidOperationException("卧室灯光当前离线或不可用"); - return light; - } - - private void Launch(string mode, int initialPercent) - { - _activeCancellation = new CancellationTokenSource(); - _activeTask = RunAsync(mode, initialPercent, _activeCancellation.Token); - } - - private async Task RunAsync(string mode, int initialPercent, CancellationToken cancellationToken) - { - var current = initialPercent; - try - { - while (true) - { - 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 _lightOff.NotifyAsync(cancellationToken); - SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止"); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "灯已关闭,但床垫按摩停止失败"); - SetStatus(false, false, mode, 0, 0, exception.Message); - } - } - else SetStatus(false, false, mode, 0, WakeTargetPercent, "灯已从其他位置关闭,渐变已停止"); - return; - } - - var brightness = light.GetDoubleAttribute("brightness"); - if (brightness is null) - { - SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0, "灯具未返回当前亮度,渐变已停止"); - return; - } - var observed = Math.Clamp((int)Math.Round(brightness.Value / 255d * 100), 1, 100); - if (Math.Abs(observed - current) > 2) - { - SetStatus(false, false, mode, observed, mode == "wake" ? WakeTargetPercent : 0, "灯光亮度已从其他位置修改,渐变已停止"); - return; - } - - if (mode == "sleep" && current == 1) - { - await _homeAssistant.CallServiceAsync("light", "turn_off", new Dictionary - { - ["entity_id"] = LightEntityId - }, cancellationToken); - _safety.MarkOff(_time.GetUtcNow()); - SetStatus(true, false, mode, 0, 0, "灯光已关闭,正在停止床垫按摩"); - try - { - await _lightOff.NotifyAsync(cancellationToken); - SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光与床垫按摩已关闭"); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - _logger.LogWarning(exception, "助眠渐暗已完成,但床垫按摩停止失败"); - SetStatus(false, false, mode, 0, 0, exception.Message); - } - return; - } - - var next = mode == "sleep" ? current - 1 : current + 1; - await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary - { - ["entity_id"] = LightEntityId, - ["brightness_pct"] = next - }, cancellationToken); - current = next; - var finished = mode == "wake" && current >= WakeTargetPercent; - SetStatus(true, !finished, mode, current, mode == "wake" ? WakeTargetPercent : 0, - finished ? "晨起唤醒已完成,灯光保持70%亮度" : mode == "wake" ? "晨起唤醒进行中" : "助眠渐暗进行中"); - if (finished) return; - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } - catch (Exception exception) - { - _logger.LogWarning(exception, "卧室灯光渐变任务中断"); - SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0, - $"灯光渐变中断:{exception.Message}"); - } - } - - private async Task StopCurrentAsync() - { - if (_activeCancellation is null) return; - _activeCancellation.Cancel(); - if (_activeTask is not null) await _activeTask; - _activeCancellation.Dispose(); - _activeCancellation = null; - _activeTask = null; - SetStatus(true, false, "none", GetStatus().BrightnessPercent, 0, "灯光渐变已停止"); - } - - private void SetStatus(bool success, bool running, string mode, int brightness, int target, string message) - { - lock (_statusLock) - { - _status = new HomeAssistantLightTransitionOutput - { - Success = success, - Running = running, - Mode = mode, - BrightnessPercent = brightness, - TargetPercent = target, - Message = message - }; - } + foreach (var light in _lights.Values) await light.StopAsync(token); + if (_lights.Count == 0) await _primary.StopAsync(token); } } diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs index 20f43fe..2bb0272 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs @@ -10,6 +10,16 @@ public sealed class HomeAssistantOptions : IConfigurableOptions public int TimeoutSeconds { get; set; } = 15; public HomeAssistantEntityOptions Entities { get; set; } = new(); public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new(); + public List AdditionalSleepLights { get; set; } = []; +} + +/// 明确加入舒睡/全关的额外灯具;未配置的灯仍仅提供单独控制。 +public sealed class SceneLightOptions +{ + public string Key { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string EntityId { get; set; } = string.Empty; + public string? SleepEffect { get; set; } } public sealed class HomeAssistantEntityOptions diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs new file mode 100644 index 0000000..399ecbc --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs @@ -0,0 +1,313 @@ +using Evergreen.Health.Application.Health.Models; +using Evergreen.Health.Application.Health.Scenes; +using Evergreen.Health.Application.Health.Devices.Lighting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; + +/// 单灯渐变执行器;每盏灯的计时、取消和状态互相隔离。 +internal sealed class LightTransitionWorker : ILightTransitionRuntime +{ + private const int SleepStartPercent = 50; + private const int WakeTargetPercent = 70; + private static readonly TimeSpan StepInterval = TimeSpan.FromSeconds(5); + + private readonly IHomeAssistantGateway _homeAssistant; + private readonly LightOffDispatcher _lightOff; + private readonly SceneLightSafetyState _safety; + private readonly TimeProvider _time; + private readonly string _entityId; + private readonly string? _sleepEffect; + private readonly bool _controlsMassage; + private readonly ILogger _logger; + private readonly SemaphoreSlim _operationLock = new(1, 1); + private readonly object _statusLock = new(); + private CancellationTokenSource? _activeCancellation; + private Task? _activeTask; + private HomeAssistantLightTransitionOutput _status = new(); + + public LightTransitionWorker( + IHomeAssistantGateway homeAssistant, + LightOffDispatcher lightOff, + IOptions options, + ILogger logger, + SceneLightSafetyState safety, + TimeProvider time, SceneLightOptions? light = null) + { + _entityId = light?.EntityId ?? options.Value.Entities.AmbientLight; + _sleepEffect = light?.SleepEffect; + _controlsMassage = light is null; + _logger = logger; + _homeAssistant = homeAssistant; + _lightOff = lightOff; + _safety = safety; + _time = time; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await _operationLock.WaitAsync(cancellationToken); + try { await StopCurrentAsync(); } + finally { _operationLock.Release(); } + } + + public HomeAssistantLightTransitionOutput GetStatus() + { + lock (_statusLock) + { + return new HomeAssistantLightTransitionOutput + { + EntityId = _entityId, + Success = _status.Success, + Running = _status.Running, + Mode = _status.Mode, + BrightnessPercent = _status.BrightnessPercent, + TargetPercent = _status.TargetPercent, + Message = _status.Message + }; + } + } + + public async Task StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken) + { + await _operationLock.WaitAsync(cancellationToken); + try + { + await StopCurrentAsync(); + var light = await ReadAvailableLightAsync(cancellationToken); + var data = BuildSleepStartData(light, colorTemperatureKelvin); + await _homeAssistant.CallServiceAsync("light", "turn_on", data, cancellationToken); + + if (_controlsMassage) _safety.MarkOn(); + SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度"); + Launch("sleep", SleepStartPercent); + return GetStatus(); + } + finally { _operationLock.Release(); } + } + + public async Task StartWakeAsync(CancellationToken cancellationToken) + { + await _operationLock.WaitAsync(cancellationToken); + try + { + await StopCurrentAsync(); + var light = await ReadAvailableLightAsync(cancellationToken); + var knownOffAt = _controlsMassage ? _safety.OffAt : null; + 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("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒"); + var current = isOff ? 1 : Math.Clamp((int)Math.Round(brightness!.Value / 255d * 100), 1, 100); + + if (current >= WakeTargetPercent && !isOff) + { + if (_controlsMassage) _safety.MarkOn(); + SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度"); + return GetStatus(); + } + + if (isOff) + { + await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary + { + ["entity_id"] = LightEntityId, + ["brightness_pct"] = 1 + }, cancellationToken); + } + + if (_controlsMassage) _safety.MarkOn(); + SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度"); + Launch("wake", current); + return GetStatus(); + } + finally { _operationLock.Release(); } + } + + public async Task CancelAsync(CancellationToken cancellationToken) + { + await _operationLock.WaitAsync(cancellationToken); + try + { + await StopCurrentAsync(); + var brightness = GetStatus().BrightnessPercent; + SetStatus(true, false, "none", brightness, 0, "灯光渐变已停止"); + return GetStatus(); + } + finally { _operationLock.Release(); } + } + + private string LightEntityId => !string.IsNullOrWhiteSpace(_entityId) + ? _entityId + : throw new InvalidOperationException("卧室灯光实体尚未配置"); + + private Dictionary BuildSleepStartData(HomeAssistantState state, int kelvin) + { + var data = new Dictionary { ["entity_id"] = LightEntityId, ["brightness_pct"] = SleepStartPercent }; + // 原绑定灯兼容旧 HA 能力回读;新场景灯必须经过同一套标准能力校验。 + JsonElement modes = default; + var hasModes = state.Attributes.ValueKind == JsonValueKind.Object && + state.Attributes.TryGetProperty("supported_color_modes", out modes) && modes.ValueKind == JsonValueKind.Array; + if (_controlsMassage && !hasModes) + { + data["color_temp_kelvin"] = Math.Clamp(kelvin, 2200, 5000); + return data; + } + var device = new HomeAssistantDiscoveredDeviceOutput + { + PrimaryControlEntityId = LightEntityId, + Entities = [new() { EntityId = LightEntityId, Domain = "light" }] + }; + var controls = new StandardLightControl(); + controls.Build(device, state, "light_brightness", SleepStartPercent); + if (hasModes && modes.EnumerateArray().Any(mode => mode.ValueKind == JsonValueKind.String && mode.GetString() == "color_temp")) + { + var min = state.GetDoubleAttribute("min_color_temp_kelvin"); + var max = state.GetDoubleAttribute("max_color_temp_kelvin"); + if (min is null || max is null || min <= 0 || max <= min || max > int.MaxValue || + min != Math.Truncate(min.Value) || max != Math.Truncate(max.Value)) + throw new InvalidOperationException("场景灯色温范围未确认,未发送启动指令"); + var target = Math.Clamp(kelvin, (int)min.Value, (int)max.Value); + controls.Build(device, state, "light_color_temperature", target); + data["color_temp_kelvin"] = target; + } + if (!string.IsNullOrWhiteSpace(_sleepEffect)) + { + controls.Build(device, state, "light_effect", _sleepEffect); + data["effect"] = _sleepEffect; + } + return data; + } + + private async Task ReadAvailableLightAsync(CancellationToken cancellationToken) + { + var light = (await _homeAssistant.GetStatesAsync(cancellationToken)) + .FirstOrDefault(item => string.Equals(item.EntityId, LightEntityId, StringComparison.OrdinalIgnoreCase)); + if (light is null || light.State is not ("on" or "off")) + throw new InvalidOperationException("卧室灯光当前离线或不可用"); + return light; + } + + private void Launch(string mode, int initialPercent) + { + _activeCancellation = new CancellationTokenSource(); + _activeTask = RunAsync(mode, initialPercent, _activeCancellation.Token); + } + + private async Task RunAsync(string mode, int initialPercent, CancellationToken cancellationToken) + { + var current = initialPercent; + try + { + while (true) + { + await Task.Delay(StepInterval, _time, cancellationToken); + var light = await ReadAvailableLightAsync(cancellationToken); + if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase)) + { + if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow()); + if (mode == "sleep" && _controlsMassage) + { + try + { + await _lightOff.NotifyAsync(cancellationToken); + SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止"); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "灯已关闭,但床垫按摩停止失败"); + SetStatus(false, false, mode, 0, 0, exception.Message); + } + } + else SetStatus(true, false, mode, 0, mode == "wake" ? WakeTargetPercent : 0, "灯已从其他位置关闭,渐变已停止"); + return; + } + + var brightness = light.GetDoubleAttribute("brightness"); + if (brightness is null) + { + SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0, "灯具未返回当前亮度,渐变已停止"); + return; + } + var observed = Math.Clamp((int)Math.Round(brightness.Value / 255d * 100), 1, 100); + if (Math.Abs(observed - current) > 2) + { + SetStatus(false, false, mode, observed, mode == "wake" ? WakeTargetPercent : 0, "灯光亮度已从其他位置修改,渐变已停止"); + return; + } + + if (mode == "sleep" && current == 1) + { + await _homeAssistant.CallServiceAsync("light", "turn_off", new Dictionary + { + ["entity_id"] = LightEntityId + }, cancellationToken); + if (_controlsMassage) _safety.MarkOff(_time.GetUtcNow()); + SetStatus(true, false, mode, 0, 0, _controlsMassage ? "灯光已关闭,正在停止床垫按摩" : "助眠渐暗已完成,灯光已关闭"); + try + { + if (_controlsMassage) await _lightOff.NotifyAsync(cancellationToken); + SetStatus(true, false, mode, 0, 0, _controlsMassage ? "助眠渐暗已完成,灯光与床垫按摩已关闭" : "助眠渐暗已完成,灯光已关闭"); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogWarning(exception, "助眠渐暗已完成,但床垫按摩停止失败"); + SetStatus(false, false, mode, 0, 0, exception.Message); + } + return; + } + + var next = mode == "sleep" ? current - 1 : current + 1; + await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary + { + ["entity_id"] = LightEntityId, + ["brightness_pct"] = next + }, cancellationToken); + current = next; + var finished = mode == "wake" && current >= WakeTargetPercent; + SetStatus(true, !finished, mode, current, mode == "wake" ? WakeTargetPercent : 0, + finished ? "晨起唤醒已完成,灯光保持70%亮度" : mode == "wake" ? "晨起唤醒进行中" : "助眠渐暗进行中"); + if (finished) return; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } + catch (Exception exception) + { + _logger.LogWarning(exception, "卧室灯光渐变任务中断"); + SetStatus(false, false, mode, current, mode == "wake" ? WakeTargetPercent : 0, + $"灯光渐变中断:{exception.Message}"); + } + } + + private async Task StopCurrentAsync() + { + if (_activeCancellation is null) return; + _activeCancellation.Cancel(); + if (_activeTask is not null) await _activeTask; + _activeCancellation.Dispose(); + _activeCancellation = null; + _activeTask = null; + SetStatus(true, false, "none", GetStatus().BrightnessPercent, 0, "灯光渐变已停止"); + } + + private void SetStatus(bool success, bool running, string mode, int brightness, int target, string message) + { + lock (_statusLock) + { + _status = new HomeAssistantLightTransitionOutput + { + EntityId = _entityId, + Success = success, + Running = running, + Mode = mode, + BrightnessPercent = brightness, + TargetPercent = target, + Message = message + }; + } + } +} + diff --git a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs index 604e6c3..d3c7614 100644 --- a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs +++ b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs @@ -38,6 +38,7 @@ public sealed class HomeAssistantDiscoveredDeviceOutput public string Category { get; set; } = nameof(HealthDeviceCategory.Other); public bool Online { get; set; } public string? PrimaryControlEntityId { get; set; } + public string? DefaultLightEffect { get; set; } public List Entities { get; set; } = []; public Dictionary Metrics { get; set; } = []; } @@ -120,12 +121,14 @@ public sealed class HomeAssistantDeviceSwitchOutput public sealed class HomeAssistantLightTransitionOutput { + public string EntityId { get; set; } = string.Empty; public bool Success { get; set; } = true; public bool Running { get; set; } public string Mode { get; set; } = "none"; public int BrightnessPercent { get; set; } public int TargetPercent { get; set; } public string Message { get; set; } = string.Empty; + public List Lights { get; set; } = []; } public sealed class HomeAssistantSetParameterInput diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index 6bb6da2..272a968 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -33,6 +33,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie private readonly MattressComfortService _mattressComfort; private readonly IHttpContextAccessor _httpContextAccessor; private readonly DeviceModuleRegistry _modules; + private readonly DeviceParameterController _parameters; private readonly HealthSceneCoordinator _scenes; private readonly HomeAssistantDeviceCatalog _catalog; private readonly ILogger _logger; @@ -48,6 +49,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie MattressComfortService mattressComfort, IHttpContextAccessor httpContextAccessor, DeviceModuleRegistry modules, + DeviceParameterController parameters, HealthSceneCoordinator scenes, HomeAssistantDeviceCatalog catalog, ILogger logger) @@ -62,6 +64,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie _mattressComfort = mattressComfort; _httpContextAccessor = httpContextAccessor; _modules = modules; + _parameters = parameters; _scenes = scenes; _catalog = catalog; _logger = logger; @@ -95,7 +98,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie [HttpPost("/api/health/home-assistant/lights/stop")] public async Task StopLightTransition(CancellationToken cancellationToken) { - try { return await _lightTransition.CancelAsync(cancellationToken); } + try { return await _lightTransition.CancelPrimaryAsync(cancellationToken); } catch (Exception exception) when (exception is not OperationCanceledException) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; } } @@ -303,20 +306,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie } var parameterType = input.ParameterType.Trim().ToLowerInvariant(); - 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, command.EntityId, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配"); - if (!targetDevice.Entities.Any(entity => string.Equals(entity.EntityId, command.EntityId, StringComparison.OrdinalIgnoreCase))) - throw new InvalidOperationException("该参数不属于指定设备"); - // 外部音频网关不依赖 HA 媒体实体是否在线,但仍校验实体归属。 - if (!module.ParameterUsesIndependentGateway(command) && - (!stateMap.TryGetValue(command.EntityId, out var state) || !IsAvailable(state))) - throw new InvalidOperationException("目标参数实体当前离线或不可用"); - await module.ExecuteParameterAsync(command, cancellationToken); + var command = await _parameters.ExecuteAsync(targetDevice, stateMap, parameterType, input.Value, + input.EntityId, cancellationToken); return new HomeAssistantSetParameterOutput { Success = true, DeviceId = input.DeviceId, ParameterType = parameterType,