M7: release polish — export/import, docs, easy docker push
- Easy docker push (MQTTower ergonomics + the image push it lacks): VERSION file → version-tag.yml (semver-guard auto-tag) → docker-publish.yml (buildx multi-arch → GHCR, registry centralized for one-line retarget to git.finalfactory.de) + ci.yml + build-and-push.ps1. - Verified end to end: deploy/Dockerfile builds; docker compose stack (app + timescaledb) comes up healthy; /healthz and the dashboard respond in-container. - JSON config export/import (ExportService) with id remapping on restore + GET /export, POST /import endpoints; round-trip test preserves meter→type, meter-scoped tariff, category links. - README, HA/Tasmota/MQTT wiring guide (docs/wiring.md), Unraid template. - CLAUDE.md updated to reflect the built codebase. - i18n: locale-aware number/currency formatting (de-DE); full de UI string localization deferred. 95 tests green (56 Core + 39 integration). Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# Build + test on push/PR. Integration tests spin a TimescaleDB via Testcontainers, which needs
|
||||||
|
# a Docker daemon — available on the ubuntu-latest runner.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build --no-restore -c Release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: dotnet test --no-build -c Release --logger "trx;LogFileName=test-results.trx"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Builds and pushes the multi-arch MeterVault image on a vX.Y.Z tag (SDD §11).
|
||||||
|
# Default registry is GHCR; change REGISTRY/IMAGE below (one place) to target the FinalFactory
|
||||||
|
# registry (e.g. REGISTRY: git.finalfactory.de) and set the matching login secrets.
|
||||||
|
name: docker-publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE: ${{ github.repository }} # ghcr.io/<owner>/metervault
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Derive version
|
||||||
|
id: ver
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to the registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: deploy/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.ver.outputs.version }}
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Turns an edit to the VERSION file on main into an annotated vX.Y.Z tag (the "easy release"
|
||||||
|
# ergonomics from MQTTower). The tag then triggers docker-publish.yml. A tag pushed with
|
||||||
|
# GITHUB_TOKEN would not trigger other workflows, so this job uses a PAT if provided
|
||||||
|
# (RELEASE_PAT), otherwise it still tags and you can trigger the image build manually.
|
||||||
|
name: version-tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "VERSION"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: version-tag
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tag-if-newer:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Create tag if VERSION is the new highest semver
|
||||||
|
env:
|
||||||
|
GIT_AUTHOR_NAME: github-actions[bot]
|
||||||
|
GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RAW="$(tr -d ' \r\n' < VERSION)"
|
||||||
|
VERSION="${RAW#v}"
|
||||||
|
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "VERSION '$RAW' is not X.Y.Z"; exit 1
|
||||||
|
fi
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
|
||||||
|
echo "Tag ${TAG} already exists; nothing to do."; exit 0
|
||||||
|
fi
|
||||||
|
HIGHEST="$(git tag -l 'v*' | sort -V | tail -n1 || true)"
|
||||||
|
if [ -n "$HIGHEST" ] && [ "$(printf '%s\n%s\n' "$HIGHEST" "$TAG" | sort -V | tail -n1)" != "$TAG" ]; then
|
||||||
|
echo "${TAG} is not greater than existing ${HIGHEST}; refusing."; exit 1
|
||||||
|
fi
|
||||||
|
git config user.name "$GIT_AUTHOR_NAME"
|
||||||
|
git config user.email "$GIT_AUTHOR_EMAIL"
|
||||||
|
git tag -a "$TAG" -m "Release $TAG"
|
||||||
|
git push origin "refs/tags/${TAG}"
|
||||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and 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, …) and meters are **user-defined, never hardcoded**.
|
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and 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, …) and meters are **user-defined, never hardcoded**.
|
||||||
|
|
||||||
**Status: pre-code.** The repository currently contains only the design spec and reference data — no solution, projects, or build yet. The first coding task is milestone **M0** (scaffold) from the spec.
|
**Status: implemented (M0–M7).** The full solution is built and green — five projects, ~95 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). Remaining refinements (HA WebSocket push, dedicated PV/oil dashboard panels, full admin CRUD, full de-DE UI localization) are noted at the end of their milestone commits.
|
||||||
|
|
||||||
## Source of truth
|
## Source of truth
|
||||||
|
|
||||||
@@ -21,29 +21,42 @@ MeterVault is a self-hosted, local-first energy & utility metering platform: it
|
|||||||
|
|
||||||
.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion/aggregation · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR.
|
.NET (current LTS — .NET 10, .NET 8 acceptable), C# · ASP.NET Core + **Blazor Server** · **MudBlazor** components · **ApexCharts** (Blazor-ApexCharts) · **MQTTnet** · **PostgreSQL + TimescaleDB** · **EF Core (Npgsql)** for schema/CRUD + **Dapper** for hot-path time-series reads · `BackgroundService` hosted services for ingestion/aggregation · **xUnit + Testcontainers** (Timescale image) · Docker Compose + GHCR.
|
||||||
|
|
||||||
## Intended project layout (created during M0, per SDD §11)
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
/src/Core domain entities, enums, interfaces, expression evaluator (no infra deps)
|
/src/Core domain entities + enums; pure Normalization engine (mode strategies,
|
||||||
/src/Infrastructure EF Core + Npgsql, Dapper repos, Timescale raw-SQL migrations, MQTT/HA clients, CSV importer
|
expression evaluator); Parsing (German dialect); Costing (TariffResolver)
|
||||||
/src/App ASP.NET Core host: Blazor Server UI + REST API + hosted workers
|
/src/Infrastructure MeterVaultDbContext + migrations (relational + raw-SQL Timescale);
|
||||||
/tests/Core.Tests unit: deltas, swaps, tariff resolution, oil rate, CSV parsing
|
Import (CsvImporter, profiles, ImportService), Ingestion (MQTT/HA workers,
|
||||||
/tests/Integration.Tests Testcontainers (Timescale): ingest→aggregate→cost e2e
|
IngestionService), Normalization service, Costing/Dashboard/Backup services
|
||||||
/tests/fixtures the 4 reference CSVs + expected outputs
|
/src/App ASP.NET Core host: Blazor Server UI (Components/), REST API (Api/), hosted
|
||||||
/deploy Dockerfile, docker-compose.yml (app + timescaledb), unraid-template.xml
|
workers, Program.cs (Serilog, migrate+seed on startup, /healthz)
|
||||||
|
/tests/Core.Tests unit (no Docker): parsers, normalizers, swap→12, tariff resolver
|
||||||
|
/tests/Integration.Tests Testcontainers (Timescale): reconciliation vs the 4 fixtures,
|
||||||
|
import commit/revert, ingestion, cost, CAgg refresh, API, export, render
|
||||||
|
/deploy Dockerfile, docker-compose.yml (app + timescaledb), build-and-push.ps1, unraid-template.xml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands (apply once M0 has scaffolded the solution)
|
Central package versions live in `Directory.Packages.props`; shared build/style in
|
||||||
|
`Directory.Build.props` + `.editorconfig`. Snake_case table/column mapping via
|
||||||
|
`UseSnakeCaseNamingConvention`. EF migrations are exempt from code-style enforcement (see `.editorconfig`).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
dotnet build # build the solution
|
dotnet build # build the solution
|
||||||
dotnet test # all tests (Integration.Tests needs Docker for Testcontainers)
|
dotnet test # all tests (Integration.Tests needs Docker for Testcontainers)
|
||||||
dotnet test tests/Core.Tests # unit tests only (no Docker needed)
|
dotnet test tests/Core.Tests # unit tests only (no Docker needed)
|
||||||
dotnet test --filter "FullyQualifiedName~Csv" # a single test / class by name filter
|
dotnet test tests/Integration.Tests --filter "FullyQualifiedName~Reconciliation" # one class/area
|
||||||
dotnet run --project src/App # run app + workers locally
|
dotnet ef migrations add <Name> -p src/Infrastructure -s src/App -o Persistence/Migrations
|
||||||
|
dotnet run --project src/App # run app + workers locally (needs a Timescale DB)
|
||||||
docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together
|
docker compose -f deploy/docker-compose.yml up # app + TimescaleDB together
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Timescale-in-EF gotchas** (already handled — follow the pattern): hypertable/CAgg DDL lives in
|
||||||
|
raw-SQL migrations; continuous-aggregate creation + policies use `migrationBuilder.Sql(..., suppressTransaction: true)`, one statement each; CAgg policy `end_offset` must be ≥ one bucket. Tests
|
||||||
|
pause the compression job (historical fixture data would otherwise deadlock imports).
|
||||||
|
|
||||||
## Core architecture (the part that spans multiple files)
|
## Core architecture (the part that spans multiple files)
|
||||||
|
|
||||||
**Data pipeline — one direction, layered (SDD §4.2, §5, §7):**
|
**Data pipeline — one direction, layered (SDD §4.2, §5, §7):**
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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, meter list, one-click reference-data load, CSV dry-run.
|
||||||
|
- **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:8080 → Import → "Load reference data" for a populated demo
|
||||||
|
# API docs at http://localhost:8080/swagger
|
||||||
|
```
|
||||||
|
|
||||||
|
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__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
||||||
|
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
||||||
|
|
||||||
|
Secrets (broker/HA tokens) are **never** stored in the database — endpoint configs hold the *name*
|
||||||
|
of an environment variable, resolved at runtime.
|
||||||
|
|
||||||
|
## Pushing readings (Home Assistant)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/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 `main`; CI tags `vX.Y.Z` and builds/pushes a multi-arch image
|
||||||
|
(`.github/workflows/`). Locally: `pwsh deploy/build-and-push.ps1 -Push`.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Not yet chosen (see SDD §14). Add a `LICENSE` before the first public tag.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Local convenience: build the multi-arch MeterVault image and push it.
|
||||||
|
# Usage: ./deploy/build-and-push.ps1 -Registry ghcr.io -Image finalfactory/metervault
|
||||||
|
# Version is read from the VERSION file; images are tagged :<version> and :latest.
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$Registry = "ghcr.io",
|
||||||
|
[string]$Image = "finalfactory/metervault",
|
||||||
|
[string[]]$Platforms = @("linux/amd64", "linux/arm64"),
|
||||||
|
[switch]$Push
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$version = (Get-Content (Join-Path $root "VERSION")).Trim()
|
||||||
|
$ref = "$Registry/$Image"
|
||||||
|
|
||||||
|
Write-Host "Building $ref:$version ($($Platforms -join ', '))" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$args = @(
|
||||||
|
"buildx", "build",
|
||||||
|
"--platform", ($Platforms -join ","),
|
||||||
|
"-f", (Join-Path $root "deploy/Dockerfile"),
|
||||||
|
"-t", "$ref`:$version",
|
||||||
|
"-t", "$ref`:latest"
|
||||||
|
)
|
||||||
|
if ($Push) { $args += "--push" } else { $args += "--load" }
|
||||||
|
$args += $root
|
||||||
|
|
||||||
|
& docker @args
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "docker buildx build failed" }
|
||||||
|
Write-Host "Done." -ForegroundColor Green
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<!-- Unraid Community Applications template for the MeterVault web app.
|
||||||
|
Requires a reachable PostgreSQL/TimescaleDB (run the timescale/timescaledb container
|
||||||
|
separately, or point ConnectionStrings__Default at an existing instance). -->
|
||||||
|
<Container version="2">
|
||||||
|
<Name>MeterVault</Name>
|
||||||
|
<Repository>ghcr.io/finalfactory/metervault:latest</Repository>
|
||||||
|
<Registry>https://ghcr.io/finalfactory/metervault</Registry>
|
||||||
|
<Network>bridge</Network>
|
||||||
|
<Shell>bash</Shell>
|
||||||
|
<Privileged>false</Privileged>
|
||||||
|
<Overview>Self-hosted energy & utility metering: ingest from Home Assistant/Tasmota/MQTT, normalize to consumption, and produce cost dashboards. Needs a TimescaleDB instance.</Overview>
|
||||||
|
<Category>HomeAutomation: Tools: Productivity:</Category>
|
||||||
|
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
|
||||||
|
<Icon>https://raw.githubusercontent.com/FinalFactory/MeterVault/main/docs/icon.png</Icon>
|
||||||
|
|
||||||
|
<Config Name="WebUI Port" Target="8080" Default="8080" Mode="tcp" Description="HTTP port" Type="Port" Display="always" Required="true">8080</Config>
|
||||||
|
|
||||||
|
<Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config>
|
||||||
|
|
||||||
|
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing/display" Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
|
||||||
|
|
||||||
|
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank to leave the API open." Type="Variable" Display="always" Required="false" Mask="true"/>
|
||||||
|
|
||||||
|
<Config Name="Reverse-proxy trust" Target="MeterVault__ReverseProxyTrust" Default="false" Mode="" Description="Honour X-Forwarded-User from a trusted auth proxy" Type="Variable" Display="advanced" Required="false">false</Config>
|
||||||
|
</Container>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Wiring up sources
|
||||||
|
|
||||||
|
MeterVault ingests from MQTT/Tasmota and Home Assistant. Sources are attached to meters; each
|
||||||
|
source's `config` JSON says where the value comes from. Secrets are referenced by environment
|
||||||
|
variable name, never stored in the database.
|
||||||
|
|
||||||
|
## 1. Create an MQTT broker endpoint
|
||||||
|
|
||||||
|
`ingestion_endpoint` (type `MqttBroker`) config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "192.168.1.10",
|
||||||
|
"port": 1883,
|
||||||
|
"usernameEnv": "MQTT_USER",
|
||||||
|
"passwordEnv": "MQTT_PASS",
|
||||||
|
"extraTopics": ["tele/+/SENSOR"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `MQTT_USER` / `MQTT_PASS` in the container environment. The worker connects on startup and
|
||||||
|
resubscribes automatically after outages.
|
||||||
|
|
||||||
|
## 2. Tasmota plug → electricity meter
|
||||||
|
|
||||||
|
Create a `Tasmota` source on the meter with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "topic": "tele/plug1/SENSOR", "path": "ENERGY.Total" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasmota publishes e.g. `{"Time":"2026-01-01T12:00:00","ENERGY":{"Total":1234.56,"Today":1.2,"Power":50}}`.
|
||||||
|
MeterVault reads `ENERGY.Total`, and uses the payload's `Time` field as the timestamp. Use
|
||||||
|
`ENERGY.Today` for daily-delta meters (`direct_delta` mode) or `ENERGY.Power` for `instant_rate`.
|
||||||
|
`scale`/`offset` on the source convert units (e.g. Wh → kWh with `scale: 0.001`).
|
||||||
|
|
||||||
|
## 3. Raw MQTT sensor
|
||||||
|
|
||||||
|
Same as Tasmota but source type `Mqtt`; point `path` at the JSON field, or omit it for a bare
|
||||||
|
numeric payload. `timePath` names a timestamp field in the payload if present.
|
||||||
|
|
||||||
|
## 4. Home Assistant
|
||||||
|
|
||||||
|
Two options:
|
||||||
|
|
||||||
|
**A — HA pushes to MQTT.** Configure an HA MQTT sensor/automation to publish to a topic and treat
|
||||||
|
it as an MQTT source (above). No HA endpoint needed.
|
||||||
|
|
||||||
|
**B — MeterVault polls HA.** Create an `ingestion_endpoint` (type `HomeAssistant`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "baseUrl": "http://homeassistant.local:8123", "tokenEnv": "HA_TOKEN" }
|
||||||
|
```
|
||||||
|
|
||||||
|
and a `HomeAssistant` source on the meter:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "entityId": "sensor.house_power", "attribute": null, "pollSeconds": 60 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `HA_TOKEN` (a long-lived access token) in the environment. Numeric state (or a named
|
||||||
|
`attribute`) is read every `pollSeconds`; `unavailable`/`unknown` states are skipped.
|
||||||
|
|
||||||
|
**C — HA pushes to the REST API.** POST to `/api/v1/readings` with an `X-Api-Key` header (see the
|
||||||
|
README). Good when HA should drive the cadence.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Cumulative registers reject spurious **decreases** unless a `counter_reset`/`meter_swap` event
|
||||||
|
explains them — record swaps via `POST /api/v1/events`.
|
||||||
|
- High-frequency sources: MeterVault stores raw readings idempotently on `(meter, time)`; use
|
||||||
|
Tasmota's `TelePeriod` and per-source sampling to bound volume.
|
||||||
@@ -112,6 +112,17 @@ public static class ApiEndpoints
|
|||||||
.Select(s => new { s.Id, s.MeterId, Type = s.SourceType.ToString(), s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus })
|
.Select(s => new { s.Id, s.MeterId, Type = s.SourceType.ToString(), s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus })
|
||||||
.ToListAsync(ct)));
|
.ToListAsync(ct)));
|
||||||
|
|
||||||
|
api.MapGet("/export", async (MeterVault.Infrastructure.Backup.ExportService export, CancellationToken ct) =>
|
||||||
|
Results.Text(await export.ExportJsonAsync(ct), "application/json"))
|
||||||
|
.WithSummary("Export configuration + hand-entered data as portable JSON (SDD §10).");
|
||||||
|
|
||||||
|
api.MapPost("/import", async (HttpRequest request, MeterVault.Infrastructure.Backup.ExportService export, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(request.Body);
|
||||||
|
await export.ImportJsonAsync(await reader.ReadToEndAsync(ct), ct);
|
||||||
|
return Results.Ok();
|
||||||
|
}).WithSummary("Restore a JSON export into an empty instance (remaps ids).");
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Backup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A portable JSON snapshot of MeterVault's configuration and hand-entered data (SDD §10). Covers
|
||||||
|
/// everything except the bulk time-series (raw readings / consumption), which is restored from the
|
||||||
|
/// live sources or a database dump — this keeps the export small and human-readable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportDocument
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; set; } = 1;
|
||||||
|
|
||||||
|
public List<EnergyType> EnergyTypes { get; set; } = [];
|
||||||
|
public List<CostCategory> CostCategories { get; set; } = [];
|
||||||
|
public List<Meter> Meters { get; set; } = [];
|
||||||
|
public List<IngestionEndpoint> IngestionEndpoints { get; set; } = [];
|
||||||
|
public List<MeterSource> MeterSources { get; set; } = [];
|
||||||
|
public List<Tank> Tanks { get; set; } = [];
|
||||||
|
public List<Tariff> Tariffs { get; set; } = [];
|
||||||
|
public List<CostCategoryMember> CostCategoryMembers { get; set; } = [];
|
||||||
|
public List<ManualCost> ManualCosts { get; set; } = [];
|
||||||
|
public List<MeterEvent> MeterEvents { get; set; } = [];
|
||||||
|
public List<AppSetting> AppSettings { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Backup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports/imports the configuration snapshot (SDD §10). Import restores into an empty instance,
|
||||||
|
/// remapping surrogate ids so foreign keys stay consistent regardless of the original ids.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExportService(MeterVaultDbContext db)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Json = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly MeterVaultDbContext _db = db;
|
||||||
|
|
||||||
|
public async Task<string> ExportJsonAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Load without navigation includes so serialization is a clean, cycle-free tree.
|
||||||
|
var document = new ExportDocument
|
||||||
|
{
|
||||||
|
EnergyTypes = await _db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
Meters = await _db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
IngestionEndpoints = await _db.IngestionEndpoints.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
MeterSources = await _db.MeterSources.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
CostCategoryMembers = await _db.CostCategoryMembers.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
ManualCosts = await _db.ManualCosts.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
MeterEvents = await _db.MeterEvents.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
AppSettings = await _db.AppSettings.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(document, Json);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ImportJsonAsync(string json, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var doc = JsonSerializer.Deserialize<ExportDocument>(json, Json)
|
||||||
|
?? throw new InvalidOperationException("Empty or invalid export document.");
|
||||||
|
|
||||||
|
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var typeMap = new Dictionary<short, short>();
|
||||||
|
foreach (var type in doc.EnergyTypes)
|
||||||
|
{
|
||||||
|
var old = type.Id;
|
||||||
|
type.Id = 0;
|
||||||
|
_db.EnergyTypes.Add(type);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
typeMap[old] = type.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryMap = await InsertMappedAsync(doc.CostCategories, c => c.Id, (c, _) => c.Id = 0, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var meterMap = new Dictionary<int, int>();
|
||||||
|
foreach (var meter in doc.Meters)
|
||||||
|
{
|
||||||
|
var old = meter.Id;
|
||||||
|
meter.Id = 0;
|
||||||
|
meter.EnergyTypeId = typeMap.GetValueOrDefault(meter.EnergyTypeId, meter.EnergyTypeId);
|
||||||
|
meter.EnergyType = null;
|
||||||
|
_db.Meters.Add(meter);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
meterMap[old] = meter.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endpointMap = await InsertMappedAsync(doc.IngestionEndpoints, e => e.Id, (e, _) => e.Id = 0, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var source in doc.MeterSources)
|
||||||
|
{
|
||||||
|
source.Id = 0;
|
||||||
|
source.MeterId = meterMap.GetValueOrDefault(source.MeterId, source.MeterId);
|
||||||
|
source.EndpointId = source.EndpointId is { } eid ? endpointMap.GetValueOrDefault(eid, eid) : null;
|
||||||
|
source.Meter = null;
|
||||||
|
source.Endpoint = null;
|
||||||
|
_db.MeterSources.Add(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tank in doc.Tanks)
|
||||||
|
{
|
||||||
|
tank.Id = 0;
|
||||||
|
tank.MeterId = meterMap.GetValueOrDefault(tank.MeterId, tank.MeterId);
|
||||||
|
tank.Meter = null;
|
||||||
|
_db.Tanks.Add(tank);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tariff in doc.Tariffs)
|
||||||
|
{
|
||||||
|
tariff.Id = 0;
|
||||||
|
tariff.ScopeId = tariff.ScopeType switch
|
||||||
|
{
|
||||||
|
TariffScope.Meter when tariff.ScopeId is { } id => meterMap.GetValueOrDefault(id, id),
|
||||||
|
TariffScope.EnergyType when tariff.ScopeId is { } id => typeMap.GetValueOrDefault((short)id, (short)id),
|
||||||
|
_ => tariff.ScopeId,
|
||||||
|
};
|
||||||
|
_db.Tariffs.Add(tariff);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var member in doc.CostCategoryMembers)
|
||||||
|
{
|
||||||
|
member.Id = 0;
|
||||||
|
member.CategoryId = categoryMap.GetValueOrDefault(member.CategoryId, member.CategoryId);
|
||||||
|
member.MeterId = member.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
|
||||||
|
member.EnergyTypeId = member.EnergyTypeId is { } tid ? typeMap.GetValueOrDefault(tid, tid) : null;
|
||||||
|
member.Category = null;
|
||||||
|
_db.CostCategoryMembers.Add(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cost in doc.ManualCosts)
|
||||||
|
{
|
||||||
|
cost.Id = 0;
|
||||||
|
cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null;
|
||||||
|
cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
|
||||||
|
_db.ManualCosts.Add(cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var meterEvent in doc.MeterEvents)
|
||||||
|
{
|
||||||
|
meterEvent.Id = 0;
|
||||||
|
meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId);
|
||||||
|
_db.MeterEvents.Add(meterEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var setting in doc.AppSettings)
|
||||||
|
{
|
||||||
|
if (!await _db.AppSettings.AnyAsync(s => s.Key == setting.Key, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
_db.AppSettings.Add(setting);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Dictionary<int, int>> InsertMappedAsync<T>(
|
||||||
|
List<T> items, Func<T, int> getId, Action<T, int> clearId, CancellationToken cancellationToken)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
var map = new Dictionary<int, int>();
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var old = getId(item);
|
||||||
|
clearId(item, 0);
|
||||||
|
_db.Add(item);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
map[old] = getId(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<MqttMessageRouter>();
|
services.AddScoped<MqttMessageRouter>();
|
||||||
services.AddScoped<Costing.CostService>();
|
services.AddScoped<Costing.CostService>();
|
||||||
services.AddScoped<Dashboard.DashboardService>();
|
services.AddScoped<Dashboard.DashboardService>();
|
||||||
|
services.AddScoped<Backup.ExportService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Backup;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.Integration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JSON config export/import (SDD §10): a round-trip through an emptied database preserves the
|
||||||
|
/// relationships (meter → energy type, meter-scoped tariff, category membership) after id remapping.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Timescale")]
|
||||||
|
public sealed class ExportRoundTripTests(TimescaleFixture fx)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Export_then_import_preserves_relationships()
|
||||||
|
{
|
||||||
|
string json;
|
||||||
|
|
||||||
|
await using (var db = fx.CreateContext())
|
||||||
|
{
|
||||||
|
await WipeAllAsync(db);
|
||||||
|
await DatabaseSeeder.SeedAsync(db);
|
||||||
|
|
||||||
|
var elec = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
|
||||||
|
var strom = await db.CostCategories.FirstAsync(c => c.Name == "Strom");
|
||||||
|
var meter = new Meter { Name = "Export Meter", EnergyTypeId = elec.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
|
||||||
|
db.Meters.Add(meter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
db.Tariffs.Add(new Tariff { ScopeType = TariffScope.Meter, ScopeId = meter.Id, Component = TariffComponent.UnitPrice, Value = 0.30, Unit = "EUR/kWh", ValidFrom = new DateOnly(2024, 1, 1) });
|
||||||
|
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = strom.Id, MeterId = meter.Id });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
json = await new ExportService(db).ExportJsonAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = fx.CreateContext())
|
||||||
|
{
|
||||||
|
await WipeAllAsync(db);
|
||||||
|
await new ExportService(db).ImportJsonAsync(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var db = fx.CreateContext())
|
||||||
|
{
|
||||||
|
var meter = await db.Meters.SingleAsync(m => m.Name == "Export Meter");
|
||||||
|
Assert.True(await db.EnergyTypes.AnyAsync(t => t.Id == meter.EnergyTypeId));
|
||||||
|
|
||||||
|
var tariff = await db.Tariffs.SingleAsync(t => t.ScopeType == TariffScope.Meter);
|
||||||
|
Assert.Equal(meter.Id, tariff.ScopeId);
|
||||||
|
|
||||||
|
var member = await db.CostCategoryMembers.SingleAsync();
|
||||||
|
Assert.Equal(meter.Id, member.MeterId);
|
||||||
|
Assert.True(await db.CostCategories.AnyAsync(c => c.Id == member.CategoryId));
|
||||||
|
|
||||||
|
await WipeAllAsync(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WipeAllAsync(MeterVaultDbContext db)
|
||||||
|
{
|
||||||
|
await db.Consumption.ExecuteDeleteAsync();
|
||||||
|
await db.Readings.ExecuteDeleteAsync();
|
||||||
|
await db.MeterEvents.ExecuteDeleteAsync();
|
||||||
|
await db.ManualCosts.ExecuteDeleteAsync();
|
||||||
|
await db.CostCategoryMembers.ExecuteDeleteAsync();
|
||||||
|
await db.Tariffs.ExecuteDeleteAsync();
|
||||||
|
await db.Tanks.ExecuteDeleteAsync();
|
||||||
|
await db.MeterSources.ExecuteDeleteAsync();
|
||||||
|
await db.Meters.ExecuteDeleteAsync();
|
||||||
|
await db.IngestionEndpoints.ExecuteDeleteAsync();
|
||||||
|
await db.CostCategories.ExecuteDeleteAsync();
|
||||||
|
await db.EnergyTypes.ExecuteDeleteAsync();
|
||||||
|
await db.AppSettings.ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user