1111
This commit is contained in:
@@ -22,6 +22,76 @@ public sealed class HomeAssistantState
|
||||
if (!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;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantDeviceRegistryItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("name_by_user")]
|
||||
public string? NameByUser { get; set; }
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public string? Model { get; set; }
|
||||
|
||||
[JsonPropertyName("manufacturer")]
|
||||
public string? Manufacturer { get; set; }
|
||||
|
||||
[JsonPropertyName("area_id")]
|
||||
public string? AreaId { get; set; }
|
||||
|
||||
[JsonPropertyName("disabled_by")]
|
||||
public string? DisabledBy { get; set; }
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantEntityRegistryItem
|
||||
{
|
||||
[JsonPropertyName("entity_id")]
|
||||
public string EntityId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("device_id")]
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("original_name")]
|
||||
public string? OriginalName { get; set; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string? Platform { get; set; }
|
||||
|
||||
[JsonPropertyName("disabled_by")]
|
||||
public string? DisabledBy { get; set; }
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantAreaRegistryItem
|
||||
{
|
||||
[JsonPropertyName("area_id")]
|
||||
public string AreaId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantRegistries
|
||||
{
|
||||
public List<HomeAssistantDeviceRegistryItem> Devices { get; set; } = [];
|
||||
public List<HomeAssistantEntityRegistryItem> Entities { get; set; } = [];
|
||||
public List<HomeAssistantAreaRegistryItem> Areas { get; set; } = [];
|
||||
public DateTimeOffset CachedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class HomeAssistantActionResult
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
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)
|
||||
{
|
||||
_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", CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略正常关闭异常
|
||||
}
|
||||
|
||||
return new HomeAssistantRegistries
|
||||
{
|
||||
Devices = devices,
|
||||
Entities = entities,
|
||||
Areas = areas,
|
||||
CachedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user