Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af786c7b28 | |||
| 1f575c9da2 | |||
| 9eb3f7d53c | |||
| cf7e0396f0 | |||
| 8fe5f4411b | |||
| cedd60ab45 | |||
| ad896db051 | |||
| 95c51842e8 | |||
| 62d102c335 | |||
| e23df37a3f | |||
| c0bbaba99f | |||
| 9bd0d60cc8 | |||
| 813d96709e | |||
| a3af4838e8 | |||
| 400d349d6a | |||
| 8e2e632c74 | |||
| 18dd4f720d | |||
| 8550ed8d9e | |||
| 14930bc3d8 | |||
| b3b8b92520 | |||
| fe3d81b192 | |||
| 92438348e7 | |||
| ecadbe1477 | |||
| 85d650a8f5 | |||
| 7e34aeccc8 | |||
| 09cd435c2b | |||
| 1282acf82c |
@@ -5,7 +5,6 @@
|
||||
.git/
|
||||
.github/
|
||||
docs/
|
||||
sampledata/
|
||||
tests/
|
||||
**/*.user
|
||||
**/appsettings.*.Local.json
|
||||
|
||||
@@ -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**.
|
||||
|
||||
**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.
|
||||
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||
|
||||
## Source of truth
|
||||
|
||||
@@ -72,7 +72,8 @@ sources (Tasmota/HA/MQTT/manual/CSV)
|
||||
|
||||
**Invariants that shape everything:**
|
||||
|
||||
- **Raw `reading` is immutable audit truth.** Everything derived (consumption, cost, balances, forecasts) is computed on top and must be reproducible. Never mutate readings to fix a derived number.
|
||||
- **Raw `reading` is immutable audit truth.** Everything derived (consumption, cost, balances, forecasts) is computed on top and must be reproducible. Never mutate readings to fix a derived number. Live ingestion recomputes the meter inline (`IngestionService.RenormalizeAsync`) — without it, polled readings never become consumption.
|
||||
- **Long gaps are apportioned, short ones are not** (`GapAttribution`, SDD §7.1). An interval containing ≥2 whole calendar months is split across those months, proportional to elapsed time, marked `Estimated`. A monthly series contains exactly one and is untouched — that's what keeps the golden fixtures reconciling. `GapSplittingIsInertOnFixturesTests` asserts the rule declines to fire on the reference data, so this can't silently drift.
|
||||
- **Dashboards and charts read aggregates only — never scan `reading`.** This is what makes 1000 meters × 50 years feasible (§5.5). Raw is kept for a bounded window (default 3y); `consumption` + aggregates are the long-term source of truth.
|
||||
- **`meter.mode` (measurement mode) is the central abstraction** for how raw readings become consumption (SDD §5.2): `cumulative_counter`, `generation_counter`, `runtime_counter` (Δhours × rate), `consumable_balance` (tank: deliveries − usage + forecast), `direct_delta`, `instant_rate`, `virtual` (expression over other meters). New ingestion/normalization logic dispatches on mode.
|
||||
- **Nothing domain-specific is hardcoded.** Energy types are data. Cost **categories are decoupled from energy types** (Heizung may be oil today, heat-pump tomorrow). PV self-consumption/savings/net are **virtual meters** with user-defined expressions, not special-cased code. Tariffs are time-ranged (price history), scoped global / per-type / per-meter.
|
||||
@@ -81,7 +82,9 @@ sources (Tasmota/HA/MQTT/manual/CSV)
|
||||
|
||||
**Time & DST (SDD §10):** store UTC everywhere; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
|
||||
|
||||
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. `ingestion_endpoint.config` holds a *reference* (env var name / Docker secret path) resolved at runtime.
|
||||
**In-app update (`UpdateRunner`):** the dashboard shows a banner when a newer tag exists (`UpdateCheckService`, cached, never blocks a render). Triggering an update is **off by default**; `MeterVault__AllowInAppUpdate` is the *only* gate — no API key, by explicit owner decision. With it on, anything that can reach the app can trigger a rebuild+restart as root (realistically a DoS, since the build comes from the owner's own repo; RCE if that repo is compromised). The REST endpoint additionally requires an `X-MeterVault-Update` header — a CSRF guard, not auth, so a foreign page cannot drive it via a LAN browser. Launches detached via `systemd-run` because the update restarts the service. Treat any change here as security-critical; `UpdateRunnerTests` pins that the flag defaults off and that API keys alone don't enable it.
|
||||
|
||||
**Secrets (SDD §6.4):** broker/HA tokens are **never** stored in DB plaintext. Two forms, chosen per connector in the admin UI: a *reference* (`token_env`/`password_env` naming an env var or Docker secret path) resolved at runtime, or *encrypted at rest* (`token_enc`/`password_enc`) via `SecretProtector` over the ASP.NET Core data-protection key ring. Exactly one survives a save; `EndpointSecret.Resolve` is the single resolution path (encrypted wins). The key ring lives outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`) because the LXC updater republishes `/opt/metervault`. `ExportService` drops `*_enc` values — they are bound to the originating key ring.
|
||||
|
||||
## Reference-data behaviours the code must reproduce (from `sampledata/`)
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- VERSION on master is the single source of release truth: editing it tags and publishes
|
||||
(.gitea/workflows/version-tag.yml). Stamp it into the assemblies too, so a running instance
|
||||
can say which version it is and compare itself against the newest tag. Without this the app
|
||||
reports 1.0.0 forever and an update banner would be meaningless. -->
|
||||
<PropertyGroup>
|
||||
<MeterVaultVersionFile>$(MSBuildThisFileDirectory)VERSION</MeterVaultVersionFile>
|
||||
<MeterVaultVersion Condition="Exists('$(MeterVaultVersionFile)')">$([System.IO.File]::ReadAllText('$(MeterVaultVersionFile)').Trim().TrimStart('v'))</MeterVaultVersion>
|
||||
<Version Condition="'$(MeterVaultVersion)' != ''">$(MeterVaultVersion)</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. -->
|
||||
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.Abstractions" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="App / UI">
|
||||
|
||||
@@ -22,7 +22,18 @@ full design.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -30,10 +41,26 @@ full design.
|
||||
|
||||
```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
|
||||
# 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 |
|
||||
@@ -44,17 +71,66 @@ Configuration is via environment variables (`Section__Key` double-underscore map
|
||||
| `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).
|
||||
|
||||
Secrets (broker/HA tokens) are **never** stored in the database — endpoint configs hold the *name*
|
||||
of an environment variable, resolved at runtime.
|
||||
> **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.
|
||||
|
||||
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`:
|
||||
|
||||
```bash
|
||||
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-Update` header 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)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/readings \
|
||||
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}]'
|
||||
```
|
||||
|
||||
+5
-3
@@ -11,8 +11,10 @@ COPY src/Infrastructure/MeterVault.Infrastructure.csproj src/Infrastructure/
|
||||
COPY src/App/MeterVault.App.csproj src/App/
|
||||
RUN dotnet restore src/App/MeterVault.App.csproj
|
||||
|
||||
# Build & publish.
|
||||
# Build & publish. sampledata/ must be present so the App csproj's linked Content glob
|
||||
# (..\..\sampledata\*.csv) resolves at publish time — otherwise "Load reference data" ships empty.
|
||||
COPY src/ ./src/
|
||||
COPY sampledata/ ./sampledata/
|
||||
RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Debian-based runtime (keeps full ICU — required by the de-DE CSV importer; do not use
|
||||
@@ -23,8 +25,8 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/publish ./
|
||||
ENV ASPNETCORE_URLS=http://+:8080 \
|
||||
ENV ASPNETCORE_URLS=http://+:8760 \
|
||||
ASPNETCORE_ENVIRONMENT=Production \
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
EXPOSE 8080
|
||||
EXPOSE 8760
|
||||
ENTRYPOINT ["dotnet", "MeterVault.App.dll"]
|
||||
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC1090 # community-scripts build.func is fetched at runtime
|
||||
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func)
|
||||
# Copyright (c) 2021-2026 community-scripts ORG
|
||||
# Author: FinalFactory
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://git.finalfactory.de/FinalFactory/MeterVault
|
||||
#
|
||||
# Creates a Debian LXC and installs MeterVault (self-hosted energy & utility metering) natively:
|
||||
# PostgreSQL + TimescaleDB, the .NET runtime, and the Blazor app built from the public Gitea repo,
|
||||
# fronted by a systemd service. Unlike MQTTower (GitHub + prebuilt release tarballs), MeterVault
|
||||
# lives on a public Gitea and ships only a container image, so this builds the app from source.
|
||||
|
||||
APP="MeterVault"
|
||||
var_tags="${var_tags:-energy;metering;iot}"
|
||||
var_cpu="${var_cpu:-2}"
|
||||
var_ram="${var_ram:-3072}"
|
||||
var_disk="${var_disk:-15}"
|
||||
var_os="${var_os:-debian}"
|
||||
# Debian 12 (bookworm): TimescaleDB's apt packages + PGDG PostgreSQL 16 are reliably built for it.
|
||||
var_version="${var_version:-12}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
color
|
||||
catch_errors
|
||||
|
||||
# Gitea (public) instead of GitHub: raw file URLs use /raw/branch/<branch>/<path>.
|
||||
METERVAULT_DEPLOY_BASE="${METERVAULT_DEPLOY_BASE:-https://git.finalfactory.de/FinalFactory/MeterVault/raw/branch/master/deploy}"
|
||||
METERVAULT_INSTALL_URL="${METERVAULT_INSTALL_URL:-${METERVAULT_DEPLOY_BASE}/install/metervault-install.sh}"
|
||||
|
||||
function update_script() {
|
||||
header_info
|
||||
check_container_storage
|
||||
check_container_resources
|
||||
|
||||
if [[ ! -d /opt/metervault ]]; then
|
||||
msg_error "No ${APP} Installation Found!"
|
||||
exit
|
||||
fi
|
||||
if [[ ! -d /opt/metervault-src ]]; then
|
||||
msg_error "No source checkout at /opt/metervault-src — cannot rebuild. Reinstall to restore it."
|
||||
exit
|
||||
fi
|
||||
|
||||
msg_info "Stopping ${APP}"
|
||||
systemctl stop metervault
|
||||
msg_ok "Stopped ${APP}"
|
||||
|
||||
msg_info "Pulling latest source and rebuilding (this can take a few minutes)"
|
||||
cd /opt/metervault-src || exit 1
|
||||
git fetch --depth 1 origin master
|
||||
git reset --hard origin/master
|
||||
dotnet publish src/App/MeterVault.App.csproj -c Release -o /opt/metervault /p:UseAppHost=false
|
||||
msg_ok "Rebuilt ${APP}"
|
||||
|
||||
msg_info "Starting ${APP}"
|
||||
systemctl start metervault
|
||||
msg_ok "Started ${APP}"
|
||||
|
||||
msg_ok "Updated successfully!"
|
||||
exit
|
||||
}
|
||||
|
||||
start
|
||||
|
||||
# =============================================================================
|
||||
# Custom questions (after the standard wizard, before build)
|
||||
# =============================================================================
|
||||
|
||||
rand_hex() {
|
||||
openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | xxd -p
|
||||
}
|
||||
|
||||
# msg_info() runs a background spinner on stderr; whiptail also draws on stderr. Stop the spinner
|
||||
# and clear the line first so dialogs don't render corrupted.
|
||||
metervault_whiptail() {
|
||||
stop_spinner
|
||||
clear_line
|
||||
whiptail "$@" 3>&1 1>&2 2>&3
|
||||
}
|
||||
|
||||
collect_metervault_env() {
|
||||
export METERVAULT_PORT="${METERVAULT_PORT:-8760}"
|
||||
export METERVAULT_TIMEZONE="${METERVAULT_TIMEZONE:-Europe/Berlin}"
|
||||
|
||||
if [[ -z "${METERVAULT_DB_PASSWORD:-}" ]]; then
|
||||
METERVAULT_DB_PASSWORD="$(rand_hex)"
|
||||
msg_info "Generated PostgreSQL password for the metervault user: ${METERVAULT_DB_PASSWORD}"
|
||||
fi
|
||||
export METERVAULT_DB_PASSWORD
|
||||
|
||||
# Demo dataset: honour an explicit env value, otherwise ask.
|
||||
local seed="${METERVAULT_SEED:-}"
|
||||
case "${seed,,}" in
|
||||
true | 1 | yes) export METERVAULT_SEED="true" ;;
|
||||
false | 0 | no) export METERVAULT_SEED="false" ;;
|
||||
*)
|
||||
if metervault_whiptail --title "${APP}" --yesno \
|
||||
"Load the bundled Energiebilanz demo dataset (meters, tariffs, ~2000 readings) on first start?" 10 70; then
|
||||
export METERVAULT_SEED="true"
|
||||
else
|
||||
export METERVAULT_SEED="false"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Custom build_container — identical to the framework except the install script URL.
|
||||
# The framework build_container() hardcodes the install URL to community-scripts/ProxmoxVE;
|
||||
# once MeterVault is accepted upstream this whole function can be replaced by build_container.
|
||||
# =============================================================================
|
||||
metervault_build_container() {
|
||||
# --- Network string (same logic as build.func) ---
|
||||
NET_STRING="-net0 name=eth0,bridge=${BRG:-vmbr0}"
|
||||
[[ -n "${MAC:-}" ]] && case "$MAC" in ,hwaddr=*) NET_STRING+="$MAC" ;; *) NET_STRING+=",hwaddr=$MAC" ;; esac
|
||||
NET_STRING+=",ip=${NET:-dhcp}"
|
||||
[[ -n "${GATE:-}" ]] && case "$GATE" in ,gw=) ;; ,gw=*) NET_STRING+="$GATE" ;; *) NET_STRING+=",gw=$GATE" ;; esac
|
||||
[[ -n "${VLAN:-}" ]] && case "$VLAN" in ,tag=*) NET_STRING+="$VLAN" ;; *) NET_STRING+=",tag=$VLAN" ;; esac
|
||||
[[ -n "${MTU:-}" ]] && case "$MTU" in ,mtu=*) NET_STRING+="$MTU" ;; *) NET_STRING+=",mtu=$MTU" ;; esac
|
||||
case "${IPV6_METHOD:-none}" in
|
||||
auto) NET_STRING+=",ip6=auto" ;;
|
||||
dhcp) NET_STRING+=",ip6=dhcp" ;;
|
||||
static) [[ -n "${IPV6_ADDR:-}" ]] && { NET_STRING+=",ip6=$IPV6_ADDR"; [[ -n "${IPV6_GATE:-}" ]] && NET_STRING+=",gw6=$IPV6_GATE"; } ;;
|
||||
esac
|
||||
|
||||
# --- Features ---
|
||||
FEATURES="nesting=1"
|
||||
[[ "$CT_TYPE" == "1" ]] && FEATURES="${FEATURES},keyctl=1"
|
||||
|
||||
# --- Download install.func for the container ---
|
||||
export FUNCTIONS_FILE_PATH
|
||||
FUNCTIONS_FILE_PATH="$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func)"
|
||||
if [[ -z "$FUNCTIONS_FILE_PATH" || ${#FUNCTIONS_FILE_PATH} -lt 100 ]]; then
|
||||
msg_error "Failed to download install.func"
|
||||
exit 115
|
||||
fi
|
||||
|
||||
# --- Standard exports (same as build.func build_container) ---
|
||||
export DIAGNOSTICS="${DIAGNOSTICS:-no}"
|
||||
export RANDOM_UUID="${RANDOM_UUID:-$(cat /proc/sys/kernel/random/uuid)}"
|
||||
export EXECUTION_ID="${EXECUTION_ID:-$RANDOM_UUID}"
|
||||
export SESSION_ID="${SESSION_ID:-${RANDOM_UUID:0:8}}"
|
||||
export CACHER="${APT_CACHER:-}"
|
||||
export CACHER_IP="${APT_CACHER_IP:-}"
|
||||
export tz="${timezone:-$(timedatectl show --value --property=Timezone 2>/dev/null || echo UTC)}"
|
||||
export APPLICATION="$APP"
|
||||
export app="$NSAPP"
|
||||
export PASSWORD="${PW:-}"
|
||||
export VERBOSE="${VERBOSE:-no}"
|
||||
export SSH_ROOT="${SSH:-no}"
|
||||
export SSH_AUTHORIZED_KEY="${SSH_AUTHORIZED_KEY:-}"
|
||||
export CTID="${CT_ID:-}"
|
||||
export CTTYPE="${CT_TYPE:-1}"
|
||||
export PCT_OSTYPE="$var_os"
|
||||
export PCT_OSVERSION="$var_version"
|
||||
export PCT_DISK_SIZE="${DISK_SIZE:-$var_disk}"
|
||||
export IPV6_METHOD="${IPV6_METHOD:-none}"
|
||||
|
||||
BUILD_LOG="${BUILD_LOG:-/tmp/create-lxc-${SESSION_ID}.log}"
|
||||
export BUILD_LOG
|
||||
export INSTALL_LOG="/root/.install-${SESSION_ID}.log"
|
||||
|
||||
# --- MeterVault-specific exports ---
|
||||
export METERVAULT_CT_URL="${METERVAULT_CT_URL:-${METERVAULT_DEPLOY_BASE}/ct/metervault.sh}"
|
||||
|
||||
# --- Build PCT_OPTIONS string ---
|
||||
PCT_OPTIONS_STRING=" -hostname ${HN:-metervault}"
|
||||
[[ -n "${TAGS:-}" ]] && PCT_OPTIONS_STRING+=$'\n'" -tags $TAGS"
|
||||
[[ -n "$FEATURES" ]] && PCT_OPTIONS_STRING=" -features $FEATURES"$'\n'"$PCT_OPTIONS_STRING"
|
||||
[[ -n "${SD:-}" ]] && PCT_OPTIONS_STRING+=$'\n'" $SD"
|
||||
[[ -n "${NS:-}" ]] && PCT_OPTIONS_STRING+=$'\n'" $NS"
|
||||
PCT_OPTIONS_STRING+=$'\n'" $NET_STRING"
|
||||
PCT_OPTIONS_STRING+=$'\n'" -onboot 1"
|
||||
PCT_OPTIONS_STRING+=$'\n'" -cores ${CORE_COUNT:-$var_cpu}"
|
||||
PCT_OPTIONS_STRING+=$'\n'" -memory ${RAM_SIZE:-$var_ram}"
|
||||
PCT_OPTIONS_STRING+=$'\n'" -unprivileged ${CT_TYPE:-1}"
|
||||
[[ -n "${PW:-}" ]] && PCT_OPTIONS_STRING+=$'\n'" $PW"
|
||||
|
||||
export PCT_OPTIONS="$PCT_OPTIONS_STRING"
|
||||
export TEMPLATE_STORAGE="${var_template_storage:-}"
|
||||
export CONTAINER_STORAGE="${var_container_storage:-}"
|
||||
|
||||
# --- Create LXC (framework function) ---
|
||||
create_lxc_container || exit $?
|
||||
|
||||
# --- Start container and wait for network ---
|
||||
msg_info "Starting LXC Container"
|
||||
pct start "$CTID"
|
||||
for i in {1..10}; do
|
||||
if pct status "$CTID" | grep -q "status: running"; then
|
||||
msg_ok "Started LXC Container"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
[[ "$i" -eq 10 ]] && { msg_error "LXC Container did not start"; exit 117; }
|
||||
done
|
||||
|
||||
msg_info "Waiting for network in LXC container"
|
||||
local ip_in_lxc="" wait_secs=0
|
||||
while true; do
|
||||
ip_in_lxc=$(pct exec "$CTID" -- ip -4 addr show dev eth0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1)
|
||||
[[ -z "$ip_in_lxc" ]] && ip_in_lxc=$(pct exec "$CTID" -- ip -6 addr show dev eth0 scope global 2>/dev/null | awk '/inet6 / {print $2}' | cut -d/ -f1 | head -n1)
|
||||
[[ -n "$ip_in_lxc" ]] && break
|
||||
sleep 1
|
||||
wait_secs=$((wait_secs + 1))
|
||||
if ((wait_secs % 20 == 0)); then
|
||||
msg_warn "No IP on eth0 after ${wait_secs}s — still waiting (Ctrl+C to abort)"
|
||||
fi
|
||||
done
|
||||
msg_ok "Network in LXC is reachable (${ip_in_lxc})"
|
||||
|
||||
# --- Base packages ---
|
||||
msg_info "Customizing LXC Container"
|
||||
sleep 2
|
||||
pct exec "$CTID" -- bash -c "apt-get update 2>&1 && apt-get install -y sudo curl mc gnupg2 jq 2>&1" >>"$BUILD_LOG" 2>&1 || {
|
||||
msg_error "Failed to install base packages"
|
||||
exit 116
|
||||
}
|
||||
msg_ok "Customized LXC Container"
|
||||
|
||||
# --- Run MeterVault install script (the ONLY difference vs the framework build_container) ---
|
||||
# pct exec (not lxc-attach): unprivileged LXC often returns EPERM from lxc-attach, and pct exec
|
||||
# does not inherit the host env — pass METERVAULT_* explicitly (collect_metervault_env). Append to
|
||||
# BUILD_LOG so failures show the real error.
|
||||
msg_info "Running MeterVault install script (full output: ${BUILD_LOG})"
|
||||
pct exec "$CTID" -- env \
|
||||
CONTAINER_INSTALLING=true \
|
||||
"METERVAULT_CT_URL=${METERVAULT_CT_URL:-${METERVAULT_DEPLOY_BASE}/ct/metervault.sh}" \
|
||||
"METERVAULT_PORT=${METERVAULT_PORT:-8760}" \
|
||||
"METERVAULT_TIMEZONE=${METERVAULT_TIMEZONE:-Europe/Berlin}" \
|
||||
"METERVAULT_DB_PASSWORD=${METERVAULT_DB_PASSWORD:-}" \
|
||||
"METERVAULT_SEED=${METERVAULT_SEED:-false}" \
|
||||
bash -c "$(curl -fsSL "$METERVAULT_INSTALL_URL")" >>"$BUILD_LOG" 2>&1 || {
|
||||
msg_error "MeterVault install script failed — see tail of ${BUILD_LOG}"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
collect_metervault_env
|
||||
metervault_build_container
|
||||
description
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
|
||||
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:${METERVAULT_PORT:-8760}${CL}"
|
||||
echo -e "${TAB}${INFO}First run: open Import → \"Load reference data\" for a demo (unless you enabled seeding).${CL}"
|
||||
echo -e "${TAB}${INFO}PostgreSQL user 'metervault' password: ${METERVAULT_DB_PASSWORD:-<generated>} (also in /etc/metervault/environment)${CL}"
|
||||
@@ -33,13 +33,21 @@ services:
|
||||
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
||||
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
||||
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
|
||||
# Set true for a populated demo: loads the bundled Energiebilanz dataset on first start
|
||||
# (idempotent). Leave false for a clean instance.
|
||||
MeterVault__SeedReferenceData: ${METERVAULT_SEED:-false}
|
||||
# Key ring for connector secrets typed into the admin UI. On the named volume below so it
|
||||
# survives image updates — lose it and every stored token must be re-entered.
|
||||
MeterVault__DataProtectionKeyPath: /var/lib/metervault/keys
|
||||
# REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN):
|
||||
# MeterVault__ApiKeys__0: your-secret-key
|
||||
# MeterVault__AllowAnonymousApi: "true"
|
||||
volumes:
|
||||
- metervault_keys:/var/lib/metervault/keys
|
||||
ports:
|
||||
- "${METERVAULT_PORT:-8080}:8080"
|
||||
- "${METERVAULT_PORT:-8760}:8760"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/healthz || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8760/healthz || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -48,3 +56,4 @@ services:
|
||||
|
||||
volumes:
|
||||
metervault_db:
|
||||
metervault_keys:
|
||||
|
||||
Executable
+276
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (c) 2021-2026 community-scripts ORG
|
||||
# Author: FinalFactory
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://git.finalfactory.de/FinalFactory/MeterVault
|
||||
#
|
||||
# Runs inside the LXC: installs PostgreSQL + TimescaleDB and the .NET SDK, builds the MeterVault
|
||||
# Blazor app from the public Gitea repo, and runs it as a systemd service. MeterVault publishes a
|
||||
# container image (not a prebuilt tarball), so — unlike MQTTower — this builds from source.
|
||||
|
||||
: "${GITEA_BASE:=https://git.finalfactory.de}"
|
||||
: "${GITEA_REPO:=FinalFactory/MeterVault}"
|
||||
: "${METERVAULT_REPO_URL:=${GITEA_BASE}/${GITEA_REPO}.git}"
|
||||
: "${METERVAULT_BRANCH:=master}"
|
||||
|
||||
: "${METERVAULT_PORT:=8760}"
|
||||
: "${METERVAULT_TIMEZONE:=Europe/Berlin}"
|
||||
: "${METERVAULT_SEED:=false}"
|
||||
: "${METERVAULT_DB_PASSWORD:=}"
|
||||
|
||||
: "${PG_VERSION:=16}"
|
||||
: "${DOTNET_CHANNEL:=10.0}"
|
||||
: "${INSTALL_DIR:=/opt/metervault}"
|
||||
: "${SOURCE_DIR:=/opt/metervault-src}"
|
||||
: "${ENV_FILE:=/etc/metervault/environment}"
|
||||
: "${KEYRING_DIR:=/var/lib/metervault/keys}"
|
||||
: "${DB_NAME:=metervault}"
|
||||
: "${DB_USER:=metervault}"
|
||||
|
||||
# SAFETY GUARD — this must run INSIDE the MeterVault LXC, never on the Proxmox host.
|
||||
# The community-scripts install.func setup below runs `apt upgrade` and installs PostgreSQL/.NET at
|
||||
# the top level; on a hypervisor that is destructive. Refuse unless we're in a container. This runs
|
||||
# BEFORE install.func is sourced, so a mistaken host invocation exits without touching anything.
|
||||
# Override for unusual setups with METERVAULT_ALLOW_HOST=1.
|
||||
if ! systemd-detect-virt --container --quiet 2>/dev/null && [[ "${METERVAULT_ALLOW_HOST:-}" != "1" ]]; then
|
||||
echo "ERROR: Run this installer INSIDE the MeterVault LXC, not on the Proxmox host." >&2
|
||||
echo " It installs PostgreSQL/.NET and runs 'apt upgrade' — destructive on a hypervisor." >&2
|
||||
echo " Enter the container first (e.g. 'pct enter <ctid>') then re-run. Override: METERVAULT_ALLOW_HOST=1." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INSTALL_FUNC_URL="${INSTALL_FUNC_URL:-https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func}"
|
||||
FUNCTIONS_FILE_PATH="${FUNCTIONS_FILE_PATH:-$(curl -fsSL "$INSTALL_FUNC_URL")}"
|
||||
|
||||
# shellcheck disable=SC1091 # community-scripts install.func is fetched at runtime
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
verb_ip6
|
||||
catch_errors
|
||||
setting_up_container
|
||||
network_check
|
||||
update_os
|
||||
|
||||
export APPLICATION="${APPLICATION:-MeterVault}"
|
||||
export app="${app:-metervault}"
|
||||
|
||||
need_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || { msg_error "Missing command: $1"; exit 127; }
|
||||
}
|
||||
|
||||
get_ipv4() {
|
||||
hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true
|
||||
}
|
||||
|
||||
rand_hex() {
|
||||
openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | xxd -p
|
||||
}
|
||||
|
||||
# Unprivileged LXC: systemctl enable may fail creating unit symlinks; start often works anyway.
|
||||
systemctl_enable_now_best_effort() {
|
||||
local svc="$1"
|
||||
if systemctl enable -q --now "$svc" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
msg_info "systemctl enable --now failed for ${svc}; trying start only (common in unprivileged LXC)."
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
if systemctl start "$svc" 2>/dev/null && systemctl is-active --quiet "$svc" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
msg_error "Could not start ${svc}. See: journalctl -xeu ${svc}"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Append KEY=VALUE only if KEY is absent (safe re-runs / new keys across versions).
|
||||
ensure_env_key() {
|
||||
local file="$1" key="$2" value="$3"
|
||||
if ! grep -q "^${key}=" "$file" 2>/dev/null; then
|
||||
printf '%s=%s\n' "$key" "$value" >>"$file"
|
||||
fi
|
||||
}
|
||||
|
||||
install_postgres_timescaledb() {
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
msg_info "Adding PostgreSQL (PGDG) and TimescaleDB apt repositories"
|
||||
$STD apt-get install -y gnupg postgresql-common apt-transport-https lsb-release wget ca-certificates
|
||||
# PGDG: the official PostgreSQL apt repo (provides a consistent postgresql-${PG_VERSION}).
|
||||
$STD /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
|
||||
local codename
|
||||
codename="$(lsb_release -cs)"
|
||||
echo "deb https://packagecloud.io/timescale/timescaledb/debian/ ${codename} main" >/etc/apt/sources.list.d/timescaledb.list
|
||||
wget --quiet -O - https://packagecloud.io/timescale/timescaledb/gpgkey | gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg
|
||||
$STD apt-get update
|
||||
msg_ok "Repositories added"
|
||||
|
||||
msg_info "Installing PostgreSQL ${PG_VERSION} + TimescaleDB"
|
||||
$STD apt-get install -y "postgresql-${PG_VERSION}" "postgresql-client-${PG_VERSION}" "timescaledb-2-postgresql-${PG_VERSION}"
|
||||
msg_ok "Installed PostgreSQL + TimescaleDB"
|
||||
|
||||
msg_info "Enabling the timescaledb preload"
|
||||
local pgconf="/etc/postgresql/${PG_VERSION}/main/postgresql.conf"
|
||||
if command -v timescaledb-tune >/dev/null 2>&1; then
|
||||
timescaledb-tune --quiet --yes --pg-config "/usr/lib/postgresql/${PG_VERSION}/bin/pg_config" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if ! grep -qE "^\s*shared_preload_libraries\s*=.*timescaledb" "$pgconf" 2>/dev/null; then
|
||||
echo "shared_preload_libraries = 'timescaledb'" >>"$pgconf"
|
||||
fi
|
||||
systemctl restart postgresql
|
||||
msg_ok "PostgreSQL restarted with timescaledb preloaded"
|
||||
}
|
||||
|
||||
provision_database() {
|
||||
msg_info "Creating the ${DB_NAME} database and role"
|
||||
[[ -n "${METERVAULT_DB_PASSWORD}" ]] || METERVAULT_DB_PASSWORD="$(rand_hex)"
|
||||
# Role: create or reset the password (idempotent re-runs).
|
||||
if sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1; then
|
||||
sudo -u postgres psql -c "ALTER ROLE ${DB_USER} WITH LOGIN PASSWORD '${METERVAULT_DB_PASSWORD}';" >/dev/null
|
||||
else
|
||||
sudo -u postgres psql -c "CREATE ROLE ${DB_USER} WITH LOGIN PASSWORD '${METERVAULT_DB_PASSWORD}';" >/dev/null
|
||||
fi
|
||||
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1; then
|
||||
sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" >/dev/null
|
||||
fi
|
||||
# Pre-create the extension as superuser (CREATE EXTENSION timescaledb needs superuser); the app's
|
||||
# migration then finds it present and its own CREATE EXTENSION IF NOT EXISTS is a no-op.
|
||||
sudo -u postgres psql -d "${DB_NAME}" -c "CREATE EXTENSION IF NOT EXISTS timescaledb;" >/dev/null
|
||||
msg_ok "Database ready"
|
||||
}
|
||||
|
||||
install_dotnet_sdk() {
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
msg_info "Installing the .NET SDK (${DOTNET_CHANNEL}) to build from source"
|
||||
local deb_ver
|
||||
deb_ver="$(. /etc/os-release 2>/dev/null && echo "${VERSION_ID%%.*}" || echo "12")"
|
||||
if [[ "$deb_ver" != "11" && "$deb_ver" != "12" && "$deb_ver" != "13" ]]; then
|
||||
deb_ver="12"
|
||||
fi
|
||||
if [[ ! -f /etc/apt/sources.list.d/microsoft-prod.list && ! -f /etc/apt/sources.list.d/microsoft-prod.sources ]]; then
|
||||
$STD curl -fsSL "https://packages.microsoft.com/config/debian/${deb_ver}/packages-microsoft-prod.deb" -o /tmp/packages-microsoft-prod.deb
|
||||
$STD dpkg -i /tmp/packages-microsoft-prod.deb
|
||||
rm -f /tmp/packages-microsoft-prod.deb
|
||||
fi
|
||||
$STD apt-get update -y
|
||||
$STD apt-get install -y "dotnet-sdk-${DOTNET_CHANNEL}"
|
||||
msg_ok "Installed .NET SDK"
|
||||
}
|
||||
|
||||
build_metervault() {
|
||||
export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
|
||||
msg_info "Fetching MeterVault source (${METERVAULT_REPO_URL})"
|
||||
if [[ -d "${SOURCE_DIR}/.git" ]]; then
|
||||
git -C "${SOURCE_DIR}" fetch --depth 1 origin "${METERVAULT_BRANCH}"
|
||||
git -C "${SOURCE_DIR}" reset --hard "origin/${METERVAULT_BRANCH}"
|
||||
else
|
||||
rm -rf "${SOURCE_DIR}"
|
||||
$STD git clone --depth 1 --branch "${METERVAULT_BRANCH}" "${METERVAULT_REPO_URL}" "${SOURCE_DIR}"
|
||||
fi
|
||||
msg_ok "Source ready at ${SOURCE_DIR}"
|
||||
|
||||
msg_info "Building MeterVault (dotnet publish — this can take a few minutes)"
|
||||
# sampledata/ is at the repo root; the App csproj links it as Content so publish ships the demo CSVs.
|
||||
$STD dotnet publish "${SOURCE_DIR}/src/App/MeterVault.App.csproj" -c Release -o "${INSTALL_DIR}" /p:UseAppHost=false
|
||||
msg_ok "Published to ${INSTALL_DIR}"
|
||||
}
|
||||
|
||||
write_env() {
|
||||
mkdir -p "$(dirname "${ENV_FILE}")"
|
||||
umask 077
|
||||
local conn="Host=127.0.0.1;Port=5432;Database=${DB_NAME};Username=${DB_USER};Password=${METERVAULT_DB_PASSWORD}"
|
||||
if [[ -f "${ENV_FILE}" ]]; then
|
||||
msg_info "Environment exists; merging new keys and refreshing the connection string"
|
||||
ensure_env_key "${ENV_FILE}" "ASPNETCORE_ENVIRONMENT" "Production"
|
||||
ensure_env_key "${ENV_FILE}" "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT" "false"
|
||||
ensure_env_key "${ENV_FILE}" "ASPNETCORE_URLS" "http://+:${METERVAULT_PORT}"
|
||||
ensure_env_key "${ENV_FILE}" "MeterVault__TimeZone" "${METERVAULT_TIMEZONE}"
|
||||
ensure_env_key "${ENV_FILE}" "MeterVault__Currency" "EUR"
|
||||
ensure_env_key "${ENV_FILE}" "MeterVault__Locale" "en"
|
||||
ensure_env_key "${ENV_FILE}" "MeterVault__SeedReferenceData" "${METERVAULT_SEED}"
|
||||
sed -i "s|^ConnectionStrings__Default=.*|ConnectionStrings__Default=${conn}|" "${ENV_FILE}"
|
||||
grep -q '^ConnectionStrings__Default=' "${ENV_FILE}" || printf 'ConnectionStrings__Default=%s\n' "${conn}" >>"${ENV_FILE}"
|
||||
msg_ok "Updated ${ENV_FILE}"
|
||||
else
|
||||
cat <<EOF >"${ENV_FILE}"
|
||||
ASPNETCORE_ENVIRONMENT=Production
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
ASPNETCORE_URLS=http://+:${METERVAULT_PORT}
|
||||
ConnectionStrings__Default=${conn}
|
||||
MeterVault__TimeZone=${METERVAULT_TIMEZONE}
|
||||
MeterVault__Currency=EUR
|
||||
MeterVault__Locale=en
|
||||
MeterVault__SeedReferenceData=${METERVAULT_SEED}
|
||||
EOF
|
||||
msg_ok "Wrote ${ENV_FILE}"
|
||||
fi
|
||||
chmod 600 "${ENV_FILE}"
|
||||
}
|
||||
|
||||
# Key ring for connector secrets typed into the admin UI (SDD §6.4). The app creates this itself if
|
||||
# missing, but with the default umask — created here instead so it is 0700 from the start, and so it
|
||||
# is visibly outside /opt/metervault, which the updater republishes on every run.
|
||||
write_keyring_dir() {
|
||||
install -d -m 0700 "${KEYRING_DIR}"
|
||||
}
|
||||
|
||||
write_systemd() {
|
||||
cat <<'EOF' >/etc/systemd/system/metervault.service
|
||||
[Unit]
|
||||
Description=MeterVault (self-hosted energy & utility metering)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
Requires=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/metervault/environment
|
||||
WorkingDirectory=/opt/metervault
|
||||
ExecStart=/usr/bin/dotnet /opt/metervault/MeterVault.App.dll
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
|
||||
# Install the in-container updater from the checkout we just built, rather than emitting it from a
|
||||
# heredoc here. Keeping it a real file in the repo means an already-provisioned container can
|
||||
# bootstrap /usr/bin/update itself after a git pull, instead of being stranded on whatever the
|
||||
# installer wrote the day it ran.
|
||||
write_update_command() {
|
||||
local src="${SOURCE_DIR}/deploy/install/metervault-update.sh"
|
||||
if [[ ! -f "${src}" ]]; then
|
||||
echo "Updater script not found at ${src} — skipping 'update' command." >&2
|
||||
return 0
|
||||
fi
|
||||
install -m 0755 "${src}" /usr/bin/update
|
||||
}
|
||||
|
||||
main() {
|
||||
need_cmd curl
|
||||
ensure_dependencies git jq openssl sudo
|
||||
|
||||
install_postgres_timescaledb
|
||||
provision_database
|
||||
install_dotnet_sdk
|
||||
build_metervault
|
||||
write_env
|
||||
write_keyring_dir
|
||||
write_systemd
|
||||
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
systemctl_enable_now_best_effort metervault
|
||||
|
||||
# Write the important post-install artifact first, then run cosmetic framework steps best-effort.
|
||||
# motd_ssh's `chmod -x /etc/update-motd.d/*` returns "Operation not permitted" in some unprivileged
|
||||
# LXCs; under the framework's ERR trap that would otherwise abort an already-successful install.
|
||||
write_update_command
|
||||
motd_ssh 2>/dev/null || true
|
||||
customize 2>/dev/null || true
|
||||
cleanup_lxc 2>/dev/null || true
|
||||
|
||||
local ip
|
||||
ip="$(get_ipv4)"
|
||||
msg_ok "MeterVault is up at http://${ip:-127.0.0.1}:${METERVAULT_PORT}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# MeterVault in-container updater — installed to /usr/bin/update by metervault-install.sh.
|
||||
# Pulls the latest source and rebuilds in place: no host round-trip and no re-fetch of the
|
||||
# community-scripts framework (safer + simpler than re-running the ct script).
|
||||
#
|
||||
# Kept as a standalone file rather than a heredoc in the installer so an already-provisioned
|
||||
# container can bootstrap it straight from its own checkout:
|
||||
# git -C /opt/metervault-src fetch --depth 1 origin master
|
||||
# git -C /opt/metervault-src reset --hard origin/master
|
||||
# install -m 0755 /opt/metervault-src/deploy/install/metervault-update.sh /usr/bin/update
|
||||
set -euo pipefail
|
||||
|
||||
: "${SOURCE_DIR:=/opt/metervault-src}"
|
||||
: "${INSTALL_DIR:=/opt/metervault}"
|
||||
: "${METERVAULT_BRANCH:=master}"
|
||||
|
||||
# SAFETY GUARD — this must run INSIDE the MeterVault LXC, never on the Proxmox host.
|
||||
if ! systemd-detect-virt --container --quiet 2>/dev/null; then
|
||||
echo "Run 'update' inside the MeterVault LXC, not on the Proxmox host." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${SOURCE_DIR}/.git" ]]; then
|
||||
echo "No source checkout at ${SOURCE_DIR} — cannot rebuild. Reinstall to restore it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
|
||||
|
||||
# Anything between the stop and the restart can fail under `set -e`: a dotnet publish OOM-killed in a
|
||||
# small container, a Gitea outage mid-fetch, a transient compile error on master. systemd's
|
||||
# Restart=always does not cover a unit stopped on purpose, so without this the service simply stays
|
||||
# down until someone notices. Bring the old build back up and say what happened — a failed update
|
||||
# should cost the new version, not the running one.
|
||||
restore_service_on_failure() {
|
||||
local code=$?
|
||||
if [[ ${code} -ne 0 ]]; then
|
||||
echo "Update failed (exit ${code}). Restarting the previous build…" >&2
|
||||
systemctl start metervault || echo "Could not restart metervault — check 'systemctl status metervault'." >&2
|
||||
fi
|
||||
exit "${code}"
|
||||
}
|
||||
trap restore_service_on_failure EXIT
|
||||
|
||||
echo "Stopping metervault…"
|
||||
systemctl stop metervault || true
|
||||
|
||||
echo "Pulling latest source…"
|
||||
git -C "${SOURCE_DIR}" fetch --depth 1 origin "${METERVAULT_BRANCH}"
|
||||
git -C "${SOURCE_DIR}" reset --hard "origin/${METERVAULT_BRANCH}"
|
||||
|
||||
echo "Rebuilding (dotnet publish — this can take a few minutes)…"
|
||||
dotnet publish "${SOURCE_DIR}/src/App/MeterVault.App.csproj" -c Release -o "${INSTALL_DIR}" /p:UseAppHost=false
|
||||
|
||||
echo "Starting metervault…"
|
||||
systemctl start metervault
|
||||
|
||||
# Refresh this script from the checkout we just pulled, so a change to the updater itself lands
|
||||
# without stranding the container again. Atomic rename, never an in-place write: bash reads the
|
||||
# running script lazily, so truncating it mid-run would corrupt the remainder of this execution.
|
||||
self="${SOURCE_DIR}/deploy/install/metervault-update.sh"
|
||||
if [[ -f "${self}" ]] && ! cmp -s "${self}" /usr/bin/update; then
|
||||
install -m 0755 "${self}" /usr/bin/.update.new && mv /usr/bin/.update.new /usr/bin/update
|
||||
echo "Updater itself refreshed — the new version applies from the next run."
|
||||
fi
|
||||
|
||||
echo "MeterVault updated."
|
||||
@@ -11,10 +11,10 @@
|
||||
<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>
|
||||
<WebUI>http://[IP]:[PORT:8760]/</WebUI>
|
||||
<Icon>https://git.finalfactory.de/FinalFactory/MeterVault/raw/branch/master/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="WebUI Port" Target="8760" Default="8760" Mode="tcp" Description="HTTP port" Type="Port" Display="always" Required="true">8760</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>
|
||||
|
||||
@@ -23,4 +23,6 @@
|
||||
<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>
|
||||
|
||||
<Config Name="Secret key ring" Target="/var/lib/metervault/keys" Default="/mnt/user/appdata/metervault/keys" Mode="rw" Description="Encryption keys for connector secrets entered in the web UI. Must persist: without this mapping every stored token is lost when the container is recreated." Type="Path" Display="always" Required="true">/mnt/user/appdata/metervault/keys</Config>
|
||||
</Container>
|
||||
|
||||
+13
-2
@@ -413,7 +413,14 @@ Normalized/rolled-up volumes are tiny regardless: daily consumption = 1000 × 36
|
||||
- Ship the four reference CSVs as built-in example imports and as test fixtures.
|
||||
|
||||
### 6.4 Secrets
|
||||
Tokens/passwords are **never** stored in plaintext in the DB. `ingestion_endpoint.config` holds a *reference* (env var name / Docker secret path); the app resolves at runtime. Document this clearly.
|
||||
Tokens/passwords are **never** stored in plaintext in the DB. Two storage forms satisfy this, chosen per connector in the admin UI:
|
||||
|
||||
- **By reference** — `ingestion_endpoint.config` names an env var / Docker secret path (`token_env`, `password_env`); the app resolves it at runtime.
|
||||
- **Encrypted at rest** — the operator types the secret into the connector dialog and it is stored encrypted (`token_enc`, `password_enc`) under the ASP.NET Core data-protection key ring.
|
||||
|
||||
Exactly one form survives a save; switching clears the other. The encrypted form exists because reference-only forced a file edit plus a service restart to add a connector, which in practice led to tokens being pasted into the env-var *name* field. It keeps the guarantee that matters — a `pg_dump` or JSON export carries nothing usable — but note the trust boundary: the key ring is on disk, so it protects against leaked database content, not against an attacker who already has the host. That is the same boundary as an env var, which is equally readable from `/proc`.
|
||||
|
||||
The key ring must be persisted outside the app directory (`MeterVault__DataProtectionKeyPath`, default `/var/lib/metervault/keys`), or a redeploy that replaces the content root will orphan every stored secret. MQTT *usernames* are not secrets and are stored as-is. JSON exports drop `*_enc` values: they are bound to the originating key ring and so are useless where an export would be restored — expect to re-enter secrets after a restore.
|
||||
|
||||
---
|
||||
|
||||
@@ -422,6 +429,10 @@ Tokens/passwords are **never** stored in plaintext in the DB. `ingestion_endpoin
|
||||
### 7.1 Register → consumption
|
||||
For `cumulative_counter`/`generation_counter`: for each new reading, `amount = value − previous_value`. Persist to `consumption`. Cross a `meter_swap` as `(old_final − prev) + (curr − new_initial)`; a `counter_reset` starts a fresh baseline. Ignore/annotate negative deltas that lack an explaining event (flag as anomaly).
|
||||
|
||||
**Gap attribution.** A delta is booked at the reading that closes it — correct at the reporting cadence, and what the reference sheets do. After a long unread stretch it misleads: 78 days of PV output arriving as one July row makes June look idle. So an interval containing **two or more complete calendar months** is apportioned across the months it covers, in proportion to elapsed time, and every row it yields is marked `quality = estimated` — the meter recorded a total, not a shape.
|
||||
|
||||
The threshold is deliberately conservative. A monthly series contains exactly one whole month per interval and is never touched, which is what keeps the golden-fixture reconciliation (§13) measuring the normalizer rather than the splitter. Counting whole months *contained* rather than boundaries *crossed* keeps the rule stable when a reading lands hours late. Swap and reset amounts are never apportioned: they are explicit corrections booked at the event. Split points are UTC, so one can sit an hour or two from a displayed month edge (§10) — immaterial when dividing a multi-month gap, and the alternative is threading a timezone through an otherwise timezone-free engine.
|
||||
|
||||
### 7.2 Runtime → consumption (burner)
|
||||
For `runtime_counter`: `amount = Δhours × rate`. `rate` comes from the linked `tank`: `fixed` (nozzle spec, L/h) or `empirical` (`Δlevel ÷ Δhours` measured between deliveries/level reads — reproduce the spreadsheet's 1.87/1.94/2.92 … behaviour). Expose both; default empirical when level data exists, else fixed.
|
||||
|
||||
@@ -490,7 +501,7 @@ OpenAPI/Swagger published. API-key auth for automation endpoints; UI uses the se
|
||||
- **Time & DST:** store UTC; bucket and display in the instance timezone (default `Europe/Berlin`). "Daily cost" boundaries are local-midnight — use `time_bucket(..., 'Europe/Berlin')`.
|
||||
- **Auth:** optional built-in local accounts; **reverse-proxy trust** mode honouring `X-Forwarded-User`/`Remote-User` behind Authelia/Traefik; API keys for machine access. Default: single admin user + one ingest API key.
|
||||
- **Observability:** `/healthz`, structured logs (Serilog), optional Prometheus `/metrics`.
|
||||
- **Config:** environment variables + a settings UI; secrets via env/Docker secrets (never in DB plaintext).
|
||||
- **Config:** environment variables + a settings UI; secrets via env/Docker secrets or encrypted at rest (never in DB plaintext) — see §6.4.
|
||||
- **i18n:** `en` (default for OSS) + `de`; locale-aware number/currency/date. Ship a German locale that matches the source data conventions.
|
||||
- **Backup:** document `pg_dump`/Timescale backup; provide a full **JSON export/import** for portability.
|
||||
- **Performance:** dashboards read aggregates only; raw reads paginated and time-bounded.
|
||||
|
||||
+5
-3
@@ -55,11 +55,13 @@ it as an MQTT source (above). No HA endpoint needed.
|
||||
and a `HomeAssistant` source on the meter:
|
||||
|
||||
```json
|
||||
{ "entityId": "sensor.house_power", "attribute": null, "pollSeconds": 60 }
|
||||
{ "entityId": "sensor.house_power", "attribute": null, "pollMinutes": 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.
|
||||
Set `HA_TOKEN` (a long-lived access token) in the environment, or type the token into the connector
|
||||
dialog to have it encrypted at rest (SDD §6.4). Numeric state (or a named `attribute`) is read every
|
||||
`pollMinutes` — default 60, because monthly totals and cost are identical whether a meter is sampled
|
||||
hourly or per-second. `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.
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Maintainer helper. Not part of the community-scripts tree under deploy/.
|
||||
#
|
||||
# If bash reports "$'\r': command not found" or "set: pipefail: invalid option", the file has
|
||||
# CRLF line endings. Fix (use the real path to this file — not $0 from an interactive shell):
|
||||
# sed -i 's/\r$//' /tmp/run-metervault-ct-install.sh && bash /tmp/run-metervault-ct-install.sh
|
||||
#
|
||||
# Run this **inside** an existing Debian LXC (not on the Proxmox host). It exports the same
|
||||
# METERVAULT_* variables that deploy/ct/metervault.sh passes via pct exec, then runs the official
|
||||
# install script from the public Gitea repo.
|
||||
#
|
||||
# Optional overrides file: export METERVAULT_INSTALL_ENV=/root/metervault-install.env
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { echo "Usage: $0 (run as root inside the LXC)"; exit 0; }
|
||||
[[ "$(id -u)" -eq 0 ]] || { echo "error: run as root inside the container" >&2; exit 1; }
|
||||
|
||||
rand_hex() { openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | xxd -p; }
|
||||
|
||||
METERVAULT_DEPLOY_BASE="${METERVAULT_DEPLOY_BASE:-https://git.finalfactory.de/FinalFactory/MeterVault/raw/branch/master/deploy}"
|
||||
METERVAULT_INSTALL_URL="${METERVAULT_INSTALL_URL:-${METERVAULT_DEPLOY_BASE}/install/metervault-install.sh}"
|
||||
|
||||
[[ -n "${METERVAULT_INSTALL_ENV:-}" && -f "${METERVAULT_INSTALL_ENV}" ]] && { set -a; # shellcheck source=/dev/null
|
||||
source "${METERVAULT_INSTALL_ENV}"; set +a; }
|
||||
|
||||
export CONTAINER_INSTALLING="${CONTAINER_INSTALLING:-true}"
|
||||
export METERVAULT_CT_URL="${METERVAULT_CT_URL:-${METERVAULT_DEPLOY_BASE}/ct/metervault.sh}"
|
||||
export METERVAULT_PORT="${METERVAULT_PORT:-8760}"
|
||||
export METERVAULT_TIMEZONE="${METERVAULT_TIMEZONE:-Europe/Berlin}"
|
||||
export METERVAULT_SEED="${METERVAULT_SEED:-false}"
|
||||
if [[ -z "${METERVAULT_DB_PASSWORD:-}" ]]; then
|
||||
METERVAULT_DB_PASSWORD="$(rand_hex)"
|
||||
echo "Generated METERVAULT_DB_PASSWORD: ${METERVAULT_DB_PASSWORD}"
|
||||
fi
|
||||
export METERVAULT_DB_PASSWORD
|
||||
|
||||
echo "METERVAULT_INSTALL_URL=${METERVAULT_INSTALL_URL} PORT=${METERVAULT_PORT} SEED=${METERVAULT_SEED}"
|
||||
exec bash -c "$(curl -fsSL "$METERVAULT_INSTALL_URL")"
|
||||
@@ -4,6 +4,7 @@ using MeterVault.Infrastructure.Dashboard;
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using MeterVault.Infrastructure.Normalization;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using MeterVault.Infrastructure.Update;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.App.Api;
|
||||
@@ -24,6 +25,9 @@ public static class ApiEndpoints
|
||||
{
|
||||
private const int MaxReadingsPerRequest = 5000;
|
||||
|
||||
/// <summary>Header confirming an update request was made on purpose rather than by a foreign page.</summary>
|
||||
public const string UpdateRequestHeader = "X-MeterVault-Update";
|
||||
|
||||
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
|
||||
@@ -36,20 +40,57 @@ public static class ApiEndpoints
|
||||
}
|
||||
|
||||
int written = 0, updated = 0, rejected = 0, ignored = 0;
|
||||
var touched = new HashSet<int>();
|
||||
foreach (var r in readings)
|
||||
{
|
||||
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct))
|
||||
// Normalize once per meter after the batch, not per reading: a recompute rewrites the
|
||||
// meter's whole consumption series, so doing it inside the loop is quadratic.
|
||||
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, cancellationToken: ct))
|
||||
{
|
||||
case IngestionOutcome.Written: written++; break;
|
||||
case IngestionOutcome.Updated: updated++; break;
|
||||
case IngestionOutcome.Written: written++; touched.Add(r.MeterId); break;
|
||||
case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break;
|
||||
case IngestionOutcome.RejectedDecrease: rejected++; break;
|
||||
default: ignored++; break; // unknown meter
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var meterId in touched)
|
||||
{
|
||||
await ingestion.RenormalizeMeterAsync(meterId, ct);
|
||||
}
|
||||
|
||||
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
|
||||
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
|
||||
|
||||
api.MapPost("/system/update", async (HttpContext http, UpdateRunner runner, CancellationToken ct) =>
|
||||
{
|
||||
if (runner.Availability is not UpdateAvailability.Allowed)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status409Conflict,
|
||||
title: runner.Availability switch
|
||||
{
|
||||
UpdateAvailability.NotEnabled => "In-app update is disabled. Set MeterVault__AllowInAppUpdate=true.",
|
||||
_ => "This install has no in-place update mechanism (containers are replaced, not updated).",
|
||||
});
|
||||
}
|
||||
|
||||
// Not authentication — the operator opted out of that. This only stops a *different site*
|
||||
// driving the endpoint through the browser of someone on this network: a plain HTML form
|
||||
// cannot set a custom header, and a cross-origin fetch that tries is stopped by the
|
||||
// preflight, which nothing here answers. Costs a deliberate caller one flag.
|
||||
if (!http.Request.Headers.ContainsKey(UpdateRequestHeader))
|
||||
{
|
||||
return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
|
||||
title: $"Send the {UpdateRequestHeader} header to confirm this is a deliberate request.");
|
||||
}
|
||||
|
||||
var launch = await runner.LaunchAsync(ct);
|
||||
return launch.Started
|
||||
? Results.Accepted(value: new { message = launch.Message })
|
||||
: Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: launch.Message);
|
||||
}).WithSummary($"Start an in-place update (LXC only; requires MeterVault__AllowInAppUpdate and the {UpdateRequestHeader} header).");
|
||||
|
||||
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
|
||||
Results.Ok(await db.Meters.AsNoTracking()
|
||||
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
|
||||
<ImportMap />
|
||||
<HeadOutlet />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="1" Dense="true">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="Toggle navigation" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
||||
<MudText Typo="Typo.h6">MeterVault</MudText>
|
||||
<MudSpacer />
|
||||
|
||||
@@ -1,11 +1,55 @@
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Core.Domain
|
||||
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
||||
|
||||
@foreach (var type in _energyTypes)
|
||||
{
|
||||
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
|
||||
}
|
||||
|
||||
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
|
||||
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
|
||||
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
||||
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
||||
<MudDivider Class="my-2" />
|
||||
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
||||
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
|
||||
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
|
||||
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink>
|
||||
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink>
|
||||
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
|
||||
</MudNavGroup>
|
||||
</MudNavMenu>
|
||||
|
||||
@code {
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Nav must never break the layout — a DB hiccup just hides the per-type links.
|
||||
_energyTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
|
||||
private static string TypeIcon(string? icon) => icon switch
|
||||
{
|
||||
"bolt" => Icons.Material.Filled.Bolt,
|
||||
"water_drop" => Icons.Material.Filled.WaterDrop,
|
||||
"local_gas_station" => Icons.Material.Filled.LocalGasStation,
|
||||
"gas_meter" => Icons.Material.Filled.GasMeter,
|
||||
"thermostat" => Icons.Material.Filled.Thermostat,
|
||||
_ => Icons.Material.Filled.Bolt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
@page "/admin/categories"
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Cost categories</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Cost categories</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add category
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_categories is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Sort</MudTh>
|
||||
<MudTh>Members</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||
{
|
||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||
}
|
||||
@context.Name
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Sort">@context.Sort</MudTd>
|
||||
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" />
|
||||
|
||||
@if (_working.Id != 0)
|
||||
{
|
||||
<MudDivider Class="my-3" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText>
|
||||
@if (_members.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var m in _members)
|
||||
{
|
||||
<MudListItem T="string">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span>@MemberLabel(m)</span>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small" OnClick="@(() => RemoveMemberAsync(m.Id))" />
|
||||
</div>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
|
||||
<MudSelect T="int?" @bind-Value="_addMeterId" Label="Add meter" Dense="true" Style="min-width:180px">
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton>
|
||||
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<CostCategory>? _categories;
|
||||
private List<Meter> _meters = [];
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<CostCategoryMember> _members = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private int? _addMeterId;
|
||||
private int? _addTypeId;
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
|
||||
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
}
|
||||
|
||||
private string MemberSummary(CostCategory c)
|
||||
{
|
||||
var meters = c.Members.Count(m => m.MeterId is not null);
|
||||
var types = c.Members.Count(m => m.EnergyTypeId is not null);
|
||||
return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)";
|
||||
}
|
||||
|
||||
private string MemberLabel(CostCategoryMember m) =>
|
||||
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}"
|
||||
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}"
|
||||
: "—";
|
||||
|
||||
private void OpenEdit(CostCategory? category)
|
||||
{
|
||||
if (category is null)
|
||||
{
|
||||
_working = new EditModel();
|
||||
_members = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
_working = new EditModel { Id = category.Id, Name = category.Name, ColorHex = category.ColorHex, Sort = category.Sort };
|
||||
_members = [.. category.Members];
|
||||
}
|
||||
_addMeterId = null;
|
||||
_addTypeId = null;
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
var category = new CostCategory { Name = _working.Name.Trim(), ColorHex = Trim(_working.ColorHex), Sort = _working.Sort };
|
||||
db.CostCategories.Add(category);
|
||||
await db.SaveChangesAsync();
|
||||
// Re-open on the new category so members can be added.
|
||||
Snackbar.Add("Saved. Add members below.", Severity.Success);
|
||||
await LoadAsync();
|
||||
OpenEdit(_categories!.First(c => c.Id == category.Id));
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await db.CostCategories.FirstAsync(c => c.Id == _working.Id);
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.ColorHex = Trim(_working.ColorHex);
|
||||
existing.Sort = _working.Sort;
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task AddMeterMemberAsync()
|
||||
{
|
||||
if (_addMeterId is not { } meterId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (!await db.CostCategoryMembers.AnyAsync(m => m.CategoryId == _working.Id && m.MeterId == meterId))
|
||||
{
|
||||
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = _working.Id, MeterId = meterId });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
_addMeterId = null;
|
||||
await ReloadMembersAsync();
|
||||
}
|
||||
|
||||
private async Task AddTypeMemberAsync()
|
||||
{
|
||||
if (_addTypeId is not { } typeId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (!await db.CostCategoryMembers.AnyAsync(m => m.CategoryId == _working.Id && m.EnergyTypeId == (short)typeId))
|
||||
{
|
||||
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = _working.Id, EnergyTypeId = (short)typeId });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
_addTypeId = null;
|
||||
await ReloadMembersAsync();
|
||||
}
|
||||
|
||||
private async Task RemoveMemberAsync(int memberId)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.CostCategoryMembers.Where(m => m.Id == memberId).ExecuteDeleteAsync();
|
||||
await ReloadMembersAsync();
|
||||
}
|
||||
|
||||
private async Task ReloadMembersAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_members = await db.CostCategoryMembers.AsNoTracking().Where(m => m.CategoryId == _working.Id).ToListAsync();
|
||||
_categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(CostCategory category)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete category",
|
||||
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked."))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
// Members cascade with the category; manual_cost.category_id is SetNull.
|
||||
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? ColorHex { get; set; }
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
@page "/admin/connectors"
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
|
||||
@inject MeterVault.Infrastructure.Security.SecretProtector Secrets
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Connectors</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Connectors</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add connector
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
Secrets are never stored here. Credentials/tokens are referenced by the <b>name of an environment variable</b>
|
||||
(or Docker secret) resolved at runtime.
|
||||
</MudAlert>
|
||||
|
||||
@if (_endpoints is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Enabled</MudTh>
|
||||
<MudTh>Last status</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Type">@context.Type</MudTd>
|
||||
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd>
|
||||
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_endpoints.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<EndpointType>())
|
||||
{
|
||||
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
|
||||
@if (_working.Type == EndpointType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="Enter the token here" Color="Color.Primary" Class="mb-1" />
|
||||
@if (_working.UseDirectToken)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
|
||||
Label="@(_working.HasStoredToken ? "Long-lived access token (stored — type to replace)" : "Long-lived access token")" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Encrypted before it is stored; database dumps and JSON exports carry nothing usable.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
The variable's <em>name</em>, not the token. Set it on the server and restart the app.
|
||||
</MudText>
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval.
|
||||
</MudText>
|
||||
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
||||
Test connection
|
||||
</MudButton>
|
||||
@if (_testing)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-2" />
|
||||
}
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="Enter credentials here" Color="Color.Primary" Class="mb-1" />
|
||||
@if (_working.UseDirectCredentials)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Username" Label="Username (optional)" Class="mb-1" />
|
||||
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
|
||||
Label="@(_working.HasStoredPassword ? "Password (stored — type to replace)" : "Password (optional)")" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Encrypted before it is stored; database dumps and JSON exports carry nothing usable.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
|
||||
}
|
||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<IngestionEndpoint>? _endpoints;
|
||||
private bool _editOpen;
|
||||
private bool _testing;
|
||||
private HaTestResult? _testResult;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private void OpenEdit(IngestionEndpoint? endpoint)
|
||||
{
|
||||
_testResult = null;
|
||||
if (endpoint is null)
|
||||
{
|
||||
_working = new EditModel();
|
||||
}
|
||||
else if (endpoint.Type == EndpointType.HomeAssistant)
|
||||
{
|
||||
var ha = HaEndpointConfig.Parse(endpoint.Config);
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||
BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv, UseWebSocket = ha.UseWebSocket,
|
||||
// Carry the ciphertext through untouched and never send the secret to the browser:
|
||||
// the field stays blank and only a typed value replaces what is stored.
|
||||
TokenEnc = ha.TokenEnc,
|
||||
UseDirectToken = !string.IsNullOrWhiteSpace(ha.TokenEnc),
|
||||
// The host the stored token was saved against; a stored token is never sent anywhere else.
|
||||
SavedBaseUrl = ha.BaseUrl,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var mqtt = EndpointConfig.Parse(endpoint.Config);
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||
Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls,
|
||||
UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv,
|
||||
Username = mqtt.Username, PasswordEnc = mqtt.PasswordEnc,
|
||||
UseDirectCredentials =
|
||||
!string.IsNullOrWhiteSpace(mqtt.Username) || !string.IsNullOrWhiteSpace(mqtt.PasswordEnc),
|
||||
ExtraTopics = string.Join(", ", mqtt.ExtraTopics),
|
||||
};
|
||||
}
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task TestHaAsync()
|
||||
{
|
||||
_testing = true;
|
||||
_testResult = null;
|
||||
try
|
||||
{
|
||||
// Test what the connector would actually use — including a token typed but not yet
|
||||
// saved, so a bad token is caught before it is stored.
|
||||
if (_working.UseDirectToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_working.Token))
|
||||
{
|
||||
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.Token, _working.TestEntityId);
|
||||
}
|
||||
else if (!SameOrigin(_working.BaseUrl, _working.SavedBaseUrl))
|
||||
{
|
||||
// Storing the token encrypted means the UI can decrypt something the operator
|
||||
// can no longer read. Sending it to a Base URL edited in this dialog would turn
|
||||
// "Test connection" into an exfiltration primitive — point it at any host and the
|
||||
// token arrives as a Bearer header. A stored secret only ever goes to the origin
|
||||
// it was saved for; testing elsewhere means typing the token again.
|
||||
_testResult = new HaTestResult(false,
|
||||
"Base URL differs from the saved one. Re-enter the token to test against a different host — "
|
||||
+ "a stored token is only sent to the host it was saved for.");
|
||||
}
|
||||
else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 })
|
||||
{
|
||||
_testResult = await HaTester.TestAsync(_working.BaseUrl, stored, _working.TestEntityId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_testResult = new HaTestResult(false, "Enter a token first.");
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
|
||||
{
|
||||
_testResult = new HaTestResult(false,
|
||||
"Name the environment variable holding the token, or switch on \"Enter the token here\".");
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
|
||||
{
|
||||
_testResult = new HaTestResult(false,
|
||||
$"Environment variable '{_working.TokenEnv}' is not set on the server. Set it and restart the app, "
|
||||
+ "or switch on \"Enter the token here\" to store the token directly.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_testResult = await HaTester.TestAsync(_working.BaseUrl, envToken, _working.TestEntityId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_working.Type == EndpointType.HomeAssistant
|
||||
&& _working.UseDirectToken
|
||||
&& string.IsNullOrWhiteSpace(_working.Token)
|
||||
&& !_working.HasStoredToken)
|
||||
{
|
||||
Snackbar.Add("Enter the token, or switch off \"Enter the token here\" and name an env var.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var config = _working.Type == EndpointType.HomeAssistant
|
||||
? new HaEndpointConfig
|
||||
{
|
||||
BaseUrl = Trim(_working.BaseUrl),
|
||||
UseWebSocket = _working.UseWebSocket,
|
||||
// Exactly one storage form survives a save: switching modes clears the other, so a
|
||||
// stale token cannot linger and silently win at resolution time.
|
||||
TokenEnv = _working.UseDirectToken ? null : Trim(_working.TokenEnv),
|
||||
TokenEnc = _working.UseDirectToken ? ProtectOrKeep(_working.Token, _working.TokenEnc) : null,
|
||||
}.ToJson()
|
||||
: new EndpointConfig
|
||||
{
|
||||
Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(),
|
||||
Port = _working.Port,
|
||||
Tls = _working.Tls,
|
||||
UsernameEnv = _working.UseDirectCredentials ? null : Trim(_working.UsernameEnv),
|
||||
PasswordEnv = _working.UseDirectCredentials ? null : Trim(_working.PasswordEnv),
|
||||
Username = _working.UseDirectCredentials ? Trim(_working.Username) : null,
|
||||
PasswordEnc = _working.UseDirectCredentials
|
||||
? ProtectOrKeep(_working.Password, _working.PasswordEnc)
|
||||
: null,
|
||||
ExtraTopics = SplitTopics(_working.ExtraTopics),
|
||||
}.ToJson();
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.IngestionEndpoints.Add(new IngestionEndpoint
|
||||
{
|
||||
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
||||
existing.Type = _working.Type;
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.Config = config;
|
||||
existing.IsEnabled = _working.IsEnabled;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(IngestionEndpoint endpoint)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id);
|
||||
// Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely
|
||||
// rather than falling back to any broker. Say so plainly.
|
||||
var note = sourceCount > 0
|
||||
? $" {sourceCount} source(s) use it and will stop ingesting until reassigned to another connector."
|
||||
: "";
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
/// <summary>
|
||||
/// Whether two URLs address the same host. Compares scheme, host and port rather than the raw
|
||||
/// string, so a trailing slash or a path tweak does not force the token to be re-typed. Fails
|
||||
/// closed: anything unparsable counts as a different origin.
|
||||
/// </summary>
|
||||
private static bool SameOrigin(string? a, string? b) =>
|
||||
Uri.TryCreate(a, UriKind.Absolute, out var left)
|
||||
&& Uri.TryCreate(b, UriKind.Absolute, out var right)
|
||||
&& string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase)
|
||||
&& left.Port == right.Port;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts a newly typed secret, or keeps the stored ciphertext when the field was left blank.
|
||||
/// The plaintext is never sent to the browser, so blank means "unchanged", not "cleared".
|
||||
/// </summary>
|
||||
private string? ProtectOrKeep(string? typed, string? existingCiphertext) =>
|
||||
string.IsNullOrWhiteSpace(typed) ? existingCiphertext : Secrets.Protect(typed);
|
||||
|
||||
private static IReadOnlyList<string> SplitTopics(string? csv) =>
|
||||
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public EndpointType Type { get; set; } = EndpointType.HomeAssistant;
|
||||
public string Name { get; set; } = "";
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
// Home Assistant
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? TokenEnv { get; set; }
|
||||
public bool UseWebSocket { get; set; }
|
||||
public string? TestEntityId { get; set; }
|
||||
|
||||
/// <summary>True to store the token here (encrypted); false to name an env var.</summary>
|
||||
public bool UseDirectToken { get; set; }
|
||||
|
||||
/// <summary>Typed token. Always blank on load — a stored secret is never sent to the browser.</summary>
|
||||
public string? Token { get; set; }
|
||||
|
||||
/// <summary>Stored ciphertext, round-tripped so leaving <see cref="Token"/> blank keeps it.</summary>
|
||||
public string? TokenEnc { get; set; }
|
||||
|
||||
/// <summary>Base URL as saved, so an edited one can be told from the token's own host.</summary>
|
||||
public string? SavedBaseUrl { get; set; }
|
||||
|
||||
public bool HasStoredToken => !string.IsNullOrWhiteSpace(TokenEnc);
|
||||
|
||||
// MQTT broker
|
||||
public string? Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 1883;
|
||||
public bool Tls { get; set; }
|
||||
public string? UsernameEnv { get; set; }
|
||||
public string? PasswordEnv { get; set; }
|
||||
public string? ExtraTopics { get; set; }
|
||||
|
||||
public bool UseDirectCredentials { get; set; }
|
||||
|
||||
/// <summary>Username entered directly — not a secret, so shown when editing.</summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
public string? Password { get; set; }
|
||||
|
||||
public string? PasswordEnc { get; set; }
|
||||
|
||||
public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
@page "/admin/energy-types"
|
||||
@rendermode InteractiveServer
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Energy types</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Energy types</MudText>
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Energy types</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add energy type
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_types is null)
|
||||
{
|
||||
@@ -19,22 +26,159 @@ else
|
||||
<MudTh>Display name</MudTh>
|
||||
<MudTh>Base unit</MudTh>
|
||||
<MudTh>Default mode</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
||||
<MudTd DataLabel="Display name">@context.DisplayName</MudTd>
|
||||
<MudTd DataLabel="Display name">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||
{
|
||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||
}
|
||||
@context.DisplayName
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
||||
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" />
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<EnergyType>? _types;
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_types = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||
}
|
||||
|
||||
private void OpenEdit(EnergyType? type)
|
||||
{
|
||||
_working = type is null
|
||||
? new EditModel()
|
||||
: new EditModel
|
||||
{
|
||||
Id = type.Id,
|
||||
Key = type.Key,
|
||||
DisplayName = type.DisplayName,
|
||||
BaseUnit = type.BaseUnit,
|
||||
Mode = type.DefaultMode,
|
||||
Icon = type.Icon,
|
||||
ColorHex = type.ColorHex,
|
||||
};
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit))
|
||||
{
|
||||
Snackbar.Add("Key, display name and base unit are required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id))
|
||||
{
|
||||
Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.EnergyTypes.Add(new EnergyType
|
||||
{
|
||||
Key = _working.Key.Trim(),
|
||||
DisplayName = _working.DisplayName.Trim(),
|
||||
BaseUnit = _working.BaseUnit.Trim(),
|
||||
DefaultMode = _working.Mode,
|
||||
Icon = string.IsNullOrWhiteSpace(_working.Icon) ? null : _working.Icon,
|
||||
ColorHex = string.IsNullOrWhiteSpace(_working.ColorHex) ? null : _working.ColorHex,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.EnergyTypes.FirstAsync(t => t.Id == _working.Id);
|
||||
existing.Key = _working.Key.Trim();
|
||||
existing.DisplayName = _working.DisplayName.Trim();
|
||||
existing.BaseUnit = _working.BaseUnit.Trim();
|
||||
existing.DefaultMode = _working.Mode;
|
||||
existing.Icon = string.IsNullOrWhiteSpace(_working.Icon) ? null : _working.Icon;
|
||||
existing.ColorHex = string.IsNullOrWhiteSpace(_working.ColorHex) ? null : _working.ColorHex;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(EnergyType type)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
|
||||
if (meterCount > 0)
|
||||
{
|
||||
Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone."))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = await db.EnergyTypes.FirstOrDefaultAsync(t => t.Id == type.Id);
|
||||
if (target is not null)
|
||||
{
|
||||
db.EnergyTypes.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public short Id { get; set; }
|
||||
public string Key { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string BaseUnit { get; set; } = "";
|
||||
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public string? Icon { get; set; }
|
||||
public string? ColorHex { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
@page "/admin/settings"
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Settings</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText>
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
These are the <b>effective</b> settings the running instance is using. They are configured via environment
|
||||
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the
|
||||
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
|
||||
</MudAlert>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Locale & time</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
||||
<tr><td>Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||
<tr><td>Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
||||
<tr><td>Raw-reading retention</td><td style="text-align:right">@_o.RawRetentionDays days</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Access & ingestion</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>REST API</td>
|
||||
<td style="text-align:right">
|
||||
@if (_o.ApiKeys.Count > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip>
|
||||
}
|
||||
else if (_o.AllowAnonymousApi)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr>
|
||||
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr>
|
||||
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
Set API keys with <code>MeterVault__ApiKeys__0</code>. Keys themselves are never shown here.
|
||||
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new();
|
||||
|
||||
protected override void OnInitialized() => _o = Options.Value;
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
@page "/admin/tariffs"
|
||||
@rendermode InteractiveServer
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Tariffs</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Tariffs</MudText>
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Tariffs</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add tariff
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_tariffs is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (_tariffs.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">No tariffs yet. Load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
||||
@@ -25,26 +28,203 @@ else
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Valid from</MudTh>
|
||||
<MudTh>Valid to</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Scope">@context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "")</MudTd>
|
||||
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd>
|
||||
<MudTd DataLabel="Component">@context.Component</MudTd>
|
||||
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_tariffs.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">No tariffs yet. Add one, or load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
|
||||
@foreach (var scope in Enum.GetValues<TariffScope>())
|
||||
{
|
||||
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.ScopeType == TariffScope.EnergyType)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else if (_working.ScopeType == TariffScope.Meter)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2">
|
||||
@foreach (var m in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2">
|
||||
@foreach (var component in Enum.GetValues<TariffComponent>())
|
||||
{
|
||||
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit (e.g. EUR/kWh, EUR/m3, EUR/month)" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<Tariff>? _tariffs;
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<Meter> _meters = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_tariffs = await db.Tariffs.AsNoTracking()
|
||||
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
||||
.ToListAsync();
|
||||
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
||||
{
|
||||
TariffScope.Global => "Global",
|
||||
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}",
|
||||
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}",
|
||||
_ => t.ScopeType.ToString(),
|
||||
};
|
||||
|
||||
private void OpenEdit(Tariff? tariff)
|
||||
{
|
||||
_working = tariff is null
|
||||
? new EditModel { ValidFrom = DateTime.Today }
|
||||
: new EditModel
|
||||
{
|
||||
Id = tariff.Id,
|
||||
ScopeType = tariff.ScopeType,
|
||||
ScopeId = tariff.ScopeId,
|
||||
Component = tariff.Component,
|
||||
Value = tariff.Value,
|
||||
Unit = tariff.Unit,
|
||||
Currency = tariff.Currency,
|
||||
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
|
||||
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
|
||||
Notes = tariff.Notes,
|
||||
};
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
|
||||
{
|
||||
Snackbar.Add("Unit and valid-from are required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
|
||||
{
|
||||
Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId;
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.Tariffs.Add(new Tariff
|
||||
{
|
||||
ScopeType = _working.ScopeType,
|
||||
ScopeId = scopeId,
|
||||
Component = _working.Component,
|
||||
Value = _working.Value,
|
||||
Unit = _working.Unit.Trim(),
|
||||
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(),
|
||||
ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value),
|
||||
ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null,
|
||||
Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.Tariffs.FirstAsync(t => t.Id == _working.Id);
|
||||
existing.ScopeType = _working.ScopeType;
|
||||
existing.ScopeId = scopeId;
|
||||
existing.Component = _working.Component;
|
||||
existing.Value = _working.Value;
|
||||
existing.Unit = _working.Unit.Trim();
|
||||
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim();
|
||||
existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value);
|
||||
existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null;
|
||||
existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Tariff tariff)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var target = await db.Tariffs.FirstOrDefaultAsync(t => t.Id == tariff.Id);
|
||||
if (target is not null)
|
||||
{
|
||||
db.Tariffs.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public TariffScope ScopeType { get; set; } = TariffScope.EnergyType;
|
||||
public int? ScopeId { get; set; }
|
||||
public TariffComponent Component { get; set; } = TariffComponent.UnitPrice;
|
||||
public double Value { get; set; }
|
||||
public string Unit { get; set; } = "EUR/kWh";
|
||||
public string Currency { get; set; } = "EUR";
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
public DateTime? ValidTo { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
@page "/consumables"
|
||||
@inject ConsumableService ConsumablesSvc
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Consumables</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Oil / consumables</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@if (_items is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (_items.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
|
||||
data from <MudLink Href="/import">Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in _items)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
|
||||
<MudText Typo="Typo.h5">
|
||||
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
|
||||
</MudText>
|
||||
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
|
||||
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
|
||||
{
|
||||
<text> · @Format.Number(cm, 0) cm</text>
|
||||
}
|
||||
@if (item.LevelAsOf is { } asOf)
|
||||
{
|
||||
<text> · as of @asOf.ToString("yyyy-MM-dd")</text>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@if (item.FixedRate is { } fr)
|
||||
{
|
||||
<text>@Format.Number(fr, 2) @item.Unit/h</text>
|
||||
}
|
||||
else if (item.EffectiveRate is { } er)
|
||||
{
|
||||
<text>@Format.Number(er, 2) @item.Unit/h</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>—</text>
|
||||
}
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
|
||||
@if (item.AveragePerDay is { } apd)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
|
||||
(@Format.Number(apd, 1) @item.Unit/day)
|
||||
</MudText>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
|
||||
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="5">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText>
|
||||
@if (item.Deliveries.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height:260px; overflow-y:auto">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th>Date</th><th style="text-align:right">Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var delivery in item.Deliveries)
|
||||
{
|
||||
<tr>
|
||||
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
|
||||
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private IReadOnlyList<ConsumableSummary>? _items;
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_items = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = asOf.AddMonths(-_months);
|
||||
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
|
||||
{
|
||||
var points = item.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption))
|
||||
.ToList();
|
||||
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
|
||||
}
|
||||
|
||||
private static Color FillColor(double fraction) => fraction switch
|
||||
{
|
||||
< 0.15 => Color.Error,
|
||||
< 0.30 => Color.Warning,
|
||||
_ => Color.Success,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Overview</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
|
||||
|
||||
<UpdateBanner />
|
||||
|
||||
@if (_summary is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
@page "/energy/{Id:int}"
|
||||
@inject FlowService Flow
|
||||
@inject MeterVault.Infrastructure.Costing.CostService Costs
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@if (_graph is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (!_graph.HasData)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
|
||||
reference data from <MudLink Href="/import">Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
|
||||
<MudText Typo="Typo.h5">@_meters.Count</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
|
||||
@if (_graph.HasChain)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
|
||||
</MudText>
|
||||
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
|
||||
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
|
||||
</MudAlert>
|
||||
@if (_graph.Nodes.Count > 0)
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
|
||||
{
|
||||
<tr>
|
||||
<td>@node.Label</td>
|
||||
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<tr>
|
||||
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
|
||||
<td>@meter.Mode</td>
|
||||
<td>@UpstreamLabel(meter.Id)</td>
|
||||
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private FlowGraph? _graph;
|
||||
private double _cost;
|
||||
private List<Meter> _meters = [];
|
||||
private Dictionary<int, List<string>> _downstream = [];
|
||||
|
||||
protected override Task OnParametersSetAsync() => LoadAsync();
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_graph = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
|
||||
var to = asOf.AddMonths(1);
|
||||
var typeId = (short)Id;
|
||||
|
||||
_graph = await Flow.GetFlowAsync(typeId, from, to);
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
|
||||
var links = await db.MeterLinks.AsNoTracking()
|
||||
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
|
||||
.ToListAsync();
|
||||
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
|
||||
_downstream = links
|
||||
.GroupBy(l => l.FromMeterId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
|
||||
|
||||
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
double cost = 0;
|
||||
foreach (var meter in _meters)
|
||||
{
|
||||
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
|
||||
}
|
||||
_cost = cost;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
|
||||
|
||||
private string UpstreamLabel(int meterId) =>
|
||||
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
@page "/import"
|
||||
@rendermode InteractiveServer
|
||||
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
|
||||
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
|
||||
@inject MeterVault.Infrastructure.Import.ImportService ImportService
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject IDialogService Dialogs
|
||||
@inject ISnackbar Snackbar
|
||||
@using MeterVault.Infrastructure.Import
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Import</PageTitle>
|
||||
|
||||
@@ -30,18 +34,23 @@
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6">Dry-run a CSV</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||
Upload a sheet and preview what would be staged (no changes are made).
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<MudText Typo="Typo.h6">Your own CSV</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh">Mapping wizard</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
|
||||
Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a
|
||||
revertible import. Or dry-run against one of the built-in reference profiles below.
|
||||
</MudText>
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="Sheet type" Dense="true" Class="mb-2">
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
||||
Choose CSV
|
||||
Dry-run a reference sheet
|
||||
</MudButton>
|
||||
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
||||
</MudPaper>
|
||||
@@ -72,6 +81,49 @@
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Recent imports</MudText>
|
||||
@if (_batches.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No imports yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>#</th><th>Source</th><th style="text-align:right">Rows</th><th>Imported</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var batch in _batches)
|
||||
{
|
||||
<tr>
|
||||
<td>@batch.Id</td>
|
||||
<td>@(batch.SourceName ?? "—")</td>
|
||||
<td style="text-align:right">@batch.RowCount</td>
|
||||
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>
|
||||
@if (batch.RevertedAt is not null)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">reverted</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">active</MudChip>
|
||||
}
|
||||
</td>
|
||||
<td style="text-align:right">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Undo"
|
||||
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
|
||||
OnClick="@(() => RevertAsync(batch))">Revert</MudButton>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
@@ -79,9 +131,49 @@
|
||||
private bool _referenceLoaded;
|
||||
private string _profileName = "Strom";
|
||||
private StagedImport? _preview;
|
||||
private List<ImportBatch> _batches = [];
|
||||
private int? _reverting;
|
||||
|
||||
protected override async Task OnInitializedAsync() =>
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_referenceLoaded = await ReferenceImporter.IsLoadedAsync();
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
|
||||
private async Task LoadBatchesAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_batches = await db.ImportBatches.AsNoTracking()
|
||||
.OrderByDescending(b => b.Id)
|
||||
.Take(25)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task RevertAsync(ImportBatch batch)
|
||||
{
|
||||
if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?",
|
||||
$"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?",
|
||||
"Revert"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_reverting = batch.Id;
|
||||
try
|
||||
{
|
||||
await ImportService.RevertAsync(batch.Id);
|
||||
Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success);
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reverting = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadReferenceAsync()
|
||||
{
|
||||
@@ -92,6 +184,7 @@
|
||||
await ReferenceImporter.LoadAsync(dir);
|
||||
_referenceLoaded = true;
|
||||
Snackbar.Add("Reference data loaded.", Severity.Success);
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
@page "/import/wizard"
|
||||
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
|
||||
@inject MeterVault.Infrastructure.Import.ImportService ImportService
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager Nav
|
||||
@using System.Text.Json
|
||||
@using MeterVault.Core.Domain
|
||||
@using MeterVault.Infrastructure.Import
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Import wizard</PageTitle>
|
||||
|
||||
<div class="d-flex align-center mb-4" style="gap:1rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="Back to Import" />
|
||||
<MudText Typo="Typo.h4">Import wizard</MudText>
|
||||
</div>
|
||||
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
|
||||
Upload any CSV, map its columns to your meters and categories, preview what would be staged, then
|
||||
commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes,
|
||||
<code>Monat JJJJ</code> or <code>TT.MM.JJJJ</code> dates) — the same parser the reference sheets use.
|
||||
</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
|
||||
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
|
||||
Choose CSV
|
||||
</MudButton>
|
||||
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
|
||||
@if (_fileName is not null)
|
||||
{
|
||||
<MudText><b>@_fileName</b> — @_rows.Count rows, @_colCount columns</MudText>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@if (_colCount > 0)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">1. Parsing options</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="int" @bind-Value="_dateColumn" Label="Date column" Dense="true">
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="Date format" Dense="true">
|
||||
<MudSelectItem T="DateKind" Value="DateKind.Auto">Auto-detect</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.MonthName">Month name (Januar 2024)</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">Day (31.12.2024)</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_headerRow" Label="Header row" Min="0" Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="First data row" Min="0" Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="2" Class="d-flex flex-column">
|
||||
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="Skip zero rows" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="Detect swaps" Color="Color.Primary" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">2. Column preview</MudText>
|
||||
<div style="overflow-x:auto">
|
||||
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
|
||||
Col @i@(i == _dateColumn ? " 📅" : "")
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in Enumerable.Range(0, Math.Min(_rows.Count, _firstDataRow + 8)))
|
||||
{
|
||||
<tr style="@(r < _firstDataRow ? "opacity:0.5" : "")">
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<td style="font-size:0.78rem; white-space:nowrap">@Cell(r, i)</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Faded rows are before the first data row. The 📅 column supplies the date.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">3. Map columns</MudText>
|
||||
<div style="overflow-x:auto">
|
||||
<MudSimpleTable Dense="true" Style="min-width:100%">
|
||||
<thead>
|
||||
<tr><th>Column</th><th>Sample</th><th style="min-width:160px">Role</th><th style="min-width:220px">Target</th><th style="min-width:120px">Unit</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<b>Col @i</b>
|
||||
@if (!string.IsNullOrWhiteSpace(Header(i)))
|
||||
{
|
||||
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span>
|
||||
}
|
||||
</td>
|
||||
<td style="font-size:0.8rem; max-width:140px; overflow:hidden; text-overflow:ellipsis">@Sample(i)</td>
|
||||
<td>
|
||||
<MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
|
||||
@foreach (var role in Enum.GetValues<MappingRole>())
|
||||
{
|
||||
<MudSelectItem T="MappingRole" Value="role">@role</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</td>
|
||||
<td>
|
||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_columns[i].MeterId" Dense="true" Margin="Margin.Dense"
|
||||
Placeholder="Select meter" Clearable="true">
|
||||
@foreach (var m in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else if (_columns[i].Role == MappingRole.ManualCost)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense"
|
||||
Placeholder="Select category" Clearable="true">
|
||||
@foreach (var c in _categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||
{
|
||||
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="e.g. kWh" Margin="Margin.Dense" />
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@if (_validationErrors.Count > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-4">
|
||||
@foreach (var err in _validationErrors)
|
||||
{
|
||||
<div>@err</div>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap">
|
||||
<MudText Typo="Typo.h6">4. Preview & commit</MudText>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
|
||||
OnClick="Preview">Dry-run preview</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
|
||||
Commit import
|
||||
</MudButton>
|
||||
@if (_committing)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_staged is not null)
|
||||
{
|
||||
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
|
||||
<MudText>Readings: <b>@_staged.Readings.Count</b></MudText>
|
||||
<MudText>Events: <b>@_staged.Events.Count</b></MudText>
|
||||
<MudText>Manual costs: <b>@_staged.ManualCosts.Count</b></MudText>
|
||||
<MudText>Skipped rows: <b>@_staged.SkippedRows</b></MudText>
|
||||
</div>
|
||||
@if (_staged.TotalRows == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
|
||||
Nothing staged. Check the first-data-row, date column and column mappings above.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (_staged.Warnings.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel Text="@($"{_staged.Warnings.Count} warnings")">
|
||||
@foreach (var warning in _staged.Warnings.Take(100))
|
||||
{
|
||||
<MudText Typo="Typo.body2">@warning</MudText>
|
||||
}
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
private sealed class ColumnState
|
||||
{
|
||||
public MappingRole Role { get; set; } = MappingRole.Ignore;
|
||||
public int? MeterId { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
}
|
||||
|
||||
private string? _fileName;
|
||||
private string? _csvText;
|
||||
private List<string[]> _rows = [];
|
||||
private int _colCount;
|
||||
private ColumnState[] _columns = [];
|
||||
|
||||
private int _dateColumn;
|
||||
private DateKind _dateKind = DateKind.Auto;
|
||||
private int _headerRow;
|
||||
private int _firstDataRow = 1;
|
||||
private bool _skipAllZero = true;
|
||||
private bool _detectSwaps;
|
||||
|
||||
private List<Meter> _meters = [];
|
||||
private List<CostCategory> _categories = [];
|
||||
private StagedImport? _staged;
|
||||
private string? _stagedMapping;
|
||||
private List<string> _validationErrors = [];
|
||||
private bool _committing;
|
||||
|
||||
private async Task OnFileAsync(InputFileChangeEventArgs args)
|
||||
{
|
||||
var file = args.File;
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_fileName = file.Name;
|
||||
using (var reader = new StreamReader(file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024)))
|
||||
{
|
||||
_csvText = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
_rows = CsvImporter.ReadRawRows(new StringReader(_csvText)).ToList();
|
||||
_colCount = _rows.Count == 0 ? 0 : _rows.Max(r => r.Length);
|
||||
_columns = Enumerable.Range(0, _colCount).Select(_ => new ColumnState()).ToArray();
|
||||
_dateColumn = 0;
|
||||
_staged = null;
|
||||
_stagedMapping = null;
|
||||
_validationErrors = [];
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||
_categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private void Preview()
|
||||
{
|
||||
_validationErrors = Validate();
|
||||
if (_validationErrors.Count > 0)
|
||||
{
|
||||
_staged = null;
|
||||
return;
|
||||
}
|
||||
|
||||
using var reader = new StringReader(_csvText ?? string.Empty);
|
||||
_staged = CsvImporter.Stage(BuildProfile(), reader);
|
||||
_stagedMapping = BuildMappingJson();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The mapping as persisted on the batch for provenance, and — compared against the mapping the
|
||||
/// preview was staged under — the check that the two still agree.
|
||||
/// </summary>
|
||||
private string BuildMappingJson() => JsonSerializer.Serialize(new
|
||||
{
|
||||
dateColumn = _dateColumn,
|
||||
dateKind = _dateKind.ToString(),
|
||||
firstDataRow = _firstDataRow,
|
||||
columns = _columns
|
||||
.Select((c, i) => new { index = i, role = c.Role.ToString(), c.MeterId, c.CategoryId, c.Unit })
|
||||
.Where(c => c.role != nameof(MappingRole.Ignore)),
|
||||
});
|
||||
|
||||
private async Task CommitAsync()
|
||||
{
|
||||
if (_staged is null || _staged.TotalRows == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The mapping can change after a dry run — re-check before writing.
|
||||
_validationErrors = Validate();
|
||||
if (_validationErrors.Count > 0)
|
||||
{
|
||||
_staged = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// The staged rows were built from the mapping as it stood at preview time. Editing a target
|
||||
// afterwards leaves them pointing at the old meter while the batch would record the new
|
||||
// mapping — wrong data, with a provenance record that contradicts it and no error to notice.
|
||||
// Nothing here can re-derive the rows, so refuse rather than write either version.
|
||||
var mappingJson = BuildMappingJson();
|
||||
if (!string.Equals(mappingJson, _stagedMapping, StringComparison.Ordinal))
|
||||
{
|
||||
_staged = null;
|
||||
_stagedMapping = null;
|
||||
_validationErrors = ["The mapping changed after the preview. Run the dry run again, then commit."];
|
||||
return;
|
||||
}
|
||||
|
||||
_committing = true;
|
||||
try
|
||||
{
|
||||
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
|
||||
Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success);
|
||||
Nav.NavigateTo("/import");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_committing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0)
|
||||
{
|
||||
errors.Add("Map at least one column to a role other than Ignore.");
|
||||
}
|
||||
|
||||
for (var i = 0; i < _columns.Length; i++)
|
||||
{
|
||||
var c = _columns[i];
|
||||
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null)
|
||||
{
|
||||
errors.Add($"Col {i} ({c.Role}) needs a target meter.");
|
||||
}
|
||||
|
||||
if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
|
||||
{
|
||||
errors.Add($"Col {i} (ManualCost) needs a target category.");
|
||||
}
|
||||
}
|
||||
|
||||
// Two Reading columns on one meter would stage two readings per row at the same timestamp,
|
||||
// and (meter_id, time) is the reading key — reject it here rather than at the DB.
|
||||
var duplicateTargets = _columns
|
||||
.Select((c, i) => (Column: c, Index: i))
|
||||
.Where(x => x.Column.Role == MappingRole.Reading && x.Column.MeterId is not null)
|
||||
.GroupBy(x => x.Column.MeterId!.Value)
|
||||
.Where(g => g.Count() > 1);
|
||||
|
||||
foreach (var group in duplicateTargets)
|
||||
{
|
||||
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}";
|
||||
var cols = string.Join(", ", group.Select(x => $"Col {x.Index}"));
|
||||
errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter.");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private MappingProfile BuildProfile() => new()
|
||||
{
|
||||
Name = _fileName ?? "Custom CSV",
|
||||
HeaderRowIndex = _headerRow,
|
||||
FirstDataRowIndex = _firstDataRow,
|
||||
DateColumn = _dateColumn,
|
||||
DateKind = _dateKind,
|
||||
SkipAllZeroRows = _skipAllZero,
|
||||
DetectCumulativeSwaps = _detectSwaps,
|
||||
Columns = Enumerable.Range(0, _colCount)
|
||||
.Where(i => _columns[i].Role != MappingRole.Ignore)
|
||||
.Select(i => new ColumnMapping
|
||||
{
|
||||
Index = i,
|
||||
Role = _columns[i].Role,
|
||||
MeterId = _columns[i].MeterId,
|
||||
CategoryId = _columns[i].CategoryId,
|
||||
Unit = string.IsNullOrWhiteSpace(_columns[i].Unit) ? null : _columns[i].Unit,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
private string Cell(int row, int col) =>
|
||||
row >= 0 && row < _rows.Count && col < _rows[row].Length ? _rows[row][col] : string.Empty;
|
||||
|
||||
private string Header(int col) => Cell(_headerRow, col);
|
||||
|
||||
private string Sample(int col)
|
||||
{
|
||||
for (var r = _firstDataRow; r < _rows.Count && r < _firstDataRow + 20; r++)
|
||||
{
|
||||
var value = Cell(r, col);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private string ColLabel(int col)
|
||||
{
|
||||
var header = Header(col);
|
||||
return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
@page "/meters/{Id:int}"
|
||||
@inject MeterDetailService Details
|
||||
@inject MeterPeriodService Periods
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@inject IServiceScopeFactory Scopes
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meter</PageTitle>
|
||||
|
||||
@if (_detail is null)
|
||||
{
|
||||
@if (_notFound)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a
|
||||
meter on a phone, where the default input sizes are fiddly. *@
|
||||
<style>
|
||||
.mv-reading-value input { font-size: 1.9rem; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
/* nowrap pins it to exactly two lines, so a long unit or a big delta cannot spill over and
|
||||
move the keypad; the full wording is repeated in the alert below the fold. */
|
||||
.mv-reading-verdict { display: flex; flex-direction: column; min-height: 2.6rem; }
|
||||
.mv-reading-verdict > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mv-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
|
||||
.mv-keypad .mud-button { height: 56px; font-size: 1.35rem; }
|
||||
</style>
|
||||
|
||||
<div class="d-flex align-center mb-4" style="gap:.75rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
|
||||
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
|
||||
@if (!_detail.IsActive)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_periods is { } p)
|
||||
{
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">@p.Label this month</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.MonthToDate, 0) @p.Unit</MudText>
|
||||
@if (p.MonthIsPartial)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
≈ @Format.Number(p.MonthProjected, 0) @p.Unit by month end
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">vs last month</MudText>
|
||||
<MudText Typo="Typo.h6">@ChangeText(p.MonthChange)</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
last month @Format.Number(p.LastMonth, 0) @p.Unit
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDate, 0) @p.Unit</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@ChangeText(p.YearChange) vs @Format.Number(p.LastYear, 0) last year
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost this year</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDateCost, 2) @p.Currency</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
≈ @Format.Number(p.YearProjectedCost, 0) @p.Currency full year
|
||||
@(p.LastYearCost > 0 ? $"· {Format.Number(p.LastYearCost, 0)} last year" : "")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (p.HasHistory)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-2" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Last 12 months</MudText>
|
||||
<div class="d-flex align-end mt-2" style="gap:.35rem; height:110px">
|
||||
@foreach (var m in p.Last12Months)
|
||||
{
|
||||
<div class="d-flex flex-column align-center" style="flex:1; height:100%">
|
||||
<div style="flex:1; display:flex; align-items:flex-end; width:100%">
|
||||
<div title="@($"{m.Month:yyyy-MM}: {Format.Number(m.Amount, 0)} {p.Unit}")"
|
||||
style="@BarStyle(m.Amount, p.Last12Months)"></div>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@m.Month.ToString("MMM")</MudText>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_periods is null && _detail.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Virtual meter — its value is an expression over other meters, evaluated when read, so it has
|
||||
no stored series of its own. See <MudLink Href="/trends">Trends</MudLink> for its figures.
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudExpansionPanels Elevation="0" Class="mb-2">
|
||||
<MudExpansionPanel Text="Meter register details">
|
||||
<div class="d-flex flex-wrap" style="gap:2rem">
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
||||
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
||||
(baseline @Format.Number(_detail.InitialBaseline, 0))
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@_detail.ReadingCount ·
|
||||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||||
</MudText>
|
||||
</div>
|
||||
<div>
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Lifetime total</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
@Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||
@if (_detail.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
A virtual meter is an expression over other meters, so it stores no readings of its own —
|
||||
enter the reading on the meter the expression refers to.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading">
|
||||
Add reading
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
@if (_detail.RecentReadings.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id.
|
||||
</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.RecentReadings)
|
||||
{
|
||||
<tr>
|
||||
<td>@Local(r.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
|
||||
<td>@QualityChip(r.Quality)</td>
|
||||
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
|
||||
@if (_detail.RecentConsumption.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _detail.RecentConsumption)
|
||||
{
|
||||
<tr>
|
||||
<td>@Local(c.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
|
||||
<td>@c.Kind</td>
|
||||
<td>@QualityChip(c.Quality)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
|
||||
@if (_detail.Events.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _detail.Events)
|
||||
{
|
||||
<tr>
|
||||
<td>@Local(e.Time).ToString("yyyy-MM-dd")</td>
|
||||
<td>@e.Type</td>
|
||||
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
||||
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
|
||||
<td>@e.Notes</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
|
||||
@if (_detail.Tariffs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Tariffs)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
|
||||
<td>@t.Component</td>
|
||||
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
|
||||
<td>@t.Unit</td>
|
||||
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
|
||||
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
|
||||
Add source
|
||||
</MudButton>
|
||||
</div>
|
||||
@if (_sources.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Type</th><th>Target</th><th>Connector</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var s in _sources)
|
||||
{
|
||||
<tr>
|
||||
<td>@s.SourceType</td>
|
||||
<td>@SourceTarget(s)</td>
|
||||
<td>
|
||||
@{ var problem = ConnectorProblem(s); }
|
||||
@if (problem is null)
|
||||
{
|
||||
@(_endpoints.FirstOrDefault(e => e.Id == s.EndpointId)?.Name ?? "—")
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTooltip Text="@problem">
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text"
|
||||
Icon="@Icons.Material.Filled.LinkOff">@problem</MudChip>
|
||||
</MudTooltip>
|
||||
}
|
||||
</td>
|
||||
<td>@(s.IsEnabled ? "yes" : "no")</td>
|
||||
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
|
||||
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
|
||||
<td>@(s.LastStatus ?? "—")</td>
|
||||
<td style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenSource(s))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteSourceAsync(s))" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Add reading — @_detail.Name</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
|
||||
|
||||
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true"
|
||||
Label="@($"Reading ({_detail.Unit})")" Variant="Variant.Outlined"
|
||||
InputMode="DecimalKeyboard" Class="mv-reading-value" Clearable="true" />
|
||||
|
||||
@* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible
|
||||
while it is being typed — the keypad pushes anything below it off a phone screen — but
|
||||
anything that grows or shrinks here would move the keys out from under the user's
|
||||
thumb mid-entry. So the slot is always the same size whether or not it says anything. *@
|
||||
<div class="mv-reading-verdict mt-1 mb-3">
|
||||
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)">
|
||||
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : "Enter a value")
|
||||
</MudText>
|
||||
@if (ChangeSinceLast is { } change)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)">
|
||||
@ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "")
|
||||
</MudText>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mv-keypad mb-3">
|
||||
@foreach (var key in Keypad)
|
||||
{
|
||||
var pressed = key;
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => PressKey(pressed))">@pressed</MudButton>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
|
||||
<MudDatePicker @bind-Date="_readingDate" Label="Date" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:150px" />
|
||||
<MudTimePicker @bind-Time="_readingTime" Label="Time" Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" Style="min-width:130px" />
|
||||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||||
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">Now</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">Local time in @_tz.Id.</MudText>
|
||||
|
||||
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
|
||||
area, so nothing the user is aiming at moves. *@
|
||||
@if (EnteredTimeSkipped)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
|
||||
That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (WouldBeRejected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
|
||||
Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a
|
||||
register that only counts up, so it will be rejected. If the meter was swapped or reset,
|
||||
record that on the Events tab first.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (ReplacesRecentReading)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
This meter already has a reading at that time — saving replaces its value.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (IsFuture)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">That time is in the future.</MudAlert>
|
||||
}
|
||||
else if (IsBackdated)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Backdated before the latest reading — consumption from there on is recomputed.
|
||||
</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
|
||||
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
|
||||
@(_readingSaving ? "Saving…" : "Save reading")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="Source type" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<SourceType>())
|
||||
{
|
||||
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
|
||||
{
|
||||
if (ConnectorsFor(needed).Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||
No @needed connector yet — <MudLink Href="/admin/connectors">create one</MudLink>
|
||||
(set it up once; every source then just picks it).
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Required="true" Class="mb-2">
|
||||
@foreach (var e in ConnectorsFor(needed))
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
}
|
||||
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" />
|
||||
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollMinutes" Label="Poll interval (minutes)" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
Hourly is plenty for a meter — monthly totals and cost come out identical, with far less raw data.
|
||||
</MudText>
|
||||
}
|
||||
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
||||
{
|
||||
<MudTextField @bind-Value="_sourceEdit.Topic" Label="MQTT topic (e.g. tele/plug1/SENSOR)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Path" Label="Value path (e.g. ENERGY.Total; blank = bare scalar)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" Class="mb-2" />
|
||||
}
|
||||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
|
||||
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||||
{
|
||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="Scale" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="Priority" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private MeterDetailView? _detail;
|
||||
private MeterPeriodView? _periods;
|
||||
private bool _notFound;
|
||||
private List<MeterSource> _sources = [];
|
||||
private List<IngestionEndpoint> _endpoints = [];
|
||||
private bool _sourceOpen;
|
||||
private SourceEdit _sourceEdit = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
private bool _readingOpen;
|
||||
private bool _readingSaving;
|
||||
private readonly ReadingEntry _entry = new();
|
||||
private DateTime? _readingDate;
|
||||
private TimeSpan? _readingTime;
|
||||
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
|
||||
|
||||
/// <summary>Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace.</summary>
|
||||
private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"];
|
||||
|
||||
/// <summary>
|
||||
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
|
||||
/// <c>decimal</c> is a C# keyword and Razor would read the required <c>@</c> escape in an
|
||||
/// attribute as a transition.
|
||||
/// </summary>
|
||||
private const InputMode DecimalKeyboard = InputMode.@decimal;
|
||||
|
||||
private static readonly MeterMode[] MonotonicModes =
|
||||
[MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter];
|
||||
|
||||
protected override void OnInitialized() => _tz = ResolveTimeZone(Options.Value.TimeZone);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_detail = null;
|
||||
_periods = null;
|
||||
_notFound = false;
|
||||
_readingOpen = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_notFound = _detail is null;
|
||||
if (_detail is not null)
|
||||
{
|
||||
_periods = await Periods.GetAsync(Id);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Readings are stored UTC (SDD §10) and shown in the instance timezone, so a value entered at
|
||||
// 18:00 reads back as 18:00 rather than as its UTC instant.
|
||||
private static TimeZoneInfo ResolveTimeZone(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||
{
|
||||
return TimeZoneInfo.Utc;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
|
||||
|
||||
/// <summary>
|
||||
/// "+12%" / "−4%" against the previous period. Less is better for consumption and worse for
|
||||
/// generation, so colour is left to the caller's context rather than hardcoded green/red here.
|
||||
/// </summary>
|
||||
private static string ChangeText(double? change)
|
||||
{
|
||||
if (change is not { } c)
|
||||
{
|
||||
return "no basis yet";
|
||||
}
|
||||
|
||||
return Math.Abs(c) < 0.005
|
||||
? "about the same"
|
||||
: $"{(c > 0 ? "+" : "−")}{Format.Number(Math.Abs(c) * 100, 0)}%";
|
||||
}
|
||||
|
||||
private static string BarStyle(double amount, IReadOnlyList<MeterMonthPoint> history)
|
||||
{
|
||||
var peak = history.Max(h => Math.Abs(h.Amount));
|
||||
var fraction = peak < 1e-9 ? 0 : Math.Abs(amount) / peak;
|
||||
// Floor at 2% so a month with a little usage is still visibly distinct from an empty one.
|
||||
var height = amount == 0 ? 0 : Math.Max(2, fraction * 100);
|
||||
return $"width:100%; height:{height.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)}%; "
|
||||
+ "background:var(--mud-palette-primary); border-radius:2px 2px 0 0";
|
||||
}
|
||||
|
||||
private void OpenReading()
|
||||
{
|
||||
if (_detail is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetNow();
|
||||
// Prefilling the last reading is what makes this quick standing at the meter: a register only
|
||||
// moves in its final digits, so backspace-and-retype beats keying six digits from scratch.
|
||||
// Falls back to the configured baseline while the meter has no readings at all.
|
||||
_entry.Prefill(_detail.LastReadingValue ?? _detail.InitialBaseline);
|
||||
_readingOpen = true;
|
||||
}
|
||||
|
||||
private void SetNow()
|
||||
{
|
||||
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, _tz);
|
||||
_readingDate = now.Date;
|
||||
_readingTime = new TimeSpan(now.Hour, now.Minute, 0);
|
||||
}
|
||||
|
||||
private void OnReadingTyped(string? value) => _entry.SetText(value);
|
||||
|
||||
private void PressKey(string key)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "⌫":
|
||||
_entry.Backspace();
|
||||
break;
|
||||
case ",":
|
||||
_entry.AppendSeparator();
|
||||
break;
|
||||
default:
|
||||
_entry.AppendDigit(key[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string LastReadingCaption()
|
||||
{
|
||||
if (_detail is not { } detail)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return detail is { LastReadingValue: { } value, LastReadingTime: { } time }
|
||||
? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}."
|
||||
: $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit}).";
|
||||
}
|
||||
|
||||
/// <summary>The wall-clock instant the two pickers describe, read in the instance timezone.</summary>
|
||||
private DateTime? EnteredWallClock =>
|
||||
_readingDate is { } date ? date.Date + (_readingTime ?? TimeSpan.Zero) : null;
|
||||
|
||||
/// <summary>
|
||||
/// True when the chosen local time falls in a spring-forward gap and so names no instant at all.
|
||||
/// Converting it would throw, so the dialog blocks the save and says why instead.
|
||||
/// </summary>
|
||||
private bool EnteredTimeSkipped =>
|
||||
EnteredWallClock is { } wall && _tz.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
|
||||
|
||||
/// <remarks>
|
||||
/// An ambiguous autumn hour resolves to standard time, <see cref="TimeZoneInfo"/>'s default. The
|
||||
/// two candidate instants are an hour apart on one hour of one night a year — well inside the
|
||||
/// precision of a timestamp somebody typed by hand.
|
||||
/// </remarks>
|
||||
private DateTimeOffset? EnteredUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
if (EnteredWallClock is not { } wall || EnteredTimeSkipped)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var unspecified = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
|
||||
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, _tz), TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsMonotonic => _detail is not null && Array.IndexOf(MonotonicModes, _detail.Mode) >= 0;
|
||||
|
||||
private bool IsBackdated => EnteredUtc is { } entered && _detail?.LastReadingTime is { } last && entered < last;
|
||||
|
||||
// A minute of slack so "now" never trips the future warning on a slow round trip.
|
||||
private bool IsFuture => EnteredUtc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1);
|
||||
|
||||
private double? ChangeSinceLast =>
|
||||
!IsBackdated && _entry.Value is { } value && _detail?.LastReadingValue is { } last ? value - last : null;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the ingestion guard closely enough to warn before saving rather than after. The
|
||||
/// service compares against the reading immediately before the entered time; this page only
|
||||
/// holds the latest one, so a backdated entry gets no verdict rather than a wrong one.
|
||||
/// </summary>
|
||||
private bool WouldBeRejected =>
|
||||
IsMonotonic && !IsBackdated && _entry.Value is { } value
|
||||
&& _detail?.LastReadingValue is { } last && value < last;
|
||||
|
||||
/// <summary>
|
||||
/// Whether saving would overwrite a reading the page already lists. Bounded to the loaded rows,
|
||||
/// so it is a heads-up rather than a guarantee — the save reports what actually happened.
|
||||
/// </summary>
|
||||
private bool ReplacesRecentReading =>
|
||||
EnteredUtc is { } entered && _detail is not null && _detail.RecentReadings.Any(r => r.Time == entered);
|
||||
|
||||
private bool CanSaveReading =>
|
||||
!_readingSaving && _entry.Value is not null && EnteredWallClock is not null && !EnteredTimeSkipped;
|
||||
|
||||
private string ChangeSinceText(double change) =>
|
||||
Math.Abs(change) < 1e-9
|
||||
? "no change since last reading"
|
||||
: $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading";
|
||||
|
||||
private async Task SaveReadingAsync()
|
||||
{
|
||||
if (_detail is null || _entry.Value is not { } value || EnteredUtc is not { } utc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_readingSaving = true;
|
||||
try
|
||||
{
|
||||
// A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit
|
||||
// long outlives the unit of work a single save should share one with.
|
||||
await using var scope = Scopes.CreateAsyncScope();
|
||||
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
var outcome = await ingestion.IngestByMeterAsync(
|
||||
Id, utc, value, renormalize: true, quality: ReadingQuality.Manual);
|
||||
|
||||
switch (outcome)
|
||||
{
|
||||
case IngestionOutcome.Written:
|
||||
Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.Updated:
|
||||
Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
|
||||
break;
|
||||
case IngestionOutcome.RejectedDecrease:
|
||||
// Leave the dialog open: the typed value is still on screen to correct, and the
|
||||
// alternative fix — recording a reset or swap — is a decision, not a retry.
|
||||
Snackbar.Add(
|
||||
"Rejected — below the previous reading on a register that only counts up. "
|
||||
+ "Record a counter reset or meter swap first.", Severity.Error);
|
||||
return;
|
||||
default:
|
||||
Snackbar.Add("This meter no longer exists.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_readingOpen = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_periods = _detail is null ? null : await Periods.GetAsync(Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_readingSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadSourcesAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == Id).OrderBy(s => s.Priority).ToListAsync();
|
||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private static string SourceTarget(MeterSource s)
|
||||
{
|
||||
var config = SourceConfig.Parse(s.Config);
|
||||
return s.SourceType == SourceType.HomeAssistant
|
||||
? config.EntityId ?? "—"
|
||||
: config.Topic ?? "—";
|
||||
}
|
||||
|
||||
private void OpenSource(MeterSource? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
_sourceEdit = new SourceEdit();
|
||||
OnSourceTypeChanged(_sourceEdit.SourceType);
|
||||
}
|
||||
else
|
||||
{
|
||||
var config = SourceConfig.Parse(source.Config);
|
||||
_sourceEdit = new SourceEdit
|
||||
{
|
||||
Id = source.Id,
|
||||
SourceType = source.SourceType,
|
||||
EndpointId = source.EndpointId,
|
||||
ValueKind = source.ValueKind,
|
||||
Scale = source.Scale,
|
||||
Offset = source.Offset,
|
||||
Priority = source.Priority,
|
||||
IsEnabled = source.IsEnabled,
|
||||
EntityId = config.EntityId,
|
||||
Attribute = config.Attribute,
|
||||
PollMinutes = config.PollMinutes,
|
||||
Topic = config.Topic,
|
||||
Path = config.Path,
|
||||
TimePath = config.TimePath,
|
||||
};
|
||||
}
|
||||
_sourceOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveSourceAsync()
|
||||
{
|
||||
// A live source without a matching connector has no connection details and would silently
|
||||
// never ingest, so refuse it here rather than letting it look configured.
|
||||
if (RequiredEndpointType(_sourceEdit.SourceType) is { } needed)
|
||||
{
|
||||
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
|
||||
if (selected is null)
|
||||
{
|
||||
Snackbar.Add($"Pick a {needed} connector for this {_sourceEdit.SourceType} source.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.Type != needed)
|
||||
{
|
||||
Snackbar.Add($"'{selected.Name}' is a {selected.Type} connector; a {_sourceEdit.SourceType} source needs {needed}.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selected.IsEnabled)
|
||||
{
|
||||
Snackbar.Add($"'{selected.Name}' is disabled, so this source would never ingest. Enable it first.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_sourceEdit.EndpointId = null;
|
||||
}
|
||||
|
||||
var config = new SourceConfig
|
||||
{
|
||||
EntityId = Trim(_sourceEdit.EntityId),
|
||||
Attribute = Trim(_sourceEdit.Attribute),
|
||||
PollMinutes = _sourceEdit.PollMinutes,
|
||||
Topic = Trim(_sourceEdit.Topic),
|
||||
Path = Trim(_sourceEdit.Path),
|
||||
TimePath = Trim(_sourceEdit.TimePath),
|
||||
};
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(config,
|
||||
new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_sourceEdit.Id == 0)
|
||||
{
|
||||
db.MeterSources.Add(new MeterSource
|
||||
{
|
||||
MeterId = Id,
|
||||
SourceType = _sourceEdit.SourceType,
|
||||
EndpointId = _sourceEdit.EndpointId,
|
||||
Config = configJson,
|
||||
ValueKind = _sourceEdit.ValueKind,
|
||||
Scale = _sourceEdit.Scale,
|
||||
Offset = _sourceEdit.Offset,
|
||||
Priority = _sourceEdit.Priority,
|
||||
IsEnabled = _sourceEdit.IsEnabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.MeterSources.FirstAsync(s => s.Id == _sourceEdit.Id);
|
||||
existing.SourceType = _sourceEdit.SourceType;
|
||||
existing.EndpointId = _sourceEdit.EndpointId;
|
||||
existing.Config = configJson;
|
||||
existing.ValueKind = _sourceEdit.ValueKind;
|
||||
existing.Scale = _sourceEdit.Scale;
|
||||
existing.Offset = _sourceEdit.Offset;
|
||||
existing.Priority = _sourceEdit.Priority;
|
||||
existing.IsEnabled = _sourceEdit.IsEnabled;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_sourceOpen = false;
|
||||
Snackbar.Add("Source saved.", Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteSourceAsync(MeterSource source)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Source deleted.", Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
/// <summary>
|
||||
/// Which connector kind a source type needs, or null if it needs none (manual/import/virtual).
|
||||
/// Tasmota has no endpoint kind of its own — it is served by an MQTT broker connector.
|
||||
/// </summary>
|
||||
private static EndpointType? RequiredEndpointType(SourceType sourceType) => sourceType switch
|
||||
{
|
||||
SourceType.HomeAssistant => EndpointType.HomeAssistant,
|
||||
SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
// Only enabled connectors can ingest: both MQTT and HA workers filter on IsEnabled, so offering
|
||||
// a disabled one would produce a source that saves cleanly and then never runs.
|
||||
private List<IngestionEndpoint> ConnectorsFor(EndpointType type) =>
|
||||
_endpoints.Where(e => e.Type == type && e.IsEnabled).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Why this source cannot ingest, or null if it can. Routing is endpoint-scoped, so an unbound
|
||||
/// or mis-bound source is silently dead — and deleting a connector unlinks its sources, which
|
||||
/// used to be harmless. Without this column such a source is indistinguishable from a healthy
|
||||
/// one at "Enabled: yes".
|
||||
/// </summary>
|
||||
private string? ConnectorProblem(MeterSource source)
|
||||
{
|
||||
if (RequiredEndpointType(source.SourceType) is not { } needed)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId);
|
||||
return endpoint switch
|
||||
{
|
||||
null => "no connector — never ingests",
|
||||
{ IsEnabled: false } => $"'{endpoint.Name}' is disabled",
|
||||
_ when endpoint.Type != needed => $"'{endpoint.Name}' is {endpoint.Type}, needs {needed}",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// Changing the source type can invalidate the chosen connector (an HA connector cannot serve an
|
||||
// MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair.
|
||||
private void OnSourceTypeChanged(SourceType sourceType)
|
||||
{
|
||||
_sourceEdit.SourceType = sourceType;
|
||||
|
||||
var needed = RequiredEndpointType(sourceType);
|
||||
var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId);
|
||||
if (needed is null || (selected is not null && selected.Type != needed))
|
||||
{
|
||||
_sourceEdit.EndpointId = null;
|
||||
}
|
||||
|
||||
// Sole candidate: preselect it, so the common single-broker / single-HA setup is one click.
|
||||
if (needed is not null && _sourceEdit.EndpointId is null)
|
||||
{
|
||||
var candidates = _endpoints.Where(e => e.Type == needed).ToList();
|
||||
if (candidates.Count == 1)
|
||||
{
|
||||
_sourceEdit.EndpointId = candidates[0].Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SourceEdit
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public SourceType SourceType { get; set; } = SourceType.HomeAssistant;
|
||||
public int? EndpointId { get; set; }
|
||||
public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register;
|
||||
public double Scale { get; set; } = 1;
|
||||
public double Offset { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public string? EntityId { get; set; }
|
||||
public string? Attribute { get; set; }
|
||||
public int? PollMinutes { get; set; } = 60;
|
||||
public string? Topic { get; set; }
|
||||
public string? Path { get; set; }
|
||||
public string? TimePath { get; set; }
|
||||
}
|
||||
|
||||
private static RenderFragment QualityChip(ReadingQuality quality) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
|
||||
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality</MudChip>;
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
@page "/meters"
|
||||
@rendermode InteractiveServer
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject MeterVault.Core.Normalization.INormalizationEngine Engine
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meters</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Meters</MudText>
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Meters</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add meter
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_meters is null)
|
||||
{
|
||||
@@ -21,40 +29,120 @@ else
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Sources</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh>Active</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
||||
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
||||
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="Last seen">
|
||||
@{
|
||||
var lastSeen = context.Sources
|
||||
.Where(s => s.LastSeenAt != null)
|
||||
.Select(s => s.LastSeenAt)
|
||||
.DefaultIfEmpty(null)
|
||||
.Max();
|
||||
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
|
||||
}
|
||||
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Active">@(context.IsActive ? "yes" : "no")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@if (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
No meters yet. Go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="Energy type" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Measurement mode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the
|
||||
upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2).
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_working.Mode == MeterMode.InstantRate)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption.
|
||||
Store the value as a <b>per-hour</b> rate in this meter's unit (e.g. kW for kWh, L/h for L): a
|
||||
source reporting W or L/min should carry a scale factor to convert it first.
|
||||
</MudAlert>
|
||||
}
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
|
||||
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("")">— none —</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
|
||||
Label="Sub-meter of (upstream meters)" Class="mb-2"
|
||||
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
||||
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
|
||||
@foreach (var m in AvailableUpstream())
|
||||
{
|
||||
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
<MudTextField @bind-Value="_working.Manufacturer" Label="Manufacturer (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Model" Label="Model (optional)" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="Active" Color="Color.Primary" />
|
||||
@if (_working.Id != 0 && _working.RecomputeNeeded)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Mode/baseline changed — consumption will be recomputed on save.</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<Meter>? _meters;
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<MeterLink> _allLinks = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
|
||||
_meters = await db.Meters
|
||||
.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
@@ -62,4 +150,210 @@ else
|
||||
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
|
||||
private IEnumerable<Meter> AvailableUpstream()
|
||||
{
|
||||
if (_meters is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var descendants = Descendants(_working.Id);
|
||||
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
|
||||
}
|
||||
|
||||
private HashSet<int> Descendants(int meterId)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
if (meterId == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(meterId);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
|
||||
{
|
||||
if (result.Add(link.ToMeterId))
|
||||
{
|
||||
queue.Enqueue(link.ToMeterId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string UpstreamText(IReadOnlyList<string> ids)
|
||||
{
|
||||
var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
|
||||
return string.Join(", ", names);
|
||||
}
|
||||
|
||||
private void OpenEdit(Meter? meter)
|
||||
{
|
||||
if (meter is null)
|
||||
{
|
||||
_working = new EditModel { EnergyTypeId = _energyTypes.FirstOrDefault()?.Id ?? 0 };
|
||||
}
|
||||
else
|
||||
{
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = meter.Id,
|
||||
Name = meter.Name,
|
||||
EnergyTypeId = meter.EnergyTypeId,
|
||||
Mode = meter.Mode,
|
||||
OriginalMode = meter.Mode,
|
||||
Unit = meter.Unit,
|
||||
InitialBaseline = meter.InitialBaseline,
|
||||
OriginalBaseline = meter.InitialBaseline,
|
||||
Role = MeterMeta.Role(meter.Meta) ?? "",
|
||||
Location = meter.Location,
|
||||
SerialNumber = meter.SerialNumber,
|
||||
Manufacturer = meter.Manufacturer,
|
||||
Model = meter.Model,
|
||||
IsActive = meter.IsActive,
|
||||
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
|
||||
};
|
||||
}
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
|
||||
{
|
||||
Snackbar.Add("Name, energy type and unit are required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
int meterId;
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
var meter = new Meter
|
||||
{
|
||||
Name = _working.Name.Trim(),
|
||||
EnergyTypeId = _working.EnergyTypeId,
|
||||
Mode = _working.Mode,
|
||||
Unit = _working.Unit.Trim(),
|
||||
InitialBaseline = _working.InitialBaseline,
|
||||
Meta = MeterMeta.SetRole("{}", _working.Role),
|
||||
Location = Trim(_working.Location),
|
||||
SerialNumber = Trim(_working.SerialNumber),
|
||||
Manufacturer = Trim(_working.Manufacturer),
|
||||
Model = Trim(_working.Model),
|
||||
IsActive = _working.IsActive,
|
||||
};
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
meterId = meter.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id);
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.EnergyTypeId = _working.EnergyTypeId;
|
||||
existing.Mode = _working.Mode;
|
||||
existing.Unit = _working.Unit.Trim();
|
||||
existing.InitialBaseline = _working.InitialBaseline;
|
||||
existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role);
|
||||
existing.Location = Trim(_working.Location);
|
||||
existing.SerialNumber = Trim(_working.SerialNumber);
|
||||
existing.Manufacturer = Trim(_working.Manufacturer);
|
||||
existing.Model = Trim(_working.Model);
|
||||
existing.IsActive = _working.IsActive;
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (_working.RecomputeNeeded)
|
||||
{
|
||||
var normalization = new MeterVault.Infrastructure.Normalization.NormalizationService(db, Engine);
|
||||
await normalization.RecomputeMeterAsync(existing.Id, null);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
meterId = existing.Id;
|
||||
}
|
||||
|
||||
await SyncUpstreamAsync(db, meterId, _working.Upstream);
|
||||
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
|
||||
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
|
||||
{
|
||||
var desired = desiredUpstream.Where(id => id != meterId).ToHashSet();
|
||||
var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
|
||||
|
||||
foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
|
||||
{
|
||||
db.MeterLinks.Remove(link);
|
||||
}
|
||||
|
||||
foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
|
||||
{
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Meter meter)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id);
|
||||
var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id);
|
||||
var detail = readings + consumption > 0
|
||||
? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)."
|
||||
: "";
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone."))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
// reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade.
|
||||
await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public short EnergyTypeId { get; set; }
|
||||
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public string Unit { get; set; } = "";
|
||||
public double InitialBaseline { get; set; }
|
||||
public double OriginalBaseline { get; set; }
|
||||
public string Role { get; set; } = "";
|
||||
public string? Location { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
|
||||
|
||||
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
@page "/solar"
|
||||
@inject SolarService SolarSvc
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Solar / PV</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Solar / PV</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@if (_summary is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (!_summary.HasGeneration)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from
|
||||
<MudLink Href="/import">Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Self-consumption</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
|
||||
@if (_summary.SelfConsumptionRatio is { } ratio)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Format.Number(ratio * 100, 0)% of generation</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Autarky</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
|
||||
@if (_summary.GridImport is { } grid)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid draw @Format.Number(grid, 0) kWh</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Savings (Ersparnis)</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Generation & self-consumption</MudText>
|
||||
<SeriesChart Series="_chart" Decimals="0" Height="340" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Generation by meter</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
@foreach (var meter in _summary.Meters)
|
||||
{
|
||||
<tr>
|
||||
<td>@meter.Name</td>
|
||||
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
@if (!_summary.HasLoadContext)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Tag a meter <code>total_load</code> and one <code>grid_import</code> (in meter metadata)
|
||||
to unlock self-consumption, autarky and savings.
|
||||
</MudAlert>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private SolarSummary? _summary;
|
||||
private IReadOnlyList<SeriesChart.SeriesDef> _chart = [];
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_summary = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = asOf.AddMonths(-_months);
|
||||
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
|
||||
var generation = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation))
|
||||
.ToList();
|
||||
var series = new List<SeriesChart.SeriesDef>
|
||||
{
|
||||
new("Generation", ApexCharts.SeriesType.Bar, generation),
|
||||
};
|
||||
if (_summary.HasLoadContext)
|
||||
{
|
||||
var self = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0))
|
||||
.ToList();
|
||||
series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self));
|
||||
}
|
||||
|
||||
_chart = series;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/trends"
|
||||
@rendermode InteractiveServer
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Trends</PageTitle>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using System.Net
|
||||
@using MeterVault.Infrastructure.Dashboard
|
||||
|
||||
@if (string.IsNullOrEmpty(_svg))
|
||||
{
|
||||
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="width:100%; overflow-x:auto">
|
||||
@((MarkupString)_svg)
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const double W = 1000;
|
||||
private const double NodeWidth = 16;
|
||||
private const double NodeGap = 12;
|
||||
private const double LeftPad = 8;
|
||||
private const double RightPad = 8;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<FlowNode> Nodes { get; set; } = [];
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<FlowLink> Links { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string Unit { get; set; } = "";
|
||||
|
||||
private string _svg = "";
|
||||
|
||||
protected override void OnParametersSet() => _svg = BuildSvg();
|
||||
|
||||
private string BuildSvg()
|
||||
{
|
||||
if (Nodes.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var maxDepth = Nodes.Max(n => n.Depth);
|
||||
var columns = Nodes.GroupBy(n => n.Depth).ToDictionary(g => g.Key, g => g.OrderByDescending(n => n.Value).ToList());
|
||||
var maxCount = columns.Values.Max(c => c.Count);
|
||||
var height = Math.Max(320, (maxCount * 46) + 40);
|
||||
|
||||
// One value→pixel scale so every column fits (flow is conserved → column totals are ~equal;
|
||||
// the densest column with the most gaps constrains the scale).
|
||||
var scale = double.MaxValue;
|
||||
foreach (var col in columns.Values)
|
||||
{
|
||||
var sum = col.Sum(n => n.Value);
|
||||
if (sum > 0)
|
||||
{
|
||||
scale = Math.Min(scale, (height - ((col.Count - 1) * NodeGap) - 20) / sum);
|
||||
}
|
||||
}
|
||||
|
||||
if (double.IsInfinity(scale) || scale <= 0)
|
||||
{
|
||||
scale = 1;
|
||||
}
|
||||
|
||||
var colStep = maxDepth == 0 ? 0 : (W - LeftPad - RightPad - NodeWidth) / maxDepth;
|
||||
var geo = new Dictionary<string, NodeGeo>();
|
||||
foreach (var (depth, col) in columns)
|
||||
{
|
||||
var heights = col.Select(n => Math.Max(3, n.Value * scale)).ToList();
|
||||
var colHeight = heights.Sum() + ((col.Count - 1) * NodeGap);
|
||||
var y = (height - colHeight) / 2;
|
||||
var x = LeftPad + (depth * colStep);
|
||||
for (var i = 0; i < col.Count; i++)
|
||||
{
|
||||
geo[col[i].Id] = new NodeGeo(x, y, heights[i]);
|
||||
y += heights[i] + NodeGap;
|
||||
}
|
||||
}
|
||||
|
||||
// Ribbon band offsets: order each source's out-links by target y, each target's in-links by source y.
|
||||
var srcOffset = new Dictionary<string, double>();
|
||||
var dstOffset = new Dictionary<string, double>();
|
||||
var srcBand = new Dictionary<FlowLink, double>();
|
||||
var dstBand = new Dictionary<FlowLink, double>();
|
||||
foreach (var group in Links.GroupBy(l => l.From))
|
||||
{
|
||||
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.To, out var g) ? g.Y : 0))
|
||||
{
|
||||
srcBand[link] = srcOffset.GetValueOrDefault(group.Key);
|
||||
srcOffset[group.Key] = srcBand[link] + (link.Value * scale);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in Links.GroupBy(l => l.To))
|
||||
{
|
||||
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.From, out var g) ? g.Y : 0))
|
||||
{
|
||||
dstBand[link] = dstOffset.GetValueOrDefault(group.Key);
|
||||
dstOffset[group.Key] = dstBand[link] + (link.Value * scale);
|
||||
}
|
||||
}
|
||||
|
||||
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
|
||||
var label = Nodes.ToDictionary(n => n.Id, n => n.Label);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"Flow diagram\">");
|
||||
|
||||
// Ribbons first (under nodes).
|
||||
foreach (var link in Links)
|
||||
{
|
||||
if (!geo.TryGetValue(link.From, out var s) || !geo.TryGetValue(link.To, out var t))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var band = link.Value * scale;
|
||||
var sy0 = s.Y + srcBand[link];
|
||||
var ty0 = t.Y + dstBand[link];
|
||||
var sx = s.X + NodeWidth;
|
||||
var tx = t.X;
|
||||
var midX = (sx + tx) / 2;
|
||||
var path =
|
||||
$"M{F(sx)},{F(sy0)} C{F(midX)},{F(sy0)} {F(midX)},{F(ty0)} {F(tx)},{F(ty0)} " +
|
||||
$"L{F(tx)},{F(ty0 + band)} C{F(midX)},{F(ty0 + band)} {F(midX)},{F(sy0 + band)} {F(sx)},{F(sy0 + band)} Z";
|
||||
var tip = Enc($"{label.GetValueOrDefault(link.From)} → {label.GetValueOrDefault(link.To)}: {Fmt(link.Value)}");
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<path d=\"{path}\" fill=\"{Enc(color.GetValueOrDefault(link.From, "#607D8B"))}\" fill-opacity=\"0.38\"><title>{tip}</title></path>");
|
||||
}
|
||||
|
||||
// Nodes + labels.
|
||||
foreach (var node in Nodes)
|
||||
{
|
||||
if (!geo.TryGetValue(node.Id, out var g))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rightmost = node.Depth == maxDepth;
|
||||
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
|
||||
var anchor = rightmost ? "end" : "start";
|
||||
var fill = Enc(node.ColorHex ?? "#607D8B");
|
||||
var name = Enc(node.Label);
|
||||
var val = Enc(Fmt(node.Value));
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>");
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<text x=\"{F(labelX)}\" y=\"{F(g.Y + (g.H / 2))}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" font-size=\"13\" fill=\"currentColor\">" +
|
||||
$"<tspan>{name}</tspan><tspan x=\"{F(labelX)}\" dy=\"15\" font-size=\"11\" fill-opacity=\"0.65\">{val}</tspan></text>");
|
||||
}
|
||||
|
||||
sb.Append("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim();
|
||||
|
||||
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Enc(string value) => WebUtility.HtmlEncode(value);
|
||||
|
||||
private readonly record struct NodeGeo(double X, double Y, double H);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@using ApexCharts
|
||||
|
||||
@if (HasData)
|
||||
{
|
||||
<ApexChart TItem="SeriesChart.Point" Options="_options" Height="@Height">
|
||||
@foreach (var series in Series)
|
||||
{
|
||||
<ApexPointSeries TItem="SeriesChart.Point"
|
||||
Items="series.Points"
|
||||
SeriesType="series.Type"
|
||||
Name="@series.Name"
|
||||
XValue="p => p.Label"
|
||||
YValue="p => (decimal)Math.Round(p.Value, Decimals)" />
|
||||
}
|
||||
</ApexChart>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>A single (label, value) point in a series.</summary>
|
||||
public sealed record Point(string Label, double Value);
|
||||
|
||||
/// <summary>A named series rendered as bars or a line over the shared category axis.</summary>
|
||||
public sealed record SeriesDef(string Name, SeriesType Type, IReadOnlyList<Point> Points);
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<SeriesDef> Series { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public int Height { get; set; } = 300;
|
||||
|
||||
[Parameter]
|
||||
public int Decimals { get; set; } = 2;
|
||||
|
||||
private bool HasData => Series.Any(s => s.Points.Count > 0);
|
||||
|
||||
private readonly ApexChartOptions<SeriesChart.Point> _options = new()
|
||||
{
|
||||
Theme = new Theme { Mode = Mode.Dark },
|
||||
DataLabels = new DataLabels { Enabled = false },
|
||||
Legend = new Legend { Position = LegendPosition.Top },
|
||||
Stroke = new Stroke { Width = 3, Curve = Curve.Smooth },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
@using MeterVault.Infrastructure.Update
|
||||
@inject UpdateCheckService Updates
|
||||
@inject UpdateRunner Runner
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
@* No key prompt: the operator opted out of that (MeterVault__AllowInAppUpdate is the whole gate).
|
||||
The confirmation stays — not as a security control, but because a stray click costs several
|
||||
minutes of downtime while the rebuild runs. *@
|
||||
|
||||
@* Renders nothing at all unless a newer release genuinely exists — no "you are up to date" noise. *@
|
||||
@if (_status is { UpdateAvailable: true, Running: { } running, Latest: { } latest })
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.SystemUpdateAlt">
|
||||
<div class="d-flex flex-wrap align-center" style="gap:.75rem">
|
||||
<span>MeterVault <b>@latest</b> is available — this instance runs <b>@running</b>.</span>
|
||||
@if (Runner.Availability is UpdateAvailability.Allowed)
|
||||
{
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.SystemUpdateAlt" OnClick="@(() => _confirmOpen = true)">
|
||||
Update now
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code style="opacity:.85">@_command</code>
|
||||
}
|
||||
</div>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_confirmOpen" Options="_dialogOptions">
|
||||
<TitleContent><MudText Typo="Typo.h6">Update MeterVault</MudText></TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
This pulls the latest source, rebuilds it, and restarts the service. It takes a few minutes,
|
||||
during which MeterVault is unavailable. Readings are not affected — ingestion resumes on restart.
|
||||
</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync" Disabled="_starting">
|
||||
@(_starting ? "Starting…" : "Update now")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private UpdateStatus? _status;
|
||||
private string _command = "";
|
||||
private bool _confirmOpen;
|
||||
private bool _starting;
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// Cached answer only — awaiting the check here would hold the dashboard's first paint open
|
||||
// for the length of an HTTP timeout on a cold start, or whenever the repo is unreachable.
|
||||
_status = Updates.Current;
|
||||
_command = UpdateCommandHint();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var refreshed = await Updates.GetAsync();
|
||||
if (refreshed != _status)
|
||||
{
|
||||
_status = refreshed;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartUpdateAsync()
|
||||
{
|
||||
_starting = true;
|
||||
try
|
||||
{
|
||||
var launch = await Runner.LaunchAsync();
|
||||
Snackbar.Add(launch.Message, launch.Started ? Severity.Success : Severity.Error);
|
||||
if (launch.Started)
|
||||
{
|
||||
_confirmOpen = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How this particular install updates. The LXC has an <c>update</c> command; a container is
|
||||
/// replaced by pulling a new image, and telling those users to run <c>update</c> would send them
|
||||
/// looking for a command that does not exist.
|
||||
/// </summary>
|
||||
private static string UpdateCommandHint() =>
|
||||
UpdateRunner.IsSupportedHere
|
||||
? "run: update"
|
||||
: "pull the new image and recreate the container";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MudBlazor;
|
||||
|
||||
namespace MeterVault.App;
|
||||
|
||||
/// <summary>Small wrapper over MudBlazor's message box for delete/confirm prompts in admin pages.</summary>
|
||||
public static class Confirm
|
||||
{
|
||||
public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message)
|
||||
{
|
||||
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: "Delete", cancelText: "Cancel")
|
||||
.ConfigureAwait(false);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
/// <summary>A generic yes/cancel confirmation with a caller-supplied confirm-button label.</summary>
|
||||
public static async Task<bool> ConfirmAsync(IDialogService dialog, string title, string message, string confirmText)
|
||||
{
|
||||
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: confirmText, cancelText: "Cancel")
|
||||
.ConfigureAwait(false);
|
||||
return result == true;
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -3,6 +3,7 @@ using MeterVault.App.Components;
|
||||
using MeterVault.Infrastructure;
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MudBlazor.Services;
|
||||
using Serilog;
|
||||
@@ -28,6 +29,41 @@ try
|
||||
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
|
||||
builder.Services.AddMeterVaultInfrastructure(connectionString);
|
||||
|
||||
// Key ring for connector secrets typed into the admin UI. It must outlive the app directory:
|
||||
// the LXC updater republishes /opt/metervault on every update, so keys stored beside the
|
||||
// binaries would be destroyed and every saved token would need re-entering. Override with
|
||||
// MeterVault__DataProtectionKeyPath (Docker: point it at a mounted volume).
|
||||
var keyPath = builder.Configuration["MeterVault:DataProtectionKeyPath"];
|
||||
if (string.IsNullOrWhiteSpace(keyPath))
|
||||
{
|
||||
keyPath = OperatingSystem.IsWindows()
|
||||
? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MeterVault", "keys")
|
||||
: "/var/lib/metervault/keys";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(keyPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Falling back beats refusing to boot, but say so plainly: on the fallback path an update
|
||||
// that replaces the content root loses the keys, and stored secrets stop decrypting.
|
||||
var fallback = Path.Combine(builder.Environment.ContentRootPath, "keys");
|
||||
Log.Warning(ex,
|
||||
"Cannot create data-protection key ring at {KeyPath}; falling back to {Fallback}. "
|
||||
+ "Secrets entered in the admin UI will not survive a redeploy that replaces the content "
|
||||
+ "root — set MeterVault__DataProtectionKeyPath to a writable persistent directory",
|
||||
keyPath, fallback);
|
||||
keyPath = fallback;
|
||||
Directory.CreateDirectory(keyPath);
|
||||
}
|
||||
|
||||
builder.Services.AddDataProtection()
|
||||
.SetApplicationName("MeterVault")
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keyPath));
|
||||
|
||||
var options = builder.Configuration.GetSection(MeterVaultOptions.SectionName).Get<MeterVaultOptions>()
|
||||
?? new MeterVaultOptions();
|
||||
if (options.EnableLiveIngestion)
|
||||
@@ -60,7 +96,7 @@ try
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseReverseProxyTrust();
|
||||
// No HTTPS redirection: the app serves plain HTTP (port 8080) behind a reverse proxy
|
||||
// No HTTPS redirection: the app serves plain HTTP (port 8760) behind a reverse proxy
|
||||
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
|
||||
app.UseAntiforgery();
|
||||
|
||||
@@ -104,6 +140,14 @@ static async Task MigrateDatabaseAsync(WebApplication app)
|
||||
await db.Database.MigrateAsync().ConfigureAwait(false);
|
||||
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
||||
Log.Information("Database migrations applied and defaults seeded");
|
||||
|
||||
if (options.SeedReferenceData)
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<MeterVault.Infrastructure.Import.ReferenceDataImporter>();
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
||||
await importer.LoadAsync(dir).ConfigureAwait(false);
|
||||
Log.Information("Reference dataset ensured (SeedReferenceData=true)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace MeterVault.App;
|
||||
|
||||
/// <summary>
|
||||
/// The edit buffer behind the manual-reading keypad. Holds the value as text rather than a number
|
||||
/// so a half-typed entry ("12345," while the decimals are still coming) is representable and no
|
||||
/// intermediate state gets rounded away by a numeric binding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Separator handling is deliberately lenient rather than culture-strict: the same field is driven
|
||||
/// by the on-screen keypad (always a comma), by an Android keyboard (comma on a German locale, dot
|
||||
/// on an English one) and by a desktop numpad, so a value has to survive either character. The rule
|
||||
/// is that only the <em>last</em> separator is decimal and earlier ones are grouping — "1.234,5"
|
||||
/// and "1,234.5" both give 1234.5. A lone separator is therefore always decimal ("1.234" → 1.234),
|
||||
/// which is what the keypad emits and what an English keyboard means; nobody types thousands
|
||||
/// separators into a meter register, and the dialog echoes the parsed value back formatted, so a
|
||||
/// misread is visible before saving. This differs from
|
||||
/// <see cref="Core.Parsing.GermanNumber"/> on purpose: that one parses spreadsheet exports, where a
|
||||
/// lone dot really is a thousands separator.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="IsPristine"/> gives the buffer calculator behaviour. The dialog opens prefilled with
|
||||
/// the meter's last reading; the first digit key then replaces it outright (a fresh reading), while
|
||||
/// backspace edits it in place (only the last few digits of a register usually move). Without that
|
||||
/// distinction one of the two workflows always costs a full retype on a phone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ReadingEntry
|
||||
{
|
||||
/// <summary>Wide enough for any real register plus decimals; stops a stuck key growing the string.</summary>
|
||||
public const int MaxLength = 18;
|
||||
|
||||
private const char Separator = ',';
|
||||
|
||||
public string Text { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>True while the buffer still holds the untouched prefill, so the next digit replaces it.</summary>
|
||||
public bool IsPristine { get; private set; }
|
||||
|
||||
/// <summary>The entered number, or null while the buffer is empty or not yet a valid number.</summary>
|
||||
public double? Value => TryParse(Text, out var value) ? value : null;
|
||||
|
||||
/// <summary>Seeds the buffer with a meter's last reading, marked pristine.</summary>
|
||||
public void Prefill(double value)
|
||||
{
|
||||
// "0.###" keeps a register readable (12345,6) without inventing precision the meter
|
||||
// never had; invariant then swapped so the buffer only ever contains one separator glyph.
|
||||
Text = value.ToString("0.###", CultureInfo.InvariantCulture).Replace('.', Separator);
|
||||
IsPristine = true;
|
||||
}
|
||||
|
||||
public void AppendDigit(char digit)
|
||||
{
|
||||
if (!char.IsAsciiDigit(digit))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReplacePrefillOnFirstKey();
|
||||
|
||||
// A leading zero is never meaningful on a register, and letting it stand makes "0" then "5"
|
||||
// read as "05" — replace it instead, exactly like a calculator.
|
||||
if (Text == "0")
|
||||
{
|
||||
Text = digit.ToString();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Text.Length < MaxLength)
|
||||
{
|
||||
Text += digit;
|
||||
}
|
||||
}
|
||||
|
||||
public void AppendSeparator()
|
||||
{
|
||||
ReplacePrefillOnFirstKey();
|
||||
|
||||
if (Text.Contains(Separator, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// "," alone parses as nothing, so lead with the zero the user means.
|
||||
Text = Text.Length == 0 ? "0" + Separator : Text + Separator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the last character. Unlike a digit key this keeps the prefill rather than clearing
|
||||
/// it — reading a register usually means correcting its final digits, not retyping all of them.
|
||||
/// </summary>
|
||||
public void Backspace()
|
||||
{
|
||||
IsPristine = false;
|
||||
if (Text.Length > 0)
|
||||
{
|
||||
Text = Text[..^1];
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Text = string.Empty;
|
||||
IsPristine = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts free text from the keyboard-bound field, keeping only characters that can form a
|
||||
/// number. Junk is dropped rather than rejected so typing never dead-ends mid-value.
|
||||
/// </summary>
|
||||
public void SetText(string? raw)
|
||||
{
|
||||
IsPristine = false;
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
Text = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(Math.Min(raw.Length, MaxLength));
|
||||
foreach (var c in raw)
|
||||
{
|
||||
if (builder.Length >= MaxLength)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (char.IsAsciiDigit(c) || c is '.' or ',' || (c == '-' && builder.Length == 0))
|
||||
{
|
||||
builder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
Text = builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Parses a buffer as described on the type: last separator decimal, earlier ones grouping.</summary>
|
||||
public static bool TryParse(string? text, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = text.Trim();
|
||||
var negative = raw[0] == '-';
|
||||
if (negative || raw[0] == '+')
|
||||
{
|
||||
raw = raw[1..];
|
||||
}
|
||||
|
||||
var lastSeparator = raw.LastIndexOfAny(['.', ',']);
|
||||
var builder = new StringBuilder(raw.Length);
|
||||
var digits = 0;
|
||||
for (var i = 0; i < raw.Length; i++)
|
||||
{
|
||||
var c = raw[i];
|
||||
if (char.IsAsciiDigit(c))
|
||||
{
|
||||
builder.Append(c);
|
||||
digits++;
|
||||
}
|
||||
else if (c is '.' or ',')
|
||||
{
|
||||
if (i == lastSeparator)
|
||||
{
|
||||
builder.Append('.');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (digits == 0
|
||||
|| !double.TryParse(builder.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = negative ? -parsed : parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReplacePrefillOnFirstKey()
|
||||
{
|
||||
if (IsPristine)
|
||||
{
|
||||
Text = string.Empty;
|
||||
IsPristine = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public enum MeterMode
|
||||
/// <summary>Source already reports increments. The value is the increment.</summary>
|
||||
DirectDelta,
|
||||
|
||||
/// <summary>Power/flow sensor (schema-supported; worker deferred post-v1). Integrate rate over time.</summary>
|
||||
/// <summary>Power/flow sensor. Consumption = rate integrated over time (trapezoidal).</summary>
|
||||
InstantRate,
|
||||
|
||||
/// <summary>Computed from other meters via a user-defined expression.</summary>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MeterVault.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A directed flow edge in the meter topology: energy/water measured by <see cref="ToMeter"/> is a
|
||||
/// <em>subsection</em> of the flow through <see cref="FromMeter"/> (upstream → downstream). Not an
|
||||
/// addition — a sub-meter shows where an upstream meter's flow goes. Multiple edges into one meter
|
||||
/// model a merge (e.g. house load fed by grid + solar); multiple edges out model a split
|
||||
/// (main → car, pool, …). Drives the per-energy-type flow (Sankey) view.
|
||||
/// </summary>
|
||||
public sealed class MeterLink
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Upstream meter (the larger flow this edge draws from).</summary>
|
||||
public int FromMeterId { get; set; }
|
||||
|
||||
public Meter? FromMeter { get; set; }
|
||||
|
||||
/// <summary>Downstream meter (the subsection).</summary>
|
||||
public int ToMeterId { get; set; }
|
||||
|
||||
public Meter? ToMeter { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MeterVault.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A meter's optional role in an energy system, stored under <c>role</c> in <see cref="Meter.Meta"/>.
|
||||
/// Roles let analytic panels (e.g. the PV self-consumption/autarky view) find the relevant meters
|
||||
/// by configuration rather than by hardcoded names — nothing domain-specific is baked into code
|
||||
/// (SDD §5.2, §8.4). A PV install typically tags one meter <see cref="TotalLoad"/> and one
|
||||
/// <see cref="GridImport"/>; self-consumption is then <c>total_load − grid_import</c>.
|
||||
/// </summary>
|
||||
public static class MeterRoles
|
||||
{
|
||||
/// <summary>Meter measuring the site's total consumption (all loads).</summary>
|
||||
public const string TotalLoad = "total_load";
|
||||
|
||||
/// <summary>Meter measuring energy drawn from the grid.</summary>
|
||||
public const string GridImport = "grid_import";
|
||||
|
||||
/// <summary>Meter measuring energy exported to the grid.</summary>
|
||||
public const string GridExport = "grid_export";
|
||||
}
|
||||
|
||||
/// <summary>Typed reads over a meter's free-form <c>Meta</c> JSON (jsonb). Tolerant of malformed
|
||||
/// or empty JSON — returns null rather than throwing, so a bad blob never breaks a dashboard.</summary>
|
||||
public static class MeterMeta
|
||||
{
|
||||
/// <summary>The meter's configured <c>role</c> (see <see cref="MeterRoles"/>), or null if unset/invalid.</summary>
|
||||
public static string? Role(string? meta) => ReadString(meta, "role");
|
||||
|
||||
/// <summary>Reads a top-level string property from the meta JSON; null if absent or unparsable.</summary>
|
||||
public static string? ReadString(string? meta, string property)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(meta);
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty(property, out var value)
|
||||
&& value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set to <paramref name="role"/>.</summary>
|
||||
public static string WithRole(string? meta, string role) => SetRole(meta, role);
|
||||
|
||||
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set, or removed when null/empty.</summary>
|
||||
public static string SetRole(string? meta, string? role)
|
||||
{
|
||||
var map = ToMap(meta);
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
{
|
||||
map.Remove("role");
|
||||
}
|
||||
else
|
||||
{
|
||||
map["role"] = role;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(map);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ToMap(string? meta)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(meta) ?? new Dictionary<string, object?>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
namespace MeterVault.Core.Normalization;
|
||||
|
||||
/// <summary>
|
||||
/// Spreads a register delta that spans several calendar months across the months it actually covers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A counter delta is booked at the reading that closes it, which is right when readings arrive at
|
||||
/// the reporting cadence: a monthly series books December's usage against the 1 January reading, and
|
||||
/// that is what the reference spreadsheet does. It stops being right when a meter goes unread for a
|
||||
/// long stretch — 78 days of PV generation arriving as a single July row makes June look idle and
|
||||
/// July look extraordinary, when nothing unusual happened.
|
||||
///
|
||||
/// Splitting is therefore deliberately conservative: an interval is divided only when it contains
|
||||
/// <em>two or more complete calendar months</em>. A normal monthly series contains exactly one, so it
|
||||
/// is left completely untouched and the golden-fixture reconciliation stands (SDD §13); a series that
|
||||
/// skipped a month or more contains two or more, which is precisely where lumping misleads.
|
||||
///
|
||||
/// Counting whole months contained, rather than boundaries crossed, is what makes this stable against
|
||||
/// readings that do not land on midnight: a monthly reading arriving at 06:00 on the 1st still
|
||||
/// contains one whole month, where a boundary count would tip over and hand the new month a sliver.
|
||||
///
|
||||
/// The division is by elapsed time, so it assumes a flat rate across the gap. That is a guess — the
|
||||
/// meter recorded a total, not a shape — so every row it produces is marked
|
||||
/// <see cref="Domain.ReadingQuality.Estimated"/>. The sum is exact: the final segment absorbs any
|
||||
/// rounding remainder, so a split never creates or destroys energy.
|
||||
///
|
||||
/// Boundaries are UTC. The dashboard buckets in the instance timezone (SDD §10), so a split point
|
||||
/// can sit an hour or two from the displayed month edge — immaterial for apportioning a multi-month
|
||||
/// gap, and the alternative would be threading a timezone through the otherwise timezone-free engine.
|
||||
/// </remarks>
|
||||
public static class GapAttribution
|
||||
{
|
||||
/// <summary>
|
||||
/// True when an interval contains two or more complete calendar months, and so would misattribute
|
||||
/// a long gap to its closing month.
|
||||
/// </summary>
|
||||
public static bool ShouldSplit(DateTimeOffset start, DateTimeOffset end) =>
|
||||
end > start && WholeMonthsInside(start, end) >= 2;
|
||||
|
||||
/// <summary>
|
||||
/// Divides <paramref name="amount"/> across the calendar months between the two instants,
|
||||
/// proportionally to the time spent in each.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each segment is stamped at its <em>end</em>, which keeps the existing convention that a
|
||||
/// consumption row records the period ending at its timestamp — the same reason an unsplit delta
|
||||
/// sits on its closing reading, and the reason the reference sheet's January row carries
|
||||
/// December's usage. So the share covering May is stamped 1 June and buckets as June, exactly as
|
||||
/// a May-to-June monthly reading pair already would. The last segment therefore keeps the closing
|
||||
/// reading's own timestamp, and nothing shifts relative to how unsplit intervals are labelled.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<GapSegment> Split(DateTimeOffset start, DateTimeOffset end, double amount)
|
||||
{
|
||||
if (end <= start)
|
||||
{
|
||||
return [new GapSegment(end, amount)];
|
||||
}
|
||||
|
||||
var total = end - start;
|
||||
var segments = new List<GapSegment>();
|
||||
var cursor = start;
|
||||
var assigned = 0d;
|
||||
|
||||
while (cursor < end)
|
||||
{
|
||||
var nextBoundary = NextMonthStart(cursor);
|
||||
var segmentEnd = nextBoundary < end ? nextBoundary : end;
|
||||
|
||||
if (segmentEnd >= end)
|
||||
{
|
||||
// Final segment takes the remainder, so the parts always sum to the original.
|
||||
segments.Add(new GapSegment(end, amount - assigned));
|
||||
break;
|
||||
}
|
||||
|
||||
var share = amount * ((segmentEnd - cursor) / total);
|
||||
segments.Add(new GapSegment(segmentEnd, share));
|
||||
assigned += share;
|
||||
cursor = segmentEnd;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static int WholeMonthsInside(DateTimeOffset start, DateTimeOffset end)
|
||||
{
|
||||
// A month counts only if it lies entirely within the interval, so a partial month at either
|
||||
// edge never tips the decision.
|
||||
var cursor = MonthStart(start) == start.ToUniversalTime() ? MonthStart(start) : NextMonthStart(start);
|
||||
var whole = 0;
|
||||
while (cursor.AddMonths(1) <= end)
|
||||
{
|
||||
whole++;
|
||||
cursor = cursor.AddMonths(1);
|
||||
}
|
||||
|
||||
return whole;
|
||||
}
|
||||
|
||||
private static DateTimeOffset MonthStart(DateTimeOffset instant)
|
||||
{
|
||||
var utc = instant.ToUniversalTime();
|
||||
return new DateTimeOffset(utc.Year, utc.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static DateTimeOffset NextMonthStart(DateTimeOffset instant) => MonthStart(instant).AddMonths(1);
|
||||
}
|
||||
|
||||
/// <summary>One month's share of a spread gap: the instant it closes and the amount attributed.</summary>
|
||||
public sealed record GapSegment(DateTimeOffset Time, double Amount);
|
||||
@@ -24,6 +24,7 @@ public sealed class NormalizationEngine : INormalizationEngine
|
||||
new Normalizers.RuntimeCounterNormalizer(),
|
||||
new Normalizers.ConsumableBalanceNormalizer(),
|
||||
new Normalizers.DirectDeltaNormalizer(),
|
||||
new Normalizers.InstantRateNormalizer(),
|
||||
new Normalizers.VirtualNormalizer(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ namespace MeterVault.Core.Normalization.Normalizers;
|
||||
/// reconciles to 12), otherwise <c>(oldFinal − prev) + (curr − newInitial)</c>;</item>
|
||||
/// <item>counter reset → baseline restarts at <c>NewValue</c> (default 0);</item>
|
||||
/// <item>unexplained decrease → 0 with an anomaly flagged (never a silent negative), rebaselined
|
||||
/// to the current value.</item>
|
||||
/// to the current value;</item>
|
||||
/// <item>a plain increase spanning two or more whole calendar months → apportioned across them
|
||||
/// and marked estimated (<see cref="GapAttribution"/>), so an unread stretch does not land wholly
|
||||
/// in its closing month. A monthly cadence never triggers this.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public abstract class CounterNormalizerBase : IMeterNormalizer
|
||||
@@ -45,6 +48,7 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
|
||||
var swap = FindEvent(swaps, previousTime, reading.Time);
|
||||
|
||||
double amount;
|
||||
var plainIncrease = false;
|
||||
if (swap is { EventType: MeterEventType.MeterSwap })
|
||||
{
|
||||
amount = swap.Amount
|
||||
@@ -57,6 +61,7 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
|
||||
else if (reading.Value >= previous)
|
||||
{
|
||||
amount = reading.Value - previous;
|
||||
plainIncrease = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -65,6 +70,30 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
|
||||
quality = ReadingQuality.Estimated;
|
||||
}
|
||||
|
||||
// Only a plain increase over an unread stretch is worth apportioning (SDD §7.1). A swap
|
||||
// or reset amount is an explicit correction booked at its event; a rejected decrease
|
||||
// contributes nothing; the first reading has no interval behind it; and fanning a zero
|
||||
// out across three months just adds rows that say nothing.
|
||||
var gapStart = plainIncrease && Math.Abs(amount) > 1e-9 ? previousTime : null;
|
||||
|
||||
if (gapStart is { } start && GapAttribution.ShouldSplit(start, reading.Time))
|
||||
{
|
||||
foreach (var segment in GapAttribution.Split(start, reading.Time, amount))
|
||||
{
|
||||
yield return new Consumption
|
||||
{
|
||||
MeterId = context.Meter.MeterId,
|
||||
Time = segment.Time,
|
||||
Amount = segment.Amount,
|
||||
Kind = Kind,
|
||||
// The total is measured; only its distribution across the gap is inferred.
|
||||
Quality = ReadingQuality.Estimated,
|
||||
ImportBatchId = reading.ImportBatchId,
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new Consumption
|
||||
{
|
||||
MeterId = context.Meter.MeterId,
|
||||
@@ -74,6 +103,7 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
|
||||
Quality = quality,
|
||||
ImportBatchId = reading.ImportBatchId,
|
||||
};
|
||||
}
|
||||
|
||||
previous = reading.Value;
|
||||
previousTime = reading.Time;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Core.Normalization.Normalizers;
|
||||
|
||||
/// <summary>
|
||||
/// Power/flow sensor (SDD §5.2 <c>instant_rate</c>): the reading value is an instantaneous rate, not
|
||||
/// a register. Consumption is the rate integrated over time (trapezoidal rule between consecutive
|
||||
/// samples), attributed to the interval's end reading — so the first reading only seeds the integral
|
||||
/// and N readings produce N−1 consumption rows.
|
||||
/// <para>
|
||||
/// The value is treated as a rate expressed <em>per hour</em> in the meter's own unit, i.e.
|
||||
/// <c>rate × Δhours = consumption</c>. Power in kW integrated over hours yields kWh; a flow in L/h
|
||||
/// yields L; m³/h yields m³. A source that reports a native unit (W, L/min) should carry a
|
||||
/// scale/offset that converts it to this canonical per-hour rate before it is stored as a reading.
|
||||
/// Signs are preserved (a bidirectional power sensor may go negative on export).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InstantRateNormalizer : IMeterNormalizer
|
||||
{
|
||||
public MeterMode Mode => MeterMode.InstantRate;
|
||||
|
||||
public IEnumerable<Consumption> Normalize(NormalizationContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var readings = context.Readings.OrderBy(r => r.Time).ToList();
|
||||
|
||||
Reading? previous = null;
|
||||
foreach (var reading in readings)
|
||||
{
|
||||
if (previous is not null)
|
||||
{
|
||||
var hours = (reading.Time - previous.Time).TotalHours;
|
||||
if (hours > 0)
|
||||
{
|
||||
// Trapezoidal integral of the rate over [previous, reading]; the linear mean of
|
||||
// the two samples is exact for a rate that varies linearly between them.
|
||||
var amount = (previous.Value + reading.Value) / 2d * hours;
|
||||
|
||||
yield return new Consumption
|
||||
{
|
||||
MeterId = context.Meter.MeterId,
|
||||
Time = reading.Time,
|
||||
Amount = amount,
|
||||
Kind = ConsumptionKind.Consumption,
|
||||
Quality = reading.Quality,
|
||||
ImportBatchId = reading.ImportBatchId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
previous = reading;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,25 @@ public sealed class ExportService(MeterVaultDbContext db)
|
||||
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
|
||||
/// <summary>
|
||||
/// Strips encrypted connector secrets from an export. They are ciphertext, not plaintext, so
|
||||
/// this is not a §6.4 requirement — but the export exists for portability (§9), and ciphertext
|
||||
/// is bound to the originating instance's key ring, so it is useless anywhere it could be
|
||||
/// restored and merely widens the blast radius if the key ring also leaks. Env-var references
|
||||
/// survive: they name a variable and reveal nothing. Restoring means re-entering the secrets.
|
||||
/// </summary>
|
||||
private static List<IngestionEndpoint> RedactSecrets(List<IngestionEndpoint> endpoints)
|
||||
{
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
endpoint.Config = endpoint.Type == EndpointType.HomeAssistant
|
||||
? (Ingestion.HaEndpointConfig.Parse(endpoint.Config) with { TokenEnc = null }).ToJson()
|
||||
: (Ingestion.EndpointConfig.Parse(endpoint.Config) with { PasswordEnc = null }).ToJson();
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
public async Task<string> ExportJsonAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Load without navigation includes so serialization is a clean, cycle-free tree.
|
||||
@@ -27,7 +46,8 @@ public sealed class ExportService(MeterVaultDbContext db)
|
||||
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),
|
||||
IngestionEndpoints = RedactSecrets(
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>One recorded delivery into a consumable store.</summary>
|
||||
public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit);
|
||||
|
||||
/// <summary>One month of consumable draw.</summary>
|
||||
public sealed record ConsumableMonth(DateOnly Period, double Consumption);
|
||||
|
||||
/// <summary>
|
||||
/// The oil / consumable panel read model (SDD §8.5) for one <see cref="MeterMode.ConsumableBalance"/>
|
||||
/// meter: current tank level (physical + volume), fill vs capacity, deliveries, associated burner
|
||||
/// runtime, effective L/h (fixed or empirical), forecast-to-empty, tariff cost and a monthly series.
|
||||
/// </summary>
|
||||
public sealed record ConsumableSummary(
|
||||
int MeterId,
|
||||
string Name,
|
||||
string Unit,
|
||||
double Capacity,
|
||||
double? CurrentLevel,
|
||||
double? PhysicalLevel,
|
||||
string? PhysicalUnit,
|
||||
DateTimeOffset? LevelAsOf,
|
||||
double FillFraction,
|
||||
double ConsumptionInRange,
|
||||
double? BurnerHours,
|
||||
double? EffectiveRate,
|
||||
double? FixedRate,
|
||||
TankRateMode RateMode,
|
||||
double? AveragePerDay,
|
||||
DateOnly? ForecastEmpty,
|
||||
double CostInRange,
|
||||
IReadOnlyList<DeliveryRow> Deliveries,
|
||||
IReadOnlyList<ConsumableMonth> Months);
|
||||
@@ -0,0 +1,177 @@
|
||||
using Dapper;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Costing;
|
||||
using MeterVault.Infrastructure.Normalization;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Read model for the oil / consumable panel (SDD §8.5). Works for any
|
||||
/// <see cref="MeterMode.ConsumableBalance"/> meter backed by a <see cref="Tank"/> — heating oil is
|
||||
/// only the reference case. Current level is the latest dipstick reading (cm calibrated to volume)
|
||||
/// plus deliveries recorded since; the effective burn rate pairs the consumable's litres with the
|
||||
/// runtime hours of same-energy-type <see cref="MeterMode.RuntimeCounter"/> meters. DbContext
|
||||
/// factory keeps it Blazor-circuit safe.
|
||||
/// </summary>
|
||||
public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
|
||||
{
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
private readonly CostService _costService = costService;
|
||||
|
||||
public async Task<IReadOnlyList<ConsumableSummary>> GetConsumablesAsync(
|
||||
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meters = await db.Meters.AsNoTracking()
|
||||
.Where(m => m.Mode == MeterMode.ConsumableBalance)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var summaries = new List<ConsumableSummary>();
|
||||
foreach (var meter in meters)
|
||||
{
|
||||
var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meter.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (tank is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
summaries.Add(await BuildAsync(db, meter, tank, from, to, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private async Task<ConsumableSummary> BuildAsync(
|
||||
MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||
{
|
||||
var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration;
|
||||
var fromUtc = ToUtc(from);
|
||||
var toUtc = ToUtc(to);
|
||||
|
||||
var events = await db.MeterEvents.AsNoTracking()
|
||||
.Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery))
|
||||
.OrderBy(e => e.Time)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var lastLevel = events.LastOrDefault(e => e.EventType == MeterEventType.TankLevel);
|
||||
double? currentLevel = null;
|
||||
double? physicalLevel = null;
|
||||
string? physicalUnit = null;
|
||||
DateTimeOffset? levelAsOf = null;
|
||||
if (lastLevel is not null)
|
||||
{
|
||||
physicalLevel = lastLevel.Amount;
|
||||
physicalUnit = lastLevel.Unit;
|
||||
levelAsOf = lastLevel.Time;
|
||||
var volume = ToVolume(lastLevel, calibration);
|
||||
// Deliveries recorded after the last dipstick raise the actual contents.
|
||||
var since = events.Where(e => e.EventType == MeterEventType.Delivery && e.Time > lastLevel.Time).Sum(e => e.Amount ?? 0);
|
||||
currentLevel = volume + since;
|
||||
}
|
||||
|
||||
var fillFraction = tank.Capacity > 0 && currentLevel is { } level
|
||||
? Math.Clamp(level / tank.Capacity, 0, 1)
|
||||
: 0;
|
||||
|
||||
var deliveries = events
|
||||
.Where(e => e.EventType == MeterEventType.Delivery)
|
||||
.OrderByDescending(e => e.Time)
|
||||
.Select(e => new DeliveryRow(e.Time, e.Amount ?? 0, e.Unit))
|
||||
.ToList();
|
||||
|
||||
var consumptionInRange = await SumConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Burner runtime: same-energy-type runtime meters feed this consumable's L/h analytic.
|
||||
var runtimeMeterIds = await db.Meters.AsNoTracking()
|
||||
.Where(m => m.EnergyTypeId == meter.EnergyTypeId && m.Mode == MeterMode.RuntimeCounter)
|
||||
.Select(m => m.Id)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
double? burnerHours = null;
|
||||
foreach (var id in runtimeMeterIds)
|
||||
{
|
||||
burnerHours = (burnerHours ?? 0) + await SumConsumptionAsync(db, id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
double? effectiveRate = burnerHours is > 0 ? consumptionInRange / burnerHours : null;
|
||||
double? fixedRate = tank.RateMode == TankRateMode.Fixed ? tank.FixedRate : null;
|
||||
|
||||
var (averagePerDay, forecastEmpty) = await ForecastAsync(db, meter.Id, currentLevel, levelAsOf, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false))
|
||||
.Sum(c => c.Cost);
|
||||
|
||||
var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ConsumableSummary(
|
||||
meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf,
|
||||
fillFraction, consumptionInRange, burnerHours, effectiveRate, fixedRate, tank.RateMode,
|
||||
averagePerDay, forecastEmpty, costInRange, deliveries, months);
|
||||
}
|
||||
|
||||
/// <summary>Recent burn rate and a forecast-to-empty anchored at the last level reading, using
|
||||
/// the trailing 365 days of consumption (delivery-only early history would otherwise skew it).</summary>
|
||||
private static async Task<(double? AveragePerDay, DateOnly? ForecastEmpty)> ForecastAsync(
|
||||
MeterVaultDbContext db, int meterId, double? currentLevel, DateTimeOffset? levelAsOf, CancellationToken cancellationToken)
|
||||
{
|
||||
var latest = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId)
|
||||
.OrderByDescending(c => c.Time)
|
||||
.Select(c => (DateTimeOffset?)c.Time)
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is null || currentLevel is not { } level || level <= 0)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var windowStart = latest.Value.AddDays(-365);
|
||||
var recent = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Time > windowStart && c.Time <= latest.Value)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
if (recent <= 0)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var averagePerDay = recent / 365.0;
|
||||
var anchor = levelAsOf ?? latest.Value;
|
||||
var daysToEmpty = level / averagePerDay;
|
||||
// Guard against absurd horizons (near-zero burn) that overflow DateTime.
|
||||
var forecast = daysToEmpty < 365 * 100
|
||||
? DateOnly.FromDateTime(anchor.UtcDateTime.AddDays(daysToEmpty))
|
||||
: (DateOnly?)null;
|
||||
return (averagePerDay, forecast);
|
||||
}
|
||||
|
||||
private static async Task<double> SumConsumptionAsync(
|
||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) =>
|
||||
await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Time >= from && c.Time < to)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
|
||||
private static async Task<IReadOnlyList<ConsumableMonth>> MonthlyConsumptionAsync(
|
||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql =
|
||||
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
|
||||
"sum(amount) AS amount " +
|
||||
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||
"GROUP BY period ORDER BY period";
|
||||
|
||||
var connection = db.Database.GetDbConnection();
|
||||
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
|
||||
return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList();
|
||||
}
|
||||
|
||||
private static double ToVolume(MeterEvent level, MeterVault.Core.Normalization.CalibrationCurve? calibration)
|
||||
{
|
||||
var value = level.Amount ?? 0;
|
||||
var isCentimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase);
|
||||
return isCentimetres && calibration is not null ? calibration.ToVolume(value) : value;
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
|
||||
public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId);
|
||||
|
||||
/// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary>
|
||||
public sealed record FlowLink(string From, string To, double Value);
|
||||
|
||||
/// <summary>
|
||||
/// The per-energy-type flow graph (SDD-style topology view): meters as nodes sized by consumption,
|
||||
/// directed edges sized by the flow along each configured link, plus "Other" remainders where an
|
||||
/// upstream meter's flow isn't fully accounted for by its sub-meters. Rendered as a Sankey diagram.
|
||||
/// </summary>
|
||||
public sealed record FlowGraph(
|
||||
short EnergyTypeId,
|
||||
string EnergyType,
|
||||
string Unit,
|
||||
double Total,
|
||||
IReadOnlyList<FlowNode> Nodes,
|
||||
IReadOnlyList<FlowLink> Links)
|
||||
{
|
||||
public bool HasData => Nodes.Count > 0;
|
||||
|
||||
/// <summary>True when meters are actually chained (not just a flat, unlinked list).</summary>
|
||||
public bool HasChain => Links.Count > 0;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-energy-type flow graph (Sankey) from the meter topology (<see cref="MeterLink"/>)
|
||||
/// and consumption over a period. Each meter is a node sized by its consumption; each configured
|
||||
/// edge carries the downstream meter's consumption (split proportionally when a meter has several
|
||||
/// upstreams); the unaccounted remainder under a meter becomes a synthetic "Other" node. Nothing is
|
||||
/// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory
|
||||
/// keeps it Blazor-circuit safe.
|
||||
/// </summary>
|
||||
public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private const double Epsilon = 0.01;
|
||||
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<FlowGraph> GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var energyType = await db.EnergyTypes.AsNoTracking().FirstOrDefaultAsync(t => t.Id == energyTypeId, cancellationToken).ConfigureAwait(false);
|
||||
var meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (energyType is null || meters.Count == 0)
|
||||
{
|
||||
return new FlowGraph(energyTypeId, energyType?.DisplayName ?? "", energyType?.BaseUnit ?? "", 0, [], []);
|
||||
}
|
||||
|
||||
var meterIds = meters.Select(m => m.Id).ToHashSet();
|
||||
var fromUtc = ToUtc(from);
|
||||
var toUtc = ToUtc(to);
|
||||
|
||||
// A meter's flow value is its throughput: consumption OR generation output — so a generation
|
||||
// meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter
|
||||
// is normally one kind, so summing both kinds is that meter's flow. Negatives (savings/balance
|
||||
// virtual meters) are clamped to 0 — a flow ribbon can't be negative.
|
||||
var sums = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.Time >= fromUtc && c.Time < toUtc)
|
||||
.GroupBy(c => c.MeterId)
|
||||
.Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => Math.Max(0, s.Total));
|
||||
double V(int id) => value.GetValueOrDefault(id);
|
||||
|
||||
var links = await db.MeterLinks.AsNoTracking()
|
||||
.Where(l => meterIds.Contains(l.FromMeterId) && meterIds.Contains(l.ToMeterId))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var parents = meters.ToDictionary(m => m.Id, _ => new List<int>());
|
||||
var children = meters.ToDictionary(m => m.Id, _ => new List<int>());
|
||||
foreach (var link in links)
|
||||
{
|
||||
children[link.FromMeterId].Add(link.ToMeterId);
|
||||
parents[link.ToMeterId].Add(link.FromMeterId);
|
||||
}
|
||||
|
||||
var depth = ComputeDepths(meters.Select(m => m.Id).ToList(), parents, children);
|
||||
|
||||
// Aggregate ("sum") meters: a Virtual-mode meter has no measurements of its own — in the flow
|
||||
// it is the sum of its upstream meters (e.g. "Sum Solar" = Solar 1 + Solar 2). Resolve these in
|
||||
// topological (depth) order so each aggregate sees its already-resolved upstream values.
|
||||
var aggregates = meters.Where(m => m.Mode == MeterMode.Virtual).Select(m => m.Id).ToHashSet();
|
||||
foreach (var id in meters.Select(m => m.Id).OrderBy(id => depth.GetValueOrDefault(id)))
|
||||
{
|
||||
if (aggregates.Contains(id))
|
||||
{
|
||||
value[id] = parents[id].Sum(V);
|
||||
}
|
||||
}
|
||||
|
||||
// Link value: a child's consumption flows in from its parent(s); with several parents it is
|
||||
// split proportionally to the parents' own consumption (equal split if those are all zero).
|
||||
var flowLinks = new List<FlowLink>();
|
||||
var outgoingByParent = meters.ToDictionary(m => m.Id, _ => 0d);
|
||||
foreach (var (childId, parentIds) in parents)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parentTotal = parentIds.Sum(V);
|
||||
foreach (var parentId in parentIds)
|
||||
{
|
||||
var share = parentIds.Count == 1 ? 1d
|
||||
: parentTotal > Epsilon ? V(parentId) / parentTotal
|
||||
: 1d / parentIds.Count;
|
||||
var linkValue = V(childId) * share;
|
||||
if (linkValue > Epsilon)
|
||||
{
|
||||
flowLinks.Add(new FlowLink(NodeId(parentId), NodeId(childId), linkValue));
|
||||
outgoingByParent[parentId] += linkValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nodes = new List<FlowNode>();
|
||||
foreach (var meter in meters)
|
||||
{
|
||||
// Keep a meter node if it carries flow or participates in the topology.
|
||||
if (V(meter.Id) <= Epsilon && children[meter.Id].Count == 0 && parents[meter.Id].Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.Add(new FlowNode(NodeId(meter.Id), meter.Name, V(meter.Id), depth.GetValueOrDefault(meter.Id), energyType.ColorHex, false, meter.Id));
|
||||
|
||||
// Unaccounted remainder under a meter with sub-meters → "Other".
|
||||
if (children[meter.Id].Count > 0)
|
||||
{
|
||||
var remainder = V(meter.Id) - outgoingByParent[meter.Id];
|
||||
if (remainder > Epsilon)
|
||||
{
|
||||
var otherId = $"other{meter.Id}";
|
||||
nodes.Add(new FlowNode(otherId, $"Other ({meter.Name})", remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
|
||||
flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var total = meters.Where(m => parents[m.Id].Count == 0).Sum(m => V(m.Id));
|
||||
return new FlowGraph(energyTypeId, energyType.DisplayName, energyType.BaseUnit, total, nodes, flowLinks);
|
||||
}
|
||||
|
||||
/// <summary>Longest-path depth from the roots (Kahn topological relaxation); robust to stray cycles.</summary>
|
||||
private static Dictionary<int, int> ComputeDepths(
|
||||
List<int> ids, Dictionary<int, List<int>> parents, Dictionary<int, List<int>> children)
|
||||
{
|
||||
var depth = ids.ToDictionary(id => id, _ => 0);
|
||||
var indegree = ids.ToDictionary(id => id, id => parents[id].Count);
|
||||
var queue = new Queue<int>(ids.Where(id => indegree[id] == 0));
|
||||
var processed = 0;
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var node = queue.Dequeue();
|
||||
processed++;
|
||||
foreach (var child in children[node])
|
||||
{
|
||||
depth[child] = Math.Max(depth[child], depth[node] + 1);
|
||||
if (--indegree[child] == 0)
|
||||
{
|
||||
queue.Enqueue(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any nodes left (a cycle) keep depth 0 — the admin prevents cycles, this is just a guard.
|
||||
return depth;
|
||||
}
|
||||
|
||||
private static string NodeId(int meterId) => $"m{meterId}";
|
||||
|
||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>A raw reading row for the meter-detail table.</summary>
|
||||
public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQuality Quality, ReadingFlags Flags);
|
||||
|
||||
/// <summary>A normalized consumption row for the meter-detail table.</summary>
|
||||
public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality);
|
||||
|
||||
/// <summary>One calendar month of a meter's normalized history, bucketed in the instance timezone.</summary>
|
||||
public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost);
|
||||
|
||||
/// <summary>
|
||||
/// A meter framed the way it is actually read: what it used this period, how that compares with the
|
||||
/// last one, and where the year is heading. Amounts are generation for a generation counter and
|
||||
/// consumption otherwise, so <see cref="Label"/> says which.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Month- and year-to-date are compared against a <em>projection</em> of the current period rather
|
||||
/// than its raw running total: three days into a month, "12 kWh vs 340 kWh last month" reads as a
|
||||
/// collapse in usage when nothing has changed. Projections are flagged so the UI can mark them.
|
||||
/// </remarks>
|
||||
public sealed record MeterPeriodView(
|
||||
string Label,
|
||||
string Unit,
|
||||
string Currency,
|
||||
double MonthToDate,
|
||||
double MonthProjected,
|
||||
double LastMonth,
|
||||
double YearToDate,
|
||||
double YearProjected,
|
||||
double LastYear,
|
||||
double YearToDateCost,
|
||||
double YearProjectedCost,
|
||||
double LastYearCost,
|
||||
bool MonthIsPartial,
|
||||
IReadOnlyList<MeterMonthPoint> Last12Months)
|
||||
{
|
||||
/// <summary>Projected month against last month, as a fraction (+0.12 = 12% more). Null if no basis.</summary>
|
||||
public double? MonthChange => Ratio(MonthProjected, LastMonth);
|
||||
|
||||
/// <summary>Projected year against last year, as a fraction. Null if no basis.</summary>
|
||||
public double? YearChange => Ratio(YearProjected, LastYear);
|
||||
|
||||
public bool HasHistory => Last12Months.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Percentage change is only meaningful against a positive baseline. Dividing by a negative one
|
||||
/// inverts the sign — a net-export meter going from −100 to −150 would report "+50% more used"
|
||||
/// when it exported half as much again — so those report no basis rather than a confident lie.
|
||||
/// </summary>
|
||||
private static double? Ratio(double current, double previous) =>
|
||||
previous <= 1e-9 ? null : (current - previous) / previous;
|
||||
}
|
||||
|
||||
/// <summary>A meter lifecycle/correction event row.</summary>
|
||||
public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
|
||||
|
||||
/// <summary>A tariff applicable to the meter (own / energy-type / global scope), for the timeline.</summary>
|
||||
public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
|
||||
|
||||
/// <summary>
|
||||
/// The meter-detail read model (SDD §8.6): identity, register span, totals, recent raw readings
|
||||
/// and normalized consumption (measured-vs-estimated markers via quality), the applicable tariff
|
||||
/// timeline, and lifecycle events (swaps/deliveries/corrections). Source management loads the
|
||||
/// source entities directly (they are editable), so it is not part of this read model.
|
||||
/// </summary>
|
||||
public sealed record MeterDetailView(
|
||||
int Id,
|
||||
string Name,
|
||||
string EnergyType,
|
||||
MeterMode Mode,
|
||||
string Unit,
|
||||
string? Location,
|
||||
string? SerialNumber,
|
||||
string? Manufacturer,
|
||||
string? Model,
|
||||
double InitialBaseline,
|
||||
bool IsActive,
|
||||
int ReadingCount,
|
||||
int ConsumptionCount,
|
||||
DateTimeOffset? FirstReadingTime,
|
||||
DateTimeOffset? LastReadingTime,
|
||||
double? FirstReadingValue,
|
||||
double? LastReadingValue,
|
||||
double TotalConsumption,
|
||||
double TotalGeneration,
|
||||
IReadOnlyList<ReadingRow> RecentReadings,
|
||||
IReadOnlyList<ConsumptionDetailRow> RecentConsumption,
|
||||
IReadOnlyList<EventRow> Events,
|
||||
IReadOnlyList<TariffRow> Tariffs,
|
||||
int SourceCount);
|
||||
@@ -0,0 +1,83 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Read model for the meter-detail view (SDD §8.6). Bounds the raw-reading and consumption pulls
|
||||
/// (this is the one place the UI touches raw rows) and gathers source status, the applicable tariff
|
||||
/// timeline and lifecycle events. DbContext factory keeps it Blazor-circuit safe.
|
||||
/// </summary>
|
||||
public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private const int MaxRows = 200;
|
||||
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<MeterDetailView?> GetAsync(int meterId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meter = await db.Meters.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
.Include(m => m.Sources)
|
||||
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
if (meter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var readingCount = await db.Readings.AsNoTracking().CountAsync(r => r.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
||||
var consumptionCount = await db.Consumption.AsNoTracking().CountAsync(c => c.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var first = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
||||
.OrderBy(r => r.Time).Select(r => new { r.Time, r.Value })
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
var last = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
||||
.OrderByDescending(r => r.Time).Select(r => new { r.Time, r.Value })
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var totalConsumption = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Consumption)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
var totalGeneration = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Generation)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
|
||||
var recentReadings = await db.Readings.AsNoTracking()
|
||||
.Where(r => r.MeterId == meterId)
|
||||
.OrderByDescending(r => r.Time).Take(MaxRows)
|
||||
.Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var recentConsumption = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId)
|
||||
.OrderByDescending(c => c.Time).Take(MaxRows)
|
||||
.Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var events = await db.MeterEvents.AsNoTracking()
|
||||
.Where(e => e.MeterId == meterId)
|
||||
.OrderByDescending(e => e.Time)
|
||||
.Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var energyTypeId = meter.EnergyTypeId;
|
||||
var tariffs = await db.Tariffs.AsNoTracking()
|
||||
.Where(t => t.ScopeType == TariffScope.Global
|
||||
|| (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId)
|
||||
|| (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId))
|
||||
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
||||
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new MeterDetailView(
|
||||
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
|
||||
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
|
||||
readingCount, consumptionCount,
|
||||
first?.Time, last?.Time, first?.Value, last?.Value,
|
||||
totalConsumption, totalGeneration,
|
||||
recentReadings, recentConsumption, events, tariffs, meter.Sources.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using Dapper;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Answers the questions a meter is actually read for (SDD §8.6): how much this month, how that
|
||||
/// compares with last month, where the year lands, what it costs. Register totals answer none of
|
||||
/// those — a cumulative counter's value is an accident of when the meter was installed.
|
||||
/// </summary>
|
||||
public sealed class MeterPeriodService(
|
||||
IDbContextFactory<MeterVaultDbContext> contextFactory,
|
||||
Costing.CostService costs,
|
||||
IOptions<MeterVaultOptions> options)
|
||||
{
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
private readonly Costing.CostService _costs = costs;
|
||||
private readonly MeterVaultOptions _options = options.Value;
|
||||
|
||||
// Monthly buckets in the instance timezone, not UTC: a reading at 00:30 local on 1 January is
|
||||
// 23:30 on 31 December in UTC, and would otherwise be booked to the wrong month (SDD §10).
|
||||
private const string MonthlySql = """
|
||||
SELECT date_trunc('month', time AT TIME ZONE @tz)::date AS month,
|
||||
sum(amount) AS amount
|
||||
FROM consumption
|
||||
WHERE meter_id = @meterId AND kind = @kind AND time >= @from
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
""";
|
||||
|
||||
public async Task<MeterPeriodView?> GetAsync(int meterId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meter = await db.Meters.AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
if (meter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Virtual meters are evaluated on read and only materialize into `consumption` when a cost
|
||||
// category references them (SDD §14.1), so summing that table would report a confident zero
|
||||
// for a meter that is working fine. Report nothing and let the page say why.
|
||||
if (meter.Mode == MeterMode.Virtual)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tz = ResolveTimeZone();
|
||||
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz).Date);
|
||||
|
||||
// A generation counter's output is generation, not consumption — reporting 0 kWh consumed
|
||||
// for a working PV array is technically true and completely useless.
|
||||
var isGeneration = meter.Mode == MeterMode.GenerationCounter;
|
||||
var kind = isGeneration ? ConsumptionKind.Generation : ConsumptionKind.Consumption;
|
||||
|
||||
// From the start of last year: enough for last-year totals and a rolling 12-month history.
|
||||
var from = new DateTimeOffset(new DateTime(today.Year - 1, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var months = (await db.Database.GetDbConnection()
|
||||
.QueryAsync<MonthlyRow>(
|
||||
MonthlySql,
|
||||
new { tz = _options.TimeZone, meterId, kind = (short)kind, from })
|
||||
.ConfigureAwait(false))
|
||||
.ToDictionary(r => r.Month, r => r.Amount);
|
||||
|
||||
var costs = await LoadCostsAsync(meterId, from, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var thisMonth = new DateOnly(today.Year, today.Month, 1);
|
||||
var lastMonth = thisMonth.AddMonths(-1);
|
||||
|
||||
var monthToDate = months.GetValueOrDefault(thisMonth);
|
||||
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
|
||||
var monthPartial = today.Day < daysInMonth;
|
||||
|
||||
var yearToDate = SumYear(months, today.Year);
|
||||
var lastYear = SumYear(months, today.Year - 1);
|
||||
var dayOfYear = today.DayOfYear;
|
||||
var daysInYear = DateTime.IsLeapYear(today.Year) ? 366 : 365;
|
||||
|
||||
var yearToDateCost = SumYear(costs, today.Year);
|
||||
|
||||
return new MeterPeriodView(
|
||||
Label: isGeneration ? "Generation" : "Consumption",
|
||||
Unit: meter.Unit,
|
||||
Currency: _options.Currency,
|
||||
MonthToDate: monthToDate,
|
||||
MonthProjected: Project(monthToDate, today.Day, daysInMonth),
|
||||
LastMonth: months.GetValueOrDefault(lastMonth),
|
||||
YearToDate: yearToDate,
|
||||
YearProjected: Project(yearToDate, dayOfYear, daysInYear),
|
||||
LastYear: lastYear,
|
||||
YearToDateCost: yearToDateCost,
|
||||
YearProjectedCost: Project(yearToDateCost, dayOfYear, daysInYear),
|
||||
LastYearCost: SumYear(costs, today.Year - 1),
|
||||
MonthIsPartial: monthPartial,
|
||||
Last12Months: BuildHistory(months, costs, thisMonth));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scales a partial period to its full length. Straight-line on elapsed days: it assumes the
|
||||
/// rest of the period looks like what came before, which is wrong for anything seasonal but is
|
||||
/// the honest reading of "at this rate". The UI marks these as projections.
|
||||
/// </summary>
|
||||
private static double Project(double soFar, int elapsed, int total) =>
|
||||
elapsed <= 0 ? soFar : soFar / elapsed * total;
|
||||
|
||||
private static double SumYear(Dictionary<DateOnly, double> byMonth, int year) =>
|
||||
byMonth.Where(kv => kv.Key.Year == year).Sum(kv => kv.Value);
|
||||
|
||||
private static IReadOnlyList<MeterMonthPoint> BuildHistory(
|
||||
Dictionary<DateOnly, double> months, Dictionary<DateOnly, double> costs, DateOnly thisMonth)
|
||||
{
|
||||
var history = new List<MeterMonthPoint>(12);
|
||||
for (var offset = 11; offset >= 0; offset--)
|
||||
{
|
||||
var month = thisMonth.AddMonths(-offset);
|
||||
history.Add(new MeterMonthPoint(month, months.GetValueOrDefault(month), costs.GetValueOrDefault(month)));
|
||||
}
|
||||
|
||||
// All-zero history means the meter has no normalized data yet; say nothing rather than
|
||||
// drawing a flat line that looks like a meter reading zero.
|
||||
return history.All(p => Math.Abs(p.Amount) < 1e-9) ? [] : history;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<DateOnly, double>> LoadCostsAsync(
|
||||
int meterId, DateTimeOffset from, CancellationToken cancellationToken)
|
||||
{
|
||||
var buckets = await _costs.GetMeterCostsAsync(
|
||||
meterId, from, DateTimeOffset.UtcNow, Costing.CostBucket.Month, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return buckets
|
||||
.GroupBy(b => new DateOnly(b.Period.Year, b.Period.Month, 1))
|
||||
.ToDictionary(g => g.Key, g => g.Sum(b => b.Cost));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dapper row shape — it maps by column name, so a value tuple will not do, and Npgsql surfaces
|
||||
/// a <c>date</c> column as <see cref="DateOnly"/>.
|
||||
/// </summary>
|
||||
private sealed record MonthlyRow(DateOnly Month, double Amount);
|
||||
|
||||
private TimeZoneInfo ResolveTimeZone()
|
||||
{
|
||||
try
|
||||
{
|
||||
return TimeZoneInfo.FindSystemTimeZoneById(_options.TimeZone);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||
{
|
||||
return TimeZoneInfo.Utc;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>One month of the PV panel: generation, and (when role-tagged meters exist) the
|
||||
/// self-consumption / grid-draw / savings split that reproduces the sheet's Netz-Einsparung column.</summary>
|
||||
public sealed record SolarMonth(
|
||||
DateOnly Period,
|
||||
double Generation,
|
||||
double? SelfConsumption,
|
||||
double? GridImport,
|
||||
double? TotalLoad,
|
||||
double? Savings);
|
||||
|
||||
/// <summary>Per-generation-meter total over the selected period (for the ranked list).</summary>
|
||||
public sealed record GenerationMeterRow(int MeterId, string Name, double Generation);
|
||||
|
||||
/// <summary>
|
||||
/// The PV / solar panel read model (SDD §8.4): total generation plus, when the install has tagged
|
||||
/// a <c>total_load</c> and <c>grid_import</c> meter, self-consumption, autarky %, self-consumption %
|
||||
/// and savings (Ersparnis). Derived metrics are null when no role config exists.
|
||||
/// </summary>
|
||||
public sealed record SolarSummary(
|
||||
double Generation,
|
||||
double? TotalLoad,
|
||||
double? GridImport,
|
||||
double? SelfConsumption,
|
||||
double? Autarky,
|
||||
double? SelfConsumptionRatio,
|
||||
double? Savings,
|
||||
IReadOnlyList<GenerationMeterRow> Meters,
|
||||
IReadOnlyList<SolarMonth> Months)
|
||||
{
|
||||
/// <summary>True when the install has the role-tagged meters needed for self-consumption metrics.</summary>
|
||||
public bool HasLoadContext => TotalLoad is not null && GridImport is not null;
|
||||
|
||||
/// <summary>True when at least one generation meter exists.</summary>
|
||||
public bool HasGeneration => Meters.Count > 0;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Dapper;
|
||||
using MeterVault.Core.Costing;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Read model for the PV / solar panel (SDD §8.4). Generation comes from every
|
||||
/// <see cref="MeterMode.GenerationCounter"/> meter; self-consumption / autarky / savings are derived
|
||||
/// from the meters tagged <see cref="MeterRoles.TotalLoad"/> and <see cref="MeterRoles.GridImport"/>
|
||||
/// — so nothing is hardcoded by meter name. Reads only the aggregated consumption hypertable
|
||||
/// (monthly, Europe/Berlin) via Dapper; safe from a Blazor circuit via a DbContext factory.
|
||||
/// </summary>
|
||||
public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<SolarSummary> GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meters = await db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var generationMeters = meters.Where(m => m.Mode == MeterMode.GenerationCounter).ToList();
|
||||
var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad);
|
||||
var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport);
|
||||
|
||||
var fromUtc = ToUtc(from);
|
||||
var toUtc = ToUtc(to);
|
||||
|
||||
// Monthly generation per generation meter.
|
||||
var genByMeter = new Dictionary<int, IReadOnlyDictionary<DateOnly, double>>();
|
||||
foreach (var meter in generationMeters)
|
||||
{
|
||||
genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var loadByMonth = loadMeter is null
|
||||
? null
|
||||
: await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
var gridByMonth = gridMeter is null
|
||||
? null
|
||||
: await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tariffs = gridMeter is null
|
||||
? []
|
||||
: await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Union of all months that carry any data.
|
||||
var periods = new SortedSet<DateOnly>();
|
||||
foreach (var series in genByMeter.Values)
|
||||
{
|
||||
periods.UnionWith(series.Keys);
|
||||
}
|
||||
|
||||
if (loadByMonth is not null)
|
||||
{
|
||||
periods.UnionWith(loadByMonth.Keys);
|
||||
}
|
||||
|
||||
var months = new List<SolarMonth>();
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var generation = genByMeter.Values.Sum(s => s.GetValueOrDefault(period));
|
||||
|
||||
double? load = loadByMonth?.GetValueOrDefault(period);
|
||||
double? grid = gridByMonth?.GetValueOrDefault(period);
|
||||
double? self = load is not null && grid is not null ? load - grid : null;
|
||||
|
||||
double? savings = null;
|
||||
if (self is { } selfValue && gridMeter is not null)
|
||||
{
|
||||
var price = TariffResolver.ResolveValue(
|
||||
tariffs, TariffComponent.UnitPrice, gridMeter.Id, gridMeter.EnergyTypeId,
|
||||
new DateOnly(period.Year, period.Month, 15));
|
||||
savings = selfValue * price;
|
||||
}
|
||||
|
||||
months.Add(new SolarMonth(period, generation, self, grid, load, savings));
|
||||
}
|
||||
|
||||
var meterRows = generationMeters
|
||||
.Select(m => new GenerationMeterRow(m.Id, m.Name, genByMeter[m.Id].Values.Sum()))
|
||||
.OrderByDescending(r => r.Generation)
|
||||
.ToList();
|
||||
|
||||
var totalGeneration = meterRows.Sum(r => r.Generation);
|
||||
double? totalLoad = loadByMonth?.Values.Sum();
|
||||
double? totalGrid = gridByMonth?.Values.Sum();
|
||||
double? totalSelf = totalLoad is not null && totalGrid is not null ? totalLoad - totalGrid : null;
|
||||
double? autarky = totalSelf is not null && totalLoad is > 0 ? totalSelf / totalLoad : null;
|
||||
double? selfRatio = totalSelf is not null && totalGeneration > 0 ? totalSelf / totalGeneration : null;
|
||||
double? totalSavings = months.Any(m => m.Savings is not null) ? months.Sum(m => m.Savings ?? 0) : null;
|
||||
|
||||
return new SolarSummary(
|
||||
totalGeneration, totalLoad, totalGrid, totalSelf, autarky, selfRatio, totalSavings, meterRows, months);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyDictionary<DateOnly, double>> MonthlyAsync(
|
||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql =
|
||||
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
|
||||
"sum(amount) AS amount " +
|
||||
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||
"GROUP BY period";
|
||||
|
||||
var connection = db.Database.GetDbConnection();
|
||||
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
|
||||
return rows.ToDictionary(r => r.Period, r => r.Amount);
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
@@ -33,8 +33,23 @@ public static class DependencyInjection
|
||||
services.AddScoped<ReferenceDataImporter>();
|
||||
services.AddScoped<IngestionService>();
|
||||
services.AddScoped<MqttMessageRouter>();
|
||||
// HttpClient + HA tester are available even with live ingestion off, so the admin
|
||||
// "Test connection" works without the background workers running.
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<HaConnectionTester>();
|
||||
// Singleton: wraps one IDataProtector, and the ingestion workers (themselves singletons)
|
||||
// resolve connector secrets on every reconnect.
|
||||
services.AddSingleton<Security.SecretProtector>();
|
||||
// Singleton: it caches the last answer so the dashboard never waits on a remote call.
|
||||
services.AddSingleton<Update.UpdateCheckService>();
|
||||
services.AddSingleton<Update.UpdateRunner>();
|
||||
services.AddScoped<Costing.CostService>();
|
||||
services.AddScoped<Dashboard.DashboardService>();
|
||||
services.AddScoped<Dashboard.SolarService>();
|
||||
services.AddScoped<Dashboard.MeterPeriodService>();
|
||||
services.AddScoped<Dashboard.ConsumableService>();
|
||||
services.AddScoped<Dashboard.MeterDetailService>();
|
||||
services.AddScoped<Dashboard.FlowService>();
|
||||
services.AddScoped<Backup.ExportService>();
|
||||
|
||||
return services;
|
||||
@@ -49,6 +64,7 @@ public static class DependencyInjection
|
||||
services.AddHttpClient();
|
||||
services.AddHostedService<MqttIngestionWorker>();
|
||||
services.AddHostedService<HomeAssistantWorker>();
|
||||
services.AddHostedService<HomeAssistantWebSocketWorker>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,12 @@ public sealed class CsvImporter
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the raw CSV grid (no profile applied) so the import wizard can show a column preview
|
||||
/// and let the user map columns before staging. Rows are ragged (their own field counts).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string[]> ReadRawRows(TextReader reader) => ReadRows(reader);
|
||||
|
||||
internal static List<string[]> ReadRows(TextReader reader)
|
||||
{
|
||||
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
|
||||
@@ -18,6 +18,8 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n
|
||||
StagedImport staged, string? sourceName, string? mappingJson, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(staged);
|
||||
GuardDuplicateReadings(staged);
|
||||
await GuardExistingReadingsAsync(staged, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -89,6 +91,75 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n
|
||||
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reading is keyed by (meter, time), so a staged set holding two rows for one meter at one
|
||||
/// timestamp cannot be written. Usually a mapping that points two source columns at one meter —
|
||||
/// report it in those terms instead of letting EF surface a change-tracker error.
|
||||
/// </summary>
|
||||
private static void GuardDuplicateReadings(StagedImport staged)
|
||||
{
|
||||
var duplicates = staged.Readings
|
||||
.GroupBy(r => (r.MeterId, r.Time))
|
||||
.Where(g => g.Count() > 1)
|
||||
.ToList();
|
||||
|
||||
if (duplicates.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sample = string.Join("; ", duplicates.Take(3)
|
||||
.Select(g => $"meter {g.Key.MeterId} at {g.Key.Time:yyyy-MM-dd}"));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"{duplicates.Count} duplicate reading(s): the same meter is written twice at the same " +
|
||||
$"timestamp ({sample}). Check that no two mapped columns target the same meter.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects readings that already exist in the database, which is what re-importing an overlapping
|
||||
/// CSV produces — the commonest real duplicate, and one the in-batch guard cannot see because the
|
||||
/// staged set is internally unique. Left to the database it surfaces as EF's
|
||||
/// "An error occurred while saving the entity changes", naming neither meter nor date.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Checks one meter at a time, bounded by that meter's staged time range, so the query stays
|
||||
/// proportional to the overlap rather than to the table.
|
||||
/// </remarks>
|
||||
private async Task GuardExistingReadingsAsync(StagedImport staged, CancellationToken cancellationToken)
|
||||
{
|
||||
var clashes = new List<string>();
|
||||
|
||||
foreach (var group in staged.Readings.GroupBy(r => r.MeterId))
|
||||
{
|
||||
var times = group.Select(r => r.Time).ToHashSet();
|
||||
var from = times.Min();
|
||||
var to = times.Max();
|
||||
|
||||
var existing = await _db.Readings.AsNoTracking()
|
||||
.Where(r => r.MeterId == group.Key && r.Time >= from && r.Time <= to)
|
||||
.Select(r => r.Time)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
foreach (var time in existing.Where(times.Contains).Take(3))
|
||||
{
|
||||
clashes.Add($"meter {group.Key} at {time:yyyy-MM-dd}");
|
||||
}
|
||||
|
||||
if (clashes.Count >= 3)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (clashes.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"This import would overwrite readings that already exist ({string.Join("; ", clashes)}). "
|
||||
+ "Revert the earlier batch on the Import page first, or narrow the file's date range.");
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<int> AffectedMeters(StagedImport staged) =>
|
||||
staged.Readings.Select(r => r.MeterId)
|
||||
.Concat(staged.Events.Select(e => e.MeterId))
|
||||
|
||||
@@ -33,6 +33,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail fast BEFORE creating the marker meter: if the CSVs are missing (e.g. not shipped in
|
||||
// the image) we must not seed a half-loaded dataset that IsLoadedAsync then reports as done.
|
||||
EnsureSampleFilesPresent(sampleDataDirectory);
|
||||
|
||||
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false);
|
||||
@@ -47,8 +51,15 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
|
||||
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
|
||||
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
|
||||
// A virtual "sum" meter: no readings of its own — in the flow view it equals Solar 1 + Solar 2.
|
||||
var sumSolar = Meter("Summe Solar", electricity, MeterMode.Virtual, "kWh");
|
||||
|
||||
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner);
|
||||
// Tag the PV meters' roles (config, not hardcoded names) so the Solar panel can derive
|
||||
// self-consumption = total_load − grid_import and savings generically (SDD §8.4).
|
||||
haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad);
|
||||
netz.Meta = MeterMeta.WithRole(netz.Meta, MeterRoles.GridImport);
|
||||
|
||||
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner, sumSolar);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_db.Tanks.Add(new Tank
|
||||
@@ -59,6 +70,16 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
|
||||
});
|
||||
|
||||
// Demo flow chain (electricity /energy view): Solar 1 + Solar 2 → Summe Solar; then
|
||||
// Grid + Summe Solar → Haus → Auto + "Other". The remainder under Grid/Summe Solar is the
|
||||
// input that didn't reach the house load (solar export / battery / inverter losses).
|
||||
_db.MeterLinks.AddRange(
|
||||
new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id },
|
||||
new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id },
|
||||
new MeterLink { FromMeterId = netz.Id, ToMeterId = haus.Id },
|
||||
new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = haus.Id },
|
||||
new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id });
|
||||
|
||||
AddElectricityTariffs(electricity);
|
||||
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
|
||||
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
|
||||
@@ -86,12 +107,32 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
|
||||
};
|
||||
|
||||
private static readonly string[] RequiredFiles = [ElectricityFile, WaterFile, OilFile, CostsFile];
|
||||
|
||||
/// <summary>Throws a clear error if the sample directory or any reference CSV is missing, so a
|
||||
/// failed load surfaces to the user instead of silently seeding meters with no data.</summary>
|
||||
private static void EnsureSampleFilesPresent(string sampleDataDirectory)
|
||||
{
|
||||
if (!Directory.Exists(sampleDataDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException(
|
||||
$"Reference-data directory not found: '{sampleDataDirectory}'. The bundled Energiebilanz CSVs are missing from this deployment.");
|
||||
}
|
||||
|
||||
var missing = RequiredFiles.Where(f => !File.Exists(Path.Combine(sampleDataDirectory, f))).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Reference CSV(s) missing from '{sampleDataDirectory}': {string.Join(", ", missing)}.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(dir, file);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
throw new FileNotFoundException($"Reference CSV disappeared during import: '{path}'.", path);
|
||||
}
|
||||
|
||||
StagedImport staged;
|
||||
|
||||
@@ -4,9 +4,10 @@ using System.Text.Json.Serialization;
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for an MQTT broker. Secrets
|
||||
/// are stored by reference only (SDD §6.4): <see cref="UsernameEnv"/>/<see cref="PasswordEnv"/>
|
||||
/// name environment variables resolved at runtime, never plaintext credentials in the database.
|
||||
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for an MQTT broker. Credentials
|
||||
/// are never held here as plaintext (SDD §6.4): <see cref="UsernameEnv"/>/<see cref="PasswordEnv"/>
|
||||
/// name environment variables resolved at runtime, or <see cref="UsernameEnc"/>/<see cref="PasswordEnc"/>
|
||||
/// hold them encrypted under the app's data-protection key ring.
|
||||
/// </summary>
|
||||
public sealed record EndpointConfig
|
||||
{
|
||||
@@ -29,6 +30,15 @@ public sealed record EndpointConfig
|
||||
|
||||
public string? PasswordEnv { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Username entered directly in the admin UI. Held as-is: §6.4 covers tokens and passwords, and
|
||||
/// encrypting a username would only blank the field on every edit for no security gain.
|
||||
/// </summary>
|
||||
public string? Username { get; init; }
|
||||
|
||||
/// <summary>Password encrypted by <see cref="Security.SecretProtector"/> (entered in the admin UI).</summary>
|
||||
public string? PasswordEnc { get; init; }
|
||||
|
||||
public static EndpointConfig Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
@@ -46,10 +56,13 @@ public sealed record EndpointConfig
|
||||
}
|
||||
}
|
||||
|
||||
public string? ResolveUsername() => Resolve(UsernameEnv);
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public string? ResolvePassword() => Resolve(PasswordEnv);
|
||||
public string? ResolveUsername(Security.SecretProtector? protector = null) =>
|
||||
!string.IsNullOrWhiteSpace(Username)
|
||||
? Username
|
||||
: EndpointSecret.Resolve(null, UsernameEnv, protector);
|
||||
|
||||
private static string? Resolve(string? envVarName) =>
|
||||
string.IsNullOrWhiteSpace(envVarName) ? null : Environment.GetEnvironmentVariable(envVarName);
|
||||
public string? ResolvePassword(Security.SecretProtector? protector = null) =>
|
||||
EndpointSecret.Resolve(PasswordEnc, PasswordEnv, protector);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using MeterVault.Infrastructure.Security;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves one connector secret from the two storage forms an endpoint config may use: a value
|
||||
/// typed into the admin UI and encrypted at rest, or the name of an environment variable resolved
|
||||
/// at runtime. Neither form keeps plaintext in the database (SDD §6.4).
|
||||
/// </summary>
|
||||
internal static class EndpointSecret
|
||||
{
|
||||
/// <summary>
|
||||
/// Encrypted wins over the env-var reference when both are present. They are mutually exclusive
|
||||
/// in the UI, so both being set means a connector was switched from one mode to the other and
|
||||
/// the write did not clear the old field; honouring the encrypted value keeps the connector on
|
||||
/// whichever secret was most recently entered.
|
||||
/// </summary>
|
||||
public static string? Resolve(string? encrypted, string? envVarName, SecretProtector? protector)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(encrypted)
|
||||
&& protector is not null
|
||||
&& protector.TryUnprotect(encrypted, out var plaintext))
|
||||
{
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(envVarName) ? null : Environment.GetEnvironmentVariable(envVarName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>Outcome of a Home Assistant connectivity test.</summary>
|
||||
public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a Home Assistant connection from the admin UI: checks the base URL + token against
|
||||
/// <c>GET /api/</c>, and optionally reads one entity's state. Confirms the app can actually read HA
|
||||
/// before a source is relied upon (SDD §6.2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Takes the token already resolved rather than a reference to one. The admin UI must be able to
|
||||
/// test a token that has been typed but not yet saved (so not yet encrypted), and keeping the two
|
||||
/// storage forms out of here leaves one resolution path in <see cref="EndpointSecret"/>.
|
||||
/// </remarks>
|
||||
public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILogger<HaConnectionTester> logger)
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
private readonly ILogger<HaConnectionTester> _logger = logger;
|
||||
|
||||
public async Task<HaTestResult> TestAsync(
|
||||
string? baseUrl, string? token, string? entityId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return new HaTestResult(false, "Base URL is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new HaTestResult(false, "No token available to test.");
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl.TrimEnd('/')}/api/");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entityId))
|
||||
{
|
||||
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.");
|
||||
}
|
||||
|
||||
var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false);
|
||||
return state is { } value
|
||||
? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value)
|
||||
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl);
|
||||
return new HaTestResult(false, $"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using MeterVault.Infrastructure.Security;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for a Home Assistant
|
||||
/// connection (SDD §6.2). The long-lived token is never held here as plaintext (SDD §6.4): either
|
||||
/// <see cref="TokenEnv"/> names an environment variable resolved at runtime, or
|
||||
/// <see cref="TokenEnc"/> holds it encrypted under the app's data-protection key ring.
|
||||
/// </summary>
|
||||
public sealed record HaEndpointConfig
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>Base URL of the Home Assistant instance, e.g. <c>http://homeassistant.local:8123</c>.</summary>
|
||||
public string? BaseUrl { get; init; }
|
||||
|
||||
/// <summary>Name of the environment variable holding the long-lived access token.</summary>
|
||||
public string? TokenEnv { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The long-lived access token, encrypted by <see cref="Security.SecretProtector"/>. Set when
|
||||
/// the operator typed the token into the admin UI instead of naming an environment variable.
|
||||
/// </summary>
|
||||
public string? TokenEnc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, a persistent WebSocket subscription pushes state changes in real time
|
||||
/// (<see cref="HomeAssistantWebSocketWorker"/>); when false (default) the REST poll worker
|
||||
/// samples on each source's interval. An endpoint is handled by exactly one of the two.
|
||||
/// </summary>
|
||||
public bool UseWebSocket { get; init; }
|
||||
|
||||
public static HaEndpointConfig Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new HaEndpointConfig();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<HaEndpointConfig>(json, Options) ?? new HaEndpointConfig();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new HaEndpointConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the token: the encrypted value when one was entered directly, otherwise the
|
||||
/// referenced environment variable. Null when neither yields anything.
|
||||
/// </summary>
|
||||
public string? ResolveToken(SecretProtector? protector = null) =>
|
||||
EndpointSecret.Resolve(TokenEnc, TokenEnv, protector);
|
||||
}
|
||||
@@ -31,10 +31,16 @@ public sealed class HaStateClient(HttpClient httpClient)
|
||||
return ParseState(await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false), attribute);
|
||||
}
|
||||
|
||||
internal static HaState? ParseState(JsonDocument document, string? attribute)
|
||||
{
|
||||
var root = document.RootElement;
|
||||
internal static HaState? ParseState(JsonDocument document, string? attribute) =>
|
||||
ParseStateElement(document.RootElement, attribute);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a numeric value + last-updated time from a Home Assistant state object (the REST
|
||||
/// entity payload, or a <c>new_state</c> from a WebSocket <c>state_changed</c> event — they share
|
||||
/// the same shape). Returns null for <c>unavailable</c>/<c>unknown</c>/non-numeric states.
|
||||
/// </summary>
|
||||
internal static HaState? ParseStateElement(JsonElement root, string? attribute)
|
||||
{
|
||||
double value;
|
||||
if (string.IsNullOrWhiteSpace(attribute))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the Home Assistant WebSocket API (<c>/api/websocket</c>): the auth handshake
|
||||
/// (<c>auth_required</c> → <c>auth</c> → <c>auth_ok</c>), subscribing to <c>state_changed</c> events,
|
||||
/// and reading the entity id + <c>new_state</c> out of an event frame. No I/O — the transport lives
|
||||
/// in <see cref="HomeAssistantWebSocketWorker"/>, which keeps this unit-testable.
|
||||
/// </summary>
|
||||
internal static class HaWebSocketProtocol
|
||||
{
|
||||
/// <summary>Derives the WebSocket endpoint from an HTTP base URL (http→ws, https→wss, path <c>/api/websocket</c>).</summary>
|
||||
public static Uri WebSocketUri(string baseUrl)
|
||||
{
|
||||
var http = new Uri(baseUrl.TrimEnd('/') + "/api/websocket", UriKind.Absolute);
|
||||
var scheme = http.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws";
|
||||
return new UriBuilder(http) { Scheme = scheme }.Uri;
|
||||
}
|
||||
|
||||
public static string AuthMessage(string token) =>
|
||||
JsonSerializer.Serialize(new { type = "auth", access_token = token });
|
||||
|
||||
public static string SubscribeStateChanged(int id) =>
|
||||
JsonSerializer.Serialize(new { id, type = "subscribe_events", event_type = "state_changed" });
|
||||
|
||||
public static string? MessageType(JsonElement root) =>
|
||||
root.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String ? type.GetString() : null;
|
||||
|
||||
public static bool IsAuthRequired(JsonElement root) => MessageType(root) == "auth_required";
|
||||
|
||||
public static bool IsAuthOk(JsonElement root) => MessageType(root) == "auth_ok";
|
||||
|
||||
public static bool IsAuthInvalid(JsonElement root) => MessageType(root) == "auth_invalid";
|
||||
|
||||
/// <summary>
|
||||
/// If <paramref name="root"/> is a <c>state_changed</c> event with a numeric-capable
|
||||
/// <c>new_state</c> object, yields the entity id and that state object. The caller extracts the
|
||||
/// numeric value (state or a named attribute) per source via
|
||||
/// <see cref="HaStateClient.ParseStateElement"/>.
|
||||
/// </summary>
|
||||
public static bool TryReadStateChanged(JsonElement root, out string entityId, out JsonElement newState)
|
||||
{
|
||||
entityId = string.Empty;
|
||||
newState = default;
|
||||
|
||||
if (MessageType(root) != "event"
|
||||
|| !root.TryGetProperty("event", out var evt)
|
||||
|| !evt.TryGetProperty("event_type", out var evtType)
|
||||
|| evtType.GetString() != "state_changed"
|
||||
|| !evt.TryGetProperty("data", out var data))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("entity_id", out var id) || id.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("new_state", out var state) || state.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false; // entity removed (new_state null) — nothing to ingest.
|
||||
}
|
||||
|
||||
entityId = id.GetString()!;
|
||||
newState = state;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time Home Assistant ingestion via the WebSocket API (SDD §6.2 push path). For each enabled
|
||||
/// HA endpoint whose config sets <c>UseWebSocket</c>, holds a persistent connection that authenticates,
|
||||
/// subscribes to <c>state_changed</c> events, and ingests changes for the endpoint's configured
|
||||
/// entities as they happen. Reconnects with capped backoff. Endpoints without <c>UseWebSocket</c> stay
|
||||
/// on the REST poll worker (<see cref="HomeAssistantWorker"/>) — each endpoint is served by exactly one.
|
||||
/// Tokens are resolved from the endpoint config — an env-var reference or an encrypted value —
|
||||
/// never stored as plaintext.
|
||||
/// </summary>
|
||||
public sealed class HomeAssistantWebSocketWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
Security.SecretProtector secrets,
|
||||
ILogger<HomeAssistantWebSocketWorker> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan SuperviseInterval = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan InitialBackoff = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan MaxBackoff = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan EntityMapTtl = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly Security.SecretProtector _secrets = secrets;
|
||||
private readonly ILogger<HomeAssistantWebSocketWorker> _logger = logger;
|
||||
private readonly ConcurrentDictionary<int, Task> _connections = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(SuperviseInterval);
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
await SuperviseAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Home Assistant WebSocket supervisor tick failed; will retry");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>Starts a connection loop for each WebSocket-enabled endpoint that isn't already running.</summary>
|
||||
private async Task SuperviseAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
List<int> wsEndpointIds;
|
||||
await using (var scope = _scopeFactory.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||
var endpoints = await db.IngestionEndpoints
|
||||
.Where(e => e.IsEnabled && e.Type == EndpointType.HomeAssistant)
|
||||
.ToListAsync(stoppingToken).ConfigureAwait(false);
|
||||
wsEndpointIds = endpoints
|
||||
.Where(e => HaEndpointConfig.Parse(e.Config).UseWebSocket)
|
||||
.Select(e => e.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Reap finished connection loops (endpoint disabled/removed, or terminal failure).
|
||||
foreach (var id in _connections.Where(kv => kv.Value.IsCompleted).Select(kv => kv.Key).ToList())
|
||||
{
|
||||
_connections.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
foreach (var endpointId in wsEndpointIds)
|
||||
{
|
||||
// Fire-and-forget: the loop stores its own Task in _connections and self-terminates when
|
||||
// the endpoint is disabled/removed; the supervisor reaps completed entries above.
|
||||
_ = _connections.GetOrAdd(endpointId, id => Task.Run(() => RunConnectionAsync(id, stoppingToken), stoppingToken));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Connect → listen → reconnect loop for one endpoint, until it is disabled or the app stops.</summary>
|
||||
private async Task RunConnectionAsync(int endpointId, CancellationToken stoppingToken)
|
||||
{
|
||||
var backoff = InitialBackoff;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
HaEndpointConfig config;
|
||||
string? token;
|
||||
await using (var scope = _scopeFactory.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||
var endpoint = await db.IngestionEndpoints
|
||||
.FirstOrDefaultAsync(e => e.Id == endpointId, stoppingToken).ConfigureAwait(false);
|
||||
if (endpoint is null || !endpoint.IsEnabled)
|
||||
{
|
||||
return; // gone/disabled — stop; the supervisor will restart it if it comes back.
|
||||
}
|
||||
|
||||
config = HaEndpointConfig.Parse(endpoint.Config);
|
||||
if (!config.UseWebSocket)
|
||||
{
|
||||
return; // switched to poll mode.
|
||||
}
|
||||
|
||||
token = config.ResolveToken(_secrets);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_logger.LogWarning("HA WebSocket endpoint {EndpointId} missing base URL or token; retrying", endpointId);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
await ListenAsync(endpointId, config.BaseUrl!, token!, stoppingToken).ConfigureAwait(false);
|
||||
backoff = InitialBackoff; // clean close → reset backoff.
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HA WebSocket connection to endpoint {EndpointId} dropped; reconnecting", endpointId);
|
||||
await UpdateEndpointStatusAsync(endpointId, "disconnected", CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(backoff, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
backoff = TimeSpan.FromSeconds(Math.Min(MaxBackoff.TotalSeconds, backoff.TotalSeconds * 2));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ListenAsync(int endpointId, string baseUrl, string token, CancellationToken stoppingToken)
|
||||
{
|
||||
using var ws = new ClientWebSocket();
|
||||
await ws.ConnectAsync(HaWebSocketProtocol.WebSocketUri(baseUrl), stoppingToken).ConfigureAwait(false);
|
||||
|
||||
// Handshake: auth_required → auth → auth_ok.
|
||||
using (var required = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!HaWebSocketProtocol.IsAuthRequired(required.RootElement))
|
||||
{
|
||||
// Some setups may not send auth_required first; proceed to auth regardless.
|
||||
_logger.LogDebug("HA WebSocket did not send auth_required first (type={Type})", HaWebSocketProtocol.MessageType(required.RootElement));
|
||||
}
|
||||
}
|
||||
|
||||
await SendAsync(ws, HaWebSocketProtocol.AuthMessage(token), stoppingToken).ConfigureAwait(false);
|
||||
using (var authResult = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!HaWebSocketProtocol.IsAuthOk(authResult.RootElement))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Home Assistant WebSocket auth failed (type={HaWebSocketProtocol.MessageType(authResult.RootElement)}).");
|
||||
}
|
||||
}
|
||||
|
||||
await SendAsync(ws, HaWebSocketProtocol.SubscribeStateChanged(1), stoppingToken).ConfigureAwait(false);
|
||||
await UpdateEndpointStatusAsync(endpointId, "connected (ws)", stoppingToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("HA WebSocket connected for endpoint {EndpointId}", endpointId);
|
||||
|
||||
var entityMap = await LoadEntityMapAsync(endpointId, stoppingToken).ConfigureAwait(false);
|
||||
var mapLoadedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested && ws.State == WebSocketState.Open)
|
||||
{
|
||||
using var doc = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false);
|
||||
if (!HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out var entityId, out var newState))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.UtcNow - mapLoadedAt > EntityMapTtl)
|
||||
{
|
||||
entityMap = await LoadEntityMapAsync(endpointId, stoppingToken).ConfigureAwait(false);
|
||||
mapLoadedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
if (!entityMap.TryGetValue(entityId, out var sources))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (sourceId, attribute) in sources)
|
||||
{
|
||||
if (HaStateClient.ParseStateElement(newState, attribute) is { } state)
|
||||
{
|
||||
await IngestAsync(sourceId, state, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps each configured entity id to the sources (and optional attribute) that read it.</summary>
|
||||
private async Task<Dictionary<string, List<(int SourceId, string? Attribute)>>> LoadEntityMapAsync(
|
||||
int endpointId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||
var sources = await db.MeterSources.AsNoTracking()
|
||||
.Where(s => s.IsEnabled && s.SourceType == SourceType.HomeAssistant && s.EndpointId == endpointId)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var map = new Dictionary<string, List<(int, string?)>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var config = SourceConfig.Parse(source.Config);
|
||||
if (string.IsNullOrWhiteSpace(config.EntityId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!map.TryGetValue(config.EntityId, out var list))
|
||||
{
|
||||
map[config.EntityId] = list = [];
|
||||
}
|
||||
|
||||
list.Add((source.Id, config.Attribute));
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private async Task IngestAsync(int sourceId, HaState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
await ingestion.IngestAsync(sourceId, state.Time, state.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpdateEndpointStatusAsync(int endpointId, string status, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||
var endpoint = await db.IngestionEndpoints
|
||||
.FirstOrDefaultAsync(e => e.Id == endpointId, cancellationToken).ConfigureAwait(false);
|
||||
if (endpoint is not null)
|
||||
{
|
||||
endpoint.LastStatus = status;
|
||||
endpoint.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to update HA endpoint {EndpointId} status", endpointId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task SendAsync(ClientWebSocket ws, string json, CancellationToken cancellationToken) =>
|
||||
ws.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
|
||||
|
||||
/// <summary>Reads one (possibly fragmented) text message and parses it as JSON.</summary>
|
||||
private static async Task<JsonDocument> ReceiveJsonAsync(ClientWebSocket ws, CancellationToken cancellationToken)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[8192];
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await ws.ReceiveAsync(chunk, cancellationToken).ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Home Assistant closed the WebSocket ({result.CloseStatus}: {result.CloseStatusDescription}).");
|
||||
}
|
||||
|
||||
buffer.Write(chunk, 0, result.Count);
|
||||
}
|
||||
while (!result.EndOfMessage);
|
||||
|
||||
buffer.Position = 0;
|
||||
return await JsonDocument.ParseAsync(buffer, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -18,12 +17,14 @@ namespace MeterVault.Infrastructure.Ingestion;
|
||||
public sealed class HomeAssistantWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
Security.SecretProtector secrets,
|
||||
ILogger<HomeAssistantWorker> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
private readonly Security.SecretProtector _secrets = secrets;
|
||||
private readonly ILogger<HomeAssistantWorker> _logger = logger;
|
||||
private readonly ConcurrentDictionary<int, DateTimeOffset> _nextPoll = new();
|
||||
|
||||
@@ -55,9 +56,14 @@ public sealed class HomeAssistantWorker(
|
||||
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
var client = new HaStateClient(_httpClientFactory.CreateClient());
|
||||
|
||||
var endpoints = await db.IngestionEndpoints
|
||||
// Endpoints using the WebSocket push path are served by HomeAssistantWebSocketWorker; skip
|
||||
// them here so a source is never both polled and pushed.
|
||||
var enabled = await db.IngestionEndpoints
|
||||
.Where(e => e.IsEnabled && e.Type == EndpointType.HomeAssistant)
|
||||
.ToDictionaryAsync(e => e.Id, cancellationToken).ConfigureAwait(false);
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var endpoints = enabled
|
||||
.Where(e => !HaEndpointConfig.Parse(e.Config).UseWebSocket)
|
||||
.ToDictionary(e => e.Id);
|
||||
if (endpoints.Count == 0)
|
||||
{
|
||||
return;
|
||||
@@ -88,7 +94,7 @@ public sealed class HomeAssistantWorker(
|
||||
}
|
||||
|
||||
var config = SourceConfig.Parse(source.Config);
|
||||
var interval = TimeSpan.FromSeconds(Math.Max(5, config.PollSeconds ?? 60));
|
||||
var interval = TimeSpan.FromMinutes(Math.Max(1, config.PollMinutes ?? 60));
|
||||
_nextPoll[source.Id] = now + interval;
|
||||
|
||||
await PollSourceAsync(client, ingestion, endpoint, source, config, cancellationToken).ConfigureAwait(false);
|
||||
@@ -99,10 +105,8 @@ public sealed class HomeAssistantWorker(
|
||||
HaStateClient client, IngestionService ingestion, IngestionEndpoint endpoint,
|
||||
MeterSource source, SourceConfig config, CancellationToken cancellationToken)
|
||||
{
|
||||
var endpointConfig = ParseHaEndpoint(endpoint.Config);
|
||||
var token = string.IsNullOrWhiteSpace(endpointConfig.TokenEnv)
|
||||
? null
|
||||
: Environment.GetEnvironmentVariable(endpointConfig.TokenEnv);
|
||||
var endpointConfig = HaEndpointConfig.Parse(endpoint.Config);
|
||||
var token = endpointConfig.ResolveToken(_secrets);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpointConfig.BaseUrl) || string.IsNullOrWhiteSpace(token)
|
||||
|| string.IsNullOrWhiteSpace(config.EntityId))
|
||||
@@ -125,28 +129,4 @@ public sealed class HomeAssistantWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private static HaEndpoint ParseHaEndpoint(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new HaEndpoint();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<HaEndpoint>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new HaEndpoint();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new HaEndpoint();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HaEndpoint
|
||||
{
|
||||
public string? BaseUrl { get; init; }
|
||||
|
||||
public string? TokenEnv { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ public enum IngestionOutcome
|
||||
/// unless an active reset/swap event explains them. Updates the source's last-seen status.
|
||||
/// Consumption normalization is recomputed separately (batch/scheduled), not per message.
|
||||
/// </summary>
|
||||
public sealed class IngestionService(MeterVaultDbContext db)
|
||||
public sealed class IngestionService(
|
||||
MeterVaultDbContext db, Normalization.NormalizationService normalization)
|
||||
{
|
||||
private static readonly HashSet<MeterMode> MonotonicModes =
|
||||
[MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter];
|
||||
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
private readonly Normalization.NormalizationService _normalization = normalization;
|
||||
|
||||
/// <summary>Ingests through a configured source (MQTT/HA workers): applies scale/offset and updates source status.</summary>
|
||||
public async Task<IngestionOutcome> IngestAsync(
|
||||
@@ -54,14 +56,26 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
||||
return IngestionOutcome.RejectedDecrease;
|
||||
}
|
||||
|
||||
var outcome = await UpsertAsync(meter, utc, value, source.Id, cancellationToken).ConfigureAwait(false);
|
||||
var outcome = await UpsertAsync(meter, utc, value, source.Id, quality: null, cancellationToken).ConfigureAwait(false);
|
||||
await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false);
|
||||
await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings).</summary>
|
||||
/// <summary>Ingests directly against a meter (REST push, or a hand-entered reading from the UI).</summary>
|
||||
/// <param name="renormalize">
|
||||
/// False to skip deriving consumption, for callers ingesting a batch into one meter: recomputing
|
||||
/// rewrites the meter's entire series, so doing it per reading is quadratic in batch size. Such a
|
||||
/// caller must recompute the affected meters itself once the batch is in.
|
||||
/// </param>
|
||||
/// <param name="quality">
|
||||
/// Provenance to stamp on the row. Null keeps the default for a new row and leaves an existing
|
||||
/// row's quality alone — a source re-reporting a timestamp must not silently relabel a reading
|
||||
/// somebody entered by hand or that came from an import.
|
||||
/// </param>
|
||||
public async Task<IngestionOutcome> IngestByMeterAsync(
|
||||
int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken = default)
|
||||
int meterId, DateTimeOffset time, double value, bool renormalize = true,
|
||||
ReadingQuality? quality = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var meter = await _db.Meters
|
||||
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
@@ -77,13 +91,83 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
||||
return IngestionOutcome.RejectedDecrease;
|
||||
}
|
||||
|
||||
var outcome = await UpsertAsync(meter, utc, value, sourceId: null, cancellationToken).ConfigureAwait(false);
|
||||
var outcome = await UpsertAsync(meter, utc, value, sourceId: null, quality, cancellationToken).ConfigureAwait(false);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (renormalize)
|
||||
{
|
||||
await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives consumption for one meter after a batch of readings has been written. The public
|
||||
/// counterpart to skipping <c>renormalize</c> on each individual ingest.
|
||||
/// </summary>
|
||||
public Task RenormalizeMeterAsync(int meterId, CancellationToken cancellationToken = default) =>
|
||||
RecomputeAtomicallyAsync(meterId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Derives consumption from the reading just written. Without this a live-ingested reading sits
|
||||
/// in <c>reading</c> forever and every derived figure — consumption, generation, cost — stays
|
||||
/// frozen at the last import, because nothing else recomputes that meter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recomputes inline rather than on a debounce. <see cref="Normalization.NormalizationService"/>
|
||||
/// rewrites a meter's whole consumption series, which is cheap at metering cadence — HA polls
|
||||
/// hourly — but would be wasteful under a chatty MQTT source publishing every few seconds. If
|
||||
/// such a source is ever added, batch this behind a dirty-set worker rather than making the
|
||||
/// normalizer incremental: consumption being a pure function of readings + events is what makes
|
||||
/// it reproducible.
|
||||
/// </remarks>
|
||||
private async Task RenormalizeAsync(
|
||||
int meterId, IngestionOutcome outcome, CancellationToken cancellationToken)
|
||||
{
|
||||
// A rejected decrease changed nothing, so the existing series is still correct.
|
||||
if (outcome is not (IngestionOutcome.Written or IngestionOutcome.Updated))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// The reading must already be persisted: RecomputeMeterAsync re-reads the meter's readings
|
||||
// from the database, so anything still pending in the change tracker would be missed.
|
||||
await RecomputeAtomicallyAsync(meterId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds a meter's consumption series as one atomic unit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Normalization.NormalizationService.RecomputeMeterAsync"/> clears the series with
|
||||
/// <c>ExecuteDelete</c>, which commits on its own when no transaction is ambient, and only then
|
||||
/// adds the rebuilt rows. Without a transaction around both halves the meter has *no*
|
||||
/// consumption in between: a dashboard read in that window reports zero, and a crash or a
|
||||
/// cancelled request makes the loss permanent — for data the SDD treats as the long-term source
|
||||
/// of truth (§5.5). Import and the events API already wrap their recomputes this way; live
|
||||
/// ingestion was the path that did not.
|
||||
///
|
||||
/// Respects an ambient transaction rather than nesting, so callers that already opened one keep
|
||||
/// a single unit of work.
|
||||
/// </remarks>
|
||||
private async Task RecomputeAtomicallyAsync(int meterId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_db.Database.CurrentTransaction is not null)
|
||||
{
|
||||
await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<IngestionOutcome> UpsertAsync(
|
||||
Meter meter, DateTimeOffset utc, double value, int? sourceId, CancellationToken cancellationToken)
|
||||
Meter meter, DateTimeOffset utc, double value, int? sourceId, ReadingQuality? quality,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc)
|
||||
?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false);
|
||||
@@ -96,13 +180,18 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
||||
Time = utc,
|
||||
Value = value,
|
||||
SourceId = sourceId,
|
||||
Quality = ReadingQuality.Measured,
|
||||
Quality = quality ?? ReadingQuality.Measured,
|
||||
});
|
||||
return IngestionOutcome.Written;
|
||||
}
|
||||
|
||||
existing.Value = value;
|
||||
existing.SourceId = sourceId ?? existing.SourceId;
|
||||
if (quality is { } stamp)
|
||||
{
|
||||
existing.Quality = stamp;
|
||||
}
|
||||
|
||||
return IngestionOutcome.Updated;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,14 @@ namespace MeterVault.Infrastructure.Ingestion;
|
||||
/// are logged and retried on the next tick rather than crashing the app.
|
||||
/// </summary>
|
||||
public sealed class MqttIngestionWorker(
|
||||
IServiceScopeFactory scopeFactory, ILogger<MqttIngestionWorker> logger) : BackgroundService
|
||||
IServiceScopeFactory scopeFactory,
|
||||
Security.SecretProtector secrets,
|
||||
ILogger<MqttIngestionWorker> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan ReconnectInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly Security.SecretProtector _secrets = secrets;
|
||||
private readonly ILogger<MqttIngestionWorker> _logger = logger;
|
||||
private readonly MqttClientFactory _factory = new();
|
||||
private readonly ConcurrentDictionary<int, IMqttClient> _clients = new();
|
||||
@@ -84,7 +87,7 @@ public sealed class MqttIngestionWorker(
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var client = _clients.GetOrAdd(endpoint.Id, _ => CreateClient());
|
||||
var client = _clients.GetOrAdd(endpoint.Id, id => CreateClient(id));
|
||||
var topics = await ResolveTopicsAsync(db, endpoint, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!client.IsConnected)
|
||||
@@ -114,10 +117,13 @@ public sealed class MqttIngestionWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private IMqttClient CreateClient()
|
||||
// One client per endpoint, with the endpoint id captured in the handler: MQTTnet's event args
|
||||
// carry the topic but not which connection delivered it, and the router needs that to keep
|
||||
// sources bound to one broker from ingesting another's traffic.
|
||||
private IMqttClient CreateClient(int endpointId)
|
||||
{
|
||||
var client = _factory.CreateMqttClient();
|
||||
client.ApplicationMessageReceivedAsync += OnMessageAsync;
|
||||
client.ApplicationMessageReceivedAsync += args => OnMessageAsync(endpointId, args);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -129,10 +135,10 @@ public sealed class MqttIngestionWorker(
|
||||
.WithTcpServer(config.Host, config.Port)
|
||||
.WithCleanSession();
|
||||
|
||||
var username = config.ResolveUsername();
|
||||
var username = config.ResolveUsername(_secrets);
|
||||
if (!string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
builder = builder.WithCredentials(username, config.ResolvePassword() ?? string.Empty);
|
||||
builder = builder.WithCredentials(username, config.ResolvePassword(_secrets) ?? string.Empty);
|
||||
}
|
||||
|
||||
if (config.Tls)
|
||||
@@ -163,10 +169,12 @@ public sealed class MqttIngestionWorker(
|
||||
private static async Task<IReadOnlyList<string>> ResolveTopicsAsync(
|
||||
MeterVaultDbContext db, IngestionEndpoint endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
// Bound sources only, matching MqttMessageRouter: an unbound source is not routed, so
|
||||
// subscribing its topic on every broker would only invite traffic nothing consumes.
|
||||
var sourceConfigs = await db.MeterSources
|
||||
.Where(s => s.IsEnabled
|
||||
&& (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota)
|
||||
&& (s.EndpointId == endpoint.Id || s.EndpointId == null))
|
||||
&& s.EndpointId == endpoint.Id)
|
||||
.Select(s => s.Config)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -188,7 +196,7 @@ public sealed class MqttIngestionWorker(
|
||||
return [.. topics];
|
||||
}
|
||||
|
||||
private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args)
|
||||
private async Task OnMessageAsync(int endpointId, MqttApplicationMessageReceivedEventArgs args)
|
||||
{
|
||||
var topic = args.ApplicationMessage.Topic;
|
||||
var payload = args.ApplicationMessage.ConvertPayloadToString() ?? string.Empty;
|
||||
@@ -197,7 +205,7 @@ public sealed class MqttIngestionWorker(
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var router = scope.ServiceProvider.GetRequiredService<MqttMessageRouter>();
|
||||
await router.RouteAsync(topic, payload).ConfigureAwait(false);
|
||||
await router.RouteAsync(endpointId, topic, payload).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -6,10 +6,16 @@ using Microsoft.Extensions.Logging;
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Routes an incoming MQTT message to every enabled MQTT/Tasmota source whose topic filter covers
|
||||
/// it, extracts the value (and payload timestamp), and ingests it (SDD §6.1). Decoupled from the
|
||||
/// broker client so it can be exercised directly against the database in tests.
|
||||
/// Routes an incoming MQTT message to every enabled MQTT/Tasmota source that is bound to the
|
||||
/// delivering broker <em>and</em> whose topic filter covers it, extracts the value (and payload
|
||||
/// timestamp), and ingests it (SDD §6.1). Decoupled from the broker client so it can be exercised
|
||||
/// directly against the database in tests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The endpoint predicate is load-bearing, not defensive: topic filters routinely overlap between
|
||||
/// brokers (every Tasmota install publishes <c>tele/+/SENSOR</c>), so matching on topic alone would
|
||||
/// let a message from one broker be ingested by a source bound to another.
|
||||
/// </remarks>
|
||||
public sealed class MqttMessageRouter(
|
||||
MeterVaultDbContext db, IngestionService ingestion, ILogger<MqttMessageRouter> logger)
|
||||
{
|
||||
@@ -17,10 +23,13 @@ public sealed class MqttMessageRouter(
|
||||
private readonly IngestionService _ingestion = ingestion;
|
||||
private readonly ILogger<MqttMessageRouter> _logger = logger;
|
||||
|
||||
public async Task<int> RouteAsync(string topic, string payload, CancellationToken cancellationToken = default)
|
||||
public async Task<int> RouteAsync(
|
||||
int endpointId, string topic, string payload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sources = await _db.MeterSources
|
||||
.Where(s => s.IsEnabled && (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota))
|
||||
.Where(s => s.IsEnabled
|
||||
&& (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota)
|
||||
&& s.EndpointId == endpointId)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var routed = 0;
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace MeterVault.Infrastructure.Ingestion;
|
||||
/// <summary>
|
||||
/// The parsed <see cref="Core.Domain.MeterSource.Config"/> JSON. Which fields matter depends on the
|
||||
/// source type: MQTT/Tasmota use <see cref="Topic"/>/<see cref="Path"/>/<see cref="TimePath"/>;
|
||||
/// Home Assistant uses <see cref="EntityId"/>/<see cref="Attribute"/>/<see cref="PollSeconds"/>.
|
||||
/// Home Assistant uses <see cref="EntityId"/>/<see cref="Attribute"/>/<see cref="PollMinutes"/>.
|
||||
/// </summary>
|
||||
public sealed record SourceConfig
|
||||
{
|
||||
@@ -31,8 +31,18 @@ public sealed record SourceConfig
|
||||
/// <summary>Home Assistant attribute name; null reads the entity state.</summary>
|
||||
public string? Attribute { get; init; }
|
||||
|
||||
/// <summary>Home Assistant REST poll interval in seconds (fallback when not using WebSocket push).</summary>
|
||||
public int? PollSeconds { get; init; }
|
||||
/// <summary>
|
||||
/// Home Assistant REST poll interval in <em>minutes</em> (fallback when not using WebSocket
|
||||
/// push). Default 60.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not seconds. A meter is read to answer "how much did I use this month, and what
|
||||
/// will it cost" — questions an hourly sample answers exactly as well as a per-second one, at a
|
||||
/// fraction of the raw volume (SDD §5.5). This field replaced <c>pollSeconds</c>; the old key no
|
||||
/// longer binds, so sources written before the change fall back to the 60 default and are read
|
||||
/// hourly instead of every 60 seconds.
|
||||
/// </remarks>
|
||||
public int? PollMinutes { get; init; }
|
||||
|
||||
public static SourceConfig Parse(string? json)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<PackageReference Include="MQTTnet" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -46,6 +46,17 @@ public sealed class NormalizationService(MeterVaultDbContext db, INormalizationE
|
||||
await _db.Consumption.Where(c => c.MeterId == meterId)
|
||||
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// ExecuteDelete goes straight to the database and leaves the change tracker untouched, so
|
||||
// rows added by an earlier recompute on this context are still tracked but no longer exist.
|
||||
// Detach them, or re-adding the same (meter, time, kind) key throws an identity conflict —
|
||||
// which is what happens when one context recomputes a meter twice, e.g. a worker ingesting
|
||||
// two readings in a single scope.
|
||||
foreach (var stale in _db.ChangeTracker.Entries<Consumption>()
|
||||
.Where(e => e.Entity.MeterId == meterId).ToList())
|
||||
{
|
||||
stale.State = EntityState.Detached;
|
||||
}
|
||||
|
||||
foreach (var row in consumption)
|
||||
{
|
||||
row.ImportBatchId = batchId;
|
||||
|
||||
@@ -19,12 +19,43 @@ public sealed class MeterVaultOptions
|
||||
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
|
||||
public bool RunMigrationsAtStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Load the bundled Energiebilanz reference dataset on startup if the database has none yet
|
||||
/// (idempotent — guarded by a marker meter). Off by default; set <c>MeterVault__SeedReferenceData=true</c>
|
||||
/// for a one-command populated demo/test instance.
|
||||
/// </summary>
|
||||
public bool SeedReferenceData { get; set; }
|
||||
|
||||
/// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary>
|
||||
public bool EnableLiveIngestion { get; set; } = true;
|
||||
|
||||
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
|
||||
public int RawRetentionDays { get; set; } = 1095;
|
||||
|
||||
/// <summary>
|
||||
/// Allow an update to be triggered from the UI/API. <b>Off by default, and deliberately.</b> The
|
||||
/// updater builds whatever is on the branch and the LXC runs this app as root, so enabling it
|
||||
/// turns a valid API key into arbitrary code execution on the host. It additionally requires at
|
||||
/// least one configured API key: an anonymous-API deployment can never reach it, because opening
|
||||
/// reads must not open root. Only sensible where the UI is behind an authenticating proxy or on
|
||||
/// a network you fully trust.
|
||||
/// </summary>
|
||||
public bool AllowInAppUpdate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Compare the running build against the newest published release and show a banner when behind.
|
||||
/// Set false for an air-gapped instance, or one that should make no outbound requests at all.
|
||||
/// </summary>
|
||||
public bool UpdateCheckEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Tag listing consulted by the update check. Points at the project's own Gitea, not a vendor
|
||||
/// endpoint — nothing about the instance is sent, it is a plain GET of a public tag list.
|
||||
/// Repoint it at a fork, or blank it to disable the check as surely as the flag above.
|
||||
/// </summary>
|
||||
public string UpdateCheckUrl { get; set; } =
|
||||
"https://git.finalfactory.de/api/v1/repos/FinalFactory/MeterVault/tags?limit=50";
|
||||
|
||||
/// <summary>
|
||||
/// API keys accepted on the <c>X-Api-Key</c> header for the REST API (SDD §9). Provide via env
|
||||
/// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty means the API is open (dev only).
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
|
||||
public DbSet<EnergyType> EnergyTypes => Set<EnergyType>();
|
||||
public DbSet<Meter> Meters => Set<Meter>();
|
||||
public DbSet<MeterSource> MeterSources => Set<MeterSource>();
|
||||
public DbSet<MeterLink> MeterLinks => Set<MeterLink>();
|
||||
public DbSet<Reading> Readings => Set<Reading>();
|
||||
public DbSet<Consumption> Consumption => Set<Consumption>();
|
||||
public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>();
|
||||
@@ -74,6 +75,16 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
|
||||
e.HasIndex(x => x.MeterId);
|
||||
});
|
||||
|
||||
b.Entity<MeterLink>(e =>
|
||||
{
|
||||
e.ToTable("meter_link");
|
||||
e.HasKey(x => x.Id);
|
||||
e.HasOne(x => x.FromMeter).WithMany().HasForeignKey(x => x.FromMeterId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.ToMeter).WithMany().HasForeignKey(x => x.ToMeterId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasIndex(x => new { x.FromMeterId, x.ToMeterId }).IsUnique();
|
||||
e.ToTable(t => t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id"));
|
||||
});
|
||||
|
||||
// Hypertable — the (meter_id, time) PK contains the partition column (time),
|
||||
// which Timescale requires. Converted to a hypertable in a raw-SQL migration.
|
||||
b.Entity<Reading>(e =>
|
||||
|
||||
+931
@@ -0,0 +1,931 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(MeterVaultDbContext))]
|
||||
[Migration("20260714114901_AddMeterLinks")]
|
||||
partial class AddMeterLinks
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Key")
|
||||
.HasName("pk_app_setting");
|
||||
|
||||
b.ToTable("app_setting", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<short>("Kind")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.HasKey("MeterId", "Time", "Kind")
|
||||
.HasName("pk_consumption");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_consumption_import_batch_id");
|
||||
|
||||
b.ToTable("consumption", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Sort")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category");
|
||||
|
||||
b.ToTable("cost_category", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<short?>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category_member");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_cost_category_member_category_id");
|
||||
|
||||
b.HasIndex("EnergyTypeId")
|
||||
.HasDatabaseName("ix_cost_category_member_energy_type_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_cost_category_member_meter_id");
|
||||
|
||||
b.ToTable("cost_category_member", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Property<short>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
|
||||
|
||||
b.Property<string>("BaseUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("base_unit");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("DefaultMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("default_mode");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_energy_type");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_energy_type_key");
|
||||
|
||||
b.ToTable("energy_type", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Mapping")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("mapping");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevertedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("reverted_at");
|
||||
|
||||
b.Property<int>("RowCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("row_count");
|
||||
|
||||
b.Property<string>("SourceName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("source_name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_import_batch");
|
||||
|
||||
b.ToTable("import_batch", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_ingestion_endpoint");
|
||||
|
||||
b.ToTable("ingestion_endpoint", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<DateOnly>("PeriodEnd")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_end");
|
||||
|
||||
b.Property<DateOnly>("PeriodStart")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_start");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_manual_cost");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_manual_cost_category_id");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_manual_cost_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_manual_cost_meter_id");
|
||||
|
||||
b.ToTable("manual_cost", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<short>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<double>("InitialBaseline")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("initial_baseline");
|
||||
|
||||
b.Property<DateOnly?>("InstalledAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("installed_at");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<string>("Manufacturer")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("manufacturer");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateOnly?>("RetiredAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("retired_at");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("serial_number");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter");
|
||||
|
||||
b.HasIndex("EnergyTypeId", "IsActive")
|
||||
.HasDatabaseName("ix_meter_energy_type_id_is_active");
|
||||
|
||||
b.ToTable("meter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double?>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("event_type");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double?>("NewValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("new_value");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<double?>("PrevValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("prev_value");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_event");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_meter_event_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId", "Time")
|
||||
.HasDatabaseName("ix_meter_event_meter_id_time");
|
||||
|
||||
b.ToTable("meter_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("FromMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("from_meter_id");
|
||||
|
||||
b.Property<int>("ToMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("to_meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_link");
|
||||
|
||||
b.HasIndex("ToMeterId")
|
||||
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||
|
||||
b.HasIndex("FromMeterId", "ToMeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||
|
||||
b.ToTable("meter_link", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int?>("EndpointId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("endpoint_id");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<double?>("LastValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("last_value");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double>("Offset")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("offset");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<double>("Scale")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("double precision")
|
||||
.HasDefaultValue(1.0)
|
||||
.HasColumnName("scale");
|
||||
|
||||
b.Property<string>("SourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("source_type");
|
||||
|
||||
b.Property<string>("ValueKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("value_kind");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_source");
|
||||
|
||||
b.HasIndex("EndpointId")
|
||||
.HasDatabaseName("ix_meter_source_endpoint_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_meter_source_meter_id");
|
||||
|
||||
b.ToTable("meter_source", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<int>("Flags")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("flags");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.Property<int?>("SourceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("source_id");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("MeterId", "Time")
|
||||
.HasName("pk_reading");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_reading_import_batch_id");
|
||||
|
||||
b.ToTable("reading", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset?>("CachedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("cached_at");
|
||||
|
||||
b.Property<double?>("CachedBalance")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("cached_balance");
|
||||
|
||||
b.Property<string>("Calibration")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("calibration");
|
||||
|
||||
b.Property<double>("Capacity")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("capacity");
|
||||
|
||||
b.Property<double?>("FixedRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("fixed_rate");
|
||||
|
||||
b.Property<double?>("LowThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("low_threshold");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("RateMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("rate_mode");
|
||||
|
||||
b.Property<double?>("ReorderThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("reorder_threshold");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tank");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tank_meter_id");
|
||||
|
||||
b.ToTable("tank", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Component")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("component");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<int?>("ScopeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("scope_id");
|
||||
|
||||
b.Property<string>("ScopeType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("scope_type");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateOnly>("ValidFrom")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_from");
|
||||
|
||||
b.Property<DateOnly?>("ValidTo")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_to");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tariff");
|
||||
|
||||
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
|
||||
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
|
||||
|
||||
b.ToTable("tariff", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_consumption_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_meter_meter_id");
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
|
||||
.WithMany("Meters")
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_energy_type_energy_type_id");
|
||||
|
||||
b.Navigation("EnergyType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("FromMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ToMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||
|
||||
b.Navigation("FromMeter");
|
||||
|
||||
b.Navigation("ToMeter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||
.WithMany()
|
||||
.HasForeignKey("EndpointId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany("Sources")
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_source_meter_meter_id");
|
||||
|
||||
b.Navigation("Endpoint");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_reading_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tank_meter_meter_id");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Navigation("Meters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Navigation("Sources");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMeterLinks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "meter_link",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
from_meter_id = table.Column<int>(type: "integer", nullable: false),
|
||||
to_meter_id = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_meter_link", x => x.id);
|
||||
table.CheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
table.ForeignKey(
|
||||
name: "fk_meter_link_meter_from_meter_id",
|
||||
column: x => x.from_meter_id,
|
||||
principalTable: "meter",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_meter_link_meter_to_meter_id",
|
||||
column: x => x.to_meter_id,
|
||||
principalTable: "meter",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_meter_link_from_meter_id_to_meter_id",
|
||||
table: "meter_link",
|
||||
columns: new[] { "from_meter_id", "to_meter_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_meter_link_to_meter_id",
|
||||
table: "meter_link",
|
||||
column: "to_meter_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "meter_link");
|
||||
}
|
||||
}
|
||||
}
|
||||
+931
@@ -0,0 +1,931 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(MeterVaultDbContext))]
|
||||
[Migration("20260718091623_BindUnboundMqttSourcesToSoleBroker")]
|
||||
partial class BindUnboundMqttSourcesToSoleBroker
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Key")
|
||||
.HasName("pk_app_setting");
|
||||
|
||||
b.ToTable("app_setting", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<short>("Kind")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.HasKey("MeterId", "Time", "Kind")
|
||||
.HasName("pk_consumption");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_consumption_import_batch_id");
|
||||
|
||||
b.ToTable("consumption", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Sort")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category");
|
||||
|
||||
b.ToTable("cost_category", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<short?>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category_member");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_cost_category_member_category_id");
|
||||
|
||||
b.HasIndex("EnergyTypeId")
|
||||
.HasDatabaseName("ix_cost_category_member_energy_type_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_cost_category_member_meter_id");
|
||||
|
||||
b.ToTable("cost_category_member", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Property<short>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
|
||||
|
||||
b.Property<string>("BaseUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("base_unit");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("DefaultMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("default_mode");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_energy_type");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_energy_type_key");
|
||||
|
||||
b.ToTable("energy_type", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Mapping")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("mapping");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevertedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("reverted_at");
|
||||
|
||||
b.Property<int>("RowCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("row_count");
|
||||
|
||||
b.Property<string>("SourceName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("source_name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_import_batch");
|
||||
|
||||
b.ToTable("import_batch", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_ingestion_endpoint");
|
||||
|
||||
b.ToTable("ingestion_endpoint", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<DateOnly>("PeriodEnd")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_end");
|
||||
|
||||
b.Property<DateOnly>("PeriodStart")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_start");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_manual_cost");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_manual_cost_category_id");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_manual_cost_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_manual_cost_meter_id");
|
||||
|
||||
b.ToTable("manual_cost", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<short>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<double>("InitialBaseline")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("initial_baseline");
|
||||
|
||||
b.Property<DateOnly?>("InstalledAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("installed_at");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<string>("Manufacturer")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("manufacturer");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateOnly?>("RetiredAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("retired_at");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("serial_number");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter");
|
||||
|
||||
b.HasIndex("EnergyTypeId", "IsActive")
|
||||
.HasDatabaseName("ix_meter_energy_type_id_is_active");
|
||||
|
||||
b.ToTable("meter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double?>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("event_type");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double?>("NewValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("new_value");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<double?>("PrevValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("prev_value");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_event");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_meter_event_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId", "Time")
|
||||
.HasDatabaseName("ix_meter_event_meter_id_time");
|
||||
|
||||
b.ToTable("meter_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("FromMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("from_meter_id");
|
||||
|
||||
b.Property<int>("ToMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("to_meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_link");
|
||||
|
||||
b.HasIndex("ToMeterId")
|
||||
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||
|
||||
b.HasIndex("FromMeterId", "ToMeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||
|
||||
b.ToTable("meter_link", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int?>("EndpointId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("endpoint_id");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<double?>("LastValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("last_value");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double>("Offset")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("offset");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<double>("Scale")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("double precision")
|
||||
.HasDefaultValue(1.0)
|
||||
.HasColumnName("scale");
|
||||
|
||||
b.Property<string>("SourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("source_type");
|
||||
|
||||
b.Property<string>("ValueKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("value_kind");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_source");
|
||||
|
||||
b.HasIndex("EndpointId")
|
||||
.HasDatabaseName("ix_meter_source_endpoint_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_meter_source_meter_id");
|
||||
|
||||
b.ToTable("meter_source", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<int>("Flags")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("flags");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.Property<int?>("SourceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("source_id");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("MeterId", "Time")
|
||||
.HasName("pk_reading");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_reading_import_batch_id");
|
||||
|
||||
b.ToTable("reading", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset?>("CachedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("cached_at");
|
||||
|
||||
b.Property<double?>("CachedBalance")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("cached_balance");
|
||||
|
||||
b.Property<string>("Calibration")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("calibration");
|
||||
|
||||
b.Property<double>("Capacity")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("capacity");
|
||||
|
||||
b.Property<double?>("FixedRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("fixed_rate");
|
||||
|
||||
b.Property<double?>("LowThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("low_threshold");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("RateMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("rate_mode");
|
||||
|
||||
b.Property<double?>("ReorderThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("reorder_threshold");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tank");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tank_meter_id");
|
||||
|
||||
b.ToTable("tank", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Component")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("component");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<int?>("ScopeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("scope_id");
|
||||
|
||||
b.Property<string>("ScopeType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("scope_type");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateOnly>("ValidFrom")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_from");
|
||||
|
||||
b.Property<DateOnly?>("ValidTo")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_to");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tariff");
|
||||
|
||||
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
|
||||
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
|
||||
|
||||
b.ToTable("tariff", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_consumption_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_meter_meter_id");
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
|
||||
.WithMany("Meters")
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_energy_type_energy_type_id");
|
||||
|
||||
b.Navigation("EnergyType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("FromMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ToMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||
|
||||
b.Navigation("FromMeter");
|
||||
|
||||
b.Navigation("ToMeter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||
.WithMany()
|
||||
.HasForeignKey("EndpointId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany("Sources")
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_source_meter_meter_id");
|
||||
|
||||
b.Navigation("Endpoint");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_reading_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tank_meter_meter_id");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Navigation("Meters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Navigation("Sources");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BindUnboundMqttSourcesToSoleBroker : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// MQTT routing now honours meter_source.endpoint_id (SDD §6.1): a source is served only
|
||||
// by the broker it is bound to. Previously an unbound source was subscribed on every
|
||||
// broker and matched on topic alone, so unbound sources that work today would silently
|
||||
// go quiet after this deploy.
|
||||
//
|
||||
// Backfill them onto the single broker only when exactly one exists — then the old
|
||||
// "any broker" behaviour and the new "its broker" behaviour are the same thing, so the
|
||||
// rewrite is provably lossless. With zero brokers there is nothing to bind to; with two
|
||||
// or more the old behaviour was already ambiguous and a guess could route a meter's
|
||||
// data to the wrong broker, so those are left for the operator to resolve in the UI.
|
||||
//
|
||||
// Enums persist as their C# names (HasConversion<string>), hence 'Mqtt'/'MqttBroker'.
|
||||
// HomeAssistant sources are deliberately excluded: the HA workers have always required
|
||||
// endpoint_id, so an unbound HA source is already inert and binding it here would
|
||||
// activate ingestion the operator never had running.
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE meter_source AS s
|
||||
SET endpoint_id = sole.id
|
||||
FROM (SELECT id FROM ingestion_endpoint WHERE type = 'MqttBroker') AS sole
|
||||
WHERE s.endpoint_id IS NULL
|
||||
AND s.source_type IN ('Mqtt', 'Tasmota')
|
||||
AND (SELECT count(*) FROM ingestion_endpoint WHERE type = 'MqttBroker') = 1;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Intentionally empty. The rows this bound are indistinguishable from ones the operator
|
||||
// bound by hand, so clearing endpoint_id on the way down would discard real
|
||||
// configuration. Leaving the binding in place is harmless under the old routing, which
|
||||
// ignored endpoint_id entirely.
|
||||
}
|
||||
}
|
||||
}
|
||||
+931
@@ -0,0 +1,931 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(MeterVaultDbContext))]
|
||||
[Migration("20260718174732_BindUnboundMqttSourcesToSoleEnabledBroker")]
|
||||
partial class BindUnboundMqttSourcesToSoleEnabledBroker
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Key")
|
||||
.HasName("pk_app_setting");
|
||||
|
||||
b.ToTable("app_setting", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<short>("Kind")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.HasKey("MeterId", "Time", "Kind")
|
||||
.HasName("pk_consumption");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_consumption_import_batch_id");
|
||||
|
||||
b.ToTable("consumption", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Sort")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category");
|
||||
|
||||
b.ToTable("cost_category", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<short?>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category_member");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_cost_category_member_category_id");
|
||||
|
||||
b.HasIndex("EnergyTypeId")
|
||||
.HasDatabaseName("ix_cost_category_member_energy_type_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_cost_category_member_meter_id");
|
||||
|
||||
b.ToTable("cost_category_member", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Property<short>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
|
||||
|
||||
b.Property<string>("BaseUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("base_unit");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("DefaultMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("default_mode");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_energy_type");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_energy_type_key");
|
||||
|
||||
b.ToTable("energy_type", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Mapping")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("mapping");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevertedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("reverted_at");
|
||||
|
||||
b.Property<int>("RowCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("row_count");
|
||||
|
||||
b.Property<string>("SourceName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("source_name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_import_batch");
|
||||
|
||||
b.ToTable("import_batch", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_ingestion_endpoint");
|
||||
|
||||
b.ToTable("ingestion_endpoint", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<DateOnly>("PeriodEnd")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_end");
|
||||
|
||||
b.Property<DateOnly>("PeriodStart")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_start");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_manual_cost");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_manual_cost_category_id");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_manual_cost_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_manual_cost_meter_id");
|
||||
|
||||
b.ToTable("manual_cost", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<short>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<double>("InitialBaseline")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("initial_baseline");
|
||||
|
||||
b.Property<DateOnly?>("InstalledAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("installed_at");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<string>("Manufacturer")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("manufacturer");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateOnly?>("RetiredAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("retired_at");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("serial_number");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter");
|
||||
|
||||
b.HasIndex("EnergyTypeId", "IsActive")
|
||||
.HasDatabaseName("ix_meter_energy_type_id_is_active");
|
||||
|
||||
b.ToTable("meter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double?>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("event_type");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double?>("NewValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("new_value");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<double?>("PrevValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("prev_value");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_event");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_meter_event_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId", "Time")
|
||||
.HasDatabaseName("ix_meter_event_meter_id_time");
|
||||
|
||||
b.ToTable("meter_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("FromMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("from_meter_id");
|
||||
|
||||
b.Property<int>("ToMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("to_meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_link");
|
||||
|
||||
b.HasIndex("ToMeterId")
|
||||
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||
|
||||
b.HasIndex("FromMeterId", "ToMeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||
|
||||
b.ToTable("meter_link", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int?>("EndpointId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("endpoint_id");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<double?>("LastValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("last_value");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double>("Offset")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("offset");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<double>("Scale")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("double precision")
|
||||
.HasDefaultValue(1.0)
|
||||
.HasColumnName("scale");
|
||||
|
||||
b.Property<string>("SourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("source_type");
|
||||
|
||||
b.Property<string>("ValueKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("value_kind");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_source");
|
||||
|
||||
b.HasIndex("EndpointId")
|
||||
.HasDatabaseName("ix_meter_source_endpoint_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_meter_source_meter_id");
|
||||
|
||||
b.ToTable("meter_source", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<int>("Flags")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("flags");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.Property<int?>("SourceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("source_id");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("MeterId", "Time")
|
||||
.HasName("pk_reading");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_reading_import_batch_id");
|
||||
|
||||
b.ToTable("reading", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset?>("CachedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("cached_at");
|
||||
|
||||
b.Property<double?>("CachedBalance")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("cached_balance");
|
||||
|
||||
b.Property<string>("Calibration")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("calibration");
|
||||
|
||||
b.Property<double>("Capacity")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("capacity");
|
||||
|
||||
b.Property<double?>("FixedRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("fixed_rate");
|
||||
|
||||
b.Property<double?>("LowThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("low_threshold");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("RateMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("rate_mode");
|
||||
|
||||
b.Property<double?>("ReorderThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("reorder_threshold");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tank");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tank_meter_id");
|
||||
|
||||
b.ToTable("tank", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Component")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("component");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<int?>("ScopeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("scope_id");
|
||||
|
||||
b.Property<string>("ScopeType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("scope_type");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateOnly>("ValidFrom")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_from");
|
||||
|
||||
b.Property<DateOnly?>("ValidTo")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_to");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tariff");
|
||||
|
||||
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
|
||||
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
|
||||
|
||||
b.ToTable("tariff", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_consumption_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_meter_meter_id");
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
|
||||
.WithMany("Meters")
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_energy_type_energy_type_id");
|
||||
|
||||
b.Navigation("EnergyType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("FromMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ToMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||
|
||||
b.Navigation("FromMeter");
|
||||
|
||||
b.Navigation("ToMeter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||
.WithMany()
|
||||
.HasForeignKey("EndpointId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany("Sources")
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_source_meter_meter_id");
|
||||
|
||||
b.Navigation("Endpoint");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_reading_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tank_meter_meter_id");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Navigation("Meters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Navigation("Sources");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class BindUnboundMqttSourcesToSoleEnabledBroker : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Corrects BindUnboundMqttSourcesToSoleBroker, which counted brokers without regard to
|
||||
// is_enabled. An instance with one live broker plus a disabled leftover counted two,
|
||||
// declined to backfill on the grounds that the old routing was "ambiguous", and left its
|
||||
// sources unbound — which under endpoint-scoped routing means permanently, silently dead.
|
||||
//
|
||||
// That reasoning was wrong for exactly this shape: MqttIngestionWorker only ever
|
||||
// connected to enabled endpoints, so with a single enabled broker the mapping was never
|
||||
// ambiguous. Re-run the backfill counting only enabled brokers.
|
||||
//
|
||||
// Idempotent and safe to follow the original: it touches only rows still NULL, so
|
||||
// anything the first migration bound, or an operator has since bound by hand, is left
|
||||
// alone. Instances that were already correct match nothing and are unaffected.
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE meter_source AS s
|
||||
SET endpoint_id = sole.id
|
||||
FROM (SELECT id FROM ingestion_endpoint WHERE type = 'MqttBroker' AND is_enabled) AS sole
|
||||
WHERE s.endpoint_id IS NULL
|
||||
AND s.source_type IN ('Mqtt', 'Tasmota')
|
||||
AND (SELECT count(*) FROM ingestion_endpoint WHERE type = 'MqttBroker' AND is_enabled) = 1;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Intentionally empty, as for the migration this corrects: the rows it bound cannot be
|
||||
// told apart from ones bound by hand, so clearing them would discard real configuration.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,6 +499,39 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("meter_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("FromMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("from_meter_id");
|
||||
|
||||
b.Property<int>("ToMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("to_meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_link");
|
||||
|
||||
b.HasIndex("ToMeterId")
|
||||
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||
|
||||
b.HasIndex("FromMeterId", "ToMeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||
|
||||
b.ToTable("meter_link", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -812,6 +845,27 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("FromMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ToMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||
|
||||
b.Navigation("FromMeter");
|
||||
|
||||
b.Navigation("ToMeter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace MeterVault.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts connector secrets that an operator types into the admin UI, so
|
||||
/// <c>ingestion_endpoint.config</c> holds ciphertext rather than the token itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SDD §6.4 requires that tokens are never in the database as plaintext. Naming an environment
|
||||
/// variable satisfies that but forces a file edit plus a service restart to add a connector, which
|
||||
/// is hostile enough that people paste the token into the name field instead. Encrypting at rest
|
||||
/// keeps the guarantee that matters — a <c>pg_dump</c> or JSON export carries nothing usable — while
|
||||
/// letting the token be entered where it is configured.
|
||||
///
|
||||
/// The key ring lives on disk outside the database, so this protects against leaked database
|
||||
/// content, not against an attacker who already has the host: they can read the keys and decrypt.
|
||||
/// That is the same trust boundary as an env var, which is equally readable from <c>/proc</c>.
|
||||
/// Losing the key ring makes existing secrets undecryptable, and they must be re-entered.
|
||||
/// </remarks>
|
||||
public sealed class SecretProtector
|
||||
{
|
||||
// Changing this string orphans every secret encrypted under the old value.
|
||||
private const string Purpose = "MeterVault.IngestionEndpoint.Secrets.v1";
|
||||
|
||||
private readonly IDataProtector _protector;
|
||||
|
||||
public SecretProtector(IDataProtectionProvider provider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
_protector = provider.CreateProtector(Purpose);
|
||||
}
|
||||
|
||||
public string Protect(string plaintext)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(plaintext);
|
||||
return _protector.Protect(plaintext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a stored secret. Returns false rather than throwing when the ciphertext cannot be
|
||||
/// read — a rotated-away or restored-without-the-key-ring deployment should degrade to "this
|
||||
/// connector has no usable secret" and surface that, not crash an ingestion worker on a timer.
|
||||
/// </summary>
|
||||
public bool TryUnprotect(string? ciphertext, out string? plaintext)
|
||||
{
|
||||
plaintext = null;
|
||||
if (string.IsNullOrWhiteSpace(ciphertext))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
plaintext = _protector.Unprotect(ciphertext);
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
namespace MeterVault.Infrastructure.Update;
|
||||
|
||||
/// <summary>
|
||||
/// A released version, parsed from either the running assembly or a git tag name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not <see cref="System.Version"/>. Tags are written <c>vX.Y.Z</c> while the assembly
|
||||
/// carries <c>X.Y.Z</c> — often with a <c>+commithash</c> suffix from the build — and comparing
|
||||
/// those as strings, or letting System.Version see a 4th component it invents as -1, produces an
|
||||
/// "update available" banner that never clears. Parsing to exactly three numbers makes the
|
||||
/// comparison total and boring.
|
||||
/// </remarks>
|
||||
public readonly record struct ReleaseVersion(int Major, int Minor, int Patch) : IComparable<ReleaseVersion>
|
||||
{
|
||||
public static bool TryParse(string? text, out ReleaseVersion version)
|
||||
{
|
||||
version = default;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = text.Trim();
|
||||
|
||||
// Build metadata ("0.2.0+3f1a9c") and prerelease suffixes ("0.2.0-rc.1") are not part of the
|
||||
// ordering here: a prerelease tag compares equal to its release, so it never nags.
|
||||
var cut = value.IndexOfAny(['+', '-']);
|
||||
if (cut >= 0)
|
||||
{
|
||||
value = value[..cut];
|
||||
}
|
||||
|
||||
value = value.TrimStart('v', 'V');
|
||||
|
||||
var parts = value.Split('.');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major)
|
||||
|| !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
|
||||
|| !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var patch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = new ReleaseVersion(major, minor, patch);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Picks the highest parseable version from a list of tag names, ignoring the rest.</summary>
|
||||
public static bool TryPickLatest(IEnumerable<string?> tagNames, [NotNullWhen(true)] out ReleaseVersion? latest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagNames);
|
||||
|
||||
ReleaseVersion? best = null;
|
||||
foreach (var name in tagNames)
|
||||
{
|
||||
if (TryParse(name, out var parsed) && (best is null || parsed > best.Value))
|
||||
{
|
||||
best = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
latest = best;
|
||||
return best is not null;
|
||||
}
|
||||
|
||||
public int CompareTo(ReleaseVersion other)
|
||||
{
|
||||
var major = Major.CompareTo(other.Major);
|
||||
if (major != 0)
|
||||
{
|
||||
return major;
|
||||
}
|
||||
|
||||
var minor = Minor.CompareTo(other.Minor);
|
||||
return minor != 0 ? minor : Patch.CompareTo(other.Patch);
|
||||
}
|
||||
|
||||
public static bool operator <(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) < 0;
|
||||
|
||||
public static bool operator >(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) > 0;
|
||||
|
||||
public static bool operator <=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) <= 0;
|
||||
|
||||
public static bool operator >=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) >= 0;
|
||||
|
||||
public override string ToString() =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}");
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeterVault.Infrastructure.Update;
|
||||
|
||||
/// <summary>What the instance is running, and what the newest published release is.</summary>
|
||||
/// <param name="Running">Version of the running build, or null if the assembly carries none.</param>
|
||||
/// <param name="Latest">Newest release tag seen, or null if the check has not succeeded.</param>
|
||||
/// <param name="CheckedAt">When the last successful check completed.</param>
|
||||
public sealed record UpdateStatus(ReleaseVersion? Running, ReleaseVersion? Latest, DateTimeOffset? CheckedAt)
|
||||
{
|
||||
/// <summary>True only when both versions are known and the published one is genuinely newer.</summary>
|
||||
public bool UpdateAvailable => Running is { } running && Latest is { } latest && latest > running;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares the running build against the newest tag in the source repository, so an instance can
|
||||
/// say it is behind (SDD §12 releases are driven by the VERSION file).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Singleton with a cached result: the dashboard renders on every navigation and must never wait on,
|
||||
/// or fail because of, a remote call. A failed check keeps serving the last good answer and is
|
||||
/// retried on a short backoff rather than per page view — an instance with no outbound access should
|
||||
/// cost one failed request every few minutes, not one per render.
|
||||
/// </remarks>
|
||||
public sealed class UpdateCheckService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<MeterVaultOptions> options,
|
||||
ILogger<UpdateCheckService> logger)
|
||||
{
|
||||
private static readonly TimeSpan SuccessTtl = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan FailureTtl = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||
private readonly MeterVaultOptions _options = options.Value;
|
||||
private readonly ILogger<UpdateCheckService> _logger = logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
private UpdateStatus _status = new(RunningVersion(), null, null);
|
||||
private DateTimeOffset _nextCheck = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>The version of the running build, or null when the assembly carries no usable one.</summary>
|
||||
/// <remarks>
|
||||
/// Reads MeterVault's own assembly rather than <see cref="Assembly.GetEntryAssembly"/>, which is
|
||||
/// whatever process happens to be hosting — the test runner under <c>dotnet test</c>, whose
|
||||
/// version parses fine and would report a confident, wrong answer. Every project shares the
|
||||
/// VERSION stamp from Directory.Build.props, so this is the release version wherever it runs.
|
||||
/// </remarks>
|
||||
public static ReleaseVersion? RunningVersion()
|
||||
{
|
||||
var informational = typeof(UpdateCheckService).Assembly
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
|
||||
return ReleaseVersion.TryParse(informational, out var version) ? version : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The last known status, without touching the network. Lets a page paint immediately and fill
|
||||
/// the banner in afterwards, instead of holding first render open for the length of an HTTP
|
||||
/// timeout on a cold start.
|
||||
/// </summary>
|
||||
public UpdateStatus Current => _status;
|
||||
|
||||
/// <summary>
|
||||
/// The current status, refreshing at most once per TTL. Never throws: a check that cannot reach
|
||||
/// the repository leaves the banner absent rather than breaking the page that asked.
|
||||
/// </summary>
|
||||
public async Task<UpdateStatus> GetAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.UpdateCheckEnabled || DateTimeOffset.UtcNow < _nextCheck)
|
||||
{
|
||||
return _status;
|
||||
}
|
||||
|
||||
// One caller refreshes; the rest take the cached answer rather than queueing behind it, so a
|
||||
// slow endpoint cannot stack up render-blocking waits.
|
||||
if (!await _gate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return _status;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (DateTimeOffset.UtcNow < _nextCheck)
|
||||
{
|
||||
return _status;
|
||||
}
|
||||
|
||||
var latest = await FetchLatestTagAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is not null)
|
||||
{
|
||||
_status = new UpdateStatus(RunningVersion(), latest, DateTimeOffset.UtcNow);
|
||||
_nextCheck = DateTimeOffset.UtcNow + SuccessTtl;
|
||||
}
|
||||
else
|
||||
{
|
||||
_nextCheck = DateTimeOffset.UtcNow + FailureTtl;
|
||||
}
|
||||
|
||||
return _status;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ReleaseVersion?> FetchLatestTagAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.UpdateCheckUrl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
using var response = await client
|
||||
.GetAsync(new Uri(_options.UpdateCheckUrl), cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Update check returned {Status}", (int)response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Gitea's /tags returns an array of objects with a "name". Order is not guaranteed to be
|
||||
// semver, so take the highest rather than the first.
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var names = document.RootElement.EnumerateArray()
|
||||
.Select(e => e.TryGetProperty("name", out var name) ? name.GetString() : null);
|
||||
|
||||
return ReleaseVersion.TryPickLatest(names, out var latest) ? latest : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or UriFormatException)
|
||||
{
|
||||
// Offline, DNS gone, repository moved, unexpected payload: all mean "cannot tell", which
|
||||
// is a missing banner, not an error the operator needs to see on every page.
|
||||
_logger.LogDebug(ex, "Update check failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Diagnostics;
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeterVault.Infrastructure.Update;
|
||||
|
||||
/// <summary>Why an in-app update cannot be started, or <see cref="Allowed"/> if it can.</summary>
|
||||
public enum UpdateAvailability
|
||||
{
|
||||
Allowed,
|
||||
|
||||
/// <summary>The operator has not set <c>MeterVault__AllowInAppUpdate</c>.</summary>
|
||||
NotEnabled,
|
||||
|
||||
/// <summary>This install has no update mechanism — a container is replaced, not updated in place.</summary>
|
||||
NotSupportedHere,
|
||||
}
|
||||
|
||||
/// <summary>Outcome of trying to launch the updater.</summary>
|
||||
public sealed record UpdateLaunch(bool Started, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Starts the in-container updater on request, gated hard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the most dangerous thing in the codebase, so the reasoning is written down. The updater
|
||||
/// runs <c>git reset --hard</c> and <c>dotnet publish</c> against whatever is on the branch, then
|
||||
/// restarts the service, and in the LXC the app runs as root.
|
||||
///
|
||||
/// <see cref="MeterVaultOptions.AllowInAppUpdate"/> is the only gate, by explicit operator choice —
|
||||
/// no key, no prompt. With it on, anything that can reach the UI can trigger a rebuild-and-restart:
|
||||
/// on the realistic threat model that is a repeatable denial of service (minutes of downtime and a
|
||||
/// pegged CPU per request) rather than code injection, since the build comes from the operator's own
|
||||
/// repository — but it becomes full remote code execution if that repository is ever compromised.
|
||||
/// With it off there is no code path to launch at all, which is why it defaults off and why the
|
||||
/// check is repeated inside <see cref="LaunchAsync"/> rather than trusted to callers.
|
||||
/// </remarks>
|
||||
public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<UpdateRunner> logger)
|
||||
{
|
||||
private const string UpdateCommandPath = "/usr/bin/update";
|
||||
private const string TransientUnit = "metervault-update";
|
||||
|
||||
private readonly MeterVaultOptions _options = options.Value;
|
||||
private readonly ILogger<UpdateRunner> _logger = logger;
|
||||
|
||||
/// <summary>True where an in-place update exists at all — the LXC install, not a container.</summary>
|
||||
public static bool IsSupportedHere => OperatingSystem.IsLinux() && File.Exists(UpdateCommandPath);
|
||||
|
||||
/// <summary>Whether an update could be started, ignoring whether any particular caller may.</summary>
|
||||
public UpdateAvailability Availability
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_options.AllowInAppUpdate)
|
||||
{
|
||||
return UpdateAvailability.NotEnabled;
|
||||
}
|
||||
|
||||
return IsSupportedHere ? UpdateAvailability.Allowed : UpdateAvailability.NotSupportedHere;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Launches the updater detached from this process and returns immediately.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The updater stops and restarts the service, so a child of this process would be killed
|
||||
/// half-way through — leaving the app down with a partially published build. <c>systemd-run</c>
|
||||
/// puts it in its own transient unit, which survives us dying and is what makes "click, wait,
|
||||
/// come back" possible at all. Callers must have checked <see cref="Availability"/> and
|
||||
/// <see cref="IsAuthorised"/> first; this re-checks availability rather than trusting them.
|
||||
/// </remarks>
|
||||
public async Task<UpdateLaunch> LaunchAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Availability is not UpdateAvailability.Allowed)
|
||||
{
|
||||
return new UpdateLaunch(false, $"Update cannot be started: {Availability}.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var start = new ProcessStartInfo("systemd-run")
|
||||
{
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
// --collect reaps the unit when it finishes, so a second update is not blocked by the
|
||||
// corpse of the first.
|
||||
start.ArgumentList.Add("--collect");
|
||||
start.ArgumentList.Add($"--unit={TransientUnit}");
|
||||
start.ArgumentList.Add("--description=MeterVault in-app update");
|
||||
start.ArgumentList.Add(UpdateCommandPath);
|
||||
|
||||
using var process = Process.Start(start);
|
||||
if (process is null)
|
||||
{
|
||||
return new UpdateLaunch(false, "Could not start systemd-run.");
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
var error = (await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).Trim();
|
||||
|
||||
// Most likely an update already running: the unit name is taken until it is collected.
|
||||
_logger.LogWarning("systemd-run failed ({ExitCode}): {Error}", process.ExitCode, error);
|
||||
return new UpdateLaunch(false,
|
||||
string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error);
|
||||
}
|
||||
|
||||
// Deliberately loud. With no key there is no caller to attribute this to, so the log is
|
||||
// the only record that it happened at all — and the update restarts the app, so nothing
|
||||
// held in memory survives.
|
||||
_logger.LogWarning("In-app update started as transient unit {Unit}", TransientUnit);
|
||||
return new UpdateLaunch(true,
|
||||
"Update started. The service restarts when the rebuild finishes — this usually takes a few minutes.");
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not launch the updater");
|
||||
return new UpdateLaunch(false, $"Could not launch the updater: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Core.Normalization;
|
||||
using static MeterVault.Core.Tests.TestData;
|
||||
|
||||
namespace MeterVault.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A counter delta is booked at the reading that closes it. That is correct at the reporting cadence
|
||||
/// and wrong after a long outage, so a gap containing two or more whole months is apportioned.
|
||||
/// The boundary between those two behaviours is what these pin down: a normal monthly series must
|
||||
/// come out byte-for-byte unchanged, because it is what reconciles against the reference spreadsheet.
|
||||
/// </summary>
|
||||
public sealed class GapAttributionTests
|
||||
{
|
||||
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
|
||||
|
||||
[Fact]
|
||||
public void A_monthly_cadence_is_never_split()
|
||||
{
|
||||
// One whole month per interval — the reference-data shape. Splitting here would move energy
|
||||
// between months and break reconciliation (SDD §13).
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2)));
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2).AddDays(-1)));
|
||||
|
||||
// A reading that lands hours late must not tip the rule and hand January a sliver.
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 12), Month(2024, 1).AddHours(6)));
|
||||
|
||||
// Nor should a six-week interval, which still contains only one whole month.
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 2).AddDays(14)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sub_month_intervals_are_never_split()
|
||||
{
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 5), Month(2023, 5).AddHours(1)));
|
||||
Assert.False(GapAttribution.ShouldSplit(Month(2023, 5).AddDays(10), Month(2023, 5).AddDays(20)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_skipped_month_is_split()
|
||||
{
|
||||
Assert.True(GapAttribution.ShouldSplit(Month(2023, 1), Month(2023, 3)));
|
||||
Assert.True(GapAttribution.ShouldSplit(Month(2026, 5), new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Splitting_preserves_the_total_and_keeps_the_closing_timestamp()
|
||||
{
|
||||
var start = Month(2026, 5);
|
||||
var end = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero);
|
||||
|
||||
var segments = GapAttribution.Split(start, end, 714.5);
|
||||
|
||||
// May, June, July.
|
||||
Assert.Equal(3, segments.Count);
|
||||
Assert.Equal(714.5, segments.Sum(s => s.Amount), 6);
|
||||
Assert.Equal(end, segments[^1].Time);
|
||||
Assert.Equal(Month(2026, 6), segments[0].Time);
|
||||
Assert.Equal(Month(2026, 7), segments[1].Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_month_gets_a_share_proportional_to_the_time_it_covers()
|
||||
{
|
||||
// Exactly two whole months: an even split, to the cent.
|
||||
var segments = GapAttribution.Split(Month(2023, 1), Month(2023, 3), 620);
|
||||
|
||||
Assert.Equal(2, segments.Count);
|
||||
var januaryShare = 31d / 59d; // 2023 is not a leap year: Jan 31 + Feb 28.
|
||||
Assert.Equal(620 * januaryShare, segments[0].Amount, 6);
|
||||
Assert.Equal(620, segments.Sum(s => s.Amount), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_gap_in_a_counter_series_is_spread_and_marked_estimated()
|
||||
{
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
|
||||
Readings =
|
||||
[
|
||||
Reading(1, Month(2023, 1), 1000),
|
||||
Reading(1, Month(2023, 4), 1900), // three months in one reading
|
||||
],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
// Baseline row for the first reading, then Jan/Feb/Mar shares of the 900 gap.
|
||||
Assert.Equal(4, result.Count);
|
||||
Assert.Equal(1000 + 900, result.Sum(c => c.Amount), 6);
|
||||
|
||||
var spread = result.Skip(1).ToList();
|
||||
Assert.All(spread, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
|
||||
Assert.Equal(900, spread.Sum(c => c.Amount), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_ordinary_monthly_series_produces_one_measured_row_per_reading()
|
||||
{
|
||||
// The regression that matters: this is the reference-data shape, and it must not gain rows
|
||||
// or lose its quality markers.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
|
||||
Readings =
|
||||
[
|
||||
Reading(1, Month(2022, 9), 0),
|
||||
Reading(1, Month(2022, 10), 411),
|
||||
Reading(1, Month(2022, 11), 1153),
|
||||
Reading(1, Month(2022, 12), 1968),
|
||||
],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
Assert.Equal(4, result.Count);
|
||||
Assert.DoesNotContain(result, c => c.Quality == ReadingQuality.Estimated);
|
||||
Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_observed_solar_gap_is_apportioned_across_the_months_it_covers()
|
||||
{
|
||||
// The case this exists for: Solar 1 read monthly to 1 May 2026, then a single live reading on
|
||||
// 18 July. 714.5 kWh of generation arriving as one July row made June look like an outage.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh" },
|
||||
Readings =
|
||||
[
|
||||
Reading(1, Month(2026, 4), 10308),
|
||||
Reading(1, Month(2026, 5), 10731),
|
||||
Reading(1, new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero), 11445.5),
|
||||
],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
var gap = result.Where(c => c.Time > Month(2026, 5)).ToList();
|
||||
|
||||
Assert.Equal(3, gap.Count);
|
||||
Assert.Equal(714.5, gap.Sum(c => c.Amount), 6);
|
||||
|
||||
// No single month swallows the whole gap any more.
|
||||
Assert.All(gap, c => Assert.True(c.Amount < 714.5 * 0.75, $"{c.Time:yyyy-MM-dd} took {c.Amount:0.#}"));
|
||||
|
||||
// Generation is preserved end to end: baseline 0 → 11445.5.
|
||||
Assert.Equal(11445.5, result.Sum(c => c.Amount), 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_register_across_a_long_gap_does_not_fan_out_into_empty_rows()
|
||||
{
|
||||
// Nothing was used. Three rows of zero say no more than one, and would dilute the
|
||||
// measured/estimated ratio on the detail page.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
|
||||
Readings = [Reading(1, Month(2023, 1), 500), Reading(1, Month(2023, 5), 500)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(0, result[^1].Amount, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_rejected_decrease_across_a_long_gap_stays_a_single_row()
|
||||
{
|
||||
// The decrease branch already yields 0 and rebaselines; spreading that zero would invent
|
||||
// rows for months the meter never reported.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
|
||||
Readings = [Reading(1, Month(2023, 1), 900), Reading(1, Month(2023, 5), 100)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(0, result[^1].Amount, 6);
|
||||
Assert.Equal(Month(2023, 5), result[^1].Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_swap_across_a_long_gap_keeps_its_explicit_amount_in_one_row()
|
||||
{
|
||||
// Swap amounts are corrections booked at the event (the water …861 → 2 case reconciles to
|
||||
// 12). Apportioning one across the gap would silently rewrite a number the operator supplied.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
|
||||
Readings =
|
||||
[
|
||||
Reading(1, Month(2023, 1), 861),
|
||||
Reading(1, Month(2023, 5), 15),
|
||||
],
|
||||
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx).ToList();
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(12, result[^1].Amount, 6);
|
||||
Assert.NotEqual(ReadingQuality.Estimated, result[^1].Quality);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Core.Normalization;
|
||||
using static MeterVault.Core.Tests.TestData;
|
||||
|
||||
namespace MeterVault.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// instant_rate: the reading value is an instantaneous rate (e.g. kW) integrated over time into
|
||||
/// consumption (kWh). Uses the trapezoidal rule between consecutive samples, attributed to the
|
||||
/// interval's end reading — the first reading only seeds the integral.
|
||||
/// </summary>
|
||||
public sealed class InstantRateNormalizerTests
|
||||
{
|
||||
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
|
||||
|
||||
private static DateTimeOffset At(int hour) => new(new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc).AddHours(hour));
|
||||
|
||||
[Fact]
|
||||
public void Constant_rate_integrates_to_rate_times_hours()
|
||||
{
|
||||
// 2 kW held steady for 3 hours → 6 kWh, booked at the end of the interval.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" },
|
||||
Readings = [Reading(1, At(0), 2), Reading(1, At(3), 2)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx);
|
||||
|
||||
var row = Assert.Single(result);
|
||||
Assert.Equal(6d, row.Amount, 6);
|
||||
Assert.Equal(At(3), row.Time);
|
||||
Assert.Equal(ConsumptionKind.Consumption, row.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Linear_ramp_integrates_trapezoidally_per_interval()
|
||||
{
|
||||
// 0 kW → 2 kW → 4 kW at 1-hour steps. Intervals: (0+2)/2·1 = 1, (2+4)/2·1 = 3.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" },
|
||||
Readings = [Reading(1, At(0), 0), Reading(1, At(1), 2), Reading(1, At(2), 4)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx);
|
||||
|
||||
// First reading only seeds the integral: N readings → N−1 rows.
|
||||
Assert.Equal([1d, 3d], result.Select(c => c.Amount));
|
||||
Assert.Equal([At(1), At(2)], result.Select(c => c.Time));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_rate_is_preserved_as_export()
|
||||
{
|
||||
// A bidirectional power sensor reading −4 kW for an hour → −4 kWh (net export), not clamped.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" },
|
||||
Readings = [Reading(1, At(0), -4), Reading(1, At(1), -4)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx);
|
||||
|
||||
Assert.Equal(-4d, Assert.Single(result).Amount, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_reading_produces_no_consumption()
|
||||
{
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" },
|
||||
Readings = [Reading(1, At(0), 5)],
|
||||
};
|
||||
|
||||
Assert.Empty(_engine.Normalize(ctx));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Core.Tests;
|
||||
|
||||
public sealed class MeterMetaTests
|
||||
{
|
||||
[Fact]
|
||||
public void Role_reads_configured_role()
|
||||
{
|
||||
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role("{\"role\":\"grid_import\"}"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("{\"role\":123}")]
|
||||
[InlineData("[1,2,3]")]
|
||||
public void Role_is_null_when_absent_or_malformed(string meta)
|
||||
{
|
||||
Assert.Null(MeterMeta.Role(meta));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRole_sets_role_and_preserves_other_keys()
|
||||
{
|
||||
var updated = MeterMeta.WithRole("{\"expression\":\"a-b\"}", MeterRoles.TotalLoad);
|
||||
|
||||
Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(updated));
|
||||
Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRole_overwrites_existing_role()
|
||||
{
|
||||
var updated = MeterMeta.WithRole("{\"role\":\"grid_import\"}", MeterRoles.GridExport);
|
||||
|
||||
Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(updated));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRole_handles_empty_meta()
|
||||
{
|
||||
var updated = MeterMeta.WithRole("", MeterRoles.GridImport);
|
||||
|
||||
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(updated));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void SetRole_removes_role_when_blank_and_keeps_other_keys(string? role)
|
||||
{
|
||||
var updated = MeterMeta.SetRole("{\"role\":\"grid_import\",\"expression\":\"a-b\"}", role);
|
||||
|
||||
Assert.Null(MeterMeta.Role(updated));
|
||||
Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression"));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,24 @@ public sealed class ApiTests(TimescaleFixture fx)
|
||||
{
|
||||
private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
|
||||
|
||||
[Fact]
|
||||
public async Task Update_endpoint_is_shut_unless_explicitly_enabled()
|
||||
{
|
||||
// Through the real pipeline, not just the runner. 409 rather than 401: the endpoint is
|
||||
// disabled, which is a different fact from the caller being unauthenticated — and the
|
||||
// default install must refuse regardless of what the caller presents.
|
||||
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: true);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/system/update");
|
||||
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
|
||||
request.Headers.Add(MeterVault.App.Api.ApiEndpoints.UpdateRequestHeader, "1");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Readings_push_requires_key_and_writes_when_authorized()
|
||||
{
|
||||
@@ -47,6 +65,7 @@ public sealed class ApiTests(TimescaleFixture fx)
|
||||
{
|
||||
var reading = await db.Readings.SingleAsync(r => r.MeterId == meterId);
|
||||
Assert.Equal(1500, reading.Value, 3);
|
||||
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
|
||||
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeterVault.Infrastructure.Costing;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
using MeterVault.Infrastructure.Import;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -43,6 +44,44 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
|
||||
}
|
||||
|
||||
// Panel read models compute real figures from the reference data (SDD §8.4–§8.6).
|
||||
int hausId;
|
||||
short electricityTypeId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider;
|
||||
var wide = new DateOnly(1997, 1, 1);
|
||||
var toEnd = new DateOnly(2027, 1, 1);
|
||||
|
||||
var solar = await services.GetRequiredService<SolarService>().GetSummaryAsync(wide, toEnd);
|
||||
Assert.True(solar.HasGeneration);
|
||||
Assert.True(solar.Generation > 0);
|
||||
// Haus (total_load) + Netz (grid_import) are role-tagged, so self-consumption/savings resolve.
|
||||
Assert.True(solar.HasLoadContext);
|
||||
Assert.NotNull(solar.SelfConsumption);
|
||||
Assert.NotNull(solar.Savings);
|
||||
|
||||
var consumables = await services.GetRequiredService<ConsumableService>().GetConsumablesAsync(wide, toEnd);
|
||||
var oil = Assert.Single(consumables);
|
||||
Assert.True(oil.CurrentLevel is > 0);
|
||||
Assert.NotEmpty(oil.Deliveries);
|
||||
Assert.True(oil.ConsumptionInRange > 0);
|
||||
|
||||
await using var db = fx.CreateContext();
|
||||
hausId = await db.Meters.Where(m => m.Name == "Zähler Haus").Select(m => m.Id).FirstAsync();
|
||||
var detail = await services.GetRequiredService<MeterDetailService>().GetAsync(hausId);
|
||||
Assert.NotNull(detail);
|
||||
Assert.True(detail!.ReadingCount > 0);
|
||||
Assert.True(detail.TotalConsumption > 0);
|
||||
|
||||
// Flow graph: the demo Haus → Auto chain yields a link + an "Other (Haus)" remainder.
|
||||
electricityTypeId = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync();
|
||||
var flow = await services.GetRequiredService<FlowService>()
|
||||
.GetFlowAsync(electricityTypeId, new DateOnly(1997, 1, 1), new DateOnly(2027, 1, 1));
|
||||
Assert.True(flow.HasChain);
|
||||
Assert.Contains(flow.Nodes, n => n.IsOther);
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var overview = await client.GetAsync(new Uri("/", UriKind.Relative));
|
||||
@@ -55,11 +94,23 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
Assert.Contains("This year", html, StringComparison.Ordinal);
|
||||
Assert.Contains("Latest month with data", html, StringComparison.Ordinal);
|
||||
|
||||
foreach (var path in new[] { "/meters", "/trends", "/import", "/admin/tariffs", "/admin/energy-types" })
|
||||
foreach (var path in new[]
|
||||
{
|
||||
"/meters", "/trends", "/solar", "/consumables", "/import",
|
||||
"/admin/tariffs", "/admin/energy-types", "/admin/categories",
|
||||
"/admin/connectors", "/admin/settings", $"/meters/{hausId}",
|
||||
$"/energy/{electricityTypeId}",
|
||||
})
|
||||
{
|
||||
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
// Manual entry is reachable without an API key or a CSV: the Readings tab of a real
|
||||
// (non-virtual) meter offers it, prefilled with that meter's last register value.
|
||||
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
|
||||
.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Add reading", meterPage, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -70,6 +121,7 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
|
||||
private static async Task ClearDataAsync(MeterVaultDbContext db)
|
||||
{
|
||||
await db.MeterLinks.ExecuteDeleteAsync();
|
||||
await db.Consumption.ExecuteDeleteAsync();
|
||||
await db.Readings.ExecuteDeleteAsync();
|
||||
await db.MeterEvents.ExecuteDeleteAsync();
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full
|
||||
/// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the
|
||||
/// child's consumption proportionally to the parents' own consumption.
|
||||
/// </summary>
|
||||
[Collection("Timescale")]
|
||||
public sealed class FlowServiceTests(TimescaleFixture fx)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Single_parent_chain_makes_other_remainder()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
try
|
||||
{
|
||||
var type = await SeedTypeAsync(db, "flow_elec_a");
|
||||
var main = await AddMeterAsync(db, "Main", type);
|
||||
var car = await AddMeterAsync(db, "Car", type);
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await AddConsumptionAsync(db, main.Id, 100);
|
||||
await AddConsumptionAsync(db, car.Id, 30);
|
||||
|
||||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||
|
||||
Assert.Equal(100, graph.Total, 1);
|
||||
var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}");
|
||||
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
|
||||
var other = Assert.Single(graph.Nodes, n => n.IsOther);
|
||||
Assert.Equal(70, other.Value, 1); // 100 − 30
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_parents_split_child_proportionally()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
try
|
||||
{
|
||||
var type = await SeedTypeAsync(db, "flow_elec_b");
|
||||
var grid = await AddMeterAsync(db, "Grid", type);
|
||||
var solar = await AddMeterAsync(db, "Solar draw", type);
|
||||
var house = await AddMeterAsync(db, "House", type);
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await AddConsumptionAsync(db, grid.Id, 75);
|
||||
await AddConsumptionAsync(db, solar.Id, 25);
|
||||
await AddConsumptionAsync(db, house.Id, 40);
|
||||
|
||||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||
|
||||
// House (40) splits 75:25 → 30 from grid, 10 from solar.
|
||||
Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generation_meter_counts_as_source()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
try
|
||||
{
|
||||
var type = await SeedTypeAsync(db, "flow_elec_c");
|
||||
var grid = await AddMeterAsync(db, "Grid", type);
|
||||
var solar = await AddMeterAsync(db, "Solar", type);
|
||||
var house = await AddMeterAsync(db, "House", type);
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await AddConsumptionAsync(db, grid.Id, 75); // grid import
|
||||
await AddConsumptionAsync(db, solar.Id, 30, ConsumptionKind.Generation); // solar generation
|
||||
await AddConsumptionAsync(db, house.Id, 40); // house load
|
||||
|
||||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||
|
||||
// Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar.
|
||||
Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
// Remainder across grid+solar = (75+30) − 40 = 65 (export + battery/inverter losses).
|
||||
Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Virtual_sum_meter_aggregates_its_upstreams()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
try
|
||||
{
|
||||
var type = await SeedTypeAsync(db, "flow_elec_d");
|
||||
var solar1 = await AddMeterAsync(db, "Solar 1", type);
|
||||
var solar2 = await AddMeterAsync(db, "Solar 2", type);
|
||||
var sumSolar = await AddMeterAsync(db, "Sum Solar", type, MeterMode.Virtual);
|
||||
var grid = await AddMeterAsync(db, "Grid", type);
|
||||
var house = await AddMeterAsync(db, "House", type);
|
||||
// Solar1 + Solar2 → Sum Solar ; Grid + Sum Solar → House.
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = house.Id });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await AddConsumptionAsync(db, solar1.Id, 15, ConsumptionKind.Generation);
|
||||
await AddConsumptionAsync(db, solar2.Id, 15, ConsumptionKind.Generation);
|
||||
await AddConsumptionAsync(db, grid.Id, 75);
|
||||
await AddConsumptionAsync(db, house.Id, 40);
|
||||
|
||||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||
|
||||
// Sum Solar has no readings but equals Solar 1 + Solar 2 = 30.
|
||||
Assert.Equal(30, graph.Nodes.Single(n => n.MeterId == sumSolar.Id).Value, 1);
|
||||
// House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar.
|
||||
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{sumSolar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
// No spurious remainder under Solar 1/2 (their whole output flows into Sum Solar).
|
||||
Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1.Id}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
|
||||
{
|
||||
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
|
||||
db.EnergyTypes.Add(type);
|
||||
await db.SaveChangesAsync();
|
||||
return type.Id;
|
||||
}
|
||||
|
||||
private static async Task<Meter> AddMeterAsync(MeterVaultDbContext db, string name, short type, MeterMode mode = MeterMode.DirectDelta)
|
||||
{
|
||||
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = "kWh" };
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
return meter;
|
||||
}
|
||||
|
||||
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption)
|
||||
{
|
||||
db.Consumption.Add(new Consumption
|
||||
{
|
||||
MeterId = meterId,
|
||||
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
Amount = amount,
|
||||
Kind = kind,
|
||||
Quality = ReadingQuality.Manual,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task ClearAsync(MeterVaultDbContext db)
|
||||
{
|
||||
await db.MeterLinks.ExecuteDeleteAsync();
|
||||
await db.Consumption.ExecuteDeleteAsync();
|
||||
await db.Meters.ExecuteDeleteAsync();
|
||||
await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
@@ -85,4 +85,55 @@ public sealed class ImportRoundTripTests(TimescaleFixture fx)
|
||||
// change tracker (which holds stale entries after the revert's set-based deletes).
|
||||
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_importing_the_same_file_is_refused_by_meter_and_date()
|
||||
{
|
||||
// The commonest real duplicate: import a file, then import an overlapping one. The in-batch
|
||||
// guard cannot see it (that set is internally unique), so left to the database it surfaced as
|
||||
// EF's "An error occurred while saving the entity changes", naming neither meter nor date.
|
||||
await using var db = fx.CreateContext();
|
||||
await DatabaseSeeder.SeedAsync(db);
|
||||
|
||||
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
|
||||
var meter = new Meter
|
||||
{
|
||||
Name = $"reimport-{Guid.NewGuid():N}",
|
||||
EnergyTypeId = type.Id,
|
||||
Mode = MeterMode.CumulativeCounter,
|
||||
Unit = "kWh",
|
||||
};
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var service = new ImportService(db, new NormalizationService(db, NormalizationEngine.CreateDefault()));
|
||||
|
||||
StagedImport Stage() => Staged(meter.Id, new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), 1200);
|
||||
|
||||
await service.CommitAsync(Stage(), "first.csv", mappingJson: null);
|
||||
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.CommitAsync(Stage(), "again.csv", mappingJson: null));
|
||||
|
||||
Assert.Contains("already exist", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("2024-05-01", error.Message, StringComparison.Ordinal);
|
||||
Assert.Contains($"meter {meter.Id}", error.Message, StringComparison.Ordinal);
|
||||
|
||||
await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
private static StagedImport Staged(int meterId, DateTimeOffset time, double value)
|
||||
{
|
||||
var staged = new StagedImport();
|
||||
staged.Readings.Add(new Reading
|
||||
{
|
||||
MeterId = meterId,
|
||||
Time = time,
|
||||
Value = value,
|
||||
Quality = ReadingQuality.Imported,
|
||||
});
|
||||
return staged;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using MeterVault.Infrastructure.Security;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Connector secrets may be stored two ways (SDD §6.4): encrypted at rest after being typed into
|
||||
/// the admin UI, or as the name of an environment variable resolved at runtime. These pin which
|
||||
/// form wins and, more importantly, that neither form ever leaves plaintext in the config JSON.
|
||||
/// </summary>
|
||||
public sealed class EndpointSecretTests
|
||||
{
|
||||
private const string UnsetVar = "METERVAULT_DEFINITELY_UNSET_TOKEN_VAR";
|
||||
|
||||
private static SecretProtector NewProtector() => new(new EphemeralDataProtectionProvider());
|
||||
|
||||
[Fact]
|
||||
public void Encrypted_token_round_trips_and_is_not_plaintext_in_the_config()
|
||||
{
|
||||
var protector = NewProtector();
|
||||
var config = new HaEndpointConfig
|
||||
{
|
||||
BaseUrl = "http://ha.local:8123",
|
||||
TokenEnc = protector.Protect("super-secret-token"),
|
||||
};
|
||||
|
||||
Assert.Equal("super-secret-token", config.ResolveToken(protector));
|
||||
|
||||
// This JSON is what lands in ingestion_endpoint.config, in pg_dump, and in a JSON export.
|
||||
Assert.DoesNotContain("super-secret-token", config.ToJson(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Env_var_is_used_when_no_encrypted_token_is_present()
|
||||
{
|
||||
var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}";
|
||||
Environment.SetEnvironmentVariable(variable, "from-the-environment");
|
||||
try
|
||||
{
|
||||
var config = new HaEndpointConfig { TokenEnv = variable };
|
||||
Assert.Equal("from-the-environment", config.ResolveToken(NewProtector()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encrypted_token_wins_when_both_forms_are_set()
|
||||
{
|
||||
var protector = NewProtector();
|
||||
var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}";
|
||||
Environment.SetEnvironmentVariable(variable, "from-the-environment");
|
||||
try
|
||||
{
|
||||
var config = new HaEndpointConfig
|
||||
{
|
||||
TokenEnv = variable,
|
||||
TokenEnc = protector.Protect("typed-in-the-ui"),
|
||||
};
|
||||
|
||||
Assert.Equal("typed-in-the-ui", config.ResolveToken(protector));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Undecryptable_ciphertext_falls_back_instead_of_throwing()
|
||||
{
|
||||
// A key ring restored without its keys: the worker must degrade, not crash on a timer.
|
||||
var config = new HaEndpointConfig { TokenEnc = "not-valid-ciphertext", TokenEnv = UnsetVar };
|
||||
|
||||
var exception = Record.Exception(() => config.ResolveToken(NewProtector()));
|
||||
|
||||
Assert.Null(exception);
|
||||
Assert.Null(config.ResolveToken(NewProtector()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolving_without_a_protector_still_reads_the_env_var()
|
||||
{
|
||||
// ResolveToken() is called with no protector in unit contexts; the env-var path must work.
|
||||
var variable = $"MV_TEST_TOKEN_{Guid.NewGuid():N}";
|
||||
Environment.SetEnvironmentVariable(variable, "plain-env");
|
||||
try
|
||||
{
|
||||
Assert.Equal("plain-env", new HaEndpointConfig { TokenEnv = variable }.ResolveToken());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mqtt_password_is_encrypted_while_username_stays_readable()
|
||||
{
|
||||
var protector = NewProtector();
|
||||
var config = new EndpointConfig
|
||||
{
|
||||
Host = "broker.local",
|
||||
Username = "metervault",
|
||||
PasswordEnc = protector.Protect("broker-password"),
|
||||
};
|
||||
|
||||
Assert.Equal("metervault", config.ResolveUsername(protector));
|
||||
Assert.Equal("broker-password", config.ResolvePassword(protector));
|
||||
|
||||
var json = config.ToJson();
|
||||
Assert.DoesNotContain("broker-password", json, StringComparison.Ordinal);
|
||||
Assert.Contains("metervault", json, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// The HA connection tester must fail closed on missing config <em>before</em> any network call —
|
||||
/// so a misconfigured connector gives a clear message, never an exception. The stub factory throws
|
||||
/// if the tester ever tries to create an HttpClient, proving these branches never reach the network.
|
||||
/// </summary>
|
||||
public sealed class HaConnectionTesterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Missing_base_url_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(baseUrl: "", token: "a-token", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("Base URL", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_token_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(baseUrl: "http://ha.local:8123", token: "", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("token", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HaConnectionTester NewTester() =>
|
||||
new(new ThrowingHttpClientFactory(), NullLogger<HaConnectionTester>.Instance);
|
||||
|
||||
private sealed class ThrowingHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) =>
|
||||
throw new InvalidOperationException("Network must not be touched for a config-guard failure.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json;
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// The pure Home Assistant WebSocket protocol helpers: URL derivation, the auth/subscribe frames,
|
||||
/// and reading the entity id + numeric value out of a <c>state_changed</c> event (the transport is
|
||||
/// exercised separately). No network — these are the parts that must be provably correct.
|
||||
/// </summary>
|
||||
public sealed class HaWebSocketProtocolTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("http://ha.local:8123", "ws", "ha.local", 8123)]
|
||||
[InlineData("http://ha.local:8123/", "ws", "ha.local", 8123)]
|
||||
[InlineData("https://ha.example.com", "wss", "ha.example.com", 443)]
|
||||
public void WebSocketUri_maps_scheme_and_appends_api_path(string baseUrl, string scheme, string host, int port)
|
||||
{
|
||||
var uri = HaWebSocketProtocol.WebSocketUri(baseUrl);
|
||||
|
||||
Assert.Equal(scheme, uri.Scheme);
|
||||
Assert.Equal(host, uri.Host);
|
||||
Assert.Equal(port, uri.Port);
|
||||
Assert.Equal("/api/websocket", uri.AbsolutePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthMessage_carries_type_and_token()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(HaWebSocketProtocol.AuthMessage("secret-token"));
|
||||
|
||||
Assert.Equal("auth", doc.RootElement.GetProperty("type").GetString());
|
||||
Assert.Equal("secret-token", doc.RootElement.GetProperty("access_token").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeStateChanged_requests_state_changed_events()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(HaWebSocketProtocol.SubscribeStateChanged(7));
|
||||
|
||||
Assert.Equal(7, doc.RootElement.GetProperty("id").GetInt32());
|
||||
Assert.Equal("subscribe_events", doc.RootElement.GetProperty("type").GetString());
|
||||
Assert.Equal("state_changed", doc.RootElement.GetProperty("event_type").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"type\":\"auth_required\",\"ha_version\":\"2024.6\"}", true, false, false)]
|
||||
[InlineData("{\"type\":\"auth_ok\"}", false, true, false)]
|
||||
[InlineData("{\"type\":\"auth_invalid\",\"message\":\"bad token\"}", false, false, true)]
|
||||
public void Auth_message_types_are_recognized(string json, bool required, bool ok, bool invalid)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal(required, HaWebSocketProtocol.IsAuthRequired(root));
|
||||
Assert.Equal(ok, HaWebSocketProtocol.IsAuthOk(root));
|
||||
Assert.Equal(invalid, HaWebSocketProtocol.IsAuthInvalid(root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadStateChanged_extracts_entity_and_numeric_state()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(StateChangedEvent(state: "1234.5"));
|
||||
|
||||
Assert.True(HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out var entityId, out var newState));
|
||||
Assert.Equal("sensor.house_power", entityId);
|
||||
|
||||
var parsed = HaStateClient.ParseStateElement(newState, attribute: null);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(1234.5, parsed!.Value.Value, 3);
|
||||
Assert.Equal(new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.Zero), parsed.Value.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadStateChanged_reads_a_named_attribute()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(StateChangedEvent(state: "on"));
|
||||
|
||||
Assert.True(HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out _, out var newState));
|
||||
// state "on" is non-numeric, but the 'current' attribute is a number.
|
||||
Assert.Null(HaStateClient.ParseStateElement(newState, attribute: null));
|
||||
var byAttribute = HaStateClient.ParseStateElement(newState, attribute: "current");
|
||||
Assert.Equal(42, byAttribute!.Value.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadStateChanged_ignores_removed_entities_and_other_events()
|
||||
{
|
||||
using var removed = JsonDocument.Parse(
|
||||
"{\"type\":\"event\",\"event\":{\"event_type\":\"state_changed\",\"data\":{\"entity_id\":\"sensor.x\",\"new_state\":null}}}");
|
||||
Assert.False(HaWebSocketProtocol.TryReadStateChanged(removed.RootElement, out _, out _));
|
||||
|
||||
using var other = JsonDocument.Parse(
|
||||
"{\"type\":\"event\",\"event\":{\"event_type\":\"call_service\",\"data\":{}}}");
|
||||
Assert.False(HaWebSocketProtocol.TryReadStateChanged(other.RootElement, out _, out _));
|
||||
|
||||
using var result = JsonDocument.Parse("{\"id\":1,\"type\":\"result\",\"success\":true}");
|
||||
Assert.False(HaWebSocketProtocol.TryReadStateChanged(result.RootElement, out _, out _));
|
||||
}
|
||||
|
||||
private static string StateChangedEvent(string state) => $$"""
|
||||
{
|
||||
"id": 1,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": "state_changed",
|
||||
"data": {
|
||||
"entity_id": "sensor.house_power",
|
||||
"new_state": {
|
||||
"entity_id": "sensor.house_power",
|
||||
"state": "{{state}}",
|
||||
"attributes": { "unit_of_measurement": "W", "current": 42 },
|
||||
"last_updated": "2024-06-15T10:00:00+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end proof of the HA WebSocket push path against an in-process fake Home Assistant server:
|
||||
/// the worker performs the auth handshake, subscribes to <c>state_changed</c>, and a pushed change
|
||||
/// for a configured entity lands as a <c>reading</c>. This exercises the real ClientWebSocket
|
||||
/// transport and handshake sequencing that the pure-protocol unit tests can't.
|
||||
/// </summary>
|
||||
[Collection("Timescale")]
|
||||
public sealed class HomeAssistantWebSocketWorkerTests(TimescaleFixture fx)
|
||||
{
|
||||
private const string TokenEnvVar = "MV_TEST_HA_WS_TOKEN";
|
||||
|
||||
[Fact]
|
||||
public async Task Pushed_state_change_becomes_a_reading()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
Environment.SetEnvironmentVariable(TokenEnvVar, "test-token");
|
||||
await using var fake = await FakeHaServer.StartAsync(entityId: "sensor.house_power", state: "4711");
|
||||
|
||||
try
|
||||
{
|
||||
var type = new EnergyType { Key = "ha_ws_test", DisplayName = "HA WS", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
|
||||
db.EnergyTypes.Add(type);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var meter = new Meter { Name = "HA WS Meter", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var endpoint = new IngestionEndpoint
|
||||
{
|
||||
Type = EndpointType.HomeAssistant,
|
||||
Name = "Fake HA",
|
||||
IsEnabled = true,
|
||||
Config = new HaEndpointConfig { BaseUrl = fake.BaseUrl, TokenEnv = TokenEnvVar, UseWebSocket = true }.ToJson(),
|
||||
};
|
||||
db.IngestionEndpoints.Add(endpoint);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.MeterSources.Add(new MeterSource
|
||||
{
|
||||
MeterId = meter.Id,
|
||||
SourceType = SourceType.HomeAssistant,
|
||||
EndpointId = endpoint.Id,
|
||||
IsEnabled = true,
|
||||
Config = JsonSerializer.Serialize(new { entityId = "sensor.house_power" }),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await using var provider = BuildProvider(fx.ConnectionString);
|
||||
var worker = new HomeAssistantWebSocketWorker(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
provider.GetRequiredService<MeterVault.Infrastructure.Security.SecretProtector>(),
|
||||
NullLogger<HomeAssistantWebSocketWorker>.Instance);
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
Reading? reading = null;
|
||||
for (var i = 0; i < 60 && reading is null; i++)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
reading = await db.Readings.AsNoTracking().FirstOrDefaultAsync(r => r.MeterId == meter.Id);
|
||||
}
|
||||
|
||||
Assert.NotNull(reading);
|
||||
Assert.Equal(4711, reading!.Value, 3);
|
||||
Assert.Equal(ReadingQuality.Measured, reading.Quality);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(TokenEnvVar, null);
|
||||
await db.Consumption.ExecuteDeleteAsync();
|
||||
await db.Readings.ExecuteDeleteAsync();
|
||||
await db.MeterSources.ExecuteDeleteAsync();
|
||||
await db.IngestionEndpoints.ExecuteDeleteAsync();
|
||||
await db.Meters.ExecuteDeleteAsync();
|
||||
await db.EnergyTypes.Where(t => t.Key == "ha_ws_test").ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildProvider(string connectionString)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContextFactory<MeterVaultDbContext>(o => o
|
||||
.UseNpgsql(connectionString, n => n.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
|
||||
.UseSnakeCaseNamingConvention());
|
||||
services.AddScoped<MeterVaultDbContext>(sp => sp.GetRequiredService<IDbContextFactory<MeterVaultDbContext>>().CreateDbContext());
|
||||
services.AddScoped<IngestionService>();
|
||||
// Ingestion derives consumption inline, so the normalizer has to be resolvable here too.
|
||||
services.AddSingleton<MeterVault.Core.Normalization.INormalizationEngine>(
|
||||
_ => MeterVault.Core.Normalization.NormalizationEngine.CreateDefault());
|
||||
services.AddScoped<MeterVault.Infrastructure.Normalization.NormalizationService>();
|
||||
// Ephemeral keys: this test's token comes from an env var, so nothing needs to outlive the run.
|
||||
services.AddSingleton<Microsoft.AspNetCore.DataProtection.IDataProtectionProvider>(
|
||||
new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider());
|
||||
services.AddSingleton<MeterVault.Infrastructure.Security.SecretProtector>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>A minimal Home Assistant WebSocket server: handshake, then push one state_changed event.</summary>
|
||||
private sealed class FakeHaServer : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
|
||||
private FakeHaServer(WebApplication app, string baseUrl)
|
||||
{
|
||||
_app = app;
|
||||
BaseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public string BaseUrl { get; }
|
||||
|
||||
public static async Task<FakeHaServer> StartAsync(string entityId, string state)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
var app = builder.Build();
|
||||
app.UseWebSockets();
|
||||
|
||||
var eventJson =
|
||||
"{\"id\":1,\"type\":\"event\",\"event\":{\"event_type\":\"state_changed\",\"data\":{\"entity_id\":\""
|
||||
+ entityId + "\",\"new_state\":{\"entity_id\":\"" + entityId + "\",\"state\":\"" + state
|
||||
+ "\",\"attributes\":{},\"last_updated\":\"2024-06-15T10:00:00+00:00\"}}}}";
|
||||
|
||||
app.Map("/api/websocket", async context =>
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = 400;
|
||||
return;
|
||||
}
|
||||
|
||||
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||
await SendAsync(ws, """{"type":"auth_required","ha_version":"2024.6"}""");
|
||||
await ReceiveAsync(ws); // client "auth"
|
||||
await SendAsync(ws, """{"type":"auth_ok"}""");
|
||||
await ReceiveAsync(ws); // client "subscribe_events"
|
||||
await SendAsync(ws, """{"id":1,"type":"result","success":true}""");
|
||||
await SendAsync(ws, eventJson);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, context.RequestAborted);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// client/test closed — expected.
|
||||
}
|
||||
});
|
||||
|
||||
await app.StartAsync();
|
||||
var address = app.Services.GetRequiredService<IServer>().Features
|
||||
.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
return new FakeHaServer(app, address);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
||||
|
||||
private static Task SendAsync(WebSocket ws, string json) =>
|
||||
ws.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
|
||||
|
||||
private static async Task ReceiveAsync(WebSocket ws)
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
await ws.ReceiveAsync(buffer, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
@@ -17,7 +17,7 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter, scale: 0.001, offset: 0);
|
||||
var service = new IngestionService(db);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
// 1000 raw × 0.001 = 1.0.
|
||||
Assert.Equal(IngestionOutcome.Written, await service.IngestAsync(sourceId, T0, 1000));
|
||||
@@ -38,7 +38,7 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = new IngestionService(db);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
await service.IngestAsync(sourceId, T0, 500);
|
||||
var outcome = await service.IngestAsync(sourceId, T0.AddHours(1), 400); // decrease, no event
|
||||
@@ -54,7 +54,7 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = new IngestionService(db);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
// A reset early on explains an early decrease...
|
||||
await service.IngestAsync(sourceId, T0, 100);
|
||||
@@ -76,7 +76,7 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = new IngestionService(db);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
await service.IngestAsync(sourceId, T0, 500);
|
||||
db.MeterEvents.Add(new MeterEvent
|
||||
@@ -100,10 +100,13 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
public async Task Mqtt_router_ingests_a_tasmota_payload()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter, topic: "tele/plug7/SENSOR");
|
||||
var router = new MqttMessageRouter(db, new IngestionService(db), NullLogger<MqttMessageRouter>.Instance);
|
||||
var brokerId = await CreateBrokerAsync(db);
|
||||
var (meterId, _) = await SetupAsync(
|
||||
db, MeterMode.CumulativeCounter, topic: "tele/plug7/SENSOR", endpointId: brokerId);
|
||||
var router = new MqttMessageRouter(db, NewIngestion(db), NullLogger<MqttMessageRouter>.Instance);
|
||||
|
||||
var routed = await router.RouteAsync(
|
||||
brokerId,
|
||||
"tele/plug7/SENSOR",
|
||||
"""{"Time":"2024-03-01T10:00:00","ENERGY":{"Total":8421.0}}""");
|
||||
|
||||
@@ -115,9 +118,157 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mqtt_router_ignores_a_source_bound_to_another_broker()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var brokerA = await CreateBrokerAsync(db);
|
||||
var brokerB = await CreateBrokerAsync(db);
|
||||
|
||||
// Topic filter that both brokers' traffic would match — the binding is the only thing
|
||||
// separating them.
|
||||
var (meterId, _) = await SetupAsync(
|
||||
db, MeterMode.CumulativeCounter, topic: "tele/+/SENSOR", endpointId: brokerB);
|
||||
var router = new MqttMessageRouter(db, NewIngestion(db), NullLogger<MqttMessageRouter>.Instance);
|
||||
|
||||
var routed = await router.RouteAsync(
|
||||
brokerA,
|
||||
"tele/plug7/SENSOR",
|
||||
"""{"Time":"2024-03-01T10:00:00","ENERGY":{"Total":8421.0}}""");
|
||||
|
||||
Assert.Equal(0, routed);
|
||||
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId));
|
||||
|
||||
// Same message on the broker it is actually bound to does land.
|
||||
Assert.Equal(1, await router.RouteAsync(
|
||||
brokerB,
|
||||
"tele/plug7/SENSOR",
|
||||
"""{"Time":"2024-03-01T10:00:00","ENERGY":{"Total":8421.0}}"""));
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ingesting_a_reading_derives_consumption_without_a_separate_recompute()
|
||||
{
|
||||
// Regression: live ingestion used to write only the raw reading, so consumption/generation
|
||||
// stayed frozen at the last import until something else recomputed the meter.
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
await service.IngestAsync(sourceId, T0, 1000);
|
||||
await service.IngestAsync(sourceId, T0.AddHours(1), 1250);
|
||||
|
||||
var consumption = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId)
|
||||
.OrderBy(c => c.Time)
|
||||
.ToListAsync();
|
||||
|
||||
// The first reading is anchored against the meter's baseline (0), so it contributes 1000;
|
||||
// what proves the fix is the second reading's 250 delta being there at all.
|
||||
Assert.Equal(2, consumption.Count);
|
||||
Assert.Equal(250d, consumption.Single(c => c.Time == T0.AddHours(1)).Amount, 3);
|
||||
Assert.Equal(1250d, consumption.Sum(c => c.Amount), 3);
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_batch_can_defer_normalization_and_derive_the_same_series_once_at_the_end()
|
||||
{
|
||||
// Recomputing rewrites a meter's whole consumption series, so the batch endpoint skips it
|
||||
// per reading and does it once. The result must be identical to normalizing as it goes.
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
for (var hour = 0; hour < 5; hour++)
|
||||
{
|
||||
await service.IngestByMeterAsync(meterId, T0.AddHours(hour), 1000 + (hour * 10), renormalize: false);
|
||||
}
|
||||
|
||||
Assert.False(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
|
||||
|
||||
await service.RenormalizeMeterAsync(meterId);
|
||||
|
||||
var consumption = await db.Consumption.AsNoTracking().Where(c => c.MeterId == meterId).ToListAsync();
|
||||
Assert.Equal(5, consumption.Count);
|
||||
Assert.Equal(1040d, consumption.Sum(c => c.Amount), 3); // baseline 0 → 1000, then 4 × 10
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hand_entered_reading_is_stamped_manual_and_normalizes_immediately()
|
||||
{
|
||||
// The meter-detail "Add reading" path: provenance has to survive, otherwise a value somebody
|
||||
// walked to the meter to read is indistinguishable from one a sensor reported.
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
var written = await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
|
||||
|
||||
Assert.Equal(IngestionOutcome.Written, written);
|
||||
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
|
||||
Assert.Equal(ReadingQuality.Manual, reading.Quality);
|
||||
Assert.True(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
|
||||
|
||||
// Correcting a typo re-enters the same timestamp: value replaced, still manual.
|
||||
var updated = await service.IngestByMeterAsync(meterId, T0, 1100, quality: ReadingQuality.Manual);
|
||||
|
||||
Assert.Equal(IngestionOutcome.Updated, updated);
|
||||
var corrected = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
|
||||
Assert.Equal(1100d, corrected.Value, 6);
|
||||
Assert.Equal(ReadingQuality.Manual, corrected.Quality);
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_source_reporting_the_same_timestamp_does_not_relabel_a_hand_entered_reading()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
|
||||
await service.IngestAsync(sourceId, T0, 1200); // same instant, this time from the broker
|
||||
|
||||
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
|
||||
Assert.Equal(1200d, reading.Value, 6); // the newer value still wins...
|
||||
Assert.Equal(ReadingQuality.Manual, reading.Quality); // ...but provenance is not silently rewritten
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_hand_entered_decrease_on_a_counter_is_rejected_like_any_other()
|
||||
{
|
||||
// The dialog warns before saving, but the guard is what actually protects the series: a
|
||||
// mistyped register must not silently wipe out a month of consumption.
|
||||
await using var db = fx.CreateContext();
|
||||
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var service = NewIngestion(db);
|
||||
|
||||
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
|
||||
var outcome = await service.IngestByMeterAsync(
|
||||
meterId, T0.AddDays(30), 100, quality: ReadingQuality.Manual);
|
||||
|
||||
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
|
||||
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == T0.AddDays(30)));
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
private static IngestionService NewIngestion(MeterVaultDbContext db) =>
|
||||
new(db, new MeterVault.Infrastructure.Normalization.NormalizationService(
|
||||
db, MeterVault.Core.Normalization.NormalizationEngine.CreateDefault()));
|
||||
|
||||
private static async Task<(int MeterId, int SourceId)> SetupAsync(
|
||||
MeterVaultDbContext db, MeterMode mode, double scale = 1, double offset = 0,
|
||||
string topic = "tele/x/SENSOR", string? path = "ENERGY.Total")
|
||||
string topic = "tele/x/SENSOR", string? path = "ENERGY.Total", int? endpointId = null)
|
||||
{
|
||||
await DatabaseSeeder.SeedAsync(db);
|
||||
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
|
||||
@@ -136,6 +287,7 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
{
|
||||
MeterId = meter.Id,
|
||||
SourceType = SourceType.Tasmota,
|
||||
EndpointId = endpointId ?? await CreateBrokerAsync(db),
|
||||
ValueKind = SourceValueKind.Register,
|
||||
Scale = scale,
|
||||
Offset = offset,
|
||||
@@ -147,10 +299,25 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
|
||||
return (meter.Id, source.Id);
|
||||
}
|
||||
|
||||
private static async Task<int> CreateBrokerAsync(MeterVaultDbContext db)
|
||||
{
|
||||
var endpoint = new IngestionEndpoint
|
||||
{
|
||||
Type = EndpointType.MqttBroker,
|
||||
Name = $"broker-{Guid.NewGuid():N}",
|
||||
Config = """{"host":"localhost","port":1883}""",
|
||||
};
|
||||
db.IngestionEndpoints.Add(endpoint);
|
||||
await db.SaveChangesAsync();
|
||||
return endpoint.Id;
|
||||
}
|
||||
|
||||
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
|
||||
{
|
||||
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
|
||||
await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync();
|
||||
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
|
||||
await db.IngestionEndpoints.Where(e => e.Name.StartsWith("broker-")).ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Costing;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
using MeterVault.Infrastructure.Options;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The meter-detail headline numbers: month/year totals bucketed in the instance timezone, and the
|
||||
/// generation-vs-consumption split that decides which of the two a meter reports.
|
||||
/// </summary>
|
||||
[Collection("Timescale")]
|
||||
public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Buckets_by_local_month_and_compares_with_the_previous_one()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var thisMonth = new DateOnly(today.Year, today.Month, 1);
|
||||
var lastMonth = thisMonth.AddMonths(-1);
|
||||
|
||||
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), 30);
|
||||
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, lastMonth.AddDays(3), 100);
|
||||
|
||||
var view = await NewService().GetAsync(meterId);
|
||||
|
||||
Assert.NotNull(view);
|
||||
Assert.Equal("Consumption", view!.Label);
|
||||
Assert.Equal(30d, view.MonthToDate, 3);
|
||||
Assert.Equal(100d, view.LastMonth, 3);
|
||||
Assert.Equal(130d, view.YearToDate, 3);
|
||||
|
||||
// Projection scales the partial month up, so it must be at least what has already happened.
|
||||
Assert.True(view.MonthProjected >= view.MonthToDate);
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_generation_counter_reports_generation_not_consumption()
|
||||
{
|
||||
// Regression: a PV meter showed "0 kWh consumption", which is true and useless.
|
||||
await using var db = fx.CreateContext();
|
||||
var meterId = await SetupAsync(db, MeterMode.GenerationCounter);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
await AddConsumptionAsync(
|
||||
db, meterId, ConsumptionKind.Generation, new DateOnly(today.Year, today.Month, 1).AddDays(1), 42);
|
||||
|
||||
var view = await NewService().GetAsync(meterId);
|
||||
|
||||
Assert.NotNull(view);
|
||||
Assert.Equal("Generation", view!.Label);
|
||||
Assert.Equal(42d, view.MonthToDate, 3);
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_meter_with_no_consumption_yields_an_empty_history_rather_than_a_flat_line()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
|
||||
var view = await NewService().GetAsync(meterId);
|
||||
|
||||
Assert.NotNull(view);
|
||||
Assert.False(view!.HasHistory);
|
||||
Assert.Empty(view.Last12Months);
|
||||
Assert.Null(view.MonthChange); // no previous month to divide by
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_virtual_meter_reports_nothing_rather_than_a_confident_zero()
|
||||
{
|
||||
// Virtual meters evaluate on read and only materialize when a cost category references them
|
||||
// (SDD §14.1). Summing `consumption` would render four zero tiles for a working meter.
|
||||
await using var db = fx.CreateContext();
|
||||
var meterId = await SetupAsync(db, MeterMode.Virtual);
|
||||
|
||||
Assert.Null(await NewService().GetAsync(meterId));
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_negative_previous_period_reports_no_basis_rather_than_an_inverted_percentage()
|
||||
{
|
||||
// Net export: -100 -> -150 is half again as much exported, but dividing by a negative
|
||||
// baseline would render it "+50%", which reads as more consumption.
|
||||
await using var db = fx.CreateContext();
|
||||
var meterId = await SetupAsync(db, MeterMode.CumulativeCounter);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var thisMonth = new DateOnly(today.Year, today.Month, 1);
|
||||
|
||||
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddDays(1), -150);
|
||||
await AddConsumptionAsync(db, meterId, ConsumptionKind.Consumption, thisMonth.AddMonths(-1).AddDays(3), -100);
|
||||
|
||||
var view = await NewService().GetAsync(meterId);
|
||||
|
||||
Assert.NotNull(view);
|
||||
Assert.Null(view!.MonthChange);
|
||||
|
||||
await CleanupAsync(db, meterId);
|
||||
}
|
||||
|
||||
private MeterPeriodService NewService()
|
||||
{
|
||||
var options = Microsoft.Extensions.Options.Options.Create(
|
||||
new MeterVaultOptions { TimeZone = "Europe/Berlin", Currency = "EUR" });
|
||||
return new MeterPeriodService(fx, new CostService(fx), options);
|
||||
}
|
||||
|
||||
private static async Task<int> SetupAsync(MeterVaultDbContext db, MeterMode mode)
|
||||
{
|
||||
await DatabaseSeeder.SeedAsync(db);
|
||||
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
|
||||
|
||||
var meter = new Meter
|
||||
{
|
||||
Name = $"period-{Guid.NewGuid():N}",
|
||||
EnergyTypeId = type.Id,
|
||||
Mode = mode,
|
||||
Unit = "kWh",
|
||||
};
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
return meter.Id;
|
||||
}
|
||||
|
||||
private static async Task AddConsumptionAsync(
|
||||
MeterVaultDbContext db, int meterId, ConsumptionKind kind, DateOnly day, double amount)
|
||||
{
|
||||
db.Consumption.Add(new Consumption
|
||||
{
|
||||
MeterId = meterId,
|
||||
// Midday local, so the row cannot drift into an adjacent month through the UTC offset.
|
||||
Time = new DateTimeOffset(day.Year, day.Month, day.Day, 12, 0, 0, TimeSpan.Zero),
|
||||
Kind = kind,
|
||||
Amount = amount,
|
||||
Quality = ReadingQuality.Measured,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
|
||||
{
|
||||
await db.Consumption.Where(c => c.MeterId == meterId).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ public sealed class MeterVaultAppFactory(string connectionString, bool configure
|
||||
builder.UseSetting("ConnectionStrings:Default", connectionString);
|
||||
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
|
||||
builder.UseSetting("MeterVault:EnableLiveIngestion", "false");
|
||||
// No outbound calls from tests: the update check would otherwise hit the real Gitea on every
|
||||
// page render, making the suite slow and dependent on that host being up.
|
||||
builder.UseSetting("MeterVault:UpdateCheckEnabled", "false");
|
||||
if (configureApiKey)
|
||||
{
|
||||
builder.UseSetting("MeterVault:ApiKeys:0", ApiKey);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user