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
+1 -1
View File
@@ -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 (M0M7) + 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 (M0M7) + 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
+3
View File
@@ -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.
@@ -8,6 +8,8 @@
<MudLayout>
<MudAppBar Elevation="1" Dense="true">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="Toggle navigation" />
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
<MudText Typo="Typo.h6">MeterVault</MudText>
<MudSpacer />
+3
View File
@@ -9,5 +9,8 @@
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink>
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink>
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
</MudNavGroup>
</MudNavMenu>
@@ -0,0 +1,261 @@
@page "/admin/categories"
@rendermode InteractiveServer
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Cost categories</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Cost categories</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add category
</MudButton>
</div>
@if (_categories is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else
{
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Sort</MudTh>
<MudTh>Members</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
{
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
}
@context.Name
</MudTd>
<MudTd DataLabel="Sort">@context.Sort</MudTd>
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd>
</RowTemplate>
</MudTable>
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" />
@if (_working.Id != 0)
{
<MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText>
@if (_members.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText>
}
else
{
<MudList T="string" Dense="true">
@foreach (var m in _members)
{
<MudListItem T="string">
<div class="d-flex align-center justify-space-between">
<span>@MemberLabel(m)</span>
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small" OnClick="@(() => RemoveMemberAsync(m.Id))" />
</div>
</MudListItem>
}
</MudList>
}
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
<MudSelect T="int?" @bind-Value="_addMeterId" Label="Add meter" Dense="true" Style="min-width:180px">
@foreach (var meter in _meters)
{
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
}
</MudSelect>
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton>
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
}
</MudSelect>
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton>
</div>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<CostCategory>? _categories;
private List<Meter> _meters = [];
private List<EnergyType> _energyTypes = [];
private List<CostCategoryMember> _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; }
}
}
@@ -0,0 +1,248 @@
@page "/admin/connectors"
@rendermode InteractiveServer
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Ingestion
@using MudBlazor
<PageTitle>MeterVault — Connectors</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Connectors</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add connector
</MudButton>
</div>
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
Secrets are never stored here. Credentials/tokens are referenced by the <b>name of an environment variable</b>
(or Docker secret) resolved at runtime.
</MudAlert>
@if (_endpoints is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else
{
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Type</MudTh>
<MudTh>Enabled</MudTh>
<MudTh>Last status</MudTh>
<MudTh>Last seen</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Type">@context.Type</MudTd>
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd>
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd>
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd>
</RowTemplate>
</MudTable>
@if (_endpoints.Count == 0)
{
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert>
}
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
@foreach (var type in Enum.GetValues<EndpointType>())
{
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
@if (_working.Type == EndpointType.HomeAssistant)
{
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-2" />
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
Test connection
</MudButton>
@if (_testing)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-2" />
}
@if (_testResult is not null)
{
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
}
}
else
{
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
}
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<IngestionEndpoint>? _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<string> 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; }
}
}
@@ -1,11 +1,19 @@
@page "/admin/energy-types"
@rendermode InteractiveServer
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Energy types</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Energy types</MudText>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Energy types</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add energy type
</MudButton>
</div>
@if (_types is null)
{
@@ -19,22 +27,159 @@ else
<MudTh>Display name</MudTh>
<MudTh>Base unit</MudTh>
<MudTh>Default mode</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Key">@context.Key</MudTd>
<MudTd DataLabel="Display name">@context.DisplayName</MudTd>
<MudTd DataLabel="Display name">
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
{
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
}
@context.DisplayName
</MudTd>
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd>
</RowTemplate>
</MudTable>
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" />
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>())
{
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<EnergyType>? _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; }
}
}
@@ -0,0 +1,73 @@
@page "/admin/settings"
@rendermode InteractiveServer
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@using MudBlazor
<PageTitle>MeterVault — Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText>
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
These are the <b>effective</b> settings the running instance is using. They are configured via environment
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) 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.
</MudAlert>
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Locale &amp; time</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
<tr><td>Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
<tr><td>Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
<tr><td>Raw-reading retention</td><td style="text-align:right">@_o.RawRetentionDays days</td></tr>
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Access &amp; ingestion</MudText>
<MudSimpleTable Dense="true">
<tbody>
<tr>
<td>REST API</td>
<td style="text-align:right">
@if (_o.ApiKeys.Count > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip>
}
else if (_o.AllowAnonymousApi)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip>
}
</td>
</tr>
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr>
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr>
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr>
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Set API keys with <code>MeterVault__ApiKeys__0</code>. Keys themselves are never shown here.
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
</MudText>
</MudPaper>
</MudItem>
</MudGrid>
@code {
private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new();
protected override void OnInitialized() => _o = Options.Value;
}
+191 -10
View File
@@ -1,20 +1,24 @@
@page "/admin/tariffs"
@rendermode InteractiveServer
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Tariffs</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Tariffs</MudText>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Tariffs</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add tariff
</MudButton>
</div>
@if (_tariffs is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_tariffs.Count == 0)
{
<MudAlert Severity="Severity.Info">No tariffs yet. Load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
}
else
{
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
@@ -25,26 +29,203 @@ else
<MudTh>Unit</MudTh>
<MudTh>Valid from</MudTh>
<MudTh>Valid to</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Scope">@context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "")</MudTd>
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd>
<MudTd DataLabel="Component">@context.Component</MudTd>
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd>
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd>
</RowTemplate>
</MudTable>
@if (_tariffs.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">No tariffs yet. Add one, or load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
}
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
@foreach (var scope in Enum.GetValues<TariffScope>())
{
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
}
</MudSelect>
@if (_working.ScopeType == TariffScope.EnergyType)
{
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
}
</MudSelect>
}
else if (_working.ScopeType == TariffScope.Meter)
{
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2">
@foreach (var m in _meters)
{
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
}
</MudSelect>
}
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2">
@foreach (var component in Enum.GetValues<TariffComponent>())
{
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem>
}
</MudSelect>
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" />
<MudTextField @bind-Value="_working.Unit" Label="Unit (e.g. EUR/kWh, EUR/m3, EUR/month)" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<Tariff>? _tariffs;
private List<EnergyType> _energyTypes = [];
private List<Meter> _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; }
}
}
+208 -6
View File
@@ -1,7 +1,12 @@
@page "/meters/{Id:int}"
@rendermode InteractiveServer
@inject MeterDetailService Details
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav
@using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Ingestion
@using MudBlazor
<PageTitle>MeterVault — Meter</PageTitle>
@@ -169,24 +174,34 @@ else
}
</MudTabPanel>
<MudTabPanel Text="@($"Sources ({_detail.Sources.Count})")">
@if (_detail.Sources.Count == 0)
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
<div class="d-flex justify-end mb-2">
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
Add source
</MudButton>
</div>
@if (_sources.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter.</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Type</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th></tr></thead>
<thead><tr><th>Type</th><th>Target</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
<tbody>
@foreach (var s in _detail.Sources)
@foreach (var s in _sources)
{
<tr>
<td>@s.Type</td>
<td>@s.SourceType</td>
<td>@SourceTarget(s)</td>
<td>@(s.IsEnabled ? "yes" : "no")</td>
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
<td>@(s.LastStatus ?? "—")</td>
<td style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenSource(s))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteSourceAsync(s))" />
</td>
</tr>
}
</tbody>
@@ -194,6 +209,57 @@ else
}
</MudTabPanel>
</MudTabs>
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
</TitleContent>
<DialogContent>
<MudSelect T="SourceType" @bind-Value="_sourceEdit.SourceType" Label="Source type" Class="mb-2">
@foreach (var type in Enum.GetValues<SourceType>())
{
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
}
</MudSelect>
@if (_sourceEdit.SourceType is SourceType.HomeAssistant or SourceType.Mqtt or SourceType.Tasmota)
{
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Clearable="true" Class="mb-2">
@foreach (var e in _endpoints)
{
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name (@e.Type)</MudSelectItem>
}
</MudSelect>
}
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
{
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" />
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollSeconds" Label="Poll interval (seconds)" Class="mb-2" />
}
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
{
<MudTextField @bind-Value="_sourceEdit.Topic" Label="MQTT topic (e.g. tele/plug1/SENSOR)" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.Path" Label="Value path (e.g. ENERGY.Total; blank = bare scalar)" Class="mb-2" />
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" Class="mb-2" />
}
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
@foreach (var kind in Enum.GetValues<SourceValueKind>())
{
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
}
</MudSelect>
<div class="d-flex" style="gap:1rem">
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="Scale" Class="mb-2" />
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="Priority" Class="mb-2" />
</div>
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
</DialogActions>
</MudDialog>
}
@code {
@@ -202,6 +268,11 @@ else
private MeterDetailView? _detail;
private bool _notFound;
private List<MeterSource> _sources = [];
private List<IngestionEndpoint> _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) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
+207 -8
View File
@@ -1,11 +1,20 @@
@page "/meters"
@rendermode InteractiveServer
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject MeterVault.Core.Normalization.INormalizationEngine Engine
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Meters</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Meters</MudText>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Meters</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add meter
</MudButton>
</div>
@if (_meters is null)
{
@@ -21,6 +30,8 @@ else
<MudTh>Unit</MudTh>
<MudTh>Sources</MudTh>
<MudTh>Last seen</MudTh>
<MudTh>Active</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
@@ -30,31 +41,83 @@ else
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
<MudTd DataLabel="Last seen">
@{
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") ?? "—")
</MudTd>
<MudTd DataLabel="Active">@(context.IsActive ? "yes" : "no")</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd>
</RowTemplate>
</MudTable>
@if (_meters.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="mt-4">
No meters yet. Go to <MudLink Href="/import">Import</MudLink> to load the reference data.
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data.
</MudAlert>
}
}
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="Energy type" Class="mb-2">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
}
</MudSelect>
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Measurement mode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>())
{
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
<MudSelectItem T="string" Value="@("")">— none —</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
</MudSelect>
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
<div class="d-flex" style="gap:1rem">
<MudTextField @bind-Value="_working.Manufacturer" Label="Manufacturer (optional)" Class="mb-2" />
<MudTextField @bind-Value="_working.Model" Label="Model (optional)" Class="mb-2" />
</div>
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="Active" Color="Color.Primary" />
@if (_working.Id != 0 && _working.RecomputeNeeded)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Mode/baseline changed — consumption will be recomputed on save.</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private List<Meter>? _meters;
private List<EnergyType> _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;
}
}
+14
View File
@@ -0,0 +1,14 @@
using MudBlazor;
namespace MeterVault.App;
/// <summary>Small wrapper over MudBlazor's message box for delete/confirm prompts in admin pages.</summary>
public static class Confirm
{
public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message)
{
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: "Delete", cancelText: "Cancel")
.ConfigureAwait(false);
return result == true;
}
}
+12 -1
View File
@@ -52,10 +52,21 @@ public static class MeterMeta
}
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set to <paramref name="role"/>.</summary>
public static string WithRole(string? meta, string role)
public static string WithRole(string? meta, string role) => SetRole(meta, role);
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set, or removed when null/empty.</summary>
public static string SetRole(string? meta, string? role)
{
var map = ToMap(meta);
if (string.IsNullOrWhiteSpace(role))
{
map.Remove("role");
}
else
{
map["role"] = role;
}
return JsonSerializer.Serialize(map);
}
@@ -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; }
}
}
+12
View File
@@ -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"));
}
}
@@ -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));
@@ -0,0 +1,49 @@
using MeterVault.Infrastructure.Ingestion;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeterVault.Integration.Tests.Ingestion;
/// <summary>
/// The HA connection tester must fail closed on missing config <em>before</em> 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.
/// </summary>
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<HaConnectionTester>.Instance);
private sealed class ThrowingHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) =>
throw new InvalidOperationException("Network must not be touched for a config-guard failure.");
}
}