Entering a reading by hand previously meant POST /api/v1/readings with an API key, or a one-row CSV through the import wizard. SourceType.Manual existed in the enum but nothing was behind it. This adds the click path, built for the case it is actually used in: walking to each manual meter with a phone in hand. "Add reading" on the Readings tab opens a dialog prefilled with the meter's last register value and the current local time, both editable: - An on-screen keypad, because a register is read standing at the meter. It behaves like a calculator against the prefill - the first digit replaces it (a fresh register), while backspace edits it in place, which is the common case since only a register's last digits move. - Typed input accepts both separators (last one wins), so a German and an English phone keyboard both do the right thing. ReadingEntry owns that rule and is unit-tested; it deliberately differs from GermanNumber, where a lone dot really is a thousands separator. - A live parsed-value echo plus delta-since-last, which is the net that catches a mistyped digit before it is committed. - Decrease / replaces-existing / future / backdated surfaced before saving, and DST spring-forward gaps refused rather than shifted. The verdict line sits in a fixed-height, no-wrap slot above the keypad. That is load-bearing, not cosmetic: an alert that appears there when the value dips below the last reading moves the keys out from under the user's thumb mid-entry, which is a guaranteed mistype on a phone. The long-form explanation goes below the keypad, where reflow is harmless. Saving goes through IngestionService.IngestByMeterAsync, so the monotonic-decrease guard and inline renormalization apply exactly as for any other ingest. A new optional quality parameter stamps the row ReadingQuality.Manual; null preserves today's behaviour, so a source re-reporting the same timestamp updates the value without silently relabelling a hand-entered or imported reading. Also: the meter-detail tabs now render times in the instance timezone per SDD section 10, instead of raw UTC. Without it a reading entered at 18:00 reads back as 16:00. Side effect is that historic imported monthly rows show 01:00/02:00 rather than 00:00 - correct, if noisier. Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX
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-scripts–style 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__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__ReverseProxyTrustthen 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:
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-Updateheader 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)
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.