diff --git a/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs b/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs index 07250f8..911f091 100644 --- a/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs +++ b/Admin.NET.Test/Health/SceneLightingIntegrationTests.cs @@ -288,17 +288,27 @@ public sealed class SceneLightingIntegrationTests } [Fact] - public void ProductionRegistrationAddsConfiguredStripWithoutCreatingASecondDeviceControlImplementation() + public async Task ProductionRegistrationAddsSceneStripWithIndependentManualPower() { var services = new ServiceCollection(); services.AddOptions(); services.AddLogging(); services.AddHttpClient(); - services.AddSingleton>(Options.Create(Settings())); - services.AddHealthDevices(); services.AddSingleton(new FakeHaGateway()); + var settings = Settings(); + settings.AdditionalSleepLights.Single().StartSleepOnManualPowerOn = false; + var gateway = new FakeHaGateway(); + gateway.States[Strip] = Light(Strip); + services.AddSingleton>(Options.Create(settings)); + services.AddHealthDevices(); services.AddSingleton(gateway); services.AddSingleton(new FakeAudioGateway()); using var provider = services.BuildServiceProvider(); var registry = provider.GetRequiredService(); - Assert.IsType(registry.ForEntities([Strip])); + var strip = Assert.IsType(registry.ForEntities([Strip])); Assert.IsType(registry.ForEntities([Primary])); Assert.IsType(registry.ForEntities([Climate])); + await strip.SetPowerAsync(true, default); + var command = Assert.Single(gateway.Calls); + Assert.Equal(Strip, command.EntityId); + Assert.Equal("turn_on", command.Service); + Assert.Single(command.Data!); // 详情页开灯不套用舒睡模式或亮度。 + Assert.False(provider.GetRequiredService().ForEntity(Strip).GetStatus().Running); } [Fact] diff --git a/Admin.NET.Test/Health/StandardLightControlTests.cs b/Admin.NET.Test/Health/StandardLightControlTests.cs index 56257d6..1e7f583 100644 --- a/Admin.NET.Test/Health/StandardLightControlTests.cs +++ b/Admin.NET.Test/Health/StandardLightControlTests.cs @@ -96,6 +96,43 @@ public sealed class StandardLightControlTests Assert.Single(gateway.Calls); } + [Fact] + public async Task ConfiguredManualLightsAreDiscoveredIndependentlyWithInventoryFilteringEnabled() + { + const string auxiliary = "light.auxiliary", unconfigured = "light.unconfigured"; + var gateway = new FakeHaGateway(); + foreach (var id in new[] { LightA, LightB, auxiliary, unconfigured }) gateway.States[id] = State(id); + var catalog = new HomeAssistantDeviceCatalog(Options.Create(new HomeAssistantOptions + { + OnlyConfiguredDevices = true, ManualLightEntityIds = [LightA, LightB, LightB] + }), new DeviceModuleRegistry([])); + var haRegistry = new HomeAssistantRegistries + { + Devices = [new() { Id = "a", Name = "顶灯一" }, new() { Id = "b", Name = "顶灯二" }, new() { Id = "other" }], + Entities = [new() { DeviceId = "a", EntityId = auxiliary }, new() { DeviceId = "a", EntityId = LightA }, + new() { DeviceId = "b", EntityId = LightB }, new() { DeviceId = "other", EntityId = unconfigured }] + }; + var controller = Controller(gateway); + foreach (var registry in new[] { haRegistry, null }) + { + var devices = catalog.BuildDiscoveredDevices(registry, gateway.States); + Assert.Equal(2, devices.Count); + var first = Assert.Single(devices, item => item.PrimaryControlEntityId == LightA); + var second = Assert.Single(devices, item => item.PrimaryControlEntityId == LightB); + Assert.NotEqual(first.DeviceId, second.DeviceId); + Assert.All(devices, item => + { + Assert.Equal(nameof(HealthDeviceCategory.Lighting), item.Category); + Assert.Equal("", item.ModuleKey); + }); + await controller.ExecuteAsync(first, gateway.States, "light_brightness", 30, LightA, default); + await controller.ExecuteAsync(second, gateway.States, "light_color_temperature", 3100, LightB, default); + await Assert.ThrowsAsync(() => controller.ExecuteAsync( + second, gateway.States, "light_brightness", 40, LightA, default)); + } + Assert.Equal(new[] { LightA, LightB, LightA, LightB }, gateway.Calls.Select(call => call.EntityId)); + } + [Fact] public async Task TwoLightsUseIndependentRangesAndEffectLists() { diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json index af82ea2..4425ce4 100644 --- a/Evergreen.Health.Application/Configuration/HomeAssistant.json +++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json @@ -35,6 +35,16 @@ "Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8" }, "OnlyConfiguredDevices": true, + "ManualLightEntityIds": [], + "AdditionalSleepLights": [ + { + "Key": "roof-light-strip-1", + "Name": "超薄智能灯带", + "EntityId": "light.hekai1_cn_2224036048_wy0a01_s_2_light", + "SleepEffect": "助眠模式", + "StartSleepOnManualPowerOn": false + } + ], "SleepScene": { "TargetHumidity": 55, "DehumidifierMode": "睡眠", diff --git a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs index 89928b6..226786f 100644 --- a/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs +++ b/Evergreen.Health.Application/Health/Devices/Discovery/HomeAssistantDeviceCatalog.cs @@ -12,8 +12,14 @@ public sealed class HomeAssistantDeviceCatalog { private readonly HomeAssistantOptions _options; private readonly DeviceModuleRegistry _modules; + private readonly HashSet _manualLightIds; public HomeAssistantDeviceCatalog(IOptions options, DeviceModuleRegistry modules) - => (_options, _modules) = (options.Value, modules); + { + (_options, _modules) = (options.Value, modules); + _manualLightIds = _options.ManualLightEntityIds + .Where(id => !string.IsNullOrWhiteSpace(id) && id.StartsWith("light.", StringComparison.Ordinal)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } public List BuildDiscoveredDevices( HomeAssistantRegistries? registries, @@ -42,7 +48,8 @@ public sealed class HomeAssistantDeviceCatalog var entityIds = regEntities.Select(entity => entity.EntityId).ToHashSet(StringComparer.OrdinalIgnoreCase); var module = _modules.ForEntities(entityIds); var displayOnly = _options.DisplayOnlyDevices.FirstOrDefault(item => entityIds.Contains(item.EntityId)); - if (_options.OnlyConfiguredDevices && module is null && displayOnly is null) continue; + var manualLightId = regEntities.FirstOrDefault(entity => _manualLightIds.Contains(entity.EntityId))?.EntityId; + if (_options.OnlyConfiguredDevices && module is null && displayOnly is null && manualLightId is null) continue; var entityOutputs = new List(); foreach (var regEntity in regEntities) @@ -80,9 +87,11 @@ public sealed class HomeAssistantDeviceCatalog ? an : null; - var category = module?.Category ?? displayOnly?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs); + var category = module?.Category ?? (manualLightId is not null ? nameof(HealthDeviceCategory.Lighting) : + displayOnly?.Category ?? CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs)); var metrics = ExtractDeviceMetrics(category, entityOutputs); - var primaryControlEntityId = module is null && displayOnly is not null ? null : FindPrimaryControlEntityId(category, entityOutputs); + var primaryControlEntityId = module is not null ? FindPrimaryControlEntityId(category, entityOutputs) : + manualLightId ?? (displayOnly is not null ? null : FindPrimaryControlEntityId(category, entityOutputs)); discovered.Add(new HomeAssistantDiscoveredDeviceOutput { @@ -137,9 +146,27 @@ public sealed class HomeAssistantDeviceCatalog Entities = entityOutputs, Metrics = ExtractDeviceMetrics(module.Category, entityOutputs) }); } + foreach (var id in _manualLightIds) + { + if (_modules.ForEntities([id]) is not null || + _options.ExcludedDeviceEntityIds.Contains(id, StringComparer.OrdinalIgnoreCase) || + !stateMap.TryGetValue(id, out var state)) continue; + var name = state.GetStringAttribute("friendly_name") ?? id; + var available = IsAvailable(state); + list.Add(new() + { + DeviceId = $"fallback_{id}", Name = name, Category = nameof(HealthDeviceCategory.Lighting), Online = available, + PrimaryControlEntityId = state.EntityId, Entities = [new() + { + EntityId = state.EntityId, Domain = "light", Name = name, State = state.State, + Available = available, Attributes = ExtractSafeAttributes(state) + }] + }); + } foreach (var device in _options.DisplayOnlyDevices) { if (string.IsNullOrWhiteSpace(device.EntityId) || _modules.ForEntities([device.EntityId]) is not null || + _manualLightIds.Contains(device.EntityId) || _options.ExcludedDeviceEntityIds.Contains(device.EntityId, StringComparer.OrdinalIgnoreCase)) continue; stateMap.TryGetValue(device.EntityId, out var state); var available = state is not null && IsAvailable(state); diff --git a/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs b/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs index d429493..2c0d331 100644 --- a/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs +++ b/Evergreen.Health.Application/Health/Devices/Lighting/ConfiguredSleepLightModule.cs @@ -25,8 +25,17 @@ public sealed class ConfiguredSleepLightModule : HealthDeviceModule } public override async Task SetPowerAsync(bool turnOn, CancellationToken token) { - if (turnOn) { await _transition.StartSleepAsync(2700, token); return; } + if (turnOn && _light.StartSleepOnManualPowerOn) + { + await _transition.StartSleepAsync(2700, token); + return; + } await _transition.CancelAsync(token); + if (turnOn) + { + await base.SetPowerAsync(true, token); + return; + } var command = GetShutdownCommands().Single(); await Runner.SendAsync(command, token); await Runner.VerifyParameterAsync(command, token); diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs index dbea880..e81db5d 100644 --- a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs @@ -12,6 +12,8 @@ public sealed class HomeAssistantOptions : IConfigurableOptions public List ExcludedDeviceEntityIds { get; set; } = []; public bool OnlyConfiguredDevices { get; set; } public List DisplayOnlyDevices { get; set; } = []; + // 每条独立灯具登记自己的主 light 实体,名称和可控能力读取自 HA。 + public List ManualLightEntityIds { get; set; } = []; public HomeAssistantEntityOptions Entities { get; set; } = new(); public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new(); public List AdditionalSleepLights { get; set; } = []; @@ -31,6 +33,7 @@ public sealed class SceneLightOptions public string Name { get; set; } = string.Empty; public string EntityId { get; set; } = string.Empty; public string? SleepEffect { get; set; } + public bool StartSleepOnManualPowerOn { get; set; } = true; } public sealed class HomeAssistantEntityOptions