294 lines
13 KiB
C#
294 lines
13 KiB
C#
using Evergreen.Health.Application.Health.Models;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
|
|
|
|
/// <summary>在服务端驱动卧室灯光渐变,浏览器关闭后任务仍继续。</summary>
|
|
public sealed class HomeAssistantLightTransitionService : IHostedService
|
|
{
|
|
private const int SleepStartPercent = 50;
|
|
private const int WakeTargetPercent = 70;
|
|
private static readonly TimeSpan StepInterval = TimeSpan.FromSeconds(5);
|
|
|
|
private readonly HomeAssistantClient _homeAssistant;
|
|
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(
|
|
IHttpClientFactory httpClientFactory,
|
|
IOptions<HomeAssistantOptions> options,
|
|
ILogger<HomeAssistantLightTransitionService> logger)
|
|
{
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
_homeAssistant = new HomeAssistantClient(httpClientFactory.CreateClient(nameof(HomeAssistantLightTransitionService)), options);
|
|
}
|
|
|
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
|
|
|
public async Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
await _operationLock.WaitAsync(cancellationToken);
|
|
try { await StopCurrentAsync(); }
|
|
finally { _operationLock.Release(); }
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken)
|
|
{
|
|
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);
|
|
|
|
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 isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase);
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
|
|
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(_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, cancellationToken);
|
|
var light = await ReadAvailableLightAsync(cancellationToken);
|
|
if (!string.Equals(light.State, "on", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (mode == "sleep")
|
|
{
|
|
try
|
|
{
|
|
await StopMattressMassageAsync(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);
|
|
SetStatus(true, false, mode, 0, 0, "灯光已关闭,正在停止床垫按摩");
|
|
try
|
|
{
|
|
await StopMattressMassageAsync(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 StopMattressMassageAsync(CancellationToken cancellationToken)
|
|
{
|
|
var massageEntities = new[]
|
|
{
|
|
_options.Entities.MattressLeftMassagePart,
|
|
_options.Entities.MattressRightMassagePart
|
|
}.Where(entityId => !string.IsNullOrWhiteSpace(entityId)).Distinct().ToArray();
|
|
var failures = new List<string>();
|
|
foreach (var entityId in massageEntities)
|
|
{
|
|
try
|
|
{
|
|
var confirmed = false;
|
|
for (var attempt = 0; attempt < 2 && !confirmed; attempt++)
|
|
{
|
|
await _homeAssistant.CallServiceAsync("select", "select_option", new Dictionary<string, object?>
|
|
{
|
|
["entity_id"] = entityId,
|
|
["option"] = "无"
|
|
}, cancellationToken);
|
|
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
|
var current = (await _homeAssistant.GetStatesAsync(cancellationToken))
|
|
.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
|
|
confirmed = current?.State == "无";
|
|
}
|
|
if (!confirmed) failures.Add(entityId);
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(exception, "灯光熄灭后未能关闭床垫按摩 {EntityId}", entityId);
|
|
failures.Add(entityId);
|
|
}
|
|
}
|
|
if (failures.Count > 0)
|
|
throw new InvalidOperationException($"灯光已关闭,但床垫按摩未确认关闭:{string.Join("、", failures)}");
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
}
|