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 options) { _httpClient = httpClient; _options = options.Value; _httpClient.Timeout = TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 3, 60)); } public async Task> 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>(cancellationToken: cancellationToken) ?? []; await EnrichTemperatureUnitAsync(states, cancellationToken); return states; } // 原生 climate 状态通常省略温度单位,它使用 HA 全局单位系统;不能把缺失直接当作摄氏。 private async Task EnrichTemperatureUnitAsync(List 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 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 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}"); } }