Files
MeterVault/README.md
T
schmidt.florian cf7e0396f0
ci / build-test (push) Successful in 1m13s
Dashboard: report when a newer release is available
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
2026-07-18 20:17:52 +02:00

7.7 KiB
Raw Blame History

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 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)

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-scriptsstyle installer builds a self-contained LXC (Debian + PostgreSQL/TimescaleDB + the app as a systemd service). Run on the Proxmox host:

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 and 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__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__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. This is why the dashboard reports that an update is available but does not offer to apply it: with the app running as root in the LXC, a one-click update would be an unauthenticated path to arbitrary code execution.

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.

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)

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 for wiring up Tasmota, MQTT and Home Assistant.

Development

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.

Releasing

Edit the 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.