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**. 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 ## 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, 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 forecast-to-empty) and a **per-meter detail view** (raw readings, consumption, sources, tariff
timeline, events), one-click reference-data load, CSV dry-run. 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). - **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
- **JSON config export/import** for portability; Docker Compose + multi-arch image. - **JSON config export/import** for portability; Docker Compose + multi-arch image.
@@ -8,6 +8,8 @@
<MudLayout> <MudLayout>
<MudAppBar Elevation="1" Dense="true"> <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" /> <MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
<MudText Typo="Typo.h6">MeterVault</MudText> <MudText Typo="Typo.h6">MeterVault</MudText>
<MudSpacer /> <MudSpacer />
+3
View File
@@ -9,5 +9,8 @@
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false"> <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/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</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> </MudNavGroup>
</MudNavMenu> </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" @page "/admin/energy-types"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Energy types</PageTitle> <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) @if (_types is null)
{ {
@@ -19,22 +27,159 @@ else
<MudTh>Display name</MudTh> <MudTh>Display name</MudTh>
<MudTh>Base unit</MudTh> <MudTh>Base unit</MudTh>
<MudTh>Default mode</MudTh> <MudTh>Default mode</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Key">@context.Key</MudTd> <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="Base unit">@context.BaseUnit</MudTd>
<MudTd DataLabel="Default mode">@context.DefaultMode</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> </RowTemplate>
</MudTable> </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 { @code {
private List<EnergyType>? _types; 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(); await using var db = await DbFactory.CreateDbContextAsync();
_types = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync(); _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" @page "/admin/tariffs"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Tariffs</PageTitle> <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) @if (_tariffs is null)
{ {
<MudProgressLinear Indeterminate="true" Color="Color.Primary" /> <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 else
{ {
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
@@ -25,26 +29,203 @@ else
<MudTh>Unit</MudTh> <MudTh>Unit</MudTh>
<MudTh>Valid from</MudTh> <MudTh>Valid from</MudTh>
<MudTh>Valid to</MudTh> <MudTh>Valid to</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <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="Component">@context.Component</MudTd>
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd> <MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd> <MudTd DataLabel="Unit">@context.Unit</MudTd>
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</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="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> </RowTemplate>
</MudTable> </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 { @code {
private List<Tariff>? _tariffs; 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(); await using var db = await DbFactory.CreateDbContextAsync();
_tariffs = await db.Tariffs.AsNoTracking() _tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom) _energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
.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}" @page "/meters/{Id:int}"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject MeterDetailService Details @inject MeterDetailService Details
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject NavigationManager Nav @inject NavigationManager Nav
@using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Ingestion
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Meter</PageTitle> <PageTitle>MeterVault — Meter</PageTitle>
@@ -169,24 +174,34 @@ else
} }
</MudTabPanel> </MudTabPanel>
<MudTabPanel Text="@($"Sources ({_detail.Sources.Count})")"> <MudTabPanel Text="@($"Sources ({_sources.Count})")">
@if (_detail.Sources.Count == 0) <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 else
{ {
<MudSimpleTable Dense="true" Hover="true"> <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> <tbody>
@foreach (var s in _detail.Sources) @foreach (var s in _sources)
{ {
<tr> <tr>
<td>@s.Type</td> <td>@s.SourceType</td>
<td>@SourceTarget(s)</td>
<td>@(s.IsEnabled ? "yes" : "no")</td> <td>@(s.IsEnabled ? "yes" : "no")</td>
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</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 style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
<td>@(s.LastStatus ?? "—")</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> </tr>
} }
</tbody> </tbody>
@@ -194,6 +209,57 @@ else
} }
</MudTabPanel> </MudTabPanel>
</MudTabs> </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 { @code {
@@ -202,6 +268,11 @@ else
private MeterDetailView? _detail; private MeterDetailView? _detail;
private bool _notFound; 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() protected override async Task OnParametersSetAsync()
{ {
@@ -209,6 +280,137 @@ else
_notFound = false; _notFound = false;
_detail = await Details.GetAsync(Id); _detail = await Details.GetAsync(Id);
_notFound = _detail is null; _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" 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" @page "/meters"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory @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 Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — Meters</PageTitle> <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) @if (_meters is null)
{ {
@@ -21,6 +30,8 @@ else
<MudTh>Unit</MudTh> <MudTh>Unit</MudTh>
<MudTh>Sources</MudTh> <MudTh>Sources</MudTh>
<MudTh>Last seen</MudTh> <MudTh>Last seen</MudTh>
<MudTh>Active</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd> <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="Sources">@context.Sources.Count</MudTd>
<MudTd DataLabel="Last seen"> <MudTd DataLabel="Last seen">
@{ @{
var lastSeen = context.Sources var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
.Where(s => s.LastSeenAt != null)
.Select(s => s.LastSeenAt)
.DefaultIfEmpty(null)
.Max();
} }
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—") @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
</MudTd> </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> </RowTemplate>
</MudTable> </MudTable>
@if (_meters.Count == 0) @if (_meters.Count == 0)
{ {
<MudAlert Severity="Severity.Info" Class="mt-4"> <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> </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 { @code {
private List<Meter>? _meters; 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(); await using var db = await DbFactory.CreateDbContextAsync();
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
_meters = await db.Meters _meters = await db.Meters
.AsNoTracking() .AsNoTracking()
.Include(m => m.EnergyType) .Include(m => m.EnergyType)
@@ -62,4 +125,140 @@ else
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name) .OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
.ToListAsync(); .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> /// <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); var map = ToMap(meta);
if (string.IsNullOrWhiteSpace(role))
{
map.Remove("role");
}
else
{
map["role"] = role; map["role"] = role;
}
return JsonSerializer.Serialize(map); 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> /// <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); 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> /// <summary>
/// The meter-detail read model (SDD §8.6): identity, register span, totals, recent raw readings /// 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 /// and normalized consumption (measured-vs-estimated markers via quality), the applicable tariff
/// applicable tariff timeline, and lifecycle events (swaps/deliveries/corrections). /// 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> /// </summary>
public sealed record MeterDetailView( public sealed record MeterDetailView(
int Id, int Id,
@@ -46,4 +44,4 @@ public sealed record MeterDetailView(
IReadOnlyList<ConsumptionDetailRow> RecentConsumption, IReadOnlyList<ConsumptionDetailRow> RecentConsumption,
IReadOnlyList<EventRow> Events, IReadOnlyList<EventRow> Events,
IReadOnlyList<TariffRow> Tariffs, 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)) .Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false); .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( return new MeterDetailView(
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit, meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive, meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
readingCount, consumptionCount, readingCount, consumptionCount,
first?.Time, last?.Time, first?.Value, last?.Value, first?.Time, last?.Time, first?.Value, last?.Value,
totalConsumption, totalGeneration, 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<ReferenceDataImporter>();
services.AddScoped<IngestionService>(); services.AddScoped<IngestionService>();
services.AddScoped<MqttMessageRouter>(); 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<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>(); services.AddScoped<Dashboard.DashboardService>();
services.AddScoped<Dashboard.SolarService>(); 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? ResolveUsername() => Resolve(UsernameEnv);
public string? ResolvePassword() => Resolve(PasswordEnv); 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.Collections.Concurrent;
using System.Text.Json;
using MeterVault.Core.Domain; using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -99,10 +98,8 @@ public sealed class HomeAssistantWorker(
HaStateClient client, IngestionService ingestion, IngestionEndpoint endpoint, HaStateClient client, IngestionService ingestion, IngestionEndpoint endpoint,
MeterSource source, SourceConfig config, CancellationToken cancellationToken) MeterSource source, SourceConfig config, CancellationToken cancellationToken)
{ {
var endpointConfig = ParseHaEndpoint(endpoint.Config); var endpointConfig = HaEndpointConfig.Parse(endpoint.Config);
var token = string.IsNullOrWhiteSpace(endpointConfig.TokenEnv) var token = endpointConfig.ResolveToken();
? null
: Environment.GetEnvironmentVariable(endpointConfig.TokenEnv);
if (string.IsNullOrWhiteSpace(endpointConfig.BaseUrl) || string.IsNullOrWhiteSpace(token) if (string.IsNullOrWhiteSpace(endpointConfig.BaseUrl) || string.IsNullOrWhiteSpace(token)
|| string.IsNullOrWhiteSpace(config.EntityId)) || 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)); 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[] foreach (var path in new[]
{ {
"/meters", "/trends", "/solar", "/consumables", "/import", "/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)); 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.");
}
}