6 Commits
Author SHA1 Message Date
schmidt.florian 92438348e7 Flow: virtual "sum" meters that aggregate upstreams + demo chain
ci / build-test (push) Successful in 1m14s
Adds the "sum meter" the user asked for: a meter that doesn't physically exist
but represents the sum of other meters in the flow view.

- FlowService: a Virtual-mode meter has no readings of its own; its flow value is
  the sum of its upstream meters, resolved in topological order (so "Summe Solar"
  = Solar 1 + Solar 2, and Grid + Summe Solar → House with the remainder as
  "Other" = export / battery / inverter losses).
- Meters editor: a hint when Mode = Virtual explaining the sum-meter behaviour.
- Reference data seeds the full demo chain: Solar 1 + Solar 2 → Summe Solar;
  Netz + Summe Solar → Haus → Auto + Other — so /energy/1 shows a multi-level
  Sankey out of the box.
- Fix: SankeyChart Unit was passed as the literal string "_graph.Unit" (missing
  @) so node labels read "_graph.Unit" instead of "kWh". Caught by screenshotting
  the live page.

Test: Virtual_sum_meter_aggregates_its_upstreams. 69 Core + 50 Integration =
119 green. Live-verified with a browser screenshot of the multi-level flow.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 15:48:50 +02:00
schmidt.florian ecadbe1477 Flow: count generation meters as sources (grid + solar → house)
ci / build-test (push) Successful in 1m14s
A meter's flow value is now its throughput — consumption OR generation output —
so a generation meter (solar) acts as a source that can feed downstream meters.
Setting a load meter's upstream to {grid, solar} now splits its consumption
across both proportionally, and the remainder under the sources (grid + solar −
load) surfaces as "Other" = export + battery/inverter losses. Negative values
(savings/balance virtuals) are clamped to 0 (a ribbon can't be negative). The
per-type KPI is relabelled "Top-level throughput" since it now spans generation.

Test: Generation_meter_counts_as_source (grid 75 + solar-gen 30 → house 40 →
28.57/11.43 split, 65 remainder). 69 Core + 48 Integration = 117 green.
Live-verified: electricity flow now shows Solar 1/2 as source nodes.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 14:33:29 +02:00
schmidt.florian 85d650a8f5 Fix: enable global interactivity so dialogs/selects actually work
ci / build-test (push) Successful in 1m12s
Editing a meter (any admin dialog, select dropdown, snackbar, dark-mode toggle)
did nothing: pages declared @rendermode InteractiveServer individually, but
MainLayout — which hosts MudDialogProvider/MudPopoverProvider/MudSnackbarProvider
— was rendered by <Routes>, which was static. Inline MudDialogs and MudSelect
popovers render through those providers, so with the providers non-interactive no
dialog could ever open.

Set the render mode on <Routes> and <HeadOutlet> in App.razor (global
interactivity) and removed the now-redundant per-page @rendermode declarations
(they would otherwise throw "parent already has a render mode").

Why it slipped through: the integration render tests only issue a GET (static
prerender), which never exercises the SignalR circuit. Verified this fix with a
real headless-browser run (Playwright): the meter edit dialog opens and the
"Sub-meter of (upstream meters)" multi-select opens with options — proving the
layout providers are now interactive. 116 tests still green.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 14:22:15 +02:00
schmidt.florian 7e34aeccc8 Meter chain topology + per-energy-type flow (Sankey) pages
ci / build-test (push) Successful in 1m24s
Adds a meter hierarchy and a flow view: a downstream meter is a *subsection*
of an upstream one (not an addition), so you can see where a main meter's flow
divides — e.g. official water → garden, pool, other; grid/battery → all → car.

- MeterLink (schema + migration AddMeterLinks): a directed from→to flow edge.
  Multi-parent allowed (a merge, e.g. grid + solar → house); multi-child is a
  split. Cascade-deletes with either endpoint; unique + distinct-endpoint checks.
- FlowService: per energy type + period, builds a Sankey graph — nodes = meters
  sized by consumption; link value = downstream meter's consumption, split
  proportionally across multiple upstreams; unaccounted remainder under a meter
  becomes a synthetic "Other" node; depth via topological longest-path.
- SankeyChart.razor: hand-rolled inline-SVG Sankey (ApexCharts has no Sankey
  type) — columns by depth, nodes stacked by value, bezier ribbons sized by flow,
  left→right, theme-aware, HTML-encoded labels, tooltips. Built as a MarkupString
  to sidestep Razor's <text> element clash.
- /energy/{id} page (one per energy type): KPIs (consumption + cost), the flow
  Sankey, and the meter list. NavMenu now lists a link per energy type
  (Electricity, Water, Gas, …) loaded from the DB.
- Meters admin: cycle-safe "Sub-meter of (upstream meters)" multi-select
  (descendants excluded to prevent cycles); reconciles meter_link rows on save.
- Reference data seeds a demo chain (Haus → Auto) so electricity flow shows
  Haus dividing into Auto + Other.

Tests: FlowServiceTests (single-parent remainder; two-parent proportional
split); render test now asserts the flow chain + covers /energy/{id}. 69 Core +
47 Integration = 116 green. Live-verified: Haus 95,450 kWh → Auto 51,909 +
Other 43,541 (flow conserved), all 5 energy-type pages render.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 14:02:16 +02:00
schmidt.florian 09cd435c2b 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
2026-07-14 11:22:17 +02:00
schmidt.florian 1282acf82c SDD §8 panels (PV/oil/meter-detail) + fix reference-data-in-Docker
Complete the SDD §8 dashboard views that were deferred at the M5 boundary,
and fix a shipping bug that left the Docker demo empty.

Bug: "Load reference data" created meters/tank/tariffs but imported zero
readings in Docker. Root cause: sampledata/ was excluded by .dockerignore and
never copied into the build stage, so the App csproj's linked Content glob
resolved to nothing at publish time; ReferenceDataImporter then silently
skipped the missing CSVs after already writing its marker meter, leaving the
DB permanently "loaded" but empty.
  - .dockerignore: stop excluding sampledata/
  - Dockerfile: COPY sampledata/ into the build stage
  - ReferenceDataImporter: fail-fast (validate CSVs exist before the marker
    meter) and throw instead of silently skipping a missing file
  - Program.cs + MeterVaultOptions: opt-in MeterVault__SeedReferenceData
    (compose METERVAULT_SEED=true) for a one-command populated demo

New SDD §8 panels (read models in Infrastructure/Dashboard, Blazor pages):
  - §8.4 Solar/PV (/solar): generation from GenerationCounter meters;
    self-consumption / autarky % / self-consumption % / savings derived from
    meters tagged total_load & grid_import via Meter.Meta role config
    (MeterRoles/MeterMeta) — nothing hardcoded by name.
  - §8.5 Oil/consumable (/consumables): tank level (cm→L calibrated), fill
    gauge, deliveries log, burner runtime, effective L/h (fixed/empirical),
    forecast-to-empty, tariff cost, monthly series.
  - §8.6 Meter detail (/meters/{id}): raw readings, normalized consumption,
    source status, tariff timeline, events, measured-vs-estimated markers.
  - Reusable SeriesChart component; nav links; Meters list rows link to detail.

Tests: MeterMetaTests (Core, +10); DashboardRenderTests extended to assert the
three panel services compute real figures and the new routes render (108 total,
all green). Live-verified in Docker: seed imports 302 readings / 347 consumption
rows; panels render (generation 16,481 kWh, oil 3,967 L) cross-checking the DB.

Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
2026-07-14 09:52:11 +02:00
50 changed files with 4801 additions and 66 deletions
-1
View File
@@ -5,7 +5,6 @@
.git/ .git/
.github/ .github/
docs/ docs/
sampledata/
tests/ tests/
**/*.user **/*.user
**/appsettings.*.Local.json **/appsettings.*.Local.json
+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 (M0–M7).** The full solution is built and green — five projects, ~95 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). Remaining refinements (HA WebSocket push, dedicated PV/oil dashboard panels, full admin CRUD, full de-DE UI localization) are noted at the end of their milestone commits. **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. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. 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
+14 -1
View File
@@ -22,7 +22,18 @@ full design.
per meter; **cost categories** decoupled from energy types; meterless manual costs. per meter; **cost categories** decoupled from energy types; meterless manual costs.
- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw. - **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw.
- **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/ - **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/
less" difference view, trends, meter list, one-click reference-data load, CSV dry-run. less" difference view, trends, a **PV/Solar panel** (generation, self-consumption, autarky %,
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.
- **Per-energy-type flow pages** (Electricity, Water, …): a **Sankey diagram** of the meter chain —
a downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), arrow
thickness ∝ amount, with an auto-computed "Other/unmetered" remainder. Meters can have several
upstreams (a merge, e.g. grid + solar → house).
- **Admin UI**: full create/edit/delete for energy types, meters (with consumption recompute on
mode/baseline change, and cycle-safe upstream-meter wiring), 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.
@@ -31,6 +42,7 @@ full design.
```bash ```bash
docker compose -f deploy/docker-compose.yml up -d docker compose -f deploy/docker-compose.yml up -d
# open http://localhost:8080 → Import → "Load reference data" for a populated demo # open http://localhost:8080 → Import → "Load reference data" for a populated demo
# ...or start pre-populated: METERVAULT_SEED=true docker compose -f deploy/docker-compose.yml up -d
# API docs at http://localhost:8080/swagger # API docs at http://localhost:8080/swagger
``` ```
@@ -44,6 +56,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) | | `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy | | `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers | | `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
returns 401. Set at least one API key (or open it explicitly for a trusted network). returns 401. Set at least one API key (or open it explicitly for a trusted network).
+3 -1
View File
@@ -11,8 +11,10 @@ COPY src/Infrastructure/MeterVault.Infrastructure.csproj src/Infrastructure/
COPY src/App/MeterVault.App.csproj src/App/ COPY src/App/MeterVault.App.csproj src/App/
RUN dotnet restore src/App/MeterVault.App.csproj RUN dotnet restore src/App/MeterVault.App.csproj
# Build & publish. # Build & publish. sampledata/ must be present so the App csproj's linked Content glob
# (..\..\sampledata\*.csv) resolves at publish time — otherwise "Load reference data" ships empty.
COPY src/ ./src/ COPY src/ ./src/
COPY sampledata/ ./sampledata/
RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false
# Debian-based runtime (keeps full ICU — required by the de-DE CSV importer; do not use # Debian-based runtime (keeps full ICU — required by the de-DE CSV importer; do not use
+3
View File
@@ -33,6 +33,9 @@ services:
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin} MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR} MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
MeterVault__Locale: ${METERVAULT_LOCALE:-en} MeterVault__Locale: ${METERVAULT_LOCALE:-en}
# Set true for a populated demo: loads the bundled Energiebilanz dataset on first start
# (idempotent). Leave false for a clean instance.
MeterVault__SeedReferenceData: ${METERVAULT_SEED:-false}
# REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN): # REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN):
# MeterVault__ApiKeys__0: your-secret-key # MeterVault__ApiKeys__0: your-secret-key
# MeterVault__AllowAnonymousApi: "true" # MeterVault__AllowAnonymousApi: "true"
+2 -2
View File
@@ -10,11 +10,11 @@
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" /> <link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" /> <link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
<ImportMap /> <ImportMap />
<HeadOutlet /> <HeadOutlet @rendermode="InteractiveServer" />
</head> </head>
<body> <body>
<Routes /> <Routes @rendermode="InteractiveServer" />
<ReconnectModal /> <ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script> <script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script> <script src="_content/MudBlazor/MudBlazor.min.js"></script>
@@ -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 />
+44
View File
@@ -1,11 +1,55 @@
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@using Microsoft.EntityFrameworkCore
@using MeterVault.Core.Domain
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink> <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink> <MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
@foreach (var type in _energyTypes)
{
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
}
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink> <MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink> <MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
<MudDivider Class="my-2" /> <MudDivider Class="my-2" />
<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>
@code {
private List<EnergyType> _energyTypes = [];
protected override async Task OnInitializedAsync()
{
try
{
await using var db = await DbFactory.CreateDbContextAsync();
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
}
catch (Exception)
{
// Nav must never break the layout — a DB hiccup just hides the per-type links.
_energyTypes = [];
}
}
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
private static string TypeIcon(string? icon) => icon switch
{
"bolt" => Icons.Material.Filled.Bolt,
"water_drop" => Icons.Material.Filled.WaterDrop,
"local_gas_station" => Icons.Material.Filled.LocalGasStation,
"gas_meter" => Icons.Material.Filled.GasMeter,
"thermostat" => Icons.Material.Filled.Thermostat,
_ => Icons.Material.Filled.Bolt,
};
}
@@ -0,0 +1,260 @@
@page "/admin/categories"
@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,247 @@
@page "/admin/connectors"
@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,18 @@
@page "/admin/energy-types" @page "/admin/energy-types"
@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 +26,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,72 @@
@page "/admin/settings"
@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 -11
View File
@@ -1,20 +1,23 @@
@page "/admin/tariffs" @page "/admin/tariffs"
@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 +28,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; }
} }
} }
+185
View File
@@ -0,0 +1,185 @@
@page "/consumables"
@inject ConsumableService ConsumablesSvc
@using MudBlazor
<PageTitle>MeterVault — Consumables</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Oil / consumables</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
</MudSelect>
</div>
@if (_items is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_items.Count == 0)
{
<MudAlert Severity="Severity.Info">
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
data from <MudLink Href="/import">Import</MudLink>.
</MudAlert>
}
else
{
@foreach (var item in _items)
{
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
<MudGrid>
<MudItem xs="12" md="4">
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
<MudText Typo="Typo.h5">
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
</MudText>
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
{
<text> · @Format.Number(cm, 0) cm</text>
}
@if (item.LevelAsOf is { } asOf)
{
<text> · as of @asOf.ToString("yyyy-MM-dd")</text>
}
</MudText>
</MudItem>
<MudItem xs="12" md="8">
<MudGrid>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText>
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText>
<MudText Typo="Typo.subtitle1">
@if (item.FixedRate is { } fr)
{
<text>@Format.Number(fr, 2) @item.Unit/h</text>
}
else if (item.EffectiveRate is { } er)
{
<text>@Format.Number(er, 2) @item.Unit/h</text>
}
else
{
<text>—</text>
}
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText>
<MudText Typo="Typo.subtitle1">
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
@if (item.AveragePerDay is { } apd)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
(@Format.Number(apd, 1) @item.Unit/day)
</MudText>
}
</MudText>
</MudItem>
</MudGrid>
</MudItem>
<MudItem xs="12" md="7">
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
</MudItem>
<MudItem xs="12" md="5">
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText>
@if (item.Deliveries.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText>
}
else
{
<div style="max-height:260px; overflow-y:auto">
<MudSimpleTable Dense="true" Hover="true">
<thead>
<tr><th>Date</th><th style="text-align:right">Amount</th></tr>
</thead>
<tbody>
@foreach (var delivery in item.Deliveries)
{
<tr>
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
</tr>
}
</tbody>
</MudSimpleTable>
</div>
}
</MudItem>
</MudGrid>
</MudPaper>
}
}
@code {
private int _months = 60;
private bool _loading;
private IReadOnlyList<ConsumableSummary>? _items;
protected override Task OnInitializedAsync() => LoadAsync();
private async Task OnRangeChanged(int months)
{
_months = months;
await LoadAsync();
}
private async Task LoadAsync()
{
if (_loading)
{
return;
}
_loading = true;
_items = null;
try
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = asOf.AddMonths(-_months);
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
}
finally
{
_loading = false;
}
}
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
{
var points = item.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption))
.ToList();
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
}
private static Color FillColor(double fraction) => fraction switch
{
< 0.15 => Color.Error,
< 0.30 => Color.Warning,
_ => Color.Success,
};
}
-1
View File
@@ -1,5 +1,4 @@
@page "/" @page "/"
@rendermode InteractiveServer
@inject DashboardService Dash @inject DashboardService Dash
<PageTitle>MeterVault — Overview</PageTitle> <PageTitle>MeterVault — Overview</PageTitle>
+172
View File
@@ -0,0 +1,172 @@
@page "/energy/{Id:int}"
@inject FlowService Flow
@inject MeterVault.Infrastructure.Costing.CostService Costs
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@using Microsoft.EntityFrameworkCore
@using MudBlazor
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
</MudSelect>
</div>
@if (_graph is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (!_graph.HasData)
{
<MudAlert Severity="Severity.Info">
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
reference data from <MudLink Href="/import">Import</MudLink>.
</MudAlert>
}
else
{
<MudGrid Class="mb-2">
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
<MudText Typo="Typo.h5">@_meters.Count</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
@if (_graph.HasChain)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
</MudText>
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
</MudAlert>
@if (_graph.Nodes.Count > 0)
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
<tbody>
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
{
<tr>
<td>@node.Label</td>
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
}
</MudPaper>
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
<tbody>
@foreach (var meter in _meters)
{
<tr>
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
<td>@meter.Mode</td>
<td>@UpstreamLabel(meter.Id)</td>
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
</tr>
}
</tbody>
</MudSimpleTable>
</MudPaper>
}
@code {
[Parameter]
public int Id { get; set; }
private int _months = 60;
private bool _loading;
private FlowGraph? _graph;
private double _cost;
private List<Meter> _meters = [];
private Dictionary<int, List<string>> _downstream = [];
protected override Task OnParametersSetAsync() => LoadAsync();
private async Task OnRangeChanged(int months)
{
_months = months;
await LoadAsync();
}
private async Task LoadAsync()
{
if (_loading)
{
return;
}
_loading = true;
_graph = null;
try
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
var to = asOf.AddMonths(1);
var typeId = (short)Id;
_graph = await Flow.GetFlowAsync(typeId, from, to);
await using var db = await DbFactory.CreateDbContextAsync();
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
var links = await db.MeterLinks.AsNoTracking()
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
.ToListAsync();
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
_downstream = links
.GroupBy(l => l.FromMeterId)
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
double cost = 0;
foreach (var meter in _meters)
{
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
}
_cost = cost;
}
finally
{
_loading = false;
}
}
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
private string UpstreamLabel(int meterId) =>
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
}
-1
View File
@@ -1,5 +1,4 @@
@page "/import" @page "/import"
@rendermode InteractiveServer
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter @inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter @inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
+417
View File
@@ -0,0 +1,417 @@
@page "/meters/{Id:int}"
@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>
@if (_detail is null)
{
@if (_notFound)
{
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
}
else
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
}
else
{
<div class="d-flex align-center mb-4" style="gap:.75rem">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
<MudText Typo="Typo.h4">@_detail.Name</MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
@if (!_detail.IsActive)
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
}
</div>
<MudGrid Class="mb-2">
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText>
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
</MudPaper>
</MudItem>
@if (_detail.TotalGeneration != 0)
{
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalGeneration, 0) @_detail.Unit</MudText>
</MudPaper>
</MudItem>
}
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
</MudText>
</MudPaper>
</MudItem>
<MudItem xs="6" sm="3">
<MudPaper Class="pa-3" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
<MudText Typo="Typo.h6">
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
@if (_detail.RecentReadings.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
<tbody>
@foreach (var r in _detail.RecentReadings)
{
<tr>
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td>
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
<td>@QualityChip(r.Quality)</td>
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
@if (_detail.RecentConsumption.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
<tbody>
@foreach (var c in _detail.RecentConsumption)
{
<tr>
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td>
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
<td>@c.Kind</td>
<td>@QualityChip(c.Quality)</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
@if (_detail.Events.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
<tbody>
@foreach (var e in _detail.Events)
{
<tr>
<td>@e.Time.ToString("yyyy-MM-dd")</td>
<td>@e.Type</td>
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
<td>@e.Notes</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
@if (_detail.Tariffs.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
<tbody>
@foreach (var t in _detail.Tariffs)
{
<tr>
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
<td>@t.Component</td>
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
<td>@t.Unit</td>
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudTabPanel>
<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. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
}
else
{
<MudSimpleTable Dense="true" Hover="true">
<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 _sources)
{
<tr>
<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>
</MudSimpleTable>
}
</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 {
[Parameter]
public int Id { get; set; }
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()
{
_detail = null;
_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"
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality</MudChip>;
}
+296 -10
View File
@@ -1,11 +1,19 @@
@page "/meters" @page "/meters"
@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,40 +29,112 @@ 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">@context.Name</MudTd> <MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd> <MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
<MudTd DataLabel="Mode">@context.Mode</MudTd> <MudTd DataLabel="Mode">@context.Mode</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd> <MudTd DataLabel="Unit">@context.Unit</MudTd>
<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>
@if (_working.Mode == MeterMode.Virtual)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the
upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2).
</MudAlert>
}
<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>
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
Label="Sub-meter of (upstream meters)" Class="mb-2"
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
@foreach (var m in AvailableUpstream())
{
<MudSelectItem T="int" Value="m.Id">@m.Name</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 List<MeterLink> _allLinks = [];
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();
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
_meters = await db.Meters _meters = await db.Meters
.AsNoTracking() .AsNoTracking()
.Include(m => m.EnergyType) .Include(m => m.EnergyType)
@@ -62,4 +142,210 @@ else
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name) .OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
.ToListAsync(); .ToListAsync();
} }
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
private IEnumerable<Meter> AvailableUpstream()
{
if (_meters is null)
{
return [];
}
var descendants = Descendants(_working.Id);
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
}
private HashSet<int> Descendants(int meterId)
{
var result = new HashSet<int>();
if (meterId == 0)
{
return result;
}
var queue = new Queue<int>();
queue.Enqueue(meterId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
{
if (result.Add(link.ToMeterId))
{
queue.Enqueue(link.ToMeterId);
}
}
}
return result;
}
private string UpstreamText(IReadOnlyList<string> ids)
{
var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
return string.Join(", ", names);
}
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,
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
};
}
_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();
int meterId;
if (_working.Id == 0)
{
var meter = 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,
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
meterId = meter.Id;
}
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();
meterId = existing.Id;
}
await SyncUpstreamAsync(db, meterId, _working.Upstream);
_editOpen = false;
Snackbar.Add("Saved.", Severity.Success);
await LoadAsync();
}
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
{
var desired = desiredUpstream.Where(id => id != meterId).ToHashSet();
var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
{
db.MeterLinks.Remove(link);
}
foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
{
db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
}
await db.SaveChangesAsync();
}
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 IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
}
} }
+148
View File
@@ -0,0 +1,148 @@
@page "/solar"
@inject SolarService SolarSvc
@using MudBlazor
<PageTitle>MeterVault — Solar / PV</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Solar / PV</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
</MudSelect>
</div>
@if (_summary is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (!_summary.HasGeneration)
{
<MudAlert Severity="Severity.Info">
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from
<MudLink Href="/import">Import</MudLink>.
</MudAlert>
}
else
{
<MudGrid>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Self-consumption</MudText>
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
@if (_summary.SelfConsumptionRatio is { } ratio)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">@Format.Number(ratio * 100, 0)% of generation</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Autarky</MudText>
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
@if (_summary.GridImport is { } grid)
{
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid draw @Format.Number(grid, 0) kWh</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Savings (Ersparnis)</MudText>
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Generation &amp; self-consumption</MudText>
<SeriesChart Series="_chart" Decimals="0" Height="340" />
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Generation by meter</MudText>
<MudSimpleTable Dense="true" Hover="true">
<tbody>
@foreach (var meter in _summary.Meters)
{
<tr>
<td>@meter.Name</td>
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
</tr>
}
</tbody>
</MudSimpleTable>
@if (!_summary.HasLoadContext)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
Tag a meter <code>total_load</code> and one <code>grid_import</code> (in meter metadata)
to unlock self-consumption, autarky and savings.
</MudAlert>
}
</MudPaper>
</MudItem>
</MudGrid>
}
@code {
private int _months = 60;
private bool _loading;
private SolarSummary? _summary;
private IReadOnlyList<SeriesChart.SeriesDef> _chart = [];
protected override Task OnInitializedAsync() => LoadAsync();
private async Task OnRangeChanged(int months)
{
_months = months;
await LoadAsync();
}
private async Task LoadAsync()
{
if (_loading)
{
return;
}
_loading = true;
_summary = null;
try
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = asOf.AddMonths(-_months);
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
var generation = _summary.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation))
.ToList();
var series = new List<SeriesChart.SeriesDef>
{
new("Generation", ApexCharts.SeriesType.Bar, generation),
};
if (_summary.HasLoadContext)
{
var self = _summary.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0))
.ToList();
series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self));
}
_chart = series;
}
finally
{
_loading = false;
}
}
}
-1
View File
@@ -1,5 +1,4 @@
@page "/trends" @page "/trends"
@rendermode InteractiveServer
@inject DashboardService Dash @inject DashboardService Dash
<PageTitle>MeterVault — Trends</PageTitle> <PageTitle>MeterVault — Trends</PageTitle>
+165
View File
@@ -0,0 +1,165 @@
@using System.Globalization
@using System.Text
@using System.Net
@using MeterVault.Infrastructure.Dashboard
@if (string.IsNullOrEmpty(_svg))
{
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText>
}
else
{
<div style="width:100%; overflow-x:auto">
@((MarkupString)_svg)
</div>
}
@code {
private const double W = 1000;
private const double NodeWidth = 16;
private const double NodeGap = 12;
private const double LeftPad = 8;
private const double RightPad = 8;
[Parameter, EditorRequired]
public IReadOnlyList<FlowNode> Nodes { get; set; } = [];
[Parameter, EditorRequired]
public IReadOnlyList<FlowLink> Links { get; set; } = [];
[Parameter]
public string Unit { get; set; } = "";
private string _svg = "";
protected override void OnParametersSet() => _svg = BuildSvg();
private string BuildSvg()
{
if (Nodes.Count == 0)
{
return "";
}
var maxDepth = Nodes.Max(n => n.Depth);
var columns = Nodes.GroupBy(n => n.Depth).ToDictionary(g => g.Key, g => g.OrderByDescending(n => n.Value).ToList());
var maxCount = columns.Values.Max(c => c.Count);
var height = Math.Max(320, (maxCount * 46) + 40);
// One value→pixel scale so every column fits (flow is conserved → column totals are ~equal;
// the densest column with the most gaps constrains the scale).
var scale = double.MaxValue;
foreach (var col in columns.Values)
{
var sum = col.Sum(n => n.Value);
if (sum > 0)
{
scale = Math.Min(scale, (height - ((col.Count - 1) * NodeGap) - 20) / sum);
}
}
if (double.IsInfinity(scale) || scale <= 0)
{
scale = 1;
}
var colStep = maxDepth == 0 ? 0 : (W - LeftPad - RightPad - NodeWidth) / maxDepth;
var geo = new Dictionary<string, NodeGeo>();
foreach (var (depth, col) in columns)
{
var heights = col.Select(n => Math.Max(3, n.Value * scale)).ToList();
var colHeight = heights.Sum() + ((col.Count - 1) * NodeGap);
var y = (height - colHeight) / 2;
var x = LeftPad + (depth * colStep);
for (var i = 0; i < col.Count; i++)
{
geo[col[i].Id] = new NodeGeo(x, y, heights[i]);
y += heights[i] + NodeGap;
}
}
// Ribbon band offsets: order each source's out-links by target y, each target's in-links by source y.
var srcOffset = new Dictionary<string, double>();
var dstOffset = new Dictionary<string, double>();
var srcBand = new Dictionary<FlowLink, double>();
var dstBand = new Dictionary<FlowLink, double>();
foreach (var group in Links.GroupBy(l => l.From))
{
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.To, out var g) ? g.Y : 0))
{
srcBand[link] = srcOffset.GetValueOrDefault(group.Key);
srcOffset[group.Key] = srcBand[link] + (link.Value * scale);
}
}
foreach (var group in Links.GroupBy(l => l.To))
{
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.From, out var g) ? g.Y : 0))
{
dstBand[link] = dstOffset.GetValueOrDefault(group.Key);
dstOffset[group.Key] = dstBand[link] + (link.Value * scale);
}
}
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
var label = Nodes.ToDictionary(n => n.Id, n => n.Label);
var sb = new StringBuilder();
sb.Append(CultureInfo.InvariantCulture,
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"Flow diagram\">");
// Ribbons first (under nodes).
foreach (var link in Links)
{
if (!geo.TryGetValue(link.From, out var s) || !geo.TryGetValue(link.To, out var t))
{
continue;
}
var band = link.Value * scale;
var sy0 = s.Y + srcBand[link];
var ty0 = t.Y + dstBand[link];
var sx = s.X + NodeWidth;
var tx = t.X;
var midX = (sx + tx) / 2;
var path =
$"M{F(sx)},{F(sy0)} C{F(midX)},{F(sy0)} {F(midX)},{F(ty0)} {F(tx)},{F(ty0)} " +
$"L{F(tx)},{F(ty0 + band)} C{F(midX)},{F(ty0 + band)} {F(midX)},{F(sy0 + band)} {F(sx)},{F(sy0 + band)} Z";
var tip = Enc($"{label.GetValueOrDefault(link.From)} → {label.GetValueOrDefault(link.To)}: {Fmt(link.Value)}");
sb.Append(CultureInfo.InvariantCulture,
$"<path d=\"{path}\" fill=\"{Enc(color.GetValueOrDefault(link.From, "#607D8B"))}\" fill-opacity=\"0.38\"><title>{tip}</title></path>");
}
// Nodes + labels.
foreach (var node in Nodes)
{
if (!geo.TryGetValue(node.Id, out var g))
{
continue;
}
var rightmost = node.Depth == maxDepth;
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
var anchor = rightmost ? "end" : "start";
var fill = Enc(node.ColorHex ?? "#607D8B");
var name = Enc(node.Label);
var val = Enc(Fmt(node.Value));
sb.Append(CultureInfo.InvariantCulture,
$"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>");
sb.Append(CultureInfo.InvariantCulture,
$"<text x=\"{F(labelX)}\" y=\"{F(g.Y + (g.H / 2))}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" font-size=\"13\" fill=\"currentColor\">" +
$"<tspan>{name}</tspan><tspan x=\"{F(labelX)}\" dy=\"15\" font-size=\"11\" fill-opacity=\"0.65\">{val}</tspan></text>");
}
sb.Append("</svg>");
return sb.ToString();
}
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim();
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
private static string Enc(string value) => WebUtility.HtmlEncode(value);
private readonly record struct NodeGeo(double X, double Y, double H);
}
@@ -0,0 +1,47 @@
@using ApexCharts
@if (HasData)
{
<ApexChart TItem="SeriesChart.Point" Options="_options" Height="@Height">
@foreach (var series in Series)
{
<ApexPointSeries TItem="SeriesChart.Point"
Items="series.Points"
SeriesType="series.Type"
Name="@series.Name"
XValue="p => p.Label"
YValue="p => (decimal)Math.Round(p.Value, Decimals)" />
}
</ApexChart>
}
else
{
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
}
@code {
/// <summary>A single (label, value) point in a series.</summary>
public sealed record Point(string Label, double Value);
/// <summary>A named series rendered as bars or a line over the shared category axis.</summary>
public sealed record SeriesDef(string Name, SeriesType Type, IReadOnlyList<Point> Points);
[Parameter, EditorRequired]
public IReadOnlyList<SeriesDef> Series { get; set; } = [];
[Parameter]
public int Height { get; set; } = 300;
[Parameter]
public int Decimals { get; set; } = 2;
private bool HasData => Series.Any(s => s.Points.Count > 0);
private readonly ApexChartOptions<SeriesChart.Point> _options = new()
{
Theme = new Theme { Mode = Mode.Dark },
DataLabels = new DataLabels { Enabled = false },
Legend = new Legend { Position = LegendPosition.Top },
Stroke = new Stroke { Width = 3, Curve = Curve.Smooth },
};
}
+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;
}
}
+8
View File
@@ -104,6 +104,14 @@ static async Task MigrateDatabaseAsync(WebApplication app)
await db.Database.MigrateAsync().ConfigureAwait(false); await db.Database.MigrateAsync().ConfigureAwait(false);
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false); await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
Log.Information("Database migrations applied and defaults seeded"); Log.Information("Database migrations applied and defaults seeded");
if (options.SeedReferenceData)
{
var importer = scope.ServiceProvider.GetRequiredService<MeterVault.Infrastructure.Import.ReferenceDataImporter>();
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
await importer.LoadAsync(dir).ConfigureAwait(false);
Log.Information("Reference dataset ensured (SeedReferenceData=true)");
}
} }
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary> /// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
+23
View File
@@ -0,0 +1,23 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// A directed flow edge in the meter topology: energy/water measured by <see cref="ToMeter"/> is a
/// <em>subsection</em> of the flow through <see cref="FromMeter"/> (upstream → downstream). Not an
/// addition — a sub-meter shows where an upstream meter's flow goes. Multiple edges into one meter
/// model a merge (e.g. house load fed by grid + solar); multiple edges out model a split
/// (main → car, pool, …). Drives the per-energy-type flow (Sankey) view.
/// </summary>
public sealed class MeterLink
{
public int Id { get; set; }
/// <summary>Upstream meter (the larger flow this edge draws from).</summary>
public int FromMeterId { get; set; }
public Meter? FromMeter { get; set; }
/// <summary>Downstream meter (the subsection).</summary>
public int ToMeterId { get; set; }
public Meter? ToMeter { get; set; }
}
+89
View File
@@ -0,0 +1,89 @@
using System.Text.Json;
namespace MeterVault.Core.Domain;
/// <summary>
/// A meter's optional role in an energy system, stored under <c>role</c> in <see cref="Meter.Meta"/>.
/// Roles let analytic panels (e.g. the PV self-consumption/autarky view) find the relevant meters
/// by configuration rather than by hardcoded names — nothing domain-specific is baked into code
/// (SDD §5.2, §8.4). A PV install typically tags one meter <see cref="TotalLoad"/> and one
/// <see cref="GridImport"/>; self-consumption is then <c>total_load − grid_import</c>.
/// </summary>
public static class MeterRoles
{
/// <summary>Meter measuring the site's total consumption (all loads).</summary>
public const string TotalLoad = "total_load";
/// <summary>Meter measuring energy drawn from the grid.</summary>
public const string GridImport = "grid_import";
/// <summary>Meter measuring energy exported to the grid.</summary>
public const string GridExport = "grid_export";
}
/// <summary>Typed reads over a meter's free-form <c>Meta</c> JSON (jsonb). Tolerant of malformed
/// or empty JSON — returns null rather than throwing, so a bad blob never breaks a dashboard.</summary>
public static class MeterMeta
{
/// <summary>The meter's configured <c>role</c> (see <see cref="MeterRoles"/>), or null if unset/invalid.</summary>
public static string? Role(string? meta) => ReadString(meta, "role");
/// <summary>Reads a top-level string property from the meta JSON; null if absent or unparsable.</summary>
public static string? ReadString(string? meta, string property)
{
if (string.IsNullOrWhiteSpace(meta))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(meta);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty(property, out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
}
catch (JsonException)
{
return null;
}
}
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set to <paramref name="role"/>.</summary>
public static string WithRole(string? meta, string role) => SetRole(meta, role);
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set, or removed when null/empty.</summary>
public static string SetRole(string? meta, string? role)
{
var map = ToMap(meta);
if (string.IsNullOrWhiteSpace(role))
{
map.Remove("role");
}
else
{
map["role"] = role;
}
return JsonSerializer.Serialize(map);
}
private static Dictionary<string, object?> ToMap(string? meta)
{
if (string.IsNullOrWhiteSpace(meta))
{
return new Dictionary<string, object?>();
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(meta) ?? new Dictionary<string, object?>();
}
catch (JsonException)
{
return new Dictionary<string, object?>();
}
}
}
@@ -0,0 +1,35 @@
using MeterVault.Core.Domain;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>One recorded delivery into a consumable store.</summary>
public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit);
/// <summary>One month of consumable draw.</summary>
public sealed record ConsumableMonth(DateOnly Period, double Consumption);
/// <summary>
/// The oil / consumable panel read model (SDD §8.5) for one <see cref="MeterMode.ConsumableBalance"/>
/// meter: current tank level (physical + volume), fill vs capacity, deliveries, associated burner
/// runtime, effective L/h (fixed or empirical), forecast-to-empty, tariff cost and a monthly series.
/// </summary>
public sealed record ConsumableSummary(
int MeterId,
string Name,
string Unit,
double Capacity,
double? CurrentLevel,
double? PhysicalLevel,
string? PhysicalUnit,
DateTimeOffset? LevelAsOf,
double FillFraction,
double ConsumptionInRange,
double? BurnerHours,
double? EffectiveRate,
double? FixedRate,
TankRateMode RateMode,
double? AveragePerDay,
DateOnly? ForecastEmpty,
double CostInRange,
IReadOnlyList<DeliveryRow> Deliveries,
IReadOnlyList<ConsumableMonth> Months);
@@ -0,0 +1,177 @@
using Dapper;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>
/// Read model for the oil / consumable panel (SDD §8.5). Works for any
/// <see cref="MeterMode.ConsumableBalance"/> meter backed by a <see cref="Tank"/> — heating oil is
/// only the reference case. Current level is the latest dipstick reading (cm calibrated to volume)
/// plus deliveries recorded since; the effective burn rate pairs the consumable's litres with the
/// runtime hours of same-energy-type <see cref="MeterMode.RuntimeCounter"/> meters. DbContext
/// factory keeps it Blazor-circuit safe.
/// </summary>
public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
{
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
private readonly CostService _costService = costService;
public async Task<IReadOnlyList<ConsumableSummary>> GetConsumablesAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var meters = await db.Meters.AsNoTracking()
.Where(m => m.Mode == MeterMode.ConsumableBalance)
.ToListAsync(cancellationToken).ConfigureAwait(false);
var summaries = new List<ConsumableSummary>();
foreach (var meter in meters)
{
var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meter.Id, cancellationToken).ConfigureAwait(false);
if (tank is null)
{
continue;
}
summaries.Add(await BuildAsync(db, meter, tank, from, to, cancellationToken).ConfigureAwait(false));
}
return summaries;
}
private async Task<ConsumableSummary> BuildAsync(
MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken)
{
var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration;
var fromUtc = ToUtc(from);
var toUtc = ToUtc(to);
var events = await db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery))
.OrderBy(e => e.Time)
.ToListAsync(cancellationToken).ConfigureAwait(false);
var lastLevel = events.LastOrDefault(e => e.EventType == MeterEventType.TankLevel);
double? currentLevel = null;
double? physicalLevel = null;
string? physicalUnit = null;
DateTimeOffset? levelAsOf = null;
if (lastLevel is not null)
{
physicalLevel = lastLevel.Amount;
physicalUnit = lastLevel.Unit;
levelAsOf = lastLevel.Time;
var volume = ToVolume(lastLevel, calibration);
// Deliveries recorded after the last dipstick raise the actual contents.
var since = events.Where(e => e.EventType == MeterEventType.Delivery && e.Time > lastLevel.Time).Sum(e => e.Amount ?? 0);
currentLevel = volume + since;
}
var fillFraction = tank.Capacity > 0 && currentLevel is { } level
? Math.Clamp(level / tank.Capacity, 0, 1)
: 0;
var deliveries = events
.Where(e => e.EventType == MeterEventType.Delivery)
.OrderByDescending(e => e.Time)
.Select(e => new DeliveryRow(e.Time, e.Amount ?? 0, e.Unit))
.ToList();
var consumptionInRange = await SumConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
// Burner runtime: same-energy-type runtime meters feed this consumable's L/h analytic.
var runtimeMeterIds = await db.Meters.AsNoTracking()
.Where(m => m.EnergyTypeId == meter.EnergyTypeId && m.Mode == MeterMode.RuntimeCounter)
.Select(m => m.Id)
.ToListAsync(cancellationToken).ConfigureAwait(false);
double? burnerHours = null;
foreach (var id in runtimeMeterIds)
{
burnerHours = (burnerHours ?? 0) + await SumConsumptionAsync(db, id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
}
double? effectiveRate = burnerHours is > 0 ? consumptionInRange / burnerHours : null;
double? fixedRate = tank.RateMode == TankRateMode.Fixed ? tank.FixedRate : null;
var (averagePerDay, forecastEmpty) = await ForecastAsync(db, meter.Id, currentLevel, levelAsOf, cancellationToken).ConfigureAwait(false);
var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false))
.Sum(c => c.Cost);
var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
return new ConsumableSummary(
meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf,
fillFraction, consumptionInRange, burnerHours, effectiveRate, fixedRate, tank.RateMode,
averagePerDay, forecastEmpty, costInRange, deliveries, months);
}
/// <summary>Recent burn rate and a forecast-to-empty anchored at the last level reading, using
/// the trailing 365 days of consumption (delivery-only early history would otherwise skew it).</summary>
private static async Task<(double? AveragePerDay, DateOnly? ForecastEmpty)> ForecastAsync(
MeterVaultDbContext db, int meterId, double? currentLevel, DateTimeOffset? levelAsOf, CancellationToken cancellationToken)
{
var latest = await db.Consumption.AsNoTracking()
.Where(c => c.MeterId == meterId)
.OrderByDescending(c => c.Time)
.Select(c => (DateTimeOffset?)c.Time)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (latest is null || currentLevel is not { } level || level <= 0)
{
return (null, null);
}
var windowStart = latest.Value.AddDays(-365);
var recent = await db.Consumption.AsNoTracking()
.Where(c => c.MeterId == meterId && c.Time > windowStart && c.Time <= latest.Value)
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
if (recent <= 0)
{
return (null, null);
}
var averagePerDay = recent / 365.0;
var anchor = levelAsOf ?? latest.Value;
var daysToEmpty = level / averagePerDay;
// Guard against absurd horizons (near-zero burn) that overflow DateTime.
var forecast = daysToEmpty < 365 * 100
? DateOnly.FromDateTime(anchor.UtcDateTime.AddDays(daysToEmpty))
: (DateOnly?)null;
return (averagePerDay, forecast);
}
private static async Task<double> SumConsumptionAsync(
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) =>
await db.Consumption.AsNoTracking()
.Where(c => c.MeterId == meterId && c.Time >= from && c.Time < to)
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
private static async Task<IReadOnlyList<ConsumableMonth>> MonthlyConsumptionAsync(
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
{
const string sql =
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
"sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period ORDER BY period";
var connection = db.Database.GetDbConnection();
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList();
}
private static double ToVolume(MeterEvent level, MeterVault.Core.Normalization.CalibrationCurve? calibration)
{
var value = level.Amount ?? 0;
var isCentimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase);
return isCentimetres && calibration is not null ? calibration.ToVolume(value) : value;
}
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
}
@@ -0,0 +1,26 @@
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId);
/// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary>
public sealed record FlowLink(string From, string To, double Value);
/// <summary>
/// The per-energy-type flow graph (SDD-style topology view): meters as nodes sized by consumption,
/// directed edges sized by the flow along each configured link, plus "Other" remainders where an
/// upstream meter's flow isn't fully accounted for by its sub-meters. Rendered as a Sankey diagram.
/// </summary>
public sealed record FlowGraph(
short EnergyTypeId,
string EnergyType,
string Unit,
double Total,
IReadOnlyList<FlowNode> Nodes,
IReadOnlyList<FlowLink> Links)
{
public bool HasData => Nodes.Count > 0;
/// <summary>True when meters are actually chained (not just a flat, unlinked list).</summary>
public bool HasChain => Links.Count > 0;
}
+158
View File
@@ -0,0 +1,158 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>
/// Builds the per-energy-type flow graph (Sankey) from the meter topology (<see cref="MeterLink"/>)
/// and consumption over a period. Each meter is a node sized by its consumption; each configured
/// edge carries the downstream meter's consumption (split proportionally when a meter has several
/// upstreams); the unaccounted remainder under a meter becomes a synthetic "Other" node. Nothing is
/// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory
/// keeps it Blazor-circuit safe.
/// </summary>
public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFactory)
{
private const double Epsilon = 0.01;
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
public async Task<FlowGraph> GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var energyType = await db.EnergyTypes.AsNoTracking().FirstOrDefaultAsync(t => t.Id == energyTypeId, cancellationToken).ConfigureAwait(false);
var meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).ToListAsync(cancellationToken).ConfigureAwait(false);
if (energyType is null || meters.Count == 0)
{
return new FlowGraph(energyTypeId, energyType?.DisplayName ?? "", energyType?.BaseUnit ?? "", 0, [], []);
}
var meterIds = meters.Select(m => m.Id).ToHashSet();
var fromUtc = ToUtc(from);
var toUtc = ToUtc(to);
// A meter's flow value is its throughput: consumption OR generation output — so a generation
// meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter
// is normally one kind, so summing both kinds is that meter's flow. Negatives (savings/balance
// virtual meters) are clamped to 0 — a flow ribbon can't be negative.
var sums = await db.Consumption.AsNoTracking()
.Where(c => c.Time >= fromUtc && c.Time < toUtc)
.GroupBy(c => c.MeterId)
.Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) })
.ToListAsync(cancellationToken).ConfigureAwait(false);
var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => Math.Max(0, s.Total));
double V(int id) => value.GetValueOrDefault(id);
var links = await db.MeterLinks.AsNoTracking()
.Where(l => meterIds.Contains(l.FromMeterId) && meterIds.Contains(l.ToMeterId))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var parents = meters.ToDictionary(m => m.Id, _ => new List<int>());
var children = meters.ToDictionary(m => m.Id, _ => new List<int>());
foreach (var link in links)
{
children[link.FromMeterId].Add(link.ToMeterId);
parents[link.ToMeterId].Add(link.FromMeterId);
}
var depth = ComputeDepths(meters.Select(m => m.Id).ToList(), parents, children);
// Aggregate ("sum") meters: a Virtual-mode meter has no measurements of its own — in the flow
// it is the sum of its upstream meters (e.g. "Sum Solar" = Solar 1 + Solar 2). Resolve these in
// topological (depth) order so each aggregate sees its already-resolved upstream values.
var aggregates = meters.Where(m => m.Mode == MeterMode.Virtual).Select(m => m.Id).ToHashSet();
foreach (var id in meters.Select(m => m.Id).OrderBy(id => depth.GetValueOrDefault(id)))
{
if (aggregates.Contains(id))
{
value[id] = parents[id].Sum(V);
}
}
// Link value: a child's consumption flows in from its parent(s); with several parents it is
// split proportionally to the parents' own consumption (equal split if those are all zero).
var flowLinks = new List<FlowLink>();
var outgoingByParent = meters.ToDictionary(m => m.Id, _ => 0d);
foreach (var (childId, parentIds) in parents)
{
if (parentIds.Count == 0)
{
continue;
}
var parentTotal = parentIds.Sum(V);
foreach (var parentId in parentIds)
{
var share = parentIds.Count == 1 ? 1d
: parentTotal > Epsilon ? V(parentId) / parentTotal
: 1d / parentIds.Count;
var linkValue = V(childId) * share;
if (linkValue > Epsilon)
{
flowLinks.Add(new FlowLink(NodeId(parentId), NodeId(childId), linkValue));
outgoingByParent[parentId] += linkValue;
}
}
}
var nodes = new List<FlowNode>();
foreach (var meter in meters)
{
// Keep a meter node if it carries flow or participates in the topology.
if (V(meter.Id) <= Epsilon && children[meter.Id].Count == 0 && parents[meter.Id].Count == 0)
{
continue;
}
nodes.Add(new FlowNode(NodeId(meter.Id), meter.Name, V(meter.Id), depth.GetValueOrDefault(meter.Id), energyType.ColorHex, false, meter.Id));
// Unaccounted remainder under a meter with sub-meters → "Other".
if (children[meter.Id].Count > 0)
{
var remainder = V(meter.Id) - outgoingByParent[meter.Id];
if (remainder > Epsilon)
{
var otherId = $"other{meter.Id}";
nodes.Add(new FlowNode(otherId, $"Other ({meter.Name})", remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder));
}
}
}
var total = meters.Where(m => parents[m.Id].Count == 0).Sum(m => V(m.Id));
return new FlowGraph(energyTypeId, energyType.DisplayName, energyType.BaseUnit, total, nodes, flowLinks);
}
/// <summary>Longest-path depth from the roots (Kahn topological relaxation); robust to stray cycles.</summary>
private static Dictionary<int, int> ComputeDepths(
List<int> ids, Dictionary<int, List<int>> parents, Dictionary<int, List<int>> children)
{
var depth = ids.ToDictionary(id => id, _ => 0);
var indegree = ids.ToDictionary(id => id, id => parents[id].Count);
var queue = new Queue<int>(ids.Where(id => indegree[id] == 0));
var processed = 0;
while (queue.Count > 0)
{
var node = queue.Dequeue();
processed++;
foreach (var child in children[node])
{
depth[child] = Math.Max(depth[child], depth[node] + 1);
if (--indegree[child] == 0)
{
queue.Enqueue(child);
}
}
}
// Any nodes left (a cycle) keep depth 0 — the admin prevents cycles, this is just a guard.
return depth;
}
private static string NodeId(int meterId) => $"m{meterId}";
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
}
@@ -0,0 +1,47 @@
using MeterVault.Core.Domain;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>A raw reading row for the meter-detail table.</summary>
public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQuality Quality, ReadingFlags Flags);
/// <summary>A normalized consumption row for the meter-detail table.</summary>
public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality);
/// <summary>A meter lifecycle/correction event row.</summary>
public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
/// <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>
/// The meter-detail read model (SDD §8.6): identity, register span, totals, recent raw readings
/// 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,
string Name,
string EnergyType,
MeterMode Mode,
string Unit,
string? Location,
string? SerialNumber,
string? Manufacturer,
string? Model,
double InitialBaseline,
bool IsActive,
int ReadingCount,
int ConsumptionCount,
DateTimeOffset? FirstReadingTime,
DateTimeOffset? LastReadingTime,
double? FirstReadingValue,
double? LastReadingValue,
double TotalConsumption,
double TotalGeneration,
IReadOnlyList<ReadingRow> RecentReadings,
IReadOnlyList<ConsumptionDetailRow> RecentConsumption,
IReadOnlyList<EventRow> Events,
IReadOnlyList<TariffRow> Tariffs,
int SourceCount);
@@ -0,0 +1,83 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>
/// Read model for the meter-detail view (SDD §8.6). Bounds the raw-reading and consumption pulls
/// (this is the one place the UI touches raw rows) and gathers source status, the applicable tariff
/// timeline and lifecycle events. DbContext factory keeps it Blazor-circuit safe.
/// </summary>
public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> contextFactory)
{
private const int MaxRows = 200;
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
public async Task<MeterDetailView?> GetAsync(int meterId, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var meter = await db.Meters.AsNoTracking()
.Include(m => m.EnergyType)
.Include(m => m.Sources)
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
if (meter is null)
{
return null;
}
var readingCount = await db.Readings.AsNoTracking().CountAsync(r => r.MeterId == meterId, cancellationToken).ConfigureAwait(false);
var consumptionCount = await db.Consumption.AsNoTracking().CountAsync(c => c.MeterId == meterId, cancellationToken).ConfigureAwait(false);
var first = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
.OrderBy(r => r.Time).Select(r => new { r.Time, r.Value })
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
var last = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
.OrderByDescending(r => r.Time).Select(r => new { r.Time, r.Value })
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
var totalConsumption = await db.Consumption.AsNoTracking()
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Consumption)
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
var totalGeneration = await db.Consumption.AsNoTracking()
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Generation)
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
var recentReadings = await db.Readings.AsNoTracking()
.Where(r => r.MeterId == meterId)
.OrderByDescending(r => r.Time).Take(MaxRows)
.Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var recentConsumption = await db.Consumption.AsNoTracking()
.Where(c => c.MeterId == meterId)
.OrderByDescending(c => c.Time).Take(MaxRows)
.Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var events = await db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meterId)
.OrderByDescending(e => e.Time)
.Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var energyTypeId = meter.EnergyTypeId;
var tariffs = await db.Tariffs.AsNoTracking()
.Where(t => t.ScopeType == TariffScope.Global
|| (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId)
|| (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId))
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false);
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, meter.Sources.Count);
}
}
@@ -0,0 +1,37 @@
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>One month of the PV panel: generation, and (when role-tagged meters exist) the
/// self-consumption / grid-draw / savings split that reproduces the sheet's Netz-Einsparung column.</summary>
public sealed record SolarMonth(
DateOnly Period,
double Generation,
double? SelfConsumption,
double? GridImport,
double? TotalLoad,
double? Savings);
/// <summary>Per-generation-meter total over the selected period (for the ranked list).</summary>
public sealed record GenerationMeterRow(int MeterId, string Name, double Generation);
/// <summary>
/// The PV / solar panel read model (SDD §8.4): total generation plus, when the install has tagged
/// a <c>total_load</c> and <c>grid_import</c> meter, self-consumption, autarky %, self-consumption %
/// and savings (Ersparnis). Derived metrics are null when no role config exists.
/// </summary>
public sealed record SolarSummary(
double Generation,
double? TotalLoad,
double? GridImport,
double? SelfConsumption,
double? Autarky,
double? SelfConsumptionRatio,
double? Savings,
IReadOnlyList<GenerationMeterRow> Meters,
IReadOnlyList<SolarMonth> Months)
{
/// <summary>True when the install has the role-tagged meters needed for self-consumption metrics.</summary>
public bool HasLoadContext => TotalLoad is not null && GridImport is not null;
/// <summary>True when at least one generation meter exists.</summary>
public bool HasGeneration => Meters.Count > 0;
}
@@ -0,0 +1,116 @@
using Dapper;
using MeterVault.Core.Costing;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Dashboard;
/// <summary>
/// Read model for the PV / solar panel (SDD §8.4). Generation comes from every
/// <see cref="MeterMode.GenerationCounter"/> meter; self-consumption / autarky / savings are derived
/// from the meters tagged <see cref="MeterRoles.TotalLoad"/> and <see cref="MeterRoles.GridImport"/>
/// — so nothing is hardcoded by meter name. Reads only the aggregated consumption hypertable
/// (monthly, Europe/Berlin) via Dapper; safe from a Blazor circuit via a DbContext factory.
/// </summary>
public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextFactory)
{
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
public async Task<SolarSummary> GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var meters = await db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
var generationMeters = meters.Where(m => m.Mode == MeterMode.GenerationCounter).ToList();
var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad);
var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport);
var fromUtc = ToUtc(from);
var toUtc = ToUtc(to);
// Monthly generation per generation meter.
var genByMeter = new Dictionary<int, IReadOnlyDictionary<DateOnly, double>>();
foreach (var meter in generationMeters)
{
genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
}
var loadByMonth = loadMeter is null
? null
: await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
var gridByMonth = gridMeter is null
? null
: await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
var tariffs = gridMeter is null
? []
: await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
// Union of all months that carry any data.
var periods = new SortedSet<DateOnly>();
foreach (var series in genByMeter.Values)
{
periods.UnionWith(series.Keys);
}
if (loadByMonth is not null)
{
periods.UnionWith(loadByMonth.Keys);
}
var months = new List<SolarMonth>();
foreach (var period in periods)
{
var generation = genByMeter.Values.Sum(s => s.GetValueOrDefault(period));
double? load = loadByMonth?.GetValueOrDefault(period);
double? grid = gridByMonth?.GetValueOrDefault(period);
double? self = load is not null && grid is not null ? load - grid : null;
double? savings = null;
if (self is { } selfValue && gridMeter is not null)
{
var price = TariffResolver.ResolveValue(
tariffs, TariffComponent.UnitPrice, gridMeter.Id, gridMeter.EnergyTypeId,
new DateOnly(period.Year, period.Month, 15));
savings = selfValue * price;
}
months.Add(new SolarMonth(period, generation, self, grid, load, savings));
}
var meterRows = generationMeters
.Select(m => new GenerationMeterRow(m.Id, m.Name, genByMeter[m.Id].Values.Sum()))
.OrderByDescending(r => r.Generation)
.ToList();
var totalGeneration = meterRows.Sum(r => r.Generation);
double? totalLoad = loadByMonth?.Values.Sum();
double? totalGrid = gridByMonth?.Values.Sum();
double? totalSelf = totalLoad is not null && totalGrid is not null ? totalLoad - totalGrid : null;
double? autarky = totalSelf is not null && totalLoad is > 0 ? totalSelf / totalLoad : null;
double? selfRatio = totalSelf is not null && totalGeneration > 0 ? totalSelf / totalGeneration : null;
double? totalSavings = months.Any(m => m.Savings is not null) ? months.Sum(m => m.Savings ?? 0) : null;
return new SolarSummary(
totalGeneration, totalLoad, totalGrid, totalSelf, autarky, selfRatio, totalSavings, meterRows, months);
}
private static async Task<IReadOnlyDictionary<DateOnly, double>> MonthlyAsync(
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
{
const string sql =
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
"sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period";
var connection = db.Database.GetDbConnection();
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
return rows.ToDictionary(r => r.Period, r => r.Amount);
}
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
}
@@ -33,8 +33,16 @@ 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.ConsumableService>();
services.AddScoped<Dashboard.MeterDetailService>();
services.AddScoped<Dashboard.FlowService>();
services.AddScoped<Backup.ExportService>(); services.AddScoped<Backup.ExportService>();
return services; return services;
@@ -33,6 +33,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
return; return;
} }
// Fail fast BEFORE creating the marker meter: if the CSVs are missing (e.g. not shipped in
// the image) we must not seed a half-loaded dataset that IsLoadedAsync then reports as done.
EnsureSampleFilesPresent(sampleDataDirectory);
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false); await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false); var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false);
@@ -47,8 +51,15 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820); var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L"); var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h"); var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
// A virtual "sum" meter: no readings of its own — in the flow view it equals Solar 1 + Solar 2.
var sumSolar = Meter("Summe Solar", electricity, MeterMode.Virtual, "kWh");
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner); // Tag the PV meters' roles (config, not hardcoded names) so the Solar panel can derive
// self-consumption = total_load − grid_import and savings generically (SDD §8.4).
haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad);
netz.Meta = MeterMeta.WithRole(netz.Meta, MeterRoles.GridImport);
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner, sumSolar);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_db.Tanks.Add(new Tank _db.Tanks.Add(new Tank
@@ -59,6 +70,16 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}", Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
}); });
// Demo flow chain (electricity /energy view): Solar 1 + Solar 2 → Summe Solar; then
// Grid + Summe Solar → Haus → Auto + "Other". The remainder under Grid/Summe Solar is the
// input that didn't reach the house load (solar export / battery / inverter losses).
_db.MeterLinks.AddRange(
new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id },
new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id },
new MeterLink { FromMeterId = netz.Id, ToMeterId = haus.Id },
new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = haus.Id },
new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id });
AddElectricityTariffs(electricity); AddElectricityTariffs(electricity);
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1)); AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the // Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
@@ -86,12 +107,32 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }], Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
}; };
private static readonly string[] RequiredFiles = [ElectricityFile, WaterFile, OilFile, CostsFile];
/// <summary>Throws a clear error if the sample directory or any reference CSV is missing, so a
/// failed load surfaces to the user instead of silently seeding meters with no data.</summary>
private static void EnsureSampleFilesPresent(string sampleDataDirectory)
{
if (!Directory.Exists(sampleDataDirectory))
{
throw new DirectoryNotFoundException(
$"Reference-data directory not found: '{sampleDataDirectory}'. The bundled Energiebilanz CSVs are missing from this deployment.");
}
var missing = RequiredFiles.Where(f => !File.Exists(Path.Combine(sampleDataDirectory, f))).ToList();
if (missing.Count > 0)
{
throw new FileNotFoundException(
$"Reference CSV(s) missing from '{sampleDataDirectory}': {string.Join(", ", missing)}.");
}
}
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken) private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
{ {
var path = Path.Combine(dir, file); var path = Path.Combine(dir, file);
if (!File.Exists(path)) if (!File.Exists(path))
{ {
return; throw new FileNotFoundException($"Reference CSV disappeared during import: '{path}'.", path);
} }
StagedImport staged; StagedImport staged;
@@ -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; }
}
} }
@@ -19,6 +19,13 @@ public sealed class MeterVaultOptions
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary> /// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
public bool RunMigrationsAtStartup { get; set; } = true; public bool RunMigrationsAtStartup { get; set; } = true;
/// <summary>
/// Load the bundled Energiebilanz reference dataset on startup if the database has none yet
/// (idempotent — guarded by a marker meter). Off by default; set <c>MeterVault__SeedReferenceData=true</c>
/// for a one-command populated demo/test instance.
/// </summary>
public bool SeedReferenceData { get; set; }
/// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary> /// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary>
public bool EnableLiveIngestion { get; set; } = true; public bool EnableLiveIngestion { get; set; } = true;
@@ -16,6 +16,7 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
public DbSet<EnergyType> EnergyTypes => Set<EnergyType>(); public DbSet<EnergyType> EnergyTypes => Set<EnergyType>();
public DbSet<Meter> Meters => Set<Meter>(); public DbSet<Meter> Meters => Set<Meter>();
public DbSet<MeterSource> MeterSources => Set<MeterSource>(); public DbSet<MeterSource> MeterSources => Set<MeterSource>();
public DbSet<MeterLink> MeterLinks => Set<MeterLink>();
public DbSet<Reading> Readings => Set<Reading>(); public DbSet<Reading> Readings => Set<Reading>();
public DbSet<Consumption> Consumption => Set<Consumption>(); public DbSet<Consumption> Consumption => Set<Consumption>();
public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>(); public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>();
@@ -74,6 +75,16 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
e.HasIndex(x => x.MeterId); e.HasIndex(x => x.MeterId);
}); });
b.Entity<MeterLink>(e =>
{
e.ToTable("meter_link");
e.HasKey(x => x.Id);
e.HasOne(x => x.FromMeter).WithMany().HasForeignKey(x => x.FromMeterId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(x => x.ToMeter).WithMany().HasForeignKey(x => x.ToMeterId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => new { x.FromMeterId, x.ToMeterId }).IsUnique();
e.ToTable(t => t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id"));
});
// Hypertable — the (meter_id, time) PK contains the partition column (time), // Hypertable — the (meter_id, time) PK contains the partition column (time),
// which Timescale requires. Converted to a hypertable in a raw-SQL migration. // which Timescale requires. Converted to a hypertable in a raw-SQL migration.
b.Entity<Reading>(e => b.Entity<Reading>(e =>
@@ -0,0 +1,931 @@
// <auto-generated />
using System;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MeterVaultDbContext))]
[Migration("20260714114901_AddMeterLinks")]
partial class AddMeterLinks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("key");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("value");
b.HasKey("Key")
.HasName("pk_app_setting");
b.ToTable("app_setting", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
{
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<short>("Kind")
.HasColumnType("smallint")
.HasColumnName("kind");
b.Property<double>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<short>("Quality")
.HasColumnType("smallint")
.HasColumnName("quality");
b.HasKey("MeterId", "Time", "Kind")
.HasName("pk_consumption");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_consumption_import_batch_id");
b.ToTable("consumption", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ColorHex")
.HasColumnType("text")
.HasColumnName("color_hex");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("name");
b.Property<int>("Sort")
.HasColumnType("integer")
.HasColumnName("sort");
b.HasKey("Id")
.HasName("pk_cost_category");
b.ToTable("cost_category", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CategoryId")
.HasColumnType("integer")
.HasColumnName("category_id");
b.Property<short?>("EnergyTypeId")
.HasColumnType("smallint")
.HasColumnName("energy_type_id");
b.Property<int?>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.HasKey("Id")
.HasName("pk_cost_category_member");
b.HasIndex("CategoryId")
.HasDatabaseName("ix_cost_category_member_category_id");
b.HasIndex("EnergyTypeId")
.HasDatabaseName("ix_cost_category_member_energy_type_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_cost_category_member_meter_id");
b.ToTable("cost_category_member", null, t =>
{
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
});
});
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
{
b.Property<short>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("smallint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
b.Property<string>("BaseUnit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("base_unit");
b.Property<string>("ColorHex")
.HasColumnType("text")
.HasColumnName("color_hex");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DefaultMode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("default_mode");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("display_name");
b.Property<string>("Icon")
.HasColumnType("text")
.HasColumnName("icon");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("key");
b.HasKey("Id")
.HasName("pk_energy_type");
b.HasIndex("Key")
.IsUnique()
.HasDatabaseName("ix_energy_type_key");
b.ToTable("energy_type", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Mapping")
.HasColumnType("jsonb")
.HasColumnName("mapping");
b.Property<DateTimeOffset?>("RevertedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("reverted_at");
b.Property<int>("RowCount")
.HasColumnType("integer")
.HasColumnName("row_count");
b.Property<string>("SourceName")
.HasColumnType("text")
.HasColumnName("source_name");
b.HasKey("Id")
.HasName("pk_import_batch");
b.ToTable("import_batch", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Config")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is_enabled");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("LastStatus")
.HasColumnType("text")
.HasColumnName("last_status");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("name");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_ingestion_endpoint");
b.ToTable("ingestion_endpoint", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<double>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<int?>("CategoryId")
.HasColumnType("integer")
.HasColumnName("category_id");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("currency");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<int?>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<DateOnly>("PeriodEnd")
.HasColumnType("date")
.HasColumnName("period_end");
b.Property<DateOnly>("PeriodStart")
.HasColumnType("date")
.HasColumnName("period_start");
b.HasKey("Id")
.HasName("pk_manual_cost");
b.HasIndex("CategoryId")
.HasDatabaseName("ix_manual_cost_category_id");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_manual_cost_import_batch_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_manual_cost_meter_id");
b.ToTable("manual_cost", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<short>("EnergyTypeId")
.HasColumnType("smallint")
.HasColumnName("energy_type_id");
b.Property<double>("InitialBaseline")
.HasColumnType("double precision")
.HasColumnName("initial_baseline");
b.Property<DateOnly?>("InstalledAt")
.HasColumnType("date")
.HasColumnName("installed_at");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("Location")
.HasColumnType("text")
.HasColumnName("location");
b.Property<string>("Manufacturer")
.HasColumnType("text")
.HasColumnName("manufacturer");
b.Property<string>("Meta")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("meta")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("mode");
b.Property<string>("Model")
.HasColumnType("text")
.HasColumnName("model");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<DateOnly?>("RetiredAt")
.HasColumnType("date")
.HasColumnName("retired_at");
b.Property<string>("SerialNumber")
.HasColumnType("text")
.HasColumnName("serial_number");
b.Property<string>("Unit")
.IsRequired()
.HasColumnType("text")
.HasColumnName("unit");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_meter");
b.HasIndex("EnergyTypeId", "IsActive")
.HasDatabaseName("ix_meter_energy_type_id_is_active");
b.ToTable("meter", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<double?>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("event_type");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<string>("Meta")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("meta")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<double?>("NewValue")
.HasColumnType("double precision")
.HasColumnName("new_value");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<double?>("PrevValue")
.HasColumnType("double precision")
.HasColumnName("prev_value");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<string>("Unit")
.HasColumnType("text")
.HasColumnName("unit");
b.HasKey("Id")
.HasName("pk_meter_event");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_meter_event_import_batch_id");
b.HasIndex("MeterId", "Time")
.HasDatabaseName("ix_meter_event_meter_id_time");
b.ToTable("meter_event", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromMeterId")
.HasColumnType("integer")
.HasColumnName("from_meter_id");
b.Property<int>("ToMeterId")
.HasColumnType("integer")
.HasColumnName("to_meter_id");
b.HasKey("Id")
.HasName("pk_meter_link");
b.HasIndex("ToMeterId")
.HasDatabaseName("ix_meter_link_to_meter_id");
b.HasIndex("FromMeterId", "ToMeterId")
.IsUnique()
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
b.ToTable("meter_link", null, t =>
{
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
});
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Config")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int?>("EndpointId")
.HasColumnType("integer")
.HasColumnName("endpoint_id");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is_enabled");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("LastStatus")
.HasColumnType("text")
.HasColumnName("last_status");
b.Property<double?>("LastValue")
.HasColumnType("double precision")
.HasColumnName("last_value");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<double>("Offset")
.HasColumnType("double precision")
.HasColumnName("offset");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Scale")
.ValueGeneratedOnAdd()
.HasColumnType("double precision")
.HasDefaultValue(1.0)
.HasColumnName("scale");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("source_type");
b.Property<string>("ValueKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("value_kind");
b.HasKey("Id")
.HasName("pk_meter_source");
b.HasIndex("EndpointId")
.HasDatabaseName("ix_meter_source_endpoint_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_meter_source_meter_id");
b.ToTable("meter_source", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
{
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<int>("Flags")
.HasColumnType("integer")
.HasColumnName("flags");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<short>("Quality")
.HasColumnType("smallint")
.HasColumnName("quality");
b.Property<int?>("SourceId")
.HasColumnType("integer")
.HasColumnName("source_id");
b.Property<double>("Value")
.HasColumnType("double precision")
.HasColumnName("value");
b.HasKey("MeterId", "Time")
.HasName("pk_reading");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_reading_import_batch_id");
b.ToTable("reading", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset?>("CachedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("cached_at");
b.Property<double?>("CachedBalance")
.HasColumnType("double precision")
.HasColumnName("cached_balance");
b.Property<string>("Calibration")
.HasColumnType("jsonb")
.HasColumnName("calibration");
b.Property<double>("Capacity")
.HasColumnType("double precision")
.HasColumnName("capacity");
b.Property<double?>("FixedRate")
.HasColumnType("double precision")
.HasColumnName("fixed_rate");
b.Property<double?>("LowThreshold")
.HasColumnType("double precision")
.HasColumnName("low_threshold");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<string>("RateMode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("rate_mode");
b.Property<double?>("ReorderThreshold")
.HasColumnType("double precision")
.HasColumnName("reorder_threshold");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("unit");
b.HasKey("Id")
.HasName("pk_tank");
b.HasIndex("MeterId")
.IsUnique()
.HasDatabaseName("ix_tank_meter_id");
b.ToTable("tank", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Component")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("component");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("currency");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<int?>("ScopeId")
.HasColumnType("integer")
.HasColumnName("scope_id");
b.Property<string>("ScopeType")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("scope_type");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("unit");
b.Property<DateOnly>("ValidFrom")
.HasColumnType("date")
.HasColumnName("valid_from");
b.Property<DateOnly?>("ValidTo")
.HasColumnType("date")
.HasColumnName("valid_to");
b.Property<double>("Value")
.HasColumnType("double precision")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_tariff");
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
b.ToTable("tariff", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_consumption_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
{
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
.WithMany("Members")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
.WithMany()
.HasForeignKey("EnergyTypeId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_cost_category_member_meter_meter_id");
b.Navigation("Category");
});
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
{
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_manual_cost_cost_category_category_id");
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_manual_cost_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
.WithMany("Meters")
.HasForeignKey("EnergyTypeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_meter_energy_type_energy_type_id");
b.Navigation("EnergyType");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_event_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
.WithMany()
.HasForeignKey("FromMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_from_meter_id");
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
.WithMany()
.HasForeignKey("ToMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_to_meter_id");
b.Navigation("FromMeter");
b.Navigation("ToMeter");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
.WithMany()
.HasForeignKey("EndpointId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
.WithMany("Sources")
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_source_meter_meter_id");
b.Navigation("Endpoint");
b.Navigation("Meter");
});
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_reading_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tank_meter_meter_id");
b.Navigation("Meter");
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
{
b.Navigation("Meters");
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.Navigation("Sources");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddMeterLinks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "meter_link",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
from_meter_id = table.Column<int>(type: "integer", nullable: false),
to_meter_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_meter_link", x => x.id);
table.CheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
table.ForeignKey(
name: "fk_meter_link_meter_from_meter_id",
column: x => x.from_meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_meter_link_meter_to_meter_id",
column: x => x.to_meter_id,
principalTable: "meter",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_meter_link_from_meter_id_to_meter_id",
table: "meter_link",
columns: new[] { "from_meter_id", "to_meter_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_meter_link_to_meter_id",
table: "meter_link",
column: "to_meter_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "meter_link");
}
}
}
@@ -499,6 +499,39 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
b.ToTable("meter_event", (string)null); b.ToTable("meter_event", (string)null);
}); });
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromMeterId")
.HasColumnType("integer")
.HasColumnName("from_meter_id");
b.Property<int>("ToMeterId")
.HasColumnType("integer")
.HasColumnName("to_meter_id");
b.HasKey("Id")
.HasName("pk_meter_link");
b.HasIndex("ToMeterId")
.HasDatabaseName("ix_meter_link_to_meter_id");
b.HasIndex("FromMeterId", "ToMeterId")
.IsUnique()
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
b.ToTable("meter_link", null, t =>
{
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
});
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b => modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -812,6 +845,27 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_meter_event_meter_meter_id"); .HasConstraintName("fk_meter_event_meter_meter_id");
}); });
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
.WithMany()
.HasForeignKey("FromMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_from_meter_id");
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
.WithMany()
.HasForeignKey("ToMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_to_meter_id");
b.Navigation("FromMeter");
b.Navigation("ToMeter");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b => modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{ {
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint") b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
+61
View File
@@ -0,0 +1,61 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests;
public sealed class MeterMetaTests
{
[Fact]
public void Role_reads_configured_role()
{
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role("{\"role\":\"grid_import\"}"));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
[InlineData("not json")]
[InlineData("{\"role\":123}")]
[InlineData("[1,2,3]")]
public void Role_is_null_when_absent_or_malformed(string meta)
{
Assert.Null(MeterMeta.Role(meta));
}
[Fact]
public void WithRole_sets_role_and_preserves_other_keys()
{
var updated = MeterMeta.WithRole("{\"expression\":\"a-b\"}", MeterRoles.TotalLoad);
Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(updated));
Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression"));
}
[Fact]
public void WithRole_overwrites_existing_role()
{
var updated = MeterMeta.WithRole("{\"role\":\"grid_import\"}", MeterRoles.GridExport);
Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(updated));
}
[Fact]
public void WithRole_handles_empty_meta()
{
var updated = MeterMeta.WithRole("", MeterRoles.GridImport);
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"));
}
}
@@ -1,4 +1,5 @@
using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import; using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -43,6 +44,44 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1); Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
} }
// Panel read models compute real figures from the reference data (SDD §8.4–§8.6).
int hausId;
short electricityTypeId;
using (var scope = factory.Services.CreateScope())
{
var services = scope.ServiceProvider;
var wide = new DateOnly(1997, 1, 1);
var toEnd = new DateOnly(2027, 1, 1);
var solar = await services.GetRequiredService<SolarService>().GetSummaryAsync(wide, toEnd);
Assert.True(solar.HasGeneration);
Assert.True(solar.Generation > 0);
// Haus (total_load) + Netz (grid_import) are role-tagged, so self-consumption/savings resolve.
Assert.True(solar.HasLoadContext);
Assert.NotNull(solar.SelfConsumption);
Assert.NotNull(solar.Savings);
var consumables = await services.GetRequiredService<ConsumableService>().GetConsumablesAsync(wide, toEnd);
var oil = Assert.Single(consumables);
Assert.True(oil.CurrentLevel is > 0);
Assert.NotEmpty(oil.Deliveries);
Assert.True(oil.ConsumptionInRange > 0);
await using var db = fx.CreateContext();
hausId = await db.Meters.Where(m => m.Name == "Zähler Haus").Select(m => m.Id).FirstAsync();
var detail = await services.GetRequiredService<MeterDetailService>().GetAsync(hausId);
Assert.NotNull(detail);
Assert.True(detail!.ReadingCount > 0);
Assert.True(detail.TotalConsumption > 0);
// Flow graph: the demo Haus → Auto chain yields a link + an "Other (Haus)" remainder.
electricityTypeId = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync();
var flow = await services.GetRequiredService<FlowService>()
.GetFlowAsync(electricityTypeId, new DateOnly(1997, 1, 1), new DateOnly(2027, 1, 1));
Assert.True(flow.HasChain);
Assert.Contains(flow.Nodes, n => n.IsOther);
}
using var client = factory.CreateClient(); using var client = factory.CreateClient();
var overview = await client.GetAsync(new Uri("/", UriKind.Relative)); var overview = await client.GetAsync(new Uri("/", UriKind.Relative));
@@ -55,7 +94,13 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
Assert.Contains("This year", html, StringComparison.Ordinal); Assert.Contains("This year", html, StringComparison.Ordinal);
Assert.Contains("Latest month with data", html, StringComparison.Ordinal); Assert.Contains("Latest month with data", html, StringComparison.Ordinal);
foreach (var path in new[] { "/meters", "/trends", "/import", "/admin/tariffs", "/admin/energy-types" }) foreach (var path in new[]
{
"/meters", "/trends", "/solar", "/consumables", "/import",
"/admin/tariffs", "/admin/energy-types", "/admin/categories",
"/admin/connectors", "/admin/settings", $"/meters/{hausId}",
$"/energy/{electricityTypeId}",
})
{ {
var response = await client.GetAsync(new Uri(path, UriKind.Relative)); var response = await client.GetAsync(new Uri(path, UriKind.Relative));
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
@@ -70,6 +115,7 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
private static async Task ClearDataAsync(MeterVaultDbContext db) private static async Task ClearDataAsync(MeterVaultDbContext db)
{ {
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync(); await db.Consumption.ExecuteDeleteAsync();
await db.Readings.ExecuteDeleteAsync(); await db.Readings.ExecuteDeleteAsync();
await db.MeterEvents.ExecuteDeleteAsync(); await db.MeterEvents.ExecuteDeleteAsync();
+182
View File
@@ -0,0 +1,182 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full
/// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the
/// child's consumption proportionally to the parents' own consumption.
/// </summary>
[Collection("Timescale")]
public sealed class FlowServiceTests(TimescaleFixture fx)
{
[Fact]
public async Task Single_parent_chain_makes_other_remainder()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_a");
var main = await AddMeterAsync(db, "Main", type);
var car = await AddMeterAsync(db, "Car", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, main.Id, 100);
await AddConsumptionAsync(db, car.Id, 30);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
Assert.Equal(100, graph.Total, 1);
var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}");
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
var other = Assert.Single(graph.Nodes, n => n.IsOther);
Assert.Equal(70, other.Value, 1); // 100 − 30
}
finally
{
await ClearAsync(db);
}
}
[Fact]
public async Task Two_parents_split_child_proportionally()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_b");
var grid = await AddMeterAsync(db, "Grid", type);
var solar = await AddMeterAsync(db, "Solar draw", type);
var house = await AddMeterAsync(db, "House", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, grid.Id, 75);
await AddConsumptionAsync(db, solar.Id, 25);
await AddConsumptionAsync(db, house.Id, 40);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// House (40) splits 75:25 → 30 from grid, 10 from solar.
Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
}
finally
{
await ClearAsync(db);
}
}
[Fact]
public async Task Generation_meter_counts_as_source()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_c");
var grid = await AddMeterAsync(db, "Grid", type);
var solar = await AddMeterAsync(db, "Solar", type);
var house = await AddMeterAsync(db, "House", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, grid.Id, 75); // grid import
await AddConsumptionAsync(db, solar.Id, 30, ConsumptionKind.Generation); // solar generation
await AddConsumptionAsync(db, house.Id, 40); // house load
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar.
Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
// Remainder across grid+solar = (75+30) − 40 = 65 (export + battery/inverter losses).
Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1);
}
finally
{
await ClearAsync(db);
}
}
[Fact]
public async Task Virtual_sum_meter_aggregates_its_upstreams()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_d");
var solar1 = await AddMeterAsync(db, "Solar 1", type);
var solar2 = await AddMeterAsync(db, "Solar 2", type);
var sumSolar = await AddMeterAsync(db, "Sum Solar", type, MeterMode.Virtual);
var grid = await AddMeterAsync(db, "Grid", type);
var house = await AddMeterAsync(db, "House", type);
// Solar1 + Solar2 → Sum Solar ; Grid + Sum Solar → House.
db.MeterLinks.Add(new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, solar1.Id, 15, ConsumptionKind.Generation);
await AddConsumptionAsync(db, solar2.Id, 15, ConsumptionKind.Generation);
await AddConsumptionAsync(db, grid.Id, 75);
await AddConsumptionAsync(db, house.Id, 40);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// Sum Solar has no readings but equals Solar 1 + Solar 2 = 30.
Assert.Equal(30, graph.Nodes.Single(n => n.MeterId == sumSolar.Id).Value, 1);
// House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar.
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{sumSolar.Id}" && l.To == $"m{house.Id}").Value, 1);
// No spurious remainder under Solar 1/2 (their whole output flows into Sum Solar).
Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1.Id}");
}
finally
{
await ClearAsync(db);
}
}
private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
{
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
return type.Id;
}
private static async Task<Meter> AddMeterAsync(MeterVaultDbContext db, string name, short type, MeterMode mode = MeterMode.DirectDelta)
{
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = "kWh" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
return meter;
}
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption)
{
db.Consumption.Add(new Consumption
{
MeterId = meterId,
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
Amount = amount,
Kind = kind,
Quality = ReadingQuality.Manual,
});
await db.SaveChangesAsync();
}
private static async Task ClearAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync();
}
}
@@ -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.");
}
}