Admin write-CRUD, Home Assistant connector config, wiring audit
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:
2026-07-14 11:22:17 +02:00
parent 1282acf82c
commit 09cd435c2b
23 changed files with 1562 additions and 72 deletions
@@ -14,13 +14,11 @@ public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double?
/// <summary>A tariff applicable to the meter (own / energy-type / global scope), for the timeline.</summary>
public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
/// <summary>Source binding + live status (last-seen / last value / last status).</summary>
public sealed record SourceRow(SourceType Type, bool IsEnabled, DateTimeOffset? LastSeenAt, double? LastValue, string? LastStatus, string Config);
/// <summary>
/// The meter-detail read model (SDD §8.6): identity, register span, totals, recent raw readings
/// and normalized consumption (measured-vs-estimated markers via quality), source status, the
/// applicable tariff timeline, and lifecycle events (swaps/deliveries/corrections).
/// and normalized consumption (measured-vs-estimated markers via quality), the applicable tariff
/// timeline, and lifecycle events (swaps/deliveries/corrections). Source management loads the
/// source entities directly (they are editable), so it is not part of this read model.
/// </summary>
public sealed record MeterDetailView(
int Id,
@@ -46,4 +44,4 @@ public sealed record MeterDetailView(
IReadOnlyList<ConsumptionDetailRow> RecentConsumption,
IReadOnlyList<EventRow> Events,
IReadOnlyList<TariffRow> Tariffs,
IReadOnlyList<SourceRow> Sources);
int SourceCount);
@@ -72,17 +72,12 @@ public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> co
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var sources = meter.Sources
.OrderBy(s => s.Priority)
.Select(s => new SourceRow(s.SourceType, s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus, s.Config))
.ToList();
return new MeterDetailView(
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
readingCount, consumptionCount,
first?.Time, last?.Time, first?.Value, last?.Value,
totalConsumption, totalGeneration,
recentReadings, recentConsumption, events, tariffs, sources);
recentReadings, recentConsumption, events, tariffs, meter.Sources.Count);
}
}
@@ -33,6 +33,10 @@ public static class DependencyInjection
services.AddScoped<ReferenceDataImporter>();
services.AddScoped<IngestionService>();
services.AddScoped<MqttMessageRouter>();
// HttpClient + HA tester are available even with live ingestion off, so the admin
// "Test connection" works without the background workers running.
services.AddHttpClient();
services.AddScoped<HaConnectionTester>();
services.AddScoped<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>();
services.AddScoped<Dashboard.SolarService>();
@@ -46,6 +46,8 @@ public sealed record EndpointConfig
}
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public string? ResolveUsername() => Resolve(UsernameEnv);
public string? ResolvePassword() => Resolve(PasswordEnv);
@@ -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}");
}
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for a Home Assistant
/// connection (SDD §6.2). The long-lived token is stored by reference only: <see cref="TokenEnv"/>
/// names an environment variable resolved at runtime — never the token itself (SDD §6.4).
/// </summary>
public sealed record HaEndpointConfig
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Base URL of the Home Assistant instance, e.g. <c>http://homeassistant.local:8123</c>.</summary>
public string? BaseUrl { get; init; }
/// <summary>Name of the environment variable holding the long-lived access token.</summary>
public string? TokenEnv { get; init; }
public static HaEndpointConfig Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new HaEndpointConfig();
}
try
{
return JsonSerializer.Deserialize<HaEndpointConfig>(json, Options) ?? new HaEndpointConfig();
}
catch (JsonException)
{
return new HaEndpointConfig();
}
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
/// <summary>Resolves the token from the referenced environment variable (null if unset).</summary>
public string? ResolveToken() =>
string.IsNullOrWhiteSpace(TokenEnv) ? null : Environment.GetEnvironmentVariable(TokenEnv);
}
@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
using System.Text.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -99,10 +98,8 @@ public sealed class HomeAssistantWorker(
HaStateClient client, IngestionService ingestion, IngestionEndpoint endpoint,
MeterSource source, SourceConfig config, CancellationToken cancellationToken)
{
var endpointConfig = ParseHaEndpoint(endpoint.Config);
var token = string.IsNullOrWhiteSpace(endpointConfig.TokenEnv)
? null
: Environment.GetEnvironmentVariable(endpointConfig.TokenEnv);
var endpointConfig = HaEndpointConfig.Parse(endpoint.Config);
var token = endpointConfig.ResolveToken();
if (string.IsNullOrWhiteSpace(endpointConfig.BaseUrl) || string.IsNullOrWhiteSpace(token)
|| string.IsNullOrWhiteSpace(config.EntityId))
@@ -125,28 +122,4 @@ public sealed class HomeAssistantWorker(
}
}
private static HaEndpoint ParseHaEndpoint(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new HaEndpoint();
}
try
{
return JsonSerializer.Deserialize<HaEndpoint>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new HaEndpoint();
}
catch (JsonException)
{
return new HaEndpoint();
}
}
private sealed record HaEndpoint
{
public string? BaseUrl { get; init; }
public string? TokenEnv { get; init; }
}
}