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
162 lines
9.3 KiB
Markdown
162 lines
9.3 KiB
Markdown
# MeterVault
|
||
|
||
A self-hosted, local-first **energy & utility metering platform**. MeterVault pulls meter data
|
||
from Home Assistant, Tasmota and raw 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, district heat, …) and meters are **user-defined — nothing
|
||
is hardcoded**.
|
||
|
||
Successor to a hand-maintained *Energiebilanz* spreadsheet. See [`docs/SDD.md`](docs/SDD.md) for the
|
||
full design.
|
||
|
||
## Features
|
||
|
||
- **Automatic ingestion** from MQTT/Tasmota (persistent subscriptions) and Home Assistant (REST
|
||
poll or push), plus manual entry, a REST push API, and CSV import.
|
||
- **Immutable raw readings** on a TimescaleDB hypertable; a normalized, append-only **consumption**
|
||
layer on top — reproducible, auditable.
|
||
- **Seven measurement modes** (cumulative/generation registers, burner runtime, tank/consumable,
|
||
direct delta, instant rate, virtual). Handles meter swaps, counter resets, tank dip-sticks with
|
||
calibration, and **virtual meters** defined by an expression (PV self-consumption, savings, net).
|
||
- **Tariff engine** with time-ranged price history (unit/base/feed-in), scoped global / per type /
|
||
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
||
- **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/
|
||
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).
|
||
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
||
|
||
## Quick start (Docker)
|
||
|
||
```bash
|
||
docker compose -f deploy/docker-compose.yml up -d
|
||
# open http://localhost:8760 → 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:8760/swagger
|
||
```
|
||
|
||
## Quick start (Proxmox VE LXC)
|
||
|
||
A community-scripts–style installer builds a self-contained LXC (Debian + PostgreSQL/TimescaleDB +
|
||
the app as a systemd service). Run **on the Proxmox host**:
|
||
|
||
```bash
|
||
bash -c "$(curl -fsSL https://git.finalfactory.de/FinalFactory/MeterVault/raw/branch/master/deploy/ct/metervault.sh)"
|
||
```
|
||
|
||
It asks the standard container questions, optionally loads the demo dataset, and prints the URL
|
||
(`http://<ct-ip>:8760`) plus the generated DB password. Re-run `update` inside the container to pull
|
||
the latest source and rebuild. It builds from this public Gitea repo (there is no prebuilt tarball —
|
||
releases ship as a container image). See [`deploy/ct/metervault.sh`](deploy/ct/metervault.sh) and
|
||
[`deploy/install/metervault-install.sh`](deploy/install/metervault-install.sh).
|
||
|
||
Configuration is via environment variables (`Section__Key` double-underscore mapping), e.g.:
|
||
|
||
| Variable | Purpose |
|
||
|----------|---------|
|
||
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
|
||
| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) |
|
||
| `MeterVault__Locale` | Default UI language, `en` or `de` (default `en`). Each visitor can switch it from the app bar; the choice is remembered in a cookie. |
|
||
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
|
||
| `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__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
||
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
|
||
| `MeterVault__DataProtectionKeyPath` | Where the key ring for UI-entered connector secrets lives (default `/var/lib/metervault/keys`) |
|
||
| `MeterVault__UpdateCheckEnabled` | `false` to stop the dashboard checking for a newer release |
|
||
| `MeterVault__AllowInAppUpdate` | `true` to allow updates triggered from the UI/API — no key required, so anything that can reach MeterVault can trigger one; see below |
|
||
| `MeterVault__UpdateCheckUrl` | Tag listing consulted by that check (repoint at a fork; blank also disables 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).
|
||
|
||
> **The web UI has no authentication.** There is no login: anything that can reach the port can read
|
||
> and change everything, including connectors and their stored secrets. Put it behind a reverse proxy
|
||
> with auth (Authelia, Traefik forward-auth, …) — `MeterVault__ReverseProxyTrust` then honours the
|
||
> user header — or keep it on a trusted network.
|
||
|
||
The dashboard compares the running build against the newest tag in the source repository and shows a
|
||
banner when it is behind. That is a plain GET of a public tag list — nothing about the instance is
|
||
sent — cached for six hours, and it never blocks or fails a page render. Turn it off with
|
||
`MeterVault__UpdateCheckEnabled=false`.
|
||
|
||
### Updating from the UI (opt-in)
|
||
|
||
`MeterVault__AllowInAppUpdate=true` adds an **Update now** button to that banner, and a
|
||
`POST /api/v1/system/update` endpoint for scripting it from Home Assistant or `curl`:
|
||
|
||
```bash
|
||
curl -X POST http://metervault:8760/api/v1/system/update -H "X-MeterVault-Update: 1"
|
||
```
|
||
|
||
Both pull the latest source, rebuild, and restart the service — a few minutes during which MeterVault
|
||
is unavailable. Readings are untouched; ingestion resumes on restart. LXC only: containers are
|
||
replaced by pulling a new image, and the endpoint reports that rather than pretending.
|
||
|
||
> **That flag is the whole gate — there is no key and no prompt.** With it on, anything that can
|
||
> reach MeterVault can trigger a rebuild and restart. Realistically that is a repeatable denial of
|
||
> service (minutes of downtime and a busy CPU per request), not code injection, because the build
|
||
> comes from your own repository — but it becomes remote code execution if that repository is ever
|
||
> compromised. It defaults off. Enable it only on a network you trust, or behind an authenticating
|
||
> proxy.
|
||
>
|
||
> The `X-MeterVault-Update` header is **not** authentication: it stops a *different website* driving
|
||
> the endpoint through the browser of someone on your network, which a plain HTML form could
|
||
> otherwise do. The UI button does not need it — it runs over the Blazor circuit, which a foreign
|
||
> page cannot reach. Every triggered update is logged as a warning, since with no key there is no
|
||
> caller to attribute it to.
|
||
|
||
Secrets (broker/HA tokens) are **never** stored in the database as plaintext. Each connector picks
|
||
one of two forms: the *name* of an environment variable, resolved at runtime, or the secret typed
|
||
into the admin UI and encrypted at rest under the data-protection key ring. Either way a `pg_dump`
|
||
or JSON export carries nothing usable.
|
||
|
||
Keep the key ring on persistent storage outside the app directory — the default
|
||
`/var/lib/metervault/keys` survives an LXC update, and the Compose file mounts a named volume for it.
|
||
Lose it and every UI-entered secret must be re-entered. The key ring is on disk, so this protects
|
||
against leaked database content, not against an attacker who already has the host; that is the same
|
||
trust boundary an environment variable has.
|
||
|
||
## Pushing readings (Home Assistant)
|
||
|
||
```bash
|
||
curl -X POST http://localhost:8760/api/v1/readings \
|
||
-H "X-Api-Key: $METERVAULT_API_KEY" -H "Content-Type: application/json" \
|
||
-d '[{"meterId": 1, "time": "2026-01-01T12:00:00Z", "value": 47200}]'
|
||
```
|
||
|
||
See [`docs/wiring.md`](docs/wiring.md) for wiring up Tasmota, MQTT and Home Assistant.
|
||
|
||
## Development
|
||
|
||
```bash
|
||
dotnet build
|
||
dotnet test # integration tests spin a TimescaleDB via Testcontainers (needs Docker)
|
||
dotnet test tests/Core.Tests # fast unit tests, no Docker
|
||
dotnet run --project src/App
|
||
```
|
||
|
||
Architecture, project layout and conventions live in [`CLAUDE.md`](CLAUDE.md).
|
||
|
||
## Releasing
|
||
|
||
Edit the [`VERSION`](VERSION) file on `master`; Gitea Actions tags `vX.Y.Z` and builds/pushes a
|
||
multi-arch image to the Gitea container registry (`.gitea/workflows/`). Locally:
|
||
`pwsh deploy/build-and-push.ps1 -Registry git.finalfactory.de -Image finalfactory/metervault -Push`.
|
||
Requires a Docker-capable `act_runner`; the image build itself is self-contained.
|
||
|
||
## License
|
||
|
||
Not yet chosen (see SDD §14). Add a `LICENSE` before the first public tag.
|