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 }; } } }