Admin write-CRUD, Home Assistant connector config, wiring audit
ci / build-test (push) Successful in 1m19s
ci / build-test (push) Successful in 1m19s
Three requested phases.
1) Admin section (SDD §8.7) — MudBlazor inline-dialog CRUD, consistent pattern,
delete guards, snackbar feedback, shared Confirm helper:
- Energy types: create/edit/delete (blocks delete when meters reference it).
- Meters: create/edit/delete; recomputes consumption when mode/baseline
changes (NormalizationService over a fresh factory context, in a tx);
delete cascades data (consumption+readings are Restrict → removed first).
- A meter's ingest sources: manage on the meter-detail Sources tab
(add/edit/delete MQTT/Tasmota/HA sources with typed config).
- Tariffs: full CRUD (scope/component/value/validity).
- Cost categories: CRUD + member management (meter or energy-type members).
- Connectors: ingestion_endpoint CRUD (MQTT broker + Home Assistant);
secrets referenced by env-var name only, never stored.
- Settings: read-only effective-config view (settings are env-driven and
reproducible, so an editable form would change nothing — kept honest).
PV role is now editable on meters (MeterMeta.SetRole can clear a role).
2) Read Home Assistant — extracted a shared public HaEndpointConfig (was a
private record in the worker), added HaConnectionTester (powers the connector
"Test connection": checks base URL + env-resolved token, optionally reads one
entity). Configuring an HA connector + an HA source on a meter drives the
existing REST-poll worker end to end. (WebSocket push stays a future
optimization; REST poll already reads HA.)
3) Wiring/placeholder audit — swept every OnClick/Href: all handlers are real,
all internal links resolve to real routes, no TODO/stub/placeholder code.
Fixed one genuine gap: MainLayout had no drawer toggle, so the nav was
unreachable on narrow screens — added a hamburger button.
Tests: +6 (MeterMeta.SetRole role-removal; HaConnectionTester fail-closed
guard branches with a throwing HttpClientFactory proving no network on bad
config); render test now covers all admin routes. 69 Core + 45 Integration =
114 green. Live-verified in Docker: all admin pages 200, drawer toggle present,
Settings shows real effective config.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
|
||||
|
||||
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. Remaining refinements (HA WebSocket push, full admin write-CRUD, full de-DE UI localization) are noted at the end of their milestone commits. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll; `HaConnectionTester` powers the connector "Test connection" button. Remaining refinements (HA WebSocket *push* — REST poll works today; commit-arbitrary-CSV-from-UI needs a meter-mapping wizard; full de-DE UI-string localization) are noted at their commits. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||
|
||||
## Source of truth
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 & 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 & 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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
map["role"] = role;
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user