Admin write-CRUD, Home Assistant connector config, wiring audit
ci / build-test (push) Successful in 1m19s
ci / build-test (push) Successful in 1m19s
Three requested phases.
1) Admin section (SDD §8.7) — MudBlazor inline-dialog CRUD, consistent pattern,
delete guards, snackbar feedback, shared Confirm helper:
- Energy types: create/edit/delete (blocks delete when meters reference it).
- Meters: create/edit/delete; recomputes consumption when mode/baseline
changes (NormalizationService over a fresh factory context, in a tx);
delete cascades data (consumption+readings are Restrict → removed first).
- A meter's ingest sources: manage on the meter-detail Sources tab
(add/edit/delete MQTT/Tasmota/HA sources with typed config).
- Tariffs: full CRUD (scope/component/value/validity).
- Cost categories: CRUD + member management (meter or energy-type members).
- Connectors: ingestion_endpoint CRUD (MQTT broker + Home Assistant);
secrets referenced by env-var name only, never stored.
- Settings: read-only effective-config view (settings are env-driven and
reproducible, so an editable form would change nothing — kept honest).
PV role is now editable on meters (MeterMeta.SetRole can clear a role).
2) Read Home Assistant — extracted a shared public HaEndpointConfig (was a
private record in the worker), added HaConnectionTester (powers the connector
"Test connection": checks base URL + env-resolved token, optionally reads one
entity). Configuring an HA connector + an HA source on a meter drives the
existing REST-poll worker end to end. (WebSocket push stays a future
optimization; REST poll already reads HA.)
3) Wiring/placeholder audit — swept every OnClick/Href: all handlers are real,
all internal links resolve to real routes, no TODO/stub/placeholder code.
Fixed one genuine gap: MainLayout had no drawer toggle, so the nav was
unreachable on narrow screens — added a hamburger button.
Tests: +6 (MeterMeta.SetRole role-removal; HaConnectionTester fail-closed
guard branches with a throwing HttpClientFactory proving no network on bad
config); render test now covers all admin routes. 69 Core + 45 Integration =
114 green. Live-verified in Docker: all admin pages 200, drawer toggle present,
Settings shows real effective config.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>Outcome of a Home Assistant connectivity test.</summary>
|
||||
public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a Home Assistant connection from the admin UI: checks the base URL + resolved token
|
||||
/// against <c>GET /api/</c>, and optionally reads one entity's state. Confirms the app can actually
|
||||
/// read HA before a source is relied upon (SDD §6.2). Token is resolved by reference (env var).
|
||||
/// </summary>
|
||||
public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILogger<HaConnectionTester> logger)
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
private readonly ILogger<HaConnectionTester> _logger = logger;
|
||||
|
||||
public async Task<HaTestResult> TestAsync(
|
||||
string? baseUrl, string? tokenEnv, string? entityId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return new HaTestResult(false, "Base URL is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenEnv))
|
||||
{
|
||||
return new HaTestResult(false, "Token env-var name is required (the token is resolved from it at runtime).");
|
||||
}
|
||||
|
||||
var token = Environment.GetEnvironmentVariable(tokenEnv);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new HaTestResult(false, $"Environment variable '{tokenEnv}' is not set on the server.");
|
||||
}
|
||||
|
||||
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}.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entityId))
|
||||
{
|
||||
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.");
|
||||
}
|
||||
|
||||
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)
|
||||
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl);
|
||||
return new HaTestResult(false, $"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user