This commit is contained in:
fuenmao
2026-10-09 16:46:30 +08:00
parent 4a634ef4c2
commit e6b90a06ab
63 changed files with 4170 additions and 1518 deletions
@@ -5,7 +5,7 @@ using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
public sealed class HomeAssistantClient
public sealed class HomeAssistantClient : IHomeAssistantGateway
{
private readonly HttpClient _httpClient;
private readonly HomeAssistantOptions _options;
@@ -20,9 +20,47 @@ public sealed class HomeAssistantClient
public async Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
{
using var request = CreateRequest(HttpMethod.Get, "api/states");
using var response = await _httpClient.SendAsync(request, cancellationToken);
using var response = await SendAsync(request, cancellationToken);
await EnsureSuccessAsync(response, cancellationToken);
return await response.Content.ReadFromJsonAsync<List<HomeAssistantState>>(cancellationToken: cancellationToken) ?? [];
var states = await response.Content.ReadFromJsonAsync<List<HomeAssistantState>>(cancellationToken: cancellationToken) ?? [];
await EnrichTemperatureUnitAsync(states, cancellationToken);
return states;
}
// 原生 climate 状态通常省略温度单位,它使用 HA 全局单位系统;不能把缺失直接当作摄氏。
private async Task EnrichTemperatureUnitAsync(List<HomeAssistantState> states, CancellationToken cancellationToken)
{
var targets = states.Where(state => state.EntityId.StartsWith("climate.", StringComparison.OrdinalIgnoreCase) &&
state.Attributes.ValueKind == JsonValueKind.Object &&
!state.Attributes.TryGetProperty("temperature_unit", out _)).ToArray();
if (targets.Length == 0) return;
using var bounded = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
bounded.CancelAfter(TimeSpan.FromSeconds(2));
try
{
using var request = CreateRequest(HttpMethod.Get, "api/config");
using var response = await SendAsync(request, bounded.Token);
await EnsureSuccessAsync(response, bounded.Token);
using var config = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(bounded.Token),
cancellationToken: bounded.Token);
if (!config.RootElement.TryGetProperty("unit_system", out var units) ||
units.ValueKind != JsonValueKind.Object || !units.TryGetProperty("temperature", out var temperature) ||
temperature.ValueKind != JsonValueKind.String) return;
var unit = temperature.GetString();
if (unit is not ("°C" or "°F")) return;
foreach (var state in targets)
{
var attributes = state.Attributes.EnumerateObject().ToDictionary(property => property.Name,
property => property.Value.Clone(), StringComparer.Ordinal);
attributes["temperature_unit"] = JsonSerializer.SerializeToElement(unit);
state.Attributes = JsonSerializer.SerializeToElement(attributes);
}
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
// 单位接口不可读时仍保留其他真实状态;空调温度功能按单位未知禁用,不猜测。
}
cancellationToken.ThrowIfCancellationRequested();
}
public async Task CallServiceAsync(
@@ -33,10 +71,19 @@ public sealed class HomeAssistantClient
{
using var request = CreateRequest(HttpMethod.Post, $"api/services/{domain}/{service}");
request.Content = JsonContent.Create(data);
using var response = await _httpClient.SendAsync(request, cancellationToken);
using var response = await SendAsync(request, cancellationToken);
await EnsureSuccessAsync(response, cancellationToken);
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
try { return await _httpClient.SendAsync(request, cancellationToken); }
catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException("Home Assistant 请求超时,未确认操作完成", exception);
}
}
private HttpRequestMessage CreateRequest(HttpMethod method, string path)
{
if (!_options.Enabled)
@@ -1,4 +1,5 @@
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Scenes;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -6,13 +7,16 @@ using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
/// <summary>在服务端驱动卧室灯光渐变,浏览器关闭后任务仍继续。</summary>
public sealed class HomeAssistantLightTransitionService : IHostedService
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 HomeAssistantClient _homeAssistant;
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);
@@ -22,13 +26,19 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
private HomeAssistantLightTransitionOutput _status = new();
public HomeAssistantLightTransitionService(
IHttpClientFactory httpClientFactory,
IHomeAssistantGateway homeAssistant,
LightOffDispatcher lightOff,
IOptions<HomeAssistantOptions> options,
ILogger<HomeAssistantLightTransitionService> logger)
ILogger<HomeAssistantLightTransitionService> logger,
SceneLightSafetyState safety,
TimeProvider time)
{
_options = options.Value;
_logger = logger;
_homeAssistant = new HomeAssistantClient(httpClientFactory.CreateClient(nameof(HomeAssistantLightTransitionService)), options);
_homeAssistant = homeAssistant;
_lightOff = lightOff;
_safety = safety;
_time = time;
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -70,6 +80,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
["color_temp_kelvin"] = Math.Clamp(colorTemperatureKelvin, 2200, 5000)
}, cancellationToken);
_safety.MarkOn();
SetStatus(true, true, "sleep", SleepStartPercent, 0, "助眠渐暗已启动,每5秒降低1%亮度");
Launch("sleep", SleepStartPercent);
return GetStatus();
@@ -84,7 +95,9 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
{
await StopCurrentAsync();
var light = await ReadAvailableLightAsync(cancellationToken);
var isOff = string.Equals(light.State, "off", StringComparison.OrdinalIgnoreCase);
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("灯具未返回当前亮度,无法从当前亮度启动晨起唤醒");
@@ -92,6 +105,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
if (current >= WakeTargetPercent && !isOff)
{
_safety.MarkOn();
SetStatus(true, false, "wake", current, WakeTargetPercent, "当前亮度已达到70%或更高,保持原亮度");
return GetStatus();
}
@@ -105,6 +119,7 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
}, cancellationToken);
}
_safety.MarkOn();
SetStatus(true, true, "wake", current, WakeTargetPercent, "晨起唤醒已启动,每5秒增加1%亮度");
Launch("wake", current);
return GetStatus();
@@ -151,15 +166,16 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
{
while (true)
{
await Task.Delay(StepInterval, cancellationToken);
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 StopMattressMassageAsync(cancellationToken);
await _lightOff.NotifyAsync(cancellationToken);
SetStatus(true, false, mode, 0, 0, "灯已关闭,床垫按摩已停止");
}
catch (Exception exception) when (exception is not OperationCanceledException)
@@ -191,10 +207,11 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
{
["entity_id"] = LightEntityId
}, cancellationToken);
_safety.MarkOff(_time.GetUtcNow());
SetStatus(true, false, mode, 0, 0, "灯光已关闭,正在停止床垫按摩");
try
{
await StopMattressMassageAsync(cancellationToken);
await _lightOff.NotifyAsync(cancellationToken);
SetStatus(true, false, mode, 0, 0, "助眠渐暗已完成,灯光与床垫按摩已关闭");
}
catch (Exception exception) when (exception is not OperationCanceledException)
@@ -227,43 +244,6 @@ public sealed class HomeAssistantLightTransitionService : IHostedService
}
}
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;
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Globalization;
using System.Text.Json.Serialization;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
@@ -17,17 +18,20 @@ public sealed class HomeAssistantState
[JsonPropertyName("last_updated")]
public DateTimeOffset? LastUpdated { get; set; }
[JsonPropertyName("last_reported")]
public DateTimeOffset? LastReported { get; set; }
public string? GetStringAttribute(string name)
{
if (!Attributes.TryGetProperty(name, out var value)) return null;
if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null;
return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
}
public double? GetDoubleAttribute(string name)
{
if (!Attributes.TryGetProperty(name, out var value)) return null;
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d)) return d;
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
if (Attributes.ValueKind != JsonValueKind.Object || !Attributes.TryGetProperty(name, out var value)) return null;
if (value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var d) && double.IsFinite(d)) return d;
if (double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) && double.IsFinite(parsed)) return parsed;
return null;
}
}
@@ -54,6 +58,9 @@ public sealed class HomeAssistantDeviceRegistryItem
[JsonPropertyName("disabled_by")]
public string? DisabledBy { get; set; }
[JsonPropertyName("entry_type")]
public string? EntryType { get; set; }
}
public sealed class HomeAssistantEntityRegistryItem
@@ -14,6 +14,9 @@ public sealed class HomeAssistantOptions : IConfigurableOptions
public sealed class HomeAssistantEntityOptions
{
public string Climate { get; set; } = string.Empty;
public string Curtain { get; set; } = string.Empty;
public string Outlet { get; set; } = string.Empty;
public string Dehumidifier { get; set; } = string.Empty;
public string DehumidifierFanLevel { get; set; } = string.Empty;
public string DehumidifierUv { get; set; } = string.Empty;
@@ -45,7 +45,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
}
return _cachedRegistries;
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("通过 WebSocket 读取 Home Assistant 注册表失败:{Message}", ex.Message);
return _cachedRegistries;
@@ -119,7 +119,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
try
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", CancellationToken.None);
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", cts.Token);
}
catch
{
@@ -134,7 +134,7 @@ public sealed class HomeAssistantRegistryService : ISingleton
CachedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("连接 Home Assistant WebSocket 发生异常:{Message}", ex.Message);
return null;
@@ -0,0 +1,8 @@
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
public interface IHomeAssistantGateway
{
Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default);
Task CallServiceAsync(string domain, string service, IReadOnlyDictionary<string, object?> data,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,11 @@
using Evergreen.Health.Application.Health.Models;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
public interface ILightTransitionRuntime
{
HomeAssistantLightTransitionOutput GetStatus();
Task<HomeAssistantLightTransitionOutput> StartSleepAsync(int colorTemperatureKelvin, CancellationToken cancellationToken);
Task<HomeAssistantLightTransitionOutput> StartWakeAsync(CancellationToken cancellationToken);
Task<HomeAssistantLightTransitionOutput> CancelAsync(CancellationToken cancellationToken);
}