Compare commits
31
Commits
v0.1.0
...
c0f52dbb6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0f52dbb6f | ||
|
|
aacdc28d70 | ||
|
|
0319e5527f | ||
|
|
bfa0b537ee | ||
|
|
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/
|
.git/
|
||||||
.github/
|
.github/
|
||||||
docs/
|
docs/
|
||||||
sampledata/
|
|
||||||
tests/
|
tests/
|
||||||
**/*.user
|
**/*.user
|
||||||
**/appsettings.*.Local.json
|
**/appsettings.*.Local.json
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
<InvariantGlobalization>false</InvariantGlobalization>
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
</PropertyGroup>
|
</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. -->
|
<!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. -->
|
||||||
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
|
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" 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>
|
||||||
|
|
||||||
<ItemGroup Label="App / UI">
|
<ItemGroup Label="App / UI">
|
||||||
|
|||||||
@@ -22,39 +22,137 @@ full design.
|
|||||||
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
||||||
- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw.
|
- **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/
|
- **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).
|
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
|
||||||
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
||||||
|
|
||||||
|
## Upgrading to 0.3.0
|
||||||
|
|
||||||
|
This release changes where consumption lands. Back the database up first (`pg_dump`), then start the
|
||||||
|
new image once and let it finish:
|
||||||
|
|
||||||
|
- **The first start re-derives all stored consumption** before the web server listens. The app is
|
||||||
|
unreachable while it runs (Compose may report the container unhealthy after ~105 s) — let it finish
|
||||||
|
rather than killing it. Progress and any meter it could not rebuild are logged.
|
||||||
|
- **Figures change once.** Consumption between two readings is now attributed to the months it
|
||||||
|
accrued in instead of landing entirely on the later reading, so historical months and their costs
|
||||||
|
can shift; rows that had to be divided are marked *estimated*.
|
||||||
|
- **Imported monthly tables are marked as such** in place before anything is recomputed, so they keep
|
||||||
|
reconciling. If that step fails nothing is rebuilt and the whole upgrade simply runs again next
|
||||||
|
start.
|
||||||
|
- **Check the log once** for `had its dates auto-detected`: a CSV imported through the wizard with the
|
||||||
|
date format left on auto-detect is treated as a monthly table when all its rows sit on the 1st.
|
||||||
|
If such a sheet really was day-dated, revert that batch on `/import` and import it again with the
|
||||||
|
day format.
|
||||||
|
- **Rolling back** to 0.2.0 leaves the re-attributed consumption in place; it is re-derived under the
|
||||||
|
old rules only as each meter next ingests a reading.
|
||||||
|
|
||||||
## Quick start (Docker)
|
## Quick start (Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml up -d
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
# open http://localhost:8080 → Import → "Load reference data" for a populated demo
|
# open http://localhost:8760 → Import → "Load reference data" for a populated demo
|
||||||
# API docs at http://localhost:8080/swagger
|
# ...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.:
|
Configuration is via environment variables (`Section__Key` double-underscore mapping), e.g.:
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
|
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
|
||||||
| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) |
|
| `MeterVault__TimeZone` | IANA timezone for buckets, display **and month attribution** (default `Europe/Berlin`). Changing it re-derives every meter's stored consumption at the next start, and historical monthly figures can shift. It must be an id both .NET and PostgreSQL know; anything else falls back to UTC and is reported in the log. |
|
||||||
|
| `MeterVault__Locale` | Default UI language, `en` or `de` (default `en`). Each visitor can switch it from the app bar; the choice is remembered in a cookie. |
|
||||||
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
|
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
|
||||||
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
|
| `MeterVault__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__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
||||||
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
| `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
|
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).
|
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*
|
> **The web UI has no authentication.** There is no login: anything that can reach the port can read
|
||||||
of an environment variable, resolved at runtime.
|
> 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)
|
## Pushing readings (Home Assistant)
|
||||||
|
|
||||||
```bash
|
```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" \
|
-H "X-Api-Key: $METERVAULT_API_KEY" -H "Content-Type: application/json" \
|
||||||
-d '[{"meterId": 1, "time": "2026-01-01T12:00:00Z", "value": 47200}]'
|
-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/
|
COPY src/App/MeterVault.App.csproj src/App/
|
||||||
RUN dotnet restore src/App/MeterVault.App.csproj
|
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 src/ ./src/
|
||||||
|
COPY sampledata/ ./sampledata/
|
||||||
RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false
|
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
|
# 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 \
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=build /app/publish ./
|
COPY --from=build /app/publish ./
|
||||||
ENV ASPNETCORE_URLS=http://+:8080 \
|
ENV ASPNETCORE_URLS=http://+:8760 \
|
||||||
ASPNETCORE_ENVIRONMENT=Production \
|
ASPNETCORE_ENVIRONMENT=Production \
|
||||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||||
EXPOSE 8080
|
EXPOSE 8760
|
||||||
ENTRYPOINT ["dotnet", "MeterVault.App.dll"]
|
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}"
|
||||||
@@ -30,16 +30,26 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}"
|
ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}"
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
|
# Buckets, display AND month attribution. Changing it re-derives stored consumption at the next
|
||||||
|
# start; must be an IANA id both .NET and PostgreSQL know.
|
||||||
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
||||||
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
||||||
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
|
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):
|
# 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__ApiKeys__0: your-secret-key
|
||||||
# MeterVault__AllowAnonymousApi: "true"
|
# MeterVault__AllowAnonymousApi: "true"
|
||||||
|
volumes:
|
||||||
|
- metervault_keys:/var/lib/metervault/keys
|
||||||
ports:
|
ports:
|
||||||
- "${METERVAULT_PORT:-8080}:8080"
|
- "${METERVAULT_PORT:-8760}:8760"
|
||||||
healthcheck:
|
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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -48,3 +58,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
metervault_db:
|
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,16 +11,20 @@
|
|||||||
<Privileged>false</Privileged>
|
<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>
|
<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>
|
<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>
|
<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>
|
<Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config>
|
||||||
|
|
||||||
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing/display" Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
|
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing, display and month attribution. Changing it re-derives stored consumption at the next start." Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
|
||||||
|
|
||||||
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank to leave the API open." Type="Variable" Display="always" Required="false" Mask="true"/>
|
<Config Name="Language" Target="MeterVault__Locale" Default="en" Mode="" Description="Default UI language: en or de. Each visitor can switch it in the app bar." Type="Variable" Display="always" Required="false">en</Config>
|
||||||
|
|
||||||
|
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank and the API stays closed (401)." 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="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>
|
</Container>
|
||||||
|
|||||||
+15
-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.
|
- Ship the four reference CSVs as built-in example imports and as test fixtures.
|
||||||
|
|
||||||
### 6.4 Secrets
|
### 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,12 @@ Tokens/passwords are **never** stored in plaintext in the DB. `ingestion_endpoin
|
|||||||
### 7.1 Register → consumption
|
### 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).
|
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).
|
||||||
|
|
||||||
|
**Month attribution.** A reading is an instant, and the consumption between two readings accrued over the time between them. Booking the whole delta at the closing reading misfiles it whenever the interval crosses a month boundary: readings on 1 August and 16 September would show six weeks of use in September and none in August. So a plain increase whose interval crosses one or more **local** month boundaries (instance timezone, §10 — the months the charts bucket by) is divided at those boundaries in proportion to elapsed time. Each share is stamped inside its month — the closing reading keeps its own timestamp for the month it falls in, other shares take the last second of their month — and a divided interval's rows are marked `quality = estimated`: the meter recorded a total, not a shape. The parts always sum to the original.
|
||||||
|
|
||||||
|
Imported monthly tables keep the golden fixtures reconciling (§13). A row labelled "Mai 2026" carries the register at the *end* of May and May's consumption, but is stamped 00:00 UTC on 1 May so it files under its month. The importer flags such a row `reading.flags & month_label` — only it still knows whether the date cell named a month or a day, and a day-dated "01.08.2026" at the same midnight is an ordinary instant. A month label is read as the end of its month: readings are walked in that effective order by every register normalizer and by the checks that judge a new reading against its predecessor (so a sheet imported after live readings of the same month does not count the month twice, and a mid-month reading below the month's end value is not a decrease), consecutive rows span exactly their closing month and book unchanged, a skipped month is shared between the months the gap covers, and a live reading after the last imported row counts from the end of that row's month rather than claiming it a second time. A label is stamped inside the month it names — at its own timestamp where that lies in the local month, otherwise (zones behind UTC) at the month's local start. Swap and reset amounts are never divided: they are explicit corrections booked at the event. A swap the importer detected at a month row applies from the start of that local month, i.e. to the first reading in it, and a new register's recorded start value never counts above that reading. Should two rows still land on the same instant, the engine adds them into one estimated row rather than producing a duplicate key.
|
||||||
|
|
||||||
|
Every reader that buckets consumption by month or day (cost, trends, solar, consumables, flow, meter detail) buckets in the configured instance timezone — the same zone the division uses — never a hard-coded one, and starts and ends requested periods at local midnight. The zone id is normalised to its IANA form, and one unknown to .NET or PostgreSQL is reported at startup. Because consumption is derived, a change to these rules is applied to stored data at startup: `app_setting.normalization_revision` and `normalization_zone` record the rule revision and zone the stored series was built with, and every non-virtual meter is recomputed when either differs (the first run also flags month rows of earlier monthly imports, identified from each batch's stored mapping; a batch whose dates were auto-detected counts as monthly when all its rows sit on the 1st across at least two months, which is logged; if the flagging fails, nothing is rebuilt and the upgrade is retried at the next start). A meter whose rebuild fails is logged and listed in `normalization_pending`, retried at the next start, and never stops the application from starting.
|
||||||
|
|
||||||
### 7.2 Runtime → consumption (burner)
|
### 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.
|
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 +503,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')`.
|
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
- **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.
|
- **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:
|
and a `HomeAssistant` source on the meter:
|
||||||
|
|
||||||
```json
|
```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
|
Set `HA_TOKEN` (a long-lived access token) in the environment, or type the token into the connector
|
||||||
`attribute`) is read every `pollSeconds`; `unavailable`/`unknown` states are skipped.
|
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
|
**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.
|
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.Ingestion;
|
||||||
using MeterVault.Infrastructure.Normalization;
|
using MeterVault.Infrastructure.Normalization;
|
||||||
using MeterVault.Infrastructure.Persistence;
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using MeterVault.Infrastructure.Update;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace MeterVault.App.Api;
|
namespace MeterVault.App.Api;
|
||||||
@@ -24,6 +25,9 @@ public static class ApiEndpoints
|
|||||||
{
|
{
|
||||||
private const int MaxReadingsPerRequest = 5000;
|
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)
|
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
|
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;
|
int written = 0, updated = 0, rejected = 0, ignored = 0;
|
||||||
|
var touched = new HashSet<int>();
|
||||||
foreach (var r in readings)
|
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.Written: written++; touched.Add(r.MeterId); break;
|
||||||
case IngestionOutcome.Updated: updated++; break;
|
case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break;
|
||||||
case IngestionOutcome.RejectedDecrease: rejected++; break;
|
case IngestionOutcome.RejectedDecrease: rejected++; break;
|
||||||
default: ignored++; break; // unknown meter
|
default: ignored++; break; // unknown meter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var meterId in touched)
|
||||||
|
{
|
||||||
|
await ingestion.RenormalizeMeterAsync(meterId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
|
return Results.Ok(new IngestResult(written, updated, rejected, ignored));
|
||||||
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
|
}).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) =>
|
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
|
||||||
Results.Ok(await db.Meters.AsNoTracking()
|
Results.Ok(await db.Meters.AsNoTracking()
|
||||||
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
|
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace MeterVault.App.Api;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
|
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
|
||||||
/// When no keys are configured the API is open — intended only for local development.
|
/// With no keys configured the API is closed, unless <c>AllowAnonymousApi</c> opts into an open one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
|
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
@using System.Globalization
|
||||||
<html lang="en">
|
@using Microsoft.AspNetCore.Localization
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
@@ -10,11 +13,11 @@
|
|||||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||||
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
|
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
<HeadOutlet />
|
<HeadOutlet @rendermode="InteractiveServer" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<Routes />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
@@ -23,3 +26,26 @@
|
|||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
// Pin whatever the middleware negotiated (Accept-Language, or the configured default) into the
|
||||||
|
// culture cookie on the very first visit. Without this the language picker would be the only
|
||||||
|
// thing that ever writes the cookie, so a reader whose browser asked for German would be served
|
||||||
|
// German until the moment they touched the picker — and the picker would open showing English.
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
HttpContext?.Response.Cookies.Append(
|
||||||
|
CookieRequestCultureProvider.DefaultCookieName,
|
||||||
|
CookieRequestCultureProvider.MakeCookieValue(
|
||||||
|
new RequestCulture(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)),
|
||||||
|
new CookieOptions
|
||||||
|
{
|
||||||
|
Path = "/",
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
HttpOnly = true,
|
||||||
|
IsEssential = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@using System.Globalization
|
||||||
@using MeterVault.App.Theme
|
@using MeterVault.App.Theme
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
|
||||||
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
|
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
@@ -8,10 +11,29 @@
|
|||||||
|
|
||||||
<MudLayout>
|
<MudLayout>
|
||||||
<MudAppBar Elevation="1" Dense="true">
|
<MudAppBar Elevation="1" Dense="true">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||||
|
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="@S.Layout_ToggleNavigation" />
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
||||||
<MudText Typo="Typo.h6">MeterVault</MudText>
|
<MudText Typo="Typo.h6">MeterVault</MudText>
|
||||||
<MudSpacer />
|
<MudSpacer />
|
||||||
<MudTooltip Text="@(_darkMode ? "Light mode" : "Dark mode")">
|
<MudTooltip Text="@S.Layout_FindMeter">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Search" Color="Color.Inherit" OnClick="OpenMeterSearchAsync"
|
||||||
|
aria-label="@S.Layout_FindMeter" />
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="@S.Layout_Language">
|
||||||
|
<MudMenu Icon="@Icons.Material.Filled.Translate" Color="Color.Inherit"
|
||||||
|
AriaLabel="@S.Layout_Language" Dense="true">
|
||||||
|
@foreach (var culture in Loc.SupportedCultures)
|
||||||
|
{
|
||||||
|
<MudMenuItem OnClick="@(() => SwitchCulture(culture))"
|
||||||
|
Icon="@(culture == _current ? Icons.Material.Filled.Check : null)"
|
||||||
|
IconColor="Color.Primary">
|
||||||
|
@Loc.DisplayName(culture)
|
||||||
|
</MudMenuItem>
|
||||||
|
}
|
||||||
|
</MudMenu>
|
||||||
|
</MudTooltip>
|
||||||
|
<MudTooltip Text="@(_darkMode ? S.Layout_LightMode : S.Layout_DarkMode)">
|
||||||
<MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
|
<MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
|
||||||
Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" />
|
Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" />
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
@@ -29,12 +51,43 @@
|
|||||||
</MudLayout>
|
</MudLayout>
|
||||||
|
|
||||||
<div id="blazor-error-ui" data-nosnippet>
|
<div id="blazor-error-ui" data-nosnippet>
|
||||||
An unhandled error has occurred.
|
@S.Layout_UnhandledError
|
||||||
<a href="." class="reload">Reload</a>
|
<a href="." class="reload">@S.Layout_Reload</a>
|
||||||
<span class="dismiss">🗙</span>
|
<span class="dismiss">🗙</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool _drawerOpen = true;
|
private bool _drawerOpen = true;
|
||||||
private bool _darkMode = true;
|
private bool _darkMode = true;
|
||||||
|
|
||||||
|
private string _current = Loc.SupportedCultures[0];
|
||||||
|
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
Loc.TryResolve(CultureInfo.CurrentUICulture.Name, out _current);
|
||||||
|
|
||||||
|
// A circuit is stuck with the culture it was opened under, so changing language is a real
|
||||||
|
// navigation: the endpoint writes the cookie and forceLoad tears the circuit down so the
|
||||||
|
// reload comes back translated. Returning to the current path keeps the reader in place.
|
||||||
|
private async Task OpenMeterSearchAsync() =>
|
||||||
|
await DialogService.ShowAsync<MeterSearchDialog>(S.Layout_FindMeter, new DialogOptions
|
||||||
|
{
|
||||||
|
MaxWidth = MaxWidth.Small,
|
||||||
|
FullWidth = true,
|
||||||
|
CloseButton = true,
|
||||||
|
CloseOnEscapeKey = true,
|
||||||
|
Position = DialogPosition.TopCenter,
|
||||||
|
});
|
||||||
|
|
||||||
|
private void SwitchCulture(string culture)
|
||||||
|
{
|
||||||
|
if (culture == _current)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var here = new Uri(Navigation.Uri).GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped);
|
||||||
|
Navigation.NavigateTo(
|
||||||
|
$"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri={Uri.EscapeDataString(here)}",
|
||||||
|
forceLoad: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,101 @@
|
|||||||
|
@implements IDisposable
|
||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject NavState NavState
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MeterVault.Core.Domain
|
||||||
|
|
||||||
|
@* Grouped by what the user came to do: look at the numbers, work with meters and data (readings,
|
||||||
|
swaps and imports all start from a meter or an import), or configure the instance. *@
|
||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">@S.Nav_Overview</MudNavLink>
|
||||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">@S.Nav_Trends</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>
|
<MudText Typo="Typo.overline" Color="Color.Secondary" Class="d-block px-4 pt-3">@S.Nav_SectionData</MudText>
|
||||||
|
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">@S.Nav_Meters</MudNavLink>
|
||||||
|
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudNavLink>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary" Class="d-block px-4 pt-3">@S.Nav_SectionEnergy</MudText>
|
||||||
|
@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">@S.Nav_Solar</MudNavLink>
|
||||||
|
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">@S.Nav_Consumables</MudNavLink>
|
||||||
|
|
||||||
<MudDivider Class="my-2" />
|
<MudDivider Class="my-2" />
|
||||||
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
@* Opens by itself on an admin page — a bookmark, a reload, a language switch or a link from
|
||||||
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
|
elsewhere would otherwise land with the current page folded away out of sight. *@
|
||||||
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
|
<MudNavGroup Title="@S.Nav_Admin" Icon="@Icons.Material.Filled.Settings" @bind-Expanded="_adminExpanded">
|
||||||
|
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">@S.Nav_EnergyTypes</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">@S.Nav_Tariffs</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">@S.Nav_CostCategories</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">@S.Nav_Connectors</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">@S.Nav_Settings</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<EnergyType> _energyTypes = [];
|
||||||
|
private bool _adminExpanded;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_adminExpanded = IsAdminPage(Navigation.Uri);
|
||||||
|
Navigation.LocationChanged += OnLocationChanged;
|
||||||
|
NavState.EnergyTypesChanged += OnEnergyTypesChanged;
|
||||||
|
await LoadEnergyTypesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadEnergyTypesAsync()
|
||||||
|
{
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsAdminPage(string uri) =>
|
||||||
|
Navigation.ToBaseRelativePath(uri).StartsWith("admin/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Opens only: collapsing it again when the user leaves is their call, not the menu's.
|
||||||
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_adminExpanded && IsAdminPage(e.Location))
|
||||||
|
{
|
||||||
|
_adminExpanded = true;
|
||||||
|
_ = InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnergyTypesChanged() =>
|
||||||
|
_ = InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
await LoadEnergyTypesAsync();
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Navigation.LocationChanged -= OnLocationChanged;
|
||||||
|
NavState.EnergyTypesChanged -= OnEnergyTypesChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the energy type's stored icon name to a Material icon; fall back to a bolt.
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,25 +7,25 @@
|
|||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="components-reconnect-first-attempt-visible">
|
<p class="components-reconnect-first-attempt-visible">
|
||||||
Rejoining the server...
|
@S.Reconnect_Rejoining
|
||||||
</p>
|
</p>
|
||||||
<p class="components-reconnect-repeated-attempt-visible">
|
<p class="components-reconnect-repeated-attempt-visible">
|
||||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
@S.Reconnect_RetryCountdownPrefix <span id="components-seconds-to-next-attempt"></span> @S.Reconnect_RetryCountdownSuffix
|
||||||
</p>
|
</p>
|
||||||
<p class="components-reconnect-failed-visible">
|
<p class="components-reconnect-failed-visible">
|
||||||
Failed to rejoin.<br />Please retry or reload the page.
|
@S.Reconnect_RejoinFailed<br />@S.Reconnect_RetryOrReload
|
||||||
</p>
|
</p>
|
||||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||||
Retry
|
@S.Reconnect_Retry
|
||||||
</button>
|
</button>
|
||||||
<p class="components-pause-visible">
|
<p class="components-pause-visible">
|
||||||
The session has been paused by the server.
|
@S.Reconnect_Paused
|
||||||
</p>
|
</p>
|
||||||
<p class="components-resume-failed-visible">
|
<p class="components-resume-failed-visible">
|
||||||
Failed to resume the session.<br />Please retry or reload the page.
|
@S.Reconnect_ResumeFailed<br />@S.Reconnect_RetryOrReload
|
||||||
</p>
|
</p>
|
||||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||||
Resume
|
@S.Reconnect_Resume
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -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 — @S.Nav_CostCategories</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">@S.Nav_CostCategories</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
@S.Categories_AddCategory
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_categories is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@S.Common_Name</MudTh>
|
||||||
|
<MudTh>@S.Categories_Sort</MudTh>
|
||||||
|
<MudTh>@S.Categories_Members</MudTh>
|
||||||
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="@S.Common_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="@S.Categories_Sort">@context.Sort</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Categories_Members">@MemberSummary(context)</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_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 ? S.Categories_NewCategory : Loc.F(S.Categories_EditCategory, _working.Name))</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.ColorHex" Label="@S.Categories_ColorHex" Class="mb-2" />
|
||||||
|
<MudNumericField T="int" @bind-Value="_working.Sort" Label="@S.Categories_SortOrder" Class="mb-2" />
|
||||||
|
|
||||||
|
@if (_working.Id != 0)
|
||||||
|
{
|
||||||
|
<MudDivider Class="my-3" />
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">@S.Categories_Members</MudText>
|
||||||
|
@if (_members.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Categories_NoMembers</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="@S.Categories_AddMeter" 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">@S.Categories_Add</MudButton>
|
||||||
|
<MudSelect T="int?" @bind-Value="_addTypeId" Label="@S.Categories_AddEnergyType" 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">@S.Categories_Add</MudButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Categories_SaveFirst</MudAlert>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">@S.Categories_Close</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_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 ? "—" : Loc.F(S.Categories_MemberSummary, meters, types);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MemberLabel(CostCategoryMember m) =>
|
||||||
|
m.MeterId is { } meterId ? Loc.F(S.Categories_MemberMeter, _meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}")
|
||||||
|
: m.EnergyTypeId is { } typeId ? Loc.F(S.Categories_MemberType, _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(S.Common_NameRequired, 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(S.Categories_SavedAddMembers, 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(S.Common_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, S.Categories_DeleteTitle,
|
||||||
|
Loc.F(S.Categories_DeleteBody, category.Name, category.Members.Count)))
|
||||||
|
{
|
||||||
|
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(S.Common_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,577 @@
|
|||||||
|
@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
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — @S.Nav_Connectors</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">@S.Nav_Connectors</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
@S.Connectors_AddConnector
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_returnTo is { } back)
|
||||||
|
{
|
||||||
|
@* Arrived from a meter's source dialog: the way back is always in view, saved or not. *@
|
||||||
|
<MudAlert Severity="Severity.Normal" Variant="Variant.Outlined" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sensors">
|
||||||
|
@Loc.F(S.Connectors_ForMeter, back.MeterName) <MudLink Href="@MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType)">@Loc.F(S.Connectors_BackToMeter, back.MeterName)</MudLink>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||||
|
@S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
@if (_endpoints is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>@S.Common_Name</MudTh>
|
||||||
|
<MudTh>@S.Common_Type</MudTh>
|
||||||
|
<MudTh>@S.Common_Enabled</MudTh>
|
||||||
|
<MudTh>@S.Connectors_UsedBy</MudTh>
|
||||||
|
<MudTh>@S.Connectors_LastStatus</MudTh>
|
||||||
|
<MudTh>@S.Common_LastSeen</MudTh>
|
||||||
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="@S.Common_Name">@context.Name</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_Type">@context.Type.Display()</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_Enabled">@(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No)</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Connectors_UsedBy">
|
||||||
|
@if (_usage.TryGetValue(context.Id, out var users))
|
||||||
|
{
|
||||||
|
@foreach (var user in users.Take(UsersShown))
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Detail(user.MeterId, MeterLinks.TabSources)" Class="mr-2">@user.MeterName</MudLink>
|
||||||
|
}
|
||||||
|
@if (users.Count > UsersShown)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Inline="true">@Loc.F(S.Connectors_UsedByMore, users.Count - UsersShown)</MudText>
|
||||||
|
}
|
||||||
|
@if (!context.IsEnabled)
|
||||||
|
{
|
||||||
|
@* Workers skip disabled connectors, so every source on it has stopped. *@
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Warning" Class="d-block">@S.Connectors_DisabledInUse</MudText>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Connectors_Unused</MudText>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Connectors_LastStatus">@(context.LastStatus ?? "—")</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_LastSeen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_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">@S.Connectors_Empty</MudAlert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Connectors_NewConnector : Loc.F(S.Connectors_EditTitle, _working.Name))</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="@S.Common_Type" Class="mb-2">
|
||||||
|
@foreach (var type in Enum.GetValues<EndpointType>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="EndpointType" Value="type">@type.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||||
|
|
||||||
|
@if (_working.Type == EndpointType.HomeAssistant)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.BaseUrl" Label="@S.Connectors_BaseUrl" Class="mb-2" />
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="@S.Connectors_EnterTokenHere" Color="Color.Primary" Class="mb-1" />
|
||||||
|
@if (_working.UseDirectToken)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
|
||||||
|
Label="@(_working.HasStoredToken ? S.Connectors_TokenStored : S.Connectors_Token)" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
|
@S.Connectors_EncryptedHint
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.TokenEnv" Label="@S.Connectors_TokenEnv" Class="mb-1" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
|
@S.Connectors_TokenEnvHintPrefix <em>@S.Connectors_TokenEnvHintEmphasis</em>@S.Connectors_TokenEnvHintSuffix
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="@S.Connectors_WebSocketPush" Color="Color.Primary" Class="mb-1" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
|
@S.Connectors_WebSocketHint
|
||||||
|
</MudText>
|
||||||
|
<MudTextField @bind-Value="_working.TestEntityId" Label="@S.Connectors_TestEntityId" Class="mb-2" />
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
||||||
|
@S.Connectors_TestConnection
|
||||||
|
</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">@TestText(_testResult)</MudAlert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.Host" Label="@S.Connectors_Host" Class="mb-2" />
|
||||||
|
<MudNumericField T="int" @bind-Value="_working.Port" Label="@S.Connectors_Port" Class="mb-2" />
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="@S.Connectors_Tls" Color="Color.Primary" Class="mb-2" />
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="@S.Connectors_EnterCredentialsHere" Color="Color.Primary" Class="mb-1" />
|
||||||
|
@if (_working.UseDirectCredentials)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.Username" Label="@S.Connectors_UsernameOptional" Class="mb-1" />
|
||||||
|
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
|
||||||
|
Label="@(_working.HasStoredPassword ? S.Connectors_PasswordStored : S.Connectors_PasswordOptional)" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||||
|
@S.Connectors_EncryptedHint
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.UsernameEnv" Label="@S.Connectors_UsernameEnv" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.PasswordEnv" Label="@S.Connectors_PasswordEnv" Class="mb-2" />
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_working.ExtraTopics" Label="@S.Connectors_ExtraTopics" Class="mb-2" />
|
||||||
|
}
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Opens a new connector of this <see cref="EndpointType"/> once the page is interactive.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "new")]
|
||||||
|
public string? NewParam { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Opens this connector for editing once the page is interactive.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "edit")]
|
||||||
|
public int? EditParam { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The meter whose source dialog sent the user here; see <see cref="MeterLinks.NewConnector"/>.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "meter")]
|
||||||
|
public int? MeterParam { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
|
||||||
|
public int? SourceParam { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
|
||||||
|
public string? SourceTypeParam { get; set; }
|
||||||
|
|
||||||
|
private const int UsersShown = 3;
|
||||||
|
|
||||||
|
private List<IngestionEndpoint>? _endpoints;
|
||||||
|
private Dictionary<int, List<ConnectorUser>> _usage = [];
|
||||||
|
private ReturnTarget? _returnTo;
|
||||||
|
private (EndpointType? New, int? Edit)? _pendingOpen;
|
||||||
|
private bool _droppingOpen;
|
||||||
|
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();
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (MeterParam != _returnTo?.MeterId)
|
||||||
|
{
|
||||||
|
_returnTo = null;
|
||||||
|
if (MeterParam is { } meterId)
|
||||||
|
{
|
||||||
|
// Resolved against the database, so the way back names a meter that exists — and only
|
||||||
|
// ever leads to a meter page, whatever the query string says.
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var name = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => m.Name).FirstOrDefaultAsync();
|
||||||
|
if (name is not null)
|
||||||
|
{
|
||||||
|
_returnTo = new ReturnTarget(meterId, name, SourceParam, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_returnTo is not null)
|
||||||
|
{
|
||||||
|
_returnTo = _returnTo with
|
||||||
|
{
|
||||||
|
SourceId = SourceParam,
|
||||||
|
SourceType = Enum.TryParse<SourceType>(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
|
||||||
|
{
|
||||||
|
_pendingOpen = (Enum.TryParse<EndpointType>(NewParam, ignoreCase: true, out var type) ? type : null, EditParam);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_droppingOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a deep-linked connector dialog. The request is dropped from the address first and the dialog
|
||||||
|
/// opened once that navigation has come back — the order the meter page uses, for the same reason: a
|
||||||
|
/// circuit's first location change dismisses any dialog already open.
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnAfterRender(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_pendingOpen is not { } open || _endpoints is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
|
||||||
|
{
|
||||||
|
if (!_droppingOpen)
|
||||||
|
{
|
||||||
|
_droppingOpen = true;
|
||||||
|
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["new"] = null,
|
||||||
|
["edit"] = null,
|
||||||
|
}), replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingOpen = null;
|
||||||
|
if (open.Edit is { } editId && _endpoints.FirstOrDefault(e => e.Id == editId) is { } endpoint)
|
||||||
|
{
|
||||||
|
OpenEdit(endpoint);
|
||||||
|
}
|
||||||
|
else if (open.New is { } newType)
|
||||||
|
{
|
||||||
|
OpenEdit(null);
|
||||||
|
_working.Type = newType;
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||||
|
var users = await db.MeterSources.AsNoTracking()
|
||||||
|
.Where(s => s.EndpointId != null)
|
||||||
|
.Select(s => new { EndpointId = s.EndpointId!.Value, s.MeterId, MeterName = s.Meter!.Name })
|
||||||
|
.ToListAsync();
|
||||||
|
_usage = users
|
||||||
|
.GroupBy(u => u.EndpointId)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.DistinctBy(u => u.MeterId).OrderBy(u => u.MeterName).Select(u => new ConnectorUser(u.MeterId, u.MeterName)).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
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, S.Connectors_TestBaseUrlChanged);
|
||||||
|
}
|
||||||
|
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, S.Connectors_TestNoToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
|
||||||
|
{
|
||||||
|
_testResult = new HaTestResult(false, S.Connectors_TestNoTokenEnv);
|
||||||
|
}
|
||||||
|
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
|
||||||
|
{
|
||||||
|
_testResult = new HaTestResult(false,
|
||||||
|
Loc.F(S.Connectors_TestEnvVarMissing, _working.TokenEnv));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_testResult = await HaTester.TestAsync(_working.BaseUrl, envToken, _working.TestEntityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.Common_NameRequired, Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_working.Type == EndpointType.HomeAssistant
|
||||||
|
&& _working.UseDirectToken
|
||||||
|
&& string.IsNullOrWhiteSpace(_working.Token)
|
||||||
|
&& !_working.HasStoredToken)
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.Connectors_TokenRequired, 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();
|
||||||
|
IngestionEndpoint saved;
|
||||||
|
if (_working.Id == 0)
|
||||||
|
{
|
||||||
|
saved = new IngestionEndpoint
|
||||||
|
{
|
||||||
|
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
|
||||||
|
};
|
||||||
|
db.IngestionEndpoints.Add(saved);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
saved = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
||||||
|
saved.Type = _working.Type;
|
||||||
|
saved.Name = _working.Name.Trim();
|
||||||
|
saved.Config = config;
|
||||||
|
saved.IsEnabled = _working.IsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_editOpen = false;
|
||||||
|
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||||
|
|
||||||
|
// Back to the source that was waiting for this connector, with it picked — when it can serve that
|
||||||
|
// source. Anything else (disabled, or of another kind) keeps the user here, the way back in view.
|
||||||
|
if (_returnTo is { } back
|
||||||
|
&& saved.IsEnabled
|
||||||
|
&& (back.SourceType is not { } sourceType || SourceRouting.Serves(saved.Type, sourceType)))
|
||||||
|
{
|
||||||
|
Nav.NavigateTo(MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType, saved.Id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? " " + Loc.F(S.Connectors_DeleteInUseNote, sourceCount)
|
||||||
|
: "";
|
||||||
|
if (!await Confirm.DeleteAsync(
|
||||||
|
DialogService, S.Connectors_DeleteTitle, Loc.F(S.Connectors_DeleteBody, endpoint.Name) + note))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
||||||
|
Snackbar.Add(S.Common_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 record ConnectorUser(int MeterId, string MeterName);
|
||||||
|
|
||||||
|
/// <summary>The meter source a user came here to set up a connector for.</summary>
|
||||||
|
private sealed record ReturnTarget(int MeterId, string MeterName, int? SourceId, SourceType? SourceType);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HaTestResult.Message stays English for the log; the reader gets the verdict in their own
|
||||||
|
// language. Anything the connector page decided for itself (Precondition) already carries its
|
||||||
|
// own localized wording, and HA's own diagnostics — an HTTP status, an exception — are passed
|
||||||
|
// through untranslated because they are not ours to reword.
|
||||||
|
private static string TestText(HaTestResult result) => result.Outcome switch
|
||||||
|
{
|
||||||
|
HaTestOutcome.Connected => S.Connectors_TestConnected,
|
||||||
|
HaTestOutcome.ConnectedWithValue => Loc.F(
|
||||||
|
S.Connectors_TestConnectedValue,
|
||||||
|
result.EntityId ?? string.Empty,
|
||||||
|
result.SampleValue is { } value ? Format.Number(value, 2) : string.Empty),
|
||||||
|
HaTestOutcome.BaseUrlMissing => S.Connectors_TestBaseUrlRequired,
|
||||||
|
HaTestOutcome.TokenMissing => S.Connectors_TestTokenMissing,
|
||||||
|
HaTestOutcome.HttpError => Loc.F(S.Connectors_TestHttpError, result.Detail ?? string.Empty),
|
||||||
|
HaTestOutcome.NoNumericState => Loc.F(S.Connectors_TestNoNumericState, result.EntityId ?? string.Empty),
|
||||||
|
HaTestOutcome.RequestFailed => Loc.F(S.Connectors_TestFailed, result.Detail ?? string.Empty),
|
||||||
|
_ => result.Message,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
@page "/admin/energy-types"
|
@page "/admin/energy-types"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject NavState NavState
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Energy types</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_EnergyTypes</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">@S.Nav_EnergyTypes</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
@S.EnergyTypes_Add
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_types is null)
|
@if (_types is null)
|
||||||
{
|
{
|
||||||
@@ -15,26 +23,165 @@ else
|
|||||||
{
|
{
|
||||||
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
|
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Key</MudTh>
|
<MudTh>@S.EnergyTypes_Key</MudTh>
|
||||||
<MudTh>Display name</MudTh>
|
<MudTh>@S.EnergyTypes_DisplayName</MudTh>
|
||||||
<MudTh>Base unit</MudTh>
|
<MudTh>@S.EnergyTypes_BaseUnit</MudTh>
|
||||||
<MudTh>Default mode</MudTh>
|
<MudTh>@S.EnergyTypes_DefaultMode</MudTh>
|
||||||
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
<MudTd DataLabel="@S.EnergyTypes_Key">@context.Key</MudTd>
|
||||||
<MudTd DataLabel="Display name">@context.DisplayName</MudTd>
|
<MudTd DataLabel="@S.EnergyTypes_DisplayName">
|
||||||
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||||
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
{
|
||||||
|
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||||
|
}
|
||||||
|
@context.DisplayName
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="@S.EnergyTypes_BaseUnit">@context.BaseUnit</MudTd>
|
||||||
|
<MudTd DataLabel="@S.EnergyTypes_DefaultMode">@context.DefaultMode.Display()</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_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>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.EnergyTypes_NewTitle : Loc.F(S.EnergyTypes_EditTitle, _working.DisplayName))</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_working.Key" Label="@S.EnergyTypes_KeyLabel" Required="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.DisplayName" Label="@S.EnergyTypes_DisplayName" Required="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.BaseUnit" Label="@S.EnergyTypes_BaseUnitLabel" Required="true" Class="mb-2" />
|
||||||
|
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.EnergyTypes_DefaultMode" Class="mb-2">
|
||||||
|
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField @bind-Value="_working.Icon" Label="@S.EnergyTypes_IconLabel" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.ColorHex" Label="@S.EnergyTypes_ColorLabel" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<EnergyType>? _types;
|
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();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
_types = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
_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(S.EnergyTypes_RequiredFields, 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(Loc.F(S.EnergyTypes_KeyInUse, _working.Key), 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(S.Common_Saved, Severity.Success);
|
||||||
|
NavState.NotifyEnergyTypesChanged();
|
||||||
|
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(Loc.F(S.EnergyTypes_DeleteBlocked, type.DisplayName, meterCount), Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, S.EnergyTypes_DeleteTitle, Loc.F(S.EnergyTypes_DeleteBody, type.DisplayName)))
|
||||||
|
{
|
||||||
|
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(S.Common_Deleted, Severity.Success);
|
||||||
|
NavState.NotifyEnergyTypesChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,71 @@
|
|||||||
|
@page "/admin/settings"
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — @S.Nav_Settings</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-2">@S.Nav_Settings</MudText>
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||||
|
@S.Settings_EffectiveLead <b>@S.Settings_EffectiveEmphasis</b> @S.Settings_EffectiveRest
|
||||||
|
(<code>MeterVault__Key</code> / <code>Section__Key</code>) @S.Settings_EffectiveTail
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Settings_LocaleAndTime</MudText>
|
||||||
|
<MudSimpleTable Dense="true">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>@S.Settings_Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
||||||
|
<tr><td>@S.Settings_Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||||
|
<tr><td>@S.Common_Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
||||||
|
<tr><td>@S.Settings_RawRetention</td><td style="text-align:right">@Loc.F(S.Settings_RetentionDays, _o.RawRetentionDays)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||||
|
@S.Settings_EnvKeysLabel <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">@S.Settings_AccessAndIngestion</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">@Loc.F(S.Settings_ApiKeysConfigured, _o.ApiKeys.Count)</MudChip>
|
||||||
|
}
|
||||||
|
else if (_o.AllowAnonymousApi)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.Settings_ApiOpen</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Settings_ApiClosed</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td>@S.Settings_ReverseProxyTrust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||||
|
<tr><td>@S.Settings_LiveIngestionWorkers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||||
|
<tr><td>@S.Settings_SeedReferenceData</td><td style="text-align:right">@(_o.SeedReferenceData ? S.Settings_On : S.Settings_Off)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||||
|
@S.Settings_ApiKeysHintBefore <code>MeterVault__ApiKeys__0</code>. @S.Settings_ApiKeysHintAfter
|
||||||
|
@S.Settings_ApiDocsLabel <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,50 +1,231 @@
|
|||||||
@page "/admin/tariffs"
|
@page "/admin/tariffs"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Tariffs</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_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">@S.Nav_Tariffs</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
@S.Tariffs_AddTariff
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_tariffs is null)
|
@if (_tariffs is null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
<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
|
else
|
||||||
{
|
{
|
||||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Scope</MudTh>
|
<MudTh>@S.Common_Scope</MudTh>
|
||||||
<MudTh>Component</MudTh>
|
<MudTh>@S.Tariffs_Component</MudTh>
|
||||||
<MudTh>Value</MudTh>
|
<MudTh>@S.Common_Value</MudTh>
|
||||||
<MudTh>Unit</MudTh>
|
<MudTh>@S.Common_Unit</MudTh>
|
||||||
<MudTh>Valid from</MudTh>
|
<MudTh>@S.Tariffs_ValidFrom</MudTh>
|
||||||
<MudTh>Valid to</MudTh>
|
<MudTh>@S.Tariffs_ValidTo</MudTh>
|
||||||
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Scope">@context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "")</MudTd>
|
<MudTd DataLabel="@S.Common_Scope">@ScopeLabel(context)</MudTd>
|
||||||
<MudTd DataLabel="Component">@context.Component</MudTd>
|
<MudTd DataLabel="@S.Tariffs_Component">@context.Component.Display()</MudTd>
|
||||||
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
<MudTd DataLabel="@S.Common_Value">@Format.Number(context.Value, 4)</MudTd>
|
||||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
<MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
|
||||||
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
<MudTd DataLabel="@S.Tariffs_ValidFrom">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||||
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
<MudTd DataLabel="@S.Tariffs_ValidTo">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? S.Tariffs_OpenEnded)</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_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>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
|
@if (_tariffs.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_EmptyState <MudLink Href="/import">@S.Nav_Import</MudLink>.</MudAlert>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff)</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="@S.Common_Scope" Class="mb-2">
|
||||||
|
@foreach (var scope in Enum.GetValues<TariffScope>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="TariffScope" Value="scope">@scope.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (_working.ScopeType == TariffScope.EnergyType)
|
||||||
|
{
|
||||||
|
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_EnergyType" 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="@S.Common_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="@S.Tariffs_Component" Class="mb-2">
|
||||||
|
@foreach (var component in Enum.GetValues<TariffComponent>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="TariffComponent" Value="component">@component.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudNumericField T="double" @bind-Value="_working.Value" Label="@S.Common_Value" Format="0.####" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Unit" Label="@S.Tariffs_UnitLabel" Required="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Currency" Label="@S.Common_Currency" Class="mb-2" />
|
||||||
|
<MudDatePicker @bind-Date="_working.ValidFrom" Label="@S.Tariffs_ValidFrom" Class="mb-2" />
|
||||||
|
<MudDatePicker @bind-Date="_working.ValidTo" Label="@S.Tariffs_ValidToLabel" Clearable="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Notes" Label="@S.Tariffs_NotesLabel" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Tariff>? _tariffs;
|
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();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
_tariffs = await db.Tariffs.AsNoTracking()
|
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
|
||||||
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||||
.ToListAsync();
|
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
||||||
|
{
|
||||||
|
TariffScope.Global => S.Tariffs_ScopeGlobal,
|
||||||
|
TariffScope.EnergyType => Loc.F(S.Tariffs_ScopeType, _energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"),
|
||||||
|
TariffScope.Meter => Loc.F(S.Tariffs_ScopeMeter, _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(S.Tariffs_UnitAndValidFromRequired, Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.Tariffs_ScopeTargetRequired, 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(S.Common_Saved, Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Tariff tariff)
|
||||||
|
{
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, S.Tariffs_DeleteTitle,
|
||||||
|
Loc.F(S.Tariffs_DeleteBody, tariff.Component.Display(), 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(S.Common_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,215 @@
|
|||||||
|
@page "/consumables"
|
||||||
|
@inject ConsumableService ConsumablesSvc
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — @S.Consumables_PageTitle</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">@S.Nav_Consumables</MudText>
|
||||||
|
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||||
|
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised —
|
||||||
|
but it must not just be missing from the page, with nothing saying where it went. *@
|
||||||
|
@foreach (var meter in _unconfigured)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Class="mb-3">
|
||||||
|
@Loc.F(S.Consumables_TankNotConfigured, meter.Name)
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
|
||||||
|
Href="@MeterLinks.Detail(meter.MeterId, action: MeterLinks.ActionEdit)">@S.MeterDetail_SetUpTank</MudButton>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_items is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else if (_items.Count == 0 && _unconfigured.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">
|
||||||
|
@S.Consumables_NoMetersLead <b>@MeterMode.ConsumableBalance.Display()</b> @S.Consumables_NoMetersTail
|
||||||
|
<MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.Consumables_NoMetersOrImport
|
||||||
|
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var item in _items)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
|
@* The actions for a tank are the ones done standing next to it, so they sit on its card. *@
|
||||||
|
<div class="d-flex align-center flex-wrap mb-3" style="gap:.5rem">
|
||||||
|
<MudLink Href="@MeterLinks.Detail(item.MeterId)" Typo="Typo.h6">@item.Name</MudLink>
|
||||||
|
<MudSpacer />
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
|
||||||
|
Href="@MeterLinks.Event(item.MeterId, MeterEventType.TankLevel)">@S.MeterDetail_RecordTankLevel</MudButton>
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.LocalShipping"
|
||||||
|
Href="@MeterLinks.Event(item.MeterId, MeterEventType.Delivery)">@S.Consumables_RecordDelivery</MudButton>
|
||||||
|
</div>
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_TankLevel</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">
|
||||||
|
@Loc.F(S.Consumables_FillOfCapacity, Format.Number(item.FillFraction * 100, 0), 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> · @Loc.F(S.Consumables_AsOf, Local(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">@S.Consumables_UsedRange</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">@S.Consumables_BurnerRuntime</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">@S.Consumables_EffectiveRate</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.Display()</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
|
||||||
|
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_ForecastEmpty</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">
|
||||||
|
@Loc.F(S.Consumables_PerDay, Format.Number(apd, 1), item.Unit)
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">@S.Consumables_ConsumptionByMonth</MudText>
|
||||||
|
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">@Loc.F(S.Consumables_DeliveriesCount, item.Deliveries.Count)</MudText>
|
||||||
|
@if (item.Deliveries.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Consumables_NoDeliveries</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="max-height:260px; overflow-y:auto">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead>
|
||||||
|
<tr><th>@S.Common_Date</th><th style="text-align:right">@S.Common_Amount</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var delivery in item.Deliveries)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@Local(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;
|
||||||
|
private IReadOnlyList<UnconfiguredConsumable> _unconfigured = [];
|
||||||
|
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
|
||||||
|
|
||||||
|
protected override Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_tz = LocalTimeEntry.Resolve(Options.Value.TimeZone);
|
||||||
|
return LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
|
||||||
|
|
||||||
|
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);
|
||||||
|
_unconfigured = await ConsumablesSvc.GetUnconfiguredAsync();
|
||||||
|
_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(Format.MonthLabel(m.Period), m.Consumption))
|
||||||
|
.ToList();
|
||||||
|
return [new SeriesChart.SeriesDef(Loc.F(S.Consumables_UnitUsed, item.Unit), ApexCharts.SeriesType.Bar, points)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color FillColor(double fraction) => fraction switch
|
||||||
|
{
|
||||||
|
< 0.15 => Color.Error,
|
||||||
|
< 0.30 => Color.Warning,
|
||||||
|
_ => Color.Success,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject DashboardService Dash
|
@inject DashboardService Dash
|
||||||
|
|
||||||
<PageTitle>MeterVault — Overview</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_Overview</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText>
|
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Overview</MudText>
|
||||||
|
|
||||||
|
<UpdateBanner />
|
||||||
|
|
||||||
@if (_summary is null)
|
@if (_summary is null)
|
||||||
{
|
{
|
||||||
@@ -15,28 +16,28 @@ else
|
|||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" sm="4">
|
<MudItem xs="12" sm="4">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText>
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_ThisMonth</MudText>
|
||||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
|
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
|
||||||
<DeltaChip Kpi="_summary.Month" />
|
<DeltaChip Kpi="_summary.Month" />
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="4">
|
<MudItem xs="12" sm="4">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText>
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_ThisYear</MudText>
|
||||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
|
<MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
|
||||||
<DeltaChip Kpi="_summary.Year" />
|
<DeltaChip Kpi="_summary.Year" />
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" sm="4">
|
<MudItem xs="12" sm="4">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Latest month with data</MudText>
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_LatestMonthWithData</MudText>
|
||||||
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
|
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatCostsMost</MudText>
|
||||||
@if (_breakdown is { Count: > 0 })
|
@if (_breakdown is { Count: > 0 })
|
||||||
{
|
{
|
||||||
<CategoryDonut Slices="_breakdown" />
|
<CategoryDonut Slices="_breakdown" />
|
||||||
@@ -54,17 +55,37 @@ else
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText>
|
@* Names the one step that is missing, in setup order, rather than every admin page. *@
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||||
|
@switch (_setup?.FirstGap)
|
||||||
|
{
|
||||||
|
case CostSetupGap.NoMeters:
|
||||||
|
<span>@S.Dashboard_SetupNoMeters <MudLink Href="/meters">@S.Nav_Meters</MudLink> · <MudLink Href="/import">@S.Nav_Import</MudLink></span>
|
||||||
|
break;
|
||||||
|
case CostSetupGap.NoCategories:
|
||||||
|
<span>@S.Dashboard_SetupNoCategories <MudLink Href="/admin/categories">@S.Nav_CostCategories</MudLink></span>
|
||||||
|
break;
|
||||||
|
case CostSetupGap.NoMembers:
|
||||||
|
<span>@S.Dashboard_SetupNoMembers <MudLink Href="/meters">@S.Nav_Meters</MudLink> · <MudLink Href="/admin/categories">@S.Nav_CostCategories</MudLink></span>
|
||||||
|
break;
|
||||||
|
case CostSetupGap.NoTariffs:
|
||||||
|
<span>@S.Dashboard_SetupNoTariffs <MudLink Href="/admin/tariffs">@S.Nav_Tariffs</MudLink></span>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<span>@S.Dashboard_SetupNoCostsThisYear</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">What cost more / less (year vs last year)</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatChanged</MudText>
|
||||||
<MudSimpleTable Dense="true" Hover="true">
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Category</th><th style="text-align:right">Now</th><th style="text-align:right">Prev</th><th style="text-align:right">Δ</th></tr>
|
<tr><th>@S.Dashboard_ColCategory</th><th style="text-align:right">@S.Dashboard_ColCurrent</th><th style="text-align:right">@S.Dashboard_ColPrevious</th><th style="text-align:right">Δ</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var row in _difference)
|
@foreach (var row in _difference)
|
||||||
@@ -89,6 +110,7 @@ else
|
|||||||
private DashboardSummary? _summary;
|
private DashboardSummary? _summary;
|
||||||
private IReadOnlyList<CategorySlice> _breakdown = [];
|
private IReadOnlyList<CategorySlice> _breakdown = [];
|
||||||
private IReadOnlyList<DifferenceRow> _difference = [];
|
private IReadOnlyList<DifferenceRow> _difference = [];
|
||||||
|
private CostSetup? _setup;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -97,6 +119,10 @@ else
|
|||||||
|
|
||||||
var yearStart = new DateOnly(asOf.Year, 1, 1);
|
var yearStart = new DateOnly(asOf.Year, 1, 1);
|
||||||
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
|
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
|
||||||
|
if (_breakdown.Count == 0)
|
||||||
|
{
|
||||||
|
_setup = await Dash.GetCostSetupAsync();
|
||||||
|
}
|
||||||
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
|
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
@page "/energy/{Id:int}"
|
||||||
|
@inject FlowService Flow
|
||||||
|
@inject MeterVault.Infrastructure.Costing.CostService Costs
|
||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — @(_graph?.EnergyType ?? S.EnergyView_EnergyFallback)</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">@Loc.F(S.EnergyView_FlowTitle, _graph?.EnergyType ?? S.EnergyView_EnergyFallback)</MudText>
|
||||||
|
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||||
|
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_graph is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else if (_meters.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">
|
||||||
|
@S.EnergyView_NoMetersIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.EnergyView_NoMetersOr
|
||||||
|
<MudLink Href="/import">@S.Nav_Import</MudLink>.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* Meters without consumption in the range still exist — a new one, or a quiet stretch — and this
|
||||||
|
page is a natural way in to them, so the list below always renders; only the figures wait. *@
|
||||||
|
@if (!_graph.HasData)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mb-4">@S.EnergyView_NoDataInRange</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudGrid Class="mb-2">
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.EnergyView_TopLevelThroughput</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">@S.Common_CostRange</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">@S.Common_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">@S.EnergyView_Flow</MudText>
|
||||||
|
@if (_graph.HasChain)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||||
|
@S.EnergyView_FlowCaption
|
||||||
|
</MudText>
|
||||||
|
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||||
|
@S.EnergyView_NoChainIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink> @S.EnergyView_NoChainMiddle
|
||||||
|
<b>@S.EnergyView_NoChainUpstream</b> @S.EnergyView_NoChainRest
|
||||||
|
</MudAlert>
|
||||||
|
@if (_graph.Nodes.Count > 0)
|
||||||
|
{
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead><tr><th>@S.Common_Meter</th><th style="text-align:right">@S.EnergyView_ColConsumption</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@(node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : 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">@S.Common_Meters</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead><tr><th>@S.Common_Name</th><th>@S.Common_Mode</th><th>@S.EnergyView_ColUpstreamOf</th><th style="text-align:right">@S.EnergyView_ColConsumption</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var meter in _meters)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><MudLink Href="@MeterLinks.Detail(meter.Id)">@meter.Name</MudLink></td>
|
||||||
|
<td>@meter.Mode.Display()</td>
|
||||||
|
<td>@UpstreamLabel(meter.Id)</td>
|
||||||
|
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
@if (MeterLinks.QuickEntry(meter.Id, meter.Mode) is { } entry)
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@(meter.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
|
||||||
|
<MudIconButton Icon="@(meter.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
|
||||||
|
Size="Size.Small" Color="Color.Primary" Href="@entry"
|
||||||
|
aria-label="@(meter.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</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;
|
||||||
|
|
||||||
|
// Assigned last: the page branches on the graph being loaded, and must not render it
|
||||||
|
// against the previous type's (or an empty) meter list in between.
|
||||||
|
var 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 zone = MeterVault.Infrastructure.Options.InstanceTimeZone.Resolve(Options.Value.TimeZone);
|
||||||
|
var fromUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(from, zone);
|
||||||
|
var toUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(to, zone);
|
||||||
|
double cost = 0;
|
||||||
|
foreach (var meter in _meters)
|
||||||
|
{
|
||||||
|
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
|
||||||
|
}
|
||||||
|
_cost = cost;
|
||||||
|
_graph = graph;
|
||||||
|
}
|
||||||
|
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,27 +1,27 @@
|
|||||||
@page "/Error"
|
@page "/Error"
|
||||||
@using System.Diagnostics
|
@using System.Diagnostics
|
||||||
|
|
||||||
<PageTitle>Error</PageTitle>
|
<PageTitle>@S.Error_PageTitle</PageTitle>
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
<h1 class="text-danger">@S.Error_Heading</h1>
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
<h2 class="text-danger">@S.Error_Message</h2>
|
||||||
|
|
||||||
@if (ShowRequestId)
|
@if (ShowRequestId)
|
||||||
{
|
{
|
||||||
<p>
|
<p>
|
||||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
<strong>@S.Error_RequestId</strong> <code>@RequestId</code>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
<h3>@S.Error_DevelopmentMode</h3>
|
||||||
<p>
|
<p>
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
@* "Development" and ASPNETCORE_ENVIRONMENT are literal environment names — emphasised, never translated. *@
|
||||||
|
@((MarkupString)Loc.F(S.Error_DevelopmentSwap, "<strong>Development</strong>"))
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
<strong>@S.Error_DevelopmentWarning</strong>
|
||||||
It can result in displaying sensitive information from exceptions to end users.
|
@S.Error_DevelopmentWarningDetail
|
||||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
@((MarkupString)Loc.F(S.Error_DevelopmentEnableHint, "<strong>Development</strong>", "<strong>ASPNETCORE_ENVIRONMENT</strong>"))
|
||||||
and restarting the app.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@code{
|
@code{
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
@page "/import"
|
@page "/import"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
|
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
|
||||||
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
|
@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
|
@inject ISnackbar Snackbar
|
||||||
@using MeterVault.Infrastructure.Import
|
@using MeterVault.Infrastructure.Import
|
||||||
|
@using MeterVault.Infrastructure.Persistence
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
|
||||||
<PageTitle>MeterVault — Import</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_Import</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">Import</MudText>
|
<MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Import</MudText>
|
||||||
|
|
||||||
<MudGrid>
|
<MudGrid>
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
<MudText Typo="Typo.h6">Reference dataset</MudText>
|
<MudText Typo="Typo.h6">@S.Import_ReferenceDataset</MudText>
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||||
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a
|
@S.Import_ReferenceDatasetHelp
|
||||||
starter dataset with meters, tariffs and categories.
|
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
|
||||||
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
|
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
|
||||||
@(_referenceLoaded ? "Loaded" : "Load reference data")
|
@(_referenceLoaded ? S.Import_ReferenceLoaded : S.Import_LoadReferenceData)
|
||||||
</MudButton>
|
</MudButton>
|
||||||
@if (_loadingReference)
|
@if (_loadingReference)
|
||||||
{
|
{
|
||||||
@@ -30,18 +33,22 @@
|
|||||||
|
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
<MudText Typo="Typo.h6">Dry-run a CSV</MudText>
|
<div class="d-flex align-center justify-space-between">
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
<MudText Typo="Typo.h6">@S.Import_YourOwnCsv</MudText>
|
||||||
Upload a sheet and preview what would be staged (no changes are made).
|
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
|
||||||
|
StartIcon="@Icons.Material.Filled.AutoFixHigh">@S.Import_MappingWizard</MudButton>
|
||||||
|
</div>
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
|
||||||
|
@S.Import_YourOwnCsvHelp
|
||||||
</MudText>
|
</MudText>
|
||||||
<MudSelect T="string" @bind-Value="_profileName" Label="Sheet type" Dense="true" Class="mb-2">
|
<MudSelect T="string" @bind-Value="_profileName" Label="@S.Import_ReferenceProfile" Dense="true" Class="mb-2">
|
||||||
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
|
<MudSelectItem T="string" Value="@("Strom")">@S.Import_ProfileElectricity</MudSelectItem>
|
||||||
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
|
<MudSelectItem T="string" Value="@("Wasser")">@S.Import_ProfileWater</MudSelectItem>
|
||||||
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
|
<MudSelectItem T="string" Value="@("Heizöl")">@S.Import_ProfileHeatingOil</MudSelectItem>
|
||||||
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
|
<MudSelectItem T="string" Value="@("Kosten")">@S.Import_ProfileCosts</MudSelectItem>
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
||||||
Choose CSV
|
@S.Import_DryRunReferenceSheet
|
||||||
</MudButton>
|
</MudButton>
|
||||||
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -51,20 +58,20 @@
|
|||||||
{
|
{
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<MudText Typo="Typo.h6" Class="mb-2">Preview</MudText>
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Common_Preview</MudText>
|
||||||
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
|
<div class="d-flex" style="gap:2rem; flex-wrap:wrap">
|
||||||
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText>
|
<MudText>@S.Common_ReadingsLabel <b>@_preview.Readings.Count</b></MudText>
|
||||||
<MudText>Events: <b>@_preview.Events.Count</b></MudText>
|
<MudText>@S.Common_EventsLabel <b>@_preview.Events.Count</b></MudText>
|
||||||
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText>
|
<MudText>@S.Common_ManualCostsLabel <b>@_preview.ManualCosts.Count</b></MudText>
|
||||||
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText>
|
<MudText>@S.Common_SkippedRowsLabel <b>@_preview.SkippedRows</b></MudText>
|
||||||
</div>
|
</div>
|
||||||
@if (_preview.Warnings.Count > 0)
|
@if (_preview.Warnings.Count > 0)
|
||||||
{
|
{
|
||||||
<MudExpansionPanels Class="mt-3">
|
<MudExpansionPanels Class="mt-3">
|
||||||
<MudExpansionPanel Text="@($"{_preview.Warnings.Count} warnings")">
|
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _preview.Warnings.Count)">
|
||||||
@foreach (var warning in _preview.Warnings.Take(50))
|
@foreach (var warning in _preview.Warnings.Take(50))
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2">@warning</MudText>
|
<MudText Typo="Typo.body2">@warning.Display()</MudText>
|
||||||
}
|
}
|
||||||
</MudExpansionPanel>
|
</MudExpansionPanel>
|
||||||
</MudExpansionPanels>
|
</MudExpansionPanels>
|
||||||
@@ -72,6 +79,68 @@
|
|||||||
</MudPaper>
|
</MudPaper>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@S.Import_RecentImports</MudText>
|
||||||
|
@if (_batches.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Import_NoImportsYet</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th>@S.Import_ColumnWritesTo</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var batch in _batches)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@batch.Id</td>
|
||||||
|
<td>@(batch.SourceName ?? "—")</td>
|
||||||
|
<td>
|
||||||
|
@* What the batch wrote to, so an import can be checked where it landed — and a
|
||||||
|
revert weighed against what it will take away. *@
|
||||||
|
@if (_targets.TryGetValue(batch.Id, out var targets))
|
||||||
|
{
|
||||||
|
@foreach (var meter in targets.Meters)
|
||||||
|
{
|
||||||
|
<MudLink Href="@MeterLinks.Detail(meter.Id)" Class="mr-2">@meter.Name</MudLink>
|
||||||
|
}
|
||||||
|
@foreach (var category in targets.Categories)
|
||||||
|
{
|
||||||
|
<MudLink Href="/admin/categories" Class="mr-2" Color="Color.Secondary">@category</MudLink>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>—</span>
|
||||||
|
}
|
||||||
|
</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">@S.Import_StatusReverted</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Success">@S.Import_StatusActive</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))">@S.Import_Revert</MudButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -79,9 +148,111 @@
|
|||||||
private bool _referenceLoaded;
|
private bool _referenceLoaded;
|
||||||
private string _profileName = "Strom";
|
private string _profileName = "Strom";
|
||||||
private StagedImport? _preview;
|
private StagedImport? _preview;
|
||||||
|
private List<ImportBatch> _batches = [];
|
||||||
|
private Dictionary<int, BatchTargets> _targets = [];
|
||||||
|
private int? _reverting;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync() =>
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
_referenceLoaded = await ReferenceImporter.IsLoadedAsync();
|
_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();
|
||||||
|
_targets = await LoadTargetsAsync(db, [.. _batches.Where(b => b.RevertedAt is null).Select(b => b.Id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The meters and cost categories each batch wrote rows for. Read from the rows themselves rather
|
||||||
|
/// than the stored mapping, which the reference import does not have and which cannot say what a
|
||||||
|
/// partly failed or reverted batch actually left behind.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<Dictionary<int, BatchTargets>> LoadTargetsAsync(MeterVaultDbContext db, List<int> batchIds)
|
||||||
|
{
|
||||||
|
if (batchIds.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var readingMeters = await db.Readings.AsNoTracking()
|
||||||
|
.Where(r => r.ImportBatchId != null && batchIds.Contains(r.ImportBatchId.Value))
|
||||||
|
.Select(r => new { Batch = r.ImportBatchId!.Value, r.MeterId })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
var eventMeters = await db.MeterEvents.AsNoTracking()
|
||||||
|
.Where(e => e.ImportBatchId != null && batchIds.Contains(e.ImportBatchId.Value))
|
||||||
|
.Select(e => new { Batch = e.ImportBatchId!.Value, e.MeterId })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
var costs = await db.ManualCosts.AsNoTracking()
|
||||||
|
.Where(c => c.ImportBatchId != null && batchIds.Contains(c.ImportBatchId.Value))
|
||||||
|
.Select(c => new { Batch = c.ImportBatchId!.Value, c.MeterId, c.CategoryId })
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var meterPairs = readingMeters.Select(r => (r.Batch, MeterId: (int?)r.MeterId))
|
||||||
|
.Concat(eventMeters.Select(e => (e.Batch, MeterId: (int?)e.MeterId)))
|
||||||
|
.Concat(costs.Select(c => (c.Batch, c.MeterId)))
|
||||||
|
.Where(p => p.MeterId is not null)
|
||||||
|
.Select(p => (p.Batch, MeterId: p.MeterId!.Value))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
var categoryPairs = costs.Where(c => c.CategoryId is not null).Select(c => (c.Batch, CategoryId: c.CategoryId!.Value)).Distinct().ToList();
|
||||||
|
|
||||||
|
var meterIds = meterPairs.Select(p => p.MeterId).Distinct().ToList();
|
||||||
|
var categoryIds = categoryPairs.Select(p => p.CategoryId).Distinct().ToList();
|
||||||
|
var meterNames = await db.Meters.AsNoTracking().Where(m => meterIds.Contains(m.Id))
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => m.Name);
|
||||||
|
var categoryNames = await db.CostCategories.AsNoTracking().Where(c => categoryIds.Contains(c.Id))
|
||||||
|
.ToDictionaryAsync(c => c.Id, c => c.Name);
|
||||||
|
|
||||||
|
return batchIds
|
||||||
|
.Select(id => (Id: id, Targets: new BatchTargets(
|
||||||
|
[.. meterPairs.Where(p => p.Batch == id && meterNames.ContainsKey(p.MeterId))
|
||||||
|
.Select(p => new MeterTarget(p.MeterId, meterNames[p.MeterId]))
|
||||||
|
.OrderBy(m => m.Name, StringComparer.CurrentCulture)],
|
||||||
|
[.. categoryPairs.Where(p => p.Batch == id && categoryNames.ContainsKey(p.CategoryId))
|
||||||
|
.Select(p => categoryNames[p.CategoryId])
|
||||||
|
.Order(StringComparer.CurrentCulture)])))
|
||||||
|
.Where(t => t.Targets.Meters.Count > 0 || t.Targets.Categories.Count > 0)
|
||||||
|
.ToDictionary(t => t.Id, t => t.Targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record MeterTarget(int Id, string Name);
|
||||||
|
|
||||||
|
private sealed record BatchTargets(IReadOnlyList<MeterTarget> Meters, IReadOnlyList<string> Categories);
|
||||||
|
|
||||||
|
private async Task RevertAsync(ImportBatch batch)
|
||||||
|
{
|
||||||
|
if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
|
||||||
|
Loc.F(S.Import_RevertConfirmBody, batch.RowCount, batch.Id, batch.SourceName ?? S.Import_UnnamedSource),
|
||||||
|
S.Import_Revert))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reverting = batch.Id;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ImportService.RevertAsync(batch.Id);
|
||||||
|
Snackbar.Add(Loc.F(S.Import_BatchReverted, batch.Id), Severity.Success);
|
||||||
|
await LoadBatchesAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(Loc.F(S.Import_RevertFailed, ex.Message), Severity.Error);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_reverting = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadReferenceAsync()
|
private async Task LoadReferenceAsync()
|
||||||
{
|
{
|
||||||
@@ -91,11 +262,12 @@
|
|||||||
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
||||||
await ReferenceImporter.LoadAsync(dir);
|
await ReferenceImporter.LoadAsync(dir);
|
||||||
_referenceLoaded = true;
|
_referenceLoaded = true;
|
||||||
Snackbar.Add("Reference data loaded.", Severity.Success);
|
Snackbar.Add(S.Import_ReferenceDataLoaded, Severity.Success);
|
||||||
|
await LoadBatchesAsync();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error);
|
Snackbar.Add(Loc.F(S.Import_Failed, ex.Message), Severity.Error);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
@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 — @S.ImportWizard_Title</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center mb-4" style="gap:1rem">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="@S.ImportWizard_BackToImport" />
|
||||||
|
<MudText Typo="Typo.h4">@S.ImportWizard_Title</MudText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
|
||||||
|
@S.ImportWizard_IntroLead
|
||||||
|
<code>Monat JJJJ</code> @S.ImportWizard_IntroOr <code>TT.MM.JJJJ</code> @S.ImportWizard_IntroTail
|
||||||
|
</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">
|
||||||
|
@S.ImportWizard_ChooseCsv
|
||||||
|
</MudButton>
|
||||||
|
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
|
||||||
|
@if (_fileName is not null)
|
||||||
|
{
|
||||||
|
<MudText><b>@_fileName</b> — @Loc.F(S.ImportWizard_FileSummary, _rows.Count, _colCount)</MudText>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (_colCount > 0)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step1Title</MudText>
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="6" md="3">
|
||||||
|
<MudSelect T="int" @bind-Value="_dateColumn" Label="@S.ImportWizard_DateColumn" 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="@S.ImportWizard_DateFormat" Dense="true">
|
||||||
|
<MudSelectItem T="DateKind" Value="DateKind.Auto">@S.ImportWizard_DateAutoDetect</MudSelectItem>
|
||||||
|
<MudSelectItem T="DateKind" Value="DateKind.MonthName">@S.ImportWizard_DateMonthName</MudSelectItem>
|
||||||
|
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">@S.ImportWizard_DateDay</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" sm="6" md="2">
|
||||||
|
<MudNumericField T="int" @bind-Value="_headerRow" Label="@S.ImportWizard_HeaderRow" Min="0" Margin="Margin.Dense" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" sm="6" md="2">
|
||||||
|
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="@S.ImportWizard_FirstDataRow" Min="0" Margin="Margin.Dense" />
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" md="2" Class="d-flex flex-column">
|
||||||
|
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="@S.ImportWizard_SkipZeroRows" Color="Color.Primary" />
|
||||||
|
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="@S.ImportWizard_DetectSwaps" Color="Color.Primary" />
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step2Title</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)" : "")">
|
||||||
|
@Loc.F(S.ImportWizard_ColumnN, 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">
|
||||||
|
@S.ImportWizard_PreviewCaption
|
||||||
|
</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step3Title</MudText>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<MudSimpleTable Dense="true" Style="min-width:100%">
|
||||||
|
<thead>
|
||||||
|
<tr><th>@S.ImportWizard_HeaderColumn</th><th>@S.ImportWizard_HeaderSample</th><th style="min-width:160px">@S.ImportWizard_HeaderRole</th><th style="min-width:220px">@S.Common_Target</th><th style="min-width:120px">@S.Common_Unit</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>@Loc.F(S.ImportWizard_ColumnN, 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.Display()</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="@S.ImportWizard_SelectMeter" 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="@S.ImportWizard_SelectCategory" 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="@S.ImportWizard_UnitPlaceholder" 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">@S.ImportWizard_Step4Title</MudText>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
|
||||||
|
OnClick="Preview">@S.ImportWizard_DryRunButton</MudButton>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
|
||||||
|
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
|
||||||
|
@S.ImportWizard_CommitButton
|
||||||
|
</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>@S.Common_ReadingsLabel <b>@_staged.Readings.Count</b></MudText>
|
||||||
|
<MudText>@S.Common_EventsLabel <b>@_staged.Events.Count</b></MudText>
|
||||||
|
<MudText>@S.Common_ManualCostsLabel <b>@_staged.ManualCosts.Count</b></MudText>
|
||||||
|
<MudText>@S.Common_SkippedRowsLabel <b>@_staged.SkippedRows</b></MudText>
|
||||||
|
</div>
|
||||||
|
@if (_staged.TotalRows == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
|
||||||
|
@S.ImportWizard_NothingStaged
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
@if (_staged.Warnings.Count > 0)
|
||||||
|
{
|
||||||
|
<MudExpansionPanels Class="mt-3">
|
||||||
|
<MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _staged.Warnings.Count)">
|
||||||
|
@foreach (var warning in _staged.Warnings.Take(100))
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@warning.Display()</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 = [S.ImportWizard_MappingChanged];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_committing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
|
||||||
|
Snackbar.Add(Loc.F(S.ImportWizard_CommitSuccess, batchId, _staged.TotalRows), Severity.Success);
|
||||||
|
Nav.NavigateTo("/import");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Snackbar.Add(Loc.F(S.ImportWizard_CommitFailed, 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(S.ImportWizard_ValidateNoMappedColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(Loc.F(S.ImportWizard_ValidateNeedsMeter, i, c.Role.Display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
|
||||||
|
{
|
||||||
|
errors.Add(Loc.F(S.ImportWizard_ValidateNeedsCategory, i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
?? Loc.F(S.ImportWizard_MeterFallback, group.Key);
|
||||||
|
var cols = string.Join(", ", group.Select(x => Loc.F(S.ImportWizard_ColumnN, x.Index)));
|
||||||
|
errors.Add(Loc.F(S.ImportWizard_ValidateDuplicateMeter, cols, meterName));
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
? Loc.F(S.ImportWizard_ColumnN, col)
|
||||||
|
: Loc.F(S.ImportWizard_ColumnWithHeader, col, header);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,19 @@
|
|||||||
@page "/meters"
|
@page "/meters"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject NavigationManager Nav
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Meters</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_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">@S.Common_Meters</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => _editor!.OpenNewAsync())">
|
||||||
|
@S.Meters_AddMeter
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_meters is null)
|
@if (_meters is null)
|
||||||
{
|
{
|
||||||
@@ -13,53 +21,165 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2">
|
@* The whole row opens the meter — on a phone this table collapses to cards, where a name link
|
||||||
|
is a small target between edit and delete. Grouped by energy type, because that is how people
|
||||||
|
look for a meter ("the water one"), not by the order they were created in. *@
|
||||||
|
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2" Filter="Matches"
|
||||||
|
GroupBy="_byEnergyType" OnRowClick="@((TableRowClickEventArgs<Meter> e) => Nav.NavigateTo(MeterLinks.Detail(e.Item!.Id)))"
|
||||||
|
RowClass="cursor-pointer">
|
||||||
|
<ToolBarContent>
|
||||||
|
<MudTextField T="string" @bind-Value="_search" Immediate="true" Placeholder="@S.Meters_SearchPlaceholder"
|
||||||
|
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Clearable="true"
|
||||||
|
Class="mt-0" Style="max-width:420px" />
|
||||||
|
</ToolBarContent>
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh>Name</MudTh>
|
<MudTh>@S.Common_Name</MudTh>
|
||||||
<MudTh>Type</MudTh>
|
<MudTh>@S.Common_Mode</MudTh>
|
||||||
<MudTh>Mode</MudTh>
|
<MudTh>@S.Common_Unit</MudTh>
|
||||||
<MudTh>Unit</MudTh>
|
<MudTh>@S.Meters_Sources</MudTh>
|
||||||
<MudTh>Sources</MudTh>
|
<MudTh>@S.Common_LastSeen</MudTh>
|
||||||
<MudTh>Last seen</MudTh>
|
<MudTh>@S.Meters_Active</MudTh>
|
||||||
|
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
|
<GroupHeaderTemplate>
|
||||||
|
<MudTh colspan="7" Class="mud-table-cell-custom-group">
|
||||||
|
<MudText Typo="Typo.subtitle2">@context.Key</MudText>
|
||||||
|
</MudTh>
|
||||||
|
</GroupHeaderTemplate>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
<MudTd DataLabel="@S.Common_Name">
|
||||||
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
@* The link stays a real link (keyboard, open in new tab) but must not also fire the row's
|
||||||
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
click, or one tap pushes the same page onto the history twice. *@
|
||||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
<span @onclick:stopPropagation="true"><MudLink Href="@MeterLinks.Detail(context.Id)">@context.Name</MudLink></span>
|
||||||
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
@if (!context.IsActive)
|
||||||
<MudTd DataLabel="Last seen">
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Class="ml-2">@S.MeterDetail_Retired</MudChip>
|
||||||
|
}
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_Mode" HideSmall="true">@context.Mode.Display()</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_Unit" HideSmall="true">@context.Unit</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Meters_Sources" HideSmall="true">@context.Sources.Count</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_LastSeen" HideSmall="true">
|
||||||
@{
|
@{
|
||||||
var lastSeen = context.Sources
|
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
|
||||||
.Where(s => s.LastSeenAt != null)
|
|
||||||
.Select(s => s.LastSeenAt)
|
|
||||||
.DefaultIfEmpty(null)
|
|
||||||
.Max();
|
|
||||||
}
|
}
|
||||||
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
||||||
</MudTd>
|
</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Meters_Active" HideSmall="true">@(context.IsActive ? S.Meters_Yes : S.Meters_No)</MudTd>
|
||||||
|
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
|
||||||
|
@* Buttons inside a clickable row must not also open the meter. *@
|
||||||
|
<div class="d-inline-flex" @onclick:stopPropagation="true">
|
||||||
|
@if (MeterLinks.QuickEntry(context.Id, context.Mode) is { } entry)
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@(context.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
|
||||||
|
<MudIconButton Icon="@(context.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
|
||||||
|
Size="Size.Small" Color="Color.Primary" Href="@entry"
|
||||||
|
aria-label="@(context.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => _editor!.OpenAsync(context.Id))" aria-label="@S.MeterDetail_EditMeter" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" aria-label="@S.Common_Delete" />
|
||||||
|
</div>
|
||||||
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
|
<NoRecordsContent>
|
||||||
|
@if (_meters.Count > 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">@Loc.F(S.Meters_NoSearchMatch, _search)</MudText>
|
||||||
|
}
|
||||||
|
</NoRecordsContent>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
|
|
||||||
@if (_meters.Count == 0)
|
@if (_meters.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
No meters yet. Go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
@S.Meters_EmptyBefore <MudLink Href="/import">@S.Nav_Import</MudLink> @S.Meters_EmptyAfter
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MeterEditor @ref="_editor" Saved="OnSavedAsync" SwapInsteadRequested="@(id => Nav.NavigateTo(MeterLinks.Event(id, MeterEventType.MeterSwap)))" />
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Meter>? _meters;
|
private List<Meter>? _meters;
|
||||||
|
private MeterEditor? _editor;
|
||||||
|
private string? _search;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
private readonly TableGroupDefinition<Meter> _byEnergyType = new()
|
||||||
|
{
|
||||||
|
Indentation = false,
|
||||||
|
Expandable = false,
|
||||||
|
Selector = m => m.EnergyType?.DisplayName ?? "—",
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
_meters = await db.Meters
|
var meters = await db.Meters
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(m => m.EnergyType)
|
.Include(m => m.EnergyType)
|
||||||
.Include(m => m.Sources)
|
.Include(m => m.Sources)
|
||||||
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Grouping follows item order, so sort by the group label first. Retired meters sink to the
|
||||||
|
// bottom of their group: their history still counts, but nobody reads them any more.
|
||||||
|
_meters = meters
|
||||||
|
.OrderBy(m => m.EnergyType?.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ThenBy(m => !m.IsActive)
|
||||||
|
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Matches(Meter meter)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_search))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var term = _search.Trim();
|
||||||
|
return Contains(meter.Name) || Contains(meter.SerialNumber) || Contains(meter.Location)
|
||||||
|
|| Contains(meter.EnergyType?.DisplayName) || Contains(meter.Mode.Display());
|
||||||
|
|
||||||
|
bool Contains(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A new meter goes straight to its own page, where adding readings or a source is the next step.</summary>
|
||||||
|
private async Task OnSavedAsync((int MeterId, bool Created) saved)
|
||||||
|
{
|
||||||
|
if (saved.Created)
|
||||||
|
{
|
||||||
|
Nav.NavigateTo(MeterLinks.Detail(saved.MeterId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 message = readings + consumption > 0
|
||||||
|
? Loc.F(S.Meters_DeleteConfirmWithData, meter.Name, readings, consumption)
|
||||||
|
: Loc.F(S.Meters_DeleteConfirm, meter.Name);
|
||||||
|
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, S.Meters_DeleteTitle, message))
|
||||||
|
{
|
||||||
|
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(S.Common_Deleted, Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@page "/not-found"
|
@page "/not-found"
|
||||||
@layout MainLayout
|
@layout MainLayout
|
||||||
|
|
||||||
<h3>Not Found</h3>
|
<h3>@S.NotFound_Title</h3>
|
||||||
<p>Sorry, the content you are looking for does not exist.</p>
|
<p>@S.NotFound_Message</p>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
@page "/solar"
|
||||||
|
@inject SolarService SolarSvc
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — @S.Nav_Solar</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">@S.Nav_Solar</MudText>
|
||||||
|
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
|
||||||
|
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_summary is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else if (!_summary.HasGeneration)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">
|
||||||
|
@S.Solar_NoGenerationLead <b>@MeterMode.GenerationCounter.Display()</b> @S.Solar_NoGenerationTail
|
||||||
|
<MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.Solar_NoGenerationOrImport
|
||||||
|
<MudLink Href="/import">@S.Nav_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">@S.Solar_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">@S.Solar_SelfConsumption</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">@Loc.F(S.Solar_ShareOfGeneration, Format.Number(ratio * 100, 0))</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_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">@Loc.F(S.Solar_GridDraw, Format.Number(grid, 0))</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Savings</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">@S.Solar_GenerationAndSelfConsumption</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">@S.Solar_GenerationByMeter</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<tbody>
|
||||||
|
@foreach (var meter in _summary.Meters)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><MudLink Href="@MeterLinks.Detail(meter.MeterId)">@meter.Name</MudLink></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">
|
||||||
|
@S.Solar_TagMetersLead <code>total_load</code> @S.Solar_TagMetersMid <code>grid_import</code>@S.Solar_TagMetersTail
|
||||||
|
<MudLink Href="/meters">@S.Nav_Meters</MudLink>.
|
||||||
|
</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(Format.MonthLabel(m.Period), m.Generation))
|
||||||
|
.ToList();
|
||||||
|
var series = new List<SeriesChart.SeriesDef>
|
||||||
|
{
|
||||||
|
new(S.Solar_Generation, ApexCharts.SeriesType.Bar, generation),
|
||||||
|
};
|
||||||
|
if (_summary.HasLoadContext)
|
||||||
|
{
|
||||||
|
var self = _summary.Months
|
||||||
|
.Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.SelfConsumption ?? 0))
|
||||||
|
.ToList();
|
||||||
|
series.Add(new(S.Solar_SelfConsumption, ApexCharts.SeriesType.Bar, self));
|
||||||
|
}
|
||||||
|
|
||||||
|
_chart = series;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
@page "/trends"
|
@page "/trends"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject DashboardService Dash
|
@inject DashboardService Dash
|
||||||
|
|
||||||
<PageTitle>MeterVault — Trends</PageTitle>
|
<PageTitle>MeterVault — @S.Nav_Trends</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">Cost trend</MudText>
|
<MudText Typo="Typo.h4" Class="mb-4">@S.Trends_Title</MudText>
|
||||||
|
|
||||||
<MudPaper Class="pa-4" Elevation="2">
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
<div class="d-flex align-center mb-3" style="gap:1rem">
|
<div class="d-flex align-center mb-3" style="gap:1rem">
|
||||||
<MudSelect T="int" @bind-Value="_months" Label="Range" Dense="true" Style="max-width:180px">
|
<MudSelect T="int" @bind-Value="_months" Label="@S.Common_Range" Dense="true" Style="max-width:180px">
|
||||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
<MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
|
||||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
<MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
|
||||||
<MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem>
|
<MudSelectItem T="int" Value="48">@S.Trends_RangeLast48Months</MudSelectItem>
|
||||||
</MudSelect>
|
</MudSelect>
|
||||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">Apply</MudButton>
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">@S.Trends_Apply</MudButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (_loading)
|
@if (_loading)
|
||||||
@@ -24,14 +23,17 @@
|
|||||||
{
|
{
|
||||||
<TrendChart Points="_points" />
|
<TrendChart Points="_points" />
|
||||||
<MudText Typo="Typo.subtitle2" Class="mt-4">
|
<MudText Typo="Typo.subtitle2" Class="mt-4">
|
||||||
Total over range: @Format.Euro(_points.Sum(p => p.Cost))
|
@Loc.F(S.Trends_TotalOverRange, Format.Euro(_points.Sum(p => p.Cost)))
|
||||||
</MudText>
|
</MudText>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private int _months = 24;
|
private int _months = 24;
|
||||||
private bool _loading = true;
|
|
||||||
|
// Starts idle: LoadAsync is the one that sets it. Starting busy made the very first load bail out
|
||||||
|
// on its own guard, so the page never got past the progress bar and Apply stayed disabled.
|
||||||
|
private bool _loading;
|
||||||
private IReadOnlyList<TrendPoint> _points = [];
|
private IReadOnlyList<TrendPoint> _points = [];
|
||||||
|
|
||||||
protected override Task OnInitializedAsync() => LoadAsync();
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<ApexPointSeries TItem="CategorySlice"
|
<ApexPointSeries TItem="CategorySlice"
|
||||||
Items="Slices"
|
Items="Slices"
|
||||||
SeriesType="SeriesType.Donut"
|
SeriesType="SeriesType.Donut"
|
||||||
Name="Cost"
|
Name="@S.CategoryDonut_Cost"
|
||||||
XValue="s => s.Name"
|
XValue="s => s.Name"
|
||||||
YValue="s => (decimal)Math.Round(s.Cost, 2)" />
|
YValue="s => (decimal)Math.Round(s.Cost, 2)" />
|
||||||
</ApexChart>
|
</ApexChart>
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MeterVault.Core.Normalization
|
||||||
|
@using MeterVault.Infrastructure.Normalization
|
||||||
|
@using MeterVault.Infrastructure.Persistence
|
||||||
|
@inject IDbContextFactory<MeterVaultDbContext> DbFactory
|
||||||
|
@inject INormalizationEngine Engine
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
|
||||||
|
@* The one meter editor, shared by the meter list and the meter's own page: a meter's settings are
|
||||||
|
edited where the user is looking at it rather than only from a pencil in a list. *@
|
||||||
|
<MudDialog Visible="_open" VisibleChanged="@(v => _open = v)" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name))</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
|
||||||
|
<MudSelect T="short" Value="_working.EnergyTypeId" ValueChanged="OnEnergyTypeChanged" Label="@S.Common_EnergyType" 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="@S.Meters_MeasurementMode" Class="mb-2">
|
||||||
|
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (_working.Mode == MeterMode.Virtual)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||||
|
@S.Meters_VirtualHelp
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else if (_working.Mode == MeterMode.InstantRate)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||||
|
@S.Meters_InstantRateHelpBefore <b>@S.Meters_InstantRateHelpPerHour</b> @S.Meters_InstantRateHelpAfter
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_working.Unit" Label="@S.Common_Unit" Required="true" Class="mb-2" />
|
||||||
|
@if (_working.Mode != MeterMode.ConsumableBalance)
|
||||||
|
{
|
||||||
|
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="@S.Meters_InitialBaseline" Class="mb-2" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_working.Mode == MeterMode.ConsumableBalance)
|
||||||
|
{
|
||||||
|
<MudPaper Outlined="true" Class="pa-3 mb-3">
|
||||||
|
<MudText Typo="Typo.subtitle2">@S.Meters_TankSection</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@S.Meters_TankHelp</MudText>
|
||||||
|
<div class="d-flex flex-wrap" style="gap:0 1rem">
|
||||||
|
<MudNumericField T="double?" @bind-Value="_working.TankCapacity" Label="@S.Meters_TankCapacity" Min="0"
|
||||||
|
Style="min-width:140px" Class="flex-grow-1 mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.TankUnit" Label="@S.Common_Unit" Style="max-width:110px" Class="mb-2" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap" style="gap:0 1rem">
|
||||||
|
<MudNumericField T="double?" @bind-Value="_working.VolumePerCm" Label="@Loc.F(S.Meters_TankVolumePerCm, TankUnitLabel)" Min="0"
|
||||||
|
HelperText="@S.Meters_TankVolumePerCmHelp" Style="min-width:160px" Class="flex-grow-1 mb-2" />
|
||||||
|
<MudNumericField T="double" @bind-Value="_working.CalibrationOffset" Label="@Loc.F(S.Meters_TankOffset, TankUnitLabel)"
|
||||||
|
Disabled="@(_working.VolumePerCm is null)" Style="max-width:140px" Class="mb-2" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap" style="gap:0 1rem">
|
||||||
|
<MudSelect T="TankRateMode" @bind-Value="_working.RateMode" Label="@S.Meters_TankRateMode" Style="min-width:160px" Class="flex-grow-1 mb-2">
|
||||||
|
@foreach (var rateMode in Enum.GetValues<TankRateMode>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="TankRateMode" Value="rateMode">@rateMode.Display()</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (_working.RateMode == TankRateMode.Fixed)
|
||||||
|
{
|
||||||
|
<MudNumericField T="double?" @bind-Value="_working.FixedRate" Label="@Loc.F(S.Meters_TankFixedRate, TankUnitLabel)" Min="0"
|
||||||
|
Style="max-width:160px" Class="mb-2" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudSelect T="string" @bind-Value="_working.Role" Label="@S.Meters_PvRole" HelperText="@S.Meters_PvRoleHelp" Class="mb-2">
|
||||||
|
<MudSelectItem T="string" Value="@("")">@S.Meters_RoleNone</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="@S.Meters_UpstreamLabel" Class="mb-2"
|
||||||
|
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
||||||
|
HelperText="@S.Meters_UpstreamHelp">
|
||||||
|
@foreach (var m in AvailableUpstream())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@* A meter only has a cost once a category counts it, and nothing else on the way to the
|
||||||
|
dashboard says so — so the membership is chosen where the meter is set up. *@
|
||||||
|
@if (_categories.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">
|
||||||
|
@S.Meters_NoCostCategories <MudLink Typo="Typo.caption" Href="/admin/categories">@S.Nav_CostCategories</MudLink>
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Categories"
|
||||||
|
Label="@S.Meters_CostCategories" Class="mb-2"
|
||||||
|
MultiSelectionTextFunc="@(ids => CategoryText(ids))"
|
||||||
|
HelperText="@CategoriesHelp">
|
||||||
|
@foreach (var category in _categories)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int" Value="category.Id">@category.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_working.Location" Label="@S.Meters_Location" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.SerialNumber" Label="@S.Meters_SerialNumber" Class="mb-2" />
|
||||||
|
<div class="d-flex" style="gap:1rem">
|
||||||
|
<MudTextField @bind-Value="_working.Manufacturer" Label="@S.Meters_Manufacturer" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Model" Label="@S.Meters_Model" Class="mb-2" />
|
||||||
|
</div>
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="@S.Meters_Active" Color="Color.Primary" />
|
||||||
|
@if (OffersSwapInstead)
|
||||||
|
{
|
||||||
|
@* Retiring and re-creating splits one meter's history in two and drops its sources, flow
|
||||||
|
links and cost categories. When the physical meter was replaced, a swap is the fix. *@
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2">
|
||||||
|
@S.Meters_RetireSwapHint
|
||||||
|
<div class="mt-2">
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning"
|
||||||
|
StartIcon="@Icons.Material.Filled.SwapHoriz" OnClick="RecordSwapInsteadAsync">
|
||||||
|
@S.Meters_RecordSwapInstead
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
@if (_working.Id != 0 && _working.RecomputeNeeded)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Meters_RecomputeNotice</MudAlert>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _open = false)" Disabled="_saving">@S.Common_Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="_saving">@S.Common_Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
|
private bool _open;
|
||||||
|
private bool _saving;
|
||||||
|
private EditModel _working = new();
|
||||||
|
private List<EnergyType> _energyTypes = [];
|
||||||
|
private List<Meter> _meters = [];
|
||||||
|
private List<MeterLink> _allLinks = [];
|
||||||
|
private List<CostCategory> _categories = [];
|
||||||
|
private List<CostCategoryMember> _typeMemberships = [];
|
||||||
|
|
||||||
|
/// <summary>Raised after a save, with the meter's id and whether it was just created.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<(int MeterId, bool Created)> Saved { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the user, about to retire a register, chooses to record a meter swap instead. The
|
||||||
|
/// host decides where that dialog lives; the editor has already closed without saving.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<int> SwapInsteadRequested { get; set; }
|
||||||
|
|
||||||
|
public async Task OpenNewAsync()
|
||||||
|
{
|
||||||
|
await LoadListsAsync();
|
||||||
|
var type = _energyTypes.FirstOrDefault();
|
||||||
|
_working = new EditModel();
|
||||||
|
if (type is not null)
|
||||||
|
{
|
||||||
|
ApplyTypeDefaults(type, previous: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_open = true;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OpenAsync(int meterId)
|
||||||
|
{
|
||||||
|
await LoadListsAsync();
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var meter = await db.Meters.AsNoTracking().FirstOrDefaultAsync(m => m.Id == meterId);
|
||||||
|
if (meter is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meterId);
|
||||||
|
var calibration = MeterConfigFactory.ParseCalibration(tank?.Calibration);
|
||||||
|
_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,
|
||||||
|
OriginalIsActive = meter.IsActive,
|
||||||
|
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
|
||||||
|
Categories = (await db.CostCategoryMembers.AsNoTracking()
|
||||||
|
.Where(m => m.MeterId == meter.Id)
|
||||||
|
.Select(m => m.CategoryId)
|
||||||
|
.ToListAsync()).ToHashSet(),
|
||||||
|
HasTank = tank is not null,
|
||||||
|
TankCapacity = tank?.Capacity,
|
||||||
|
TankUnit = tank?.Unit ?? meter.Unit,
|
||||||
|
VolumePerCm = calibration?.VolumePerUnit,
|
||||||
|
CalibrationOffset = calibration?.Offset ?? 0,
|
||||||
|
RateMode = tank?.RateMode ?? TankRateMode.Empirical,
|
||||||
|
FixedRate = tank?.FixedRate,
|
||||||
|
};
|
||||||
|
_working.OriginalTank = _working.TankSignature;
|
||||||
|
_open = true;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string TankUnitLabel => string.IsNullOrWhiteSpace(_working.TankUnit) ? _working.Unit : _working.TankUnit.Trim();
|
||||||
|
|
||||||
|
private bool OffersSwapInstead =>
|
||||||
|
_working.Id != 0 && _working.OriginalIsActive && !_working.IsActive
|
||||||
|
&& MeterEventRules.IsMonotonic(_working.OriginalMode) && SwapInsteadRequested.HasDelegate;
|
||||||
|
|
||||||
|
private async Task LoadListsAsync()
|
||||||
|
{
|
||||||
|
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().OrderBy(m => m.Name).ToListAsync();
|
||||||
|
_categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
|
||||||
|
_typeMemberships = await db.CostCategoryMembers.AsNoTracking().Where(m => m.EnergyTypeId != null).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Categories that already count this meter through its energy type. They are named rather than
|
||||||
|
/// offered as a choice: the meter cannot leave them here, only the energy type can.
|
||||||
|
/// </summary>
|
||||||
|
private string CategoriesHelp
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var inherited = _categories
|
||||||
|
.Where(c => _typeMemberships.Any(m => m.CategoryId == c.Id && m.EnergyTypeId == _working.EnergyTypeId))
|
||||||
|
.Select(c => c.Name)
|
||||||
|
.ToList();
|
||||||
|
return inherited.Count == 0
|
||||||
|
? S.Meters_CostCategoriesHelp
|
||||||
|
: Loc.F(S.Meters_CostCategoriesInherited, string.Join(", ", inherited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CategoryText(IReadOnlyList<string> ids) =>
|
||||||
|
string.Join(", ", ids.Select(idText =>
|
||||||
|
int.TryParse(idText, out var id) ? _categories.FirstOrDefault(c => c.Id == id)?.Name ?? idText : idText));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A new meter takes its energy type's default mode and base unit, so an oil meter does not
|
||||||
|
/// silently land on "cumulative counter" in kWh. An edited meter keeps what it has.
|
||||||
|
/// </summary>
|
||||||
|
private void OnEnergyTypeChanged(short typeId)
|
||||||
|
{
|
||||||
|
var previous = _energyTypes.FirstOrDefault(t => t.Id == _working.EnergyTypeId);
|
||||||
|
_working.EnergyTypeId = typeId;
|
||||||
|
if (_working.Id == 0 && _energyTypes.FirstOrDefault(t => t.Id == typeId) is { } type)
|
||||||
|
{
|
||||||
|
ApplyTypeDefaults(type, previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyTypeDefaults(EnergyType type, EnergyType? previous)
|
||||||
|
{
|
||||||
|
_working.EnergyTypeId = type.Id;
|
||||||
|
_working.Mode = type.DefaultMode;
|
||||||
|
// Only replace a unit the user has not typed themselves.
|
||||||
|
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.Unit == previous?.BaseUnit)
|
||||||
|
{
|
||||||
|
_working.Unit = type.BaseUnit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
|
||||||
|
private IEnumerable<Meter> AvailableUpstream()
|
||||||
|
{
|
||||||
|
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 async Task RecordSwapInsteadAsync()
|
||||||
|
{
|
||||||
|
var meterId = _working.Id;
|
||||||
|
_open = false;
|
||||||
|
await SwapInsteadRequested.InvokeAsync(meterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.Meters_RequiredFields, Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_working.Mode == MeterMode.ConsumableBalance && _working.WantsTank && _working.TankCapacity is not > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add(S.Meters_TankCapacityRequired, Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_saving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
// One unit: the meter, its tank, its flow links and its categories are saved together or not at
|
||||||
|
// all — a failure halfway must not leave a meter with half its settings.
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
var created = _working.Id == 0;
|
||||||
|
int meterId;
|
||||||
|
if (created)
|
||||||
|
{
|
||||||
|
var meter = new Meter
|
||||||
|
{
|
||||||
|
Name = _working.Name.Trim(),
|
||||||
|
EnergyTypeId = _working.EnergyTypeId,
|
||||||
|
Mode = _working.Mode,
|
||||||
|
Unit = _working.Unit.Trim(),
|
||||||
|
InitialBaseline = _working.Mode == MeterMode.ConsumableBalance ? 0 : _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;
|
||||||
|
await SaveTankAsync(db, meterId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
await SaveTankAsync(db, existing.Id);
|
||||||
|
|
||||||
|
if (_working.RecomputeNeeded)
|
||||||
|
{
|
||||||
|
var normalization = new NormalizationService(db, Engine, Options);
|
||||||
|
await normalization.RecomputeMeterAsync(existing.Id, null);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
meterId = existing.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SyncUpstreamAsync(db, meterId, _working.Upstream);
|
||||||
|
await SyncCategoriesAsync(db, meterId, _working.Categories);
|
||||||
|
await tx.CommitAsync();
|
||||||
|
|
||||||
|
_open = false;
|
||||||
|
Snackbar.Add(S.Common_Saved, Severity.Success);
|
||||||
|
await Saved.InvokeAsync((meterId, created));
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// Something this dialog relied on changed meanwhile (a category or meter deleted in another
|
||||||
|
// tab). Nothing was saved; show what is there now rather than end the circuit.
|
||||||
|
Snackbar.Add(S.Meters_ChangedMeanwhile, Severity.Warning);
|
||||||
|
if (_working.Id != 0)
|
||||||
|
{
|
||||||
|
await OpenAsync(_working.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await LoadListsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates or updates the tank for a consumable meter. A tank is left alone when the meter moves
|
||||||
|
/// to another mode — its calibration is configuration somebody measured, not something to lose
|
||||||
|
/// on a misclick in a mode select.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveTankAsync(MeterVaultDbContext db, int meterId)
|
||||||
|
{
|
||||||
|
if (_working.Mode != MeterMode.ConsumableBalance || !_working.WantsTank || _working.TankCapacity is not { } capacity)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tank = await db.Tanks.FirstOrDefaultAsync(t => t.MeterId == meterId);
|
||||||
|
if (tank is null)
|
||||||
|
{
|
||||||
|
tank = new Tank { MeterId = meterId };
|
||||||
|
db.Tanks.Add(tank);
|
||||||
|
}
|
||||||
|
|
||||||
|
tank.Capacity = capacity;
|
||||||
|
tank.Unit = TankUnitLabel;
|
||||||
|
tank.Calibration = MeterConfigFactory.SerializeCalibration(
|
||||||
|
_working.VolumePerCm is { } perCm ? new CalibrationCurve(perCm, _working.CalibrationOffset) : null);
|
||||||
|
tank.RateMode = _working.RateMode;
|
||||||
|
tank.FixedRate = _working.RateMode == TankRateMode.Fixed ? _working.FixedRate : null;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
|
||||||
|
private static async Task SyncUpstreamAsync(MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
|
||||||
|
{
|
||||||
|
var wanted = desiredUpstream.Where(id => id != meterId).ToList();
|
||||||
|
// Against the database, not the list the dialog loaded: a meter deleted meanwhile is not linked.
|
||||||
|
var desired = (await db.Meters.Where(m => wanted.Contains(m.Id)).Select(m => m.Id).ToListAsync()).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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reconciles the meter's own cost-category memberships to the selection. Memberships a category
|
||||||
|
/// holds through an energy type are the category's, not the meter's, and are left alone.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task SyncCategoriesAsync(MeterVaultDbContext db, int meterId, IEnumerable<int> desiredCategories)
|
||||||
|
{
|
||||||
|
// Against the database, not the list the dialog loaded: a category deleted meanwhile is not re-added
|
||||||
|
// under a dangling id.
|
||||||
|
var wanted = desiredCategories.ToList();
|
||||||
|
var desired = (await db.CostCategories.Where(c => wanted.Contains(c.Id)).Select(c => c.Id).ToListAsync()).ToHashSet();
|
||||||
|
var existing = await db.CostCategoryMembers.Where(m => m.MeterId == meterId).ToListAsync();
|
||||||
|
|
||||||
|
foreach (var member in existing.Where(m => !desired.Contains(m.CategoryId)))
|
||||||
|
{
|
||||||
|
db.CostCategoryMembers.Remove(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var categoryId in desired.Where(id => existing.All(m => m.CategoryId != id)))
|
||||||
|
{
|
||||||
|
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categoryId, MeterId = meterId });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bool OriginalIsActive { get; set; } = true;
|
||||||
|
public IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
|
||||||
|
public IReadOnlyCollection<int> Categories { get; set; } = new HashSet<int>();
|
||||||
|
|
||||||
|
public bool HasTank { get; set; }
|
||||||
|
public double? TankCapacity { get; set; }
|
||||||
|
public string TankUnit { get; set; } = "";
|
||||||
|
public double? VolumePerCm { get; set; }
|
||||||
|
public double CalibrationOffset { get; set; }
|
||||||
|
public TankRateMode RateMode { get; set; } = TankRateMode.Empirical;
|
||||||
|
public double? FixedRate { get; set; }
|
||||||
|
public string OriginalTank { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>A tank is kept once it exists, and created as soon as any of its fields is filled in.</summary>
|
||||||
|
public bool WantsTank => HasTank || TankCapacity is not null || VolumePerCm is not null || FixedRate is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The tank fields that change this meter's derived consumption: the calibration turns levels
|
||||||
|
/// into volume. The burner rate only feeds the Consumables panel's rate figure.
|
||||||
|
/// </summary>
|
||||||
|
public string TankSignature => FormattableString.Invariant($"{VolumePerCm}|{CalibrationOffset}");
|
||||||
|
|
||||||
|
public bool RecomputeNeeded =>
|
||||||
|
Mode != OriginalMode
|
||||||
|
|| Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9
|
||||||
|
|| (Mode == MeterMode.ConsumableBalance && WantsTank && TankSignature != OriginalTank);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
@using System.Globalization
|
||||||
|
@using Microsoft.Extensions.DependencyInjection
|
||||||
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
|
@inject IServiceScopeFactory Scopes
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||||
|
@inject ILogger<MeterEventDialog> Logger
|
||||||
|
|
||||||
|
@* Records a meter lifecycle event: a swap or reset on a register, a level or delivery on a tank, or a
|
||||||
|
note on anything. Everything it books is explained before saving — the old register's tail, where
|
||||||
|
later readings count from, what a dipstick level means in litres — because each of these is the
|
||||||
|
kind of entry whose mistake only shows up weeks later as a spike in a chart. *@
|
||||||
|
<MudDialog Visible="_open" VisibleChanged="OnVisibleChangedAsync" Options="_options">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@Loc.F(S.MeterEvent_Title, _type.Display(), MeterName)</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="d-block mb-3">@MeterEventText.Intro(_type)</MudText>
|
||||||
|
|
||||||
|
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
|
||||||
|
<MudDatePicker Date="_when.Date" DateChanged="OnDateChangedAsync" Label="@S.Common_Date" Variant="Variant.Outlined"
|
||||||
|
Class="flex-grow-1" Style="min-width:150px" />
|
||||||
|
<MudTimePicker Time="_when.TimeOfDay" TimeChanged="OnTimeChangedAsync" Label="@S.MeterDetail_TimeOfDay" Variant="Variant.Outlined"
|
||||||
|
Class="flex-grow-1" Style="min-width:130px" />
|
||||||
|
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNowAsync">@S.Common_Now</MudButton>
|
||||||
|
</div>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1 mb-3">@Loc.F(S.MeterDetail_LocalTimeIn, _when.Zone.Id)</MudText>
|
||||||
|
|
||||||
|
@if (IsBoundary)
|
||||||
|
{
|
||||||
|
<MudTextField T="string" Value="_prevText" ValueChanged="@(v => _prevText = v)" Immediate="true"
|
||||||
|
Label="@Loc.F(_type == MeterEventType.MeterSwap ? S.MeterEvent_OldFinalLabel : S.MeterEvent_BeforeResetLabel, Unit)"
|
||||||
|
HelperText="@(_type == MeterEventType.MeterSwap ? S.MeterEvent_OldFinalHelp : S.MeterEvent_BeforeResetHelp)"
|
||||||
|
Error="@IsUnparseable(_prevText)" ErrorText="@S.MeterEvent_NotANumber"
|
||||||
|
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="mb-3" />
|
||||||
|
<MudTextField T="string" Value="_newText" ValueChanged="@(v => _newText = v)" Immediate="true"
|
||||||
|
Label="@Loc.F(_type == MeterEventType.MeterSwap ? S.MeterEvent_NewStartLabel : S.MeterEvent_AfterResetLabel, Unit)"
|
||||||
|
HelperText="@S.MeterEvent_NewStartHelp"
|
||||||
|
Error="@IsUnparseable(_newText)" ErrorText="@S.MeterEvent_NotANumber"
|
||||||
|
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="mb-3" />
|
||||||
|
|
||||||
|
@if (_context is { } c)
|
||||||
|
{
|
||||||
|
<MudPaper Outlined="true" Class="pa-3 mb-2">
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@(c.Previous is { } previous
|
||||||
|
? Loc.F(S.MeterEvent_LastReadingBefore, Register(previous.Value), Unit, Stamp(previous.Time))
|
||||||
|
: Loc.F(S.MeterEvent_NoReadingBefore, Register(c.InitialBaseline), Unit))
|
||||||
|
</MudText>
|
||||||
|
@if (c.Tail(Parse(_prevText)) is { } tail)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="@(tail < 0 ? Color.Error : Color.Default)">
|
||||||
|
@Loc.F(S.MeterEvent_TailBooked, Signed(tail), Unit)
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
@if (Parse(_newText) is { } start)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_CountsFrom, Register(start), Unit)</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
@if (c.ReadingsAfter > 0 && c.Next is { } next)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||||
|
@Loc.F(S.MeterEvent_ReadingsAfterWarning,
|
||||||
|
c.ReadingsAfter >= MeterEventService.ReadingsAfterCap ? $"{MeterEventService.ReadingsAfterCap}+" : c.ReadingsAfter.ToString(CultureInfo.CurrentCulture),
|
||||||
|
Register(next.Value), Unit, Stamp(next.Time))
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
@if (c.LiveSources > 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">@Loc.F(S.MeterEvent_LiveSourcesWarning, c.LiveSources)</MudAlert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_type is MeterEventType.TankLevel or MeterEventType.Delivery)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-start" style="gap:.75rem">
|
||||||
|
<MudTextField T="string" Value="_amountText" ValueChanged="@(v => _amountText = v)" Immediate="true"
|
||||||
|
Label="@(_type == MeterEventType.TankLevel ? S.MeterEvent_LevelLabel : Loc.F(S.MeterEvent_DeliveredLabel, TankUnit))"
|
||||||
|
Error="@IsUnparseable(_amountText)" ErrorText="@S.MeterEvent_NotANumber"
|
||||||
|
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="flex-grow-1 mb-3" />
|
||||||
|
@if (_type == MeterEventType.TankLevel)
|
||||||
|
{
|
||||||
|
<MudSelect T="bool" @bind-Value="_centimetres" Label="@S.Common_Unit" Variant="Variant.Outlined"
|
||||||
|
Disabled="@(_context?.Calibration is null)" Style="max-width:110px">
|
||||||
|
<MudSelectItem T="bool" Value="true">cm</MudSelectItem>
|
||||||
|
<MudSelectItem T="bool" Value="false">@TankUnit</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_type == MeterEventType.TankLevel && _context is { } c)
|
||||||
|
{
|
||||||
|
@if (c.Calibration is null)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@S.MeterEvent_NoCalibrationHint</MudText>
|
||||||
|
}
|
||||||
|
<MudPaper Outlined="true" Class="pa-3 mb-2">
|
||||||
|
@if (Parse(_amountText) is { } level && _centimetres)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_LevelVolume, Format.Number(c.ToVolume(level, true), 0), TankUnit)</MudText>
|
||||||
|
}
|
||||||
|
<MudText Typo="Typo.body2">
|
||||||
|
@(c.LastLevel is { } last
|
||||||
|
? Loc.F(S.MeterEvent_LastLevel, $"{Format.Number(last.Amount, 1)} {last.Unit ?? TankUnit}", Format.Number(last.Volume, 0), TankUnit, Stamp(last.Time))
|
||||||
|
: S.MeterEvent_FirstLevel)
|
||||||
|
</MudText>
|
||||||
|
@if (c.DeliveredSinceLastLevel > 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_DeliveredSince, Format.Number(c.DeliveredSinceLastLevel, 0), TankUnit)</MudText>
|
||||||
|
}
|
||||||
|
@if (Parse(_amountText) is { } entered && c.UsedSinceLastLevel(c.ToVolume(entered, _centimetres)) is { } used)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="@(used < 0 ? Color.Warning : Color.Default)">
|
||||||
|
@(used < 0
|
||||||
|
? Loc.F(S.MeterEvent_LevelRose, Format.Number(-used, 0), TankUnit)
|
||||||
|
: Loc.F(S.MeterEvent_UsedSince, Format.Number(used, 0), TankUnit))
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudTextField T="string" @bind-Value="_notes" Immediate="true" Lines="@(_type == MeterEventType.Note ? 3 : 1)"
|
||||||
|
Label="@(_type == MeterEventType.Note ? S.MeterEvent_NoteLabel : S.MeterEvent_NotesOptional)"
|
||||||
|
Variant="Variant.Outlined" Class="mt-1" />
|
||||||
|
|
||||||
|
@if (_when.IsSkipped)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">@Loc.F(S.MeterDetail_SkippedTime, _when.Zone.Id)</MudAlert>
|
||||||
|
}
|
||||||
|
else if (_when.Utc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1))
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
|
||||||
|
}
|
||||||
|
@if (VisibleProblem is { } problem)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">@MeterEventText.Problem(problem)</MudAlert>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="CancelAsync" Disabled="_saving">@S.Common_Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="@(!CanSave)">
|
||||||
|
@(_saving ? S.MeterDetail_Saving : S.Common_Save)
|
||||||
|
</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <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 escape in an attribute as a transition.
|
||||||
|
/// </summary>
|
||||||
|
private const InputMode DecimalKeyboard = InputMode.@decimal;
|
||||||
|
|
||||||
|
private readonly DialogOptions _options = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
|
private bool _open;
|
||||||
|
private bool _saving;
|
||||||
|
private MeterEventType _type = MeterEventType.Note;
|
||||||
|
private LocalTimeEntry _when = new(TimeZoneInfo.Utc);
|
||||||
|
private MeterEventContext? _context;
|
||||||
|
private DateTimeOffset? _contextFor;
|
||||||
|
private int _contextVersion;
|
||||||
|
private string? _prevText;
|
||||||
|
private string? _newText;
|
||||||
|
private string? _amountText;
|
||||||
|
private string? _notes;
|
||||||
|
private bool _centimetres;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public int MeterId { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string MeterName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Raised after an event was stored and the meter recomputed.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<MeterEventType> Saved { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised when the dialog is closed without saving.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback Cancelled { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized() => _when = new LocalTimeEntry(LocalTimeEntry.Resolve(Options.Value.TimeZone));
|
||||||
|
|
||||||
|
/// <summary>Opens the dialog for <paramref name="type"/>, at <paramref name="at"/> or now.</summary>
|
||||||
|
public async Task OpenAsync(MeterEventType type, DateTimeOffset? at = null)
|
||||||
|
{
|
||||||
|
_type = type;
|
||||||
|
_prevText = null;
|
||||||
|
_newText = null;
|
||||||
|
_amountText = null;
|
||||||
|
_notes = null;
|
||||||
|
_context = null;
|
||||||
|
_contextFor = null;
|
||||||
|
if (at is { } instant)
|
||||||
|
{
|
||||||
|
_when.Set(instant);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_when.SetNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
_open = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
await LoadContextAsync();
|
||||||
|
if (_context is { } context)
|
||||||
|
{
|
||||||
|
if (IsBoundary)
|
||||||
|
{
|
||||||
|
// Like the reading dialog: a register's final value usually differs from the last
|
||||||
|
// reading only in its last digits, so correcting a prefill beats typing it out. With no
|
||||||
|
// reading yet, the register starts from the meter's baseline — the same place the
|
||||||
|
// normalizer measures the tail from.
|
||||||
|
_prevText = EntryText(context.RegisterBefore);
|
||||||
|
_newText = "0";
|
||||||
|
}
|
||||||
|
else if (type == MeterEventType.TankLevel)
|
||||||
|
{
|
||||||
|
_centimetres = context.Calibration is not null
|
||||||
|
&& (context.LastLevel is null || string.Equals(context.LastLevel.Unit, "cm", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsBoundary => MeterEventRules.IsRegisterBoundary(_type);
|
||||||
|
|
||||||
|
private string Unit => _context?.Unit ?? string.Empty;
|
||||||
|
|
||||||
|
private string TankUnit => _context?.TankUnit ?? string.Empty;
|
||||||
|
|
||||||
|
private MeterEventDraft? Draft => _when.Utc is { } utc
|
||||||
|
? new MeterEventDraft(_type, utc)
|
||||||
|
{
|
||||||
|
PrevValue = IsBoundary ? Parse(_prevText) : null,
|
||||||
|
NewValue = IsBoundary ? Parse(_newText) : null,
|
||||||
|
Amount = IsBoundary ? null : Parse(_amountText),
|
||||||
|
Unit = _type == MeterEventType.TankLevel && _centimetres ? "cm" : null,
|
||||||
|
Notes = _notes,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>The service's verdict on the current input, from the context for the time shown.</summary>
|
||||||
|
private MeterEventProblem Problem =>
|
||||||
|
_context is { } context && Draft is { } draft && _contextFor == draft.Time
|
||||||
|
? MeterEventService.Validate(context, draft)
|
||||||
|
: MeterEventProblem.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Problems worth an alert. An empty required field is already obvious from the disabled Save
|
||||||
|
/// button, and shouting about it before the user has typed anything is just noise.
|
||||||
|
/// </summary>
|
||||||
|
private MeterEventProblem? VisibleProblem =>
|
||||||
|
Problem is MeterEventProblem.None or MeterEventProblem.AmountRequired or MeterEventProblem.NoteRequired ? null : Problem;
|
||||||
|
|
||||||
|
private bool RequiredFilled => _type switch
|
||||||
|
{
|
||||||
|
MeterEventType.MeterSwap or MeterEventType.CounterReset => !string.IsNullOrWhiteSpace(_newText),
|
||||||
|
MeterEventType.TankLevel or MeterEventType.Delivery => !string.IsNullOrWhiteSpace(_amountText),
|
||||||
|
_ => !string.IsNullOrWhiteSpace(_notes),
|
||||||
|
};
|
||||||
|
|
||||||
|
private bool CanSave =>
|
||||||
|
!_saving
|
||||||
|
&& _context is not null
|
||||||
|
&& _when.Utc is { } utc && _contextFor == utc
|
||||||
|
&& RequiredFilled
|
||||||
|
&& !IsUnparseable(_prevText) && !IsUnparseable(_newText) && !IsUnparseable(_amountText)
|
||||||
|
&& Problem == MeterEventProblem.None;
|
||||||
|
|
||||||
|
private async Task OnDateChangedAsync(DateTime? date)
|
||||||
|
{
|
||||||
|
_when.Date = date;
|
||||||
|
await LoadContextAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnTimeChangedAsync(TimeSpan? time)
|
||||||
|
{
|
||||||
|
_when.TimeOfDay = time;
|
||||||
|
await LoadContextAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetNowAsync()
|
||||||
|
{
|
||||||
|
_when.SetNow();
|
||||||
|
await LoadContextAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-reads the surroundings of the chosen time. Versioned, because the pickers can change faster
|
||||||
|
/// than the queries return, and a late answer for an earlier time must not overwrite a newer one.
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadContextAsync()
|
||||||
|
{
|
||||||
|
if (_when.Utc is not { } utc)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var version = ++_contextVersion;
|
||||||
|
await using var scope = Scopes.CreateAsyncScope();
|
||||||
|
var service = scope.ServiceProvider.GetRequiredService<MeterEventService>();
|
||||||
|
var context = await service.GetContextAsync(MeterId, utc);
|
||||||
|
if (version == _contextVersion)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_contextFor = utc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (!CanSave || Draft is not { } draft)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_saving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A scope per save: the service holds a scoped DbContext, and a circuit far outlives the
|
||||||
|
// single unit of work a save should share one with.
|
||||||
|
await using var scope = Scopes.CreateAsyncScope();
|
||||||
|
var service = scope.ServiceProvider.GetRequiredService<MeterEventService>();
|
||||||
|
MeterEventResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await service.RecordAsync(MeterId, draft);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The service's transaction has already rolled back; an unhandled exception here would
|
||||||
|
// take the whole circuit down for a problem the user can simply retry or correct.
|
||||||
|
Logger.LogError(ex, "Recording a {Type} event on meter {MeterId} failed", _type, MeterId);
|
||||||
|
Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
|
||||||
|
await LoadContextAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
Snackbar.Add(MeterEventText.Problem(result.Problem), Severity.Error);
|
||||||
|
await LoadContextAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.Add(MeterEventText.Saved(_type), Severity.Success);
|
||||||
|
_open = false;
|
||||||
|
await Saved.InvokeAsync(_type);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CancelAsync()
|
||||||
|
{
|
||||||
|
_open = false;
|
||||||
|
await Cancelled.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnVisibleChangedAsync(bool visible)
|
||||||
|
{
|
||||||
|
if (!visible && _open)
|
||||||
|
{
|
||||||
|
await CancelAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Stamp(DateTimeOffset instant) => _when.Local(instant).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static double? Parse(string? text) =>
|
||||||
|
!string.IsNullOrWhiteSpace(text) && ReadingEntry.TryParse(text, out var value) ? value : null;
|
||||||
|
|
||||||
|
private static bool IsUnparseable(string? text) => !string.IsNullOrWhiteSpace(text) && !ReadingEntry.TryParse(text, out _);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A register value as editable text, to nine decimals: enough to round-trip what a sensor stored
|
||||||
|
/// (a prefill rounded to three would fail its own "not below the last reading" check), while
|
||||||
|
/// trimming binary noise such as 861.1234000000001.
|
||||||
|
/// </summary>
|
||||||
|
private static string EntryText(double value) => value.ToString("0.#########", CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
|
/// <summary>A register value for display, at the same precision the checks use.</summary>
|
||||||
|
private static string Register(double value) => value.ToString("#,##0.#########", CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
|
private static string Signed(double value) => $"{(value >= 0 ? "+" : "−")}{Format.Number(Math.Abs(value), 3)}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
|
@* Reachable from every page through the app bar: type part of a name, serial number or location and
|
||||||
|
either open the meter or go straight to entering its reading. Two taps from anywhere to the
|
||||||
|
keypad, which is what standing in a basement with a phone calls for. *@
|
||||||
|
<MudDialog>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField T="string" Value="_search" ValueChanged="@(v => _search = v)" Immediate="true" AutoFocus="true"
|
||||||
|
Placeholder="@S.MeterSearch_Placeholder" Variant="Variant.Outlined" Clearable="true"
|
||||||
|
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||||
|
OnKeyDown="OnKeyDown" Class="mb-2" />
|
||||||
|
|
||||||
|
@if (_meters is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else if (_meters.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="pa-2">@S.MeterSearch_NoMeters</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var matches = Matches().ToList();
|
||||||
|
@if (matches.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="pa-2">@Loc.F(S.Meters_NoSearchMatch, _search)</MudText>
|
||||||
|
}
|
||||||
|
<div style="max-height:60vh; overflow-y:auto">
|
||||||
|
@foreach (var meter in matches.Take(MaxShown))
|
||||||
|
{
|
||||||
|
var hit = meter;
|
||||||
|
<div class="d-flex align-center px-2 py-1">
|
||||||
|
@* A focusable control of its own, beside (not around) the quick-entry button, so every
|
||||||
|
result is reachable and announced from the keyboard, not only the first. *@
|
||||||
|
<div class="flex-grow-1 mud-list-item-clickable" role="button" tabindex="0"
|
||||||
|
style="min-width:0; cursor:pointer; border-radius:4px"
|
||||||
|
@onclick="@(() => Go(MeterLinks.Detail(hit.Id)))"
|
||||||
|
@onkeydown="@(e => OnResultKey(e, hit))">
|
||||||
|
<MudText Typo="Typo.body1" Style="@(hit.IsActive ? null : "opacity:.6")">@hit.Name</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">@Describe(hit)</MudText>
|
||||||
|
</div>
|
||||||
|
@if (MeterLinks.QuickEntry(hit.Id, hit.Mode) is { } entry)
|
||||||
|
{
|
||||||
|
<MudTooltip Text="@(hit.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
|
||||||
|
<MudIconButton Icon="@(hit.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
|
||||||
|
Color="Color.Primary" OnClick="@(() => Go(entry))"
|
||||||
|
aria-label="@(hit.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
|
||||||
|
</MudTooltip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (matches.Count > MaxShown)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block pa-2">@Loc.F(S.MeterSearch_More, MaxShown, matches.Count)</MudText>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private const int MaxShown = 30;
|
||||||
|
|
||||||
|
private List<MeterHit>? _meters;
|
||||||
|
private string? _search;
|
||||||
|
|
||||||
|
[CascadingParameter]
|
||||||
|
private IMudDialogInstance? Dialog { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var meters = await db.Meters.AsNoTracking()
|
||||||
|
.Select(m => new MeterHit(m.Id, m.Name, m.EnergyType != null ? m.EnergyType.DisplayName : "—", m.Mode, m.IsActive, m.SerialNumber, m.Location))
|
||||||
|
.ToListAsync();
|
||||||
|
_meters = meters
|
||||||
|
.OrderBy(m => !m.IsActive)
|
||||||
|
.ThenBy(m => m.EnergyType, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<MeterHit> Matches()
|
||||||
|
{
|
||||||
|
if (_meters is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_search))
|
||||||
|
{
|
||||||
|
return _meters;
|
||||||
|
}
|
||||||
|
|
||||||
|
var term = _search.Trim();
|
||||||
|
return _meters.Where(m => Has(m.Name) || Has(m.SerialNumber) || Has(m.Location) || Has(m.EnergyType));
|
||||||
|
|
||||||
|
bool Has(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe(MeterHit meter)
|
||||||
|
{
|
||||||
|
var parts = new List<string> { meter.EnergyType, meter.Mode.Display() };
|
||||||
|
if (!string.IsNullOrWhiteSpace(meter.SerialNumber))
|
||||||
|
{
|
||||||
|
parts.Add(Loc.F(S.MeterDetail_SerialValue, meter.SerialNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(meter.Location))
|
||||||
|
{
|
||||||
|
parts.Add(meter.Location);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meter.IsActive)
|
||||||
|
{
|
||||||
|
parts.Add(S.MeterDetail_Retired);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(" · ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enter opens the first match, so a typed name plus Enter is the whole interaction on a keyboard.</summary>
|
||||||
|
private void OnKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter" && Matches().FirstOrDefault() is { } first)
|
||||||
|
{
|
||||||
|
Go(MeterLinks.Detail(first.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnResultKey(KeyboardEventArgs e, MeterHit hit)
|
||||||
|
{
|
||||||
|
if (e.Key is "Enter" or " ")
|
||||||
|
{
|
||||||
|
Go(MeterLinks.Detail(hit.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Go(string href)
|
||||||
|
{
|
||||||
|
Dialog?.Close();
|
||||||
|
Nav.NavigateTo(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record MeterHit(int Id, string Name, string EnergyType, MeterMode Mode, bool IsActive, string? SerialNumber, string? Location);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
@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">@S.Sankey_NoFlow</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, NodeLabel);
|
||||||
|
|
||||||
|
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=\"{Enc(S.Sankey_AriaLabel)}\">");
|
||||||
|
|
||||||
|
// 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(NodeLabel(node));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A remainder node carries only its parent meter's name (see <see cref="FlowNode"/>); the
|
||||||
|
/// "Other (…)" framing is added here, where the reader's language is known.
|
||||||
|
/// </summary>
|
||||||
|
private static string NodeLabel(FlowNode node) =>
|
||||||
|
node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label;
|
||||||
|
|
||||||
|
private string Fmt(double value) => $"{Format.Number(value)} {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">@S.Common_NoDataInRange</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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,14 +7,14 @@
|
|||||||
<ApexPointSeries TItem="TrendPoint"
|
<ApexPointSeries TItem="TrendPoint"
|
||||||
Items="Points"
|
Items="Points"
|
||||||
SeriesType="SeriesType.Bar"
|
SeriesType="SeriesType.Bar"
|
||||||
Name="Monthly cost"
|
Name="@S.TrendChart_MonthlyCost"
|
||||||
XValue="Label"
|
XValue="Label"
|
||||||
YValue="p => (decimal)Math.Round(p.Cost, 2)" />
|
YValue="p => (decimal)Math.Round(p.Cost, 2)" />
|
||||||
</ApexChart>
|
</ApexChart>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
|
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">@S.Common_NoDataInRange</MudText>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
@@ -27,5 +27,6 @@ else
|
|||||||
DataLabels = new DataLabels { Enabled = false },
|
DataLabels = new DataLabels { Enabled = false },
|
||||||
};
|
};
|
||||||
|
|
||||||
private static object Label(TrendPoint p) => p.Period.ToString("MMM yy", CultureInfo.InvariantCulture);
|
// Qualified: ApexCharts also exports a `Format`, and this file imports it.
|
||||||
|
private static object Label(TrendPoint p) => MeterVault.App.Format.MonthLabel(p.Period);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
@using System.Net
|
||||||
|
@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>@((MarkupString)Loc.F(S.UpdateBanner_ReleaseAvailable, Bold(latest), Bold(running)))</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)">
|
||||||
|
@S.UpdateBanner_UpdateNow
|
||||||
|
</MudButton>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<code style="opacity:.85">@_command</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_confirmOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent><MudText Typo="Typo.h6">@S.UpdateBanner_DialogTitle</MudText></TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudText Typo="Typo.body2" Class="mb-3">
|
||||||
|
@S.UpdateBanner_DialogBody
|
||||||
|
</MudText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">@S.Common_Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync" Disabled="_starting">
|
||||||
|
@(_starting ? S.UpdateBanner_Starting : S.UpdateBanner_UpdateNow)
|
||||||
|
</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(OutcomeText(launch), launch.Started ? Severity.Success : Severity.Error);
|
||||||
|
if (launch.Started)
|
||||||
|
{
|
||||||
|
_confirmOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLaunch.Message stays English for the log; the reader gets the outcome in their own
|
||||||
|
// language, with the launcher's own diagnostic text appended untranslated — it comes from
|
||||||
|
// systemd, not from us, and mangling it would make it unsearchable.
|
||||||
|
private static string OutcomeText(UpdateLaunch launch) => launch.Outcome switch
|
||||||
|
{
|
||||||
|
UpdateOutcome.Started => S.Update_LaunchStarted,
|
||||||
|
UpdateOutcome.NotAllowed => Loc.F(
|
||||||
|
S.Update_LaunchNotAllowed,
|
||||||
|
launch.Availability?.Display() ?? string.Empty),
|
||||||
|
UpdateOutcome.LauncherMissing => S.Update_LaunchLauncherMissing,
|
||||||
|
UpdateOutcome.LauncherFailed => launch.Detail is { Length: > 0 } detail
|
||||||
|
? Loc.F(S.Update_LaunchFailedWithDetail, detail)
|
||||||
|
: S.Update_LaunchFailed,
|
||||||
|
_ => launch.Detail is { Length: > 0 } error
|
||||||
|
? Loc.F(S.Update_LaunchFailedWithDetail, error)
|
||||||
|
: S.Update_LaunchFailed,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
? S.UpdateBanner_HintRunUpdate
|
||||||
|
: S.UpdateBanner_HintPullImage;
|
||||||
|
|
||||||
|
/// <summary>Emphasises a version inside the banner sentence without letting it carry markup.</summary>
|
||||||
|
private static string Bold(object value) => $"<b>{WebUtility.HtmlEncode(value.ToString())}</b>";
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
@using MeterVault.App
|
@using MeterVault.App
|
||||||
|
@using MeterVault.App.Localization
|
||||||
|
@* UI strings live in Localization/Strings.resx and are reached through this alias: @S.Common_Save.
|
||||||
|
Compiled properties, so a stale key fails the build. @Loc.F(...) formats the {0} ones. *@
|
||||||
|
@using S = MeterVault.App.Localization.Strings
|
||||||
@using MeterVault.App.Components
|
@using MeterVault.App.Components
|
||||||
@using MeterVault.App.Components.Layout
|
@using MeterVault.App.Components.Layout
|
||||||
@using MeterVault.App.Components.Shared
|
@using MeterVault.App.Components.Shared
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using MeterVault.App.Localization;
|
||||||
|
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: Strings.Common_Delete, cancelText: Strings.Common_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: Strings.Common_Cancel)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace MeterVault.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unsaved dialog input that has to survive a detour to another page of the same circuit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A meter's source dialog sends the user off to create the connector the source needs, and the meter
|
||||||
|
/// page is disposed on the way. Keeping the draft here, keyed by what it belongs to, lets the dialog come
|
||||||
|
/// back with everything that was typed instead of empty. Scoped: it lives as long as the circuit and is
|
||||||
|
/// never shared between users.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class DraftStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, object> _drafts = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public void Save(string key, object draft) => _drafts[key] = draft;
|
||||||
|
|
||||||
|
/// <summary>Hands back a draft once; it is gone afterwards.</summary>
|
||||||
|
public bool TryTake<T>(string key, [NotNullWhen(true)] out T? draft)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (_drafts.Remove(key, out var stored) && stored is T typed)
|
||||||
|
{
|
||||||
|
draft = typed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Discard(string key) => _drafts.Remove(key);
|
||||||
|
}
|
||||||
+23
-6
@@ -2,18 +2,35 @@ using System.Globalization;
|
|||||||
|
|
||||||
namespace MeterVault.App;
|
namespace MeterVault.App;
|
||||||
|
|
||||||
/// <summary>Small display formatters for the UI (locale-aware formatting arrives with i18n in M7).</summary>
|
/// <summary>
|
||||||
|
/// Small display formatters for the UI.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything formats against <see cref="CultureInfo.CurrentCulture"/>, which the request
|
||||||
|
/// localization middleware sets from the reader's chosen UI language — so 1234.5 renders as
|
||||||
|
/// "1.234,5" for a German reader and "1,234.5" for an English one, from the same call site. This is
|
||||||
|
/// display only: the CSV importer still parses the spreadsheet dialect with an explicit de-DE
|
||||||
|
/// culture (<see cref="Core.Parsing.GermanNumber"/>), because that dialect is a property of the
|
||||||
|
/// files, not of who is looking at them.
|
||||||
|
/// </remarks>
|
||||||
public static class Format
|
public static class Format
|
||||||
{
|
{
|
||||||
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("de-DE");
|
/// <summary>
|
||||||
|
/// A money amount. The symbol is the euro rather than the culture's own, because the figure is
|
||||||
public static string Euro(double value) => value.ToString("N2", Culture) + " €";
|
/// in the instance's configured currency — switching UI language must not restate the amount as
|
||||||
|
/// dollars. Only the digit grouping follows the reader.
|
||||||
|
/// </summary>
|
||||||
|
public static string Euro(double value) => value.ToString("N2", CultureInfo.CurrentCulture) + " €";
|
||||||
|
|
||||||
public static string Number(double value, int decimals = 0) =>
|
public static string Number(double value, int decimals = 0) =>
|
||||||
value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), Culture);
|
value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
public static string Percent(double value) =>
|
public static string Percent(double value) =>
|
||||||
(value >= 0 ? "+" : "") + value.ToString("N1", Culture) + " %";
|
(value >= 0 ? "+" : "") + value.ToString("N1", CultureInfo.CurrentCulture) + " %";
|
||||||
|
|
||||||
|
/// <summary>A month label for chart axes and period lists ("Mrz 25" / "Mar 25").</summary>
|
||||||
|
public static string MonthLabel(DateOnly month) =>
|
||||||
|
month.ToString("MMM yy", CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
public static string DirectionIcon(int direction) => direction switch
|
public static string DirectionIcon(int direction) => direction switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
namespace MeterVault.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A date and time of day as typed into a pair of pickers, read in the instance timezone. Values
|
||||||
|
/// are stored UTC (SDD §10) but entered as wall-clock time, so a reading taken at 18:00 reads back
|
||||||
|
/// as 18:00 rather than as its UTC instant.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Shared by every dialog that timestamps something by hand — a reading, a swap, a delivery — so
|
||||||
|
/// they agree on the two awkward hours of the year: a spring-forward time names no instant at all
|
||||||
|
/// and is refused, and an ambiguous autumn time resolves to standard time,
|
||||||
|
/// <see cref="TimeZoneInfo"/>'s default. Those two candidate instants are an hour apart on one night
|
||||||
|
/// a year, well inside the precision of a timestamp somebody typed.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LocalTimeEntry(TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
public TimeZoneInfo Zone { get; } = zone;
|
||||||
|
|
||||||
|
/// <summary>Bound to the date picker; only the date part is used.</summary>
|
||||||
|
public DateTime? Date { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bound to the time picker; minute precision.</summary>
|
||||||
|
public TimeSpan? TimeOfDay { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The wall-clock moment the pickers describe, or null until a date is chosen.</summary>
|
||||||
|
public DateTime? WallClock => Date is { } date ? date.Date + (TimeOfDay ?? TimeSpan.Zero) : null;
|
||||||
|
|
||||||
|
/// <summary>True when the wall-clock time falls in a spring-forward gap and so names no instant.</summary>
|
||||||
|
public bool IsSkipped =>
|
||||||
|
WallClock is { } wall && Zone.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
|
||||||
|
|
||||||
|
/// <summary>The UTC instant entered, or null while incomplete or skipped.</summary>
|
||||||
|
public DateTimeOffset? Utc
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (WallClock is not { } wall || IsSkipped)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unspecified = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
|
||||||
|
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, Zone), TimeSpan.Zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolves the configured timezone id, falling back to UTC for an unknown one.</summary>
|
||||||
|
public static TimeZoneInfo Resolve(string? id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id))
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.Utc;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.FindSystemTimeZoneById(id);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||||
|
{
|
||||||
|
return TimeZoneInfo.Utc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets the pickers to <paramref name="instant"/> in the instance timezone, dropping seconds.</summary>
|
||||||
|
public void Set(DateTimeOffset instant)
|
||||||
|
{
|
||||||
|
var local = TimeZoneInfo.ConvertTime(instant, Zone);
|
||||||
|
Date = local.Date;
|
||||||
|
TimeOfDay = new TimeSpan(local.Hour, local.Minute, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetNow() => Set(DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>An instant as the instance's wall clock, for display.</summary>
|
||||||
|
public DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, Zone);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.AspNetCore.Localization;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Localization;
|
||||||
|
|
||||||
|
/// <summary>The endpoint behind the language picker in the app bar.</summary>
|
||||||
|
public static class CultureEndpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Persists a UI language and returns the user to where they were.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A redirect rather than an interactive state change on purpose: a Blazor Server circuit
|
||||||
|
/// captures <see cref="System.Globalization.CultureInfo.CurrentUICulture"/> from the request
|
||||||
|
/// that opened it, so switching language has to re-establish the circuit. The picker therefore
|
||||||
|
/// navigates here with <c>forceLoad</c>, this writes the culture cookie the localization
|
||||||
|
/// middleware reads, and the reload comes back in the new language.
|
||||||
|
/// </remarks>
|
||||||
|
public static IEndpointRouteBuilder MapCultureEndpoints(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(endpoints);
|
||||||
|
|
||||||
|
endpoints.MapGet("/culture/set", (HttpContext http, string? culture, string? redirectUri) =>
|
||||||
|
{
|
||||||
|
// Only ever store a language we ship, so a hand-edited link can't park an unusable
|
||||||
|
// culture in the cookie and leave the UI stuck in fallback.
|
||||||
|
if (!Loc.TryResolve(culture, out var resolved))
|
||||||
|
{
|
||||||
|
return Results.BadRequest($"Unsupported culture '{culture}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response.Cookies.Append(
|
||||||
|
CookieRequestCultureProvider.DefaultCookieName,
|
||||||
|
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(resolved)),
|
||||||
|
new CookieOptions
|
||||||
|
{
|
||||||
|
Path = "/",
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||||
|
SameSite = SameSiteMode.Lax,
|
||||||
|
// Read only by the localization middleware, never by script.
|
||||||
|
HttpOnly = true,
|
||||||
|
// A language preference is exempt from consent gating, and the app is
|
||||||
|
// self-hosted and single-user anyway.
|
||||||
|
IsEssential = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anything but a local path is refused rather than followed: this endpoint takes a
|
||||||
|
// redirect target from the query string, which is exactly the shape of an open redirect.
|
||||||
|
return Results.LocalRedirect(IsLocalPath(redirectUri) ? redirectUri! : "/");
|
||||||
|
})
|
||||||
|
.ExcludeFromDescription();
|
||||||
|
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The framework's own local-URL rule: rooted, and not the "//host" or "/\host" forms browsers
|
||||||
|
/// resolve as protocol-relative absolute URLs.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsLocalPath(string? url) =>
|
||||||
|
!string.IsNullOrEmpty(url)
|
||||||
|
&& url[0] == '/'
|
||||||
|
&& (url.Length == 1 || (url[1] != '/' && url[1] != '\\'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Import;
|
||||||
|
using MeterVault.Infrastructure.Update;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Localization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Human wording for the domain enums the UI puts on screen.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The enums themselves stay bare identifiers: they are persisted as text (SDD §5.2) and appear in
|
||||||
|
/// the REST API, so their names are part of the data contract and must not move with the reader's
|
||||||
|
/// language. This is the one place that decides how each value is <em>spoken</em>, which keeps the
|
||||||
|
/// domain layer free of presentation and gives every page the same word for the same concept —
|
||||||
|
/// a <c>TariffComponent.UnitPrice</c> is "Arbeitspreis" in the table, the dropdown and the
|
||||||
|
/// confirm dialog alike.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every arm resolves a <c>Enum_<Type>_<Value></c> resource. The fallback arms return the
|
||||||
|
/// identifier rather than throwing, so adding an enum value can never crash a dashboard — and
|
||||||
|
/// <c>EnumDisplayNameTests</c> fails the build if one is ever left without a translation, which is
|
||||||
|
/// what stops that safety net from quietly becoming the shipping behaviour.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class DisplayNames
|
||||||
|
{
|
||||||
|
/// <summary>The enums this class is responsible for; the resource-coverage test walks this list.</summary>
|
||||||
|
public static IReadOnlyList<Type> LocalizedEnums { get; } =
|
||||||
|
[
|
||||||
|
typeof(MeterMode),
|
||||||
|
typeof(ConsumptionKind),
|
||||||
|
typeof(ReadingQuality),
|
||||||
|
typeof(ReadingFlags),
|
||||||
|
typeof(MeterEventType),
|
||||||
|
typeof(SourceType),
|
||||||
|
typeof(SourceValueKind),
|
||||||
|
typeof(TariffScope),
|
||||||
|
typeof(TariffComponent),
|
||||||
|
typeof(TankRateMode),
|
||||||
|
typeof(EndpointType),
|
||||||
|
typeof(MappingRole),
|
||||||
|
typeof(UpdateAvailability),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static string Display(this MeterMode value) => value switch
|
||||||
|
{
|
||||||
|
MeterMode.CumulativeCounter => Strings.Enum_MeterMode_CumulativeCounter,
|
||||||
|
MeterMode.GenerationCounter => Strings.Enum_MeterMode_GenerationCounter,
|
||||||
|
MeterMode.RuntimeCounter => Strings.Enum_MeterMode_RuntimeCounter,
|
||||||
|
MeterMode.ConsumableBalance => Strings.Enum_MeterMode_ConsumableBalance,
|
||||||
|
MeterMode.DirectDelta => Strings.Enum_MeterMode_DirectDelta,
|
||||||
|
MeterMode.InstantRate => Strings.Enum_MeterMode_InstantRate,
|
||||||
|
MeterMode.Virtual => Strings.Enum_MeterMode_Virtual,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this ConsumptionKind value) => value switch
|
||||||
|
{
|
||||||
|
ConsumptionKind.Consumption => Strings.Enum_ConsumptionKind_Consumption,
|
||||||
|
ConsumptionKind.Generation => Strings.Enum_ConsumptionKind_Generation,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this ReadingQuality value) => value switch
|
||||||
|
{
|
||||||
|
ReadingQuality.Measured => Strings.Enum_ReadingQuality_Measured,
|
||||||
|
ReadingQuality.Estimated => Strings.Enum_ReadingQuality_Estimated,
|
||||||
|
ReadingQuality.Manual => Strings.Enum_ReadingQuality_Manual,
|
||||||
|
ReadingQuality.Imported => Strings.Enum_ReadingQuality_Imported,
|
||||||
|
ReadingQuality.Interpolated => Strings.Enum_ReadingQuality_Interpolated,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A bitmask rendered as the set flags, comma-joined. <see cref="ReadingFlags.None"/> gives an
|
||||||
|
/// empty string: the readings table shows a flag column that is blank for almost every row, and
|
||||||
|
/// printing "None" a thousand times down a page is noise, not information.
|
||||||
|
/// </summary>
|
||||||
|
public static string Display(this ReadingFlags value)
|
||||||
|
{
|
||||||
|
if (value == ReadingFlags.None)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var names = new List<string>(4);
|
||||||
|
if (value.HasFlag(ReadingFlags.CounterReset))
|
||||||
|
{
|
||||||
|
names.Add(Strings.Enum_ReadingFlags_CounterReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.HasFlag(ReadingFlags.MeterSwap))
|
||||||
|
{
|
||||||
|
names.Add(Strings.Enum_ReadingFlags_MeterSwap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.HasFlag(ReadingFlags.Anomaly))
|
||||||
|
{
|
||||||
|
names.Add(Strings.Enum_ReadingFlags_Anomaly);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.HasFlag(ReadingFlags.MonthLabel))
|
||||||
|
{
|
||||||
|
names.Add(Strings.Enum_ReadingFlags_MonthLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return names.Count > 0 ? string.Join(", ", names) : value.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Display(this MeterEventType value) => value switch
|
||||||
|
{
|
||||||
|
MeterEventType.MeterSwap => Strings.Enum_MeterEventType_MeterSwap,
|
||||||
|
MeterEventType.CounterReset => Strings.Enum_MeterEventType_CounterReset,
|
||||||
|
MeterEventType.Delivery => Strings.Enum_MeterEventType_Delivery,
|
||||||
|
MeterEventType.TankLevel => Strings.Enum_MeterEventType_TankLevel,
|
||||||
|
MeterEventType.Correction => Strings.Enum_MeterEventType_Correction,
|
||||||
|
MeterEventType.Note => Strings.Enum_MeterEventType_Note,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this SourceType value) => value switch
|
||||||
|
{
|
||||||
|
SourceType.Mqtt => Strings.Enum_SourceType_Mqtt,
|
||||||
|
SourceType.Tasmota => Strings.Enum_SourceType_Tasmota,
|
||||||
|
SourceType.HomeAssistant => Strings.Enum_SourceType_HomeAssistant,
|
||||||
|
SourceType.Manual => Strings.Enum_SourceType_Manual,
|
||||||
|
SourceType.Import => Strings.Enum_SourceType_Import,
|
||||||
|
SourceType.Virtual => Strings.Enum_SourceType_Virtual,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this SourceValueKind value) => value switch
|
||||||
|
{
|
||||||
|
SourceValueKind.Register => Strings.Enum_SourceValueKind_Register,
|
||||||
|
SourceValueKind.Delta => Strings.Enum_SourceValueKind_Delta,
|
||||||
|
SourceValueKind.Rate => Strings.Enum_SourceValueKind_Rate,
|
||||||
|
SourceValueKind.Level => Strings.Enum_SourceValueKind_Level,
|
||||||
|
SourceValueKind.Runtime => Strings.Enum_SourceValueKind_Runtime,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this TariffScope value) => value switch
|
||||||
|
{
|
||||||
|
TariffScope.Global => Strings.Enum_TariffScope_Global,
|
||||||
|
TariffScope.EnergyType => Strings.Enum_TariffScope_EnergyType,
|
||||||
|
TariffScope.Meter => Strings.Enum_TariffScope_Meter,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this TariffComponent value) => value switch
|
||||||
|
{
|
||||||
|
TariffComponent.UnitPrice => Strings.Enum_TariffComponent_UnitPrice,
|
||||||
|
TariffComponent.BasePrice => Strings.Enum_TariffComponent_BasePrice,
|
||||||
|
TariffComponent.FeedIn => Strings.Enum_TariffComponent_FeedIn,
|
||||||
|
TariffComponent.Bonus => Strings.Enum_TariffComponent_Bonus,
|
||||||
|
TariffComponent.Discount => Strings.Enum_TariffComponent_Discount,
|
||||||
|
TariffComponent.Tax => Strings.Enum_TariffComponent_Tax,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this TankRateMode value) => value switch
|
||||||
|
{
|
||||||
|
TankRateMode.Fixed => Strings.Enum_TankRateMode_Fixed,
|
||||||
|
TankRateMode.Empirical => Strings.Enum_TankRateMode_Empirical,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this EndpointType value) => value switch
|
||||||
|
{
|
||||||
|
EndpointType.MqttBroker => Strings.Enum_EndpointType_MqttBroker,
|
||||||
|
EndpointType.HomeAssistant => Strings.Enum_EndpointType_HomeAssistant,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this UpdateAvailability value) => value switch
|
||||||
|
{
|
||||||
|
UpdateAvailability.Allowed => Strings.Enum_UpdateAvailability_Allowed,
|
||||||
|
UpdateAvailability.NotEnabled => Strings.Enum_UpdateAvailability_NotEnabled,
|
||||||
|
UpdateAvailability.NotSupportedHere => Strings.Enum_UpdateAvailability_NotSupportedHere,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Display(this MappingRole value) => value switch
|
||||||
|
{
|
||||||
|
MappingRole.Ignore => Strings.Enum_MappingRole_Ignore,
|
||||||
|
MappingRole.Reading => Strings.Enum_MappingRole_Reading,
|
||||||
|
MappingRole.Delivery => Strings.Enum_MappingRole_Delivery,
|
||||||
|
MappingRole.TankLevel => Strings.Enum_MappingRole_TankLevel,
|
||||||
|
MappingRole.ManualCost => Strings.Enum_MappingRole_ManualCost,
|
||||||
|
_ => value.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using MeterVault.Infrastructure.Import;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Localization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Says a staging warning in the reader's language.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The importer emits <see cref="ImportWarning"/> values carrying an English sentence plus the
|
||||||
|
/// arguments that filled it (see <c>ImportWarnings</c>). Re-formatting from the arguments rather
|
||||||
|
/// than translating the finished sentence is what lets the numbers pick up the reader's digit
|
||||||
|
/// grouping — a register that reads <c>2.940,19</c> everywhere else must not read <c>2940.19</c>
|
||||||
|
/// only inside a warning.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ImportWarningText
|
||||||
|
{
|
||||||
|
public static string Display(this ImportWarning warning)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(warning);
|
||||||
|
|
||||||
|
var args = warning.Args as object?[] ?? [.. warning.Args];
|
||||||
|
|
||||||
|
return warning.Kind switch
|
||||||
|
{
|
||||||
|
ImportWarningKind.RowSkipped => Loc.F(Strings.ImportWarning_RowSkipped, args),
|
||||||
|
ImportWarningKind.UnparseableDate => Loc.F(Strings.ImportWarning_UnparseableDate, args),
|
||||||
|
ImportWarningKind.UnitMismatch => Loc.F(Strings.ImportWarning_UnitMismatch, args),
|
||||||
|
ImportWarningKind.RegisterDropped => Loc.F(Strings.ImportWarning_RegisterDropped, args),
|
||||||
|
ImportWarningKind.RegisterDroppedWithAmount =>
|
||||||
|
Loc.F(Strings.ImportWarning_RegisterDroppedWithAmount, args),
|
||||||
|
|
||||||
|
// A kind added later still says something useful rather than blanking the panel.
|
||||||
|
_ => warning.Message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Localization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI-language plumbing around <see cref="Strings"/>, the strongly-typed accessor MSBuild generates
|
||||||
|
/// from <c>Strings.resx</c> (see the <c>EmbeddedResource</c> block in the project file).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The neutral resource is English and every other language ships as a satellite assembly, so an
|
||||||
|
/// <c>Accept-Language</c> we don't translate degrades to English rather than to raw resource keys.
|
||||||
|
/// Strings are referenced as compiled properties (<c>S.Common_Save</c>), not string lookups, so a
|
||||||
|
/// key that no longer exists is a build error instead of a mystery label at runtime.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Number and date <em>formatting</em> follows <see cref="CultureInfo.CurrentCulture"/> and the UI
|
||||||
|
/// language follows <see cref="CultureInfo.CurrentUICulture"/>; the request-localization middleware
|
||||||
|
/// sets both from the same choice, so the two never disagree.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class Loc
|
||||||
|
{
|
||||||
|
/// <summary>Cultures the UI ships translations for. The first entry is the neutral fallback.</summary>
|
||||||
|
public static IReadOnlyList<string> SupportedCultures { get; } = ["en", "de"];
|
||||||
|
|
||||||
|
/// <summary>Formats a resource carrying <c>{0}</c>-style placeholders in the request's culture.</summary>
|
||||||
|
public static string F(string format, params object?[] args) =>
|
||||||
|
string.Format(CultureInfo.CurrentCulture, format, args);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a requested language onto one we actually ship, matching on the two-letter tag so
|
||||||
|
/// <c>de-AT</c> and <c>de-CH</c> get German instead of falling through to English.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns><c>true</c> when the request named a language we translate.</returns>
|
||||||
|
public static bool TryResolve(string? requested, out string resolved)
|
||||||
|
{
|
||||||
|
resolved = SupportedCultures[0];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(requested))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = requested.Trim();
|
||||||
|
var separator = trimmed.IndexOfAny(['-', '_']);
|
||||||
|
var language = separator < 0 ? trimmed : trimmed[..separator];
|
||||||
|
|
||||||
|
foreach (var supported in SupportedCultures)
|
||||||
|
{
|
||||||
|
if (string.Equals(supported, language, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
resolved = supported;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The display name of a supported culture, written in that language ("Deutsch", "English").</summary>
|
||||||
|
public static string DisplayName(string culture) =>
|
||||||
|
CultureInfo.GetCultureInfo(culture).NativeName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Ingestion;
|
||||||
|
|
||||||
|
namespace MeterVault.App.Localization;
|
||||||
|
|
||||||
|
/// <summary>How meter-event outcomes are worded — one place, shared by the event dialog and the meter page.</summary>
|
||||||
|
public static class MeterEventText
|
||||||
|
{
|
||||||
|
public static string Problem(MeterEventProblem problem) => problem switch
|
||||||
|
{
|
||||||
|
MeterEventProblem.None => string.Empty,
|
||||||
|
MeterEventProblem.UnknownMeter => Strings.MeterDetail_MeterGone,
|
||||||
|
MeterEventProblem.NotRecordableForMode => Strings.MeterEvent_ProblemNotForMode,
|
||||||
|
MeterEventProblem.AmountRequired => Strings.MeterEvent_ProblemAmountRequired,
|
||||||
|
MeterEventProblem.AmountOutOfRange => Strings.MeterEvent_ProblemAmountOutOfRange,
|
||||||
|
MeterEventProblem.NoteRequired => Strings.MeterEvent_ProblemNoteRequired,
|
||||||
|
MeterEventProblem.LevelNeedsCalibration => Strings.MeterEvent_ProblemNeedsCalibration,
|
||||||
|
MeterEventProblem.LevelAtSameTime => Strings.MeterEvent_ProblemLevelAtSameTime,
|
||||||
|
MeterEventProblem.LaterBoundaryDependsOnIt => Strings.MeterEvent_ProblemLaterBoundary,
|
||||||
|
MeterEventProblem.OldRegisterBelowPreviousReading => Strings.MeterEvent_ProblemBelowPrevious,
|
||||||
|
MeterEventProblem.ReadingAtSameTime => Strings.MeterEvent_ProblemReadingAtSameTime,
|
||||||
|
MeterEventProblem.BoundaryAlreadyRecorded => Strings.MeterEvent_ProblemAlreadyRecorded,
|
||||||
|
MeterEventProblem.StartReadingRejected => Strings.MeterEvent_ProblemStartRejected,
|
||||||
|
MeterEventProblem.NotFound => Strings.MeterEvent_ProblemNotFound,
|
||||||
|
MeterEventProblem.Imported => Strings.MeterEvent_ProblemImported,
|
||||||
|
MeterEventProblem.NotManual => Strings.MeterEvent_ProblemNotManual,
|
||||||
|
_ => problem.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Saved(MeterEventType type) => type switch
|
||||||
|
{
|
||||||
|
MeterEventType.MeterSwap => Strings.MeterEvent_SavedSwap,
|
||||||
|
MeterEventType.CounterReset => Strings.MeterEvent_SavedReset,
|
||||||
|
MeterEventType.TankLevel => Strings.MeterEvent_SavedTankLevel,
|
||||||
|
MeterEventType.Delivery => Strings.MeterEvent_SavedDelivery,
|
||||||
|
_ => Strings.MeterEvent_SavedNote,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Intro(MeterEventType type) => type switch
|
||||||
|
{
|
||||||
|
MeterEventType.MeterSwap => Strings.MeterEvent_IntroSwap,
|
||||||
|
MeterEventType.CounterReset => Strings.MeterEvent_IntroReset,
|
||||||
|
MeterEventType.TankLevel => Strings.MeterEvent_IntroTankLevel,
|
||||||
|
MeterEventType.Delivery => Strings.MeterEvent_IntroDelivery,
|
||||||
|
_ => Strings.MeterEvent_IntroNote,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The Material icon for an event type, so menus and the events list read at a glance.</summary>
|
||||||
|
public static string Icon(MeterEventType type) => type switch
|
||||||
|
{
|
||||||
|
MeterEventType.MeterSwap => MudBlazor.Icons.Material.Filled.SwapHoriz,
|
||||||
|
MeterEventType.CounterReset => MudBlazor.Icons.Material.Filled.RestartAlt,
|
||||||
|
MeterEventType.TankLevel => MudBlazor.Icons.Material.Filled.Straighten,
|
||||||
|
MeterEventType.Delivery => MudBlazor.Icons.Material.Filled.LocalShipping,
|
||||||
|
MeterEventType.Correction => MudBlazor.Icons.Material.Filled.Build,
|
||||||
|
_ => MudBlazor.Icons.Material.Filled.StickyNote2,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Addresses into a meter's page. The meter page is where every per-meter task lives — readings,
|
||||||
|
/// swaps, tank levels, sources, settings — so other screens link straight to the tab and the action
|
||||||
|
/// rather than to the top of the page and a hunt for the right button.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>/meters/{id}?tab=events&action=swap</c> opens the Events tab with the swap dialog up. The
|
||||||
|
/// action is consumed once and dropped from the address, so reloading the page does not reopen it.
|
||||||
|
/// </remarks>
|
||||||
|
public static class MeterLinks
|
||||||
|
{
|
||||||
|
public const string TabReadings = "readings";
|
||||||
|
public const string TabConsumption = "consumption";
|
||||||
|
public const string TabEvents = "events";
|
||||||
|
public const string TabTariffs = "tariffs";
|
||||||
|
public const string TabSources = "sources";
|
||||||
|
|
||||||
|
public const string ActionReading = "reading";
|
||||||
|
public const string ActionEdit = "edit";
|
||||||
|
public const string ActionSource = "source";
|
||||||
|
|
||||||
|
/// <summary>Query keys that preset the source dialog: the source to edit, its type, its connector.</summary>
|
||||||
|
public const string ParamSource = "source";
|
||||||
|
public const string ParamSourceType = "type";
|
||||||
|
public const string ParamConnector = "connector";
|
||||||
|
|
||||||
|
/// <summary>Tab keys in the order the meter page renders its panels.</summary>
|
||||||
|
public static readonly IReadOnlyList<string> Tabs = [TabReadings, TabConsumption, TabEvents, TabTariffs, TabSources];
|
||||||
|
|
||||||
|
public static string Detail(int meterId, string? tab = null, string? action = null)
|
||||||
|
{
|
||||||
|
var query = new List<string>(2);
|
||||||
|
if (!string.IsNullOrEmpty(tab))
|
||||||
|
{
|
||||||
|
query.Add($"tab={Uri.EscapeDataString(tab)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(action))
|
||||||
|
{
|
||||||
|
query.Add($"action={Uri.EscapeDataString(action)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.Count == 0 ? $"/meters/{meterId}" : $"/meters/{meterId}?{string.Join('&', query)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Sources tab with the source dialog open: a new source, or <paramref name="sourceId"/> for an
|
||||||
|
/// existing one, optionally already set to a source type and connector. This is where the connector
|
||||||
|
/// page returns to once the connector a source was waiting for exists.
|
||||||
|
/// </summary>
|
||||||
|
public static string Source(int meterId, int? sourceId = null, SourceType? sourceType = null, int? connectorId = null)
|
||||||
|
{
|
||||||
|
var link = Detail(meterId, TabSources, ActionSource);
|
||||||
|
if (sourceId is { } source)
|
||||||
|
{
|
||||||
|
link += $"&{ParamSource}={source.ToString(CultureInfo.InvariantCulture)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceType is { } type)
|
||||||
|
{
|
||||||
|
link += $"&{ParamSourceType}={type}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectorId is { } connector)
|
||||||
|
{
|
||||||
|
link += $"&{ParamConnector}={connector.ToString(CultureInfo.InvariantCulture)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The connector page with a new connector of <paramref name="endpointType"/> open, for a source that
|
||||||
|
/// has none yet. Saving it leads back to the source dialog with the new connector picked.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The way back is the meter's id, not a URL, so the connector page cannot be made to redirect
|
||||||
|
/// anywhere but a meter page.
|
||||||
|
/// </remarks>
|
||||||
|
public static string NewConnector(int meterId, int? sourceId, SourceType sourceType, EndpointType endpointType) =>
|
||||||
|
$"/admin/connectors?new={endpointType}{ReturnQuery(meterId, sourceId, sourceType)}";
|
||||||
|
|
||||||
|
/// <summary>The connector page with an existing connector open — to enable it — and the same way back.</summary>
|
||||||
|
public static string EditConnector(int meterId, int? sourceId, SourceType sourceType, int connectorId) =>
|
||||||
|
$"/admin/connectors?edit={connectorId.ToString(CultureInfo.InvariantCulture)}{ReturnQuery(meterId, sourceId, sourceType)}";
|
||||||
|
|
||||||
|
private static string ReturnQuery(int meterId, int? sourceId, SourceType sourceType) =>
|
||||||
|
$"&meter={meterId.ToString(CultureInfo.InvariantCulture)}"
|
||||||
|
+ (sourceId is { } source ? $"&{ParamSource}={source.ToString(CultureInfo.InvariantCulture)}" : "")
|
||||||
|
+ $"&{ParamSourceType}={sourceType}";
|
||||||
|
|
||||||
|
/// <summary>The meter page's Events tab with the dialog for <paramref name="type"/> open.</summary>
|
||||||
|
public static string Event(int meterId, MeterEventType type) => Detail(meterId, TabEvents, ActionFor(type));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one-tap data entry for a meter: a reading for a register, a tank level for a tank, and
|
||||||
|
/// nothing for a virtual meter, which has nothing to enter.
|
||||||
|
/// </summary>
|
||||||
|
public static string? QuickEntry(int meterId, MeterMode mode) => mode switch
|
||||||
|
{
|
||||||
|
MeterMode.Virtual => null,
|
||||||
|
MeterMode.ConsumableBalance => Event(meterId, MeterEventType.TankLevel),
|
||||||
|
_ => Detail(meterId, TabReadings, ActionReading),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string ActionFor(MeterEventType type) => type switch
|
||||||
|
{
|
||||||
|
MeterEventType.MeterSwap => "swap",
|
||||||
|
MeterEventType.CounterReset => "reset",
|
||||||
|
MeterEventType.Delivery => "delivery",
|
||||||
|
MeterEventType.TankLevel => "tank-level",
|
||||||
|
MeterEventType.Correction => "correction",
|
||||||
|
_ => "note",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The event an action names, or null if it names something else (or nothing).</summary>
|
||||||
|
public static MeterEventType? EventFor(string? action)
|
||||||
|
{
|
||||||
|
foreach (var type in Enum.GetValues<MeterEventType>())
|
||||||
|
{
|
||||||
|
if (string.Equals(ActionFor(type), action, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The panel index for a tab key; unknown or missing keys open the first tab.</summary>
|
||||||
|
public static int TabIndex(string? tab)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < Tabs.Count; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(Tabs[i], tab, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,4 +26,20 @@
|
|||||||
<Content Include="..\..\sampledata\*.csv" Link="sampledata\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="..\..\sampledata\*.csv" Link="sampledata\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- UI strings (SDD §12, M7). The neutral resx generates a strongly-typed `Strings` class, so
|
||||||
|
components reference compiled properties (S.Common_Save) instead of string keys: a renamed or
|
||||||
|
deleted string breaks the build rather than silently rendering its key. Generation runs in
|
||||||
|
MSBuild, not the IDE designer, so `dotnet build` alone reproduces it on any platform.
|
||||||
|
Translations live in Strings.<culture>.resx and ship as satellite assemblies. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Update="Localization\Strings.resx">
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
<StronglyTypedFileName>$(IntermediateOutputPath)\Strings.Designer.cs</StronglyTypedFileName>
|
||||||
|
<StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
|
||||||
|
<StronglyTypedNamespace>MeterVault.App.Localization</StronglyTypedNamespace>
|
||||||
|
<StronglyTypedClassName>Strings</StronglyTypedClassName>
|
||||||
|
<PublicClass>true</PublicClass>
|
||||||
|
</EmbeddedResource>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MeterVault.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-circuit signal that something the navigation menu lists has changed. The menu lives in the
|
||||||
|
/// layout, which Blazor keeps for the whole circuit, so without this a new or renamed energy type
|
||||||
|
/// would not appear in it until the browser reloaded.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NavState
|
||||||
|
{
|
||||||
|
public event Action? EnergyTypesChanged;
|
||||||
|
|
||||||
|
public void NotifyEnergyTypesChanged() => EnergyTypesChanged?.Invoke();
|
||||||
|
}
|
||||||
+96
-4
@@ -1,8 +1,10 @@
|
|||||||
using MeterVault.App.Api;
|
using MeterVault.App.Api;
|
||||||
using MeterVault.App.Components;
|
using MeterVault.App.Components;
|
||||||
|
using MeterVault.App.Localization;
|
||||||
using MeterVault.Infrastructure;
|
using MeterVault.Infrastructure;
|
||||||
using MeterVault.Infrastructure.Options;
|
using MeterVault.Infrastructure.Options;
|
||||||
using MeterVault.Infrastructure.Persistence;
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MudBlazor.Services;
|
using MudBlazor.Services;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -23,11 +25,48 @@ try
|
|||||||
|
|
||||||
builder.Services.Configure<MeterVaultOptions>(
|
builder.Services.Configure<MeterVaultOptions>(
|
||||||
builder.Configuration.GetSection(MeterVaultOptions.SectionName));
|
builder.Configuration.GetSection(MeterVaultOptions.SectionName));
|
||||||
|
// One zone id for .NET and PostgreSQL alike: a Windows id works for the first and not the second.
|
||||||
|
builder.Services.PostConfigure<MeterVaultOptions>(o => o.TimeZone = InstanceTimeZone.Canonical(o.TimeZone));
|
||||||
|
|
||||||
var connectionString = builder.Configuration.GetConnectionString("Default")
|
var connectionString = builder.Configuration.GetConnectionString("Default")
|
||||||
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
|
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
|
||||||
builder.Services.AddMeterVaultInfrastructure(connectionString);
|
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>()
|
var options = builder.Configuration.GetSection(MeterVaultOptions.SectionName).Get<MeterVaultOptions>()
|
||||||
?? new MeterVaultOptions();
|
?? new MeterVaultOptions();
|
||||||
if (options.EnableLiveIngestion)
|
if (options.EnableLiveIngestion)
|
||||||
@@ -35,7 +74,10 @@ try
|
|||||||
builder.Services.AddMeterVaultIngestion();
|
builder.Services.AddMeterVaultIngestion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder.Services.AddLocalization();
|
||||||
builder.Services.AddMudServices();
|
builder.Services.AddMudServices();
|
||||||
|
builder.Services.AddScoped<MeterVault.App.NavState>();
|
||||||
|
builder.Services.AddScoped<MeterVault.App.DraftStore>();
|
||||||
builder.Services.AddRazorComponents()
|
builder.Services.AddRazorComponents()
|
||||||
.AddInteractiveServerComponents();
|
.AddInteractiveServerComponents();
|
||||||
|
|
||||||
@@ -60,10 +102,28 @@ try
|
|||||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
app.UseReverseProxyTrust();
|
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.
|
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
// UI language (SDD §12, M7). Must run before MapRazorComponents: a Blazor Server circuit takes
|
||||||
|
// its culture from the request that opens it, so this is what every render downstream sees.
|
||||||
|
// Order of preference is the culture cookie the picker writes, then Accept-Language, then
|
||||||
|
// MeterVault__Locale — an instance can be pinned to one language, and a user can still switch.
|
||||||
|
// TryResolve yields the neutral fallback when it fails, so defaultCulture is usable either way.
|
||||||
|
if (!Loc.TryResolve(options.Locale, out var defaultCulture) && !string.IsNullOrWhiteSpace(options.Locale))
|
||||||
|
{
|
||||||
|
Log.Warning(
|
||||||
|
"MeterVault__Locale is '{Locale}', which has no translations; falling back to '{Fallback}'. "
|
||||||
|
+ "Supported: {Supported}",
|
||||||
|
options.Locale, defaultCulture, string.Join(", ", Loc.SupportedCultures));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseRequestLocalization(new RequestLocalizationOptions()
|
||||||
|
.SetDefaultCulture(defaultCulture)
|
||||||
|
.AddSupportedCultures([.. Loc.SupportedCultures])
|
||||||
|
.AddSupportedUICultures([.. Loc.SupportedCultures]));
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1"));
|
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1"));
|
||||||
|
|
||||||
@@ -72,6 +132,7 @@ try
|
|||||||
.AddInteractiveServerRenderMode();
|
.AddInteractiveServerRenderMode();
|
||||||
|
|
||||||
app.MapMeterVaultApi();
|
app.MapMeterVaultApi();
|
||||||
|
app.MapCultureEndpoints();
|
||||||
|
|
||||||
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
|
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
|
||||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||||
@@ -90,9 +151,14 @@ finally
|
|||||||
|
|
||||||
static async Task MigrateDatabaseAsync(WebApplication app)
|
static async Task MigrateDatabaseAsync(WebApplication app)
|
||||||
{
|
{
|
||||||
var options = app.Configuration
|
var options = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<MeterVaultOptions>>().Value;
|
||||||
.GetSection(MeterVaultOptions.SectionName)
|
|
||||||
.Get<MeterVaultOptions>() ?? new MeterVaultOptions();
|
// Months are divided and bucketed in this zone. An id nobody knows would silently divide in UTC while
|
||||||
|
// every database query that buckets by it fails, so say so where the operator will look.
|
||||||
|
if (!TimeZoneInfo.TryFindSystemTimeZoneById(options.TimeZone, out _))
|
||||||
|
{
|
||||||
|
Log.Error("MeterVault:TimeZone '{TimeZone}' is not a known timezone id (expected an IANA id such as Europe/Berlin)", options.TimeZone);
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.RunMigrationsAtStartup)
|
if (!options.RunMigrationsAtStartup)
|
||||||
{
|
{
|
||||||
@@ -103,7 +169,33 @@ static async Task MigrateDatabaseAsync(WebApplication app)
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||||
await db.Database.MigrateAsync().ConfigureAwait(false);
|
await db.Database.MigrateAsync().ConfigureAwait(false);
|
||||||
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var zoneKnownToDatabase = await db.Database
|
||||||
|
.SqlQuery<bool>($"SELECT EXISTS (SELECT 1 FROM pg_timezone_names WHERE name = {options.TimeZone}) AS \"Value\"")
|
||||||
|
.SingleAsync().ConfigureAwait(false);
|
||||||
|
if (!zoneKnownToDatabase)
|
||||||
|
{
|
||||||
|
Log.Error("MeterVault:TimeZone '{TimeZone}' is not a timezone PostgreSQL knows; every chart and cost query will fail", options.TimeZone);
|
||||||
|
}
|
||||||
Log.Information("Database migrations applied and defaults seeded");
|
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)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the data is in place: stored consumption is derived, so when the engine starts booking
|
||||||
|
// readings differently, existing meters are rebuilt once rather than only as new readings arrive.
|
||||||
|
var rebuilt = await scope.ServiceProvider
|
||||||
|
.GetRequiredService<MeterVault.Infrastructure.Normalization.NormalizationUpgrade>()
|
||||||
|
.RunAsync().ConfigureAwait(false);
|
||||||
|
if (rebuilt > 0)
|
||||||
|
{
|
||||||
|
Log.Information("Recomputed consumption for {Meters} meter(s) after a normalization change", rebuilt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
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, yet round-trips a sensor's four or five decimals — rounded to three, the prefill
|
||||||
|
// would read as lower than the stored reading and be flagged as a decrease before a key is
|
||||||
|
// pressed. 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>
|
/// <summary>Source already reports increments. The value is the increment.</summary>
|
||||||
DirectDelta,
|
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,
|
InstantRate,
|
||||||
|
|
||||||
/// <summary>Computed from other meters via a user-defined expression.</summary>
|
/// <summary>Computed from other meters via a user-defined expression.</summary>
|
||||||
@@ -53,6 +53,13 @@ public enum ReadingFlags
|
|||||||
CounterReset = 1,
|
CounterReset = 1,
|
||||||
MeterSwap = 2,
|
MeterSwap = 2,
|
||||||
Anomaly = 4,
|
Anomaly = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An imported row whose date was a month name ("Mai 2026"), stamped on the 1st: it carries the
|
||||||
|
/// register at the end of that month, not at the instant it is stamped. Set by the importer, which
|
||||||
|
/// is the only place that still knows whether the date cell named a month or a day.
|
||||||
|
/// </summary>
|
||||||
|
MonthLabel = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Discrete meter lifecycle/correction events (SDD meter_event.event_type).</summary>
|
/// <summary>Discrete meter lifecycle/correction events (SDD meter_event.event_type).</summary>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which lifecycle events a meter can meaningfully record, decided by its measurement mode — the
|
||||||
|
/// same dispatch the normalizers use, so the UI never offers an event that would change nothing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="MeterEventType.Correction"/> is deliberately never offered: no normalizer reads it, so
|
||||||
|
/// recording one would look like a fix while leaving every derived number untouched.
|
||||||
|
/// </remarks>
|
||||||
|
public static class MeterEventRules
|
||||||
|
{
|
||||||
|
private static readonly MeterEventType[] RegisterEvents =
|
||||||
|
[MeterEventType.MeterSwap, MeterEventType.CounterReset, MeterEventType.Note];
|
||||||
|
|
||||||
|
private static readonly MeterEventType[] TankEvents =
|
||||||
|
[MeterEventType.TankLevel, MeterEventType.Delivery, MeterEventType.Note];
|
||||||
|
|
||||||
|
private static readonly MeterEventType[] NoteOnly = [MeterEventType.Note];
|
||||||
|
|
||||||
|
/// <summary>The event types worth recording for a meter in <paramref name="mode"/>, most common first.</summary>
|
||||||
|
public static IReadOnlyList<MeterEventType> RecordableFor(MeterMode mode) => mode switch
|
||||||
|
{
|
||||||
|
MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => RegisterEvents,
|
||||||
|
MeterMode.ConsumableBalance => TankEvents,
|
||||||
|
_ => NoteOnly,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool CanRecord(MeterMode mode, MeterEventType type) => RecordableFor(mode).Contains(type);
|
||||||
|
|
||||||
|
/// <summary>A swap or reset: an instant where a register legitimately starts over.</summary>
|
||||||
|
public static bool IsRegisterBoundary(MeterEventType type) =>
|
||||||
|
type is MeterEventType.MeterSwap or MeterEventType.CounterReset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether raw readings drive this meter's consumption. A tank is driven by level and delivery
|
||||||
|
/// events, and a virtual meter by other meters, so a reading typed against either changes nothing.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TakesReadings(MeterMode mode) => mode is not (MeterMode.ConsumableBalance or MeterMode.Virtual);
|
||||||
|
|
||||||
|
/// <summary>Whether the register may only count up, so a lower value needs a swap or reset to explain it.</summary>
|
||||||
|
public static bool IsMonotonic(MeterMode mode) =>
|
||||||
|
mode is MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter;
|
||||||
|
}
|
||||||
@@ -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,30 @@
|
|||||||
|
namespace MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which connector a live source is served by. Routing is endpoint-scoped: a source without a
|
||||||
|
/// connector of the right kind has no connection details and silently never ingests.
|
||||||
|
/// </summary>
|
||||||
|
public static class SourceRouting
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The 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>
|
||||||
|
public static EndpointType? RequiredEndpoint(SourceType sourceType) => sourceType switch
|
||||||
|
{
|
||||||
|
SourceType.HomeAssistant => EndpointType.HomeAssistant,
|
||||||
|
SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Whether a connector of <paramref name="endpointType"/> can serve a source of <paramref name="sourceType"/>.</summary>
|
||||||
|
public static bool Serves(EndpointType endpointType, SourceType sourceType) =>
|
||||||
|
RequiredEndpoint(sourceType) == endpointType;
|
||||||
|
|
||||||
|
/// <summary>The source type a new source on a connector of this kind starts as.</summary>
|
||||||
|
public static SourceType DefaultSourceFor(EndpointType endpointType) => endpointType switch
|
||||||
|
{
|
||||||
|
EndpointType.HomeAssistant => SourceType.HomeAssistant,
|
||||||
|
_ => SourceType.Mqtt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attributes a register delta to the calendar months it actually accrued in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A reading is an instant, and the consumption between two readings happened over the time between
|
||||||
|
/// them. Booking the whole delta at the closing reading puts it in the wrong month whenever the
|
||||||
|
/// interval crosses a month boundary: a reading on 1 August and the next on 16 September would show
|
||||||
|
/// six weeks of use in September and none in August. So an interval that crosses one or more local
|
||||||
|
/// month boundaries is divided at those boundaries, in proportion to elapsed time, and every share is
|
||||||
|
/// stamped inside the month it belongs to. The meter recorded a total, not a shape, so a divided
|
||||||
|
/// interval's rows are marked <see cref="ReadingQuality.Estimated"/>; the parts always sum to the
|
||||||
|
/// original, so nothing is created or lost.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Imported monthly tables are the exception that keeps the golden fixtures reconciling (SDD §13). A
|
||||||
|
/// row labelled "Mai 2026" carries the register at the <em>end</em> of May and May's consumption — the
|
||||||
|
/// sheet is filled after the month closes — but the importer stamps it on the 1st so that it files
|
||||||
|
/// under its month, and flags it <see cref="ReadingFlags.MonthLabel"/>. <see cref="EffectiveTime"/>
|
||||||
|
/// reads such a <see cref="IsMonthLabel">month label</see> as the end of its month. Two consecutive
|
||||||
|
/// labels then span exactly their closing month and book unchanged, while a live reading that follows
|
||||||
|
/// the last imported row counts from the end of that row's month instead of claiming it a second time.
|
||||||
|
/// A day-dated row ("01.08.2026") is an instant like any other reading, even though it is stamped at
|
||||||
|
/// the same midnight — which is why label status is recorded at import rather than read off the clock.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Months are the instance's local months (SDD §10), because that is how every chart buckets them: a
|
||||||
|
/// UTC split would let a reading taken shortly after local midnight on the 1st file a whole month's
|
||||||
|
/// use under the new month.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class GapAttribution
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True for a row from an imported monthly table (<see cref="ReadingFlags.MonthLabel"/>). Such a
|
||||||
|
/// timestamp names a month, not the moment of reading. A hand correction of the row keeps that
|
||||||
|
/// meaning; a live source writing to the same instant clears the flag (see the ingestion upsert).
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsMonthLabel(Reading reading)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reading);
|
||||||
|
|
||||||
|
return reading.Flags.HasFlag(ReadingFlags.MonthLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The instant a reading describes: its timestamp, or for a <see cref="IsMonthLabel">month label</see>
|
||||||
|
/// the local midnight that ends the labelled month.
|
||||||
|
/// </summary>
|
||||||
|
public static DateTimeOffset EffectiveTime(Reading reading, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reading);
|
||||||
|
ArgumentNullException.ThrowIfNull(zone);
|
||||||
|
|
||||||
|
return IsMonthLabel(reading)
|
||||||
|
? LocalMidnight(LabelledMonth(reading).AddMonths(1), zone)
|
||||||
|
: reading.Time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a reading's own consumption row is stamped: its timestamp, unless it is a month label whose
|
||||||
|
/// timestamp falls outside the labelled local month. In a zone behind UTC, 00:00 UTC on the 1st is
|
||||||
|
/// still the previous local month, so such a label is stamped at the start of its month instead.
|
||||||
|
/// </summary>
|
||||||
|
public static DateTimeOffset StampTime(Reading reading, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reading);
|
||||||
|
ArgumentNullException.ThrowIfNull(zone);
|
||||||
|
|
||||||
|
if (!IsMonthLabel(reading))
|
||||||
|
{
|
||||||
|
return reading.Time;
|
||||||
|
}
|
||||||
|
|
||||||
|
var monthStart = LabelMonthStart(reading, zone);
|
||||||
|
return reading.Time >= monthStart && reading.Time < EffectiveTime(reading, zone) ? reading.Time : monthStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The local midnight that starts the month a label names.</summary>
|
||||||
|
public static DateTimeOffset LabelMonthStart(Reading reading, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reading);
|
||||||
|
ArgumentNullException.ThrowIfNull(zone);
|
||||||
|
|
||||||
|
return LocalMidnight(LabelledMonth(reading), zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The instant a local calendar day starts in <paramref name="zone"/>, in UTC. Readers turn the dates
|
||||||
|
/// of a requested range into instants with this, so a range covers exactly the local months its rows
|
||||||
|
/// are bucketed and stamped in.
|
||||||
|
/// </summary>
|
||||||
|
public static DateTimeOffset LocalMidnight(DateOnly date, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zone);
|
||||||
|
|
||||||
|
return LocalMidnight(date.ToDateTime(TimeOnly.MinValue), zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Divides <paramref name="amount"/>, accrued over <c>[from, to)</c>, across the local calendar
|
||||||
|
/// months it touches, in proportion to the time spent in each.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="closingStamp">
|
||||||
|
/// Where the closing reading's own row goes (<see cref="StampTime"/>). The share for the month it
|
||||||
|
/// falls in keeps it, so an interval inside a single month produces exactly the row it always did. A
|
||||||
|
/// share in any other month is stamped at the last second of that month — inside it, and strictly
|
||||||
|
/// between the two readings.
|
||||||
|
/// </param>
|
||||||
|
public static IReadOnlyList<GapSegment> Attribute(
|
||||||
|
DateTimeOffset from, DateTimeOffset to, DateTimeOffset closingStamp, double amount, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zone);
|
||||||
|
|
||||||
|
if (to <= from)
|
||||||
|
{
|
||||||
|
return [new GapSegment(closingStamp.ToUniversalTime(), amount)];
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = to - from;
|
||||||
|
var segments = new List<GapSegment>();
|
||||||
|
var cursor = from;
|
||||||
|
var assigned = 0d;
|
||||||
|
|
||||||
|
while (cursor < to)
|
||||||
|
{
|
||||||
|
var monthStart = MonthStartContaining(cursor, zone);
|
||||||
|
var monthEnd = NextMonthStart(monthStart, zone);
|
||||||
|
var segmentEnd = monthEnd < to ? monthEnd : to;
|
||||||
|
var last = segmentEnd >= to;
|
||||||
|
if (segmentEnd <= cursor)
|
||||||
|
{
|
||||||
|
// Zone data that contradicts itself around a transition must never stall the walk: book
|
||||||
|
// the rest here rather than loop without advancing.
|
||||||
|
segmentEnd = to;
|
||||||
|
last = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The final share takes the remainder, so rounding never creates or destroys energy.
|
||||||
|
var share = last ? amount - assigned : amount * ((segmentEnd - cursor) / total);
|
||||||
|
var stamp = closingStamp >= monthStart && closingStamp < monthEnd
|
||||||
|
? closingStamp
|
||||||
|
: InsideSegment(cursor, segmentEnd, monthEnd);
|
||||||
|
|
||||||
|
// UTC, like every stored instant (SDD §10): the boundaries are computed as local wall-clock
|
||||||
|
// times, and PostgreSQL's timestamptz accepts only zero offsets from Npgsql.
|
||||||
|
segments.Add(new GapSegment(stamp.ToUniversalTime(), share));
|
||||||
|
assigned += share;
|
||||||
|
cursor = segmentEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The month a label names — its UTC month, which is what the importer wrote.</summary>
|
||||||
|
private static DateTime LabelledMonth(Reading reading)
|
||||||
|
{
|
||||||
|
var utc = reading.Time.UtcDateTime;
|
||||||
|
return new DateTime(utc.Year, utc.Month, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The last second of the month, or the segment's midpoint when the segment is shorter than that.</summary>
|
||||||
|
private static DateTimeOffset InsideSegment(DateTimeOffset start, DateTimeOffset end, DateTimeOffset monthEnd)
|
||||||
|
{
|
||||||
|
var lastSecond = (end < monthEnd ? end : monthEnd).AddSeconds(-1);
|
||||||
|
return lastSecond > start ? lastSecond : start + ((end - start) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset MonthStartContaining(DateTimeOffset instant, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
var local = TimeZoneInfo.ConvertTime(instant, zone);
|
||||||
|
return LocalMidnight(new DateTime(local.Year, local.Month, 1), zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset NextMonthStart(DateTimeOffset monthStart, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
var local = TimeZoneInfo.ConvertTime(monthStart, zone);
|
||||||
|
return LocalMidnight(new DateTime(local.Year, local.Month, 1).AddMonths(1), zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Midnight at the start of a local date as an instant. A zone whose clocks jump forward at midnight
|
||||||
|
/// has no such moment on that day; the first moment that does exist is the month's real start. A zone
|
||||||
|
/// whose clocks fall back at midnight has it twice; the first of the two is.
|
||||||
|
/// </summary>
|
||||||
|
private static DateTimeOffset LocalMidnight(DateTime date, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
var day = DateTime.SpecifyKind(date.Date, DateTimeKind.Unspecified);
|
||||||
|
var wall = day;
|
||||||
|
while (zone.IsInvalidTime(wall))
|
||||||
|
{
|
||||||
|
wall = wall.AddMinutes(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The larger offset is the earlier UTC instant, i.e. the first occurrence of the wall-clock time.
|
||||||
|
var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
|
||||||
|
var instant = new DateTimeOffset(wall, offset).ToUniversalTime();
|
||||||
|
|
||||||
|
// Some zone data reports, for a midnight right at a transition, the offset from after the change
|
||||||
|
// — naming an instant that is still the evening before (America/Asuncion, Europe/Volgograd). The
|
||||||
|
// day starts at the first instant whose local date is that day. Bounded: offsets move by hours.
|
||||||
|
for (var step = 0; step < 4 * 26 && TimeZoneInfo.ConvertTime(instant, zone).DateTime < day; step++)
|
||||||
|
{
|
||||||
|
instant = instant.AddMinutes(15);
|
||||||
|
}
|
||||||
|
|
||||||
|
return instant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One month's share of an interval: the instant it is stamped at and the amount attributed.</summary>
|
||||||
|
public sealed record GapSegment(DateTimeOffset Time, double Amount);
|
||||||
@@ -16,6 +16,12 @@ public sealed class NormalizationContext
|
|||||||
|
|
||||||
public IReadOnlyList<MeterEvent> Events { get; init; } = [];
|
public IReadOnlyList<MeterEvent> Events { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The instance timezone. Consumption is attributed to local calendar months — the months every
|
||||||
|
/// chart buckets by (SDD §10) — so an interval is divided at local, not UTC, month boundaries.
|
||||||
|
/// </summary>
|
||||||
|
public TimeZoneInfo TimeZone { get; init; } = TimeZoneInfo.Utc;
|
||||||
|
|
||||||
/// <summary>Referenced meters' consumption series, keyed by meter id (virtual meters only).</summary>
|
/// <summary>Referenced meters' consumption series, keyed by meter id (virtual meters only).</summary>
|
||||||
public IReadOnlyDictionary<int, IReadOnlyList<Consumption>> ReferencedSeries { get; init; }
|
public IReadOnlyDictionary<int, IReadOnlyList<Consumption>> ReferencedSeries { get; init; }
|
||||||
= new Dictionary<int, IReadOnlyList<Consumption>>();
|
= new Dictionary<int, IReadOnlyList<Consumption>>();
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class NormalizationEngine : INormalizationEngine
|
|||||||
new Normalizers.RuntimeCounterNormalizer(),
|
new Normalizers.RuntimeCounterNormalizer(),
|
||||||
new Normalizers.ConsumableBalanceNormalizer(),
|
new Normalizers.ConsumableBalanceNormalizer(),
|
||||||
new Normalizers.DirectDeltaNormalizer(),
|
new Normalizers.DirectDeltaNormalizer(),
|
||||||
|
new Normalizers.InstantRateNormalizer(),
|
||||||
new Normalizers.VirtualNormalizer(),
|
new Normalizers.VirtualNormalizer(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -35,6 +36,33 @@ public sealed class NormalizationEngine : INormalizationEngine
|
|||||||
throw new NotSupportedException($"No normalizer registered for mode {context.Meter.Mode}.");
|
throw new NotSupportedException($"No normalizer registered for mode {context.Meter.Mode}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. normalizer.Normalize(context)];
|
return Coalesce(normalizer.Normalize(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per (time, kind), in time order. A stored row is keyed by meter, time and kind, so two
|
||||||
|
/// rows on the same instant would make the whole recompute fail — and with it every ingest for the
|
||||||
|
/// meter. Rows can meet when a share stamped at a month's last second lands on a reading taken at
|
||||||
|
/// exactly that second, or at a local midnight a label is stamped at. Their amounts add up: the
|
||||||
|
/// total is what the readings say, only its placement is shared.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Consumption> Coalesce(IEnumerable<Consumption> rows)
|
||||||
|
{
|
||||||
|
var byKey = new Dictionary<(DateTimeOffset Time, ConsumptionKind Kind), Consumption>();
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
if (byKey.TryGetValue((row.Time, row.Kind), out var existing))
|
||||||
|
{
|
||||||
|
existing.Amount += row.Amount;
|
||||||
|
existing.Quality = ReadingQuality.Estimated;
|
||||||
|
existing.ImportBatchId ??= row.ImportBatchId;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
byKey[(row.Time, row.Kind)] = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. byKey.Values.OrderBy(r => r.Time).ThenBy(r => r.Kind)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,21 @@ namespace MeterVault.Core.Normalization.Normalizers;
|
|||||||
/// spreadsheet's month-one full register, e.g. Haus 411, Auto 3755);</item>
|
/// spreadsheet's month-one full register, e.g. Haus 411, Auto 3755);</item>
|
||||||
/// <item>meter swap → an explicit <c>Amount</c> override if given (how the water swap …861→2
|
/// <item>meter swap → an explicit <c>Amount</c> override if given (how the water swap …861→2
|
||||||
/// reconciles to 12), otherwise <c>(oldFinal − prev) + (curr − newInitial)</c>;</item>
|
/// reconciles to 12), otherwise <c>(oldFinal − prev) + (curr − newInitial)</c>;</item>
|
||||||
/// <item>counter reset → baseline restarts at <c>NewValue</c> (default 0);</item>
|
/// <item>counter reset → baseline restarts at <c>NewValue</c> (default 0); a <c>PrevValue</c>, when
|
||||||
|
/// given, is the register's last value before the reset and books the stretch since the previous
|
||||||
|
/// reading, exactly like a swap — without it that stretch is simply not counted;</item>
|
||||||
/// <item>unexplained decrease → 0 with an anomaly flagged (never a silent negative), rebaselined
|
/// <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 over an interval that crosses a local month boundary → divided across
|
||||||
|
/// those months by elapsed time and marked estimated (<see cref="GapAttribution"/>), so a reading
|
||||||
|
/// on 16 September does not file August's use under September. Consecutive imported month rows
|
||||||
|
/// span exactly one month and are never divided.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// "Ascending" is the order of <see cref="ReadingTimeline"/>: an imported month row describes the
|
||||||
|
/// register at the end of its month, so it comes after live readings taken during that month.
|
||||||
|
/// </remarks>
|
||||||
public abstract class CounterNormalizerBase : IMeterNormalizer
|
public abstract class CounterNormalizerBase : IMeterNormalizer
|
||||||
{
|
{
|
||||||
public abstract MeterMode Mode { get; }
|
public abstract MeterMode Mode { get; }
|
||||||
@@ -25,38 +35,37 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
var readings = context.Readings.OrderBy(r => r.Time).ToList();
|
var zone = context.TimeZone;
|
||||||
if (readings.Count == 0)
|
var timeline = ReadingTimeline.Build(context.Readings, zone);
|
||||||
|
if (timeline.Readings.Count == 0)
|
||||||
{
|
{
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var swaps = context.Events
|
var swaps = RegisterBoundary.Of(context.Events);
|
||||||
.Where(e => e.EventType is MeterEventType.MeterSwap or MeterEventType.CounterReset)
|
|
||||||
.OrderBy(e => e.Time)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
double previous = context.Meter.InitialBaseline;
|
double previous = context.Meter.InitialBaseline;
|
||||||
DateTimeOffset? previousTime = null;
|
DateTimeOffset? previousEffective = null;
|
||||||
|
|
||||||
foreach (var reading in readings)
|
foreach (var (reading, effective) in timeline.Readings)
|
||||||
{
|
{
|
||||||
var quality = reading.Quality == ReadingQuality.Measured ? ReadingQuality.Measured : reading.Quality;
|
var quality = reading.Quality == ReadingQuality.Measured ? ReadingQuality.Measured : reading.Quality;
|
||||||
var swap = FindEvent(swaps, previousTime, reading.Time);
|
var swap = RegisterBoundary.Find(swaps, previousEffective, effective, timeline.BoundaryTime);
|
||||||
|
|
||||||
double amount;
|
double amount;
|
||||||
|
var plainIncrease = false;
|
||||||
if (swap is { EventType: MeterEventType.MeterSwap })
|
if (swap is { EventType: MeterEventType.MeterSwap })
|
||||||
{
|
{
|
||||||
amount = swap.Amount
|
amount = swap.Amount ?? RegisterBoundary.Advance(swap, previous, reading.Value);
|
||||||
?? ((swap.PrevValue ?? previous) - previous) + (reading.Value - (swap.NewValue ?? 0));
|
|
||||||
}
|
}
|
||||||
else if (swap is { EventType: MeterEventType.CounterReset })
|
else if (swap is { EventType: MeterEventType.CounterReset })
|
||||||
{
|
{
|
||||||
amount = reading.Value - (swap.NewValue ?? 0);
|
amount = RegisterBoundary.Advance(swap, previous, reading.Value);
|
||||||
}
|
}
|
||||||
else if (reading.Value >= previous)
|
else if (reading.Value >= previous)
|
||||||
{
|
{
|
||||||
amount = reading.Value - previous;
|
amount = reading.Value - previous;
|
||||||
|
plainIncrease = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -65,32 +74,32 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
|
|||||||
quality = ReadingQuality.Estimated;
|
quality = ReadingQuality.Estimated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only a plain increase is attributed across months (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 months just adds
|
||||||
|
// rows that say nothing.
|
||||||
|
var stamp = GapAttribution.StampTime(reading, zone);
|
||||||
|
var segments = plainIncrease && Math.Abs(amount) > 1e-9 && previousEffective is { } from
|
||||||
|
? GapAttribution.Attribute(from, effective, stamp, amount, zone)
|
||||||
|
: [new GapSegment(stamp.ToUniversalTime(), amount)];
|
||||||
|
|
||||||
|
foreach (var segment in segments)
|
||||||
|
{
|
||||||
yield return new Consumption
|
yield return new Consumption
|
||||||
{
|
{
|
||||||
MeterId = context.Meter.MeterId,
|
MeterId = context.Meter.MeterId,
|
||||||
Time = reading.Time,
|
Time = segment.Time,
|
||||||
Amount = amount,
|
Amount = segment.Amount,
|
||||||
Kind = Kind,
|
Kind = Kind,
|
||||||
Quality = quality,
|
// A divided interval's total is measured; only its distribution across the months is
|
||||||
|
// inferred.
|
||||||
|
Quality = segments.Count > 1 ? ReadingQuality.Estimated : quality,
|
||||||
ImportBatchId = reading.ImportBatchId,
|
ImportBatchId = reading.ImportBatchId,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
previous = reading.Value;
|
previous = reading.Value;
|
||||||
previousTime = reading.Time;
|
previousEffective = effective;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MeterEvent? FindEvent(List<MeterEvent> events, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive)
|
|
||||||
{
|
|
||||||
foreach (var e in events)
|
|
||||||
{
|
|
||||||
var afterOk = afterExclusive is null || e.Time > afterExclusive.Value;
|
|
||||||
if (afterOk && e.Time <= upToInclusive)
|
|
||||||
{
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ namespace MeterVault.Core.Normalization.Normalizers;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The source already reports increments (SDD §5.2 <c>direct_delta</c>): each reading value is the
|
/// The source already reports increments (SDD §5.2 <c>direct_delta</c>): each reading value is the
|
||||||
/// consumption for its interval, used verbatim.
|
/// consumption for its interval, used verbatim. An imported month row ("August 2026" = 20) is that
|
||||||
|
/// month's consumption and is stamped inside it (<see cref="GapAttribution.StampTime"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DirectDeltaNormalizer : IMeterNormalizer
|
public sealed class DirectDeltaNormalizer : IMeterNormalizer
|
||||||
{
|
{
|
||||||
@@ -19,7 +20,7 @@ public sealed class DirectDeltaNormalizer : IMeterNormalizer
|
|||||||
yield return new Consumption
|
yield return new Consumption
|
||||||
{
|
{
|
||||||
MeterId = context.Meter.MeterId,
|
MeterId = context.Meter.MeterId,
|
||||||
Time = reading.Time,
|
Time = GapAttribution.StampTime(reading, context.TimeZone).ToUniversalTime(),
|
||||||
Amount = reading.Value,
|
Amount = reading.Value,
|
||||||
Kind = ConsumptionKind.Consumption,
|
Kind = ConsumptionKind.Consumption,
|
||||||
Quality = reading.Quality,
|
Quality = reading.Quality,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization.Normalizers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The register math shared by every normalizer that reads a monotonic register: a
|
||||||
|
/// <see cref="MeterEventType.MeterSwap"/> or <see cref="MeterEventType.CounterReset"/> is a point
|
||||||
|
/// where the register legitimately starts over, and the interval that contains it is booked as the
|
||||||
|
/// old register's tail plus the new register's advance rather than as a raw difference.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RegisterBoundary
|
||||||
|
{
|
||||||
|
/// <summary>The swap/reset events of a meter, oldest first — the only events that move a register.</summary>
|
||||||
|
public static List<MeterEvent> Of(IEnumerable<MeterEvent> events) =>
|
||||||
|
events
|
||||||
|
.Where(e => e.EventType is MeterEventType.MeterSwap or MeterEventType.CounterReset)
|
||||||
|
.OrderBy(e => e.Time)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The first boundary inside the reading interval <c>(afterExclusive, upToInclusive]</c>. A reading
|
||||||
|
/// taken at exactly the boundary's instant belongs to the new register, which is what lets a swap
|
||||||
|
/// recorded together with the new meter's start reading book cleanly at that instant.
|
||||||
|
/// </summary>
|
||||||
|
public static MeterEvent? Find(List<MeterEvent> boundaries, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive) =>
|
||||||
|
Find(boundaries, afterExclusive, upToInclusive, e => e.Time);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// As <see cref="Find(List{MeterEvent}, DateTimeOffset?, DateTimeOffset)"/>, placing each boundary on the
|
||||||
|
/// timeline at <paramref name="timeOf"/> — for a series walked in effective time rather than stamps.
|
||||||
|
/// </summary>
|
||||||
|
public static MeterEvent? Find(
|
||||||
|
List<MeterEvent> boundaries, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive, Func<MeterEvent, DateTimeOffset> timeOf)
|
||||||
|
{
|
||||||
|
foreach (var e in boundaries)
|
||||||
|
{
|
||||||
|
var time = timeOf(e);
|
||||||
|
var afterOk = afterExclusive is null || time > afterExclusive.Value;
|
||||||
|
if (afterOk && time <= upToInclusive)
|
||||||
|
{
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How far the register moved across a boundary: the old register from the previous reading up to
|
||||||
|
/// its final value (<see cref="MeterEvent.PrevValue"/>, defaulting to the previous reading, i.e. no
|
||||||
|
/// tail), plus the new register from its start value (<see cref="MeterEvent.NewValue"/>, default 0)
|
||||||
|
/// up to the current reading.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The start value never counts above the current reading. A swap detected in a monthly table records
|
||||||
|
/// the new register at the end of that month as its "start"; when a live reading earlier in the month
|
||||||
|
/// is the first on the new register, it is already below that value, and the advance is zero, not
|
||||||
|
/// negative.
|
||||||
|
/// </remarks>
|
||||||
|
public static double Advance(MeterEvent boundary, double previous, double current) =>
|
||||||
|
((boundary.PrevValue ?? previous) - previous) + (current - Math.Min(boundary.NewValue ?? 0, current));
|
||||||
|
}
|
||||||
@@ -9,6 +9,15 @@ namespace MeterVault.Core.Normalization.Normalizers;
|
|||||||
/// the tank level-Δ (<see cref="ConsumableBalanceNormalizer"/>); a runtime meter's Δhours feeds
|
/// the tank level-Δ (<see cref="ConsumableBalanceNormalizer"/>); a runtime meter's Δhours feeds
|
||||||
/// the empirical L/h analytic and is not double-counted as litres.
|
/// the empirical L/h analytic and is not double-counted as litres.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An hour counter is a register like any other, so a replaced burner or a reset counter is a
|
||||||
|
/// <see cref="MeterEventType.MeterSwap"/> / <see cref="MeterEventType.CounterReset"/> handled with the
|
||||||
|
/// same register math as <see cref="CounterNormalizerBase"/>: the interval containing the boundary
|
||||||
|
/// books the old counter's tail plus the new counter's advance. Without one, a decrease still books
|
||||||
|
/// nothing rather than a negative runtime. Readings are walked in <see cref="ReadingTimeline"/> order and
|
||||||
|
/// a month row's hours are stamped inside the month it names, like any other register; the hours are
|
||||||
|
/// not divided across months.
|
||||||
|
/// </remarks>
|
||||||
public sealed class RuntimeCounterNormalizer : IMeterNormalizer
|
public sealed class RuntimeCounterNormalizer : IMeterNormalizer
|
||||||
{
|
{
|
||||||
public MeterMode Mode => MeterMode.RuntimeCounter;
|
public MeterMode Mode => MeterMode.RuntimeCounter;
|
||||||
@@ -18,19 +27,36 @@ public sealed class RuntimeCounterNormalizer : IMeterNormalizer
|
|||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
var rate = context.Meter.Tank is { RateMode: TankRateMode.Fixed, FixedRate: { } r } ? r : 1d;
|
var rate = context.Meter.Tank is { RateMode: TankRateMode.Fixed, FixedRate: { } r } ? r : 1d;
|
||||||
var readings = context.Readings.OrderBy(x => x.Time).ToList();
|
var timeline = ReadingTimeline.Build(context.Readings, context.TimeZone);
|
||||||
|
var boundaries = RegisterBoundary.Of(context.Events);
|
||||||
|
|
||||||
double previous = context.Meter.InitialBaseline;
|
double previous = context.Meter.InitialBaseline;
|
||||||
foreach (var reading in readings)
|
DateTimeOffset? previousTime = null;
|
||||||
|
foreach (var (reading, effective) in timeline.Readings)
|
||||||
{
|
{
|
||||||
var deltaHours = reading.Value >= previous ? reading.Value - previous : 0d;
|
var boundary = RegisterBoundary.Find(boundaries, previousTime, effective, timeline.BoundaryTime);
|
||||||
|
|
||||||
|
double amount;
|
||||||
|
if (boundary is { EventType: MeterEventType.MeterSwap, Amount: { } explicitAmount })
|
||||||
|
{
|
||||||
|
amount = explicitAmount;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var deltaHours = boundary is not null
|
||||||
|
? RegisterBoundary.Advance(boundary, previous, reading.Value)
|
||||||
|
: reading.Value - previous;
|
||||||
|
amount = Math.Max(0d, deltaHours) * rate;
|
||||||
|
}
|
||||||
|
|
||||||
previous = reading.Value;
|
previous = reading.Value;
|
||||||
|
previousTime = effective;
|
||||||
|
|
||||||
yield return new Consumption
|
yield return new Consumption
|
||||||
{
|
{
|
||||||
MeterId = context.Meter.MeterId,
|
MeterId = context.Meter.MeterId,
|
||||||
Time = reading.Time,
|
Time = GapAttribution.StampTime(reading, context.TimeZone).ToUniversalTime(),
|
||||||
Amount = deltaHours * rate,
|
Amount = amount,
|
||||||
Kind = ConsumptionKind.Consumption,
|
Kind = ConsumptionKind.Consumption,
|
||||||
Quality = reading.Quality,
|
Quality = reading.Quality,
|
||||||
ImportBatchId = reading.ImportBatchId,
|
ImportBatchId = reading.ImportBatchId,
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Normalization;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A meter's readings in the order they describe the register, and where its swaps and resets sit on
|
||||||
|
/// that order. Shared by every normalizer that reads a register, and by the checks that must agree with
|
||||||
|
/// them (the decrease guard, the event dialog), so none of them orders readings differently.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The order is effective time (<see cref="GapAttribution.EffectiveTime"/>): an imported month row
|
||||||
|
/// describes the register at the end of its month, so it comes after live readings taken during that
|
||||||
|
/// month even though it is stamped on the 1st. Walking stamps instead would take the end-of-month value
|
||||||
|
/// first, see the live readings as a drop, and count the month twice.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A swap or reset the importer detected in a monthly table is stored at that row's stamp, but the row
|
||||||
|
/// only says the register started over at some point during its month. It is placed at the start of
|
||||||
|
/// the labelled local month, so it applies to the month's first reading: the month row itself when the
|
||||||
|
/// table stands alone, or the first live reading of that month when there are any — which is already
|
||||||
|
/// on the new register.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ReadingTimeline
|
||||||
|
{
|
||||||
|
private readonly Dictionary<DateTimeOffset, DateTimeOffset> _labelMonthStarts;
|
||||||
|
|
||||||
|
private ReadingTimeline(IReadOnlyList<TimelineReading> readings, Dictionary<DateTimeOffset, DateTimeOffset> labelMonthStarts)
|
||||||
|
{
|
||||||
|
Readings = readings;
|
||||||
|
_labelMonthStarts = labelMonthStarts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The readings, oldest first by effective time, then by stamp.</summary>
|
||||||
|
public IReadOnlyList<TimelineReading> Readings { get; }
|
||||||
|
|
||||||
|
public static ReadingTimeline Build(IEnumerable<Reading> readings, TimeZoneInfo zone)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(readings);
|
||||||
|
ArgumentNullException.ThrowIfNull(zone);
|
||||||
|
|
||||||
|
var ordered = readings
|
||||||
|
.Select(r => new TimelineReading(r, GapAttribution.EffectiveTime(r, zone)))
|
||||||
|
.OrderBy(r => r.Effective)
|
||||||
|
.ThenBy(r => r.Reading.Time)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var labelMonthStarts = new Dictionary<DateTimeOffset, DateTimeOffset>();
|
||||||
|
foreach (var entry in ordered.Where(r => GapAttribution.IsMonthLabel(r.Reading)))
|
||||||
|
{
|
||||||
|
labelMonthStarts.TryAdd(entry.Reading.Time, GapAttribution.LabelMonthStart(entry.Reading, zone));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReadingTimeline(ordered, labelMonthStarts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a swap or reset sits on the timeline: its own time, or — when it is stamped exactly at a
|
||||||
|
/// month row — just after the start of that row's local month.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset BoundaryTime(MeterEvent boundary)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(boundary);
|
||||||
|
|
||||||
|
return _labelMonthStarts.TryGetValue(boundary.Time, out var monthStart) ? monthStart.AddTicks(1) : boundary.Time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A reading and the instant it describes.</summary>
|
||||||
|
public readonly record struct TimelineReading(Reading Reading, DateTimeOffset Effective);
|
||||||
@@ -19,6 +19,25 @@ public sealed class ExportService(MeterVaultDbContext db)
|
|||||||
|
|
||||||
private readonly MeterVaultDbContext _db = 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)
|
public async Task<string> ExportJsonAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Load without navigation includes so serialization is a clean, cycle-free tree.
|
// 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),
|
EnergyTypes = await _db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
Meters = await _db.Meters.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),
|
MeterSources = await _db.MeterSources.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using MeterVault.Core.Costing;
|
using MeterVault.Core.Costing;
|
||||||
using MeterVault.Core.Domain;
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Options;
|
||||||
using MeterVault.Infrastructure.Persistence;
|
using MeterVault.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace MeterVault.Infrastructure.Costing;
|
namespace MeterVault.Infrastructure.Costing;
|
||||||
|
|
||||||
@@ -13,10 +15,18 @@ namespace MeterVault.Infrastructure.Costing;
|
|||||||
/// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext
|
/// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext
|
||||||
/// factory (short-lived context per operation) so it is safe from a Blazor circuit.
|
/// factory (short-lived context per operation) so it is safe from a Blazor circuit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
public sealed class CostService(
|
||||||
|
IDbContextFactory<MeterVaultDbContext> contextFactory, IOptions<MeterVaultOptions>? options = null)
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
|
||||||
|
/// intervals at, so a share stamped at a month's last second is read back under that month.
|
||||||
|
/// </summary>
|
||||||
|
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
|
||||||
|
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync(
|
public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync(
|
||||||
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month,
|
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -31,7 +41,7 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFa
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, cancellationToken).ConfigureAwait(false);
|
var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, _timeZone, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var results = new List<MeterCostBucket>();
|
var results = new List<MeterCostBucket>();
|
||||||
foreach (var period in series.Keys.OrderBy(k => k))
|
foreach (var period in series.Keys.OrderBy(k => k))
|
||||||
@@ -85,8 +95,9 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fromDate = DateOnly.FromDateTime(from.Date);
|
// Manual costs are dated in local months; the instants passed in are local midnights.
|
||||||
var toDate = DateOnly.FromDateTime(to.Date);
|
var fromDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(from, _zone).Date);
|
||||||
|
var toDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(to, _zone).Date);
|
||||||
var manualCosts = await db.ManualCosts.AsNoTracking()
|
var manualCosts = await db.ManualCosts.AsNoTracking()
|
||||||
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate)
|
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate)
|
||||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -100,7 +111,7 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFa
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
|
private static async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
|
||||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket,
|
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket, string tz,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var interval = bucket switch
|
var interval = bucket switch
|
||||||
@@ -111,13 +122,13 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFa
|
|||||||
};
|
};
|
||||||
|
|
||||||
var sql =
|
var sql =
|
||||||
$"SELECT (time_bucket(INTERVAL '{interval}', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
|
$"SELECT (time_bucket(INTERVAL '{interval}', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " +
|
||||||
"kind, sum(amount) AS amount " +
|
"kind, sum(amount) AS amount " +
|
||||||
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||||
"GROUP BY period, kind";
|
"GROUP BY period, kind";
|
||||||
|
|
||||||
var connection = db.Database.GetDbConnection();
|
var connection = db.Database.GetDbConnection();
|
||||||
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken);
|
||||||
var rows = await connection.QueryAsync<ConsumptionRow>(command).ConfigureAwait(false);
|
var rows = await connection.QueryAsync<ConsumptionRow>(command).ConfigureAwait(false);
|
||||||
|
|
||||||
var result = new Dictionary<DateOnly, (double, double)>();
|
var result = new Dictionary<DateOnly, (double, double)>();
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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>A consumable-balance meter with no tank yet, so it has no level, fill or forecast to show.</summary>
|
||||||
|
public sealed record UnconfiguredConsumable(int MeterId, string Name);
|
||||||
|
|
||||||
|
/// <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,202 @@
|
|||||||
|
using Dapper;
|
||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using MeterVault.Infrastructure.Normalization;
|
||||||
|
using MeterVault.Infrastructure.Options;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
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, IOptions<MeterVaultOptions>? options = null)
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
|
private readonly CostService _costService = costService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
|
||||||
|
/// intervals at, so a share stamped at a month's last second is read back under that month.
|
||||||
|
/// </summary>
|
||||||
|
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
|
||||||
|
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consumable-balance meters that have no tank row. <see cref="GetConsumablesAsync"/> has to skip
|
||||||
|
/// them — capacity and calibration come from the tank — so without this they would simply be
|
||||||
|
/// missing from the panel, with nothing saying why.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<UnconfiguredConsumable>> GetUnconfiguredAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await db.Meters.AsNoTracking()
|
||||||
|
.Where(m => m.Mode == MeterMode.ConsumableBalance && !db.Tanks.Any(t => t.MeterId == m.Id))
|
||||||
|
.OrderBy(m => m.Name)
|
||||||
|
.Select(m => new UnconfiguredConsumable(m.Id, m.Name))
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = InstanceTimeZone.StartOf(from, _zone);
|
||||||
|
var toUtc = InstanceTimeZone.StartOf(to, _zone);
|
||||||
|
|
||||||
|
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, _timeZone, 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, string tz, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql =
|
||||||
|
"SELECT (time_bucket(INTERVAL '1 month', \"time\", @tz) AT TIME ZONE @tz)::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, tz }, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -17,6 +17,35 @@ public sealed record DashboardSummary(DateOnly AsOf, CostKpi Month, CostKpi Year
|
|||||||
/// <summary>One slice of the cost breakdown / "what costs most" view.</summary>
|
/// <summary>One slice of the cost breakdown / "what costs most" view.</summary>
|
||||||
public sealed record CategorySlice(string Name, string? ColorHex, double Cost);
|
public sealed record CategorySlice(string Name, string? ColorHex, double Cost);
|
||||||
|
|
||||||
|
/// <summary>The first thing missing before the dashboard can show a cost, in the order they are set up.</summary>
|
||||||
|
public enum CostSetupGap
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
NoMeters,
|
||||||
|
NoCategories,
|
||||||
|
NoMembers,
|
||||||
|
NoTariffs,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What exists of the chain a cost figure needs: a meter, a category that counts it, and a price.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Manual costs stand in for the meter side only on an instance without meters. Once there are meters,
|
||||||
|
/// their chain is diagnosed on its own: the diagnosis only runs when this year shows no cost, so any
|
||||||
|
/// manual cost there is from another year and explains nothing about the meters.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record CostSetup(bool HasMeters, bool HasCategories, bool HasMembers, bool HasTariffs, bool HasManualCosts)
|
||||||
|
{
|
||||||
|
public CostSetupGap FirstGap =>
|
||||||
|
!HasMeters && !HasManualCosts ? CostSetupGap.NoMeters
|
||||||
|
: !HasCategories ? CostSetupGap.NoCategories
|
||||||
|
: !HasMeters ? CostSetupGap.None
|
||||||
|
: !HasMembers ? CostSetupGap.NoMembers
|
||||||
|
: !HasTariffs ? CostSetupGap.NoTariffs
|
||||||
|
: CostSetupGap.None;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A point on a monthly cost/consumption trend.</summary>
|
/// <summary>A point on a monthly cost/consumption trend.</summary>
|
||||||
public sealed record TrendPoint(DateOnly Period, double Cost);
|
public sealed record TrendPoint(DateOnly Period, double Cost);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using MeterVault.Infrastructure.Costing;
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using MeterVault.Infrastructure.Options;
|
||||||
using MeterVault.Infrastructure.Persistence;
|
using MeterVault.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace MeterVault.Infrastructure.Dashboard;
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
@@ -10,11 +12,18 @@ namespace MeterVault.Infrastructure.Dashboard;
|
|||||||
/// only aggregated cost — never the raw hypertable. Uses a DbContext factory (short-lived context
|
/// only aggregated cost — never the raw hypertable. Uses a DbContext factory (short-lived context
|
||||||
/// per operation) so it is safe from a Blazor circuit.
|
/// per operation) so it is safe from a Blazor circuit.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
|
public sealed class DashboardService(
|
||||||
|
IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService, IOptions<MeterVaultOptions>? options = null)
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
private readonly CostService _costService = costService;
|
private readonly CostService _costService = costService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The instance timezone: periods are local months, so their bounds are local midnights — the same
|
||||||
|
/// months consumption is divided and bucketed in.
|
||||||
|
/// </summary>
|
||||||
|
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
|
||||||
|
|
||||||
public async Task<DashboardSummary> GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default)
|
public async Task<DashboardSummary> GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -36,6 +45,24 @@ public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> cont
|
|||||||
return new DashboardSummary(asOf, month, year, latest);
|
return new DashboardSummary(asOf, month, year, latest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which part of the cost setup exists, so an empty dashboard can name the step that is missing
|
||||||
|
/// instead of listing every admin page. Existence checks only.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CostSetup> GetCostSetupAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return new CostSetup(
|
||||||
|
HasMeters: await db.Meters.AnyAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
HasCategories: await db.CostCategories.AnyAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
// A membership counts only if it reaches a meter: an energy type with no meters costs nothing.
|
||||||
|
HasMembers: await db.CostCategoryMembers
|
||||||
|
.AnyAsync(m => m.MeterId != null || db.Meters.Any(x => x.EnergyTypeId == m.EnergyTypeId), cancellationToken)
|
||||||
|
.ConfigureAwait(false),
|
||||||
|
HasTariffs: await db.Tariffs.AnyAsync(cancellationToken).ConfigureAwait(false),
|
||||||
|
HasManualCosts: await db.ManualCosts.AnyAsync(cancellationToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync(
|
public async Task<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync(
|
||||||
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -124,12 +151,13 @@ public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> cont
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var monthStart = new DateOnly(latest.Value.Year, latest.Value.Month, 1);
|
var local = TimeZoneInfo.ConvertTime(latest.Value, _zone);
|
||||||
|
var monthStart = new DateOnly(local.Year, local.Month, 1);
|
||||||
return await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false);
|
return await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<int>> ActiveMeterIdsAsync(MeterVaultDbContext db, CancellationToken cancellationToken) =>
|
private static async Task<List<int>> ActiveMeterIdsAsync(MeterVaultDbContext db, CancellationToken cancellationToken) =>
|
||||||
await db.Meters.AsNoTracking().Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
|
await db.Meters.AsNoTracking().Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
private DateTimeOffset ToUtc(DateOnly date) => InstanceTimeZone.StartOf(date, _zone);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
|
||||||
|
/// <param name="Label">
|
||||||
|
/// A meter's name — for a remainder node (<paramref name="IsOther"/>) the <em>parent</em> meter's
|
||||||
|
/// name. The surrounding wording ("Other (…)") is the UI's to supply, because it is the only layer
|
||||||
|
/// that knows the reader's language; see <c>Flow_OtherNode</c>.
|
||||||
|
/// </param>
|
||||||
|
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,165 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Options;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
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, IOptions<MeterVaultOptions>? options = null)
|
||||||
|
{
|
||||||
|
private const double Epsilon = 0.01;
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
|
|
||||||
|
/// <summary>The instance timezone a requested date range starts and ends in.</summary>
|
||||||
|
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
|
||||||
|
|
||||||
|
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 = InstanceTimeZone.StartOf(from, _zone);
|
||||||
|
var toUtc = InstanceTimeZone.StartOf(to, _zone);
|
||||||
|
|
||||||
|
// 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}";
|
||||||
|
// Just the parent's name: the "Other (…)" phrasing is added by the UI, which is
|
||||||
|
// where the reader's language is known.
|
||||||
|
nodes.Add(new FlowNode(otherId, 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}";
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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="Kind"/> says which — as the enum, not a word, because the
|
||||||
|
/// wording belongs to whichever language the reader picked.
|
||||||
|
/// </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(
|
||||||
|
ConsumptionKind Kind,
|
||||||
|
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. <see cref="ImportBatchId"/> marks one only its batch can remove.</summary>
|
||||||
|
public sealed record EventRow(int Id, DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes, int? ImportBatchId);
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
short EnergyTypeId,
|
||||||
|
bool HasTank);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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).ThenByDescending(e => e.Id)
|
||||||
|
.Select(e => new EventRow(e.Id, e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes, e.ImportBatchId))
|
||||||
|
.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);
|
||||||
|
|
||||||
|
var hasTank = await db.Tanks.AsNoTracking().AnyAsync(t => t.MeterId == meterId, 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, meter.EnergyTypeId, hasTank);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
Kind: kind,
|
||||||
|
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,125 @@
|
|||||||
|
using Dapper;
|
||||||
|
using MeterVault.Core.Costing;
|
||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Options;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
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, in the instance timezone) via Dapper; safe from a Blazor circuit via a DbContext factory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SolarService(
|
||||||
|
IDbContextFactory<MeterVaultDbContext> contextFactory, IOptions<MeterVaultOptions>? options = null)
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
|
||||||
|
/// intervals at, so a share stamped at a month's last second is read back under that month.
|
||||||
|
/// </summary>
|
||||||
|
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
|
||||||
|
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
|
||||||
|
|
||||||
|
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 = InstanceTimeZone.StartOf(from, _zone);
|
||||||
|
var toUtc = InstanceTimeZone.StartOf(to, _zone);
|
||||||
|
|
||||||
|
// 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, _timeZone, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadByMonth = loadMeter is null
|
||||||
|
? null
|
||||||
|
: await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
|
||||||
|
var gridByMonth = gridMeter is null
|
||||||
|
? null
|
||||||
|
: await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, _timeZone, 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, string tz, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql =
|
||||||
|
"SELECT (time_bucket(INTERVAL '1 month', \"time\", @tz) AT TIME ZONE @tz)::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, tz }, cancellationToken: cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
|
||||||
|
return rows.ToDictionary(r => r.Period, r => r.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -28,13 +28,30 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.AddSingleton<INormalizationEngine>(_ => NormalizationEngine.CreateDefault());
|
services.AddSingleton<INormalizationEngine>(_ => NormalizationEngine.CreateDefault());
|
||||||
services.AddScoped<NormalizationService>();
|
services.AddScoped<NormalizationService>();
|
||||||
|
services.AddScoped<NormalizationUpgrade>();
|
||||||
services.AddScoped<CsvImporter>();
|
services.AddScoped<CsvImporter>();
|
||||||
services.AddScoped<ImportService>();
|
services.AddScoped<ImportService>();
|
||||||
services.AddScoped<ReferenceDataImporter>();
|
services.AddScoped<ReferenceDataImporter>();
|
||||||
services.AddScoped<IngestionService>();
|
services.AddScoped<IngestionService>();
|
||||||
|
services.AddScoped<MeterEventService>();
|
||||||
services.AddScoped<MqttMessageRouter>();
|
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<Costing.CostService>();
|
||||||
services.AddScoped<Dashboard.DashboardService>();
|
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>();
|
services.AddScoped<Backup.ExportService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
@@ -49,6 +66,7 @@ public static class DependencyInjection
|
|||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
services.AddHostedService<MqttIngestionWorker>();
|
services.AddHostedService<MqttIngestionWorker>();
|
||||||
services.AddHostedService<HomeAssistantWorker>();
|
services.AddHostedService<HomeAssistantWorker>();
|
||||||
|
services.AddHostedService<HomeAssistantWebSocketWorker>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,29 +31,29 @@ public sealed class CsvImporter
|
|||||||
staged.SkippedRows++;
|
staged.SkippedRows++;
|
||||||
if (!reason.StartsWith("blank", StringComparison.Ordinal))
|
if (!reason.StartsWith("blank", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
staged.Warnings.Add($"Row {r + 1}: skipped ({reason}).");
|
staged.Warnings.Add(ImportWarnings.RowSkipped(r + 1, reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryGetDate(row, profile, out var time))
|
if (!TryGetDate(row, profile, out var time, out var monthLabel))
|
||||||
{
|
{
|
||||||
staged.SkippedRows++;
|
staged.SkippedRows++;
|
||||||
staged.Warnings.Add($"Row {r + 1}: unparseable date, skipped.");
|
staged.Warnings.Add(ImportWarnings.UnparseableDate(r + 1));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var column in profile.Columns)
|
foreach (var column in profile.Columns)
|
||||||
{
|
{
|
||||||
StageColumn(column, row, time, r, staged, previousByMeter, profile.DetectCumulativeSwaps);
|
StageColumn(column, row, time, monthLabel, r, staged, previousByMeter, profile.DetectCumulativeSwaps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return staged;
|
return staged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time,
|
private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time, bool monthLabel,
|
||||||
int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
|
int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
|
||||||
{
|
{
|
||||||
if (column.Role == MappingRole.Ignore || column.Index >= row.Count)
|
if (column.Role == MappingRole.Ignore || column.Index >= row.Count)
|
||||||
@@ -70,7 +70,7 @@ public sealed class CsvImporter
|
|||||||
switch (column.Role)
|
switch (column.Role)
|
||||||
{
|
{
|
||||||
case MappingRole.Reading:
|
case MappingRole.Reading:
|
||||||
StageReading(column, row, cell, time, rowIndex, staged, previousByMeter, detectSwaps);
|
StageReading(column, row, cell, time, monthLabel, rowIndex, staged, previousByMeter, detectSwaps);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MappingRole.Delivery:
|
case MappingRole.Delivery:
|
||||||
@@ -126,7 +126,7 @@ public sealed class CsvImporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void StageReading(ColumnMapping column, IReadOnlyList<string> row, string cell,
|
private static void StageReading(ColumnMapping column, IReadOnlyList<string> row, string cell,
|
||||||
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
|
DateTimeOffset time, bool monthLabel, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
|
||||||
{
|
{
|
||||||
var split = ValueCell.Split(cell);
|
var split = ValueCell.Split(cell);
|
||||||
if (split is null)
|
if (split is null)
|
||||||
@@ -140,8 +140,7 @@ public sealed class CsvImporter
|
|||||||
if (column.Unit is not null && split.Value.Unit is not null
|
if (column.Unit is not null && split.Value.Unit is not null
|
||||||
&& !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase))
|
&& !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
staged.Warnings.Add(
|
staged.Warnings.Add(ImportWarnings.UnitMismatch(rowIndex + 1, column.Unit, split.Value.Unit));
|
||||||
$"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
|
if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
|
||||||
@@ -157,9 +156,9 @@ public sealed class CsvImporter
|
|||||||
Amount = override_,
|
Amount = override_,
|
||||||
Notes = "Auto-detected register decrease (meter swap / reset candidate).",
|
Notes = "Auto-detected register decrease (meter swap / reset candidate).",
|
||||||
});
|
});
|
||||||
staged.Warnings.Add(
|
staged.Warnings.Add(override_ is { } amount
|
||||||
$"Row {rowIndex + 1}: register for meter {meterId} dropped {previous}→{value}; " +
|
? ImportWarnings.RegisterDroppedWithAmount(rowIndex + 1, meterId, previous, value, amount)
|
||||||
(override_ is null ? "swap event created (verify)." : $"swap consumption set to {override_}."));
|
: ImportWarnings.RegisterDropped(rowIndex + 1, meterId, previous, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
staged.Readings.Add(new Reading
|
staged.Readings.Add(new Reading
|
||||||
@@ -168,6 +167,7 @@ public sealed class CsvImporter
|
|||||||
Time = time,
|
Time = time,
|
||||||
Value = value,
|
Value = value,
|
||||||
Quality = ReadingQuality.Imported,
|
Quality = ReadingQuality.Imported,
|
||||||
|
Flags = monthLabel ? ReadingFlags.MonthLabel : ReadingFlags.None,
|
||||||
});
|
});
|
||||||
previousByMeter[meterId] = value;
|
previousByMeter[meterId] = value;
|
||||||
}
|
}
|
||||||
@@ -182,9 +182,16 @@ public sealed class CsvImporter
|
|||||||
return GermanNumber.TryParse(row[source], out var value) ? value : null;
|
return GermanNumber.TryParse(row[source], out var value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetDate(IReadOnlyList<string> row, MappingProfile profile, out DateTimeOffset time)
|
/// <param name="monthLabel">
|
||||||
|
/// Whether the cell named a month ("Mai 2026") stamped on its 1st, rather than a day. Normalization
|
||||||
|
/// reads such a row as the register at the end of that month (<see cref="ReadingFlags.MonthLabel"/>);
|
||||||
|
/// a day-dated "01.08.2026" at the same midnight is an instant. Months anchored to their last day
|
||||||
|
/// are stamped where they describe, so they are not labels.
|
||||||
|
/// </param>
|
||||||
|
private static bool TryGetDate(IReadOnlyList<string> row, MappingProfile profile, out DateTimeOffset time, out bool monthLabel)
|
||||||
{
|
{
|
||||||
time = default;
|
time = default;
|
||||||
|
monthLabel = false;
|
||||||
if (profile.DateColumn >= row.Count)
|
if (profile.DateColumn >= row.Count)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -192,12 +199,21 @@ public sealed class CsvImporter
|
|||||||
|
|
||||||
var raw = row[profile.DateColumn];
|
var raw = row[profile.DateColumn];
|
||||||
DateOnly date;
|
DateOnly date;
|
||||||
var parsed = profile.DateKind switch
|
bool parsed;
|
||||||
|
switch (profile.DateKind)
|
||||||
{
|
{
|
||||||
DateKind.MonthName => TryParseMonthAnchored(raw, profile.AnchorMonthsToEnd, out date),
|
case DateKind.MonthName:
|
||||||
DateKind.DayDotMonthYear => GermanDate.TryParseDay(raw, out date),
|
parsed = TryParseMonthAnchored(raw, profile.AnchorMonthsToEnd, out date);
|
||||||
_ => ImportDate.TryResolve(raw, profile.AnchorMonthsToEnd, out date),
|
monthLabel = parsed && !profile.AnchorMonthsToEnd;
|
||||||
};
|
break;
|
||||||
|
case DateKind.DayDotMonthYear:
|
||||||
|
parsed = GermanDate.TryParseDay(raw, out date);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
parsed = ImportDate.TryResolve(raw, profile.AnchorMonthsToEnd, out date);
|
||||||
|
monthLabel = parsed && !profile.AnchorMonthsToEnd && !GermanDate.TryParseDay(raw, out _);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (!parsed)
|
if (!parsed)
|
||||||
{
|
{
|
||||||
@@ -222,6 +238,12 @@ public sealed class CsvImporter
|
|||||||
return true;
|
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)
|
internal static List<string[]> ReadRows(TextReader reader)
|
||||||
{
|
{
|
||||||
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
|
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)
|
StagedImport staged, string? sourceName, string? mappingJson, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(staged);
|
ArgumentNullException.ThrowIfNull(staged);
|
||||||
|
GuardDuplicateReadings(staged);
|
||||||
|
await GuardExistingReadingsAsync(staged, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await using var tx = await _db.Database.BeginTransactionAsync(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);
|
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) =>
|
private static IEnumerable<int> AffectedMeters(StagedImport staged) =>
|
||||||
staged.Readings.Select(r => r.MeterId)
|
staged.Readings.Select(r => r.MeterId)
|
||||||
.Concat(staged.Events.Select(e => e.MeterId))
|
.Concat(staged.Events.Select(e => e.MeterId))
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Import;
|
||||||
|
|
||||||
|
/// <summary>What a staging warning is about.</summary>
|
||||||
|
public enum ImportWarningKind
|
||||||
|
{
|
||||||
|
/// <summary>A row matched a skip rule (a summary line, an all-zero placeholder). Args: row, reason.</summary>
|
||||||
|
RowSkipped,
|
||||||
|
|
||||||
|
/// <summary>The date cell parsed as neither <c>Monat JJJJ</c> nor <c>TT.MM.JJJJ</c>. Args: row.</summary>
|
||||||
|
UnparseableDate,
|
||||||
|
|
||||||
|
/// <summary>The value carried a different unit suffix than the column expects. Args: row, expected, found.</summary>
|
||||||
|
UnitMismatch,
|
||||||
|
|
||||||
|
/// <summary>A register went backwards; a swap event was staged for review. Args: row, meter, previous, new.</summary>
|
||||||
|
RegisterDropped,
|
||||||
|
|
||||||
|
/// <summary>Same, with the consumption across the swap taken from the sheet. Args: row, meter, previous, new, amount.</summary>
|
||||||
|
RegisterDroppedWithAmount,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One thing worth telling the user about a staged row.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Message">
|
||||||
|
/// The English sentence, kept for logs and any non-UI caller.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Args">
|
||||||
|
/// The values that filled it, in order. The UI re-formats them into its own language's sentence, so
|
||||||
|
/// numbers pick up the reader's digit grouping instead of being frozen into a string here.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ImportWarning(ImportWarningKind Kind, string Message, IReadOnlyList<object?> Args)
|
||||||
|
{
|
||||||
|
/// <summary>Falls back to the English sentence, so a plain <c>ToString()</c> is still useful.</summary>
|
||||||
|
public override string ToString() => Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the warnings the CSV importer emits.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each kind has exactly one English format string here, and the arguments travel alongside the
|
||||||
|
/// rendered sentence — so the importer stays free of any notion of who is reading, and the admin UI
|
||||||
|
/// can say the same thing in German without this assembly growing a resource file.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class ImportWarnings
|
||||||
|
{
|
||||||
|
public static ImportWarning RowSkipped(int row, string reason) =>
|
||||||
|
Create(ImportWarningKind.RowSkipped, "Row {0}: skipped ({1}).", row, reason);
|
||||||
|
|
||||||
|
public static ImportWarning UnparseableDate(int row) =>
|
||||||
|
Create(ImportWarningKind.UnparseableDate, "Row {0}: unparseable date, skipped.", row);
|
||||||
|
|
||||||
|
public static ImportWarning UnitMismatch(int row, string expected, string found) =>
|
||||||
|
Create(ImportWarningKind.UnitMismatch, "Row {0}: expected unit '{1}' but found '{2}'.", row, expected, found);
|
||||||
|
|
||||||
|
public static ImportWarning RegisterDropped(int row, int meterId, double previous, double current) =>
|
||||||
|
Create(
|
||||||
|
ImportWarningKind.RegisterDropped,
|
||||||
|
"Row {0}: register for meter {1} dropped {2}→{3}; swap event created (verify).",
|
||||||
|
row, meterId, previous, current);
|
||||||
|
|
||||||
|
public static ImportWarning RegisterDroppedWithAmount(
|
||||||
|
int row, int meterId, double previous, double current, double amount) =>
|
||||||
|
Create(
|
||||||
|
ImportWarningKind.RegisterDroppedWithAmount,
|
||||||
|
"Row {0}: register for meter {1} dropped {2}→{3}; swap consumption set to {4}.",
|
||||||
|
row, meterId, previous, current, amount);
|
||||||
|
|
||||||
|
private static ImportWarning Create(ImportWarningKind kind, string format, params object?[] args) =>
|
||||||
|
new(kind, string.Format(CultureInfo.InvariantCulture, format, args), args);
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
|||||||
return;
|
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);
|
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var electricity = await EnergyTypeIdAsync("electricity", 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 wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
|
||||||
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
|
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
|
||||||
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
|
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);
|
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
_db.Tanks.Add(new Tank
|
_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)}}}",
|
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);
|
AddElectricityTariffs(electricity);
|
||||||
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
|
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
|
// 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 }],
|
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)
|
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = Path.Combine(dir, file);
|
var path = Path.Combine(dir, file);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
return;
|
throw new FileNotFoundException($"Reference CSV disappeared during import: '{path}'.", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
StagedImport staged;
|
StagedImport staged;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed class StagedImport
|
|||||||
|
|
||||||
public List<ManualCost> ManualCosts { get; } = [];
|
public List<ManualCost> ManualCosts { get; } = [];
|
||||||
|
|
||||||
public List<string> Warnings { get; } = [];
|
public List<ImportWarning> Warnings { get; } = [];
|
||||||
|
|
||||||
public int SkippedRows { get; set; }
|
public int SkippedRows { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ using System.Text.Json.Serialization;
|
|||||||
namespace MeterVault.Infrastructure.Ingestion;
|
namespace MeterVault.Infrastructure.Ingestion;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for an MQTT broker. Secrets
|
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for an MQTT broker. Credentials
|
||||||
/// are stored by reference only (SDD §6.4): <see cref="UsernameEnv"/>/<see cref="PasswordEnv"/>
|
/// are never held here as plaintext (SDD §6.4): <see cref="UsernameEnv"/>/<see cref="PasswordEnv"/>
|
||||||
/// name environment variables resolved at runtime, never plaintext credentials in the database.
|
/// 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>
|
/// </summary>
|
||||||
public sealed record EndpointConfig
|
public sealed record EndpointConfig
|
||||||
{
|
{
|
||||||
@@ -29,6 +30,15 @@ public sealed record EndpointConfig
|
|||||||
|
|
||||||
public string? PasswordEnv { get; init; }
|
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)
|
public static EndpointConfig Parse(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(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) =>
|
public string? ResolvePassword(Security.SecretProtector? protector = null) =>
|
||||||
string.IsNullOrWhiteSpace(envVarName) ? null : Environment.GetEnvironmentVariable(envVarName);
|
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,119 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Ingestion;
|
||||||
|
|
||||||
|
/// <summary>Which way a Home Assistant connectivity test ended.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The verdict is shown to whoever clicked "Test connection", so it has to be sayable in their
|
||||||
|
/// language — and this assembly has no business knowing what that is. The outcome travels as a
|
||||||
|
/// value and the admin UI supplies the words.
|
||||||
|
/// </remarks>
|
||||||
|
public enum HaTestOutcome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No test was run — the caller decided beforehand (no token configured, base URL edited since
|
||||||
|
/// saving) and put its own, already-localized wording in <see cref="HaTestResult.Message"/>.
|
||||||
|
/// The default, so a result built by the UI needs no ceremony to say so.
|
||||||
|
/// </summary>
|
||||||
|
Precondition,
|
||||||
|
|
||||||
|
/// <summary>Reachable and the token was accepted; no entity was named to sample.</summary>
|
||||||
|
Connected,
|
||||||
|
|
||||||
|
/// <summary>Reachable and the named entity returned a number — see <see cref="HaTestResult.SampleValue"/>.</summary>
|
||||||
|
ConnectedWithValue,
|
||||||
|
|
||||||
|
/// <summary>No base URL was given.</summary>
|
||||||
|
BaseUrlMissing,
|
||||||
|
|
||||||
|
/// <summary>No token was available to test with.</summary>
|
||||||
|
TokenMissing,
|
||||||
|
|
||||||
|
/// <summary>HA answered, but not with success. <see cref="HaTestResult.Detail"/> carries the status.</summary>
|
||||||
|
HttpError,
|
||||||
|
|
||||||
|
/// <summary>Reachable, but the named entity has no numeric state (unavailable/unknown/non-numeric).</summary>
|
||||||
|
NoNumericState,
|
||||||
|
|
||||||
|
/// <summary>The request threw. <see cref="HaTestResult.Detail"/> carries the exception message.</summary>
|
||||||
|
RequestFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Outcome of a Home Assistant connectivity test.</summary>
|
||||||
|
/// <param name="Message">The English summary, kept for logs and non-UI callers.</param>
|
||||||
|
/// <param name="Outcome">The same verdict as a value, for a UI that has to phrase it in some language.</param>
|
||||||
|
/// <param name="EntityId">The entity that was sampled, when one was named.</param>
|
||||||
|
/// <param name="Detail">Diagnostic text (HTTP status, exception message). Not ours to translate.</param>
|
||||||
|
public sealed record HaTestResult(
|
||||||
|
bool Ok,
|
||||||
|
string Message,
|
||||||
|
double? SampleValue = null,
|
||||||
|
HaTestOutcome Outcome = HaTestOutcome.Precondition,
|
||||||
|
string? EntityId = null,
|
||||||
|
string? Detail = 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.", Outcome: HaTestOutcome.BaseUrlMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return new HaTestResult(false, "No token available to test.", Outcome: HaTestOutcome.TokenMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}.",
|
||||||
|
Outcome: HaTestOutcome.HttpError,
|
||||||
|
Detail: $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(entityId))
|
||||||
|
{
|
||||||
|
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.",
|
||||||
|
Outcome: HaTestOutcome.Connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
HaTestOutcome.ConnectedWithValue, entityId)
|
||||||
|
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).",
|
||||||
|
Outcome: HaTestOutcome.NoNumericState, EntityId: entityId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl);
|
||||||
|
return new HaTestResult(false, $"Connection failed: {ex.Message}",
|
||||||
|
Outcome: HaTestOutcome.RequestFailed, Detail: 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);
|
return ParseState(await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false), attribute);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static HaState? ParseState(JsonDocument document, string? attribute)
|
internal static HaState? ParseState(JsonDocument document, string? attribute) =>
|
||||||
{
|
ParseStateElement(document.RootElement, attribute);
|
||||||
var root = document.RootElement;
|
|
||||||
|
|
||||||
|
/// <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;
|
double value;
|
||||||
if (string.IsNullOrWhiteSpace(attribute))
|
if (string.IsNullOrWhiteSpace(attribute))
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user