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
@@ -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": "睡眠",
@@ -0,0 +1,12 @@
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
namespace Evergreen.Health.Application.Health.Devices.Contracts;
/// <summary>按设备实例实际能力处理标准手动参数,不绑定单一型号,也不登记自动场景。</summary>
public interface IStandardDeviceControl
{
IReadOnlyList<string> ParameterKeys { get; }
DeviceCommand Build(HomeAssistantDiscoveredDeviceOutput device, HomeAssistantState state,
string parameterType, object? value);
}
@@ -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;
/// <summary>共用归属校验和路由;标准能力处理普通参数,专属模块保留自己的执行与场景逻辑。</summary>
public sealed class DeviceParameterController
{
private readonly DeviceModuleRegistry _modules;
private readonly DeviceActionRunner _runner;
private readonly Dictionary<string, IStandardDeviceControl> _standard = new(StringComparer.OrdinalIgnoreCase);
public DeviceParameterController(DeviceModuleRegistry modules, DeviceActionRunner runner,
IEnumerable<IStandardDeviceControl> 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<DeviceCommand> ExecuteAsync(HomeAssistantDiscoveredDeviceOutput device,
IReadOnlyDictionary<string, HomeAssistantState> 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;
}
}
@@ -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<HomeAssistantEntityOutput> 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",
@@ -61,7 +61,11 @@ public static class HealthDeviceRegistration
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<DehumidifierModule>());
services.AddSingleton<FragranceModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<FragranceModule>());
services.AddSingleton<LightingModule>();
services.AddSingleton<LightingModule>(provider => new(
provider.GetRequiredService<DeviceActionRunner>(), provider.GetRequiredService<IOptions<HomeAssistantOptions>>(),
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.Entities.AmbientLight),
provider.GetRequiredService<LightOffDispatcher>()));
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<LightingModule>());
services.AddSingleton<AudioModule>();
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<AudioModule>());
@@ -69,7 +73,13 @@ public static class HealthDeviceRegistration
services.AddSingleton<IHealthDeviceModule>(provider => provider.GetRequiredService<MattressModule>());
services.AddSingleton<ILightOffHandler>(provider => provider.GetRequiredService<MattressModule>());
services.AddSingleton<LightOffDispatcher>();
services.AddSingleton<DeviceModuleRegistry>();
services.AddSingleton<DeviceModuleRegistry>(provider => new(
provider.GetServices<IHealthDeviceModule>().Concat(
provider.GetRequiredService<IOptions<HomeAssistantOptions>>().Value.AdditionalSleepLights.Select(light =>
new ConfiguredSleepLightModule(provider.GetRequiredService<DeviceActionRunner>(), light,
provider.GetRequiredService<HomeAssistantLightTransitionService>().ForEntity(light.EntityId))))));
services.AddSingleton<IStandardDeviceControl, StandardLightControl>();
services.AddSingleton<DeviceParameterController>();
services.AddSingleton<HomeAssistantDeviceCatalog>();
services.AddSingleton<HealthSceneCoordinator>();
return services;
@@ -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()
: [];
}
@@ -6,268 +6,69 @@ using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
/// <summary>在服务端驱动卧室灯光渐变,浏览器关闭后任务仍继续。</summary>
/// <summary>管理明确配置的场景灯;共享单灯渐变实现,但不共享亮度、计时或取消状态。</summary>
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<string, LightTransitionWorker> _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<HomeAssistantLightTransitionService> _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<HomeAssistantOptions> options,
ILogger<HomeAssistantLightTransitionService> logger,
SceneLightSafetyState safety,
TimeProvider time)
public HomeAssistantLightTransitionService(IHomeAssistantGateway gateway, LightOffDispatcher lightOff,
IOptions<HomeAssistantOptions> options, ILogger<HomeAssistantLightTransitionService> 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<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
// 旧助眠/晨起接口只作用于原卧室灯;额外灯由各自场景模块调用。
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int kelvin, CancellationToken token)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
await StopCurrentAsync();
await ReadAvailableLightAsync(cancellationToken);
await _homeAssistant.CallServiceAsync("light", "turn_on", new Dictionary<string, object?>
{
["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<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken token)
{
await _primary.StartWakeAsync(token);
return GetStatus();
}
public async Task<HomeAssistantLightTransitionOutput> CancelPrimaryAsync(CancellationToken token)
{
await _primary.CancelAsync(token);
return GetStatus();
}
public async Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken cancellationToken)
// 一键关闭/场景替换必须取消全部灯的计时任务,防止后续 tick 重新点亮灯。
public async Task<HomeAssistantLightTransitionOutput> 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<string, object?>
{
["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<HomeAssistantLightTransitionOutput> 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<HomeAssistantState> 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<string, object?>
{
["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<string, object?>
{
["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);
}
}
@@ -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<SceneLightOptions> AdditionalSleepLights { get; set; } = [];
}
/// <summary>明确加入舒睡/全关的额外灯具;未配置的灯仍仅提供单独控制。</summary>
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
@@ -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;
/// <summary>单灯渐变执行器;每盏灯的计时、取消和状态互相隔离。</summary>
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<HomeAssistantLightTransitionService> _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<HomeAssistantOptions> options,
ILogger<HomeAssistantLightTransitionService> 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<HomeAssistantLightTransitionOutput> 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<HomeAssistantLightTransitionOutput> 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<string, object?>
{
["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<HomeAssistantLightTransitionOutput> 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<string, object?> BuildSleepStartData(HomeAssistantState state, int kelvin)
{
var data = new Dictionary<string, object?> { ["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<HomeAssistantState> 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<string, object?>
{
["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<string, object?>
{
["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
};
}
}
}
@@ -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<HomeAssistantEntityOutput> Entities { get; set; } = [];
public Dictionary<string, object?> 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<HomeAssistantLightTransitionOutput> Lights { get; set; } = [];
}
public sealed class HomeAssistantSetParameterInput
@@ -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<HealthHomeAssistantService> _logger;
@@ -48,6 +49,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
MattressComfortService mattressComfort,
IHttpContextAccessor httpContextAccessor,
DeviceModuleRegistry modules,
DeviceParameterController parameters,
HealthSceneCoordinator scenes,
HomeAssistantDeviceCatalog catalog,
ILogger<HealthHomeAssistantService> 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<HomeAssistantLightTransitionOutput> 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,