Commit Graph
10 Commits
Author SHA1 Message Date
Florian Schmidt bfa0b537ee i18n: ship the UI in English and German
ci / build-test (push) Failing after 35s
The last open item on the M7 list. Number and currency formatting was
already locale-aware, but every string in the UI was an English literal,
so a German instance read half in each language -- German data, English
chrome. This translates all of it and adds the machinery to keep it
translated.

Strings live in Localization/Strings.resx (English, neutral) and
Strings.de.resx. The neutral file generates a strongly-typed accessor at
build time, aliased as S in _Imports.razor, so components reference
compiled properties -- @S.Common_Save, not a string key. That choice is
the point: across 4,500 lines of markup, a key lookup that silently
falls back to its own name is a defect you find in production, while a
renamed property is a build error. Generation runs in MSBuild rather
than the IDE designer, so dotnet build alone reproduces it anywhere.

Resource fallback is the hazard here. Ask for a key the German satellite
lacks and ResourceManager quietly serves the English one -- correct at
runtime, disastrous at release time, because a half-translated build
looks perfectly healthy. StringResourceTests reads each satellite with
tryParents: false, which is the only way to see what one actually
contains, and fails on a missing or blank translation, a placeholder
that changed arity, an orphan, or a key nothing references.

Three things needed more than substitution:

- Domain enums reached the screen as bare identifiers. They stay bare in
  the model -- they are persisted as text and appear in the REST API, so
  their names are part of the data contract -- and DisplayNames is now
  the single place that decides how each value is spoken. Every arm ends
  in a fallback returning the identifier, so a value added later cannot
  throw mid-render; EnumDisplayNameTests is what stops that safety net
  quietly becoming the shipping behaviour.

- Infrastructure was writing display text: FlowService's "Other (X)",
  MeterPeriodView's "Generation"/"Consumption", the HA connection-test
  verdicts, the updater's snackbar, the CSV importer's row warnings.
  Each now returns an outcome value and the UI supplies the words, which
  is where the reader's language is known. Diagnostics that are not ours
  -- an HTTP status, systemd's stderr, an exception message -- are passed
  through untranslated, and every English summary is kept alongside the
  outcome so log lines never move with the UI language. The UpdateRunner
  change is additive only; no gate was touched.

- Importer warnings carry their arguments rather than a finished
  sentence, so the numbers inside them pick up the reader's grouping. A
  register that reads 2.940,19 everywhere else must not read 2940.19
  only inside a warning.

Switching language is a redirect through /culture/set followed by a full
reload, not an interactive state change: a Blazor Server circuit is fixed
to the culture of the request that opened it. That makes the endpoint a
redirector taking its target from the query string, so anything but a
local path is refused rather than followed. Preference order is the
cookie, then Accept-Language, then MeterVault__Locale -- an instance can
be pinned to one language and a reader can still switch.

Locale keeps its documented default of "en". Format now follows
CurrentCulture instead of a hardcoded de-DE, so an instance with nothing
configured and a browser asking for English will show English number
formatting where it previously showed German; set MeterVault__Locale=de
to pin the old behaviour. The importer's de-DE parsing is untouched and
stays that way -- that dialect is a property of the spreadsheets, not of
whoever is looking at the dashboard.

Anything that comes from the database -- meter names, energy-type display
names, category names -- is user data and is never translated.

Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
2026-08-13 16:36:25 +02:00
schmidt.florian 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
2026-07-18 15:41:52 +02:00
schmidt.florian fe3d81b192 Deploy: change default HTTP port 8080 -> 8760
8080 is a very common, collision-prone port. 8760 (hours in a year) is an uncommon default that also avoids Home Assistant's 8123. Applied across Dockerfile (ASPNETCORE_URLS/EXPOSE), compose (mapping + healthcheck), the Unraid template, README and a code comment. The METERVAULT_PORT host override still works.

Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB
2026-07-17 11:05:01 +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
schmidt.florian 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
2026-07-13 12:56:51 +02:00
schmidt.florian 9abc2937c2 M6: REST API + API-key auth + OpenAPI
- Minimal API under /api/v1 (SDD §9): POST /readings (idempotent HA push), GET /meters,
  /energy-types, /consumption, /cost, /dashboard/summary, POST /events (records + recomputes),
  GET+POST /tariffs, GET /sources/status.
- IngestionService.IngestByMeterAsync for direct REST push (batch-safe upsert via Local cache).
- ApiKeyFilter: X-Api-Key enforced against configured keys (open only when none set).
- ReverseProxyTrust middleware: adopt X-Forwarded-User/Remote-User behind Authelia/Traefik.
- Swagger/OpenAPI (Swashbuckle) at /swagger.
- Tests: push rejected without key (401), accepted + persisted with key; meters + swagger live.

94 tests green (56 Core + 38 integration).

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
2026-07-13 12:16:49 +02:00
schmidt.florian 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
2026-07-13 12:11:12 +02:00
schmidt.florian 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
2026-07-13 11:45:20 +02:00
schmidt.florian d972f67bad M1: pure normalization engine + default seed
Infrastructure-free normalization engine in Core dispatching on MeterMode:
- Cumulative/Generation counters: register deltas, first-reading baseline (Haus 411,
  Auto 3755), meter swaps (water …861→2 reconciles to 12 via boundary registers OR an
  explicit amount override), counter resets, anomaly-guarded decreases.
- RuntimeCounter: Δhours × rate (fixed/empirical).
- ConsumableBalance: tank level-Δ + deliveries → consumption, cm→litre calibration;
  delivery-only rows before the first dipstick emit nothing.
- DirectDelta, and Virtual meters via a small safe arithmetic evaluator (Netz Einsparung
  = Haus − Netz, Eigenverbrauch = Erzeugung − Einsparung) — data-driven, not hardcoded.
- DatabaseSeeder: default energy types, cost categories, base settings (idempotent).

24 Core unit tests + 4 integration tests green.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
2026-07-13 11:12:03 +02:00
schmidt.florian 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
2026-07-13 11:02:21 +02:00