using System.Net.Http.Headers; using Microsoft.Extensions.Logging; namespace MeterVault.Infrastructure.Ingestion; /// Which way a Home Assistant connectivity test ended. /// /// The verdict is shown to whoever clicked "Test connection", so it has to be sayable in their /// language — and this assembly has no business knowing what that is. The outcome travels as a /// value and the admin UI supplies the words. /// public enum HaTestOutcome { /// /// No test was run — the caller decided beforehand (no token configured, base URL edited since /// saving) and put its own, already-localized wording in . /// The default, so a result built by the UI needs no ceremony to say so. /// Precondition, /// Reachable and the token was accepted; no entity was named to sample. Connected, /// Reachable and the named entity returned a number — see . ConnectedWithValue, /// No base URL was given. BaseUrlMissing, /// No token was available to test with. TokenMissing, /// HA answered, but not with success. carries the status. HttpError, /// Reachable, but the named entity has no numeric state (unavailable/unknown/non-numeric). NoNumericState, /// The request threw. carries the exception message. RequestFailed, } /// Outcome of a Home Assistant connectivity test. /// The English summary, kept for logs and non-UI callers. /// The same verdict as a value, for a UI that has to phrase it in some language. /// The entity that was sampled, when one was named. /// Diagnostic text (HTTP status, exception message). Not ours to translate. public sealed record HaTestResult( bool Ok, string Message, double? SampleValue = null, HaTestOutcome Outcome = HaTestOutcome.Precondition, string? EntityId = null, string? Detail = null); /// /// Verifies a Home Assistant connection from the admin UI: checks the base URL + token against /// GET /api/, and optionally reads one entity's state. Confirms the app can actually read HA /// before a source is relied upon (SDD §6.2). /// /// /// Takes the token already resolved rather than a reference to one. The admin UI must be able to /// test a token that has been typed but not yet saved (so not yet encrypted), and keeping the two /// storage forms out of here leaves one resolution path in . /// public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILogger logger) { private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; private readonly ILogger _logger = logger; public async Task TestAsync( string? baseUrl, string? token, string? entityId, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(baseUrl)) { return new HaTestResult(false, "Base URL is required.", Outcome: HaTestOutcome.BaseUrlMissing); } if (string.IsNullOrWhiteSpace(token)) { return new HaTestResult(false, "No token available to test.", Outcome: HaTestOutcome.TokenMissing); } var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(10); try { using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl.TrimEnd('/')}/api/"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}.", Outcome: HaTestOutcome.HttpError, Detail: $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim()); } if (string.IsNullOrWhiteSpace(entityId)) { return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.", Outcome: HaTestOutcome.Connected); } var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false); return state is { } value ? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value, HaTestOutcome.ConnectedWithValue, entityId) : new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).", Outcome: HaTestOutcome.NoNumericState, EntityId: entityId); } catch (Exception ex) { _logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl); return new HaTestResult(false, $"Connection failed: {ex.Message}", Outcome: HaTestOutcome.RequestFailed, Detail: ex.Message); } } }