Files
Evergreen-Health/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantRegistryService.cs
T
2026-10-09 16:46:30 +08:00

209 lines
7.8 KiB
C#

using Furion.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
public sealed class HomeAssistantRegistryService : ISingleton
{
private readonly HomeAssistantOptions _options;
private readonly ILogger<HomeAssistantRegistryService> _logger;
private readonly SemaphoreSlim _lock = new(1, 1);
private HomeAssistantRegistries? _cachedRegistries;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public HomeAssistantRegistryService(
IOptions<HomeAssistantOptions> options,
ILogger<HomeAssistantRegistryService> logger)
{
_options = options.Value;
_logger = logger;
}
public async Task<HomeAssistantRegistries?> GetRegistriesAsync(CancellationToken cancellationToken = default)
{
if (_cachedRegistries is not null && DateTimeOffset.UtcNow - _cachedRegistries.CachedAt < CacheDuration)
{
return _cachedRegistries;
}
await _lock.WaitAsync(cancellationToken);
try
{
if (_cachedRegistries is not null && DateTimeOffset.UtcNow - _cachedRegistries.CachedAt < CacheDuration)
{
return _cachedRegistries;
}
var registries = await FetchRegistriesFromWebSocketAsync(cancellationToken);
if (registries is not null)
{
_cachedRegistries = registries;
}
return _cachedRegistries;
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("通过 WebSocket 读取 Home Assistant 注册表失败:{Message}", ex.Message);
return _cachedRegistries;
}
finally
{
_lock.Release();
}
}
private async Task<HomeAssistantRegistries?> FetchRegistriesFromWebSocketAsync(CancellationToken cancellationToken)
{
if (!_options.Enabled || string.IsNullOrWhiteSpace(_options.BaseUrl) || string.IsNullOrWhiteSpace(_options.AccessToken))
{
return null;
}
var wsUri = ConvertToWebSocketUri(_options.BaseUrl);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 5, 20)));
using var ws = new ClientWebSocket();
ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(15);
try
{
await ws.ConnectAsync(wsUri, cts.Token);
// 1. 等待 auth_required
var authRequiredJson = await ReceiveTextMessageAsync(ws, cts.Token);
using (var doc = JsonDocument.Parse(authRequiredJson))
{
var type = doc.RootElement.GetProperty("type").GetString();
if (type != "auth_required")
{
throw new InvalidOperationException($"Home Assistant WebSocket 未按预期发送 auth_required,实际接收:{type}");
}
}
// 2. 发送认证消息
var authMessage = JsonSerializer.Serialize(new
{
type = "auth",
access_token = _options.AccessToken
});
await SendTextMessageAsync(ws, authMessage, cts.Token);
// 3. 等待 auth_ok
var authResultJson = await ReceiveTextMessageAsync(ws, cts.Token);
using (var doc = JsonDocument.Parse(authResultJson))
{
var type = doc.RootElement.GetProperty("type").GetString();
if (type != "auth_ok")
{
var msg = doc.RootElement.TryGetProperty("message", out var m) ? m.GetString() : "认证失败";
throw new InvalidOperationException($"Home Assistant WebSocket 认证失败:{msg}");
}
}
// 4. 获取设备注册表 (id: 1)
var devicesJson = await SendCommandAsync(ws, 1, "config/device_registry/list", cts.Token);
var devices = ParseRegistryResult<List<HomeAssistantDeviceRegistryItem>>(devicesJson) ?? [];
// 5. 获取实体注册表 (id: 2)
var entitiesJson = await SendCommandAsync(ws, 2, "config/entity_registry/list", cts.Token);
var entities = ParseRegistryResult<List<HomeAssistantEntityRegistryItem>>(entitiesJson) ?? [];
// 6. 获取区域注册表 (id: 3)
var areasJson = await SendCommandAsync(ws, 3, "config/area_registry/list", cts.Token);
var areas = ParseRegistryResult<List<HomeAssistantAreaRegistryItem>>(areasJson) ?? [];
try
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed", cts.Token);
}
catch
{
// 忽略正常关闭异常
}
return new HomeAssistantRegistries
{
Devices = devices,
Entities = entities,
Areas = areas,
CachedAt = DateTimeOffset.UtcNow
};
}
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("连接 Home Assistant WebSocket 发生异常:{Message}", ex.Message);
return null;
}
}
private static async Task<string> SendCommandAsync(ClientWebSocket ws, int id, string type, CancellationToken cancellationToken)
{
var command = JsonSerializer.Serialize(new { id, type });
await SendTextMessageAsync(ws, command, cancellationToken);
return await ReceiveTextMessageAsync(ws, cancellationToken);
}
private static T? ParseRegistryResult<T>(string responseJson)
{
using var doc = JsonDocument.Parse(responseJson);
var root = doc.RootElement;
if (root.TryGetProperty("success", out var success) && success.GetBoolean())
{
if (root.TryGetProperty("result", out var result))
{
return JsonSerializer.Deserialize<T>(result.GetRawText());
}
}
return default;
}
private static async Task SendTextMessageAsync(ClientWebSocket ws, string text, CancellationToken cancellationToken)
{
var bytes = Encoding.UTF8.GetBytes(text);
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, cancellationToken);
}
private static async Task<string> ReceiveTextMessageAsync(ClientWebSocket ws, CancellationToken cancellationToken)
{
var buffer = new byte[8192];
using var ms = new MemoryStream();
while (true)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
throw new WebSocketException("WebSocket 服务端已关闭连接");
}
ms.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
break;
}
}
return Encoding.UTF8.GetString(ms.ToArray());
}
private static Uri ConvertToWebSocketUri(string baseUrl)
{
var url = baseUrl.TrimEnd('/');
if (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
url = "wss://" + url[8..] + "/api/websocket";
}
else if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
url = "ws://" + url[7..] + "/api/websocket";
}
else
{
url = "ws://" + url + "/api/websocket";
}
return new Uri(url);
}
}