Two threads that ended up in the same files. One is navigation: a meter
swap that happened today had no click path at all, and most per-meter
tasks were reachable only by knowing which admin page owned them. The
other is attribution: readings on 1 August and 16 September showed six
weeks of water under September and nothing under August.
Meter events from the UI
Swap, counter reset, tank level, delivery and note are recorded through
MeterEventService rather than ad-hoc inserts, so the dialog's verdict and
the saved result come from the same Validate call, and every record or
delete recomputes the meter inside one transaction. MeterEventRules
decides which events a mode offers -- a tank has no register to swap, and
Correction is offered nowhere because nothing reads it.
A swap is stored as the event at T plus a manual reading of the new
register's start value at exactly T. That pairing is the whole trick: the
boundary window is (previousReading, reading], so the old register's tail
books at T and every later reading counts from the new start. Writing the
old final value as the reading at T instead -- the obvious thing -- double
counts the tail and then rejects every reading the new register produces.
Deleting a swap removes that start reading only while it is still the
untouched start value, and only Manual readings can be deleted at all.
Navigation
The meter page is now the hub: primary entry by mode, a "Record event"
menu, and Edit through a shared MeterEditor that also owns tank setup.
Other pages link into it with MeterLinks (/meters/{id}?tab=...&action=...),
whose action is consumed once after the interactive render and dropped
from the address -- the reverse order flashes the dialog and closes it,
because a circuit's first location change dismisses every open dialog.
The app bar gains a "Find a meter" dialog with the same quick entry.
A source that has no usable connector now links to creating (or enabling)
one and comes back to the same source dialog with the connector picked
and everything typed still there; the draft survives in a circuit-scoped
DraftStore, and the way back is a meter id rather than a URL, so the page
cannot be made to redirect anywhere else. The connector list shows which
meters use each connector, import batches list the meters and categories
they wrote to, the meter editor owns the meter's own cost categories, and
the dashboard's empty cost panel names the first missing step instead of
listing every admin page.
Months
A reading is an instant, and what it measures accrued over the time since
the previous one. Booking the whole delta at the closing reading misfiles
it whenever the interval crosses a month boundary, so a plain increase is
now divided at local month boundaries in proportion to elapsed time, each
share stamped inside its month and marked estimated: the meter recorded a
total, not a shape. The parts always sum to the original.
Imported monthly tables are the exception that keeps the golden fixtures
reconciling. "Mai 2026" carries the register at the end of May but is
stamped on the 1st, so the importer -- the only place that still knows
whether the date cell named a month or a day -- flags it MonthLabel, and
the engine reads it as the end of its month. Inferring that from the
stamp instead would catch day-dated rows: a sheet with "01.08.2026" in it
is not a monthly table, and reading it as one moves two thirds of July
into August.
ReadingTimeline is the single ordering built on that: effective time,
then stamp. The register normalizers walk it, and so do the decrease
guard and the event dialog, which is what stops them disagreeing about
which reading is "previous" -- a sheet imported after live readings of the
same month used to count that month twice, and a mid-month reading below
the month's end value was rejected as a drop. A swap detected in a
monthly table applies from the start of that local month, i.e. to the
first reading in it, and a recorded start value never counts above the
reading it lands on.
Every reader buckets in the configured timezone rather than a hardcoded
one, and turns a requested date into that zone's local midnight, so the
divided shares are read back under the months they were stamped in. The
zone id is normalised to its IANA form, because .NET accepts a Windows id
that PostgreSQL will not bucket by, and both are checked at startup.
Stored consumption is derived, so a rule change reaches a meter only at
its next reading -- weeks, for a meter read monthly. NormalizationUpgrade
records the revision and zone the stored series was built with and
rebuilds everything once at startup when either differs, each meter in
its own transaction. A meter that fails is logged, kept in
normalization_pending and retried at the next start: one bad series must
never keep the application down.
What an operator sees once
Existing charts change on the first start after the update: months that
carried a neighbour's use give it back. Rows of earlier imports from
monthly tables are marked as such before anything is recomputed, and if
that marking fails nothing is rebuilt or recorded, so the upgrade simply
runs again next time rather than shifting every imported month by one. A
wizard import whose date format was left on auto-detect is treated as a
monthly table when all of its rows sit on the 1st across at least two
months -- exactly how those rows were attributed before -- and each such
batch is named in the log, because a day-dated sheet always read on the
1st looks identical; revert and re-import it with the day format if that
is what it was.
Tests: 120 unit and 230 integration, including the reference fixtures,
which still reconcile month for month.
11 KiB
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.
Upgrading to 0.3.0
This release changes where consumption lands. Back the database up first (pg_dump), then start the
new image once and let it finish:
- The first start re-derives all stored consumption before the web server listens. The app is unreachable while it runs (Compose may report the container unhealthy after ~105 s) — let it finish rather than killing it. Progress and any meter it could not rebuild are logged.
- Figures change once. Consumption between two readings is now attributed to the months it accrued in instead of landing entirely on the later reading, so historical months and their costs can shift; rows that had to be divided are marked estimated.
- Imported monthly tables are marked as such in place before anything is recomputed, so they keep reconciling. If that step fails nothing is rebuilt and the whole upgrade simply runs again next start.
- Check the log once for
had its dates auto-detected: a CSV imported through the wizard with the date format left on auto-detect is treated as a monthly table when all its rows sit on the 1st. If such a sheet really was day-dated, revert that batch on/importand import it again with the day format. - Rolling back to 0.2.0 leaves the re-attributed consumption in place; it is re-derived under the old rules only as each meter next ingests a reading.
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 |
IANA timezone for buckets, display and month attribution (default Europe/Berlin). Changing it re-derives every meter's stored consumption at the next start, and historical monthly figures can shift. It must be an id both .NET and PostgreSQL know; anything else falls back to UTC and is reported in the log. |
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__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.