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 _logger; private readonly SemaphoreSlim _lock = new(1, 1); private HomeAssistantRegistries? _cachedRegistries; private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); public HomeAssistantRegistryService( IOptions options, ILogger logger) { _options = options.Value; _logger = logger; } public async Task 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 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>(devicesJson) ?? []; // 5. 获取实体注册表 (id: 2) var entitiesJson = await SendCommandAsync(ws, 2, "config/entity_registry/list", cts.Token); var entities = ParseRegistryResult>(entitiesJson) ?? []; // 6. 获取区域注册表 (id: 3) var areasJson = await SendCommandAsync(ws, 3, "config/area_registry/list", cts.Token); var areas = ParseRegistryResult>(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 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(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(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(bytes), WebSocketMessageType.Text, true, cancellationToken); } private static async Task ReceiveTextMessageAsync(ClientWebSocket ws, CancellationToken cancellationToken) { var buffer = new byte[8192]; using var ms = new MemoryStream(); while (true) { var result = await ws.ReceiveAsync(new ArraySegment(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); } }