This commit is contained in:
fuenmao
2026-10-09 18:06:11 +08:00
parent e6b90a06ab
commit 2d44cc3966
17 changed files with 1161 additions and 264 deletions
@@ -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;
/// <summary>额外场景灯复用标准参数和单灯渐变;型号专属模式通过配置选择,不套用原灯的按摩/唤醒钩子。</summary>
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<DeviceParameterDefinition> 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<DeviceCommand> 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 });
}
@@ -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)
@@ -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;
/// <summary>标准灯光参数依赖 HA 上报能力,不能将一个型号的色温范围或模式复制给另一盏灯。</summary>
public sealed class StandardLightControl : IStandardDeviceControl
{
public IReadOnlyList<string> 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<string, object?> { ["color_temp_kelvin"] = DeviceValues.Integer(value, 1, int.MaxValue, "灯光色温") }),
"light_effect" => new DeviceCommand("设置灯光模式", entityId, "light", "turn_on",
new Dictionary<string, object?> { ["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<string, object?> { ["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()
: [];
}