0319e5527fb33207199bad9a5d86e3ca7b15f67d
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9eb3f7d53c |
Update: add an opt-in "Update now" button, gated on an API key
ci / build-test (push) Successful in 1m9s
Adds the button, plus POST /api/v1/system/update for driving it from Home Assistant or curl. Both pull the latest source, rebuild and restart the service. The gating is the substance of this change. The updater builds whatever is on the branch and the LXC runs MeterVault as root, so triggering it is root-equivalent on that host, and the web UI has no login — "reachable from the dashboard" alone would mean any device on the network could take the machine. Three independent conditions must hold before anything runs: the operator set MeterVault__AllowInAppUpdate, at least one API key is configured, and the caller presented one, compared in constant time so retries cannot time out the key. AllowAnonymousApi cannot reach it. That flag opens reads, and opening reads must not open root, so the endpoint checks the presented key itself rather than relying on the shared group filter that honours it. Availability is re-checked inside LaunchAsync rather than trusting the caller to have done so. The UI button asks for the key every time instead of remembering it: with no login, a browser left open on the dashboard would otherwise be a standing permission to execute code on the host. The key is cleared from component state immediately, and a wrong key and a keyless deployment give the same message so an unauthenticated caller cannot tell them apart. Launched detached through systemd-run: the updater restarts the service, so a child process would be killed part-way through, leaving the app down with a half-published build. --collect reaps the transient unit so a later update is not blocked by the remains of the previous one. Off by default, and where there is no /usr/bin/update — a container, a dev box — it reports that rather than half-running something. Tests pin every refusal, including through the real HTTP pipeline; none of them launch anything. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
cf7e0396f0 |
Dashboard: report when a newer release is available
ci / build-test (push) Successful in 1m13s
The instance had no idea what version it was: VERSION drives tagging and the image publish, but was never stamped into the assemblies, so a running build reported 1.0.0 forever. Directory.Build.props now stamps it into every project. The dashboard compares that against the newest tag in the source repository and shows a banner when behind. A plain GET of a public tag list -- nothing about the instance is sent -- cached six hours, failing quiet. Two things it deliberately does not do. It never blocks a render: the banner paints from the cached answer and refreshes after first render, so a cold start or an unreachable repository costs nothing rather than holding the dashboard open for an HTTP timeout. And it never guesses: an unknown version on either side shows no banner at all, because a banner that cannot clear trains people to ignore the next real one. Version comparison is numeric on exactly three components, not System.Version and not string order. Tags are written vX.Y.Z, the assembly reports X.Y.Z with a +commithash suffix, and "0.10.0" sorts below "0.9.0" as a string -- each of those is a way the banner sticks or never appears. Prerelease suffixes compare equal to their release so an rc tag does not nag. Gitea does not promise semver ordering, so the highest tag wins rather than the first. The command shown depends on the install: the LXC has `update`, a container is replaced by pulling an image, and telling container users to run `update` sends them after a command that does not exist. No update *button*. The UI has no authentication and the LXC runs the app as root, and `update` builds whatever is on master, so a click would be an unauthenticated path to arbitrary code execution for anything on the LAN. The README now states the no-auth position plainly rather than leaving it implied. Tests cover the parse and ordering cases that would strand a banner, the Gitea payload shape captured from the live API, unreachable and garbage responses, and that the VERSION file actually reaches the assembly -- read from MeterVault's own assembly rather than GetEntryAssembly(), which under `dotnet test` is the test host and reported a confident wrong answer. The suite makes no outbound request: the app factory disables the check. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
95c51842e8 |
Meter detail: lead with periods and change, not register totals
ci / build-test (push) Successful in 1m23s
The headline tiles were lifetime consumption, a raw reading count and the register span. None of those answer why someone opens a meter: how much this month, more or less than last, where the year lands, what it costs. A cumulative counter's register value is an accident of when the meter was installed. MeterPeriodService buckets consumption by calendar month in the instance timezone -- via date_trunc(... AT TIME ZONE) rather than EF grouping, because a reading at 00:30 local on 1 January is 23:30 on 31 December in UTC and would be booked to the wrong month (SDD §10). It reports generation for a generation counter and consumption otherwise, so a PV meter stops claiming it consumed 0 kWh. Month- and year-to-date are compared against a projection of the current period rather than its running total. Three days into a month, "12 kWh vs 340 kWh last month" reads as a collapse in usage when nothing has changed. The projection is straight-line on elapsed days -- wrong for anything seasonal, but the honest reading of "at this rate" -- and the UI marks it with a leading ~. A 12-month bar strip gives the shape at a glance. A meter with nothing normalized yet returns an empty history rather than a flat line, which would look like a meter reading zero. Register span, reading count and lifetime total move into a collapsed panel. Still there when needed for an audit, no longer the first thing you see. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
e23df37a3f |
Connectors: allow secrets to be entered in the UI, encrypted at rest
ci / build-test (push) Successful in 1m12s
Reference-only secrets (SDD §6.4) meant adding a connector required editing a file on the server and restarting the service. In practice that leads to the token being pasted into the env-var *name* field, which fails with "environment variable '<token>' is not set" and gives no hint what went wrong. Add a second storage form, chosen per connector: type the secret in and it is encrypted via ASP.NET Core data protection before it is stored. The env-var reference stays as an equal alternative — this widens the choice rather than replacing it. Exactly one form survives a save, so a stale secret cannot linger and silently win; EndpointSecret.Resolve is the single resolution path. The guarantee that matters is preserved: no plaintext in the database, so pg_dump and JSON exports carry nothing usable. The trust boundary is stated plainly in §6.4 — the key ring is on disk, so this protects against leaked database content, not an attacker who already has the host, which is the same boundary an env var has. Details worth noting: - Key ring defaults to /var/lib/metervault/keys, outside the app directory, because the LXC updater republishes /opt/metervault on every update. Docker gets a named volume. Overridable via MeterVault__DataProtectionKeyPath. - Undecryptable ciphertext (key ring lost) falls back rather than throwing: an ingestion worker on a timer should degrade, not crash. - The stored secret is never sent to the browser; a blank field means "unchanged", not "cleared". - MQTT usernames are stored as-is — §6.4 covers tokens and passwords, and encrypting a username would only blank the field on every edit. - ExportService drops *_enc values: bound to the originating key ring, so useless where an export would be restored. Expect to re-enter after a restore. - HaConnectionTester now takes a resolved token, so the admin UI can test a token that has been typed but not yet saved. SDD §6.4 and §9 updated to describe both forms rather than contradict the code. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd |
||
|
|
8550ed8d9e |
Ingestion: Home Assistant WebSocket push path
ci / build-test (push) Successful in 1m16s
HomeAssistantWebSocketWorker holds a persistent state_changed subscription per HA endpoint that opts in via the connector's WebSocket toggle (HaEndpointConfig.UseWebSocket): auth handshake, subscribe, ingest in real time, capped-backoff reconnect. The REST poll worker skips WS endpoints so each is served once. HaWebSocketProtocol holds the pure handshake/parse logic. Verified by 11 protocol unit tests + a live integration test against an in-process fake HA server. CLAUDE.md updated. Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB |
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
a6edec2b12 |
Polish/audit: fix bugs found by 3 subsystem audits
ci / build-test (push) Successful in 2m45s
Correctness/data: - Fix demo cost double-count: reference importer no longer imports the Kosten Strom/Wasser columns for categories that are metered (only Heizung), so Wasser rollup is 70€ not 140€. - Spurious-decrease guard: only a reset/swap in the window (prevReading, thisReading] explains a decrease — an old historical reset no longer permanently disables the guard. - Gate swap auto-detection on MappingProfile.DetectCumulativeSwaps (flag was ignored). - Prorate basePrice by bucket length (day/month/year); guard virtual expressions against NaN/Inf. Concurrency/infra: - Blazor: register a DbContextFactory; CostService/DashboardService and the read pages now use short-lived per-operation contexts (no shared circuit DbContext); guard Trends re-entrancy. - /events: wrap event insert + consumption recompute in one transaction (atomic); 404 (not 500) on unknown meter. - MQTT worker: subscribe to newly-added topics on each tick; move client cleanup into finally. - Migrations: CREATE MATERIALIZED VIEW IF NOT EXISTS + if_not_exists on CAgg/compression/ hypertable calls (re-run-safe after a mid-migration crash). - HA worker: prune stale poll-schedule entries; export: null dangling ImportBatchIds on restore. API/security: - API fail-closed by default: with no keys and AllowAnonymousApi off, /api/v1 returns 401 (protects /export and /import). New MeterVault:AllowAnonymousApi opt-in. - Cap /readings batch at 5000; report ignored (unknown-meter) count; enums as strings in JSON. +4 regression tests (guard window, API closed, /events 404, no demo double-count). 98 tests green; Docker deploy re-verified healthy with the API fail-closed. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
e223278771 |
M7: release polish — export/import, docs, easy docker push
- Easy docker push (MQTTower ergonomics + the image push it lacks): VERSION file → version-tag.yml (semver-guard auto-tag) → docker-publish.yml (buildx multi-arch → GHCR, registry centralized for one-line retarget to git.finalfactory.de) + ci.yml + build-and-push.ps1. - Verified end to end: deploy/Dockerfile builds; docker compose stack (app + timescaledb) comes up healthy; /healthz and the dashboard respond in-container. - JSON config export/import (ExportService) with id remapping on restore + GET /export, POST /import endpoints; round-trip test preserves meter→type, meter-scoped tariff, category links. - README, HA/Tasmota/MQTT wiring guide (docs/wiring.md), Unraid template. - CLAUDE.md updated to reflect the built codebase. - i18n: locale-aware number/currency formatting (de-DE); full de UI string localization deferred. 95 tests green (56 Core + 39 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
d5419729e5 |
M5: Blazor dashboard (MudBlazor + ApexCharts)
- MudBlazor theme (dark default) + responsive drawer/appbar layout + nav. - DashboardService read model: KPIs with period-over-period deltas, category breakdown, "what cost more/less" difference view, monthly trends. - Pages: Overview (KPI cards + DeltaChip + donut + difference table), Trends (range-select bar chart), Meters (list + source status), Import (load reference dataset + CSV dry-run preview), Admin (energy types, tariffs). Charts isolated into components to avoid the ApexCharts/MudBlazor Color/Format name clashes. - ReferenceDataImporter: one-click load of all four sheets as a starter dataset (meters, tank, tariff history, category memberships) — bundled sample CSVs copied to app output. - End-to-end render test: import creates meters + consumption; overview/meters/trends/ import/admin pages all return 200 with KPI cards rendered. 92 tests green (56 Core + 36 integration). Deferred to polish: dedicated PV & oil/consumable panels, meter-detail page, full admin CRUD, prev-year trend overlay. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
6fc710d55a |
M4: aggregation + tariff-aware cost engine
- ContinuousAggregates migration: consumption_daily/monthly/yearly in Europe/Berlin buckets via migrationBuilder.Sql(..., suppressTransaction: true), with refresh policies (end_offset >= 1 bucket; current bucket covered by real-time aggregation). - TariffResolver (Core): time-ranged price resolution, scope precedence meter > type > global. - CostService: Dapper-aggregated monthly consumption × resolved unit price (+base, -feed-in), month-dominant pricing; category rollups over member meters + meterless manual costs. - Tests: water cost reconciles to the sheet's Kosten column, Wasser category rollup (Dez=70€), monthly CAgg refresh matches base, TariffResolver unit tests. 91 tests green (56 Core + 35 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
4b0cad67df |
M3: live ingestion (MQTT/Tasmota + Home Assistant)
- PayloadExtractor: dot-path value/time extraction (Tasmota ENERGY.Total, bare scalars). - MqttTopicMatcher: standard +/# wildcard matching. - IngestionService: scale/offset, idempotent upsert on (meter_id, time), and a spurious- decrease guard for monotonic registers (allowed only with a reset/swap event) + source last-seen status. - MqttMessageRouter + MqttIngestionWorker (MQTTnet 5): per-endpoint persistent connections, topic subscription, graceful degradation; secrets resolved by env-var reference. - Home Assistant: HaStateClient (REST /api/states parse) + HomeAssistantWorker polling on each source's interval. HA-via-MQTT also works through the MQTT path. - Ingestion workers gated by MeterVault:EnableLiveIngestion (off in tests). 85 tests green (53 Core + 32 integration): Tasmota payload → reading verified end to end. Follow-up (polish): HA WebSocket push (state_changed) as an alternative to REST poll; source-topic index caching in the router. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
5977c81002 |
M2: German CSV importer + reconciliation of all 4 fixtures
- German-dialect scalar parsers in Core (GermanNumber/Money/Date, ValueCell): decimal comma, thousands dot, unit suffixes, € currency, both date shapes. - Declarative MappingProfile + RowClassifier (skip summary/blank/all-zero rows) + CsvImporter (CsvHelper) staging readings/events/manual-costs, with auto swap detection on register decreases and month-end anchoring for interleaved oil dates. - Four built-in ReferenceProfiles (Strom/Wasser/Heizöl/Kosten). - ImportService: commit as revertible import_batch + wholesale consumption recompute per affected meter (NormalizationService/MeterConfigFactory), revert by batch. - Reconciliation tests: all 4 CSVs match the sheet's own columns within tolerance (electricity 5 meters + Netz Einsparung, water swap→12, oil tank incl. deliveries + burner hours, cost category totals). Commit/revert round-trip verified on Timescale. 69 tests green (53 Core + 16 integration). Known follow-up (polish): historical imports can contend with the 30-day compression policy's background job; tests pause it. Consider retry-on-deadlock or deferred compression for large historical imports in production. Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |
||
|
|
48a7f5a825 |
M0: scaffold solution, EF+Timescale schema, /healthz
Five-project Clean Architecture solution (Core/Infrastructure/App + Core.Tests/ Integration.Tests) on .NET 10 with central package management, snake_case EF mapping, and shared build/style config. - Full domain entity set + EF DbContext for the SDD §5.3 schema (singular table names). - InitialSchema migration (relational) + TimescaleHypertables migration (raw SQL: create_hypertable + compression on reading, hypertable on consumption). - App wiring: Serilog (actually wired, unlike MQTTower), DbContext, migrate-on-startup, /healthz. Serves plain HTTP behind a reverse proxy (no HTTPS redirect). - deploy/Dockerfile (2-stage, ICU-capable) + docker-compose (app + timescaledb). - Integration.Tests: shared TimescaleFixture (Testcontainers) — migrations, hypertables, compression policy, and /healthz all verified green (4/4). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr |