cf7e0396f0
ci / build-test (push) Successful in 1m13s
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
136 lines
7.7 KiB
Markdown
136 lines
7.7 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__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)
|
||
|
||
```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.
|