209 lines
7.8 KiB
C#
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);
|
|
}
|
|
}
|