diff --git a/CLAUDE.md b/CLAUDE.md index c6fc0dc..422401e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**. -**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. Remaining refinements (HA WebSocket push, full admin write-CRUD, full de-DE UI localization) are noted at the end of their milestone commits. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo. +**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll; `HaConnectionTester` powers the connector "Test connection" button. Remaining refinements (HA WebSocket *push* — REST poll works today; commit-arbitrary-CSV-from-UI needs a meter-mapping wizard; full de-DE UI-string localization) are noted at their commits. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo. ## Source of truth diff --git a/README.md b/README.md index b9dfbbf..14fc2ef 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ full design. savings), an **oil/consumable panel** (tank gauge, deliveries, burner runtime, effective L/h, forecast-to-empty) and a **per-meter detail view** (raw readings, consumption, sources, tariff timeline, events), one-click reference-data load, CSV dry-run. +- **Admin UI**: full create/edit/delete for energy types, meters (with consumption recompute on + mode/baseline change), ingest sources, tariffs, cost categories, and MQTT/Home-Assistant + connectors; a "Test connection" for Home Assistant; effective-settings view. - **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik). - **JSON config export/import** for portability; Docker Compose + multi-arch image. diff --git a/src/App/Components/Layout/MainLayout.razor b/src/App/Components/Layout/MainLayout.razor index 6334943..951ec76 100644 --- a/src/App/Components/Layout/MainLayout.razor +++ b/src/App/Components/Layout/MainLayout.razor @@ -8,6 +8,8 @@ + MeterVault diff --git a/src/App/Components/Layout/NavMenu.razor b/src/App/Components/Layout/NavMenu.razor index eb763b4..ead1a9b 100644 --- a/src/App/Components/Layout/NavMenu.razor +++ b/src/App/Components/Layout/NavMenu.razor @@ -9,5 +9,8 @@ Energy types Tariffs + Cost categories + Connectors + Settings diff --git a/src/App/Components/Pages/Admin/Categories.razor b/src/App/Components/Pages/Admin/Categories.razor new file mode 100644 index 0000000..8c92cb0 --- /dev/null +++ b/src/App/Components/Pages/Admin/Categories.razor @@ -0,0 +1,261 @@ +@page "/admin/categories" +@rendermode InteractiveServer +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using Microsoft.EntityFrameworkCore +@using MudBlazor + +MeterVault — Cost categories + +
+ Cost categories + + Add category + +
+ +@if (_categories is null) +{ + +} +else +{ + + + Name + Sort + Members + Actions + + + + @if (!string.IsNullOrWhiteSpace(context.ColorHex)) + { + + } + @context.Name + + @context.Sort + @MemberSummary(context) + + + + + + +} + + + + @(_working.Id == 0 ? "New category" : $"Edit {_working.Name}") + + + + + + + @if (_working.Id != 0) + { + + Members + @if (_members.Count == 0) + { + No members yet — add a meter or an energy type. + } + else + { + + @foreach (var m in _members) + { + +
+ @MemberLabel(m) + +
+
+ } +
+ } +
+ + @foreach (var meter in _meters) + { + @meter.Name + } + + Add + + @foreach (var t in _energyTypes) + { + @t.DisplayName + } + + Add +
+ } + else + { + Save the category first to add members. + } +
+ + Close + Save + +
+ +@code { + private List? _categories; + private List _meters = []; + private List _energyTypes = []; + private List _members = []; + private bool _editOpen; + private EditModel _working = new(); + private int? _addMeterId; + private int? _addTypeId; + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync(); + _meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync(); + _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync(); + } + + private string MemberSummary(CostCategory c) + { + var meters = c.Members.Count(m => m.MeterId is not null); + var types = c.Members.Count(m => m.EnergyTypeId is not null); + return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)"; + } + + private string MemberLabel(CostCategoryMember m) => + m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}" + : m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}" + : "—"; + + private void OpenEdit(CostCategory? category) + { + if (category is null) + { + _working = new EditModel(); + _members = []; + } + else + { + _working = new EditModel { Id = category.Id, Name = category.Name, ColorHex = category.ColorHex, Sort = category.Sort }; + _members = [.. category.Members]; + } + _addMeterId = null; + _addTypeId = null; + _editOpen = true; + } + + private async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(_working.Name)) + { + Snackbar.Add("Name is required.", Severity.Warning); + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (_working.Id == 0) + { + var category = new CostCategory { Name = _working.Name.Trim(), ColorHex = Trim(_working.ColorHex), Sort = _working.Sort }; + db.CostCategories.Add(category); + await db.SaveChangesAsync(); + // Re-open on the new category so members can be added. + Snackbar.Add("Saved. Add members below.", Severity.Success); + await LoadAsync(); + OpenEdit(_categories!.First(c => c.Id == category.Id)); + return; + } + + var existing = await db.CostCategories.FirstAsync(c => c.Id == _working.Id); + existing.Name = _working.Name.Trim(); + existing.ColorHex = Trim(_working.ColorHex); + existing.Sort = _working.Sort; + await db.SaveChangesAsync(); + _editOpen = false; + Snackbar.Add("Saved.", Severity.Success); + await LoadAsync(); + } + + private async Task AddMeterMemberAsync() + { + if (_addMeterId is not { } meterId) + { + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (!await db.CostCategoryMembers.AnyAsync(m => m.CategoryId == _working.Id && m.MeterId == meterId)) + { + db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = _working.Id, MeterId = meterId }); + await db.SaveChangesAsync(); + } + _addMeterId = null; + await ReloadMembersAsync(); + } + + private async Task AddTypeMemberAsync() + { + if (_addTypeId is not { } typeId) + { + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (!await db.CostCategoryMembers.AnyAsync(m => m.CategoryId == _working.Id && m.EnergyTypeId == (short)typeId)) + { + db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = _working.Id, EnergyTypeId = (short)typeId }); + await db.SaveChangesAsync(); + } + _addTypeId = null; + await ReloadMembersAsync(); + } + + private async Task RemoveMemberAsync(int memberId) + { + await using var db = await DbFactory.CreateDbContextAsync(); + await db.CostCategoryMembers.Where(m => m.Id == memberId).ExecuteDeleteAsync(); + await ReloadMembersAsync(); + } + + private async Task ReloadMembersAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _members = await db.CostCategoryMembers.AsNoTracking().Where(m => m.CategoryId == _working.Id).ToListAsync(); + _categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync(); + } + + private async Task DeleteAsync(CostCategory category) + { + if (!await Confirm.DeleteAsync(DialogService, "Delete category", + $"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked.")) + { + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + // Members cascade with the category; manual_cost.category_id is SetNull. + await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync(); + Snackbar.Add("Deleted.", Severity.Success); + await LoadAsync(); + } + + private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed class EditModel + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string? ColorHex { get; set; } + public int Sort { get; set; } + } +} diff --git a/src/App/Components/Pages/Admin/Connectors.razor b/src/App/Components/Pages/Admin/Connectors.razor new file mode 100644 index 0000000..f768a60 --- /dev/null +++ b/src/App/Components/Pages/Admin/Connectors.razor @@ -0,0 +1,248 @@ +@page "/admin/connectors" +@rendermode InteractiveServer +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using Microsoft.EntityFrameworkCore +@using MeterVault.Infrastructure.Ingestion +@using MudBlazor + +MeterVault — Connectors + +
+ Connectors + + Add connector + +
+ + + Secrets are never stored here. Credentials/tokens are referenced by the name of an environment variable + (or Docker secret) resolved at runtime. + + +@if (_endpoints is null) +{ + +} +else +{ + + + Name + Type + Enabled + Last status + Last seen + Actions + + + @context.Name + @context.Type + @(context.IsEnabled ? "yes" : "no") + @(context.LastStatus ?? "—") + @(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") + + + + + + + @if (_endpoints.Count == 0) + { + No connectors yet. Add an MQTT broker or a Home Assistant connection. + } +} + + + + @(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}") + + + + @foreach (var type in Enum.GetValues()) + { + @type + } + + + + @if (_working.Type == EndpointType.HomeAssistant) + { + + + + + Test connection + + @if (_testing) + { + + } + @if (_testResult is not null) + { + @_testResult.Message + } + } + else + { + + + + + + + } + + + + Cancel + Save + + + +@code { + private List? _endpoints; + private bool _editOpen; + private bool _testing; + private HaTestResult? _testResult; + private EditModel _working = new(); + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(); + } + + private void OpenEdit(IngestionEndpoint? endpoint) + { + _testResult = null; + if (endpoint is null) + { + _working = new EditModel(); + } + else if (endpoint.Type == EndpointType.HomeAssistant) + { + var ha = HaEndpointConfig.Parse(endpoint.Config); + _working = new EditModel + { + Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled, + BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv, + }; + } + else + { + var mqtt = EndpointConfig.Parse(endpoint.Config); + _working = new EditModel + { + Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled, + Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls, + UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv, + ExtraTopics = string.Join(", ", mqtt.ExtraTopics), + }; + } + _editOpen = true; + } + + private async Task TestHaAsync() + { + _testing = true; + _testResult = null; + try + { + _testResult = await HaTester.TestAsync(_working.BaseUrl, _working.TokenEnv, _working.TestEntityId); + } + finally + { + _testing = false; + } + } + + private async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(_working.Name)) + { + Snackbar.Add("Name is required.", Severity.Warning); + return; + } + + var config = _working.Type == EndpointType.HomeAssistant + ? new HaEndpointConfig { BaseUrl = Trim(_working.BaseUrl), TokenEnv = Trim(_working.TokenEnv) }.ToJson() + : new EndpointConfig + { + Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(), + Port = _working.Port, + Tls = _working.Tls, + UsernameEnv = Trim(_working.UsernameEnv), + PasswordEnv = Trim(_working.PasswordEnv), + ExtraTopics = SplitTopics(_working.ExtraTopics), + }.ToJson(); + + await using var db = await DbFactory.CreateDbContextAsync(); + if (_working.Id == 0) + { + db.IngestionEndpoints.Add(new IngestionEndpoint + { + Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled, + }); + } + else + { + var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id); + existing.Type = _working.Type; + existing.Name = _working.Name.Trim(); + existing.Config = config; + existing.IsEnabled = _working.IsEnabled; + } + + await db.SaveChangesAsync(); + _editOpen = false; + Snackbar.Add("Saved.", Severity.Success); + await LoadAsync(); + } + + private async Task DeleteAsync(IngestionEndpoint endpoint) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id); + var note = sourceCount > 0 ? $" {sourceCount} source(s) reference it and will be unlinked." : ""; + if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}")) + { + return; + } + + await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync(); + Snackbar.Add("Deleted.", Severity.Success); + await LoadAsync(); + } + + private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static IReadOnlyList SplitTopics(string? csv) => + string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; + + private sealed class EditModel + { + public int Id { get; set; } + public EndpointType Type { get; set; } = EndpointType.HomeAssistant; + public string Name { get; set; } = ""; + public bool IsEnabled { get; set; } = true; + + // Home Assistant + public string? BaseUrl { get; set; } + public string? TokenEnv { get; set; } + public string? TestEntityId { get; set; } + + // MQTT broker + public string? Host { get; set; } = "localhost"; + public int Port { get; set; } = 1883; + public bool Tls { get; set; } + public string? UsernameEnv { get; set; } + public string? PasswordEnv { get; set; } + public string? ExtraTopics { get; set; } + } +} diff --git a/src/App/Components/Pages/Admin/EnergyTypes.razor b/src/App/Components/Pages/Admin/EnergyTypes.razor index a2995e2..4dee006 100644 --- a/src/App/Components/Pages/Admin/EnergyTypes.razor +++ b/src/App/Components/Pages/Admin/EnergyTypes.razor @@ -1,11 +1,19 @@ @page "/admin/energy-types" @rendermode InteractiveServer @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject ISnackbar Snackbar +@inject IDialogService DialogService @using Microsoft.EntityFrameworkCore +@using MudBlazor MeterVault — Energy types -Energy types +
+ Energy types + + Add energy type + +
@if (_types is null) { @@ -19,22 +27,159 @@ else Display name Base unit Default mode + Actions @context.Key - @context.DisplayName + + @if (!string.IsNullOrWhiteSpace(context.ColorHex)) + { + + } + @context.DisplayName + @context.BaseUnit @context.DefaultMode + + + + } + + + @(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}") + + + + + + + @foreach (var mode in Enum.GetValues()) + { + @mode + } + + + + + + Cancel + Save + + + @code { private List? _types; + private bool _editOpen; + private EditModel _working = new(); + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() { await using var db = await DbFactory.CreateDbContextAsync(); _types = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync(); } + + private void OpenEdit(EnergyType? type) + { + _working = type is null + ? new EditModel() + : new EditModel + { + Id = type.Id, + Key = type.Key, + DisplayName = type.DisplayName, + BaseUnit = type.BaseUnit, + Mode = type.DefaultMode, + Icon = type.Icon, + ColorHex = type.ColorHex, + }; + _editOpen = true; + } + + private async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit)) + { + Snackbar.Add("Key, display name and base unit are required.", Severity.Warning); + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id)) + { + Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error); + return; + } + + if (_working.Id == 0) + { + db.EnergyTypes.Add(new EnergyType + { + Key = _working.Key.Trim(), + DisplayName = _working.DisplayName.Trim(), + BaseUnit = _working.BaseUnit.Trim(), + DefaultMode = _working.Mode, + Icon = string.IsNullOrWhiteSpace(_working.Icon) ? null : _working.Icon, + ColorHex = string.IsNullOrWhiteSpace(_working.ColorHex) ? null : _working.ColorHex, + }); + } + else + { + var existing = await db.EnergyTypes.FirstAsync(t => t.Id == _working.Id); + existing.Key = _working.Key.Trim(); + existing.DisplayName = _working.DisplayName.Trim(); + existing.BaseUnit = _working.BaseUnit.Trim(); + existing.DefaultMode = _working.Mode; + existing.Icon = string.IsNullOrWhiteSpace(_working.Icon) ? null : _working.Icon; + existing.ColorHex = string.IsNullOrWhiteSpace(_working.ColorHex) ? null : _working.ColorHex; + } + + await db.SaveChangesAsync(); + _editOpen = false; + Snackbar.Add("Saved.", Severity.Success); + await LoadAsync(); + } + + private async Task DeleteAsync(EnergyType type) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id); + if (meterCount > 0) + { + Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error); + return; + } + + if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone.")) + { + return; + } + + var target = await db.EnergyTypes.FirstOrDefaultAsync(t => t.Id == type.Id); + if (target is not null) + { + db.EnergyTypes.Remove(target); + await db.SaveChangesAsync(); + Snackbar.Add("Deleted.", Severity.Success); + } + + await LoadAsync(); + } + + private sealed class EditModel + { + public short Id { get; set; } + public string Key { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string BaseUnit { get; set; } = ""; + public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter; + public string? Icon { get; set; } + public string? ColorHex { get; set; } + } } diff --git a/src/App/Components/Pages/Admin/Settings.razor b/src/App/Components/Pages/Admin/Settings.razor new file mode 100644 index 0000000..f8b5158 --- /dev/null +++ b/src/App/Components/Pages/Admin/Settings.razor @@ -0,0 +1,73 @@ +@page "/admin/settings" +@rendermode InteractiveServer +@inject Microsoft.Extensions.Options.IOptions Options +@using MudBlazor + +MeterVault — Settings + +Settings + + These are the effective settings the running instance is using. They are configured via environment + variables (MeterVault__Key / Section__Key) or Docker/compose, not stored in the + database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart. + + + + + + Locale & time + + + Timezone@_o.TimeZone + Locale@_o.Locale + Currency@_o.Currency + Raw-reading retention@_o.RawRetentionDays days + + + + Env keys: MeterVault__TimeZone, MeterVault__Locale, + MeterVault__Currency, MeterVault__RawRetentionDays. + + + + + + + Access & ingestion + + + + REST API + + @if (_o.ApiKeys.Count > 0) + { + @_o.ApiKeys.Count key(s) configured + } + else if (_o.AllowAnonymousApi) + { + open (anonymous) + } + else + { + closed (401) + } + + + Reverse-proxy trust@(_o.ReverseProxyTrust ? "on" : "off") + Live ingestion workers@(_o.EnableLiveIngestion ? "on" : "off") + Seed reference data on start@(_o.SeedReferenceData ? "on" : "off") + + + + Set API keys with MeterVault__ApiKeys__0. Keys themselves are never shown here. + API docs at /swagger. + + + + + +@code { + private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new(); + + protected override void OnInitialized() => _o = Options.Value; +} diff --git a/src/App/Components/Pages/Admin/Tariffs.razor b/src/App/Components/Pages/Admin/Tariffs.razor index bd40bd8..089458f 100644 --- a/src/App/Components/Pages/Admin/Tariffs.razor +++ b/src/App/Components/Pages/Admin/Tariffs.razor @@ -1,20 +1,24 @@ @page "/admin/tariffs" @rendermode InteractiveServer @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject ISnackbar Snackbar +@inject IDialogService DialogService @using Microsoft.EntityFrameworkCore +@using MudBlazor MeterVault — Tariffs -Tariffs +
+ Tariffs + + Add tariff + +
@if (_tariffs is null) { } -else if (_tariffs.Count == 0) -{ - No tariffs yet. Load the reference data from Import. -} else { @@ -25,26 +29,203 @@ else Unit Valid from Valid to + Actions - @context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "") + @ScopeLabel(context) @context.Component @Format.Number(context.Value, 4) @context.Unit @context.ValidFrom.ToString("yyyy-MM-dd") @(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open") + + + + + @if (_tariffs.Count == 0) + { + No tariffs yet. Add one, or load the reference data from Import. + } } + + + @(_working.Id == 0 ? "New tariff" : "Edit tariff") + + + + @foreach (var scope in Enum.GetValues()) + { + @scope + } + + @if (_working.ScopeType == TariffScope.EnergyType) + { + + @foreach (var t in _energyTypes) + { + @t.DisplayName + } + + } + else if (_working.ScopeType == TariffScope.Meter) + { + + @foreach (var m in _meters) + { + @m.Name + } + + } + + @foreach (var component in Enum.GetValues()) + { + @component + } + + + + + + + + + + Cancel + Save + + + @code { private List? _tariffs; + private List _energyTypes = []; + private List _meters = []; + private bool _editOpen; + private EditModel _working = new(); + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() { await using var db = await DbFactory.CreateDbContextAsync(); - _tariffs = await db.Tariffs.AsNoTracking() - .OrderBy(t => t.Component).ThenBy(t => t.ValidFrom) - .ToListAsync(); + _tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync(); + _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync(); + _meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync(); + } + + private string ScopeLabel(Tariff t) => t.ScopeType switch + { + TariffScope.Global => "Global", + TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}", + TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}", + _ => t.ScopeType.ToString(), + }; + + private void OpenEdit(Tariff? tariff) + { + _working = tariff is null + ? new EditModel { ValidFrom = DateTime.Today } + : new EditModel + { + Id = tariff.Id, + ScopeType = tariff.ScopeType, + ScopeId = tariff.ScopeId, + Component = tariff.Component, + Value = tariff.Value, + Unit = tariff.Unit, + Currency = tariff.Currency, + ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue), + ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue), + Notes = tariff.Notes, + }; + _editOpen = true; + } + + private async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null) + { + Snackbar.Add("Unit and valid-from are required.", Severity.Warning); + return; + } + + if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null) + { + Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning); + return; + } + + var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId; + + await using var db = await DbFactory.CreateDbContextAsync(); + if (_working.Id == 0) + { + db.Tariffs.Add(new Tariff + { + ScopeType = _working.ScopeType, + ScopeId = scopeId, + Component = _working.Component, + Value = _working.Value, + Unit = _working.Unit.Trim(), + Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(), + ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value), + ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null, + Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes, + }); + } + else + { + var existing = await db.Tariffs.FirstAsync(t => t.Id == _working.Id); + existing.ScopeType = _working.ScopeType; + existing.ScopeId = scopeId; + existing.Component = _working.Component; + existing.Value = _working.Value; + existing.Unit = _working.Unit.Trim(); + existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(); + existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value); + existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null; + existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes; + } + + await db.SaveChangesAsync(); + _editOpen = false; + Snackbar.Add("Saved.", Severity.Success); + await LoadAsync(); + } + + private async Task DeleteAsync(Tariff tariff) + { + if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?")) + { + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + var target = await db.Tariffs.FirstOrDefaultAsync(t => t.Id == tariff.Id); + if (target is not null) + { + db.Tariffs.Remove(target); + await db.SaveChangesAsync(); + Snackbar.Add("Deleted.", Severity.Success); + } + + await LoadAsync(); + } + + private sealed class EditModel + { + public int Id { get; set; } + public TariffScope ScopeType { get; set; } = TariffScope.EnergyType; + public int? ScopeId { get; set; } + public TariffComponent Component { get; set; } = TariffComponent.UnitPrice; + public double Value { get; set; } + public string Unit { get; set; } = "EUR/kWh"; + public string Currency { get; set; } = "EUR"; + public DateTime? ValidFrom { get; set; } + public DateTime? ValidTo { get; set; } + public string? Notes { get; set; } } } diff --git a/src/App/Components/Pages/MeterDetail.razor b/src/App/Components/Pages/MeterDetail.razor index 44b8e7b..4e9b466 100644 --- a/src/App/Components/Pages/MeterDetail.razor +++ b/src/App/Components/Pages/MeterDetail.razor @@ -1,7 +1,12 @@ @page "/meters/{Id:int}" @rendermode InteractiveServer @inject MeterDetailService Details +@inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory +@inject ISnackbar Snackbar +@inject IDialogService DialogService @inject NavigationManager Nav +@using Microsoft.EntityFrameworkCore +@using MeterVault.Infrastructure.Ingestion @using MudBlazor MeterVault — Meter @@ -169,24 +174,34 @@ else } - - @if (_detail.Sources.Count == 0) + +
+ + Add source + +
+ @if (_sources.Count == 0) { - No ingest sources bound to this meter. + No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant. } else { - TypeEnabledLast seenLast valueStatus + TypeTargetEnabledLast seenLast valueStatusActions - @foreach (var s in _detail.Sources) + @foreach (var s in _sources) { - @s.Type + @s.SourceType + @SourceTarget(s) @(s.IsEnabled ? "yes" : "no") @(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") @(s.LastValue is { } v ? Format.Number(v, 2) : "—") @(s.LastStatus ?? "—") + + + + } @@ -194,6 +209,57 @@ else }
+ + + + @(_sourceEdit.Id == 0 ? "New source" : "Edit source") + + + + @foreach (var type in Enum.GetValues()) + { + @type + } + + @if (_sourceEdit.SourceType is SourceType.HomeAssistant or SourceType.Mqtt or SourceType.Tasmota) + { + + @foreach (var e in _endpoints) + { + @e.Name (@e.Type) + } + + } + @if (_sourceEdit.SourceType == SourceType.HomeAssistant) + { + + + + } + else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota) + { + + + + } + + @foreach (var kind in Enum.GetValues()) + { + @kind + } + +
+ + + +
+ +
+ + Cancel + Save + +
} @code { @@ -202,6 +268,11 @@ else private MeterDetailView? _detail; private bool _notFound; + private List _sources = []; + private List _endpoints = []; + private bool _sourceOpen; + private SourceEdit _sourceEdit = new(); + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; protected override async Task OnParametersSetAsync() { @@ -209,6 +280,137 @@ else _notFound = false; _detail = await Details.GetAsync(Id); _notFound = _detail is null; + if (_detail is not null) + { + await LoadSourcesAsync(); + } + } + + private async Task LoadSourcesAsync() + { + await using var db = await DbFactory.CreateDbContextAsync(); + _sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == Id).OrderBy(s => s.Priority).ToListAsync(); + _endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(); + } + + private static string SourceTarget(MeterSource s) + { + var config = SourceConfig.Parse(s.Config); + return s.SourceType == SourceType.HomeAssistant + ? config.EntityId ?? "—" + : config.Topic ?? "—"; + } + + private void OpenSource(MeterSource? source) + { + if (source is null) + { + _sourceEdit = new SourceEdit(); + } + else + { + var config = SourceConfig.Parse(source.Config); + _sourceEdit = new SourceEdit + { + Id = source.Id, + SourceType = source.SourceType, + EndpointId = source.EndpointId, + ValueKind = source.ValueKind, + Scale = source.Scale, + Offset = source.Offset, + Priority = source.Priority, + IsEnabled = source.IsEnabled, + EntityId = config.EntityId, + Attribute = config.Attribute, + PollSeconds = config.PollSeconds, + Topic = config.Topic, + Path = config.Path, + TimePath = config.TimePath, + }; + } + _sourceOpen = true; + } + + private async Task SaveSourceAsync() + { + var config = new SourceConfig + { + EntityId = Trim(_sourceEdit.EntityId), + Attribute = Trim(_sourceEdit.Attribute), + PollSeconds = _sourceEdit.PollSeconds, + Topic = Trim(_sourceEdit.Topic), + Path = Trim(_sourceEdit.Path), + TimePath = Trim(_sourceEdit.TimePath), + }; + var configJson = System.Text.Json.JsonSerializer.Serialize(config, + new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); + + await using var db = await DbFactory.CreateDbContextAsync(); + if (_sourceEdit.Id == 0) + { + db.MeterSources.Add(new MeterSource + { + MeterId = Id, + SourceType = _sourceEdit.SourceType, + EndpointId = _sourceEdit.EndpointId, + Config = configJson, + ValueKind = _sourceEdit.ValueKind, + Scale = _sourceEdit.Scale, + Offset = _sourceEdit.Offset, + Priority = _sourceEdit.Priority, + IsEnabled = _sourceEdit.IsEnabled, + }); + } + else + { + var existing = await db.MeterSources.FirstAsync(s => s.Id == _sourceEdit.Id); + existing.SourceType = _sourceEdit.SourceType; + existing.EndpointId = _sourceEdit.EndpointId; + existing.Config = configJson; + existing.ValueKind = _sourceEdit.ValueKind; + existing.Scale = _sourceEdit.Scale; + existing.Offset = _sourceEdit.Offset; + existing.Priority = _sourceEdit.Priority; + existing.IsEnabled = _sourceEdit.IsEnabled; + } + + await db.SaveChangesAsync(); + _sourceOpen = false; + Snackbar.Add("Source saved.", Severity.Success); + await LoadSourcesAsync(); + } + + private async Task DeleteSourceAsync(MeterSource source) + { + if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?")) + { + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync(); + Snackbar.Add("Source deleted.", Severity.Success); + await LoadSourcesAsync(); + } + + private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed class SourceEdit + { + public int Id { get; set; } + public SourceType SourceType { get; set; } = SourceType.HomeAssistant; + public int? EndpointId { get; set; } + public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register; + public double Scale { get; set; } = 1; + public double Offset { get; set; } + public int Priority { get; set; } + public bool IsEnabled { get; set; } = true; + public string? EntityId { get; set; } + public string? Attribute { get; set; } + public int? PollSeconds { get; set; } = 60; + public string? Topic { get; set; } + public string? Path { get; set; } + public string? TimePath { get; set; } } private static RenderFragment QualityChip(ReadingQuality quality) =>@ DbFactory +@inject MeterVault.Core.Normalization.INormalizationEngine Engine +@inject ISnackbar Snackbar +@inject IDialogService DialogService @using Microsoft.EntityFrameworkCore +@using MudBlazor MeterVault — Meters -Meters +
+ Meters + + Add meter + +
@if (_meters is null) { @@ -21,6 +30,8 @@ else Unit Sources Last seen + Active + Actions @context.Name @@ -30,31 +41,83 @@ else @context.Sources.Count @{ - var lastSeen = context.Sources - .Where(s => s.LastSeenAt != null) - .Select(s => s.LastSeenAt) - .DefaultIfEmpty(null) - .Max(); + var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max(); } @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—") + @(context.IsActive ? "yes" : "no") + + + + @if (_meters.Count == 0) { - No meters yet. Go to Import to load the reference data. + No meters yet. Add one, or go to Import to load the reference data. } } + + + @(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}") + + + + + @foreach (var t in _energyTypes) + { + @t.DisplayName + } + + + @foreach (var mode in Enum.GetValues()) + { + @mode + } + + + + + — none — + total_load + grid_import + grid_export + + + +
+ + +
+ + @if (_working.Id != 0 && _working.RecomputeNeeded) + { + Mode/baseline changed — consumption will be recomputed on save. + } +
+ + Cancel + Save + +
+ @code { private List? _meters; + private List _energyTypes = []; + private bool _editOpen; + private EditModel _working = new(); + private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() { await using var db = await DbFactory.CreateDbContextAsync(); + _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync(); _meters = await db.Meters .AsNoTracking() .Include(m => m.EnergyType) @@ -62,4 +125,140 @@ else .OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name) .ToListAsync(); } + + private void OpenEdit(Meter? meter) + { + if (meter is null) + { + _working = new EditModel { EnergyTypeId = _energyTypes.FirstOrDefault()?.Id ?? 0 }; + } + else + { + _working = new EditModel + { + Id = meter.Id, + Name = meter.Name, + EnergyTypeId = meter.EnergyTypeId, + Mode = meter.Mode, + OriginalMode = meter.Mode, + Unit = meter.Unit, + InitialBaseline = meter.InitialBaseline, + OriginalBaseline = meter.InitialBaseline, + Role = MeterMeta.Role(meter.Meta) ?? "", + Location = meter.Location, + SerialNumber = meter.SerialNumber, + Manufacturer = meter.Manufacturer, + Model = meter.Model, + IsActive = meter.IsActive, + }; + } + _editOpen = true; + } + + private async Task SaveAsync() + { + if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0) + { + Snackbar.Add("Name, energy type and unit are required.", Severity.Warning); + return; + } + + await using var db = await DbFactory.CreateDbContextAsync(); + if (_working.Id == 0) + { + db.Meters.Add(new Meter + { + Name = _working.Name.Trim(), + EnergyTypeId = _working.EnergyTypeId, + Mode = _working.Mode, + Unit = _working.Unit.Trim(), + InitialBaseline = _working.InitialBaseline, + Meta = MeterMeta.SetRole("{}", _working.Role), + Location = Trim(_working.Location), + SerialNumber = Trim(_working.SerialNumber), + Manufacturer = Trim(_working.Manufacturer), + Model = Trim(_working.Model), + IsActive = _working.IsActive, + }); + await db.SaveChangesAsync(); + } + else + { + await using var tx = await db.Database.BeginTransactionAsync(); + var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id); + existing.Name = _working.Name.Trim(); + existing.EnergyTypeId = _working.EnergyTypeId; + existing.Mode = _working.Mode; + existing.Unit = _working.Unit.Trim(); + existing.InitialBaseline = _working.InitialBaseline; + existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role); + existing.Location = Trim(_working.Location); + existing.SerialNumber = Trim(_working.SerialNumber); + existing.Manufacturer = Trim(_working.Manufacturer); + existing.Model = Trim(_working.Model); + existing.IsActive = _working.IsActive; + existing.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + if (_working.RecomputeNeeded) + { + var normalization = new MeterVault.Infrastructure.Normalization.NormalizationService(db, Engine); + await normalization.RecomputeMeterAsync(existing.Id, null); + await db.SaveChangesAsync(); + } + + await tx.CommitAsync(); + } + + _editOpen = false; + Snackbar.Add("Saved.", Severity.Success); + await LoadAsync(); + } + + private async Task DeleteAsync(Meter meter) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id); + var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id); + var detail = readings + consumption > 0 + ? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)." + : ""; + + if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone.")) + { + return; + } + + await using var tx = await db.Database.BeginTransactionAsync(); + // reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade. + await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync(); + await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync(); + await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync(); + await tx.CommitAsync(); + + Snackbar.Add("Deleted.", Severity.Success); + await LoadAsync(); + } + + private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private sealed class EditModel + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public short EnergyTypeId { get; set; } + public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter; + public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter; + public string Unit { get; set; } = ""; + public double InitialBaseline { get; set; } + public double OriginalBaseline { get; set; } + public string Role { get; set; } = ""; + public string? Location { get; set; } + public string? SerialNumber { get; set; } + public string? Manufacturer { get; set; } + public string? Model { get; set; } + public bool IsActive { get; set; } = true; + + public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9; + } } diff --git a/src/App/Confirm.cs b/src/App/Confirm.cs new file mode 100644 index 0000000..5d5ddcf --- /dev/null +++ b/src/App/Confirm.cs @@ -0,0 +1,14 @@ +using MudBlazor; + +namespace MeterVault.App; + +/// Small wrapper over MudBlazor's message box for delete/confirm prompts in admin pages. +public static class Confirm +{ + public static async Task DeleteAsync(IDialogService dialog, string title, string message) + { + var result = await dialog.ShowMessageBoxAsync(title, message, yesText: "Delete", cancelText: "Cancel") + .ConfigureAwait(false); + return result == true; + } +} diff --git a/src/Core/Domain/MeterMeta.cs b/src/Core/Domain/MeterMeta.cs index 00b9361..317d8ba 100644 --- a/src/Core/Domain/MeterMeta.cs +++ b/src/Core/Domain/MeterMeta.cs @@ -52,10 +52,21 @@ public static class MeterMeta } /// Returns with role set to . - public static string WithRole(string? meta, string role) + public static string WithRole(string? meta, string role) => SetRole(meta, role); + + /// Returns with role set, or removed when null/empty. + public static string SetRole(string? meta, string? role) { var map = ToMap(meta); - map["role"] = role; + if (string.IsNullOrWhiteSpace(role)) + { + map.Remove("role"); + } + else + { + map["role"] = role; + } + return JsonSerializer.Serialize(map); } diff --git a/src/Infrastructure/Dashboard/MeterDetailModels.cs b/src/Infrastructure/Dashboard/MeterDetailModels.cs index c86f38b..654340a 100644 --- a/src/Infrastructure/Dashboard/MeterDetailModels.cs +++ b/src/Infrastructure/Dashboard/MeterDetailModels.cs @@ -14,13 +14,11 @@ public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? /// A tariff applicable to the meter (own / energy-type / global scope), for the timeline. public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo); -/// Source binding + live status (last-seen / last value / last status). -public sealed record SourceRow(SourceType Type, bool IsEnabled, DateTimeOffset? LastSeenAt, double? LastValue, string? LastStatus, string Config); - /// /// 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. /// public sealed record MeterDetailView( int Id, @@ -46,4 +44,4 @@ public sealed record MeterDetailView( IReadOnlyList RecentConsumption, IReadOnlyList Events, IReadOnlyList Tariffs, - IReadOnlyList Sources); + int SourceCount); diff --git a/src/Infrastructure/Dashboard/MeterDetailService.cs b/src/Infrastructure/Dashboard/MeterDetailService.cs index faffbc1..7d092d6 100644 --- a/src/Infrastructure/Dashboard/MeterDetailService.cs +++ b/src/Infrastructure/Dashboard/MeterDetailService.cs @@ -72,17 +72,12 @@ public sealed class MeterDetailService(IDbContextFactory 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); } } diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index eae30de..315d7d2 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -33,6 +33,10 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + // 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(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/Ingestion/EndpointConfig.cs b/src/Infrastructure/Ingestion/EndpointConfig.cs index 03dd666..3f0a3ef 100644 --- a/src/Infrastructure/Ingestion/EndpointConfig.cs +++ b/src/Infrastructure/Ingestion/EndpointConfig.cs @@ -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); diff --git a/src/Infrastructure/Ingestion/HaConnectionTester.cs b/src/Infrastructure/Ingestion/HaConnectionTester.cs new file mode 100644 index 0000000..ffe7f56 --- /dev/null +++ b/src/Infrastructure/Ingestion/HaConnectionTester.cs @@ -0,0 +1,67 @@ +using System.Net.Http.Headers; +using Microsoft.Extensions.Logging; + +namespace MeterVault.Infrastructure.Ingestion; + +/// Outcome of a Home Assistant connectivity test. +public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null); + +/// +/// Verifies a Home Assistant connection from the admin UI: checks the base URL + resolved 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). Token is resolved by reference (env var). +/// +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? 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}"); + } + } +} diff --git a/src/Infrastructure/Ingestion/HaEndpointConfig.cs b/src/Infrastructure/Ingestion/HaEndpointConfig.cs new file mode 100644 index 0000000..8ef9a6b --- /dev/null +++ b/src/Infrastructure/Ingestion/HaEndpointConfig.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MeterVault.Infrastructure.Ingestion; + +/// +/// The parsed JSON for a Home Assistant +/// connection (SDD §6.2). The long-lived token is stored by reference only: +/// names an environment variable resolved at runtime — never the token itself (SDD §6.4). +/// +public sealed record HaEndpointConfig +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + /// Base URL of the Home Assistant instance, e.g. http://homeassistant.local:8123. + public string? BaseUrl { get; init; } + + /// Name of the environment variable holding the long-lived access token. + public string? TokenEnv { get; init; } + + public static HaEndpointConfig Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new HaEndpointConfig(); + } + + try + { + return JsonSerializer.Deserialize(json, Options) ?? new HaEndpointConfig(); + } + catch (JsonException) + { + return new HaEndpointConfig(); + } + } + + public string ToJson() => JsonSerializer.Serialize(this, Options); + + /// Resolves the token from the referenced environment variable (null if unset). + public string? ResolveToken() => + string.IsNullOrWhiteSpace(TokenEnv) ? null : Environment.GetEnvironmentVariable(TokenEnv); +} diff --git a/src/Infrastructure/Ingestion/HomeAssistantWorker.cs b/src/Infrastructure/Ingestion/HomeAssistantWorker.cs index 02b1395..4dffd34 100644 --- a/src/Infrastructure/Ingestion/HomeAssistantWorker.cs +++ b/src/Infrastructure/Ingestion/HomeAssistantWorker.cs @@ -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(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; } - } } diff --git a/tests/Core.Tests/MeterMetaTests.cs b/tests/Core.Tests/MeterMetaTests.cs index c23257f..075f934 100644 --- a/tests/Core.Tests/MeterMetaTests.cs +++ b/tests/Core.Tests/MeterMetaTests.cs @@ -46,4 +46,16 @@ public sealed class MeterMetaTests Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(updated)); } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetRole_removes_role_when_blank_and_keeps_other_keys(string? role) + { + var updated = MeterMeta.SetRole("{\"role\":\"grid_import\",\"expression\":\"a-b\"}", role); + + Assert.Null(MeterMeta.Role(updated)); + Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression")); + } } diff --git a/tests/Integration.Tests/DashboardRenderTests.cs b/tests/Integration.Tests/DashboardRenderTests.cs index 8cd5258..4a78c59 100644 --- a/tests/Integration.Tests/DashboardRenderTests.cs +++ b/tests/Integration.Tests/DashboardRenderTests.cs @@ -89,7 +89,8 @@ public sealed class DashboardRenderTests(TimescaleFixture fx) foreach (var path in new[] { "/meters", "/trends", "/solar", "/consumables", "/import", - "/admin/tariffs", "/admin/energy-types", $"/meters/{hausId}", + "/admin/tariffs", "/admin/energy-types", "/admin/categories", + "/admin/connectors", "/admin/settings", $"/meters/{hausId}", }) { var response = await client.GetAsync(new Uri(path, UriKind.Relative)); diff --git a/tests/Integration.Tests/Ingestion/HaConnectionTesterTests.cs b/tests/Integration.Tests/Ingestion/HaConnectionTesterTests.cs new file mode 100644 index 0000000..877d6ed --- /dev/null +++ b/tests/Integration.Tests/Ingestion/HaConnectionTesterTests.cs @@ -0,0 +1,49 @@ +using MeterVault.Infrastructure.Ingestion; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MeterVault.Integration.Tests.Ingestion; + +/// +/// The HA connection tester must fail closed on missing config before any network call — +/// so a misconfigured connector gives a clear message, never an exception. The stub factory throws +/// if the tester ever tries to create an HttpClient, proving these branches never reach the network. +/// +public sealed class HaConnectionTesterTests +{ + [Fact] + public async Task Missing_base_url_fails_without_network() + { + var result = await NewTester().TestAsync(baseUrl: "", tokenEnv: "SOME_TOKEN", entityId: null); + + Assert.False(result.Ok); + Assert.Contains("Base URL", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Missing_token_env_fails_without_network() + { + var result = await NewTester().TestAsync(baseUrl: "http://ha.local:8123", tokenEnv: "", entityId: null); + + Assert.False(result.Ok); + Assert.Contains("env-var", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Unset_token_env_var_fails_without_network() + { + var result = await NewTester().TestAsync( + baseUrl: "http://ha.local:8123", tokenEnv: "METERVAULT_DEFINITELY_UNSET_TOKEN_VAR", entityId: null); + + Assert.False(result.Ok); + Assert.Contains("is not set", result.Message, StringComparison.OrdinalIgnoreCase); + } + + private static HaConnectionTester NewTester() => + new(new ThrowingHttpClientFactory(), NullLogger.Instance); + + private sealed class ThrowingHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => + throw new InvalidOperationException("Network must not be touched for a config-guard failure."); + } +}