1
This commit is contained in:
+51
-4
@@ -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)
|
||||
|
||||
+26
-46
@@ -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;
|
||||
|
||||
+11
-4
@@ -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;
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+8
@@ -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);
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user