Compare commits
15
Commits
v0.1.0
...
813d96709e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813d96709e | ||
|
|
a3af4838e8 | ||
|
|
400d349d6a | ||
|
|
8e2e632c74 | ||
|
|
18dd4f720d | ||
|
|
8550ed8d9e | ||
|
|
14930bc3d8 | ||
|
|
b3b8b92520 | ||
|
|
fe3d81b192 | ||
|
|
92438348e7 | ||
|
|
ecadbe1477 | ||
|
|
85d650a8f5 | ||
|
|
7e34aeccc8 | ||
|
|
09cd435c2b | ||
|
|
1282acf82c |
@@ -5,7 +5,6 @@
|
||||
.git/
|
||||
.github/
|
||||
docs/
|
||||
sampledata/
|
||||
tests/
|
||||
**/*.user
|
||||
**/appsettings.*.Local.json
|
||||
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
|
||||
|
||||
**Status: implemented (M0–M7).** The full solution is built and green — five projects, ~95 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). Remaining refinements (HA WebSocket push, dedicated PV/oil dashboard panels, full admin CRUD, full de-DE UI localization) are noted at the end of their milestone commits.
|
||||
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||
|
||||
## Source of truth
|
||||
|
||||
|
||||
@@ -22,7 +22,18 @@ full design.
|
||||
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
||||
- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw.
|
||||
- **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/
|
||||
less" difference view, trends, meter list, one-click reference-data load, CSV dry-run.
|
||||
less" difference view, trends, a **PV/Solar panel** (generation, self-consumption, autarky %,
|
||||
savings), an **oil/consumable panel** (tank gauge, deliveries, burner runtime, effective L/h,
|
||||
forecast-to-empty) and a **per-meter detail view** (raw readings, consumption, sources, tariff
|
||||
timeline, events), one-click reference-data load, CSV dry-run.
|
||||
- **Per-energy-type flow pages** (Electricity, Water, …): a **Sankey diagram** of the meter chain —
|
||||
a downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), arrow
|
||||
thickness ∝ amount, with an auto-computed "Other/unmetered" remainder. Meters can have several
|
||||
upstreams (a merge, e.g. grid + solar → house).
|
||||
- **Admin UI**: full create/edit/delete for energy types, meters (with consumption recompute on
|
||||
mode/baseline change, and cycle-safe upstream-meter wiring), ingest sources, tariffs, cost
|
||||
categories, and MQTT/Home-Assistant connectors; a "Test connection" for Home Assistant;
|
||||
effective-settings view.
|
||||
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
|
||||
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
||||
|
||||
@@ -30,10 +41,26 @@ full design.
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/docker-compose.yml up -d
|
||||
# open http://localhost:8080 → Import → "Load reference data" for a populated demo
|
||||
# API docs at http://localhost:8080/swagger
|
||||
# open http://localhost:8760 → Import → "Load reference data" for a populated demo
|
||||
# ...or start pre-populated: METERVAULT_SEED=true docker compose -f deploy/docker-compose.yml up -d
|
||||
# API docs at http://localhost:8760/swagger
|
||||
```
|
||||
|
||||
## Quick start (Proxmox VE LXC)
|
||||
|
||||
A community-scripts–style installer builds a self-contained LXC (Debian + PostgreSQL/TimescaleDB +
|
||||
the app as a systemd service). Run **on the Proxmox host**:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://git.finalfactory.de/FinalFactory/MeterVault/raw/branch/master/deploy/ct/metervault.sh)"
|
||||
```
|
||||
|
||||
It asks the standard container questions, optionally loads the demo dataset, and prints the URL
|
||||
(`http://<ct-ip>:8760`) plus the generated DB password. Re-run `update` inside the container to pull
|
||||
the latest source and rebuild. It builds from this public Gitea repo (there is no prebuilt tarball —
|
||||
releases ship as a container image). See [`deploy/ct/metervault.sh`](deploy/ct/metervault.sh) and
|
||||
[`deploy/install/metervault-install.sh`](deploy/install/metervault-install.sh).
|
||||
|
||||
Configuration is via environment variables (`Section__Key` double-underscore mapping), e.g.:
|
||||
|
||||
| Variable | Purpose |
|
||||
@@ -44,6 +71,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map
|
||||
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
|
||||
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
||||
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
||||
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
|
||||
|
||||
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).
|
||||
@@ -54,7 +82,7 @@ of an environment variable, resolved at runtime.
|
||||
## Pushing readings (Home Assistant)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/readings \
|
||||
curl -X POST http://localhost:8760/api/v1/readings \
|
||||
-H "X-Api-Key: $METERVAULT_API_KEY" -H "Content-Type: application/json" \
|
||||
-d '[{"meterId": 1, "time": "2026-01-01T12:00:00Z", "value": 47200}]'
|
||||
```
|
||||
|
||||
+5
-3
@@ -11,8 +11,10 @@ COPY src/Infrastructure/MeterVault.Infrastructure.csproj src/Infrastructure/
|
||||
COPY src/App/MeterVault.App.csproj src/App/
|
||||
RUN dotnet restore src/App/MeterVault.App.csproj
|
||||
|
||||
# Build & publish.
|
||||
# Build & publish. sampledata/ must be present so the App csproj's linked Content glob
|
||||
# (..\..\sampledata\*.csv) resolves at publish time — otherwise "Load reference data" ships empty.
|
||||
COPY src/ ./src/
|
||||
COPY sampledata/ ./sampledata/
|
||||
RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
# Debian-based runtime (keeps full ICU — required by the de-DE CSV importer; do not use
|
||||
@@ -23,8 +25,8 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app/publish ./
|
||||
ENV ASPNETCORE_URLS=http://+:8080 \
|
||||
ENV ASPNETCORE_URLS=http://+:8760 \
|
||||
ASPNETCORE_ENVIRONMENT=Production \
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
EXPOSE 8080
|
||||
EXPOSE 8760
|
||||
ENTRYPOINT ["dotnet", "MeterVault.App.dll"]
|
||||
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC1090 # community-scripts build.func is fetched at runtime
|
||||
source <(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/build.func)
|
||||
# Copyright (c) 2021-2026 community-scripts ORG
|
||||
# Author: FinalFactory
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://git.finalfactory.de/FinalFactory/MeterVault
|
||||
#
|
||||
# Creates a Debian LXC and installs MeterVault (self-hosted energy & utility metering) natively:
|
||||
# PostgreSQL + TimescaleDB, the .NET runtime, and the Blazor app built from the public Gitea repo,
|
||||
# fronted by a systemd service. Unlike MQTTower (GitHub + prebuilt release tarballs), MeterVault
|
||||
# lives on a public Gitea and ships only a container image, so this builds the app from source.
|
||||
|
||||
APP="MeterVault"
|
||||
var_tags="${var_tags:-energy;metering;iot}"
|
||||
var_cpu="${var_cpu:-2}"
|
||||
var_ram="${var_ram:-3072}"
|
||||
var_disk="${var_disk:-15}"
|
||||
var_os="${var_os:-debian}"
|
||||
# Debian 12 (bookworm): TimescaleDB's apt packages + PGDG PostgreSQL 16 are reliably built for it.
|
||||
var_version="${var_version:-12}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
color
|
||||
catch_errors
|
||||
|
||||
# Gitea (public) instead of GitHub: raw file URLs use /raw/branch/<branch>/<path>.
|
||||
METERVAULT_DEPLOY_BASE="${METERVAULT_DEPLOY_BASE:-https://git.finalfactory.de/FinalFactory/MeterVault/raw/branch/master/deploy}"
|
||||
METERVAULT_INSTALL_URL="${METERVAULT_INSTALL_URL:-${METERVAULT_DEPLOY_BASE}/install/metervault-install.sh}"
|
||||
|
||||
function update_script() {
|
||||
header_info
|
||||
check_container_storage
|
||||
check_container_resources
|
||||
|
||||
if [[ ! -d /opt/metervault ]]; then
|
||||
msg_error "No ${APP} Installation Found!"
|
||||
exit
|
||||
fi
|
||||
if [[ ! -d /opt/metervault-src ]]; then
|
||||
msg_error "No source checkout at /opt/metervault-src — cannot rebuild. Reinstall to restore it."
|
||||
exit
|
||||
fi
|
||||
|
||||
msg_info "Stopping ${APP}"
|
||||
systemctl stop metervault
|
||||
msg_ok "Stopped ${APP}"
|
||||
|
||||
msg_info "Pulling latest source and rebuilding (this can take a few minutes)"
|
||||
cd /opt/metervault-src || exit 1
|
||||
git fetch --depth 1 origin master
|
||||
git reset --hard origin/master
|
||||
dotnet publish src/App/MeterVault.App.csproj -c Release -o /opt/metervault /p:UseAppHost=false
|
||||
msg_ok "Rebuilt ${APP}"
|
||||
|
||||
msg_info "Starting ${APP}"
|
||||
systemctl start metervault
|
||||
msg_ok "Started ${APP}"
|
||||
|
||||
msg_ok "Updated successfully!"
|
||||
exit
|
||||
}
|
||||
|
||||
start
|
||||
|
||||
# =============================================================================
|
||||
# Custom questions (after the standard wizard, before build)
|
||||
# =============================================================================
|
||||
|
||||
rand_hex() {
|
||||
openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | xxd -p
|
||||
}
|
||||
|
||||
# msg_info() runs a background spinner on stderr; whiptail also draws on stderr. Stop the spinner
|
||||
# and clear the line first so dialogs don't render corrupted.
|
||||
metervault_whiptail() {
|
||||
stop_spinner
|
||||
clear_line
|
||||
whiptail "$@" 3>&1 1>&2 2>&3
|
||||
}
|
||||
|
||||
collect_metervault_env() {
|
||||
export METERVAULT_PORT="${METERVAULT_PORT:-8760}"
|
||||
export METERVAULT_TIMEZONE="${METERVAULT_TIMEZONE:-Europe/Berlin}"
|
||||
|
||||
if [[ -z "${METERVAULT_DB_PASSWORD:-}" ]]; then
|
||||
METERVAULT_DB_PASSWORD="$(rand_hex)"
|
||||
msg_info "Generated PostgreSQL password for the metervault user: ${METERVAULT_DB_PASSWORD}"
|
||||
fi
|
||||
export METERVAULT_DB_PASSWORD
|
||||
|
||||
# Demo dataset: honour an explicit env value, otherwise ask.
|
||||
local seed="${METERVAULT_SEED:-}"
|
||||
case "${seed,,}" in
|
||||
true | 1 | yes) export METERVAULT_SEED="true" ;;
|
||||
false | 0 | no) export METERVAULT_SEED="false" ;;
|
||||
*)
|
||||
if metervault_whiptail --title "${APP}" --yesno \
|
||||
"Load the bundled Energiebilanz demo dataset (meters, tariffs, ~2000 readings) on first start?" 10 70; then
|
||||
export METERVAULT_SEED="true"
|
||||
else
|
||||
export METERVAULT_SEED="false"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Custom build_container — identical to the framework except the install script URL.
|
||||
# The framework build_container() hardcodes the install URL to community-scripts/ProxmoxVE;
|
||||
# once MeterVault is accepted upstream this whole function can be replaced by build_container.
|
||||
# =============================================================================
|
||||
metervault_build_container() {
|
||||
# --- Network string (same logic as build.func) ---
|
||||
NET_STRING="-net0 name=eth0,bridge=${BRG:-vmbr0}"
|
||||
[[ -n "${MAC:-}" ]] && case "$MAC" in ,hwaddr=*) NET_STRING+="$MAC" ;; *) NET_STRING+=",hwaddr=$MAC" ;; esac
|
||||
NET_STRING+=",ip=${NET:-dhcp}"
|
||||
[[ -n "${GATE:-}" ]] && case "$GATE" in ,gw=) ;; ,gw=*) NET_STRING+="$GATE" ;; *) NET_STRING+=",gw=$GATE" ;; esac
|
||||
[[ -n "${VLAN:-}" ]] && case "$VLAN" in ,tag=*) NET_STRING+="$VLAN" ;; *) NET_STRING+=",tag=$VLAN" ;; esac
|
||||
[[ -n "${MTU:-}" ]] && case "$MTU" in ,mtu=*) NET_STRING+="$MTU" ;; *) NET_STRING+=",mtu=$MTU" ;; esac
|
||||
case "${IPV6_METHOD:-none}" in
|
||||
auto) NET_STRING+=",ip6=auto" ;;
|
||||
dhcp) NET_STRING+=",ip6=dhcp" ;;
|
||||
static) [[ -n "${IPV6_ADDR:-}" ]] && { NET_STRING+=",ip6=$IPV6_ADDR"; [[ -n "${IPV6_GATE:-}" ]] && NET_STRING+=",gw6=$IPV6_GATE"; } ;;
|
||||
esac
|
||||
|
||||
# --- Features ---
|
||||
FEATURES="nesting=1"
|
||||
[[ "$CT_TYPE" == "1" ]] && FEATURES="${FEATURES},keyctl=1"
|
||||
|
||||
# --- Download install.func for the container ---
|
||||
export FUNCTIONS_FILE_PATH
|
||||
FUNCTIONS_FILE_PATH="$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/misc/install.func)"
|
||||
if [[ -z "$FUNCTIONS_FILE_PATH" || ${#FUNCTIONS_FILE_PATH} -lt 100 ]]; then
|
||||
msg_error "Failed to download install.func"
|
||||
exit 115
|
||||
fi
|
||||
|
||||
# --- Standard exports (same as build.func build_container) ---
|
||||
export DIAGNOSTICS="${DIAGNOSTICS:-no}"
|
||||
export RANDOM_UUID="${RANDOM_UUID:-$(cat /proc/sys/kernel/random/uuid)}"
|
||||
export EXECUTION_ID="${EXECUTION_ID:-$RANDOM_UUID}"
|
||||
export SESSION_ID="${SESSION_ID:-${RANDOM_UUID:0:8}}"
|
||||
export CACHER="${APT_CACHER:-}"
|
||||
export CACHER_IP="${APT_CACHER_IP:-}"
|
||||
export tz="${timezone:-$(timedatectl show --value --property=Timezone 2>/dev/null || echo UTC)}"
|
||||
export APPLICATION="$APP"
|
||||
export app="$NSAPP"
|
||||
export PASSWORD="${PW:-}"
|
||||
export VERBOSE="${VERBOSE:-no}"
|
||||
export SSH_ROOT="${SSH:-no}"
|
||||
export SSH_AUTHORIZED_KEY="${SSH_AUTHORIZED_KEY:-}"
|
||||
export CTID="${CT_ID:-}"
|
||||
export CTTYPE="${CT_TYPE:-1}"
|
||||
export PCT_OSTYPE="$var_os"
|
||||
export PCT_OSVERSION="$var_version"
|
||||
export PCT_DISK_SIZE="${DISK_SIZE:-$var_disk}"
|
||||
export IPV6_METHOD="${IPV6_METHOD:-none}"
|
||||
|
||||
BUILD_LOG="${BUILD_LOG:-/tmp/create-lxc-${SESSION_ID}.log}"
|
||||
export BUILD_LOG
|
||||
export INSTALL_LOG="/root/.install-${SESSION_ID}.log"
|
||||
|
||||
# --- MeterVault-specific exports ---
|
||||
export METERVAULT_CT_URL="${METERVAULT_CT_URL:-${METERVAULT_DEPLOY_BASE}/ct/metervault.sh}"
|
||||
|
||||
# --- Build PCT_OPTIONS string ---
|
||||
PCT_OPTIONS_STRING=" -hostname ${HN:-metervault}"
|
||||
[[ -n "${TAGS:-}" ]] && PCT_OPTIONS_STRING+=$'\n'" -tags $TAGS"
|
||||
[[ -n "$FEATURES" ]] && PCT_OPTIONS_STRING=" -features $FEATURES"$'\n'"$PCT_OPTIONS_STRING"
|
||||
[[ -n "${SD:-}" ]] && PCT_OPTIONS_STRING+=$'\n'" $SD"
|
||||
[[ -n "${NS:-}" ]] && PCT_OPTIONS_STRING+=$'\n'" $NS"
|
||||
PCT_OPTIONS_STRING+=$'\n'" $NET_STRING"
|
||||
PCT_OPTIONS_STRING+=$'\n'" -onboot 1"
|
||||
PCT_OPTIONS_STRING+=$'\n'" -cores ${CORE_COUNT:-$var_cpu}"
|
||||
PCT_OPTIONS_STRING+=$'\n'" -memory ${RAM_SIZE:-$var_ram}"
|
||||
PCT_OPTIONS_STRING+=$'\n'" -unprivileged ${CT_TYPE:-1}"
|
||||
[[ -n "${PW:-}" ]] && PCT_OPTIONS_STRING+=$'\n'" $PW"
|
||||
|
||||
export PCT_OPTIONS="$PCT_OPTIONS_STRING"
|
||||
export TEMPLATE_STORAGE="${var_template_storage:-}"
|
||||
export CONTAINER_STORAGE="${var_container_storage:-}"
|
||||
|
||||
# --- Create LXC (framework function) ---
|
||||
create_lxc_container || exit $?
|
||||
|
||||
# --- Start container and wait for network ---
|
||||
msg_info "Starting LXC Container"
|
||||
pct start "$CTID"
|
||||
for i in {1..10}; do
|
||||
if pct status "$CTID" | grep -q "status: running"; then
|
||||
msg_ok "Started LXC Container"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
[[ "$i" -eq 10 ]] && { msg_error "LXC Container did not start"; exit 117; }
|
||||
done
|
||||
|
||||
msg_info "Waiting for network in LXC container"
|
||||
local ip_in_lxc="" wait_secs=0
|
||||
while true; do
|
||||
ip_in_lxc=$(pct exec "$CTID" -- ip -4 addr show dev eth0 2>/dev/null | awk '/inet / {print $2}' | cut -d/ -f1)
|
||||
[[ -z "$ip_in_lxc" ]] && ip_in_lxc=$(pct exec "$CTID" -- ip -6 addr show dev eth0 scope global 2>/dev/null | awk '/inet6 / {print $2}' | cut -d/ -f1 | head -n1)
|
||||
[[ -n "$ip_in_lxc" ]] && break
|
||||
sleep 1
|
||||
wait_secs=$((wait_secs + 1))
|
||||
if ((wait_secs % 20 == 0)); then
|
||||
msg_warn "No IP on eth0 after ${wait_secs}s — still waiting (Ctrl+C to abort)"
|
||||
fi
|
||||
done
|
||||
msg_ok "Network in LXC is reachable (${ip_in_lxc})"
|
||||
|
||||
# --- Base packages ---
|
||||
msg_info "Customizing LXC Container"
|
||||
sleep 2
|
||||
pct exec "$CTID" -- bash -c "apt-get update 2>&1 && apt-get install -y sudo curl mc gnupg2 jq 2>&1" >>"$BUILD_LOG" 2>&1 || {
|
||||
msg_error "Failed to install base packages"
|
||||
exit 116
|
||||
}
|
||||
msg_ok "Customized LXC Container"
|
||||
|
||||
# --- Run MeterVault install script (the ONLY difference vs the framework build_container) ---
|
||||
# pct exec (not lxc-attach): unprivileged LXC often returns EPERM from lxc-attach, and pct exec
|
||||
# does not inherit the host env — pass METERVAULT_* explicitly (collect_metervault_env). Append to
|
||||
# BUILD_LOG so failures show the real error.
|
||||
msg_info "Running MeterVault install script (full output: ${BUILD_LOG})"
|
||||
pct exec "$CTID" -- env \
|
||||
CONTAINER_INSTALLING=true \
|
||||
"METERVAULT_CT_URL=${METERVAULT_CT_URL:-${METERVAULT_DEPLOY_BASE}/ct/metervault.sh}" \
|
||||
"METERVAULT_PORT=${METERVAULT_PORT:-8760}" \
|
||||
"METERVAULT_TIMEZONE=${METERVAULT_TIMEZONE:-Europe/Berlin}" \
|
||||
"METERVAULT_DB_PASSWORD=${METERVAULT_DB_PASSWORD:-}" \
|
||||
"METERVAULT_SEED=${METERVAULT_SEED:-false}" \
|
||||
bash -c "$(curl -fsSL "$METERVAULT_INSTALL_URL")" >>"$BUILD_LOG" 2>&1 || {
|
||||
msg_error "MeterVault install script failed — see tail of ${BUILD_LOG}"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
collect_metervault_env
|
||||
metervault_build_container
|
||||
description
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
|
||||
echo -e "${TAB}${GATEWAY}${BGN}http://${IP}:${METERVAULT_PORT:-8760}${CL}"
|
||||
echo -e "${TAB}${INFO}First run: open Import → \"Load reference data\" for a demo (unless you enabled seeding).${CL}"
|
||||
echo -e "${TAB}${INFO}PostgreSQL user 'metervault' password: ${METERVAULT_DB_PASSWORD:-<generated>} (also in /etc/metervault/environment)${CL}"
|
||||
@@ -33,13 +33,16 @@ services:
|
||||
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
||||
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
||||
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
|
||||
# Set true for a populated demo: loads the bundled Energiebilanz dataset on first start
|
||||
# (idempotent). Leave false for a clean instance.
|
||||
MeterVault__SeedReferenceData: ${METERVAULT_SEED:-false}
|
||||
# REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN):
|
||||
# MeterVault__ApiKeys__0: your-secret-key
|
||||
# MeterVault__AllowAnonymousApi: "true"
|
||||
ports:
|
||||
- "${METERVAULT_PORT:-8080}:8080"
|
||||
- "${METERVAULT_PORT:-8760}:8760"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/healthz || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8760/healthz || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
Executable
+267
@@ -0,0 +1,267 @@
|
||||
#!/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}"
|
||||
: "${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}"
|
||||
}
|
||||
|
||||
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_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,52 @@
|
||||
#!/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
|
||||
|
||||
echo "Stopping metervault…"
|
||||
systemctl stop metervault || true
|
||||
|
||||
echo "Pulling latest source…"
|
||||
git -C "${SOURCE_DIR}" fetch --depth 1 origin "${METERVAULT_BRANCH}"
|
||||
git -C "${SOURCE_DIR}" reset --hard "origin/${METERVAULT_BRANCH}"
|
||||
|
||||
echo "Rebuilding (dotnet publish — this can take a few minutes)…"
|
||||
dotnet publish "${SOURCE_DIR}/src/App/MeterVault.App.csproj" -c Release -o "${INSTALL_DIR}" /p:UseAppHost=false
|
||||
|
||||
echo "Starting metervault…"
|
||||
systemctl start metervault
|
||||
|
||||
# Refresh this script from the checkout we just pulled, so a change to the updater itself lands
|
||||
# without stranding the container again. Atomic rename, never an in-place write: bash reads the
|
||||
# running script lazily, so truncating it mid-run would corrupt the remainder of this execution.
|
||||
self="${SOURCE_DIR}/deploy/install/metervault-update.sh"
|
||||
if [[ -f "${self}" ]] && ! cmp -s "${self}" /usr/bin/update; then
|
||||
install -m 0755 "${self}" /usr/bin/.update.new && mv /usr/bin/.update.new /usr/bin/update
|
||||
echo "Updater itself refreshed — the new version applies from the next run."
|
||||
fi
|
||||
|
||||
echo "MeterVault updated."
|
||||
@@ -11,10 +11,10 @@
|
||||
<Privileged>false</Privileged>
|
||||
<Overview>Self-hosted energy & utility metering: ingest from Home Assistant/Tasmota/MQTT, normalize to consumption, and produce cost dashboards. Needs a TimescaleDB instance.</Overview>
|
||||
<Category>HomeAutomation: Tools: Productivity:</Category>
|
||||
<WebUI>http://[IP]:[PORT:8080]/</WebUI>
|
||||
<WebUI>http://[IP]:[PORT:8760]/</WebUI>
|
||||
<Icon>https://git.finalfactory.de/FinalFactory/MeterVault/raw/branch/master/docs/icon.png</Icon>
|
||||
|
||||
<Config Name="WebUI Port" Target="8080" Default="8080" Mode="tcp" Description="HTTP port" Type="Port" Display="always" Required="true">8080</Config>
|
||||
<Config Name="WebUI Port" Target="8760" Default="8760" Mode="tcp" Description="HTTP port" Type="Port" Display="always" Required="true">8760</Config>
|
||||
|
||||
<Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config>
|
||||
|
||||
|
||||
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")"
|
||||
@@ -10,11 +10,11 @@
|
||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
|
||||
<ImportMap />
|
||||
<HeadOutlet />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="1" Dense="true">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="Toggle navigation" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
||||
<MudText Typo="Typo.h6">MeterVault</MudText>
|
||||
<MudSpacer />
|
||||
|
||||
@@ -1,11 +1,55 @@
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Core.Domain
|
||||
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
||||
|
||||
@foreach (var type in _energyTypes)
|
||||
{
|
||||
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
|
||||
}
|
||||
|
||||
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
|
||||
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
|
||||
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
||||
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
||||
<MudDivider Class="my-2" />
|
||||
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
||||
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
|
||||
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
|
||||
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink>
|
||||
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink>
|
||||
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
|
||||
</MudNavGroup>
|
||||
</MudNavMenu>
|
||||
|
||||
@code {
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Nav must never break the layout — a DB hiccup just hides the per-type links.
|
||||
_energyTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
|
||||
private static string TypeIcon(string? icon) => icon switch
|
||||
{
|
||||
"bolt" => Icons.Material.Filled.Bolt,
|
||||
"water_drop" => Icons.Material.Filled.WaterDrop,
|
||||
"local_gas_station" => Icons.Material.Filled.LocalGasStation,
|
||||
"gas_meter" => Icons.Material.Filled.GasMeter,
|
||||
"thermostat" => Icons.Material.Filled.Thermostat,
|
||||
_ => Icons.Material.Filled.Bolt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
@page "/admin/categories"
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Cost categories</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Cost categories</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add category
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_categories is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Sort</MudTh>
|
||||
<MudTh>Members</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||
{
|
||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||
}
|
||||
@context.Name
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Sort">@context.Sort</MudTd>
|
||||
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" />
|
||||
|
||||
@if (_working.Id != 0)
|
||||
{
|
||||
<MudDivider Class="my-3" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText>
|
||||
@if (_members.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="string" Dense="true">
|
||||
@foreach (var m in _members)
|
||||
{
|
||||
<MudListItem T="string">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span>@MemberLabel(m)</span>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small" OnClick="@(() => RemoveMemberAsync(m.Id))" />
|
||||
</div>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
|
||||
<MudSelect T="int?" @bind-Value="_addMeterId" Label="Add meter" Dense="true" Style="min-width:180px">
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton>
|
||||
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<CostCategory>? _categories;
|
||||
private List<Meter> _meters = [];
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<CostCategoryMember> _members = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private int? _addMeterId;
|
||||
private int? _addTypeId;
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
|
||||
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
}
|
||||
|
||||
private string MemberSummary(CostCategory c)
|
||||
{
|
||||
var meters = c.Members.Count(m => m.MeterId is not null);
|
||||
var types = c.Members.Count(m => m.EnergyTypeId is not null);
|
||||
return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)";
|
||||
}
|
||||
|
||||
private string MemberLabel(CostCategoryMember m) =>
|
||||
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}"
|
||||
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}"
|
||||
: "—";
|
||||
|
||||
private void OpenEdit(CostCategory? category)
|
||||
{
|
||||
if (category is null)
|
||||
{
|
||||
_working = new EditModel();
|
||||
_members = [];
|
||||
}
|
||||
else
|
||||
{
|
||||
_working = new EditModel { Id = category.Id, Name = category.Name, ColorHex = category.ColorHex, Sort = category.Sort };
|
||||
_members = [.. category.Members];
|
||||
}
|
||||
_addMeterId = null;
|
||||
_addTypeId = null;
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
var category = new CostCategory { Name = _working.Name.Trim(), ColorHex = Trim(_working.ColorHex), Sort = _working.Sort };
|
||||
db.CostCategories.Add(category);
|
||||
await db.SaveChangesAsync();
|
||||
// Re-open on the new category so members can be added.
|
||||
Snackbar.Add("Saved. Add members below.", Severity.Success);
|
||||
await LoadAsync();
|
||||
OpenEdit(_categories!.First(c => c.Id == category.Id));
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await db.CostCategories.FirstAsync(c => c.Id == _working.Id);
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.ColorHex = Trim(_working.ColorHex);
|
||||
existing.Sort = _working.Sort;
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task AddMeterMemberAsync()
|
||||
{
|
||||
if (_addMeterId is not { } meterId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (!await db.CostCategoryMembers.AnyAsync(m => m.CategoryId == _working.Id && m.MeterId == meterId))
|
||||
{
|
||||
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = _working.Id, MeterId = meterId });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
_addMeterId = null;
|
||||
await ReloadMembersAsync();
|
||||
}
|
||||
|
||||
private async Task AddTypeMemberAsync()
|
||||
{
|
||||
if (_addTypeId is not { } typeId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (!await db.CostCategoryMembers.AnyAsync(m => m.CategoryId == _working.Id && m.EnergyTypeId == (short)typeId))
|
||||
{
|
||||
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = _working.Id, EnergyTypeId = (short)typeId });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
_addTypeId = null;
|
||||
await ReloadMembersAsync();
|
||||
}
|
||||
|
||||
private async Task RemoveMemberAsync(int memberId)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.CostCategoryMembers.Where(m => m.Id == memberId).ExecuteDeleteAsync();
|
||||
await ReloadMembersAsync();
|
||||
}
|
||||
|
||||
private async Task ReloadMembersAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_members = await db.CostCategoryMembers.AsNoTracking().Where(m => m.CategoryId == _working.Id).ToListAsync();
|
||||
_categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(CostCategory category)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete category",
|
||||
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked."))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
// Members cascade with the category; manual_cost.category_id is SetNull.
|
||||
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? ColorHex { get; set; }
|
||||
public int Sort { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
@page "/admin/connectors"
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Connectors</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Connectors</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add connector
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
Secrets are never stored here. Credentials/tokens are referenced by the <b>name of an environment variable</b>
|
||||
(or Docker secret) resolved at runtime.
|
||||
</MudAlert>
|
||||
|
||||
@if (_endpoints is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Type</MudTh>
|
||||
<MudTh>Enabled</MudTh>
|
||||
<MudTh>Last status</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Type">@context.Type</MudTd>
|
||||
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd>
|
||||
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_endpoints.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<EndpointType>())
|
||||
{
|
||||
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
|
||||
@if (_working.Type == EndpointType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||||
On: subscribe to state changes and ingest in real time. Off: REST poll on each source's interval.
|
||||
</MudText>
|
||||
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
||||
Test connection
|
||||
</MudButton>
|
||||
@if (_testing)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-2" />
|
||||
}
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
|
||||
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
|
||||
}
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<IngestionEndpoint>? _endpoints;
|
||||
private bool _editOpen;
|
||||
private bool _testing;
|
||||
private HaTestResult? _testResult;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private void OpenEdit(IngestionEndpoint? endpoint)
|
||||
{
|
||||
_testResult = null;
|
||||
if (endpoint is null)
|
||||
{
|
||||
_working = new EditModel();
|
||||
}
|
||||
else if (endpoint.Type == EndpointType.HomeAssistant)
|
||||
{
|
||||
var ha = HaEndpointConfig.Parse(endpoint.Config);
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||
BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv, UseWebSocket = ha.UseWebSocket,
|
||||
};
|
||||
}
|
||||
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,
|
||||
ExtraTopics = string.Join(", ", mqtt.ExtraTopics),
|
||||
};
|
||||
}
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task TestHaAsync()
|
||||
{
|
||||
_testing = true;
|
||||
_testResult = null;
|
||||
try
|
||||
{
|
||||
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.TokenEnv, _working.TestEntityId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||
{
|
||||
Snackbar.Add("Name is required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var config = _working.Type == EndpointType.HomeAssistant
|
||||
? new HaEndpointConfig { BaseUrl = Trim(_working.BaseUrl), TokenEnv = Trim(_working.TokenEnv), UseWebSocket = _working.UseWebSocket }.ToJson()
|
||||
: new EndpointConfig
|
||||
{
|
||||
Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(),
|
||||
Port = _working.Port,
|
||||
Tls = _working.Tls,
|
||||
UsernameEnv = Trim(_working.UsernameEnv),
|
||||
PasswordEnv = Trim(_working.PasswordEnv),
|
||||
ExtraTopics = SplitTopics(_working.ExtraTopics),
|
||||
}.ToJson();
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.IngestionEndpoints.Add(new IngestionEndpoint
|
||||
{
|
||||
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
||||
existing.Type = _working.Type;
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.Config = config;
|
||||
existing.IsEnabled = _working.IsEnabled;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(IngestionEndpoint endpoint)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id);
|
||||
var note = sourceCount > 0 ? $" {sourceCount} source(s) reference it and will be unlinked." : "";
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static IReadOnlyList<string> SplitTopics(string? csv) =>
|
||||
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public EndpointType Type { get; set; } = EndpointType.HomeAssistant;
|
||||
public string Name { get; set; } = "";
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
// Home Assistant
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? TokenEnv { get; set; }
|
||||
public bool UseWebSocket { get; set; }
|
||||
public string? TestEntityId { get; set; }
|
||||
|
||||
// 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; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
@page "/admin/energy-types"
|
||||
@rendermode InteractiveServer
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Energy types</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Energy types</MudText>
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Energy types</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add energy type
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_types is null)
|
||||
{
|
||||
@@ -19,22 +26,159 @@ else
|
||||
<MudTh>Display name</MudTh>
|
||||
<MudTh>Base unit</MudTh>
|
||||
<MudTh>Default mode</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
||||
<MudTd DataLabel="Display name">@context.DisplayName</MudTd>
|
||||
<MudTd DataLabel="Display name">
|
||||
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||
{
|
||||
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||
}
|
||||
@context.DisplayName
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
||||
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" />
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<EnergyType>? _types;
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_types = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||
}
|
||||
|
||||
private void OpenEdit(EnergyType? type)
|
||||
{
|
||||
_working = type is null
|
||||
? new EditModel()
|
||||
: new EditModel
|
||||
{
|
||||
Id = type.Id,
|
||||
Key = type.Key,
|
||||
DisplayName = type.DisplayName,
|
||||
BaseUnit = type.BaseUnit,
|
||||
Mode = type.DefaultMode,
|
||||
Icon = type.Icon,
|
||||
ColorHex = type.ColorHex,
|
||||
};
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit))
|
||||
{
|
||||
Snackbar.Add("Key, display name and base unit are required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id))
|
||||
{
|
||||
Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.EnergyTypes.Add(new EnergyType
|
||||
{
|
||||
Key = _working.Key.Trim(),
|
||||
DisplayName = _working.DisplayName.Trim(),
|
||||
BaseUnit = _working.BaseUnit.Trim(),
|
||||
DefaultMode = _working.Mode,
|
||||
Icon = string.IsNullOrWhiteSpace(_working.Icon) ? null : _working.Icon,
|
||||
ColorHex = string.IsNullOrWhiteSpace(_working.ColorHex) ? null : _working.ColorHex,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.EnergyTypes.FirstAsync(t => t.Id == _working.Id);
|
||||
existing.Key = _working.Key.Trim();
|
||||
existing.DisplayName = _working.DisplayName.Trim();
|
||||
existing.BaseUnit = _working.BaseUnit.Trim();
|
||||
existing.DefaultMode = _working.Mode;
|
||||
existing.Icon = string.IsNullOrWhiteSpace(_working.Icon) ? null : _working.Icon;
|
||||
existing.ColorHex = string.IsNullOrWhiteSpace(_working.ColorHex) ? null : _working.ColorHex;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(EnergyType type)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
|
||||
if (meterCount > 0)
|
||||
{
|
||||
Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone."))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = await db.EnergyTypes.FirstOrDefaultAsync(t => t.Id == type.Id);
|
||||
if (target is not null)
|
||||
{
|
||||
db.EnergyTypes.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public short Id { get; set; }
|
||||
public string Key { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string BaseUnit { get; set; } = "";
|
||||
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public string? Icon { get; set; }
|
||||
public string? ColorHex { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
@page "/admin/settings"
|
||||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Settings</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText>
|
||||
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||
These are the <b>effective</b> settings the running instance is using. They are configured via environment
|
||||
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the
|
||||
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
|
||||
</MudAlert>
|
||||
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Locale & time</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
||||
<tr><td>Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||
<tr><td>Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
||||
<tr><td>Raw-reading retention</td><td style="text-align:right">@_o.RawRetentionDays days</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Access & ingestion</MudText>
|
||||
<MudSimpleTable Dense="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>REST API</td>
|
||||
<td style="text-align:right">
|
||||
@if (_o.ApiKeys.Count > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip>
|
||||
}
|
||||
else if (_o.AllowAnonymousApi)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr>
|
||||
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr>
|
||||
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||
Set API keys with <code>MeterVault__ApiKeys__0</code>. Keys themselves are never shown here.
|
||||
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new();
|
||||
|
||||
protected override void OnInitialized() => _o = Options.Value;
|
||||
}
|
||||
@@ -1,20 +1,23 @@
|
||||
@page "/admin/tariffs"
|
||||
@rendermode InteractiveServer
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Tariffs</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Tariffs</MudText>
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Tariffs</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add tariff
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_tariffs is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (_tariffs.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">No tariffs yet. Load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
||||
@@ -25,26 +28,203 @@ else
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Valid from</MudTh>
|
||||
<MudTh>Valid to</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Scope">@context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "")</MudTd>
|
||||
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd>
|
||||
<MudTd DataLabel="Component">@context.Component</MudTd>
|
||||
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@if (_tariffs.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">No tariffs yet. Add one, or load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
|
||||
@foreach (var scope in Enum.GetValues<TariffScope>())
|
||||
{
|
||||
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.ScopeType == TariffScope.EnergyType)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else if (_working.ScopeType == TariffScope.Meter)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2">
|
||||
@foreach (var m in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2">
|
||||
@foreach (var component in Enum.GetValues<TariffComponent>())
|
||||
{
|
||||
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit (e.g. EUR/kWh, EUR/m3, EUR/month)" Required="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" />
|
||||
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<Tariff>? _tariffs;
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<Meter> _meters = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_tariffs = await db.Tariffs.AsNoTracking()
|
||||
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
||||
.ToListAsync();
|
||||
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
||||
{
|
||||
TariffScope.Global => "Global",
|
||||
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}",
|
||||
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}",
|
||||
_ => t.ScopeType.ToString(),
|
||||
};
|
||||
|
||||
private void OpenEdit(Tariff? tariff)
|
||||
{
|
||||
_working = tariff is null
|
||||
? new EditModel { ValidFrom = DateTime.Today }
|
||||
: new EditModel
|
||||
{
|
||||
Id = tariff.Id,
|
||||
ScopeType = tariff.ScopeType,
|
||||
ScopeId = tariff.ScopeId,
|
||||
Component = tariff.Component,
|
||||
Value = tariff.Value,
|
||||
Unit = tariff.Unit,
|
||||
Currency = tariff.Currency,
|
||||
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
|
||||
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
|
||||
Notes = tariff.Notes,
|
||||
};
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
|
||||
{
|
||||
Snackbar.Add("Unit and valid-from are required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
|
||||
{
|
||||
Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId;
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.Tariffs.Add(new Tariff
|
||||
{
|
||||
ScopeType = _working.ScopeType,
|
||||
ScopeId = scopeId,
|
||||
Component = _working.Component,
|
||||
Value = _working.Value,
|
||||
Unit = _working.Unit.Trim(),
|
||||
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(),
|
||||
ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value),
|
||||
ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null,
|
||||
Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.Tariffs.FirstAsync(t => t.Id == _working.Id);
|
||||
existing.ScopeType = _working.ScopeType;
|
||||
existing.ScopeId = scopeId;
|
||||
existing.Component = _working.Component;
|
||||
existing.Value = _working.Value;
|
||||
existing.Unit = _working.Unit.Trim();
|
||||
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim();
|
||||
existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value);
|
||||
existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null;
|
||||
existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Tariff tariff)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var target = await db.Tariffs.FirstOrDefaultAsync(t => t.Id == tariff.Id);
|
||||
if (target is not null)
|
||||
{
|
||||
db.Tariffs.Remove(target);
|
||||
await db.SaveChangesAsync();
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public TariffScope ScopeType { get; set; } = TariffScope.EnergyType;
|
||||
public int? ScopeId { get; set; }
|
||||
public TariffComponent Component { get; set; } = TariffComponent.UnitPrice;
|
||||
public double Value { get; set; }
|
||||
public string Unit { get; set; } = "EUR/kWh";
|
||||
public string Currency { get; set; } = "EUR";
|
||||
public DateTime? ValidFrom { get; set; }
|
||||
public DateTime? ValidTo { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
@page "/consumables"
|
||||
@inject ConsumableService ConsumablesSvc
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Consumables</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Oil / consumables</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@if (_items is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (_items.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
|
||||
data from <MudLink Href="/import">Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var item in _items)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
|
||||
<MudText Typo="Typo.h5">
|
||||
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
|
||||
</MudText>
|
||||
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
|
||||
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
|
||||
{
|
||||
<text> · @Format.Number(cm, 0) cm</text>
|
||||
}
|
||||
@if (item.LevelAsOf is { } asOf)
|
||||
{
|
||||
<text> · as of @asOf.ToString("yyyy-MM-dd")</text>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@if (item.FixedRate is { } fr)
|
||||
{
|
||||
<text>@Format.Number(fr, 2) @item.Unit/h</text>
|
||||
}
|
||||
else if (item.EffectiveRate is { } er)
|
||||
{
|
||||
<text>@Format.Number(er, 2) @item.Unit/h</text>
|
||||
}
|
||||
else
|
||||
{
|
||||
<text>—</text>
|
||||
}
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText>
|
||||
<MudText Typo="Typo.subtitle1">
|
||||
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
|
||||
@if (item.AveragePerDay is { } apd)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
|
||||
(@Format.Number(apd, 1) @item.Unit/day)
|
||||
</MudText>
|
||||
}
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="7">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
|
||||
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="5">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText>
|
||||
@if (item.Deliveries.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height:260px; overflow-y:auto">
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr><th>Date</th><th style="text-align:right">Amount</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var delivery in item.Deliveries)
|
||||
{
|
||||
<tr>
|
||||
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
|
||||
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private IReadOnlyList<ConsumableSummary>? _items;
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_items = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = asOf.AddMonths(-_months);
|
||||
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
|
||||
{
|
||||
var points = item.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption))
|
||||
.ToList();
|
||||
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
|
||||
}
|
||||
|
||||
private static Color FillColor(double fraction) => fraction switch
|
||||
{
|
||||
< 0.15 => Color.Error,
|
||||
< 0.30 => Color.Warning,
|
||||
_ => Color.Success,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Overview</PageTitle>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
@page "/energy/{Id:int}"
|
||||
@inject FlowService Flow
|
||||
@inject MeterVault.Infrastructure.Costing.CostService Costs
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@if (_graph is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (!_graph.HasData)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
|
||||
reference data from <MudLink Href="/import">Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
|
||||
<MudText Typo="Typo.h5">@_meters.Count</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
|
||||
@if (_graph.HasChain)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
|
||||
</MudText>
|
||||
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
|
||||
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
|
||||
</MudAlert>
|
||||
@if (_graph.Nodes.Count > 0)
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
|
||||
{
|
||||
<tr>
|
||||
<td>@node.Label</td>
|
||||
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<tr>
|
||||
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
|
||||
<td>@meter.Mode</td>
|
||||
<td>@UpstreamLabel(meter.Id)</td>
|
||||
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private FlowGraph? _graph;
|
||||
private double _cost;
|
||||
private List<Meter> _meters = [];
|
||||
private Dictionary<int, List<string>> _downstream = [];
|
||||
|
||||
protected override Task OnParametersSetAsync() => LoadAsync();
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_graph = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
|
||||
var to = asOf.AddMonths(1);
|
||||
var typeId = (short)Id;
|
||||
|
||||
_graph = await Flow.GetFlowAsync(typeId, from, to);
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
|
||||
var links = await db.MeterLinks.AsNoTracking()
|
||||
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
|
||||
.ToListAsync();
|
||||
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
|
||||
_downstream = links
|
||||
.GroupBy(l => l.FromMeterId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
|
||||
|
||||
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
double cost = 0;
|
||||
foreach (var meter in _meters)
|
||||
{
|
||||
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
|
||||
}
|
||||
_cost = cost;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
|
||||
|
||||
private string UpstreamLabel(int meterId) =>
|
||||
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
@page "/import"
|
||||
@rendermode InteractiveServer
|
||||
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
|
||||
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
|
||||
@inject MeterVault.Infrastructure.Import.ImportService ImportService
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject IDialogService Dialogs
|
||||
@inject ISnackbar Snackbar
|
||||
@using MeterVault.Infrastructure.Import
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Import</PageTitle>
|
||||
|
||||
@@ -30,18 +34,23 @@
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6">Dry-run a CSV</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||
Upload a sheet and preview what would be staged (no changes are made).
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<MudText Typo="Typo.h6">Your own CSV</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
|
||||
StartIcon="@Icons.Material.Filled.AutoFixHigh">Mapping wizard</MudButton>
|
||||
</div>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1">
|
||||
Map an arbitrary sheet's columns to your meters/categories, preview and commit it as a
|
||||
revertible import. Or dry-run against one of the built-in reference profiles below.
|
||||
</MudText>
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="Sheet type" Dense="true" Class="mb-2">
|
||||
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
|
||||
Choose CSV
|
||||
Dry-run a reference sheet
|
||||
</MudButton>
|
||||
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
|
||||
</MudPaper>
|
||||
@@ -72,6 +81,49 @@
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Recent imports</MudText>
|
||||
@if (_batches.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No imports yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>#</th><th>Source</th><th style="text-align:right">Rows</th><th>Imported</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var batch in _batches)
|
||||
{
|
||||
<tr>
|
||||
<td>@batch.Id</td>
|
||||
<td>@(batch.SourceName ?? "—")</td>
|
||||
<td style="text-align:right">@batch.RowCount</td>
|
||||
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td>
|
||||
@if (batch.RevertedAt is not null)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">reverted</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">active</MudChip>
|
||||
}
|
||||
</td>
|
||||
<td style="text-align:right">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
|
||||
StartIcon="@Icons.Material.Filled.Undo"
|
||||
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
|
||||
OnClick="@(() => RevertAsync(batch))">Revert</MudButton>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@code {
|
||||
@@ -79,9 +131,49 @@
|
||||
private bool _referenceLoaded;
|
||||
private string _profileName = "Strom";
|
||||
private StagedImport? _preview;
|
||||
private List<ImportBatch> _batches = [];
|
||||
private int? _reverting;
|
||||
|
||||
protected override async Task OnInitializedAsync() =>
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_referenceLoaded = await ReferenceImporter.IsLoadedAsync();
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
|
||||
private async Task LoadBatchesAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_batches = await db.ImportBatches.AsNoTracking()
|
||||
.OrderByDescending(b => b.Id)
|
||||
.Take(25)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task RevertAsync(ImportBatch batch)
|
||||
{
|
||||
if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?",
|
||||
$"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?",
|
||||
"Revert"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_reverting = batch.Id;
|
||||
try
|
||||
{
|
||||
await ImportService.RevertAsync(batch.Id);
|
||||
Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success);
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reverting = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadReferenceAsync()
|
||||
{
|
||||
@@ -92,6 +184,7 @@
|
||||
await ReferenceImporter.LoadAsync(dir);
|
||||
_referenceLoaded = true;
|
||||
Snackbar.Add("Reference data loaded.", Severity.Success);
|
||||
await LoadBatchesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
@page "/import/wizard"
|
||||
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
|
||||
@inject MeterVault.Infrastructure.Import.ImportService ImportService
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject NavigationManager Nav
|
||||
@using System.Text.Json
|
||||
@using MeterVault.Core.Domain
|
||||
@using MeterVault.Infrastructure.Import
|
||||
@using MeterVault.Infrastructure.Persistence
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
|
||||
<PageTitle>MeterVault — Import wizard</PageTitle>
|
||||
|
||||
<div class="d-flex align-center mb-4" style="gap:1rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="Back to Import" />
|
||||
<MudText Typo="Typo.h4">Import wizard</MudText>
|
||||
</div>
|
||||
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">
|
||||
Upload any CSV, map its columns to your meters and categories, preview what would be staged, then
|
||||
commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes,
|
||||
<code>Monat JJJJ</code> or <code>TT.MM.JJJJ</code> dates) — the same parser the reference sheets use.
|
||||
</MudText>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
|
||||
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
|
||||
Choose CSV
|
||||
</MudButton>
|
||||
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
|
||||
@if (_fileName is not null)
|
||||
{
|
||||
<MudText><b>@_fileName</b> — @_rows.Count rows, @_colCount columns</MudText>
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@if (_colCount > 0)
|
||||
{
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">1. Parsing options</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="int" @bind-Value="_dateColumn" Label="Date column" Dense="true">
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="Date format" Dense="true">
|
||||
<MudSelectItem T="DateKind" Value="DateKind.Auto">Auto-detect</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.MonthName">Month name (Januar 2024)</MudSelectItem>
|
||||
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">Day (31.12.2024)</MudSelectItem>
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_headerRow" Label="Header row" Min="0" Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="6" md="2">
|
||||
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="First data row" Min="0" Margin="Margin.Dense" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="2" Class="d-flex flex-column">
|
||||
<MudSwitch T="bool" @bind-Value="_skipAllZero" Label="Skip zero rows" Color="Color.Primary" />
|
||||
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="Detect swaps" Color="Color.Primary" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">2. Column preview</MudText>
|
||||
<div style="overflow-x:auto">
|
||||
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
|
||||
Col @i@(i == _dateColumn ? " 📅" : "")
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in Enumerable.Range(0, Math.Min(_rows.Count, _firstDataRow + 8)))
|
||||
{
|
||||
<tr style="@(r < _firstDataRow ? "opacity:0.5" : "")">
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<td style="font-size:0.78rem; white-space:nowrap">@Cell(r, i)</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Faded rows are before the first data row. The 📅 column supplies the date.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">3. Map columns</MudText>
|
||||
<div style="overflow-x:auto">
|
||||
<MudSimpleTable Dense="true" Style="min-width:100%">
|
||||
<thead>
|
||||
<tr><th>Column</th><th>Sample</th><th style="min-width:160px">Role</th><th style="min-width:220px">Target</th><th style="min-width:120px">Unit</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var i in Enumerable.Range(0, _colCount))
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<b>Col @i</b>
|
||||
@if (!string.IsNullOrWhiteSpace(Header(i)))
|
||||
{
|
||||
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span>
|
||||
}
|
||||
</td>
|
||||
<td style="font-size:0.8rem; max-width:140px; overflow:hidden; text-overflow:ellipsis">@Sample(i)</td>
|
||||
<td>
|
||||
<MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
|
||||
@foreach (var role in Enum.GetValues<MappingRole>())
|
||||
{
|
||||
<MudSelectItem T="MappingRole" Value="role">@role</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</td>
|
||||
<td>
|
||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_columns[i].MeterId" Dense="true" Margin="Margin.Dense"
|
||||
Placeholder="Select meter" Clearable="true">
|
||||
@foreach (var m in _meters)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else if (_columns[i].Role == MappingRole.ManualCost)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense"
|
||||
Placeholder="Select category" Clearable="true">
|
||||
@foreach (var c in _categories)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel)
|
||||
{
|
||||
<MudTextField @bind-Value="_columns[i].Unit" Placeholder="e.g. kWh" Margin="Margin.Dense" />
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</div>
|
||||
</MudPaper>
|
||||
|
||||
@if (_validationErrors.Count > 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-4">
|
||||
@foreach (var err in _validationErrors)
|
||||
{
|
||||
<div>@err</div>
|
||||
}
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap">
|
||||
<MudText Typo="Typo.h6">4. Preview & commit</MudText>
|
||||
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
|
||||
OnClick="Preview">Dry-run preview</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
|
||||
Commit import
|
||||
</MudButton>
|
||||
@if (_committing)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_staged is not null)
|
||||
{
|
||||
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
|
||||
<MudText>Readings: <b>@_staged.Readings.Count</b></MudText>
|
||||
<MudText>Events: <b>@_staged.Events.Count</b></MudText>
|
||||
<MudText>Manual costs: <b>@_staged.ManualCosts.Count</b></MudText>
|
||||
<MudText>Skipped rows: <b>@_staged.SkippedRows</b></MudText>
|
||||
</div>
|
||||
@if (_staged.TotalRows == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
|
||||
Nothing staged. Check the first-data-row, date column and column mappings above.
|
||||
</MudAlert>
|
||||
}
|
||||
@if (_staged.Warnings.Count > 0)
|
||||
{
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel Text="@($"{_staged.Warnings.Count} warnings")">
|
||||
@foreach (var warning in _staged.Warnings.Take(100))
|
||||
{
|
||||
<MudText Typo="Typo.body2">@warning</MudText>
|
||||
}
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
private sealed class ColumnState
|
||||
{
|
||||
public MappingRole Role { get; set; } = MappingRole.Ignore;
|
||||
public int? MeterId { get; set; }
|
||||
public int? CategoryId { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
}
|
||||
|
||||
private string? _fileName;
|
||||
private string? _csvText;
|
||||
private List<string[]> _rows = [];
|
||||
private int _colCount;
|
||||
private ColumnState[] _columns = [];
|
||||
|
||||
private int _dateColumn;
|
||||
private DateKind _dateKind = DateKind.Auto;
|
||||
private int _headerRow;
|
||||
private int _firstDataRow = 1;
|
||||
private bool _skipAllZero = true;
|
||||
private bool _detectSwaps;
|
||||
|
||||
private List<Meter> _meters = [];
|
||||
private List<CostCategory> _categories = [];
|
||||
private StagedImport? _staged;
|
||||
private 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;
|
||||
_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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
_committing = true;
|
||||
try
|
||||
{
|
||||
var mappingJson = 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)),
|
||||
});
|
||||
|
||||
var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
|
||||
Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success);
|
||||
Nav.NavigateTo("/import");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_committing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0)
|
||||
{
|
||||
errors.Add("Map at least one column to a role other than Ignore.");
|
||||
}
|
||||
|
||||
for (var i = 0; i < _columns.Length; i++)
|
||||
{
|
||||
var c = _columns[i];
|
||||
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null)
|
||||
{
|
||||
errors.Add($"Col {i} ({c.Role}) needs a target meter.");
|
||||
}
|
||||
|
||||
if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
|
||||
{
|
||||
errors.Add($"Col {i} (ManualCost) needs a target category.");
|
||||
}
|
||||
}
|
||||
|
||||
// Two Reading columns on one meter would stage two readings per row at the same timestamp,
|
||||
// and (meter_id, time) is the reading key — reject it here rather than at the DB.
|
||||
var duplicateTargets = _columns
|
||||
.Select((c, i) => (Column: c, Index: i))
|
||||
.Where(x => x.Column.Role == MappingRole.Reading && x.Column.MeterId is not null)
|
||||
.GroupBy(x => x.Column.MeterId!.Value)
|
||||
.Where(g => g.Count() > 1);
|
||||
|
||||
foreach (var group in duplicateTargets)
|
||||
{
|
||||
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}";
|
||||
var cols = string.Join(", ", group.Select(x => $"Col {x.Index}"));
|
||||
errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter.");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private MappingProfile BuildProfile() => new()
|
||||
{
|
||||
Name = _fileName ?? "Custom CSV",
|
||||
HeaderRowIndex = _headerRow,
|
||||
FirstDataRowIndex = _firstDataRow,
|
||||
DateColumn = _dateColumn,
|
||||
DateKind = _dateKind,
|
||||
SkipAllZeroRows = _skipAllZero,
|
||||
DetectCumulativeSwaps = _detectSwaps,
|
||||
Columns = Enumerable.Range(0, _colCount)
|
||||
.Where(i => _columns[i].Role != MappingRole.Ignore)
|
||||
.Select(i => new ColumnMapping
|
||||
{
|
||||
Index = i,
|
||||
Role = _columns[i].Role,
|
||||
MeterId = _columns[i].MeterId,
|
||||
CategoryId = _columns[i].CategoryId,
|
||||
Unit = string.IsNullOrWhiteSpace(_columns[i].Unit) ? null : _columns[i].Unit,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
private string Cell(int row, int col) =>
|
||||
row >= 0 && row < _rows.Count && col < _rows[row].Length ? _rows[row][col] : string.Empty;
|
||||
|
||||
private string Header(int col) => Cell(_headerRow, col);
|
||||
|
||||
private string Sample(int col)
|
||||
{
|
||||
for (var r = _firstDataRow; r < _rows.Count && r < _firstDataRow + 20; r++)
|
||||
{
|
||||
var value = Cell(r, col);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private string ColLabel(int col)
|
||||
{
|
||||
var header = Header(col);
|
||||
return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
@page "/meters/{Id:int}"
|
||||
@inject MeterDetailService Details
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject NavigationManager Nav
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Infrastructure.Ingestion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meter</PageTitle>
|
||||
|
||||
@if (_detail is null)
|
||||
{
|
||||
@if (_notFound)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-center mb-4" style="gap:.75rem">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
|
||||
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
|
||||
@if (!_detail.IsActive)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
|
||||
}
|
||||
</div>
|
||||
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
@if (_detail.TotalGeneration != 0)
|
||||
{
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
||||
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalGeneration, 0) @_detail.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
||||
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
||||
<MudText Typo="Typo.h6">
|
||||
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
||||
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||
@if (_detail.RecentReadings.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _detail.RecentReadings)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
|
||||
<td>@QualityChip(r.Quality)</td>
|
||||
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
|
||||
@if (_detail.RecentConsumption.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _detail.RecentConsumption)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
|
||||
<td>@c.Kind</td>
|
||||
<td>@QualityChip(c.Quality)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
|
||||
@if (_detail.Events.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var e in _detail.Events)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.Time.ToString("yyyy-MM-dd")</td>
|
||||
<td>@e.Type</td>
|
||||
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
||||
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
|
||||
<td>@e.Notes</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
|
||||
@if (_detail.Tariffs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var t in _detail.Tariffs)
|
||||
{
|
||||
<tr>
|
||||
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
|
||||
<td>@t.Component</td>
|
||||
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
|
||||
<td>@t.Unit</td>
|
||||
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
|
||||
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
|
||||
<MudTabPanel Text="@($"Sources ({_sources.Count})")">
|
||||
<div class="d-flex justify-end mb-2">
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
|
||||
Add source
|
||||
</MudButton>
|
||||
</div>
|
||||
@if (_sources.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Type</th><th>Target</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var s in _sources)
|
||||
{
|
||||
<tr>
|
||||
<td>@s.SourceType</td>
|
||||
<td>@SourceTarget(s)</td>
|
||||
<td>@(s.IsEnabled ? "yes" : "no")</td>
|
||||
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
|
||||
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
|
||||
<td>@(s.LastStatus ?? "—")</td>
|
||||
<td style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenSource(s))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteSourceAsync(s))" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudSelect T="SourceType" @bind-Value="_sourceEdit.SourceType" Label="Source type" Class="mb-2">
|
||||
@foreach (var type in Enum.GetValues<SourceType>())
|
||||
{
|
||||
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_sourceEdit.SourceType is SourceType.HomeAssistant or SourceType.Mqtt or SourceType.Tasmota)
|
||||
{
|
||||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Clearable="true" Class="mb-2">
|
||||
@foreach (var e in _endpoints)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name (@e.Type)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
|
||||
{
|
||||
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" />
|
||||
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollSeconds" Label="Poll interval (seconds)" Class="mb-2" />
|
||||
}
|
||||
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
||||
{
|
||||
<MudTextField @bind-Value="_sourceEdit.Topic" Label="MQTT topic (e.g. tele/plug1/SENSOR)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.Path" Label="Value path (e.g. ENERGY.Total; blank = bare scalar)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" Class="mb-2" />
|
||||
}
|
||||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
|
||||
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||||
{
|
||||
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="Scale" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" Class="mb-2" />
|
||||
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="Priority" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private MeterDetailView? _detail;
|
||||
private bool _notFound;
|
||||
private List<MeterSource> _sources = [];
|
||||
private List<IngestionEndpoint> _endpoints = [];
|
||||
private bool _sourceOpen;
|
||||
private SourceEdit _sourceEdit = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_detail = null;
|
||||
_notFound = false;
|
||||
_detail = await Details.GetAsync(Id);
|
||||
_notFound = _detail is null;
|
||||
if (_detail is not null)
|
||||
{
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadSourcesAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == Id).OrderBy(s => s.Priority).ToListAsync();
|
||||
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||
}
|
||||
|
||||
private static string SourceTarget(MeterSource s)
|
||||
{
|
||||
var config = SourceConfig.Parse(s.Config);
|
||||
return s.SourceType == SourceType.HomeAssistant
|
||||
? config.EntityId ?? "—"
|
||||
: config.Topic ?? "—";
|
||||
}
|
||||
|
||||
private void OpenSource(MeterSource? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
_sourceEdit = new SourceEdit();
|
||||
}
|
||||
else
|
||||
{
|
||||
var config = SourceConfig.Parse(source.Config);
|
||||
_sourceEdit = new SourceEdit
|
||||
{
|
||||
Id = source.Id,
|
||||
SourceType = source.SourceType,
|
||||
EndpointId = source.EndpointId,
|
||||
ValueKind = source.ValueKind,
|
||||
Scale = source.Scale,
|
||||
Offset = source.Offset,
|
||||
Priority = source.Priority,
|
||||
IsEnabled = source.IsEnabled,
|
||||
EntityId = config.EntityId,
|
||||
Attribute = config.Attribute,
|
||||
PollSeconds = config.PollSeconds,
|
||||
Topic = config.Topic,
|
||||
Path = config.Path,
|
||||
TimePath = config.TimePath,
|
||||
};
|
||||
}
|
||||
_sourceOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveSourceAsync()
|
||||
{
|
||||
var config = new SourceConfig
|
||||
{
|
||||
EntityId = Trim(_sourceEdit.EntityId),
|
||||
Attribute = Trim(_sourceEdit.Attribute),
|
||||
PollSeconds = _sourceEdit.PollSeconds,
|
||||
Topic = Trim(_sourceEdit.Topic),
|
||||
Path = Trim(_sourceEdit.Path),
|
||||
TimePath = Trim(_sourceEdit.TimePath),
|
||||
};
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(config,
|
||||
new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
if (_sourceEdit.Id == 0)
|
||||
{
|
||||
db.MeterSources.Add(new MeterSource
|
||||
{
|
||||
MeterId = Id,
|
||||
SourceType = _sourceEdit.SourceType,
|
||||
EndpointId = _sourceEdit.EndpointId,
|
||||
Config = configJson,
|
||||
ValueKind = _sourceEdit.ValueKind,
|
||||
Scale = _sourceEdit.Scale,
|
||||
Offset = _sourceEdit.Offset,
|
||||
Priority = _sourceEdit.Priority,
|
||||
IsEnabled = _sourceEdit.IsEnabled,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await db.MeterSources.FirstAsync(s => s.Id == _sourceEdit.Id);
|
||||
existing.SourceType = _sourceEdit.SourceType;
|
||||
existing.EndpointId = _sourceEdit.EndpointId;
|
||||
existing.Config = configJson;
|
||||
existing.ValueKind = _sourceEdit.ValueKind;
|
||||
existing.Scale = _sourceEdit.Scale;
|
||||
existing.Offset = _sourceEdit.Offset;
|
||||
existing.Priority = _sourceEdit.Priority;
|
||||
existing.IsEnabled = _sourceEdit.IsEnabled;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
_sourceOpen = false;
|
||||
Snackbar.Add("Source saved.", Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteSourceAsync(MeterSource source)
|
||||
{
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
|
||||
Snackbar.Add("Source deleted.", Severity.Success);
|
||||
await LoadSourcesAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class SourceEdit
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public SourceType SourceType { get; set; } = SourceType.HomeAssistant;
|
||||
public int? EndpointId { get; set; }
|
||||
public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register;
|
||||
public double Scale { get; set; } = 1;
|
||||
public double Offset { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public string? EntityId { get; set; }
|
||||
public string? Attribute { get; set; }
|
||||
public int? PollSeconds { get; set; } = 60;
|
||||
public string? Topic { get; set; }
|
||||
public string? Path { get; set; }
|
||||
public string? TimePath { get; set; }
|
||||
}
|
||||
|
||||
private static RenderFragment QualityChip(ReadingQuality quality) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
|
||||
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality</MudChip>;
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
@page "/meters"
|
||||
@rendermode InteractiveServer
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@inject MeterVault.Core.Normalization.INormalizationEngine Engine
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Meters</PageTitle>
|
||||
|
||||
<MudText Typo="Typo.h4" Class="mb-4">Meters</MudText>
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Meters</MudText>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||
Add meter
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (_meters is null)
|
||||
{
|
||||
@@ -21,40 +29,120 @@ else
|
||||
<MudTh>Unit</MudTh>
|
||||
<MudTh>Sources</MudTh>
|
||||
<MudTh>Last seen</MudTh>
|
||||
<MudTh>Active</MudTh>
|
||||
<MudTh Style="text-align:right">Actions</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
||||
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
||||
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
||||
<MudTd DataLabel="Last seen">
|
||||
@{
|
||||
var lastSeen = context.Sources
|
||||
.Where(s => s.LastSeenAt != null)
|
||||
.Select(s => s.LastSeenAt)
|
||||
.DefaultIfEmpty(null)
|
||||
.Max();
|
||||
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
|
||||
}
|
||||
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Active">@(context.IsActive ? "yes" : "no")</MudTd>
|
||||
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@if (_meters.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||
No meters yet. Go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="Energy type" Class="mb-2">
|
||||
@foreach (var t in _energyTypes)
|
||||
{
|
||||
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Measurement mode" Class="mb-2">
|
||||
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||
{
|
||||
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_working.Mode == MeterMode.Virtual)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the
|
||||
upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2).
|
||||
</MudAlert>
|
||||
}
|
||||
else if (_working.Mode == MeterMode.InstantRate)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||
Power/flow sensor — readings are an instantaneous rate, integrated over time into consumption.
|
||||
Store the value as a <b>per-hour</b> rate in this meter's unit (e.g. kW for kWh, L/h for L): a
|
||||
source reporting W or L/min should carry a scale factor to convert it first.
|
||||
</MudAlert>
|
||||
}
|
||||
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
|
||||
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
|
||||
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
|
||||
<MudSelectItem T="string" Value="@("")">— none —</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
|
||||
Label="Sub-meter of (upstream meters)" Class="mb-2"
|
||||
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
||||
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
|
||||
@foreach (var m in AvailableUpstream())
|
||||
{
|
||||
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
<MudTextField @bind-Value="_working.Manufacturer" Label="Manufacturer (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.Model" Label="Model (optional)" Class="mb-2" />
|
||||
</div>
|
||||
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="Active" Color="Color.Primary" />
|
||||
@if (_working.Id != 0 && _working.RecomputeNeeded)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Mode/baseline changed — consumption will be recomputed on save.</MudAlert>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
private List<Meter>? _meters;
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<MeterLink> _allLinks = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
|
||||
_meters = await db.Meters
|
||||
.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
@@ -62,4 +150,210 @@ else
|
||||
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
|
||||
private IEnumerable<Meter> AvailableUpstream()
|
||||
{
|
||||
if (_meters is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var descendants = Descendants(_working.Id);
|
||||
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
|
||||
}
|
||||
|
||||
private HashSet<int> Descendants(int meterId)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
if (meterId == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(meterId);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
|
||||
{
|
||||
if (result.Add(link.ToMeterId))
|
||||
{
|
||||
queue.Enqueue(link.ToMeterId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string UpstreamText(IReadOnlyList<string> ids)
|
||||
{
|
||||
var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
|
||||
return string.Join(", ", names);
|
||||
}
|
||||
|
||||
private void OpenEdit(Meter? meter)
|
||||
{
|
||||
if (meter is null)
|
||||
{
|
||||
_working = new EditModel { EnergyTypeId = _energyTypes.FirstOrDefault()?.Id ?? 0 };
|
||||
}
|
||||
else
|
||||
{
|
||||
_working = new EditModel
|
||||
{
|
||||
Id = meter.Id,
|
||||
Name = meter.Name,
|
||||
EnergyTypeId = meter.EnergyTypeId,
|
||||
Mode = meter.Mode,
|
||||
OriginalMode = meter.Mode,
|
||||
Unit = meter.Unit,
|
||||
InitialBaseline = meter.InitialBaseline,
|
||||
OriginalBaseline = meter.InitialBaseline,
|
||||
Role = MeterMeta.Role(meter.Meta) ?? "",
|
||||
Location = meter.Location,
|
||||
SerialNumber = meter.SerialNumber,
|
||||
Manufacturer = meter.Manufacturer,
|
||||
Model = meter.Model,
|
||||
IsActive = meter.IsActive,
|
||||
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
|
||||
};
|
||||
}
|
||||
_editOpen = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
|
||||
{
|
||||
Snackbar.Add("Name, energy type and unit are required.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
int meterId;
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
var meter = new Meter
|
||||
{
|
||||
Name = _working.Name.Trim(),
|
||||
EnergyTypeId = _working.EnergyTypeId,
|
||||
Mode = _working.Mode,
|
||||
Unit = _working.Unit.Trim(),
|
||||
InitialBaseline = _working.InitialBaseline,
|
||||
Meta = MeterMeta.SetRole("{}", _working.Role),
|
||||
Location = Trim(_working.Location),
|
||||
SerialNumber = Trim(_working.SerialNumber),
|
||||
Manufacturer = Trim(_working.Manufacturer),
|
||||
Model = Trim(_working.Model),
|
||||
IsActive = _working.IsActive,
|
||||
};
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
meterId = meter.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id);
|
||||
existing.Name = _working.Name.Trim();
|
||||
existing.EnergyTypeId = _working.EnergyTypeId;
|
||||
existing.Mode = _working.Mode;
|
||||
existing.Unit = _working.Unit.Trim();
|
||||
existing.InitialBaseline = _working.InitialBaseline;
|
||||
existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role);
|
||||
existing.Location = Trim(_working.Location);
|
||||
existing.SerialNumber = Trim(_working.SerialNumber);
|
||||
existing.Manufacturer = Trim(_working.Manufacturer);
|
||||
existing.Model = Trim(_working.Model);
|
||||
existing.IsActive = _working.IsActive;
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (_working.RecomputeNeeded)
|
||||
{
|
||||
var normalization = new MeterVault.Infrastructure.Normalization.NormalizationService(db, Engine);
|
||||
await normalization.RecomputeMeterAsync(existing.Id, null);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
meterId = existing.Id;
|
||||
}
|
||||
|
||||
await SyncUpstreamAsync(db, meterId, _working.Upstream);
|
||||
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
|
||||
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
|
||||
{
|
||||
var desired = desiredUpstream.Where(id => id != meterId).ToHashSet();
|
||||
var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
|
||||
|
||||
foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
|
||||
{
|
||||
db.MeterLinks.Remove(link);
|
||||
}
|
||||
|
||||
foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
|
||||
{
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Meter meter)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id);
|
||||
var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id);
|
||||
var detail = readings + consumption > 0
|
||||
? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)."
|
||||
: "";
|
||||
|
||||
if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone."))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
// reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade.
|
||||
await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
Snackbar.Add("Deleted.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class EditModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public short EnergyTypeId { get; set; }
|
||||
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter;
|
||||
public string Unit { get; set; } = "";
|
||||
public double InitialBaseline { get; set; }
|
||||
public double OriginalBaseline { get; set; }
|
||||
public string Role { get; set; } = "";
|
||||
public string? Location { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
|
||||
|
||||
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
@page "/solar"
|
||||
@inject SolarService SolarSvc
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — Solar / PV</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">Solar / PV</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@if (_summary is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (!_summary.HasGeneration)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from
|
||||
<MudLink Href="/import">Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Self-consumption</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
|
||||
@if (_summary.SelfConsumptionRatio is { } ratio)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Format.Number(ratio * 100, 0)% of generation</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Autarky</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
|
||||
@if (_summary.GridImport is { } grid)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid draw @Format.Number(grid, 0) kWh</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Savings (Ersparnis)</MudText>
|
||||
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="8">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Generation & self-consumption</MudText>
|
||||
<SeriesChart Series="_chart" Decimals="0" Height="340" />
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="4">
|
||||
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Generation by meter</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
@foreach (var meter in _summary.Meters)
|
||||
{
|
||||
<tr>
|
||||
<td>@meter.Name</td>
|
||||
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
@if (!_summary.HasLoadContext)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||
Tag a meter <code>total_load</code> and one <code>grid_import</code> (in meter metadata)
|
||||
to unlock self-consumption, autarky and savings.
|
||||
</MudAlert>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@code {
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private SolarSummary? _summary;
|
||||
private IReadOnlyList<SeriesChart.SeriesDef> _chart = [];
|
||||
|
||||
protected override Task OnInitializedAsync() => LoadAsync();
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_summary = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = asOf.AddMonths(-_months);
|
||||
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||
|
||||
var generation = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation))
|
||||
.ToList();
|
||||
var series = new List<SeriesChart.SeriesDef>
|
||||
{
|
||||
new("Generation", ApexCharts.SeriesType.Bar, generation),
|
||||
};
|
||||
if (_summary.HasLoadContext)
|
||||
{
|
||||
var self = _summary.Months
|
||||
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0))
|
||||
.ToList();
|
||||
series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self));
|
||||
}
|
||||
|
||||
_chart = series;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/trends"
|
||||
@rendermode InteractiveServer
|
||||
@inject DashboardService Dash
|
||||
|
||||
<PageTitle>MeterVault — Trends</PageTitle>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using System.Net
|
||||
@using MeterVault.Infrastructure.Dashboard
|
||||
|
||||
@if (string.IsNullOrEmpty(_svg))
|
||||
{
|
||||
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="width:100%; overflow-x:auto">
|
||||
@((MarkupString)_svg)
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const double W = 1000;
|
||||
private const double NodeWidth = 16;
|
||||
private const double NodeGap = 12;
|
||||
private const double LeftPad = 8;
|
||||
private const double RightPad = 8;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<FlowNode> Nodes { get; set; } = [];
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<FlowLink> Links { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string Unit { get; set; } = "";
|
||||
|
||||
private string _svg = "";
|
||||
|
||||
protected override void OnParametersSet() => _svg = BuildSvg();
|
||||
|
||||
private string BuildSvg()
|
||||
{
|
||||
if (Nodes.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var maxDepth = Nodes.Max(n => n.Depth);
|
||||
var columns = Nodes.GroupBy(n => n.Depth).ToDictionary(g => g.Key, g => g.OrderByDescending(n => n.Value).ToList());
|
||||
var maxCount = columns.Values.Max(c => c.Count);
|
||||
var height = Math.Max(320, (maxCount * 46) + 40);
|
||||
|
||||
// One value→pixel scale so every column fits (flow is conserved → column totals are ~equal;
|
||||
// the densest column with the most gaps constrains the scale).
|
||||
var scale = double.MaxValue;
|
||||
foreach (var col in columns.Values)
|
||||
{
|
||||
var sum = col.Sum(n => n.Value);
|
||||
if (sum > 0)
|
||||
{
|
||||
scale = Math.Min(scale, (height - ((col.Count - 1) * NodeGap) - 20) / sum);
|
||||
}
|
||||
}
|
||||
|
||||
if (double.IsInfinity(scale) || scale <= 0)
|
||||
{
|
||||
scale = 1;
|
||||
}
|
||||
|
||||
var colStep = maxDepth == 0 ? 0 : (W - LeftPad - RightPad - NodeWidth) / maxDepth;
|
||||
var geo = new Dictionary<string, NodeGeo>();
|
||||
foreach (var (depth, col) in columns)
|
||||
{
|
||||
var heights = col.Select(n => Math.Max(3, n.Value * scale)).ToList();
|
||||
var colHeight = heights.Sum() + ((col.Count - 1) * NodeGap);
|
||||
var y = (height - colHeight) / 2;
|
||||
var x = LeftPad + (depth * colStep);
|
||||
for (var i = 0; i < col.Count; i++)
|
||||
{
|
||||
geo[col[i].Id] = new NodeGeo(x, y, heights[i]);
|
||||
y += heights[i] + NodeGap;
|
||||
}
|
||||
}
|
||||
|
||||
// Ribbon band offsets: order each source's out-links by target y, each target's in-links by source y.
|
||||
var srcOffset = new Dictionary<string, double>();
|
||||
var dstOffset = new Dictionary<string, double>();
|
||||
var srcBand = new Dictionary<FlowLink, double>();
|
||||
var dstBand = new Dictionary<FlowLink, double>();
|
||||
foreach (var group in Links.GroupBy(l => l.From))
|
||||
{
|
||||
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.To, out var g) ? g.Y : 0))
|
||||
{
|
||||
srcBand[link] = srcOffset.GetValueOrDefault(group.Key);
|
||||
srcOffset[group.Key] = srcBand[link] + (link.Value * scale);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in Links.GroupBy(l => l.To))
|
||||
{
|
||||
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.From, out var g) ? g.Y : 0))
|
||||
{
|
||||
dstBand[link] = dstOffset.GetValueOrDefault(group.Key);
|
||||
dstOffset[group.Key] = dstBand[link] + (link.Value * scale);
|
||||
}
|
||||
}
|
||||
|
||||
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
|
||||
var label = Nodes.ToDictionary(n => n.Id, n => n.Label);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"Flow diagram\">");
|
||||
|
||||
// Ribbons first (under nodes).
|
||||
foreach (var link in Links)
|
||||
{
|
||||
if (!geo.TryGetValue(link.From, out var s) || !geo.TryGetValue(link.To, out var t))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var band = link.Value * scale;
|
||||
var sy0 = s.Y + srcBand[link];
|
||||
var ty0 = t.Y + dstBand[link];
|
||||
var sx = s.X + NodeWidth;
|
||||
var tx = t.X;
|
||||
var midX = (sx + tx) / 2;
|
||||
var path =
|
||||
$"M{F(sx)},{F(sy0)} C{F(midX)},{F(sy0)} {F(midX)},{F(ty0)} {F(tx)},{F(ty0)} " +
|
||||
$"L{F(tx)},{F(ty0 + band)} C{F(midX)},{F(ty0 + band)} {F(midX)},{F(sy0 + band)} {F(sx)},{F(sy0 + band)} Z";
|
||||
var tip = Enc($"{label.GetValueOrDefault(link.From)} → {label.GetValueOrDefault(link.To)}: {Fmt(link.Value)}");
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<path d=\"{path}\" fill=\"{Enc(color.GetValueOrDefault(link.From, "#607D8B"))}\" fill-opacity=\"0.38\"><title>{tip}</title></path>");
|
||||
}
|
||||
|
||||
// Nodes + labels.
|
||||
foreach (var node in Nodes)
|
||||
{
|
||||
if (!geo.TryGetValue(node.Id, out var g))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rightmost = node.Depth == maxDepth;
|
||||
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
|
||||
var anchor = rightmost ? "end" : "start";
|
||||
var fill = Enc(node.ColorHex ?? "#607D8B");
|
||||
var name = Enc(node.Label);
|
||||
var val = Enc(Fmt(node.Value));
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>");
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<text x=\"{F(labelX)}\" y=\"{F(g.Y + (g.H / 2))}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" font-size=\"13\" fill=\"currentColor\">" +
|
||||
$"<tspan>{name}</tspan><tspan x=\"{F(labelX)}\" dy=\"15\" font-size=\"11\" fill-opacity=\"0.65\">{val}</tspan></text>");
|
||||
}
|
||||
|
||||
sb.Append("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim();
|
||||
|
||||
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Enc(string value) => WebUtility.HtmlEncode(value);
|
||||
|
||||
private readonly record struct NodeGeo(double X, double Y, double H);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@using ApexCharts
|
||||
|
||||
@if (HasData)
|
||||
{
|
||||
<ApexChart TItem="SeriesChart.Point" Options="_options" Height="@Height">
|
||||
@foreach (var series in Series)
|
||||
{
|
||||
<ApexPointSeries TItem="SeriesChart.Point"
|
||||
Items="series.Points"
|
||||
SeriesType="series.Type"
|
||||
Name="@series.Name"
|
||||
XValue="p => p.Label"
|
||||
YValue="p => (decimal)Math.Round(p.Value, Decimals)" />
|
||||
}
|
||||
</ApexChart>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>A single (label, value) point in a series.</summary>
|
||||
public sealed record Point(string Label, double Value);
|
||||
|
||||
/// <summary>A named series rendered as bars or a line over the shared category axis.</summary>
|
||||
public sealed record SeriesDef(string Name, SeriesType Type, IReadOnlyList<Point> Points);
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<SeriesDef> Series { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public int Height { get; set; } = 300;
|
||||
|
||||
[Parameter]
|
||||
public int Decimals { get; set; } = 2;
|
||||
|
||||
private bool HasData => Series.Any(s => s.Points.Count > 0);
|
||||
|
||||
private readonly ApexChartOptions<SeriesChart.Point> _options = new()
|
||||
{
|
||||
Theme = new Theme { Mode = Mode.Dark },
|
||||
DataLabels = new DataLabels { Enabled = false },
|
||||
Legend = new Legend { Position = LegendPosition.Top },
|
||||
Stroke = new Stroke { Width = 3, Curve = Curve.Smooth },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MudBlazor;
|
||||
|
||||
namespace MeterVault.App;
|
||||
|
||||
/// <summary>Small wrapper over MudBlazor's message box for delete/confirm prompts in admin pages.</summary>
|
||||
public static class Confirm
|
||||
{
|
||||
public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message)
|
||||
{
|
||||
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: "Delete", cancelText: "Cancel")
|
||||
.ConfigureAwait(false);
|
||||
return result == true;
|
||||
}
|
||||
|
||||
/// <summary>A generic yes/cancel confirmation with a caller-supplied confirm-button label.</summary>
|
||||
public static async Task<bool> ConfirmAsync(IDialogService dialog, string title, string message, string confirmText)
|
||||
{
|
||||
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: confirmText, cancelText: "Cancel")
|
||||
.ConfigureAwait(false);
|
||||
return result == true;
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -60,7 +60,7 @@ try
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseReverseProxyTrust();
|
||||
// No HTTPS redirection: the app serves plain HTTP (port 8080) behind a reverse proxy
|
||||
// No HTTPS redirection: the app serves plain HTTP (port 8760) behind a reverse proxy
|
||||
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
|
||||
app.UseAntiforgery();
|
||||
|
||||
@@ -104,6 +104,14 @@ static async Task MigrateDatabaseAsync(WebApplication app)
|
||||
await db.Database.MigrateAsync().ConfigureAwait(false);
|
||||
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
||||
Log.Information("Database migrations applied and defaults seeded");
|
||||
|
||||
if (options.SeedReferenceData)
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<MeterVault.Infrastructure.Import.ReferenceDataImporter>();
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
||||
await importer.LoadAsync(dir).ConfigureAwait(false);
|
||||
Log.Information("Reference dataset ensured (SeedReferenceData=true)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
||||
|
||||
@@ -21,7 +21,7 @@ public enum MeterMode
|
||||
/// <summary>Source already reports increments. The value is the increment.</summary>
|
||||
DirectDelta,
|
||||
|
||||
/// <summary>Power/flow sensor (schema-supported; worker deferred post-v1). Integrate rate over time.</summary>
|
||||
/// <summary>Power/flow sensor. Consumption = rate integrated over time (trapezoidal).</summary>
|
||||
InstantRate,
|
||||
|
||||
/// <summary>Computed from other meters via a user-defined expression.</summary>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MeterVault.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A directed flow edge in the meter topology: energy/water measured by <see cref="ToMeter"/> is a
|
||||
/// <em>subsection</em> of the flow through <see cref="FromMeter"/> (upstream → downstream). Not an
|
||||
/// addition — a sub-meter shows where an upstream meter's flow goes. Multiple edges into one meter
|
||||
/// model a merge (e.g. house load fed by grid + solar); multiple edges out model a split
|
||||
/// (main → car, pool, …). Drives the per-energy-type flow (Sankey) view.
|
||||
/// </summary>
|
||||
public sealed class MeterLink
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Upstream meter (the larger flow this edge draws from).</summary>
|
||||
public int FromMeterId { get; set; }
|
||||
|
||||
public Meter? FromMeter { get; set; }
|
||||
|
||||
/// <summary>Downstream meter (the subsection).</summary>
|
||||
public int ToMeterId { get; set; }
|
||||
|
||||
public Meter? ToMeter { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MeterVault.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A meter's optional role in an energy system, stored under <c>role</c> in <see cref="Meter.Meta"/>.
|
||||
/// Roles let analytic panels (e.g. the PV self-consumption/autarky view) find the relevant meters
|
||||
/// by configuration rather than by hardcoded names — nothing domain-specific is baked into code
|
||||
/// (SDD §5.2, §8.4). A PV install typically tags one meter <see cref="TotalLoad"/> and one
|
||||
/// <see cref="GridImport"/>; self-consumption is then <c>total_load − grid_import</c>.
|
||||
/// </summary>
|
||||
public static class MeterRoles
|
||||
{
|
||||
/// <summary>Meter measuring the site's total consumption (all loads).</summary>
|
||||
public const string TotalLoad = "total_load";
|
||||
|
||||
/// <summary>Meter measuring energy drawn from the grid.</summary>
|
||||
public const string GridImport = "grid_import";
|
||||
|
||||
/// <summary>Meter measuring energy exported to the grid.</summary>
|
||||
public const string GridExport = "grid_export";
|
||||
}
|
||||
|
||||
/// <summary>Typed reads over a meter's free-form <c>Meta</c> JSON (jsonb). Tolerant of malformed
|
||||
/// or empty JSON — returns null rather than throwing, so a bad blob never breaks a dashboard.</summary>
|
||||
public static class MeterMeta
|
||||
{
|
||||
/// <summary>The meter's configured <c>role</c> (see <see cref="MeterRoles"/>), or null if unset/invalid.</summary>
|
||||
public static string? Role(string? meta) => ReadString(meta, "role");
|
||||
|
||||
/// <summary>Reads a top-level string property from the meta JSON; null if absent or unparsable.</summary>
|
||||
public static string? ReadString(string? meta, string property)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(meta);
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty(property, out var value)
|
||||
&& value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set to <paramref name="role"/>.</summary>
|
||||
public static string WithRole(string? meta, string role) => SetRole(meta, role);
|
||||
|
||||
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set, or removed when null/empty.</summary>
|
||||
public static string SetRole(string? meta, string? role)
|
||||
{
|
||||
var map = ToMap(meta);
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
{
|
||||
map.Remove("role");
|
||||
}
|
||||
else
|
||||
{
|
||||
map["role"] = role;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(map);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ToMap(string? meta)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(meta))
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(meta) ?? new Dictionary<string, object?>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public sealed class NormalizationEngine : INormalizationEngine
|
||||
new Normalizers.RuntimeCounterNormalizer(),
|
||||
new Normalizers.ConsumableBalanceNormalizer(),
|
||||
new Normalizers.DirectDeltaNormalizer(),
|
||||
new Normalizers.InstantRateNormalizer(),
|
||||
new Normalizers.VirtualNormalizer(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,35 @@
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>One recorded delivery into a consumable store.</summary>
|
||||
public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit);
|
||||
|
||||
/// <summary>One month of consumable draw.</summary>
|
||||
public sealed record ConsumableMonth(DateOnly Period, double Consumption);
|
||||
|
||||
/// <summary>
|
||||
/// The oil / consumable panel read model (SDD §8.5) for one <see cref="MeterMode.ConsumableBalance"/>
|
||||
/// meter: current tank level (physical + volume), fill vs capacity, deliveries, associated burner
|
||||
/// runtime, effective L/h (fixed or empirical), forecast-to-empty, tariff cost and a monthly series.
|
||||
/// </summary>
|
||||
public sealed record ConsumableSummary(
|
||||
int MeterId,
|
||||
string Name,
|
||||
string Unit,
|
||||
double Capacity,
|
||||
double? CurrentLevel,
|
||||
double? PhysicalLevel,
|
||||
string? PhysicalUnit,
|
||||
DateTimeOffset? LevelAsOf,
|
||||
double FillFraction,
|
||||
double ConsumptionInRange,
|
||||
double? BurnerHours,
|
||||
double? EffectiveRate,
|
||||
double? FixedRate,
|
||||
TankRateMode RateMode,
|
||||
double? AveragePerDay,
|
||||
DateOnly? ForecastEmpty,
|
||||
double CostInRange,
|
||||
IReadOnlyList<DeliveryRow> Deliveries,
|
||||
IReadOnlyList<ConsumableMonth> Months);
|
||||
@@ -0,0 +1,177 @@
|
||||
using Dapper;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Costing;
|
||||
using MeterVault.Infrastructure.Normalization;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Read model for the oil / consumable panel (SDD §8.5). Works for any
|
||||
/// <see cref="MeterMode.ConsumableBalance"/> meter backed by a <see cref="Tank"/> — heating oil is
|
||||
/// only the reference case. Current level is the latest dipstick reading (cm calibrated to volume)
|
||||
/// plus deliveries recorded since; the effective burn rate pairs the consumable's litres with the
|
||||
/// runtime hours of same-energy-type <see cref="MeterMode.RuntimeCounter"/> meters. DbContext
|
||||
/// factory keeps it Blazor-circuit safe.
|
||||
/// </summary>
|
||||
public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
|
||||
{
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
private readonly CostService _costService = costService;
|
||||
|
||||
public async Task<IReadOnlyList<ConsumableSummary>> GetConsumablesAsync(
|
||||
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meters = await db.Meters.AsNoTracking()
|
||||
.Where(m => m.Mode == MeterMode.ConsumableBalance)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var summaries = new List<ConsumableSummary>();
|
||||
foreach (var meter in meters)
|
||||
{
|
||||
var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meter.Id, cancellationToken).ConfigureAwait(false);
|
||||
if (tank is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
summaries.Add(await BuildAsync(db, meter, tank, from, to, cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private async Task<ConsumableSummary> BuildAsync(
|
||||
MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||
{
|
||||
var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration;
|
||||
var fromUtc = ToUtc(from);
|
||||
var toUtc = ToUtc(to);
|
||||
|
||||
var events = await db.MeterEvents.AsNoTracking()
|
||||
.Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery))
|
||||
.OrderBy(e => e.Time)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var lastLevel = events.LastOrDefault(e => e.EventType == MeterEventType.TankLevel);
|
||||
double? currentLevel = null;
|
||||
double? physicalLevel = null;
|
||||
string? physicalUnit = null;
|
||||
DateTimeOffset? levelAsOf = null;
|
||||
if (lastLevel is not null)
|
||||
{
|
||||
physicalLevel = lastLevel.Amount;
|
||||
physicalUnit = lastLevel.Unit;
|
||||
levelAsOf = lastLevel.Time;
|
||||
var volume = ToVolume(lastLevel, calibration);
|
||||
// Deliveries recorded after the last dipstick raise the actual contents.
|
||||
var since = events.Where(e => e.EventType == MeterEventType.Delivery && e.Time > lastLevel.Time).Sum(e => e.Amount ?? 0);
|
||||
currentLevel = volume + since;
|
||||
}
|
||||
|
||||
var fillFraction = tank.Capacity > 0 && currentLevel is { } level
|
||||
? Math.Clamp(level / tank.Capacity, 0, 1)
|
||||
: 0;
|
||||
|
||||
var deliveries = events
|
||||
.Where(e => e.EventType == MeterEventType.Delivery)
|
||||
.OrderByDescending(e => e.Time)
|
||||
.Select(e => new DeliveryRow(e.Time, e.Amount ?? 0, e.Unit))
|
||||
.ToList();
|
||||
|
||||
var consumptionInRange = await SumConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Burner runtime: same-energy-type runtime meters feed this consumable's L/h analytic.
|
||||
var runtimeMeterIds = await db.Meters.AsNoTracking()
|
||||
.Where(m => m.EnergyTypeId == meter.EnergyTypeId && m.Mode == MeterMode.RuntimeCounter)
|
||||
.Select(m => m.Id)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
double? burnerHours = null;
|
||||
foreach (var id in runtimeMeterIds)
|
||||
{
|
||||
burnerHours = (burnerHours ?? 0) + await SumConsumptionAsync(db, id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
double? effectiveRate = burnerHours is > 0 ? consumptionInRange / burnerHours : null;
|
||||
double? fixedRate = tank.RateMode == TankRateMode.Fixed ? tank.FixedRate : null;
|
||||
|
||||
var (averagePerDay, forecastEmpty) = await ForecastAsync(db, meter.Id, currentLevel, levelAsOf, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false))
|
||||
.Sum(c => c.Cost);
|
||||
|
||||
var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ConsumableSummary(
|
||||
meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf,
|
||||
fillFraction, consumptionInRange, burnerHours, effectiveRate, fixedRate, tank.RateMode,
|
||||
averagePerDay, forecastEmpty, costInRange, deliveries, months);
|
||||
}
|
||||
|
||||
/// <summary>Recent burn rate and a forecast-to-empty anchored at the last level reading, using
|
||||
/// the trailing 365 days of consumption (delivery-only early history would otherwise skew it).</summary>
|
||||
private static async Task<(double? AveragePerDay, DateOnly? ForecastEmpty)> ForecastAsync(
|
||||
MeterVaultDbContext db, int meterId, double? currentLevel, DateTimeOffset? levelAsOf, CancellationToken cancellationToken)
|
||||
{
|
||||
var latest = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId)
|
||||
.OrderByDescending(c => c.Time)
|
||||
.Select(c => (DateTimeOffset?)c.Time)
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (latest is null || currentLevel is not { } level || level <= 0)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var windowStart = latest.Value.AddDays(-365);
|
||||
var recent = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Time > windowStart && c.Time <= latest.Value)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
if (recent <= 0)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var averagePerDay = recent / 365.0;
|
||||
var anchor = levelAsOf ?? latest.Value;
|
||||
var daysToEmpty = level / averagePerDay;
|
||||
// Guard against absurd horizons (near-zero burn) that overflow DateTime.
|
||||
var forecast = daysToEmpty < 365 * 100
|
||||
? DateOnly.FromDateTime(anchor.UtcDateTime.AddDays(daysToEmpty))
|
||||
: (DateOnly?)null;
|
||||
return (averagePerDay, forecast);
|
||||
}
|
||||
|
||||
private static async Task<double> SumConsumptionAsync(
|
||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) =>
|
||||
await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Time >= from && c.Time < to)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
|
||||
private static async Task<IReadOnlyList<ConsumableMonth>> MonthlyConsumptionAsync(
|
||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql =
|
||||
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
|
||||
"sum(amount) AS amount " +
|
||||
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||
"GROUP BY period ORDER BY period";
|
||||
|
||||
var connection = db.Database.GetDbConnection();
|
||||
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
|
||||
return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList();
|
||||
}
|
||||
|
||||
private static double ToVolume(MeterEvent level, MeterVault.Core.Normalization.CalibrationCurve? calibration)
|
||||
{
|
||||
var value = level.Amount ?? 0;
|
||||
var isCentimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase);
|
||||
return isCentimetres && calibration is not null ? calibration.ToVolume(value) : value;
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
|
||||
public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId);
|
||||
|
||||
/// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary>
|
||||
public sealed record FlowLink(string From, string To, double Value);
|
||||
|
||||
/// <summary>
|
||||
/// The per-energy-type flow graph (SDD-style topology view): meters as nodes sized by consumption,
|
||||
/// directed edges sized by the flow along each configured link, plus "Other" remainders where an
|
||||
/// upstream meter's flow isn't fully accounted for by its sub-meters. Rendered as a Sankey diagram.
|
||||
/// </summary>
|
||||
public sealed record FlowGraph(
|
||||
short EnergyTypeId,
|
||||
string EnergyType,
|
||||
string Unit,
|
||||
double Total,
|
||||
IReadOnlyList<FlowNode> Nodes,
|
||||
IReadOnlyList<FlowLink> Links)
|
||||
{
|
||||
public bool HasData => Nodes.Count > 0;
|
||||
|
||||
/// <summary>True when meters are actually chained (not just a flat, unlinked list).</summary>
|
||||
public bool HasChain => Links.Count > 0;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-energy-type flow graph (Sankey) from the meter topology (<see cref="MeterLink"/>)
|
||||
/// and consumption over a period. Each meter is a node sized by its consumption; each configured
|
||||
/// edge carries the downstream meter's consumption (split proportionally when a meter has several
|
||||
/// upstreams); the unaccounted remainder under a meter becomes a synthetic "Other" node. Nothing is
|
||||
/// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory
|
||||
/// keeps it Blazor-circuit safe.
|
||||
/// </summary>
|
||||
public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private const double Epsilon = 0.01;
|
||||
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<FlowGraph> GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var energyType = await db.EnergyTypes.AsNoTracking().FirstOrDefaultAsync(t => t.Id == energyTypeId, cancellationToken).ConfigureAwait(false);
|
||||
var meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (energyType is null || meters.Count == 0)
|
||||
{
|
||||
return new FlowGraph(energyTypeId, energyType?.DisplayName ?? "", energyType?.BaseUnit ?? "", 0, [], []);
|
||||
}
|
||||
|
||||
var meterIds = meters.Select(m => m.Id).ToHashSet();
|
||||
var fromUtc = ToUtc(from);
|
||||
var toUtc = ToUtc(to);
|
||||
|
||||
// A meter's flow value is its throughput: consumption OR generation output — so a generation
|
||||
// meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter
|
||||
// is normally one kind, so summing both kinds is that meter's flow. Negatives (savings/balance
|
||||
// virtual meters) are clamped to 0 — a flow ribbon can't be negative.
|
||||
var sums = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.Time >= fromUtc && c.Time < toUtc)
|
||||
.GroupBy(c => c.MeterId)
|
||||
.Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) })
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => Math.Max(0, s.Total));
|
||||
double V(int id) => value.GetValueOrDefault(id);
|
||||
|
||||
var links = await db.MeterLinks.AsNoTracking()
|
||||
.Where(l => meterIds.Contains(l.FromMeterId) && meterIds.Contains(l.ToMeterId))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var parents = meters.ToDictionary(m => m.Id, _ => new List<int>());
|
||||
var children = meters.ToDictionary(m => m.Id, _ => new List<int>());
|
||||
foreach (var link in links)
|
||||
{
|
||||
children[link.FromMeterId].Add(link.ToMeterId);
|
||||
parents[link.ToMeterId].Add(link.FromMeterId);
|
||||
}
|
||||
|
||||
var depth = ComputeDepths(meters.Select(m => m.Id).ToList(), parents, children);
|
||||
|
||||
// Aggregate ("sum") meters: a Virtual-mode meter has no measurements of its own — in the flow
|
||||
// it is the sum of its upstream meters (e.g. "Sum Solar" = Solar 1 + Solar 2). Resolve these in
|
||||
// topological (depth) order so each aggregate sees its already-resolved upstream values.
|
||||
var aggregates = meters.Where(m => m.Mode == MeterMode.Virtual).Select(m => m.Id).ToHashSet();
|
||||
foreach (var id in meters.Select(m => m.Id).OrderBy(id => depth.GetValueOrDefault(id)))
|
||||
{
|
||||
if (aggregates.Contains(id))
|
||||
{
|
||||
value[id] = parents[id].Sum(V);
|
||||
}
|
||||
}
|
||||
|
||||
// Link value: a child's consumption flows in from its parent(s); with several parents it is
|
||||
// split proportionally to the parents' own consumption (equal split if those are all zero).
|
||||
var flowLinks = new List<FlowLink>();
|
||||
var outgoingByParent = meters.ToDictionary(m => m.Id, _ => 0d);
|
||||
foreach (var (childId, parentIds) in parents)
|
||||
{
|
||||
if (parentIds.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parentTotal = parentIds.Sum(V);
|
||||
foreach (var parentId in parentIds)
|
||||
{
|
||||
var share = parentIds.Count == 1 ? 1d
|
||||
: parentTotal > Epsilon ? V(parentId) / parentTotal
|
||||
: 1d / parentIds.Count;
|
||||
var linkValue = V(childId) * share;
|
||||
if (linkValue > Epsilon)
|
||||
{
|
||||
flowLinks.Add(new FlowLink(NodeId(parentId), NodeId(childId), linkValue));
|
||||
outgoingByParent[parentId] += linkValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nodes = new List<FlowNode>();
|
||||
foreach (var meter in meters)
|
||||
{
|
||||
// Keep a meter node if it carries flow or participates in the topology.
|
||||
if (V(meter.Id) <= Epsilon && children[meter.Id].Count == 0 && parents[meter.Id].Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.Add(new FlowNode(NodeId(meter.Id), meter.Name, V(meter.Id), depth.GetValueOrDefault(meter.Id), energyType.ColorHex, false, meter.Id));
|
||||
|
||||
// Unaccounted remainder under a meter with sub-meters → "Other".
|
||||
if (children[meter.Id].Count > 0)
|
||||
{
|
||||
var remainder = V(meter.Id) - outgoingByParent[meter.Id];
|
||||
if (remainder > Epsilon)
|
||||
{
|
||||
var otherId = $"other{meter.Id}";
|
||||
nodes.Add(new FlowNode(otherId, $"Other ({meter.Name})", remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
|
||||
flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var total = meters.Where(m => parents[m.Id].Count == 0).Sum(m => V(m.Id));
|
||||
return new FlowGraph(energyTypeId, energyType.DisplayName, energyType.BaseUnit, total, nodes, flowLinks);
|
||||
}
|
||||
|
||||
/// <summary>Longest-path depth from the roots (Kahn topological relaxation); robust to stray cycles.</summary>
|
||||
private static Dictionary<int, int> ComputeDepths(
|
||||
List<int> ids, Dictionary<int, List<int>> parents, Dictionary<int, List<int>> children)
|
||||
{
|
||||
var depth = ids.ToDictionary(id => id, _ => 0);
|
||||
var indegree = ids.ToDictionary(id => id, id => parents[id].Count);
|
||||
var queue = new Queue<int>(ids.Where(id => indegree[id] == 0));
|
||||
var processed = 0;
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var node = queue.Dequeue();
|
||||
processed++;
|
||||
foreach (var child in children[node])
|
||||
{
|
||||
depth[child] = Math.Max(depth[child], depth[node] + 1);
|
||||
if (--indegree[child] == 0)
|
||||
{
|
||||
queue.Enqueue(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any nodes left (a cycle) keep depth 0 — the admin prevents cycles, this is just a guard.
|
||||
return depth;
|
||||
}
|
||||
|
||||
private static string NodeId(int meterId) => $"m{meterId}";
|
||||
|
||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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>A meter lifecycle/correction event row.</summary>
|
||||
public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
|
||||
|
||||
/// <summary>A tariff applicable to the meter (own / energy-type / global scope), for the timeline.</summary>
|
||||
public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
|
||||
|
||||
/// <summary>
|
||||
/// The meter-detail read model (SDD §8.6): identity, register span, totals, recent raw readings
|
||||
/// and normalized consumption (measured-vs-estimated markers via quality), the applicable tariff
|
||||
/// timeline, and lifecycle events (swaps/deliveries/corrections). Source management loads the
|
||||
/// source entities directly (they are editable), so it is not part of this read model.
|
||||
/// </summary>
|
||||
public sealed record MeterDetailView(
|
||||
int Id,
|
||||
string Name,
|
||||
string EnergyType,
|
||||
MeterMode Mode,
|
||||
string Unit,
|
||||
string? Location,
|
||||
string? SerialNumber,
|
||||
string? Manufacturer,
|
||||
string? Model,
|
||||
double InitialBaseline,
|
||||
bool IsActive,
|
||||
int ReadingCount,
|
||||
int ConsumptionCount,
|
||||
DateTimeOffset? FirstReadingTime,
|
||||
DateTimeOffset? LastReadingTime,
|
||||
double? FirstReadingValue,
|
||||
double? LastReadingValue,
|
||||
double TotalConsumption,
|
||||
double TotalGeneration,
|
||||
IReadOnlyList<ReadingRow> RecentReadings,
|
||||
IReadOnlyList<ConsumptionDetailRow> RecentConsumption,
|
||||
IReadOnlyList<EventRow> Events,
|
||||
IReadOnlyList<TariffRow> Tariffs,
|
||||
int SourceCount);
|
||||
@@ -0,0 +1,83 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Read model for the meter-detail view (SDD §8.6). Bounds the raw-reading and consumption pulls
|
||||
/// (this is the one place the UI touches raw rows) and gathers source status, the applicable tariff
|
||||
/// timeline and lifecycle events. DbContext factory keeps it Blazor-circuit safe.
|
||||
/// </summary>
|
||||
public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private const int MaxRows = 200;
|
||||
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<MeterDetailView?> GetAsync(int meterId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meter = await db.Meters.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
.Include(m => m.Sources)
|
||||
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||
if (meter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var readingCount = await db.Readings.AsNoTracking().CountAsync(r => r.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
||||
var consumptionCount = await db.Consumption.AsNoTracking().CountAsync(c => c.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var first = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
||||
.OrderBy(r => r.Time).Select(r => new { r.Time, r.Value })
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
var last = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
||||
.OrderByDescending(r => r.Time).Select(r => new { r.Time, r.Value })
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var totalConsumption = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Consumption)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
var totalGeneration = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Generation)
|
||||
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||
|
||||
var recentReadings = await db.Readings.AsNoTracking()
|
||||
.Where(r => r.MeterId == meterId)
|
||||
.OrderByDescending(r => r.Time).Take(MaxRows)
|
||||
.Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var recentConsumption = await db.Consumption.AsNoTracking()
|
||||
.Where(c => c.MeterId == meterId)
|
||||
.OrderByDescending(c => c.Time).Take(MaxRows)
|
||||
.Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var events = await db.MeterEvents.AsNoTracking()
|
||||
.Where(e => e.MeterId == meterId)
|
||||
.OrderByDescending(e => e.Time)
|
||||
.Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var energyTypeId = meter.EnergyTypeId;
|
||||
var tariffs = await db.Tariffs.AsNoTracking()
|
||||
.Where(t => t.ScopeType == TariffScope.Global
|
||||
|| (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId)
|
||||
|| (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId))
|
||||
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
||||
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new MeterDetailView(
|
||||
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
|
||||
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
|
||||
readingCount, consumptionCount,
|
||||
first?.Time, last?.Time, first?.Value, last?.Value,
|
||||
totalConsumption, totalGeneration,
|
||||
recentReadings, recentConsumption, events, tariffs, meter.Sources.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>One month of the PV panel: generation, and (when role-tagged meters exist) the
|
||||
/// self-consumption / grid-draw / savings split that reproduces the sheet's Netz-Einsparung column.</summary>
|
||||
public sealed record SolarMonth(
|
||||
DateOnly Period,
|
||||
double Generation,
|
||||
double? SelfConsumption,
|
||||
double? GridImport,
|
||||
double? TotalLoad,
|
||||
double? Savings);
|
||||
|
||||
/// <summary>Per-generation-meter total over the selected period (for the ranked list).</summary>
|
||||
public sealed record GenerationMeterRow(int MeterId, string Name, double Generation);
|
||||
|
||||
/// <summary>
|
||||
/// The PV / solar panel read model (SDD §8.4): total generation plus, when the install has tagged
|
||||
/// a <c>total_load</c> and <c>grid_import</c> meter, self-consumption, autarky %, self-consumption %
|
||||
/// and savings (Ersparnis). Derived metrics are null when no role config exists.
|
||||
/// </summary>
|
||||
public sealed record SolarSummary(
|
||||
double Generation,
|
||||
double? TotalLoad,
|
||||
double? GridImport,
|
||||
double? SelfConsumption,
|
||||
double? Autarky,
|
||||
double? SelfConsumptionRatio,
|
||||
double? Savings,
|
||||
IReadOnlyList<GenerationMeterRow> Meters,
|
||||
IReadOnlyList<SolarMonth> Months)
|
||||
{
|
||||
/// <summary>True when the install has the role-tagged meters needed for self-consumption metrics.</summary>
|
||||
public bool HasLoadContext => TotalLoad is not null && GridImport is not null;
|
||||
|
||||
/// <summary>True when at least one generation meter exists.</summary>
|
||||
public bool HasGeneration => Meters.Count > 0;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using Dapper;
|
||||
using MeterVault.Core.Costing;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Read model for the PV / solar panel (SDD §8.4). Generation comes from every
|
||||
/// <see cref="MeterMode.GenerationCounter"/> meter; self-consumption / autarky / savings are derived
|
||||
/// from the meters tagged <see cref="MeterRoles.TotalLoad"/> and <see cref="MeterRoles.GridImport"/>
|
||||
/// — so nothing is hardcoded by meter name. Reads only the aggregated consumption hypertable
|
||||
/// (monthly, Europe/Berlin) via Dapper; safe from a Blazor circuit via a DbContext factory.
|
||||
/// </summary>
|
||||
public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||
{
|
||||
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||
|
||||
public async Task<SolarSummary> GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var meters = await db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var generationMeters = meters.Where(m => m.Mode == MeterMode.GenerationCounter).ToList();
|
||||
var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad);
|
||||
var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport);
|
||||
|
||||
var fromUtc = ToUtc(from);
|
||||
var toUtc = ToUtc(to);
|
||||
|
||||
// Monthly generation per generation meter.
|
||||
var genByMeter = new Dictionary<int, IReadOnlyDictionary<DateOnly, double>>();
|
||||
foreach (var meter in generationMeters)
|
||||
{
|
||||
genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var loadByMonth = loadMeter is null
|
||||
? null
|
||||
: await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
var gridByMonth = gridMeter is null
|
||||
? null
|
||||
: await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tariffs = gridMeter is null
|
||||
? []
|
||||
: await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Union of all months that carry any data.
|
||||
var periods = new SortedSet<DateOnly>();
|
||||
foreach (var series in genByMeter.Values)
|
||||
{
|
||||
periods.UnionWith(series.Keys);
|
||||
}
|
||||
|
||||
if (loadByMonth is not null)
|
||||
{
|
||||
periods.UnionWith(loadByMonth.Keys);
|
||||
}
|
||||
|
||||
var months = new List<SolarMonth>();
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var generation = genByMeter.Values.Sum(s => s.GetValueOrDefault(period));
|
||||
|
||||
double? load = loadByMonth?.GetValueOrDefault(period);
|
||||
double? grid = gridByMonth?.GetValueOrDefault(period);
|
||||
double? self = load is not null && grid is not null ? load - grid : null;
|
||||
|
||||
double? savings = null;
|
||||
if (self is { } selfValue && gridMeter is not null)
|
||||
{
|
||||
var price = TariffResolver.ResolveValue(
|
||||
tariffs, TariffComponent.UnitPrice, gridMeter.Id, gridMeter.EnergyTypeId,
|
||||
new DateOnly(period.Year, period.Month, 15));
|
||||
savings = selfValue * price;
|
||||
}
|
||||
|
||||
months.Add(new SolarMonth(period, generation, self, grid, load, savings));
|
||||
}
|
||||
|
||||
var meterRows = generationMeters
|
||||
.Select(m => new GenerationMeterRow(m.Id, m.Name, genByMeter[m.Id].Values.Sum()))
|
||||
.OrderByDescending(r => r.Generation)
|
||||
.ToList();
|
||||
|
||||
var totalGeneration = meterRows.Sum(r => r.Generation);
|
||||
double? totalLoad = loadByMonth?.Values.Sum();
|
||||
double? totalGrid = gridByMonth?.Values.Sum();
|
||||
double? totalSelf = totalLoad is not null && totalGrid is not null ? totalLoad - totalGrid : null;
|
||||
double? autarky = totalSelf is not null && totalLoad is > 0 ? totalSelf / totalLoad : null;
|
||||
double? selfRatio = totalSelf is not null && totalGeneration > 0 ? totalSelf / totalGeneration : null;
|
||||
double? totalSavings = months.Any(m => m.Savings is not null) ? months.Sum(m => m.Savings ?? 0) : null;
|
||||
|
||||
return new SolarSummary(
|
||||
totalGeneration, totalLoad, totalGrid, totalSelf, autarky, selfRatio, totalSavings, meterRows, months);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyDictionary<DateOnly, double>> MonthlyAsync(
|
||||
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql =
|
||||
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
|
||||
"sum(amount) AS amount " +
|
||||
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||
"GROUP BY period";
|
||||
|
||||
var connection = db.Database.GetDbConnection();
|
||||
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
||||
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
|
||||
return rows.ToDictionary(r => r.Period, r => r.Amount);
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
}
|
||||
@@ -33,8 +33,16 @@ public static class DependencyInjection
|
||||
services.AddScoped<ReferenceDataImporter>();
|
||||
services.AddScoped<IngestionService>();
|
||||
services.AddScoped<MqttMessageRouter>();
|
||||
// HttpClient + HA tester are available even with live ingestion off, so the admin
|
||||
// "Test connection" works without the background workers running.
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<HaConnectionTester>();
|
||||
services.AddScoped<Costing.CostService>();
|
||||
services.AddScoped<Dashboard.DashboardService>();
|
||||
services.AddScoped<Dashboard.SolarService>();
|
||||
services.AddScoped<Dashboard.ConsumableService>();
|
||||
services.AddScoped<Dashboard.MeterDetailService>();
|
||||
services.AddScoped<Dashboard.FlowService>();
|
||||
services.AddScoped<Backup.ExportService>();
|
||||
|
||||
return services;
|
||||
@@ -49,6 +57,7 @@ public static class DependencyInjection
|
||||
services.AddHttpClient();
|
||||
services.AddHostedService<MqttIngestionWorker>();
|
||||
services.AddHostedService<HomeAssistantWorker>();
|
||||
services.AddHostedService<HomeAssistantWebSocketWorker>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,12 @@ public sealed class CsvImporter
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the raw CSV grid (no profile applied) so the import wizard can show a column preview
|
||||
/// and let the user map columns before staging. Rows are ragged (their own field counts).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string[]> ReadRawRows(TextReader reader) => ReadRows(reader);
|
||||
|
||||
internal static List<string[]> ReadRows(TextReader reader)
|
||||
{
|
||||
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n
|
||||
StagedImport staged, string? sourceName, string? mappingJson, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(staged);
|
||||
GuardDuplicateReadings(staged);
|
||||
|
||||
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -89,6 +90,31 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n
|
||||
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reading is keyed by (meter, time), so a staged set holding two rows for one meter at one
|
||||
/// timestamp cannot be written. Usually a mapping that points two source columns at one meter —
|
||||
/// report it in those terms instead of letting EF surface a change-tracker error.
|
||||
/// </summary>
|
||||
private static void GuardDuplicateReadings(StagedImport staged)
|
||||
{
|
||||
var duplicates = staged.Readings
|
||||
.GroupBy(r => (r.MeterId, r.Time))
|
||||
.Where(g => g.Count() > 1)
|
||||
.ToList();
|
||||
|
||||
if (duplicates.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sample = string.Join("; ", duplicates.Take(3)
|
||||
.Select(g => $"meter {g.Key.MeterId} at {g.Key.Time:yyyy-MM-dd}"));
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"{duplicates.Count} duplicate reading(s): the same meter is written twice at the same " +
|
||||
$"timestamp ({sample}). Check that no two mapped columns target the same meter.");
|
||||
}
|
||||
|
||||
private static IEnumerable<int> AffectedMeters(StagedImport staged) =>
|
||||
staged.Readings.Select(r => r.MeterId)
|
||||
.Concat(staged.Events.Select(e => e.MeterId))
|
||||
|
||||
@@ -33,6 +33,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail fast BEFORE creating the marker meter: if the CSVs are missing (e.g. not shipped in
|
||||
// the image) we must not seed a half-loaded dataset that IsLoadedAsync then reports as done.
|
||||
EnsureSampleFilesPresent(sampleDataDirectory);
|
||||
|
||||
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false);
|
||||
@@ -47,8 +51,15 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
|
||||
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
|
||||
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
|
||||
// A virtual "sum" meter: no readings of its own — in the flow view it equals Solar 1 + Solar 2.
|
||||
var sumSolar = Meter("Summe Solar", electricity, MeterMode.Virtual, "kWh");
|
||||
|
||||
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner);
|
||||
// Tag the PV meters' roles (config, not hardcoded names) so the Solar panel can derive
|
||||
// self-consumption = total_load − grid_import and savings generically (SDD §8.4).
|
||||
haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad);
|
||||
netz.Meta = MeterMeta.WithRole(netz.Meta, MeterRoles.GridImport);
|
||||
|
||||
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner, sumSolar);
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_db.Tanks.Add(new Tank
|
||||
@@ -59,6 +70,16 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
|
||||
});
|
||||
|
||||
// Demo flow chain (electricity /energy view): Solar 1 + Solar 2 → Summe Solar; then
|
||||
// Grid + Summe Solar → Haus → Auto + "Other". The remainder under Grid/Summe Solar is the
|
||||
// input that didn't reach the house load (solar export / battery / inverter losses).
|
||||
_db.MeterLinks.AddRange(
|
||||
new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id },
|
||||
new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id },
|
||||
new MeterLink { FromMeterId = netz.Id, ToMeterId = haus.Id },
|
||||
new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = haus.Id },
|
||||
new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id });
|
||||
|
||||
AddElectricityTariffs(electricity);
|
||||
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
|
||||
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
|
||||
@@ -86,12 +107,32 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
||||
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
|
||||
};
|
||||
|
||||
private static readonly string[] RequiredFiles = [ElectricityFile, WaterFile, OilFile, CostsFile];
|
||||
|
||||
/// <summary>Throws a clear error if the sample directory or any reference CSV is missing, so a
|
||||
/// failed load surfaces to the user instead of silently seeding meters with no data.</summary>
|
||||
private static void EnsureSampleFilesPresent(string sampleDataDirectory)
|
||||
{
|
||||
if (!Directory.Exists(sampleDataDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException(
|
||||
$"Reference-data directory not found: '{sampleDataDirectory}'. The bundled Energiebilanz CSVs are missing from this deployment.");
|
||||
}
|
||||
|
||||
var missing = RequiredFiles.Where(f => !File.Exists(Path.Combine(sampleDataDirectory, f))).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Reference CSV(s) missing from '{sampleDataDirectory}': {string.Join(", ", missing)}.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = Path.Combine(dir, file);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
throw new FileNotFoundException($"Reference CSV disappeared during import: '{path}'.", path);
|
||||
}
|
||||
|
||||
StagedImport staged;
|
||||
|
||||
@@ -46,6 +46,8 @@ public sealed record EndpointConfig
|
||||
}
|
||||
}
|
||||
|
||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||
|
||||
public string? ResolveUsername() => Resolve(UsernameEnv);
|
||||
|
||||
public string? ResolvePassword() => Resolve(PasswordEnv);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>Outcome of a Home Assistant connectivity test.</summary>
|
||||
public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a Home Assistant connection from the admin UI: checks the base URL + resolved 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). Token is resolved by reference (env var).
|
||||
/// </summary>
|
||||
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? tokenEnv, string? entityId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return new HaTestResult(false, "Base URL is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenEnv))
|
||||
{
|
||||
return new HaTestResult(false, "Token env-var name is required (the token is resolved from it at runtime).");
|
||||
}
|
||||
|
||||
var token = Environment.GetEnvironmentVariable(tokenEnv);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new HaTestResult(false, $"Environment variable '{tokenEnv}' is not set on the server.");
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl.TrimEnd('/')}/api/");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entityId))
|
||||
{
|
||||
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.");
|
||||
}
|
||||
|
||||
var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false);
|
||||
return state is { } value
|
||||
? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value)
|
||||
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl);
|
||||
return new HaTestResult(false, $"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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 stored by reference only: <see cref="TokenEnv"/>
|
||||
/// names an environment variable resolved at runtime — never the token itself (SDD §6.4).
|
||||
/// </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>
|
||||
/// 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 from the referenced environment variable (null if unset).</summary>
|
||||
public string? ResolveToken() =>
|
||||
string.IsNullOrWhiteSpace(TokenEnv) ? null : Environment.GetEnvironmentVariable(TokenEnv);
|
||||
}
|
||||
@@ -31,10 +31,16 @@ public sealed class HaStateClient(HttpClient httpClient)
|
||||
return ParseState(await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false), attribute);
|
||||
}
|
||||
|
||||
internal static HaState? ParseState(JsonDocument document, string? attribute)
|
||||
{
|
||||
var root = document.RootElement;
|
||||
internal static HaState? ParseState(JsonDocument document, string? attribute) =>
|
||||
ParseStateElement(document.RootElement, attribute);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a numeric value + last-updated time from a Home Assistant state object (the REST
|
||||
/// entity payload, or a <c>new_state</c> from a WebSocket <c>state_changed</c> event — they share
|
||||
/// the same shape). Returns null for <c>unavailable</c>/<c>unknown</c>/non-numeric states.
|
||||
/// </summary>
|
||||
internal static HaState? ParseStateElement(JsonElement root, string? attribute)
|
||||
{
|
||||
double value;
|
||||
if (string.IsNullOrWhiteSpace(attribute))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the Home Assistant WebSocket API (<c>/api/websocket</c>): the auth handshake
|
||||
/// (<c>auth_required</c> → <c>auth</c> → <c>auth_ok</c>), subscribing to <c>state_changed</c> events,
|
||||
/// and reading the entity id + <c>new_state</c> out of an event frame. No I/O — the transport lives
|
||||
/// in <see cref="HomeAssistantWebSocketWorker"/>, which keeps this unit-testable.
|
||||
/// </summary>
|
||||
internal static class HaWebSocketProtocol
|
||||
{
|
||||
/// <summary>Derives the WebSocket endpoint from an HTTP base URL (http→ws, https→wss, path <c>/api/websocket</c>).</summary>
|
||||
public static Uri WebSocketUri(string baseUrl)
|
||||
{
|
||||
var http = new Uri(baseUrl.TrimEnd('/') + "/api/websocket", UriKind.Absolute);
|
||||
var scheme = http.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? "wss" : "ws";
|
||||
return new UriBuilder(http) { Scheme = scheme }.Uri;
|
||||
}
|
||||
|
||||
public static string AuthMessage(string token) =>
|
||||
JsonSerializer.Serialize(new { type = "auth", access_token = token });
|
||||
|
||||
public static string SubscribeStateChanged(int id) =>
|
||||
JsonSerializer.Serialize(new { id, type = "subscribe_events", event_type = "state_changed" });
|
||||
|
||||
public static string? MessageType(JsonElement root) =>
|
||||
root.TryGetProperty("type", out var type) && type.ValueKind == JsonValueKind.String ? type.GetString() : null;
|
||||
|
||||
public static bool IsAuthRequired(JsonElement root) => MessageType(root) == "auth_required";
|
||||
|
||||
public static bool IsAuthOk(JsonElement root) => MessageType(root) == "auth_ok";
|
||||
|
||||
public static bool IsAuthInvalid(JsonElement root) => MessageType(root) == "auth_invalid";
|
||||
|
||||
/// <summary>
|
||||
/// If <paramref name="root"/> is a <c>state_changed</c> event with a numeric-capable
|
||||
/// <c>new_state</c> object, yields the entity id and that state object. The caller extracts the
|
||||
/// numeric value (state or a named attribute) per source via
|
||||
/// <see cref="HaStateClient.ParseStateElement"/>.
|
||||
/// </summary>
|
||||
public static bool TryReadStateChanged(JsonElement root, out string entityId, out JsonElement newState)
|
||||
{
|
||||
entityId = string.Empty;
|
||||
newState = default;
|
||||
|
||||
if (MessageType(root) != "event"
|
||||
|| !root.TryGetProperty("event", out var evt)
|
||||
|| !evt.TryGetProperty("event_type", out var evtType)
|
||||
|| evtType.GetString() != "state_changed"
|
||||
|| !evt.TryGetProperty("data", out var data))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("entity_id", out var id) || id.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.TryGetProperty("new_state", out var state) || state.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false; // entity removed (new_state null) — nothing to ingest.
|
||||
}
|
||||
|
||||
entityId = id.GetString()!;
|
||||
newState = state;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time Home Assistant ingestion via the WebSocket API (SDD §6.2 push path). For each enabled
|
||||
/// HA endpoint whose config sets <c>UseWebSocket</c>, holds a persistent connection that authenticates,
|
||||
/// subscribes to <c>state_changed</c> events, and ingests changes for the endpoint's configured
|
||||
/// entities as they happen. Reconnects with capped backoff. Endpoints without <c>UseWebSocket</c> stay
|
||||
/// on the REST poll worker (<see cref="HomeAssistantWorker"/>) — each endpoint is served by exactly one.
|
||||
/// Tokens are resolved from environment variables named in the endpoint config, never stored plaintext.
|
||||
/// </summary>
|
||||
public sealed class HomeAssistantWebSocketWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<HomeAssistantWebSocketWorker> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan SuperviseInterval = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan InitialBackoff = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan MaxBackoff = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan EntityMapTtl = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||
private readonly ILogger<HomeAssistantWebSocketWorker> _logger = logger;
|
||||
private readonly ConcurrentDictionary<int, Task> _connections = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(SuperviseInterval);
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
await SuperviseAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Home Assistant WebSocket supervisor tick failed; will retry");
|
||||
}
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>Starts a connection loop for each WebSocket-enabled endpoint that isn't already running.</summary>
|
||||
private async Task SuperviseAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
List<int> wsEndpointIds;
|
||||
await using (var scope = _scopeFactory.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||
var endpoints = await db.IngestionEndpoints
|
||||
.Where(e => e.IsEnabled && e.Type == EndpointType.HomeAssistant)
|
||||
.ToListAsync(stoppingToken).ConfigureAwait(false);
|
||||
wsEndpointIds = endpoints
|
||||
.Where(e => HaEndpointConfig.Parse(e.Config).UseWebSocket)
|
||||
.Select(e => e.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Reap finished connection loops (endpoint disabled/removed, or terminal failure).
|
||||
foreach (var id in _connections.Where(kv => kv.Value.IsCompleted).Select(kv => kv.Key).ToList())
|
||||
{
|
||||
_connections.TryRemove(id, out _);
|
||||
}
|
||||
|
||||
foreach (var endpointId in wsEndpointIds)
|
||||
{
|
||||
// Fire-and-forget: the loop stores its own Task in _connections and self-terminates when
|
||||
// the endpoint is disabled/removed; the supervisor reaps completed entries above.
|
||||
_ = _connections.GetOrAdd(endpointId, id => Task.Run(() => RunConnectionAsync(id, stoppingToken), stoppingToken));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Connect → listen → reconnect loop for one endpoint, until it is disabled or the app stops.</summary>
|
||||
private async Task RunConnectionAsync(int endpointId, CancellationToken stoppingToken)
|
||||
{
|
||||
var backoff = InitialBackoff;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
HaEndpointConfig config;
|
||||
string? token;
|
||||
await using (var scope = _scopeFactory.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||
var endpoint = await db.IngestionEndpoints
|
||||
.FirstOrDefaultAsync(e => e.Id == endpointId, stoppingToken).ConfigureAwait(false);
|
||||
if (endpoint is null || !endpoint.IsEnabled)
|
||||
{
|
||||
return; // gone/disabled — stop; the supervisor will restart it if it comes back.
|
||||
}
|
||||
|
||||
config = HaEndpointConfig.Parse(endpoint.Config);
|
||||
if (!config.UseWebSocket)
|
||||
{
|
||||
return; // switched to poll mode.
|
||||
}
|
||||
|
||||
token = config.ResolveToken();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_logger.LogWarning("HA WebSocket endpoint {EndpointId} missing base URL or token; retrying", endpointId);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
await ListenAsync(endpointId, config.BaseUrl!, token!, stoppingToken).ConfigureAwait(false);
|
||||
backoff = InitialBackoff; // clean close → reset backoff.
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "HA WebSocket connection to endpoint {EndpointId} dropped; reconnecting", endpointId);
|
||||
await UpdateEndpointStatusAsync(endpointId, "disconnected", CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(backoff, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
backoff = TimeSpan.FromSeconds(Math.Min(MaxBackoff.TotalSeconds, backoff.TotalSeconds * 2));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ListenAsync(int endpointId, string baseUrl, string token, CancellationToken stoppingToken)
|
||||
{
|
||||
using var ws = new ClientWebSocket();
|
||||
await ws.ConnectAsync(HaWebSocketProtocol.WebSocketUri(baseUrl), stoppingToken).ConfigureAwait(false);
|
||||
|
||||
// Handshake: auth_required → auth → auth_ok.
|
||||
using (var required = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!HaWebSocketProtocol.IsAuthRequired(required.RootElement))
|
||||
{
|
||||
// Some setups may not send auth_required first; proceed to auth regardless.
|
||||
_logger.LogDebug("HA WebSocket did not send auth_required first (type={Type})", HaWebSocketProtocol.MessageType(required.RootElement));
|
||||
}
|
||||
}
|
||||
|
||||
await SendAsync(ws, HaWebSocketProtocol.AuthMessage(token), stoppingToken).ConfigureAwait(false);
|
||||
using (var authResult = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!HaWebSocketProtocol.IsAuthOk(authResult.RootElement))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Home Assistant WebSocket auth failed (type={HaWebSocketProtocol.MessageType(authResult.RootElement)}).");
|
||||
}
|
||||
}
|
||||
|
||||
await SendAsync(ws, HaWebSocketProtocol.SubscribeStateChanged(1), stoppingToken).ConfigureAwait(false);
|
||||
await UpdateEndpointStatusAsync(endpointId, "connected (ws)", stoppingToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("HA WebSocket connected for endpoint {EndpointId}", endpointId);
|
||||
|
||||
var entityMap = await LoadEntityMapAsync(endpointId, stoppingToken).ConfigureAwait(false);
|
||||
var mapLoadedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested && ws.State == WebSocketState.Open)
|
||||
{
|
||||
using var doc = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false);
|
||||
if (!HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out var entityId, out var newState))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.UtcNow - mapLoadedAt > EntityMapTtl)
|
||||
{
|
||||
entityMap = await LoadEntityMapAsync(endpointId, stoppingToken).ConfigureAwait(false);
|
||||
mapLoadedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
if (!entityMap.TryGetValue(entityId, out var sources))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (sourceId, attribute) in sources)
|
||||
{
|
||||
if (HaStateClient.ParseStateElement(newState, attribute) is { } state)
|
||||
{
|
||||
await IngestAsync(sourceId, state, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps each configured entity id to the sources (and optional attribute) that read it.</summary>
|
||||
private async Task<Dictionary<string, List<(int SourceId, string? Attribute)>>> LoadEntityMapAsync(
|
||||
int endpointId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||
var sources = await db.MeterSources.AsNoTracking()
|
||||
.Where(s => s.IsEnabled && s.SourceType == SourceType.HomeAssistant && s.EndpointId == endpointId)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var map = new Dictionary<string, List<(int, string?)>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var config = SourceConfig.Parse(source.Config);
|
||||
if (string.IsNullOrWhiteSpace(config.EntityId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!map.TryGetValue(config.EntityId, out var list))
|
||||
{
|
||||
map[config.EntityId] = list = [];
|
||||
}
|
||||
|
||||
list.Add((source.Id, config.Attribute));
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private async Task IngestAsync(int sourceId, HaState state, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
await ingestion.IngestAsync(sourceId, state.Time, state.Value, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpdateEndpointStatusAsync(int endpointId, string status, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
|
||||
var endpoint = await db.IngestionEndpoints
|
||||
.FirstOrDefaultAsync(e => e.Id == endpointId, cancellationToken).ConfigureAwait(false);
|
||||
if (endpoint is not null)
|
||||
{
|
||||
endpoint.LastStatus = status;
|
||||
endpoint.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to update HA endpoint {EndpointId} status", endpointId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task SendAsync(ClientWebSocket ws, string json, CancellationToken cancellationToken) =>
|
||||
ws.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
|
||||
|
||||
/// <summary>Reads one (possibly fragmented) text message and parses it as JSON.</summary>
|
||||
private static async Task<JsonDocument> ReceiveJsonAsync(ClientWebSocket ws, CancellationToken cancellationToken)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[8192];
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await ws.ReceiveAsync(chunk, cancellationToken).ConfigureAwait(false);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Home Assistant closed the WebSocket ({result.CloseStatus}: {result.CloseStatusDescription}).");
|
||||
}
|
||||
|
||||
buffer.Write(chunk, 0, result.Count);
|
||||
}
|
||||
while (!result.EndOfMessage);
|
||||
|
||||
buffer.Position = 0;
|
||||
return await JsonDocument.ParseAsync(buffer, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -55,9 +54,14 @@ public sealed class HomeAssistantWorker(
|
||||
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||
var client = new HaStateClient(_httpClientFactory.CreateClient());
|
||||
|
||||
var endpoints = await db.IngestionEndpoints
|
||||
// Endpoints using the WebSocket push path are served by HomeAssistantWebSocketWorker; skip
|
||||
// them here so a source is never both polled and pushed.
|
||||
var enabled = await db.IngestionEndpoints
|
||||
.Where(e => e.IsEnabled && e.Type == EndpointType.HomeAssistant)
|
||||
.ToDictionaryAsync(e => e.Id, cancellationToken).ConfigureAwait(false);
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
var endpoints = enabled
|
||||
.Where(e => !HaEndpointConfig.Parse(e.Config).UseWebSocket)
|
||||
.ToDictionary(e => e.Id);
|
||||
if (endpoints.Count == 0)
|
||||
{
|
||||
return;
|
||||
@@ -99,10 +103,8 @@ public sealed class HomeAssistantWorker(
|
||||
HaStateClient client, IngestionService ingestion, IngestionEndpoint endpoint,
|
||||
MeterSource source, SourceConfig config, CancellationToken cancellationToken)
|
||||
{
|
||||
var endpointConfig = ParseHaEndpoint(endpoint.Config);
|
||||
var token = string.IsNullOrWhiteSpace(endpointConfig.TokenEnv)
|
||||
? null
|
||||
: Environment.GetEnvironmentVariable(endpointConfig.TokenEnv);
|
||||
var endpointConfig = HaEndpointConfig.Parse(endpoint.Config);
|
||||
var token = endpointConfig.ResolveToken();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(endpointConfig.BaseUrl) || string.IsNullOrWhiteSpace(token)
|
||||
|| string.IsNullOrWhiteSpace(config.EntityId))
|
||||
@@ -125,28 +127,4 @@ public sealed class HomeAssistantWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private static HaEndpoint ParseHaEndpoint(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new HaEndpoint();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<HaEndpoint>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new HaEndpoint();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new HaEndpoint();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HaEndpoint
|
||||
{
|
||||
public string? BaseUrl { get; init; }
|
||||
|
||||
public string? TokenEnv { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ public sealed class MeterVaultOptions
|
||||
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
|
||||
public bool RunMigrationsAtStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Load the bundled Energiebilanz reference dataset on startup if the database has none yet
|
||||
/// (idempotent — guarded by a marker meter). Off by default; set <c>MeterVault__SeedReferenceData=true</c>
|
||||
/// for a one-command populated demo/test instance.
|
||||
/// </summary>
|
||||
public bool SeedReferenceData { get; set; }
|
||||
|
||||
/// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary>
|
||||
public bool EnableLiveIngestion { get; set; } = true;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
|
||||
public DbSet<EnergyType> EnergyTypes => Set<EnergyType>();
|
||||
public DbSet<Meter> Meters => Set<Meter>();
|
||||
public DbSet<MeterSource> MeterSources => Set<MeterSource>();
|
||||
public DbSet<MeterLink> MeterLinks => Set<MeterLink>();
|
||||
public DbSet<Reading> Readings => Set<Reading>();
|
||||
public DbSet<Consumption> Consumption => Set<Consumption>();
|
||||
public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>();
|
||||
@@ -74,6 +75,16 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
|
||||
e.HasIndex(x => x.MeterId);
|
||||
});
|
||||
|
||||
b.Entity<MeterLink>(e =>
|
||||
{
|
||||
e.ToTable("meter_link");
|
||||
e.HasKey(x => x.Id);
|
||||
e.HasOne(x => x.FromMeter).WithMany().HasForeignKey(x => x.FromMeterId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(x => x.ToMeter).WithMany().HasForeignKey(x => x.ToMeterId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasIndex(x => new { x.FromMeterId, x.ToMeterId }).IsUnique();
|
||||
e.ToTable(t => t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id"));
|
||||
});
|
||||
|
||||
// Hypertable — the (meter_id, time) PK contains the partition column (time),
|
||||
// which Timescale requires. Converted to a hypertable in a raw-SQL migration.
|
||||
b.Entity<Reading>(e =>
|
||||
|
||||
+931
@@ -0,0 +1,931 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(MeterVaultDbContext))]
|
||||
[Migration("20260714114901_AddMeterLinks")]
|
||||
partial class AddMeterLinks
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Key")
|
||||
.HasName("pk_app_setting");
|
||||
|
||||
b.ToTable("app_setting", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<short>("Kind")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.HasKey("MeterId", "Time", "Kind")
|
||||
.HasName("pk_consumption");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_consumption_import_batch_id");
|
||||
|
||||
b.ToTable("consumption", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Sort")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category");
|
||||
|
||||
b.ToTable("cost_category", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<short?>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_cost_category_member");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_cost_category_member_category_id");
|
||||
|
||||
b.HasIndex("EnergyTypeId")
|
||||
.HasDatabaseName("ix_cost_category_member_energy_type_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_cost_category_member_meter_id");
|
||||
|
||||
b.ToTable("cost_category_member", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Property<short>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
|
||||
|
||||
b.Property<string>("BaseUnit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("base_unit");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("color_hex");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("DefaultMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("default_mode");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_energy_type");
|
||||
|
||||
b.HasIndex("Key")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_energy_type_key");
|
||||
|
||||
b.ToTable("energy_type", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Mapping")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("mapping");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevertedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("reverted_at");
|
||||
|
||||
b.Property<int>("RowCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("row_count");
|
||||
|
||||
b.Property<string>("SourceName")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("source_name");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_import_batch");
|
||||
|
||||
b.ToTable("import_batch", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_ingestion_endpoint");
|
||||
|
||||
b.ToTable("ingestion_endpoint", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<int?>("CategoryId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("category_id");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<int?>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<DateOnly>("PeriodEnd")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_end");
|
||||
|
||||
b.Property<DateOnly>("PeriodStart")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("period_start");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_manual_cost");
|
||||
|
||||
b.HasIndex("CategoryId")
|
||||
.HasDatabaseName("ix_manual_cost_category_id");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_manual_cost_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_manual_cost_meter_id");
|
||||
|
||||
b.ToTable("manual_cost", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<short>("EnergyTypeId")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("energy_type_id");
|
||||
|
||||
b.Property<double>("InitialBaseline")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("initial_baseline");
|
||||
|
||||
b.Property<DateOnly?>("InstalledAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("installed_at");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("location");
|
||||
|
||||
b.Property<string>("Manufacturer")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("manufacturer");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateOnly?>("RetiredAt")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("retired_at");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("serial_number");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter");
|
||||
|
||||
b.HasIndex("EnergyTypeId", "IsActive")
|
||||
.HasDatabaseName("ix_meter_energy_type_id_is_active");
|
||||
|
||||
b.ToTable("meter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<double?>("Amount")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("amount");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("event_type");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<string>("Meta")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("meta")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double?>("NewValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("new_value");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<double?>("PrevValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("prev_value");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_event");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_meter_event_import_batch_id");
|
||||
|
||||
b.HasIndex("MeterId", "Time")
|
||||
.HasDatabaseName("ix_meter_event_meter_id_time");
|
||||
|
||||
b.ToTable("meter_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("FromMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("from_meter_id");
|
||||
|
||||
b.Property<int>("ToMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("to_meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_link");
|
||||
|
||||
b.HasIndex("ToMeterId")
|
||||
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||
|
||||
b.HasIndex("FromMeterId", "ToMeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||
|
||||
b.ToTable("meter_link", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Config")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int?>("EndpointId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("endpoint_id");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("LastStatus")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("last_status");
|
||||
|
||||
b.Property<double?>("LastValue")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("last_value");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<double>("Offset")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("offset");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<double>("Scale")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("double precision")
|
||||
.HasDefaultValue(1.0)
|
||||
.HasColumnName("scale");
|
||||
|
||||
b.Property<string>("SourceType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("source_type");
|
||||
|
||||
b.Property<string>("ValueKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("value_kind");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_source");
|
||||
|
||||
b.HasIndex("EndpointId")
|
||||
.HasDatabaseName("ix_meter_source_endpoint_id");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.HasDatabaseName("ix_meter_source_meter_id");
|
||||
|
||||
b.ToTable("meter_source", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("time");
|
||||
|
||||
b.Property<int>("Flags")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("flags");
|
||||
|
||||
b.Property<int?>("ImportBatchId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("import_batch_id");
|
||||
|
||||
b.Property<short>("Quality")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("quality");
|
||||
|
||||
b.Property<int?>("SourceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("source_id");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("MeterId", "Time")
|
||||
.HasName("pk_reading");
|
||||
|
||||
b.HasIndex("ImportBatchId")
|
||||
.HasDatabaseName("ix_reading_import_batch_id");
|
||||
|
||||
b.ToTable("reading", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTimeOffset?>("CachedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("cached_at");
|
||||
|
||||
b.Property<double?>("CachedBalance")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("cached_balance");
|
||||
|
||||
b.Property<string>("Calibration")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("calibration");
|
||||
|
||||
b.Property<double>("Capacity")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("capacity");
|
||||
|
||||
b.Property<double?>("FixedRate")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("fixed_rate");
|
||||
|
||||
b.Property<double?>("LowThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("low_threshold");
|
||||
|
||||
b.Property<int>("MeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("meter_id");
|
||||
|
||||
b.Property<string>("RateMode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("rate_mode");
|
||||
|
||||
b.Property<double?>("ReorderThreshold")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("reorder_threshold");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tank");
|
||||
|
||||
b.HasIndex("MeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_tank_meter_id");
|
||||
|
||||
b.ToTable("tank", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Component")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("component");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)")
|
||||
.HasColumnName("currency");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<int?>("ScopeId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("scope_id");
|
||||
|
||||
b.Property<string>("ScopeType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("scope_type");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateOnly>("ValidFrom")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_from");
|
||||
|
||||
b.Property<DateOnly?>("ValidTo")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("valid_to");
|
||||
|
||||
b.Property<double>("Value")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("value");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_tariff");
|
||||
|
||||
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
|
||||
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
|
||||
|
||||
b.ToTable("tariff", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_consumption_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
|
||||
.WithMany("Members")
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_cost_category_member_meter_meter_id");
|
||||
|
||||
b.Navigation("Category");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_cost_category_category_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_manual_cost_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
|
||||
.WithMany("Meters")
|
||||
.HasForeignKey("EnergyTypeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_energy_type_energy_type_id");
|
||||
|
||||
b.Navigation("EnergyType");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("FromMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ToMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||
|
||||
b.Navigation("FromMeter");
|
||||
|
||||
b.Navigation("ToMeter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||
.WithMany()
|
||||
.HasForeignKey("EndpointId")
|
||||
.OnDelete(DeleteBehavior.SetNull)
|
||||
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany("Sources")
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_source_meter_meter_id");
|
||||
|
||||
b.Navigation("Endpoint");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_reading_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||
.WithMany()
|
||||
.HasForeignKey("MeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_tank_meter_meter_id");
|
||||
|
||||
b.Navigation("Meter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||
{
|
||||
b.Navigation("Members");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||
{
|
||||
b.Navigation("Meters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||
{
|
||||
b.Navigation("Sources");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMeterLinks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "meter_link",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
from_meter_id = table.Column<int>(type: "integer", nullable: false),
|
||||
to_meter_id = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_meter_link", x => x.id);
|
||||
table.CheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
table.ForeignKey(
|
||||
name: "fk_meter_link_meter_from_meter_id",
|
||||
column: x => x.from_meter_id,
|
||||
principalTable: "meter",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_meter_link_meter_to_meter_id",
|
||||
column: x => x.to_meter_id,
|
||||
principalTable: "meter",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_meter_link_from_meter_id_to_meter_id",
|
||||
table: "meter_link",
|
||||
columns: new[] { "from_meter_id", "to_meter_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_meter_link_to_meter_id",
|
||||
table: "meter_link",
|
||||
column: "to_meter_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "meter_link");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,6 +499,39 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("meter_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("FromMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("from_meter_id");
|
||||
|
||||
b.Property<int>("ToMeterId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("to_meter_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_meter_link");
|
||||
|
||||
b.HasIndex("ToMeterId")
|
||||
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||
|
||||
b.HasIndex("FromMeterId", "ToMeterId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||
|
||||
b.ToTable("meter_link", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -812,6 +845,27 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("FromMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||
|
||||
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ToMeterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||
|
||||
b.Navigation("FromMeter");
|
||||
|
||||
b.Navigation("ToMeter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||
{
|
||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Core.Normalization;
|
||||
using static MeterVault.Core.Tests.TestData;
|
||||
|
||||
namespace MeterVault.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// instant_rate: the reading value is an instantaneous rate (e.g. kW) integrated over time into
|
||||
/// consumption (kWh). Uses the trapezoidal rule between consecutive samples, attributed to the
|
||||
/// interval's end reading — the first reading only seeds the integral.
|
||||
/// </summary>
|
||||
public sealed class InstantRateNormalizerTests
|
||||
{
|
||||
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
|
||||
|
||||
private static DateTimeOffset At(int hour) => new(new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc).AddHours(hour));
|
||||
|
||||
[Fact]
|
||||
public void Constant_rate_integrates_to_rate_times_hours()
|
||||
{
|
||||
// 2 kW held steady for 3 hours → 6 kWh, booked at the end of the interval.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" },
|
||||
Readings = [Reading(1, At(0), 2), Reading(1, At(3), 2)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx);
|
||||
|
||||
var row = Assert.Single(result);
|
||||
Assert.Equal(6d, row.Amount, 6);
|
||||
Assert.Equal(At(3), row.Time);
|
||||
Assert.Equal(ConsumptionKind.Consumption, row.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Linear_ramp_integrates_trapezoidally_per_interval()
|
||||
{
|
||||
// 0 kW → 2 kW → 4 kW at 1-hour steps. Intervals: (0+2)/2·1 = 1, (2+4)/2·1 = 3.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" },
|
||||
Readings = [Reading(1, At(0), 0), Reading(1, At(1), 2), Reading(1, At(2), 4)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx);
|
||||
|
||||
// First reading only seeds the integral: N readings → N−1 rows.
|
||||
Assert.Equal([1d, 3d], result.Select(c => c.Amount));
|
||||
Assert.Equal([At(1), At(2)], result.Select(c => c.Time));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_rate_is_preserved_as_export()
|
||||
{
|
||||
// A bidirectional power sensor reading −4 kW for an hour → −4 kWh (net export), not clamped.
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" },
|
||||
Readings = [Reading(1, At(0), -4), Reading(1, At(1), -4)],
|
||||
};
|
||||
|
||||
var result = _engine.Normalize(ctx);
|
||||
|
||||
Assert.Equal(-4d, Assert.Single(result).Amount, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_reading_produces_no_consumption()
|
||||
{
|
||||
var ctx = new NormalizationContext
|
||||
{
|
||||
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.InstantRate, Unit = "kWh" },
|
||||
Readings = [Reading(1, At(0), 5)],
|
||||
};
|
||||
|
||||
Assert.Empty(_engine.Normalize(ctx));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using MeterVault.Core.Domain;
|
||||
|
||||
namespace MeterVault.Core.Tests;
|
||||
|
||||
public sealed class MeterMetaTests
|
||||
{
|
||||
[Fact]
|
||||
public void Role_reads_configured_role()
|
||||
{
|
||||
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role("{\"role\":\"grid_import\"}"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
[InlineData("not json")]
|
||||
[InlineData("{\"role\":123}")]
|
||||
[InlineData("[1,2,3]")]
|
||||
public void Role_is_null_when_absent_or_malformed(string meta)
|
||||
{
|
||||
Assert.Null(MeterMeta.Role(meta));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRole_sets_role_and_preserves_other_keys()
|
||||
{
|
||||
var updated = MeterMeta.WithRole("{\"expression\":\"a-b\"}", MeterRoles.TotalLoad);
|
||||
|
||||
Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(updated));
|
||||
Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRole_overwrites_existing_role()
|
||||
{
|
||||
var updated = MeterMeta.WithRole("{\"role\":\"grid_import\"}", MeterRoles.GridExport);
|
||||
|
||||
Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(updated));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRole_handles_empty_meta()
|
||||
{
|
||||
var updated = MeterMeta.WithRole("", MeterRoles.GridImport);
|
||||
|
||||
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(updated));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void SetRole_removes_role_when_blank_and_keeps_other_keys(string? role)
|
||||
{
|
||||
var updated = MeterMeta.SetRole("{\"role\":\"grid_import\",\"expression\":\"a-b\"}", role);
|
||||
|
||||
Assert.Null(MeterMeta.Role(updated));
|
||||
Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression"));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeterVault.Infrastructure.Costing;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
using MeterVault.Infrastructure.Import;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -43,6 +44,44 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
|
||||
}
|
||||
|
||||
// Panel read models compute real figures from the reference data (SDD §8.4–§8.6).
|
||||
int hausId;
|
||||
short electricityTypeId;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var services = scope.ServiceProvider;
|
||||
var wide = new DateOnly(1997, 1, 1);
|
||||
var toEnd = new DateOnly(2027, 1, 1);
|
||||
|
||||
var solar = await services.GetRequiredService<SolarService>().GetSummaryAsync(wide, toEnd);
|
||||
Assert.True(solar.HasGeneration);
|
||||
Assert.True(solar.Generation > 0);
|
||||
// Haus (total_load) + Netz (grid_import) are role-tagged, so self-consumption/savings resolve.
|
||||
Assert.True(solar.HasLoadContext);
|
||||
Assert.NotNull(solar.SelfConsumption);
|
||||
Assert.NotNull(solar.Savings);
|
||||
|
||||
var consumables = await services.GetRequiredService<ConsumableService>().GetConsumablesAsync(wide, toEnd);
|
||||
var oil = Assert.Single(consumables);
|
||||
Assert.True(oil.CurrentLevel is > 0);
|
||||
Assert.NotEmpty(oil.Deliveries);
|
||||
Assert.True(oil.ConsumptionInRange > 0);
|
||||
|
||||
await using var db = fx.CreateContext();
|
||||
hausId = await db.Meters.Where(m => m.Name == "Zähler Haus").Select(m => m.Id).FirstAsync();
|
||||
var detail = await services.GetRequiredService<MeterDetailService>().GetAsync(hausId);
|
||||
Assert.NotNull(detail);
|
||||
Assert.True(detail!.ReadingCount > 0);
|
||||
Assert.True(detail.TotalConsumption > 0);
|
||||
|
||||
// Flow graph: the demo Haus → Auto chain yields a link + an "Other (Haus)" remainder.
|
||||
electricityTypeId = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync();
|
||||
var flow = await services.GetRequiredService<FlowService>()
|
||||
.GetFlowAsync(electricityTypeId, new DateOnly(1997, 1, 1), new DateOnly(2027, 1, 1));
|
||||
Assert.True(flow.HasChain);
|
||||
Assert.Contains(flow.Nodes, n => n.IsOther);
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var overview = await client.GetAsync(new Uri("/", UriKind.Relative));
|
||||
@@ -55,7 +94,13 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
Assert.Contains("This year", html, StringComparison.Ordinal);
|
||||
Assert.Contains("Latest month with data", html, StringComparison.Ordinal);
|
||||
|
||||
foreach (var path in new[] { "/meters", "/trends", "/import", "/admin/tariffs", "/admin/energy-types" })
|
||||
foreach (var path in new[]
|
||||
{
|
||||
"/meters", "/trends", "/solar", "/consumables", "/import",
|
||||
"/admin/tariffs", "/admin/energy-types", "/admin/categories",
|
||||
"/admin/connectors", "/admin/settings", $"/meters/{hausId}",
|
||||
$"/energy/{electricityTypeId}",
|
||||
})
|
||||
{
|
||||
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -70,6 +115,7 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
||||
|
||||
private static async Task ClearDataAsync(MeterVaultDbContext db)
|
||||
{
|
||||
await db.MeterLinks.ExecuteDeleteAsync();
|
||||
await db.Consumption.ExecuteDeleteAsync();
|
||||
await db.Readings.ExecuteDeleteAsync();
|
||||
await db.MeterEvents.ExecuteDeleteAsync();
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Dashboard;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full
|
||||
/// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the
|
||||
/// child's consumption proportionally to the parents' own consumption.
|
||||
/// </summary>
|
||||
[Collection("Timescale")]
|
||||
public sealed class FlowServiceTests(TimescaleFixture fx)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Single_parent_chain_makes_other_remainder()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
try
|
||||
{
|
||||
var type = await SeedTypeAsync(db, "flow_elec_a");
|
||||
var main = await AddMeterAsync(db, "Main", type);
|
||||
var car = await AddMeterAsync(db, "Car", type);
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await AddConsumptionAsync(db, main.Id, 100);
|
||||
await AddConsumptionAsync(db, car.Id, 30);
|
||||
|
||||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||
|
||||
Assert.Equal(100, graph.Total, 1);
|
||||
var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}");
|
||||
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
|
||||
var other = Assert.Single(graph.Nodes, n => n.IsOther);
|
||||
Assert.Equal(70, other.Value, 1); // 100 − 30
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_parents_split_child_proportionally()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
try
|
||||
{
|
||||
var type = await SeedTypeAsync(db, "flow_elec_b");
|
||||
var grid = await AddMeterAsync(db, "Grid", type);
|
||||
var solar = await AddMeterAsync(db, "Solar draw", type);
|
||||
var house = await AddMeterAsync(db, "House", type);
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await AddConsumptionAsync(db, grid.Id, 75);
|
||||
await AddConsumptionAsync(db, solar.Id, 25);
|
||||
await AddConsumptionAsync(db, house.Id, 40);
|
||||
|
||||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||
|
||||
// House (40) splits 75:25 → 30 from grid, 10 from solar.
|
||||
Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generation_meter_counts_as_source()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
try
|
||||
{
|
||||
var type = await SeedTypeAsync(db, "flow_elec_c");
|
||||
var grid = await AddMeterAsync(db, "Grid", type);
|
||||
var solar = await AddMeterAsync(db, "Solar", type);
|
||||
var house = await AddMeterAsync(db, "House", type);
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await AddConsumptionAsync(db, grid.Id, 75); // grid import
|
||||
await AddConsumptionAsync(db, solar.Id, 30, ConsumptionKind.Generation); // solar generation
|
||||
await AddConsumptionAsync(db, house.Id, 40); // house load
|
||||
|
||||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||
|
||||
// Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar.
|
||||
Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
// Remainder across grid+solar = (75+30) − 40 = 65 (export + battery/inverter losses).
|
||||
Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Virtual_sum_meter_aggregates_its_upstreams()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
try
|
||||
{
|
||||
var type = await SeedTypeAsync(db, "flow_elec_d");
|
||||
var solar1 = await AddMeterAsync(db, "Solar 1", type);
|
||||
var solar2 = await AddMeterAsync(db, "Solar 2", type);
|
||||
var sumSolar = await AddMeterAsync(db, "Sum Solar", type, MeterMode.Virtual);
|
||||
var grid = await AddMeterAsync(db, "Grid", type);
|
||||
var house = await AddMeterAsync(db, "House", type);
|
||||
// Solar1 + Solar2 → Sum Solar ; Grid + Sum Solar → House.
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = house.Id });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await AddConsumptionAsync(db, solar1.Id, 15, ConsumptionKind.Generation);
|
||||
await AddConsumptionAsync(db, solar2.Id, 15, ConsumptionKind.Generation);
|
||||
await AddConsumptionAsync(db, grid.Id, 75);
|
||||
await AddConsumptionAsync(db, house.Id, 40);
|
||||
|
||||
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||
|
||||
// Sum Solar has no readings but equals Solar 1 + Solar 2 = 30.
|
||||
Assert.Equal(30, graph.Nodes.Single(n => n.MeterId == sumSolar.Id).Value, 1);
|
||||
// House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar.
|
||||
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{sumSolar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||
// No spurious remainder under Solar 1/2 (their whole output flows into Sum Solar).
|
||||
Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1.Id}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearAsync(db);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
|
||||
{
|
||||
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
|
||||
db.EnergyTypes.Add(type);
|
||||
await db.SaveChangesAsync();
|
||||
return type.Id;
|
||||
}
|
||||
|
||||
private static async Task<Meter> AddMeterAsync(MeterVaultDbContext db, string name, short type, MeterMode mode = MeterMode.DirectDelta)
|
||||
{
|
||||
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = "kWh" };
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
return meter;
|
||||
}
|
||||
|
||||
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption)
|
||||
{
|
||||
db.Consumption.Add(new Consumption
|
||||
{
|
||||
MeterId = meterId,
|
||||
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
|
||||
Amount = amount,
|
||||
Kind = kind,
|
||||
Quality = ReadingQuality.Manual,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task ClearAsync(MeterVaultDbContext db)
|
||||
{
|
||||
await db.MeterLinks.ExecuteDeleteAsync();
|
||||
await db.Consumption.ExecuteDeleteAsync();
|
||||
await db.Meters.ExecuteDeleteAsync();
|
||||
await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// The HA connection tester must fail closed on missing config <em>before</em> any network call —
|
||||
/// so a misconfigured connector gives a clear message, never an exception. The stub factory throws
|
||||
/// if the tester ever tries to create an HttpClient, proving these branches never reach the network.
|
||||
/// </summary>
|
||||
public sealed class HaConnectionTesterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Missing_base_url_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(baseUrl: "", tokenEnv: "SOME_TOKEN", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("Base URL", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_token_env_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(baseUrl: "http://ha.local:8123", tokenEnv: "", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("env-var", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unset_token_env_var_fails_without_network()
|
||||
{
|
||||
var result = await NewTester().TestAsync(
|
||||
baseUrl: "http://ha.local:8123", tokenEnv: "METERVAULT_DEFINITELY_UNSET_TOKEN_VAR", entityId: null);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("is not set", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static HaConnectionTester NewTester() =>
|
||||
new(new ThrowingHttpClientFactory(), NullLogger<HaConnectionTester>.Instance);
|
||||
|
||||
private sealed class ThrowingHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) =>
|
||||
throw new InvalidOperationException("Network must not be touched for a config-guard failure.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json;
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// The pure Home Assistant WebSocket protocol helpers: URL derivation, the auth/subscribe frames,
|
||||
/// and reading the entity id + numeric value out of a <c>state_changed</c> event (the transport is
|
||||
/// exercised separately). No network — these are the parts that must be provably correct.
|
||||
/// </summary>
|
||||
public sealed class HaWebSocketProtocolTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("http://ha.local:8123", "ws", "ha.local", 8123)]
|
||||
[InlineData("http://ha.local:8123/", "ws", "ha.local", 8123)]
|
||||
[InlineData("https://ha.example.com", "wss", "ha.example.com", 443)]
|
||||
public void WebSocketUri_maps_scheme_and_appends_api_path(string baseUrl, string scheme, string host, int port)
|
||||
{
|
||||
var uri = HaWebSocketProtocol.WebSocketUri(baseUrl);
|
||||
|
||||
Assert.Equal(scheme, uri.Scheme);
|
||||
Assert.Equal(host, uri.Host);
|
||||
Assert.Equal(port, uri.Port);
|
||||
Assert.Equal("/api/websocket", uri.AbsolutePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthMessage_carries_type_and_token()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(HaWebSocketProtocol.AuthMessage("secret-token"));
|
||||
|
||||
Assert.Equal("auth", doc.RootElement.GetProperty("type").GetString());
|
||||
Assert.Equal("secret-token", doc.RootElement.GetProperty("access_token").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeStateChanged_requests_state_changed_events()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(HaWebSocketProtocol.SubscribeStateChanged(7));
|
||||
|
||||
Assert.Equal(7, doc.RootElement.GetProperty("id").GetInt32());
|
||||
Assert.Equal("subscribe_events", doc.RootElement.GetProperty("type").GetString());
|
||||
Assert.Equal("state_changed", doc.RootElement.GetProperty("event_type").GetString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"type\":\"auth_required\",\"ha_version\":\"2024.6\"}", true, false, false)]
|
||||
[InlineData("{\"type\":\"auth_ok\"}", false, true, false)]
|
||||
[InlineData("{\"type\":\"auth_invalid\",\"message\":\"bad token\"}", false, false, true)]
|
||||
public void Auth_message_types_are_recognized(string json, bool required, bool ok, bool invalid)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal(required, HaWebSocketProtocol.IsAuthRequired(root));
|
||||
Assert.Equal(ok, HaWebSocketProtocol.IsAuthOk(root));
|
||||
Assert.Equal(invalid, HaWebSocketProtocol.IsAuthInvalid(root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadStateChanged_extracts_entity_and_numeric_state()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(StateChangedEvent(state: "1234.5"));
|
||||
|
||||
Assert.True(HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out var entityId, out var newState));
|
||||
Assert.Equal("sensor.house_power", entityId);
|
||||
|
||||
var parsed = HaStateClient.ParseStateElement(newState, attribute: null);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(1234.5, parsed!.Value.Value, 3);
|
||||
Assert.Equal(new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.Zero), parsed.Value.Time);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadStateChanged_reads_a_named_attribute()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(StateChangedEvent(state: "on"));
|
||||
|
||||
Assert.True(HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out _, out var newState));
|
||||
// state "on" is non-numeric, but the 'current' attribute is a number.
|
||||
Assert.Null(HaStateClient.ParseStateElement(newState, attribute: null));
|
||||
var byAttribute = HaStateClient.ParseStateElement(newState, attribute: "current");
|
||||
Assert.Equal(42, byAttribute!.Value.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadStateChanged_ignores_removed_entities_and_other_events()
|
||||
{
|
||||
using var removed = JsonDocument.Parse(
|
||||
"{\"type\":\"event\",\"event\":{\"event_type\":\"state_changed\",\"data\":{\"entity_id\":\"sensor.x\",\"new_state\":null}}}");
|
||||
Assert.False(HaWebSocketProtocol.TryReadStateChanged(removed.RootElement, out _, out _));
|
||||
|
||||
using var other = JsonDocument.Parse(
|
||||
"{\"type\":\"event\",\"event\":{\"event_type\":\"call_service\",\"data\":{}}}");
|
||||
Assert.False(HaWebSocketProtocol.TryReadStateChanged(other.RootElement, out _, out _));
|
||||
|
||||
using var result = JsonDocument.Parse("{\"id\":1,\"type\":\"result\",\"success\":true}");
|
||||
Assert.False(HaWebSocketProtocol.TryReadStateChanged(result.RootElement, out _, out _));
|
||||
}
|
||||
|
||||
private static string StateChangedEvent(string state) => $$"""
|
||||
{
|
||||
"id": 1,
|
||||
"type": "event",
|
||||
"event": {
|
||||
"event_type": "state_changed",
|
||||
"data": {
|
||||
"entity_id": "sensor.house_power",
|
||||
"new_state": {
|
||||
"entity_id": "sensor.house_power",
|
||||
"state": "{{state}}",
|
||||
"attributes": { "unit_of_measurement": "W", "current": 42 },
|
||||
"last_updated": "2024-06-15T10:00:00+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Ingestion;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeterVault.Integration.Tests.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end proof of the HA WebSocket push path against an in-process fake Home Assistant server:
|
||||
/// the worker performs the auth handshake, subscribes to <c>state_changed</c>, and a pushed change
|
||||
/// for a configured entity lands as a <c>reading</c>. This exercises the real ClientWebSocket
|
||||
/// transport and handshake sequencing that the pure-protocol unit tests can't.
|
||||
/// </summary>
|
||||
[Collection("Timescale")]
|
||||
public sealed class HomeAssistantWebSocketWorkerTests(TimescaleFixture fx)
|
||||
{
|
||||
private const string TokenEnvVar = "MV_TEST_HA_WS_TOKEN";
|
||||
|
||||
[Fact]
|
||||
public async Task Pushed_state_change_becomes_a_reading()
|
||||
{
|
||||
await using var db = fx.CreateContext();
|
||||
Environment.SetEnvironmentVariable(TokenEnvVar, "test-token");
|
||||
await using var fake = await FakeHaServer.StartAsync(entityId: "sensor.house_power", state: "4711");
|
||||
|
||||
try
|
||||
{
|
||||
var type = new EnergyType { Key = "ha_ws_test", DisplayName = "HA WS", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
|
||||
db.EnergyTypes.Add(type);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var meter = new Meter { Name = "HA WS Meter", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" };
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var endpoint = new IngestionEndpoint
|
||||
{
|
||||
Type = EndpointType.HomeAssistant,
|
||||
Name = "Fake HA",
|
||||
IsEnabled = true,
|
||||
Config = new HaEndpointConfig { BaseUrl = fake.BaseUrl, TokenEnv = TokenEnvVar, UseWebSocket = true }.ToJson(),
|
||||
};
|
||||
db.IngestionEndpoints.Add(endpoint);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.MeterSources.Add(new MeterSource
|
||||
{
|
||||
MeterId = meter.Id,
|
||||
SourceType = SourceType.HomeAssistant,
|
||||
EndpointId = endpoint.Id,
|
||||
IsEnabled = true,
|
||||
Config = JsonSerializer.Serialize(new { entityId = "sensor.house_power" }),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await using var provider = BuildProvider(fx.ConnectionString);
|
||||
var worker = new HomeAssistantWebSocketWorker(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(), NullLogger<HomeAssistantWebSocketWorker>.Instance);
|
||||
|
||||
await worker.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
Reading? reading = null;
|
||||
for (var i = 0; i < 60 && reading is null; i++)
|
||||
{
|
||||
await Task.Delay(200);
|
||||
reading = await db.Readings.AsNoTracking().FirstOrDefaultAsync(r => r.MeterId == meter.Id);
|
||||
}
|
||||
|
||||
Assert.NotNull(reading);
|
||||
Assert.Equal(4711, reading!.Value, 3);
|
||||
Assert.Equal(ReadingQuality.Measured, reading.Quality);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await worker.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(TokenEnvVar, null);
|
||||
await db.Readings.ExecuteDeleteAsync();
|
||||
await db.MeterSources.ExecuteDeleteAsync();
|
||||
await db.IngestionEndpoints.ExecuteDeleteAsync();
|
||||
await db.Meters.ExecuteDeleteAsync();
|
||||
await db.EnergyTypes.Where(t => t.Key == "ha_ws_test").ExecuteDeleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildProvider(string connectionString)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddDbContextFactory<MeterVaultDbContext>(o => o
|
||||
.UseNpgsql(connectionString, n => n.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
|
||||
.UseSnakeCaseNamingConvention());
|
||||
services.AddScoped<MeterVaultDbContext>(sp => sp.GetRequiredService<IDbContextFactory<MeterVaultDbContext>>().CreateDbContext());
|
||||
services.AddScoped<IngestionService>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
/// <summary>A minimal Home Assistant WebSocket server: handshake, then push one state_changed event.</summary>
|
||||
private sealed class FakeHaServer : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
|
||||
private FakeHaServer(WebApplication app, string baseUrl)
|
||||
{
|
||||
_app = app;
|
||||
BaseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public string BaseUrl { get; }
|
||||
|
||||
public static async Task<FakeHaServer> StartAsync(string entityId, string state)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
var app = builder.Build();
|
||||
app.UseWebSockets();
|
||||
|
||||
var eventJson =
|
||||
"{\"id\":1,\"type\":\"event\",\"event\":{\"event_type\":\"state_changed\",\"data\":{\"entity_id\":\""
|
||||
+ entityId + "\",\"new_state\":{\"entity_id\":\"" + entityId + "\",\"state\":\"" + state
|
||||
+ "\",\"attributes\":{},\"last_updated\":\"2024-06-15T10:00:00+00:00\"}}}}";
|
||||
|
||||
app.Map("/api/websocket", async context =>
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = 400;
|
||||
return;
|
||||
}
|
||||
|
||||
using var ws = await context.WebSockets.AcceptWebSocketAsync();
|
||||
await SendAsync(ws, """{"type":"auth_required","ha_version":"2024.6"}""");
|
||||
await ReceiveAsync(ws); // client "auth"
|
||||
await SendAsync(ws, """{"type":"auth_ok"}""");
|
||||
await ReceiveAsync(ws); // client "subscribe_events"
|
||||
await SendAsync(ws, """{"id":1,"type":"result","success":true}""");
|
||||
await SendAsync(ws, eventJson);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, context.RequestAborted);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// client/test closed — expected.
|
||||
}
|
||||
});
|
||||
|
||||
await app.StartAsync();
|
||||
var address = app.Services.GetRequiredService<IServer>().Features
|
||||
.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
return new FakeHaServer(app, address);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
||||
|
||||
private static Task SendAsync(WebSocket ws, string json) =>
|
||||
ws.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
|
||||
|
||||
private static async Task ReceiveAsync(WebSocket ws)
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
await ws.ReceiveAsync(buffer, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user