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

111 lines
5.5 KiB
C#

using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant;
public sealed class HomeAssistantClient : IHomeAssistantGateway
{
private readonly HttpClient _httpClient;
private readonly HomeAssistantOptions _options;
public HomeAssistantClient(HttpClient httpClient, IOptions<HomeAssistantOptions> options)
{
_httpClient = httpClient;
_options = options.Value;
_httpClient.Timeout = TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 3, 60));
}
public async Task<IReadOnlyList<HomeAssistantState>> GetStatesAsync(CancellationToken cancellationToken = default)
{
using var request = CreateRequest(HttpMethod.Get, "api/states");
using var response = await SendAsync(request, cancellationToken);
await EnsureSuccessAsync(response, 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(
string domain,
string service,
IReadOnlyDictionary<string, object?> data,
CancellationToken cancellationToken = default)
{
using var request = CreateRequest(HttpMethod.Post, $"api/services/{domain}/{service}");
request.Content = JsonContent.Create(data);
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)
throw new InvalidOperationException("Home Assistant 接入未启用");
if (string.IsNullOrWhiteSpace(_options.BaseUrl))
throw new InvalidOperationException("Home Assistant 地址未配置");
if (string.IsNullOrWhiteSpace(_options.AccessToken))
throw new InvalidOperationException("Home Assistant 访问令牌未配置");
var baseUrl = _options.BaseUrl.TrimEnd('/') + "/";
var request = new HttpRequestMessage(method, new Uri(new Uri(baseUrl), path));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.AccessToken);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return request;
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.IsSuccessStatusCode) return;
var body = await response.Content.ReadAsStringAsync(cancellationToken);
var message = body.Length > 300 ? body[..300] : body;
throw new HttpRequestException($"Home Assistant 返回 {(int)response.StatusCode}: {message}");
}
}