Files
Evergreen-Health/Evergreen.Health.Application/Health/Integrations/HomeAssistant/LightTransitionWorker.cs
T
2026-11-11 16:11:41 +08:00

322 lines
14 KiB
C#

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 _isPrimaryLight;
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;
_isPrimaryLight = 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 StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: false, cancellationToken))!;
internal Task<HomeAssistantLightTransitionOutput?> StartSleepIfIdleAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
=> StartSleepCoreAsync(colorTemperatureKelvin, onlyIfIdle: true, cancellationToken);
private async Task<HomeAssistantLightTransitionOutput?> StartSleepCoreAsync(int colorTemperatureKelvin, bool onlyIfIdle,
CancellationToken cancellationToken)
{
await _operationLock.WaitAsync(cancellationToken);
try
{
if (onlyIfIdle && GetStatus().Running) return null;
await StopCurrentAsync();
var light = await ReadAvailableLightAsync(cancellationToken);
var data = BuildSleepStartData(light, colorTemperatureKelvin);
await _homeAssistant.CallServiceAsync("light", "turn_on", data, cancellationToken);
if (_isPrimaryLight) _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 = _isPrimaryLight ? _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 (_isPrimaryLight) _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 (_isPrimaryLight) _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 (_isPrimaryLight && !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 (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow());
if (mode == "sleep" && _isPrimaryLight)
{
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 (_isPrimaryLight) _safety.MarkOff(_time.GetUtcNow());
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光已关闭");
try
{
if (_isPrimaryLight) 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
{
EntityId = _entityId,
Success = success,
Running = running,
Mode = mode,
BrightnessPercent = brightness,
TargetPercent = target,
Message = message
};
}
}
}