11 Commits
Author SHA1 Message Date
Florian Schmidt c0f52dbb6f Release 0.3.0
ci / build-test (push) Successful in 1m50s
version-tag / tag-if-newer (push) Successful in 7s
Editing VERSION on master is what tags v0.3.0 and publishes the image, so
this is the whole change.

What an operator gets: meter swaps, resets, tank levels and deliveries
recorded from the meter page, the navigation around it, and consumption
attributed to the months it accrued in rather than to whichever reading
closed the interval.

The first start after the update re-derives all stored consumption before
the web server listens, so the instance is briefly unreachable and
historical monthly figures can shift once. README's "Upgrading to 0.3.0"
has the details and the one log line worth checking afterwards.
2026-09-17 21:09:27 +02:00
Florian Schmidt aacdc28d70 Meters: record events from the UI, and book consumption in the months it accrued in
Two threads that ended up in the same files. One is navigation: a meter
swap that happened today had no click path at all, and most per-meter
tasks were reachable only by knowing which admin page owned them. The
other is attribution: readings on 1 August and 16 September showed six
weeks of water under September and nothing under August.

Meter events from the UI

Swap, counter reset, tank level, delivery and note are recorded through
MeterEventService rather than ad-hoc inserts, so the dialog's verdict and
the saved result come from the same Validate call, and every record or
delete recomputes the meter inside one transaction. MeterEventRules
decides which events a mode offers -- a tank has no register to swap, and
Correction is offered nowhere because nothing reads it.

A swap is stored as the event at T plus a manual reading of the new
register's start value at exactly T. That pairing is the whole trick: the
boundary window is (previousReading, reading], so the old register's tail
books at T and every later reading counts from the new start. Writing the
old final value as the reading at T instead -- the obvious thing -- double
counts the tail and then rejects every reading the new register produces.
Deleting a swap removes that start reading only while it is still the
untouched start value, and only Manual readings can be deleted at all.

Navigation

The meter page is now the hub: primary entry by mode, a "Record event"
menu, and Edit through a shared MeterEditor that also owns tank setup.
Other pages link into it with MeterLinks (/meters/{id}?tab=...&action=...),
whose action is consumed once after the interactive render and dropped
from the address -- the reverse order flashes the dialog and closes it,
because a circuit's first location change dismisses every open dialog.
The app bar gains a "Find a meter" dialog with the same quick entry.

A source that has no usable connector now links to creating (or enabling)
one and comes back to the same source dialog with the connector picked
and everything typed still there; the draft survives in a circuit-scoped
DraftStore, and the way back is a meter id rather than a URL, so the page
cannot be made to redirect anywhere else. The connector list shows which
meters use each connector, import batches list the meters and categories
they wrote to, the meter editor owns the meter's own cost categories, and
the dashboard's empty cost panel names the first missing step instead of
listing every admin page.

Months

A reading is an instant, and what it measures accrued over the time since
the previous one. Booking the whole delta at the closing reading misfiles
it whenever the interval crosses a month boundary, so a plain increase is
now divided at local month boundaries in proportion to elapsed time, each
share stamped inside its month and marked estimated: the meter recorded a
total, not a shape. The parts always sum to the original.

Imported monthly tables are the exception that keeps the golden fixtures
reconciling. "Mai 2026" carries the register at the end of May but is
stamped on the 1st, so the importer -- the only place that still knows
whether the date cell named a month or a day -- flags it MonthLabel, and
the engine reads it as the end of its month. Inferring that from the
stamp instead would catch day-dated rows: a sheet with "01.08.2026" in it
is not a monthly table, and reading it as one moves two thirds of July
into August.

ReadingTimeline is the single ordering built on that: effective time,
then stamp. The register normalizers walk it, and so do the decrease
guard and the event dialog, which is what stops them disagreeing about
which reading is "previous" -- a sheet imported after live readings of the
same month used to count that month twice, and a mid-month reading below
the month's end value was rejected as a drop. A swap detected in a
monthly table applies from the start of that local month, i.e. to the
first reading in it, and a recorded start value never counts above the
reading it lands on.

Every reader buckets in the configured timezone rather than a hardcoded
one, and turns a requested date into that zone's local midnight, so the
divided shares are read back under the months they were stamped in. The
zone id is normalised to its IANA form, because .NET accepts a Windows id
that PostgreSQL will not bucket by, and both are checked at startup.

Stored consumption is derived, so a rule change reaches a meter only at
its next reading -- weeks, for a meter read monthly. NormalizationUpgrade
records the revision and zone the stored series was built with and
rebuilds everything once at startup when either differs, each meter in
its own transaction. A meter that fails is logged, kept in
normalization_pending and retried at the next start: one bad series must
never keep the application down.

What an operator sees once

Existing charts change on the first start after the update: months that
carried a neighbour's use give it back. Rows of earlier imports from
monthly tables are marked as such before anything is recomputed, and if
that marking fails nothing is rebuilt or recorded, so the upgrade simply
runs again next time rather than shifting every imported month by one. A
wizard import whose date format was left on auto-detect is treated as a
monthly table when all of its rows sit on the 1st across at least two
months -- exactly how those rows were attributed before -- and each such
batch is named in the log, because a day-dated sheet always read on the
1st looks identical; revert and re-import it with the day format if that
is what it was.

Tests: 120 unit and 230 integration, including the reference fixtures,
which still reconcile month for month.
2026-09-17 21:09:17 +02:00
Florian Schmidt 0319e5527f Release 0.2.0
ci / build-test (push) Failing after 38s
version-tag / tag-if-newer (push) Failing after 35s
First release carrying the German UI. Editing VERSION on master is what
tags v0.2.0 and publishes the image, so this is the whole change.

Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
2026-08-13 23:27:54 +02:00
Florian Schmidt bfa0b537ee i18n: ship the UI in English and German
ci / build-test (push) Failing after 35s
The last open item on the M7 list. Number and currency formatting was
already locale-aware, but every string in the UI was an English literal,
so a German instance read half in each language -- German data, English
chrome. This translates all of it and adds the machinery to keep it
translated.

Strings live in Localization/Strings.resx (English, neutral) and
Strings.de.resx. The neutral file generates a strongly-typed accessor at
build time, aliased as S in _Imports.razor, so components reference
compiled properties -- @S.Common_Save, not a string key. That choice is
the point: across 4,500 lines of markup, a key lookup that silently
falls back to its own name is a defect you find in production, while a
renamed property is a build error. Generation runs in MSBuild rather
than the IDE designer, so dotnet build alone reproduces it anywhere.

Resource fallback is the hazard here. Ask for a key the German satellite
lacks and ResourceManager quietly serves the English one -- correct at
runtime, disastrous at release time, because a half-translated build
looks perfectly healthy. StringResourceTests reads each satellite with
tryParents: false, which is the only way to see what one actually
contains, and fails on a missing or blank translation, a placeholder
that changed arity, an orphan, or a key nothing references.

Three things needed more than substitution:

- Domain enums reached the screen as bare identifiers. They stay bare in
  the model -- they are persisted as text and appear in the REST API, so
  their names are part of the data contract -- and DisplayNames is now
  the single place that decides how each value is spoken. Every arm ends
  in a fallback returning the identifier, so a value added later cannot
  throw mid-render; EnumDisplayNameTests is what stops that safety net
  quietly becoming the shipping behaviour.

- Infrastructure was writing display text: FlowService's "Other (X)",
  MeterPeriodView's "Generation"/"Consumption", the HA connection-test
  verdicts, the updater's snackbar, the CSV importer's row warnings.
  Each now returns an outcome value and the UI supplies the words, which
  is where the reader's language is known. Diagnostics that are not ours
  -- an HTTP status, systemd's stderr, an exception message -- are passed
  through untranslated, and every English summary is kept alongside the
  outcome so log lines never move with the UI language. The UpdateRunner
  change is additive only; no gate was touched.

- Importer warnings carry their arguments rather than a finished
  sentence, so the numbers inside them pick up the reader's grouping. A
  register that reads 2.940,19 everywhere else must not read 2940.19
  only inside a warning.

Switching language is a redirect through /culture/set followed by a full
reload, not an interactive state change: a Blazor Server circuit is fixed
to the culture of the request that opened it. That makes the endpoint a
redirector taking its target from the query string, so anything but a
local path is refused rather than followed. Preference order is the
cookie, then Accept-Language, then MeterVault__Locale -- an instance can
be pinned to one language and a reader can still switch.

Locale keeps its documented default of "en". Format now follows
CurrentCulture instead of a hardcoded de-DE, so an instance with nothing
configured and a browser asking for English will show English number
formatting where it previously showed German; set MeterVault__Locale=de
to pin the old behaviour. The importer's de-DE parsing is untouched and
stays that way -- that dialect is a property of the spreadsheets, not of
whoever is looking at the dashboard.

Anything that comes from the database -- meter names, energy-type display
names, category names -- is user data and is never translated.

Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
2026-08-13 16:36:25 +02:00
schmidt.florian af786c7b28 Meters: add manual reading entry from the meter-detail Readings tab
ci / build-test (push) Successful in 1m37s
Entering a reading by hand previously meant POST /api/v1/readings with an
API key, or a one-row CSV through the import wizard. SourceType.Manual
existed in the enum but nothing was behind it. This adds the click path,
built for the case it is actually used in: walking to each manual meter
with a phone in hand.

"Add reading" on the Readings tab opens a dialog prefilled with the
meter's last register value and the current local time, both editable:

- An on-screen keypad, because a register is read standing at the meter.
  It behaves like a calculator against the prefill - the first digit
  replaces it (a fresh register), while backspace edits it in place,
  which is the common case since only a register's last digits move.
- Typed input accepts both separators (last one wins), so a German and
  an English phone keyboard both do the right thing. ReadingEntry owns
  that rule and is unit-tested; it deliberately differs from
  GermanNumber, where a lone dot really is a thousands separator.
- A live parsed-value echo plus delta-since-last, which is the net that
  catches a mistyped digit before it is committed.
- Decrease / replaces-existing / future / backdated surfaced before
  saving, and DST spring-forward gaps refused rather than shifted.

The verdict line sits in a fixed-height, no-wrap slot above the keypad.
That is load-bearing, not cosmetic: an alert that appears there when the
value dips below the last reading moves the keys out from under the
user's thumb mid-entry, which is a guaranteed mistype on a phone. The
long-form explanation goes below the keypad, where reflow is harmless.

Saving goes through IngestionService.IngestByMeterAsync, so the
monotonic-decrease guard and inline renormalization apply exactly as for
any other ingest. A new optional quality parameter stamps the row
ReadingQuality.Manual; null preserves today's behaviour, so a source
re-reporting the same timestamp updates the value without silently
relabelling a hand-entered or imported reading.

Also: the meter-detail tabs now render times in the instance timezone
per SDD section 10, instead of raw UTC. Without it a reading entered at
18:00 reads back as 16:00. Side effect is that historic imported monthly
rows show 01:00/02:00 rather than 00:00 - correct, if noisier.

Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX
2026-08-01 10:25:05 +02:00
schmidt.florian 1f575c9da2 Update: drop the API-key requirement from the update trigger
ci / build-test (push) Successful in 1m14s
Owner's call: MeterVault__AllowInAppUpdate is now the whole gate. One click on
the banner, no key, no prompt, and the REST endpoint no longer asks for one
either.

What that means, recorded so it is not rediscovered later: with the flag on,
anything that can reach MeterVault can trigger a rebuild and restart. On the
realistic threat model that is a repeatable denial of service — minutes of
downtime and a pegged CPU per request — rather than code injection, because the
build comes from the owner's own repository. It becomes remote code execution
if that repository is ever compromised. The flag still defaults off, and that
default is now the only thing between an upgrade and an open trigger, so
UpdateRunnerTests pins it along with the fact that configuring API keys does not
imply consent to rebuild the host.

Kept one guard, which is not authentication: the REST endpoint requires an
X-MeterVault-Update header. Without it any website could POST to the endpoint
through the browser of someone on the network — a plain HTML form is enough,
and no key means nothing else would stop it. A form cannot set a custom header
and a cross-origin fetch that tries is stopped by a preflight nothing here
answers, so this costs a deliberate caller one flag and costs the button
nothing, since it runs over the Blazor circuit rather than HTTP.

The confirmation dialog stays, now purely as a guard against a stray click
costing several minutes of downtime. Every triggered update is logged as a
warning: with no key there is no caller to attribute it to, and the restart
discards anything held in memory.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 20:39:12 +02:00
schmidt.florian 9eb3f7d53c Update: add an opt-in "Update now" button, gated on an API key
ci / build-test (push) Successful in 1m9s
Adds the button, plus POST /api/v1/system/update for driving it from Home
Assistant or curl. Both pull the latest source, rebuild and restart the service.

The gating is the substance of this change. The updater builds whatever is on
the branch and the LXC runs MeterVault as root, so triggering it is
root-equivalent on that host, and the web UI has no login — "reachable from the
dashboard" alone would mean any device on the network could take the machine.
Three independent conditions must hold before anything runs: the operator set
MeterVault__AllowInAppUpdate, at least one API key is configured, and the caller
presented one, compared in constant time so retries cannot time out the key.

AllowAnonymousApi cannot reach it. That flag opens reads, and opening reads must
not open root, so the endpoint checks the presented key itself rather than
relying on the shared group filter that honours it. Availability is re-checked
inside LaunchAsync rather than trusting the caller to have done so.

The UI button asks for the key every time instead of remembering it: with no
login, a browser left open on the dashboard would otherwise be a standing
permission to execute code on the host. The key is cleared from component state
immediately, and a wrong key and a keyless deployment give the same message so
an unauthenticated caller cannot tell them apart.

Launched detached through systemd-run: the updater restarts the service, so a
child process would be killed part-way through, leaving the app down with a
half-published build. --collect reaps the transient unit so a later update is
not blocked by the remains of the previous one.

Off by default, and where there is no /usr/bin/update — a container, a dev box —
it reports that rather than half-running something. Tests pin every refusal,
including through the real HTTP pipeline; none of them launch anything.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 20:23:47 +02:00
schmidt.florian cf7e0396f0 Dashboard: report when a newer release is available
ci / build-test (push) Successful in 1m13s
The instance had no idea what version it was: VERSION drives tagging and the
image publish, but was never stamped into the assemblies, so a running build
reported 1.0.0 forever. Directory.Build.props now stamps it into every project.

The dashboard compares that against the newest tag in the source repository and
shows a banner when behind. A plain GET of a public tag list -- nothing about
the instance is sent -- cached six hours, failing quiet.

Two things it deliberately does not do. It never blocks a render: the banner
paints from the cached answer and refreshes after first render, so a cold start
or an unreachable repository costs nothing rather than holding the dashboard
open for an HTTP timeout. And it never guesses: an unknown version on either
side shows no banner at all, because a banner that cannot clear trains people
to ignore the next real one.

Version comparison is numeric on exactly three components, not System.Version
and not string order. Tags are written vX.Y.Z, the assembly reports X.Y.Z with
a +commithash suffix, and "0.10.0" sorts below "0.9.0" as a string -- each of
those is a way the banner sticks or never appears. Prerelease suffixes compare
equal to their release so an rc tag does not nag. Gitea does not promise semver
ordering, so the highest tag wins rather than the first.

The command shown depends on the install: the LXC has `update`, a container is
replaced by pulling an image, and telling container users to run `update` sends
them after a command that does not exist.

No update *button*. The UI has no authentication and the LXC runs the app as
root, and `update` builds whatever is on master, so a click would be an
unauthenticated path to arbitrary code execution for anything on the LAN. The
README now states the no-auth position plainly rather than leaving it implied.

Tests cover the parse and ordering cases that would strand a banner, the Gitea
payload shape captured from the live API, unreachable and garbage responses,
and that the VERSION file actually reaches the assembly -- read from MeterVault's
own assembly rather than GetEntryAssembly(), which under `dotnet test` is the
test host and reported a confident wrong answer. The suite makes no outbound
request: the app factory disables the check.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 20:17:52 +02:00
schmidt.florian 8fe5f4411b Fix defects found auditing the ingestion, import and connector changes
ci / build-test (push) Successful in 1m16s
An audit of this session's commits found several real problems, three of which
lose or expose data. Ordered by severity.

Live recompute was not atomic. RecomputeMeterAsync clears a meter's series with
ExecuteDelete, which commits by itself when no transaction is ambient, and only
then adds the rebuilt rows. Between the two the meter had *no* consumption:
a dashboard read reported zero, and a crash or cancelled request made the loss
permanent, for data the SDD treats as the long-term source of truth (§5.5).
Import and the events API already wrapped their recomputes; live ingestion,
which I added this session, did not. Now shares one transaction, joining an
ambient one rather than nesting.

The MQTT backfill migration counted brokers without regard to is_enabled. One
live broker plus a disabled leftover counted two, declined to backfill, and left
those sources unbound — which under endpoint-scoped routing means silently and
permanently dead. The "two or more is ambiguous" reasoning did not hold there:
the worker only ever connected to enabled endpoints. Corrected by a follow-up
migration rather than an edit, since the original may already have run; it
touches only rows still NULL, so hand-made bindings are safe.

A mapping edited after a dry run committed the *old* staged rows under the
*new* mapping. Readings went to the previous meter while the batch recorded the
current mapping — wrong data, provenance contradicting it, no exception. The
earlier fix re-validated but did not detect staleness. Commit now compares the
mapping against the one the preview was staged under and refuses.

"Test connection" sent a stored token to whatever Base URL was in the dialog.
Encrypting secrets at rest means the UI can decrypt what the operator can no
longer read, so this turned the button into an exfiltration primitive: point it
at any host, the token arrives as a Bearer header. A stored token now only goes
to the origin it was saved for; testing elsewhere requires typing it again.

A source that cannot ingest looked identical to a healthy one. Endpoint-scoped
routing made unbound and mis-bound sources silently dead, while the Sources tab
showed no connector at all and the delete dialog still promised sources would be
"unlinked". Added a Connector column that names the fault, stopped offering
disabled connectors (both workers filter on IsEnabled), and made the delete
warning say ingestion stops.

Virtual meters rendered four zero tiles: they evaluate on read and only
materialize when a cost category references them (§14.1), so summing
consumption is a confident lie about a working meter. They now report nothing
and the page explains why.

Re-importing an overlapping file failed at the database with EF's "An error
occurred while saving the entity changes", naming neither meter nor date — the
diagnosis problem a3af483 set out to fix, via the path its guard could not see.
Checked up front now, bounded by each meter's staged range.

The LXC updater left the service stopped on any failure. set -e plus an
explicit stop means Restart=always does not apply, so an OOM-killed publish or
a brief Gitea outage took MeterVault down until someone noticed. An EXIT trap
restarts the previous build and says so.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:52:48 +02:00
schmidt.florian cedd60ab45 Audit fixes: batch recompute, negative-baseline percentages, key-ring persistence
ci / build-test (push) Successful in 1m17s
Three defects found reviewing the last few commits.

Deriving consumption on ingest made the batch reading endpoint quadratic. A
recompute rewrites a meter's entire consumption series, and POST
/api/v1/readings ran one per reading -- 500 readings for one meter meant 500
full rewrites. IngestByMeterAsync takes renormalize:false and the endpoint
normalizes each touched meter once after the batch.

Percentage change divided by a possibly negative baseline. A net-export meter
going from -100 to -150 exported half again as much and would have been
reported as "+50%", reading as more consumption. A non-positive baseline now
reports no basis rather than a confident lie.

The data-protection key ring had no persistent home outside Docker Compose. The
LXC installer now creates /var/lib/metervault/keys at 0700 -- the app would
otherwise create it under the default umask, leaving a key ring world-readable
-- and the Unraid template maps it, since without that every UI-entered secret
was lost whenever the container was recreated. README documents the variable
and the trust boundary: keys on disk protect against leaked database content,
not against an attacker who already has the host.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:39:56 +02:00
schmidt.florian ad896db051 Normalization: apportion a long unread gap across the months it covers
A counter delta is booked at the reading that closes it. That is right at the
reporting cadence -- a monthly series books December against the 1 January
reading, which is what the reference sheet does -- and wrong after an outage.
Observed on Solar 1: 78 days of generation arrived as one July row, leaving
June looking like the array was switched off.

An interval containing two or more complete calendar months is now divided
across them in proportion to elapsed time. The meter recorded a total, not a
shape, so every row a split produces is marked Estimated. The sum is exact: the
final segment absorbs the rounding remainder, so a split never creates or
destroys energy.

Counting whole months *contained* rather than boundaries *crossed* is what
makes the rule safe. A monthly series contains exactly one whole month per
interval and is untouched, so the golden fixtures keep measuring the normalizer
rather than the splitter; and a reading landing hours late cannot tip the rule
and hand the new month a sliver. GapSplittingIsInertOnFixturesTests asserts the
rule declines to fire on every reference interval, so this cannot drift into
the oracle unnoticed.

Not apportioned: swap and reset amounts (explicit corrections booked at their
event -- apportioning one would rewrite a number the operator supplied), a
rejected decrease, and a zero delta, which would otherwise fan out into rows
that say nothing.

Segments are stamped at their end, keeping the existing convention that a row
records the period ending at its timestamp -- so nothing shifts relative to how
unsplit intervals are already labelled.

Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
2026-07-18 19:39:42 +02:00
123 changed files with 15075 additions and 1024 deletions
+7 -2
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -15,6 +15,16 @@
<InvariantGlobalization>false</InvariantGlobalization> <InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup> </PropertyGroup>
<!-- VERSION on master is the single source of release truth: editing it tags and publishes
(.gitea/workflows/version-tag.yml). Stamp it into the assemblies too, so a running instance
can say which version it is and compare itself against the newest tag. Without this the app
reports 1.0.0 forever and an update banner would be meaningless. -->
<PropertyGroup>
<MeterVaultVersionFile>$(MSBuildThisFileDirectory)VERSION</MeterVaultVersionFile>
<MeterVaultVersion Condition="Exists('$(MeterVaultVersionFile)')">$([System.IO.File]::ReadAllText('$(MeterVaultVersionFile)').Trim().TrimStart('v'))</MeterVaultVersion>
<Version Condition="'$(MeterVaultVersion)' != ''">$(MeterVaultVersion)</Version>
</PropertyGroup>
<!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. --> <!-- Test projects: mirror under tests/, name *.Tests, never packed, relaxed warnings. -->
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))"> <PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
+73 -3
View File
@@ -37,6 +37,27 @@ full design.
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik). - **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
- **JSON config export/import** for portability; Docker Compose + multi-arch image. - **JSON config export/import** for portability; Docker Compose + multi-arch image.
## Upgrading to 0.3.0
This release changes where consumption lands. Back the database up first (`pg_dump`), then start the
new image once and let it finish:
- **The first start re-derives all stored consumption** before the web server listens. The app is
unreachable while it runs (Compose may report the container unhealthy after ~105 s) — let it finish
rather than killing it. Progress and any meter it could not rebuild are logged.
- **Figures change once.** Consumption between two readings is now attributed to the months it
accrued in instead of landing entirely on the later reading, so historical months and their costs
can shift; rows that had to be divided are marked *estimated*.
- **Imported monthly tables are marked as such** in place before anything is recomputed, so they keep
reconciling. If that step fails nothing is rebuilt and the whole upgrade simply runs again next
start.
- **Check the log once** for `had its dates auto-detected`: a CSV imported through the wizard with the
date format left on auto-detect is treated as a monthly table when all its rows sit on the 1st.
If such a sheet really was day-dated, revert that batch on `/import` and import it again with the
day format.
- **Rolling back** to 0.2.0 leaves the re-attributed consumption in place; it is re-derived under the
old rules only as each meter next ingests a reading.
## Quick start (Docker) ## Quick start (Docker)
```bash ```bash
@@ -66,18 +87,67 @@ Configuration is via environment variables (`Section__Key` double-underscore map
| Variable | Purpose | | Variable | Purpose |
|----------|---------| |----------|---------|
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string | | `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) | | `MeterVault__TimeZone` | IANA timezone for buckets, display **and month attribution** (default `Europe/Berlin`). Changing it re-derives every meter's stored consumption at the next start, and historical monthly figures can shift. It must be an id both .NET and PostgreSQL know; anything else falls back to UTC and is reported in the log. |
| `MeterVault__Locale` | Default UI language, `en` or `de` (default `en`). Each visitor can switch it from the app bar; the choice is remembered in a cookie. |
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header | | `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) | | `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy | | `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers | | `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) | | `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
| `MeterVault__DataProtectionKeyPath` | Where the key ring for UI-entered connector secrets lives (default `/var/lib/metervault/keys`) |
| `MeterVault__UpdateCheckEnabled` | `false` to stop the dashboard checking for a newer release |
| `MeterVault__AllowInAppUpdate` | `true` to allow updates triggered from the UI/API — no key required, so anything that can reach MeterVault can trigger one; see below |
| `MeterVault__UpdateCheckUrl` | Tag listing consulted by that check (repoint at a fork; blank also disables it) |
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
returns 401. Set at least one API key (or open it explicitly for a trusted network). returns 401. Set at least one API key (or open it explicitly for a trusted network).
Secrets (broker/HA tokens) are **never** stored in the database — endpoint configs hold the *name* > **The web UI has no authentication.** There is no login: anything that can reach the port can read
of an environment variable, resolved at runtime. > and change everything, including connectors and their stored secrets. Put it behind a reverse proxy
> with auth (Authelia, Traefik forward-auth, …) — `MeterVault__ReverseProxyTrust` then honours the
> user header — or keep it on a trusted network.
The dashboard compares the running build against the newest tag in the source repository and shows a
banner when it is behind. That is a plain GET of a public tag list — nothing about the instance is
sent — cached for six hours, and it never blocks or fails a page render. Turn it off with
`MeterVault__UpdateCheckEnabled=false`.
### Updating from the UI (opt-in)
`MeterVault__AllowInAppUpdate=true` adds an **Update now** button to that banner, and a
`POST /api/v1/system/update` endpoint for scripting it from Home Assistant or `curl`:
```bash
curl -X POST http://metervault:8760/api/v1/system/update -H "X-MeterVault-Update: 1"
```
Both pull the latest source, rebuild, and restart the service — a few minutes during which MeterVault
is unavailable. Readings are untouched; ingestion resumes on restart. LXC only: containers are
replaced by pulling a new image, and the endpoint reports that rather than pretending.
> **That flag is the whole gate — there is no key and no prompt.** With it on, anything that can
> reach MeterVault can trigger a rebuild and restart. Realistically that is a repeatable denial of
> service (minutes of downtime and a busy CPU per request), not code injection, because the build
> comes from your own repository — but it becomes remote code execution if that repository is ever
> compromised. It defaults off. Enable it only on a network you trust, or behind an authenticating
> proxy.
>
> The `X-MeterVault-Update` header is **not** authentication: it stops a *different website* driving
> the endpoint through the browser of someone on your network, which a plain HTML form could
> otherwise do. The UI button does not need it — it runs over the Blazor circuit, which a foreign
> page cannot reach. Every triggered update is logged as a warning, since with no key there is no
> caller to attribute it to.
Secrets (broker/HA tokens) are **never** stored in the database as plaintext. Each connector picks
one of two forms: the *name* of an environment variable, resolved at runtime, or the secret typed
into the admin UI and encrypted at rest under the data-protection key ring. Either way a `pg_dump`
or JSON export carries nothing usable.
Keep the key ring on persistent storage outside the app directory — the default
`/var/lib/metervault/keys` survives an LXC update, and the Compose file mounts a named volume for it.
Lose it and every UI-entered secret must be re-entered. The key ring is on disk, so this protects
against leaked database content, not against an attacker who already has the host; that is the same
trust boundary an environment variable has.
## Pushing readings (Home Assistant) ## Pushing readings (Home Assistant)
+1 -1
View File
@@ -1 +1 @@
0.1.0 0.3.0
+2
View File
@@ -30,6 +30,8 @@ services:
environment: environment:
ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}" ConnectionStrings__Default: "Host=db;Port=5432;Database=metervault;Username=metervault;Password=${METERVAULT_DB_PASSWORD:-metervault}"
ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_ENVIRONMENT: Production
# Buckets, display AND month attribution. Changing it re-derives stored consumption at the next
# start; must be an IANA id both .NET and PostgreSQL know.
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin} MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR} MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
MeterVault__Locale: ${METERVAULT_LOCALE:-en} MeterVault__Locale: ${METERVAULT_LOCALE:-en}
+9
View File
@@ -23,6 +23,7 @@
: "${INSTALL_DIR:=/opt/metervault}" : "${INSTALL_DIR:=/opt/metervault}"
: "${SOURCE_DIR:=/opt/metervault-src}" : "${SOURCE_DIR:=/opt/metervault-src}"
: "${ENV_FILE:=/etc/metervault/environment}" : "${ENV_FILE:=/etc/metervault/environment}"
: "${KEYRING_DIR:=/var/lib/metervault/keys}"
: "${DB_NAME:=metervault}" : "${DB_NAME:=metervault}"
: "${DB_USER:=metervault}" : "${DB_USER:=metervault}"
@@ -203,6 +204,13 @@ EOF
chmod 600 "${ENV_FILE}" chmod 600 "${ENV_FILE}"
} }
# Key ring for connector secrets typed into the admin UI (SDD §6.4). The app creates this itself if
# missing, but with the default umask — created here instead so it is 0700 from the start, and so it
# is visibly outside /opt/metervault, which the updater republishes on every run.
write_keyring_dir() {
install -d -m 0700 "${KEYRING_DIR}"
}
write_systemd() { write_systemd() {
cat <<'EOF' >/etc/systemd/system/metervault.service cat <<'EOF' >/etc/systemd/system/metervault.service
[Unit] [Unit]
@@ -246,6 +254,7 @@ main() {
install_dotnet_sdk install_dotnet_sdk
build_metervault build_metervault
write_env write_env
write_keyring_dir
write_systemd write_systemd
systemctl daemon-reload 2>/dev/null || true systemctl daemon-reload 2>/dev/null || true
+15
View File
@@ -27,6 +27,21 @@ fi
export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1
# Anything between the stop and the restart can fail under `set -e`: a dotnet publish OOM-killed in a
# small container, a Gitea outage mid-fetch, a transient compile error on master. systemd's
# Restart=always does not cover a unit stopped on purpose, so without this the service simply stays
# down until someone notices. Bring the old build back up and say what happened — a failed update
# should cost the new version, not the running one.
restore_service_on_failure() {
local code=$?
if [[ ${code} -ne 0 ]]; then
echo "Update failed (exit ${code}). Restarting the previous build…" >&2
systemctl start metervault || echo "Could not restart metervault — check 'systemctl status metervault'." >&2
fi
exit "${code}"
}
trap restore_service_on_failure EXIT
echo "Stopping metervault…" echo "Stopping metervault…"
systemctl stop metervault || true systemctl stop metervault || true
+6 -2
View File
@@ -18,9 +18,13 @@
<Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config> <Config Name="Database connection" Target="ConnectionStrings__Default" Default="Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme" Mode="" Description="PostgreSQL/TimescaleDB connection string" Type="Variable" Display="always" Required="true">Host=timescaledb;Port=5432;Database=metervault;Username=metervault;Password=changeme</Config>
<Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing/display" Type="Variable" Display="always" Required="false">Europe/Berlin</Config> <Config Name="Time zone" Target="MeterVault__TimeZone" Default="Europe/Berlin" Mode="" Description="IANA timezone for bucketing, display and month attribution. Changing it re-derives stored consumption at the next start." Type="Variable" Display="always" Required="false">Europe/Berlin</Config>
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank to leave the API open." Type="Variable" Display="always" Required="false" Mask="true"/> <Config Name="Language" Target="MeterVault__Locale" Default="en" Mode="" Description="Default UI language: en or de. Each visitor can switch it in the app bar." Type="Variable" Display="always" Required="false">en</Config>
<Config Name="API key" Target="MeterVault__ApiKeys__0" Default="" Mode="" Description="API key for the REST API (X-Api-Key header). Leave blank and the API stays closed (401)." Type="Variable" Display="always" Required="false" Mask="true"/>
<Config Name="Reverse-proxy trust" Target="MeterVault__ReverseProxyTrust" Default="false" Mode="" Description="Honour X-Forwarded-User from a trusted auth proxy" Type="Variable" Display="advanced" Required="false">false</Config> <Config Name="Reverse-proxy trust" Target="MeterVault__ReverseProxyTrust" Default="false" Mode="" Description="Honour X-Forwarded-User from a trusted auth proxy" Type="Variable" Display="advanced" Required="false">false</Config>
<Config Name="Secret key ring" Target="/var/lib/metervault/keys" Default="/mnt/user/appdata/metervault/keys" Mode="rw" Description="Encryption keys for connector secrets entered in the web UI. Must persist: without this mapping every stored token is lost when the container is recreated." Type="Path" Display="always" Required="true">/mnt/user/appdata/metervault/keys</Config>
</Container> </Container>
+6
View File
@@ -429,6 +429,12 @@ The key ring must be persisted outside the app directory (`MeterVault__DataProte
### 7.1 Register → consumption ### 7.1 Register → consumption
For `cumulative_counter`/`generation_counter`: for each new reading, `amount = value previous_value`. Persist to `consumption`. Cross a `meter_swap` as `(old_final prev) + (curr new_initial)`; a `counter_reset` starts a fresh baseline. Ignore/annotate negative deltas that lack an explaining event (flag as anomaly). For `cumulative_counter`/`generation_counter`: for each new reading, `amount = value previous_value`. Persist to `consumption`. Cross a `meter_swap` as `(old_final prev) + (curr new_initial)`; a `counter_reset` starts a fresh baseline. Ignore/annotate negative deltas that lack an explaining event (flag as anomaly).
**Month attribution.** A reading is an instant, and the consumption between two readings accrued over the time between them. Booking the whole delta at the closing reading misfiles it whenever the interval crosses a month boundary: readings on 1 August and 16 September would show six weeks of use in September and none in August. So a plain increase whose interval crosses one or more **local** month boundaries (instance timezone, §10 — the months the charts bucket by) is divided at those boundaries in proportion to elapsed time. Each share is stamped inside its month — the closing reading keeps its own timestamp for the month it falls in, other shares take the last second of their month — and a divided interval's rows are marked `quality = estimated`: the meter recorded a total, not a shape. The parts always sum to the original.
Imported monthly tables keep the golden fixtures reconciling (§13). A row labelled "Mai 2026" carries the register at the *end* of May and May's consumption, but is stamped 00:00 UTC on 1 May so it files under its month. The importer flags such a row `reading.flags & month_label` — only it still knows whether the date cell named a month or a day, and a day-dated "01.08.2026" at the same midnight is an ordinary instant. A month label is read as the end of its month: readings are walked in that effective order by every register normalizer and by the checks that judge a new reading against its predecessor (so a sheet imported after live readings of the same month does not count the month twice, and a mid-month reading below the month's end value is not a decrease), consecutive rows span exactly their closing month and book unchanged, a skipped month is shared between the months the gap covers, and a live reading after the last imported row counts from the end of that row's month rather than claiming it a second time. A label is stamped inside the month it names — at its own timestamp where that lies in the local month, otherwise (zones behind UTC) at the month's local start. Swap and reset amounts are never divided: they are explicit corrections booked at the event. A swap the importer detected at a month row applies from the start of that local month, i.e. to the first reading in it, and a new register's recorded start value never counts above that reading. Should two rows still land on the same instant, the engine adds them into one estimated row rather than producing a duplicate key.
Every reader that buckets consumption by month or day (cost, trends, solar, consumables, flow, meter detail) buckets in the configured instance timezone — the same zone the division uses — never a hard-coded one, and starts and ends requested periods at local midnight. The zone id is normalised to its IANA form, and one unknown to .NET or PostgreSQL is reported at startup. Because consumption is derived, a change to these rules is applied to stored data at startup: `app_setting.normalization_revision` and `normalization_zone` record the rule revision and zone the stored series was built with, and every non-virtual meter is recomputed when either differs (the first run also flags month rows of earlier monthly imports, identified from each batch's stored mapping; a batch whose dates were auto-detected counts as monthly when all its rows sit on the 1st across at least two months, which is logged; if the flagging fails, nothing is rebuilt and the upgrade is retried at the next start). A meter whose rebuild fails is logged and listed in `normalization_pending`, retried at the next start, and never stops the application from starting.
### 7.2 Runtime → consumption (burner) ### 7.2 Runtime → consumption (burner)
For `runtime_counter`: `amount = Δhours × rate`. `rate` comes from the linked `tank`: `fixed` (nozzle spec, L/h) or `empirical` (`Δlevel ÷ Δhours` measured between deliveries/level reads — reproduce the spreadsheet's 1.87/1.94/2.92 … behaviour). Expose both; default empirical when level data exists, else fixed. For `runtime_counter`: `amount = Δhours × rate`. `rate` comes from the linked `tank`: `fixed` (nozzle spec, L/h) or `empirical` (`Δlevel ÷ Δhours` measured between deliveries/level reads — reproduce the spreadsheet's 1.87/1.94/2.92 … behaviour). Expose both; default empirical when level data exists, else fixed.
+44 -3
View File
@@ -4,6 +4,7 @@ using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Ingestion; using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using MeterVault.Infrastructure.Update;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MeterVault.App.Api; namespace MeterVault.App.Api;
@@ -24,6 +25,9 @@ public static class ApiEndpoints
{ {
private const int MaxReadingsPerRequest = 5000; private const int MaxReadingsPerRequest = 5000;
/// <summary>Header confirming an update request was made on purpose rather than by a foreign page.</summary>
public const string UpdateRequestHeader = "X-MeterVault-Update";
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
{ {
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault"); var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
@@ -36,20 +40,57 @@ public static class ApiEndpoints
} }
int written = 0, updated = 0, rejected = 0, ignored = 0; int written = 0, updated = 0, rejected = 0, ignored = 0;
var touched = new HashSet<int>();
foreach (var r in readings) foreach (var r in readings)
{ {
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct)) // Normalize once per meter after the batch, not per reading: a recompute rewrites the
// meter's whole consumption series, so doing it inside the loop is quadratic.
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, cancellationToken: ct))
{ {
case IngestionOutcome.Written: written++; break; case IngestionOutcome.Written: written++; touched.Add(r.MeterId); break;
case IngestionOutcome.Updated: updated++; break; case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break;
case IngestionOutcome.RejectedDecrease: rejected++; break; case IngestionOutcome.RejectedDecrease: rejected++; break;
default: ignored++; break; // unknown meter default: ignored++; break; // unknown meter
} }
} }
foreach (var meterId in touched)
{
await ingestion.RenormalizeMeterAsync(meterId, ct);
}
return Results.Ok(new IngestResult(written, updated, rejected, ignored)); return Results.Ok(new IngestResult(written, updated, rejected, ignored));
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push."); }).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
api.MapPost("/system/update", async (HttpContext http, UpdateRunner runner, CancellationToken ct) =>
{
if (runner.Availability is not UpdateAvailability.Allowed)
{
return Results.Problem(
statusCode: StatusCodes.Status409Conflict,
title: runner.Availability switch
{
UpdateAvailability.NotEnabled => "In-app update is disabled. Set MeterVault__AllowInAppUpdate=true.",
_ => "This install has no in-place update mechanism (containers are replaced, not updated).",
});
}
// Not authentication — the operator opted out of that. This only stops a *different site*
// driving the endpoint through the browser of someone on this network: a plain HTML form
// cannot set a custom header, and a cross-origin fetch that tries is stopped by the
// preflight, which nothing here answers. Costs a deliberate caller one flag.
if (!http.Request.Headers.ContainsKey(UpdateRequestHeader))
{
return Results.Problem(statusCode: StatusCodes.Status400BadRequest,
title: $"Send the {UpdateRequestHeader} header to confirm this is a deliberate request.");
}
var launch = await runner.LaunchAsync(ct);
return launch.Started
? Results.Accepted(value: new { message = launch.Message })
: Results.Problem(statusCode: StatusCodes.Status500InternalServerError, title: launch.Message);
}).WithSummary($"Start an in-place update (LXC only; requires MeterVault__AllowInAppUpdate and the {UpdateRequestHeader} header).");
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) => api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
Results.Ok(await db.Meters.AsNoTracking() Results.Ok(await db.Meters.AsNoTracking()
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive }) .Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
+1 -1
View File
@@ -5,7 +5,7 @@ namespace MeterVault.App.Api;
/// <summary> /// <summary>
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9). /// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
/// When no keys are configured the API is open — intended only for local development. /// With no keys configured the API is closed, unless <c>AllowAnonymousApi</c> opts into an open one.
/// </summary> /// </summary>
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
{ {
+28 -2
View File
@@ -1,5 +1,8 @@
<!DOCTYPE html> @using System.Globalization
<html lang="en"> @using Microsoft.AspNetCore.Localization
<!DOCTYPE html>
<html lang="@CultureInfo.CurrentUICulture.TwoLetterISOLanguageName">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -23,3 +26,26 @@
</body> </body>
</html> </html>
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
// Pin whatever the middleware negotiated (Accept-Language, or the configured default) into the
// culture cookie on the very first visit. Without this the language picker would be the only
// thing that ever writes the cookie, so a reader whose browser asked for German would be served
// German until the moment they touched the picker — and the picker would open showing English.
protected override void OnInitialized() =>
HttpContext?.Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(
new RequestCulture(CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture)),
new CookieOptions
{
Path = "/",
Expires = DateTimeOffset.UtcNow.AddYears(1),
SameSite = SameSiteMode.Lax,
HttpOnly = true,
IsEssential = true,
});
}
+55 -4
View File
@@ -1,5 +1,8 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using System.Globalization
@using MeterVault.App.Theme @using MeterVault.App.Theme
@inject NavigationManager Navigation
@inject IDialogService DialogService
<MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" /> <MudThemeProvider Theme="MeterVaultTheme.Instance" @bind-IsDarkMode="_darkMode" />
<MudPopoverProvider /> <MudPopoverProvider />
@@ -9,11 +12,28 @@
<MudLayout> <MudLayout>
<MudAppBar Elevation="1" Dense="true"> <MudAppBar Elevation="1" Dense="true">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" <MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="Toggle navigation" /> OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="@S.Layout_ToggleNavigation" />
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" /> <MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
<MudText Typo="Typo.h6">MeterVault</MudText> <MudText Typo="Typo.h6">MeterVault</MudText>
<MudSpacer /> <MudSpacer />
<MudTooltip Text="@(_darkMode ? "Light mode" : "Dark mode")"> <MudTooltip Text="@S.Layout_FindMeter">
<MudIconButton Icon="@Icons.Material.Filled.Search" Color="Color.Inherit" OnClick="OpenMeterSearchAsync"
aria-label="@S.Layout_FindMeter" />
</MudTooltip>
<MudTooltip Text="@S.Layout_Language">
<MudMenu Icon="@Icons.Material.Filled.Translate" Color="Color.Inherit"
AriaLabel="@S.Layout_Language" Dense="true">
@foreach (var culture in Loc.SupportedCultures)
{
<MudMenuItem OnClick="@(() => SwitchCulture(culture))"
Icon="@(culture == _current ? Icons.Material.Filled.Check : null)"
IconColor="Color.Primary">
@Loc.DisplayName(culture)
</MudMenuItem>
}
</MudMenu>
</MudTooltip>
<MudTooltip Text="@(_darkMode ? S.Layout_LightMode : S.Layout_DarkMode)">
<MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)" <MudIconButton Icon="@(_darkMode ? Icons.Material.Filled.LightMode : Icons.Material.Filled.DarkMode)"
Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" /> Color="Color.Inherit" OnClick="@(() => _darkMode = !_darkMode)" />
</MudTooltip> </MudTooltip>
@@ -31,12 +51,43 @@
</MudLayout> </MudLayout>
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred. @S.Layout_UnhandledError
<a href="." class="reload">Reload</a> <a href="." class="reload">@S.Layout_Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
@code { @code {
private bool _drawerOpen = true; private bool _drawerOpen = true;
private bool _darkMode = true; private bool _darkMode = true;
private string _current = Loc.SupportedCultures[0];
protected override void OnInitialized() =>
Loc.TryResolve(CultureInfo.CurrentUICulture.Name, out _current);
// A circuit is stuck with the culture it was opened under, so changing language is a real
// navigation: the endpoint writes the cookie and forceLoad tears the circuit down so the
// reload comes back translated. Returning to the current path keeps the reader in place.
private async Task OpenMeterSearchAsync() =>
await DialogService.ShowAsync<MeterSearchDialog>(S.Layout_FindMeter, new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseButton = true,
CloseOnEscapeKey = true,
Position = DialogPosition.TopCenter,
});
private void SwitchCulture(string culture)
{
if (culture == _current)
{
return;
}
var here = new Uri(Navigation.Uri).GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped);
Navigation.NavigateTo(
$"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri={Uri.EscapeDataString(here)}",
forceLoad: true);
}
} }
+59 -13
View File
@@ -1,34 +1,54 @@
@implements IDisposable
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject NavigationManager Navigation
@inject NavState NavState
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MeterVault.Core.Domain @using MeterVault.Core.Domain
@* Grouped by what the user came to do: look at the numbers, work with meters and data (readings,
swaps and imports all start from a meter or an import), or configure the instance. *@
<MudNavMenu> <MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink> <MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">@S.Nav_Overview</MudNavLink>
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink> <MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">@S.Nav_Trends</MudNavLink>
<MudText Typo="Typo.overline" Color="Color.Secondary" Class="d-block px-4 pt-3">@S.Nav_SectionData</MudText>
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">@S.Nav_Meters</MudNavLink>
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">@S.Nav_Import</MudNavLink>
<MudText Typo="Typo.overline" Color="Color.Secondary" Class="d-block px-4 pt-3">@S.Nav_SectionEnergy</MudText>
@foreach (var type in _energyTypes) @foreach (var type in _energyTypes)
{ {
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink> <MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
} }
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">@S.Nav_Solar</MudNavLink>
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">@S.Nav_Consumables</MudNavLink>
<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" /> <MudDivider Class="my-2" />
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false"> @* Opens by itself on an admin page — a bookmark, a reload, a language switch or a link from
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink> elsewhere would otherwise land with the current page folded away out of sight. *@
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink> <MudNavGroup Title="@S.Nav_Admin" Icon="@Icons.Material.Filled.Settings" @bind-Expanded="_adminExpanded">
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink> <MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">@S.Nav_EnergyTypes</MudNavLink>
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink> <MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">@S.Nav_Tariffs</MudNavLink>
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink> <MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">@S.Nav_CostCategories</MudNavLink>
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">@S.Nav_Connectors</MudNavLink>
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">@S.Nav_Settings</MudNavLink>
</MudNavGroup> </MudNavGroup>
</MudNavMenu> </MudNavMenu>
@code { @code {
private List<EnergyType> _energyTypes = []; private List<EnergyType> _energyTypes = [];
private bool _adminExpanded;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{
_adminExpanded = IsAdminPage(Navigation.Uri);
Navigation.LocationChanged += OnLocationChanged;
NavState.EnergyTypesChanged += OnEnergyTypesChanged;
await LoadEnergyTypesAsync();
}
private async Task LoadEnergyTypesAsync()
{ {
try try
{ {
@@ -42,7 +62,33 @@
} }
} }
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge. private bool IsAdminPage(string uri) =>
Navigation.ToBaseRelativePath(uri).StartsWith("admin/", StringComparison.OrdinalIgnoreCase);
// Opens only: collapsing it again when the user leaves is their call, not the menu's.
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
if (!_adminExpanded && IsAdminPage(e.Location))
{
_adminExpanded = true;
_ = InvokeAsync(StateHasChanged);
}
}
private void OnEnergyTypesChanged() =>
_ = InvokeAsync(async () =>
{
await LoadEnergyTypesAsync();
StateHasChanged();
});
public void Dispose()
{
Navigation.LocationChanged -= OnLocationChanged;
NavState.EnergyTypesChanged -= OnEnergyTypesChanged;
}
// Map the energy type's stored icon name to a Material icon; fall back to a bolt.
private static string TypeIcon(string? icon) => icon switch private static string TypeIcon(string? icon) => icon switch
{ {
"bolt" => Icons.Material.Filled.Bolt, "bolt" => Icons.Material.Filled.Bolt,
@@ -7,25 +7,25 @@
<div></div> <div></div>
</div> </div>
<p class="components-reconnect-first-attempt-visible"> <p class="components-reconnect-first-attempt-visible">
Rejoining the server... @S.Reconnect_Rejoining
</p> </p>
<p class="components-reconnect-repeated-attempt-visible"> <p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds. @S.Reconnect_RetryCountdownPrefix <span id="components-seconds-to-next-attempt"></span> @S.Reconnect_RetryCountdownSuffix
</p> </p>
<p class="components-reconnect-failed-visible"> <p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page. @S.Reconnect_RejoinFailed<br />@S.Reconnect_RetryOrReload
</p> </p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible"> <button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry @S.Reconnect_Retry
</button> </button>
<p class="components-pause-visible"> <p class="components-pause-visible">
The session has been paused by the server. @S.Reconnect_Paused
</p> </p>
<p class="components-resume-failed-visible"> <p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page. @S.Reconnect_ResumeFailed<br />@S.Reconnect_RetryOrReload
</p> </p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible"> <button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume @S.Reconnect_Resume
</button> </button>
</div> </div>
</dialog> </dialog>
+33 -33
View File
@@ -5,12 +5,12 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Cost categories</PageTitle> <PageTitle>MeterVault — @S.Nav_CostCategories</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Cost categories</MudText> <MudText Typo="Typo.h4">@S.Nav_CostCategories</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add category @S.Categories_AddCategory
</MudButton> </MudButton>
</div> </div>
@@ -22,22 +22,22 @@ else
{ {
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>@S.Common_Name</MudTh>
<MudTh>Sort</MudTh> <MudTh>@S.Categories_Sort</MudTh>
<MudTh>Members</MudTh> <MudTh>@S.Categories_Members</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name"> <MudTd DataLabel="@S.Common_Name">
@if (!string.IsNullOrWhiteSpace(context.ColorHex)) @if (!string.IsNullOrWhiteSpace(context.ColorHex))
{ {
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span> <span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
} }
@context.Name @context.Name
</MudTd> </MudTd>
<MudTd DataLabel="Sort">@context.Sort</MudTd> <MudTd DataLabel="@S.Categories_Sort">@context.Sort</MudTd>
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd> <MudTd DataLabel="@S.Categories_Members">@MemberSummary(context)</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -47,20 +47,20 @@ else
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText> <MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Categories_NewCategory : Loc.F(S.Categories_EditCategory, _working.Name))</MudText>
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" /> <MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" /> <MudTextField @bind-Value="_working.ColorHex" Label="@S.Categories_ColorHex" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" /> <MudNumericField T="int" @bind-Value="_working.Sort" Label="@S.Categories_SortOrder" Class="mb-2" />
@if (_working.Id != 0) @if (_working.Id != 0)
{ {
<MudDivider Class="my-3" /> <MudDivider Class="my-3" />
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText> <MudText Typo="Typo.subtitle2" Class="mb-2">@S.Categories_Members</MudText>
@if (_members.Count == 0) @if (_members.Count == 0)
{ {
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">@S.Categories_NoMembers</MudText>
} }
else else
{ {
@@ -77,30 +77,30 @@ else
</MudList> </MudList>
} }
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap"> <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"> <MudSelect T="int?" @bind-Value="_addMeterId" Label="@S.Categories_AddMeter" Dense="true" Style="min-width:180px">
@foreach (var meter in _meters) @foreach (var meter in _meters)
{ {
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton> <MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">@S.Categories_Add</MudButton>
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px"> <MudSelect T="int?" @bind-Value="_addTypeId" Label="@S.Categories_AddEnergyType" Dense="true" Style="min-width:180px">
@foreach (var t in _energyTypes) @foreach (var t in _energyTypes)
{ {
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton> <MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">@S.Categories_Add</MudButton>
</div> </div>
} }
else else
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert> <MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Categories_SaveFirst</MudAlert>
} }
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Categories_Close</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -129,12 +129,12 @@ else
{ {
var meters = c.Members.Count(m => m.MeterId is not null); var meters = c.Members.Count(m => m.MeterId is not null);
var types = c.Members.Count(m => m.EnergyTypeId is not null); var types = c.Members.Count(m => m.EnergyTypeId is not null);
return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)"; return meters + types == 0 ? "—" : Loc.F(S.Categories_MemberSummary, meters, types);
} }
private string MemberLabel(CostCategoryMember m) => private string MemberLabel(CostCategoryMember m) =>
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}" m.MeterId is { } meterId ? Loc.F(S.Categories_MemberMeter, _meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}")
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}" : m.EnergyTypeId is { } typeId ? Loc.F(S.Categories_MemberType, _energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}")
: "—"; : "—";
private void OpenEdit(CostCategory? category) private void OpenEdit(CostCategory? category)
@@ -158,7 +158,7 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Name)) if (string.IsNullOrWhiteSpace(_working.Name))
{ {
Snackbar.Add("Name is required.", Severity.Warning); Snackbar.Add(S.Common_NameRequired, Severity.Warning);
return; return;
} }
@@ -169,7 +169,7 @@ else
db.CostCategories.Add(category); db.CostCategories.Add(category);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Re-open on the new category so members can be added. // Re-open on the new category so members can be added.
Snackbar.Add("Saved. Add members below.", Severity.Success); Snackbar.Add(S.Categories_SavedAddMembers, Severity.Success);
await LoadAsync(); await LoadAsync();
OpenEdit(_categories!.First(c => c.Id == category.Id)); OpenEdit(_categories!.First(c => c.Id == category.Id));
return; return;
@@ -181,7 +181,7 @@ else
existing.Sort = _working.Sort; existing.Sort = _working.Sort;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
@@ -235,8 +235,8 @@ else
private async Task DeleteAsync(CostCategory category) private async Task DeleteAsync(CostCategory category)
{ {
if (!await Confirm.DeleteAsync(DialogService, "Delete category", if (!await Confirm.DeleteAsync(DialogService, S.Categories_DeleteTitle,
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked.")) Loc.F(S.Categories_DeleteBody, category.Name, category.Members.Count)))
{ {
return; return;
} }
@@ -244,7 +244,7 @@ else
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
// Members cascade with the category; manual_cost.category_id is SetNull. // Members cascade with the category; manual_cost.category_id is SetNull.
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync(); await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
+283 -70
View File
@@ -4,22 +4,30 @@
@inject MeterVault.Infrastructure.Security.SecretProtector Secrets @inject MeterVault.Infrastructure.Security.SecretProtector Secrets
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject NavigationManager Nav
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MeterVault.Infrastructure.Ingestion @using MeterVault.Infrastructure.Ingestion
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Connectors</PageTitle> <PageTitle>MeterVault — @S.Nav_Connectors</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Connectors</MudText> <MudText Typo="Typo.h4">@S.Nav_Connectors</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add connector @S.Connectors_AddConnector
</MudButton> </MudButton>
</div> </div>
@if (_returnTo is { } back)
{
@* Arrived from a meter's source dialog: the way back is always in view, saved or not. *@
<MudAlert Severity="Severity.Normal" Variant="Variant.Outlined" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sensors">
@Loc.F(S.Connectors_ForMeter, back.MeterName) <MudLink Href="@MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType)">@Loc.F(S.Connectors_BackToMeter, back.MeterName)</MudLink>
</MudAlert>
}
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true"> <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> @S.Connectors_SecretsNoticePrefix <b>@S.Connectors_SecretsNoticeEnvVar</b> @S.Connectors_SecretsNoticeSuffix
(or Docker secret) resolved at runtime.
</MudAlert> </MudAlert>
@if (_endpoints is null) @if (_endpoints is null)
@@ -30,20 +38,43 @@ else
{ {
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>@S.Common_Name</MudTh>
<MudTh>Type</MudTh> <MudTh>@S.Common_Type</MudTh>
<MudTh>Enabled</MudTh> <MudTh>@S.Common_Enabled</MudTh>
<MudTh>Last status</MudTh> <MudTh>@S.Connectors_UsedBy</MudTh>
<MudTh>Last seen</MudTh> <MudTh>@S.Connectors_LastStatus</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh>@S.Common_LastSeen</MudTh>
<MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd> <MudTd DataLabel="@S.Common_Name">@context.Name</MudTd>
<MudTd DataLabel="Type">@context.Type</MudTd> <MudTd DataLabel="@S.Common_Type">@context.Type.Display()</MudTd>
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd> <MudTd DataLabel="@S.Common_Enabled">@(context.IsEnabled ? S.Connectors_Yes : S.Connectors_No)</MudTd>
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd> <MudTd DataLabel="@S.Connectors_UsedBy">
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd> @if (_usage.TryGetValue(context.Id, out var users))
<MudTd DataLabel="Actions" Style="text-align:right"> {
@foreach (var user in users.Take(UsersShown))
{
<MudLink Href="@MeterLinks.Detail(user.MeterId, MeterLinks.TabSources)" Class="mr-2">@user.MeterName</MudLink>
}
@if (users.Count > UsersShown)
{
<MudText Typo="Typo.caption" Inline="true">@Loc.F(S.Connectors_UsedByMore, users.Count - UsersShown)</MudText>
}
@if (!context.IsEnabled)
{
@* Workers skip disabled connectors, so every source on it has stopped. *@
<MudText Typo="Typo.caption" Color="Color.Warning" Class="d-block">@S.Connectors_DisabledInUse</MudText>
}
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.Connectors_Unused</MudText>
}
</MudTd>
<MudTd DataLabel="@S.Connectors_LastStatus">@(context.LastStatus ?? "—")</MudTd>
<MudTd DataLabel="@S.Common_LastSeen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
<MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -51,49 +82,49 @@ else
</MudTable> </MudTable>
@if (_endpoints.Count == 0) @if (_endpoints.Count == 0)
{ {
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert> <MudAlert Severity="Severity.Normal" Class="mt-4">@S.Connectors_Empty</MudAlert>
} }
} }
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText> <MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Connectors_NewConnector : Loc.F(S.Connectors_EditTitle, _working.Name))</MudText>
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2"> <MudSelect T="EndpointType" @bind-Value="_working.Type" Label="@S.Common_Type" Class="mb-2">
@foreach (var type in Enum.GetValues<EndpointType>()) @foreach (var type in Enum.GetValues<EndpointType>())
{ {
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem> <MudSelectItem T="EndpointType" Value="type">@type.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" /> <MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
@if (_working.Type == EndpointType.HomeAssistant) @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.BaseUrl" Label="@S.Connectors_BaseUrl" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="Enter the token here" Color="Color.Primary" Class="mb-1" /> <MudSwitch T="bool" @bind-Value="_working.UseDirectToken" Label="@S.Connectors_EnterTokenHere" Color="Color.Primary" Class="mb-1" />
@if (_working.UseDirectToken) @if (_working.UseDirectToken)
{ {
<MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1" <MudTextField @bind-Value="_working.Token" InputType="InputType.Password" Class="mb-1"
Label="@(_working.HasStoredToken ? "Long-lived access token (stored — type to replace)" : "Long-lived access token")" /> Label="@(_working.HasStoredToken ? S.Connectors_TokenStored : S.Connectors_Token)" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
Encrypted before it is stored; database dumps and JSON exports carry nothing usable. @S.Connectors_EncryptedHint
</MudText> </MudText>
} }
else else
{ {
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-1" /> <MudTextField @bind-Value="_working.TokenEnv" Label="@S.Connectors_TokenEnv" Class="mb-1" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
The variable's <em>name</em>, not the token. Set it on the server and restart the app. @S.Connectors_TokenEnvHintPrefix <em>@S.Connectors_TokenEnvHintEmphasis</em>@S.Connectors_TokenEnvHintSuffix
</MudText> </MudText>
} }
<MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="Real-time WebSocket push" Color="Color.Primary" Class="mb-1" /> <MudSwitch T="bool" @bind-Value="_working.UseWebSocket" Label="@S.Connectors_WebSocketPush" Color="Color.Primary" Class="mb-1" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block"> <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. @S.Connectors_WebSocketHint
</MudText> </MudText>
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" /> <MudTextField @bind-Value="_working.TestEntityId" Label="@S.Connectors_TestEntityId" Class="mb-2" />
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2"> <MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
Test connection @S.Connectors_TestConnection
</MudButton> </MudButton>
@if (_testing) @if (_testing)
{ {
@@ -101,41 +132,65 @@ else
} }
@if (_testResult is not null) @if (_testResult is not null)
{ {
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert> <MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@TestText(_testResult)</MudAlert>
} }
} }
else else
{ {
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" /> <MudTextField @bind-Value="_working.Host" Label="@S.Connectors_Host" Class="mb-2" />
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" /> <MudNumericField T="int" @bind-Value="_working.Port" Label="@S.Connectors_Port" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" /> <MudSwitch T="bool" @bind-Value="_working.Tls" Label="@S.Connectors_Tls" Color="Color.Primary" Class="mb-2" />
<MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="Enter credentials here" Color="Color.Primary" Class="mb-1" /> <MudSwitch T="bool" @bind-Value="_working.UseDirectCredentials" Label="@S.Connectors_EnterCredentialsHere" Color="Color.Primary" Class="mb-1" />
@if (_working.UseDirectCredentials) @if (_working.UseDirectCredentials)
{ {
<MudTextField @bind-Value="_working.Username" Label="Username (optional)" Class="mb-1" /> <MudTextField @bind-Value="_working.Username" Label="@S.Connectors_UsernameOptional" Class="mb-1" />
<MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1" <MudTextField @bind-Value="_working.Password" InputType="InputType.Password" Class="mb-1"
Label="@(_working.HasStoredPassword ? "Password (stored — type to replace)" : "Password (optional)")" /> Label="@(_working.HasStoredPassword ? S.Connectors_PasswordStored : S.Connectors_PasswordOptional)" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
Encrypted before it is stored; database dumps and JSON exports carry nothing usable. @S.Connectors_EncryptedHint
</MudText> </MudText>
} }
else else
{ {
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.UsernameEnv" Label="@S.Connectors_UsernameEnv" Class="mb-2" />
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.PasswordEnv" Label="@S.Connectors_PasswordEnv" Class="mb-2" />
} }
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.ExtraTopics" Label="@S.Connectors_ExtraTopics" Class="mb-2" />
} }
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" /> <MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@code { @code {
/// <summary>Opens a new connector of this <see cref="EndpointType"/> once the page is interactive.</summary>
[SupplyParameterFromQuery(Name = "new")]
public string? NewParam { get; set; }
/// <summary>Opens this connector for editing once the page is interactive.</summary>
[SupplyParameterFromQuery(Name = "edit")]
public int? EditParam { get; set; }
/// <summary>The meter whose source dialog sent the user here; see <see cref="MeterLinks.NewConnector"/>.</summary>
[SupplyParameterFromQuery(Name = "meter")]
public int? MeterParam { get; set; }
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
public int? SourceParam { get; set; }
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
public string? SourceTypeParam { get; set; }
private const int UsersShown = 3;
private List<IngestionEndpoint>? _endpoints; private List<IngestionEndpoint>? _endpoints;
private Dictionary<int, List<ConnectorUser>> _usage = [];
private ReturnTarget? _returnTo;
private (EndpointType? New, int? Edit)? _pendingOpen;
private bool _droppingOpen;
private bool _editOpen; private bool _editOpen;
private bool _testing; private bool _testing;
private HaTestResult? _testResult; private HaTestResult? _testResult;
@@ -144,10 +199,97 @@ else
protected override Task OnInitializedAsync() => LoadAsync(); protected override Task OnInitializedAsync() => LoadAsync();
protected override async Task OnParametersSetAsync()
{
if (MeterParam != _returnTo?.MeterId)
{
_returnTo = null;
if (MeterParam is { } meterId)
{
// Resolved against the database, so the way back names a meter that exists — and only
// ever leads to a meter page, whatever the query string says.
await using var db = await DbFactory.CreateDbContextAsync();
var name = await db.Meters.AsNoTracking().Where(m => m.Id == meterId).Select(m => m.Name).FirstOrDefaultAsync();
if (name is not null)
{
_returnTo = new ReturnTarget(meterId, name, SourceParam, null);
}
}
}
if (_returnTo is not null)
{
_returnTo = _returnTo with
{
SourceId = SourceParam,
SourceType = Enum.TryParse<SourceType>(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null,
};
}
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
{
_pendingOpen = (Enum.TryParse<EndpointType>(NewParam, ignoreCase: true, out var type) ? type : null, EditParam);
}
else
{
_droppingOpen = false;
}
}
/// <summary>
/// Opens a deep-linked connector dialog. The request is dropped from the address first and the dialog
/// opened once that navigation has come back — the order the meter page uses, for the same reason: a
/// circuit's first location change dismisses any dialog already open.
/// </summary>
protected override void OnAfterRender(bool firstRender)
{
if (_pendingOpen is not { } open || _endpoints is null)
{
return;
}
if (!string.IsNullOrEmpty(NewParam) || EditParam is not null)
{
if (!_droppingOpen)
{
_droppingOpen = true;
Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary<string, object?>
{
["new"] = null,
["edit"] = null,
}), replace: true);
}
return;
}
_pendingOpen = null;
if (open.Edit is { } editId && _endpoints.FirstOrDefault(e => e.Id == editId) is { } endpoint)
{
OpenEdit(endpoint);
}
else if (open.New is { } newType)
{
OpenEdit(null);
_working.Type = newType;
}
StateHasChanged();
}
private async Task LoadAsync() private async Task LoadAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(); _endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
var users = await db.MeterSources.AsNoTracking()
.Where(s => s.EndpointId != null)
.Select(s => new { EndpointId = s.EndpointId!.Value, s.MeterId, MeterName = s.Meter!.Name })
.ToListAsync();
_usage = users
.GroupBy(u => u.EndpointId)
.ToDictionary(
g => g.Key,
g => g.DistinctBy(u => u.MeterId).OrderBy(u => u.MeterName).Select(u => new ConnectorUser(u.MeterId, u.MeterName)).ToList());
} }
private void OpenEdit(IngestionEndpoint? endpoint) private void OpenEdit(IngestionEndpoint? endpoint)
@@ -168,6 +310,8 @@ else
// the field stays blank and only a typed value replaces what is stored. // the field stays blank and only a typed value replaces what is stored.
TokenEnc = ha.TokenEnc, TokenEnc = ha.TokenEnc,
UseDirectToken = !string.IsNullOrWhiteSpace(ha.TokenEnc), UseDirectToken = !string.IsNullOrWhiteSpace(ha.TokenEnc),
// The host the stored token was saved against; a stored token is never sent anywhere else.
SavedBaseUrl = ha.BaseUrl,
}; };
} }
else else
@@ -197,24 +341,36 @@ else
// saved, so a bad token is caught before it is stored. // saved, so a bad token is caught before it is stored.
if (_working.UseDirectToken) if (_working.UseDirectToken)
{ {
var token = !string.IsNullOrWhiteSpace(_working.Token) if (!string.IsNullOrWhiteSpace(_working.Token))
? _working.Token {
: (Secrets.TryUnprotect(_working.TokenEnc, out var stored) ? stored : null); _testResult = await HaTester.TestAsync(_working.BaseUrl, _working.Token, _working.TestEntityId);
}
_testResult = string.IsNullOrWhiteSpace(token) else if (!SameOrigin(_working.BaseUrl, _working.SavedBaseUrl))
? new HaTestResult(false, "Enter a token first.") {
: await HaTester.TestAsync(_working.BaseUrl, token, _working.TestEntityId); // Storing the token encrypted means the UI can decrypt something the operator
// can no longer read. Sending it to a Base URL edited in this dialog would turn
// "Test connection" into an exfiltration primitive — point it at any host and the
// token arrives as a Bearer header. A stored secret only ever goes to the origin
// it was saved for; testing elsewhere means typing the token again.
_testResult = new HaTestResult(false, S.Connectors_TestBaseUrlChanged);
}
else if (Secrets.TryUnprotect(_working.TokenEnc, out var stored) && stored is { Length: > 0 })
{
_testResult = await HaTester.TestAsync(_working.BaseUrl, stored, _working.TestEntityId);
}
else
{
_testResult = new HaTestResult(false, S.Connectors_TestNoToken);
}
} }
else if (string.IsNullOrWhiteSpace(_working.TokenEnv)) else if (string.IsNullOrWhiteSpace(_working.TokenEnv))
{ {
_testResult = new HaTestResult(false, _testResult = new HaTestResult(false, S.Connectors_TestNoTokenEnv);
"Name the environment variable holding the token, or switch on \"Enter the token here\".");
} }
else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken) else if (Environment.GetEnvironmentVariable(_working.TokenEnv) is not { Length: > 0 } envToken)
{ {
_testResult = new HaTestResult(false, _testResult = new HaTestResult(false,
$"Environment variable '{_working.TokenEnv}' is not set on the server. Set it and restart the app, " Loc.F(S.Connectors_TestEnvVarMissing, _working.TokenEnv));
+ "or switch on \"Enter the token here\" to store the token directly.");
} }
else else
{ {
@@ -231,7 +387,7 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Name)) if (string.IsNullOrWhiteSpace(_working.Name))
{ {
Snackbar.Add("Name is required.", Severity.Warning); Snackbar.Add(S.Common_NameRequired, Severity.Warning);
return; return;
} }
@@ -240,7 +396,7 @@ else
&& string.IsNullOrWhiteSpace(_working.Token) && string.IsNullOrWhiteSpace(_working.Token)
&& !_working.HasStoredToken) && !_working.HasStoredToken)
{ {
Snackbar.Add("Enter the token, or switch off \"Enter the token here\" and name an env var.", Severity.Warning); Snackbar.Add(S.Connectors_TokenRequired, Severity.Warning);
return; return;
} }
@@ -269,25 +425,38 @@ else
}.ToJson(); }.ToJson();
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
IngestionEndpoint saved;
if (_working.Id == 0) if (_working.Id == 0)
{ {
db.IngestionEndpoints.Add(new IngestionEndpoint saved = new IngestionEndpoint
{ {
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled, Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
}); };
db.IngestionEndpoints.Add(saved);
} }
else else
{ {
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id); saved = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
existing.Type = _working.Type; saved.Type = _working.Type;
existing.Name = _working.Name.Trim(); saved.Name = _working.Name.Trim();
existing.Config = config; saved.Config = config;
existing.IsEnabled = _working.IsEnabled; saved.IsEnabled = _working.IsEnabled;
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
// Back to the source that was waiting for this connector, with it picked — when it can serve that
// source. Anything else (disabled, or of another kind) keeps the user here, the way back in view.
if (_returnTo is { } back
&& saved.IsEnabled
&& (back.SourceType is not { } sourceType || SourceRouting.Serves(saved.Type, sourceType)))
{
Nav.NavigateTo(MeterLinks.Source(back.MeterId, back.SourceId, back.SourceType, saved.Id));
return;
}
await LoadAsync(); await LoadAsync();
} }
@@ -295,19 +464,36 @@ else
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id); var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id);
var note = sourceCount > 0 ? $" {sourceCount} source(s) reference it and will be unlinked." : ""; // Routing is endpoint-scoped, so "unlinked" now means those sources stop ingesting entirely
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}")) // rather than falling back to any broker. Say so plainly.
var note = sourceCount > 0
? " " + Loc.F(S.Connectors_DeleteInUseNote, sourceCount)
: "";
if (!await Confirm.DeleteAsync(
DialogService, S.Connectors_DeleteTitle, Loc.F(S.Connectors_DeleteBody, endpoint.Name) + note))
{ {
return; return;
} }
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync(); await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>
/// Whether two URLs address the same host. Compares scheme, host and port rather than the raw
/// string, so a trailing slash or a path tweak does not force the token to be re-typed. Fails
/// closed: anything unparsable counts as a different origin.
/// </summary>
private static bool SameOrigin(string? a, string? b) =>
Uri.TryCreate(a, UriKind.Absolute, out var left)
&& Uri.TryCreate(b, UriKind.Absolute, out var right)
&& string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase)
&& left.Port == right.Port;
/// <summary> /// <summary>
/// Encrypts a newly typed secret, or keeps the stored ciphertext when the field was left blank. /// Encrypts a newly typed secret, or keeps the stored ciphertext when the field was left blank.
/// The plaintext is never sent to the browser, so blank means "unchanged", not "cleared". /// The plaintext is never sent to the browser, so blank means "unchanged", not "cleared".
@@ -318,6 +504,11 @@ else
private static IReadOnlyList<string> SplitTopics(string? csv) => private static IReadOnlyList<string> SplitTopics(string? csv) =>
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)]; string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
private sealed record ConnectorUser(int MeterId, string MeterName);
/// <summary>The meter source a user came here to set up a connector for.</summary>
private sealed record ReturnTarget(int MeterId, string MeterName, int? SourceId, SourceType? SourceType);
private sealed class EditModel private sealed class EditModel
{ {
public int Id { get; set; } public int Id { get; set; }
@@ -340,6 +531,9 @@ else
/// <summary>Stored ciphertext, round-tripped so leaving <see cref="Token"/> blank keeps it.</summary> /// <summary>Stored ciphertext, round-tripped so leaving <see cref="Token"/> blank keeps it.</summary>
public string? TokenEnc { get; set; } public string? TokenEnc { get; set; }
/// <summary>Base URL as saved, so an edited one can be told from the token's own host.</summary>
public string? SavedBaseUrl { get; set; }
public bool HasStoredToken => !string.IsNullOrWhiteSpace(TokenEnc); public bool HasStoredToken => !string.IsNullOrWhiteSpace(TokenEnc);
// MQTT broker // MQTT broker
@@ -361,4 +555,23 @@ else
public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc); public bool HasStoredPassword => !string.IsNullOrWhiteSpace(PasswordEnc);
} }
// HaTestResult.Message stays English for the log; the reader gets the verdict in their own
// language. Anything the connector page decided for itself (Precondition) already carries its
// own localized wording, and HA's own diagnostics — an HTTP status, an exception — are passed
// through untranslated because they are not ours to reword.
private static string TestText(HaTestResult result) => result.Outcome switch
{
HaTestOutcome.Connected => S.Connectors_TestConnected,
HaTestOutcome.ConnectedWithValue => Loc.F(
S.Connectors_TestConnectedValue,
result.EntityId ?? string.Empty,
result.SampleValue is { } value ? Format.Number(value, 2) : string.Empty),
HaTestOutcome.BaseUrlMissing => S.Connectors_TestBaseUrlRequired,
HaTestOutcome.TokenMissing => S.Connectors_TestTokenMissing,
HaTestOutcome.HttpError => Loc.F(S.Connectors_TestHttpError, result.Detail ?? string.Empty),
HaTestOutcome.NoNumericState => Loc.F(S.Connectors_TestNoNumericState, result.EntityId ?? string.Empty),
HaTestOutcome.RequestFailed => Loc.F(S.Connectors_TestFailed, result.Detail ?? string.Empty),
_ => result.Message,
};
} }
@@ -2,15 +2,16 @@
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject NavState NavState
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Energy types</PageTitle> <PageTitle>MeterVault — @S.Nav_EnergyTypes</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Energy types</MudText> <MudText Typo="Typo.h4">@S.Nav_EnergyTypes</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add energy type @S.EnergyTypes_Add
</MudButton> </MudButton>
</div> </div>
@@ -22,24 +23,24 @@ else
{ {
<MudTable Items="_types" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_types" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Key</MudTh> <MudTh>@S.EnergyTypes_Key</MudTh>
<MudTh>Display name</MudTh> <MudTh>@S.EnergyTypes_DisplayName</MudTh>
<MudTh>Base unit</MudTh> <MudTh>@S.EnergyTypes_BaseUnit</MudTh>
<MudTh>Default mode</MudTh> <MudTh>@S.EnergyTypes_DefaultMode</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Key">@context.Key</MudTd> <MudTd DataLabel="@S.EnergyTypes_Key">@context.Key</MudTd>
<MudTd DataLabel="Display name"> <MudTd DataLabel="@S.EnergyTypes_DisplayName">
@if (!string.IsNullOrWhiteSpace(context.ColorHex)) @if (!string.IsNullOrWhiteSpace(context.ColorHex))
{ {
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span> <span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
} }
@context.DisplayName @context.DisplayName
</MudTd> </MudTd>
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd> <MudTd DataLabel="@S.EnergyTypes_BaseUnit">@context.BaseUnit</MudTd>
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd> <MudTd DataLabel="@S.EnergyTypes_DefaultMode">@context.DefaultMode.Display()</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -49,24 +50,24 @@ else
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText> <MudText Typo="Typo.h6">@(_working.Id == 0 ? S.EnergyTypes_NewTitle : Loc.F(S.EnergyTypes_EditTitle, _working.DisplayName))</MudText>
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" /> <MudTextField @bind-Value="_working.Key" Label="@S.EnergyTypes_KeyLabel" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" /> <MudTextField @bind-Value="_working.DisplayName" Label="@S.EnergyTypes_DisplayName" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" /> <MudTextField @bind-Value="_working.BaseUnit" Label="@S.EnergyTypes_BaseUnitLabel" Required="true" Class="mb-2" />
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2"> <MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.EnergyTypes_DefaultMode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>()) @foreach (var mode in Enum.GetValues<MeterMode>())
{ {
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem> <MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" /> <MudTextField @bind-Value="_working.Icon" Label="@S.EnergyTypes_IconLabel" Class="mb-2" />
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" /> <MudTextField @bind-Value="_working.ColorHex" Label="@S.EnergyTypes_ColorLabel" />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -105,14 +106,14 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit)) 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); Snackbar.Add(S.EnergyTypes_RequiredFields, Severity.Warning);
return; return;
} }
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id)) 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); Snackbar.Add(Loc.F(S.EnergyTypes_KeyInUse, _working.Key), Severity.Error);
return; return;
} }
@@ -141,7 +142,8 @@ else
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
NavState.NotifyEnergyTypesChanged();
await LoadAsync(); await LoadAsync();
} }
@@ -151,11 +153,11 @@ else
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id); var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
if (meterCount > 0) if (meterCount > 0)
{ {
Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error); Snackbar.Add(Loc.F(S.EnergyTypes_DeleteBlocked, type.DisplayName, meterCount), Severity.Error);
return; return;
} }
if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone.")) if (!await Confirm.DeleteAsync(DialogService, S.EnergyTypes_DeleteTitle, Loc.F(S.EnergyTypes_DeleteBody, type.DisplayName)))
{ {
return; return;
} }
@@ -165,7 +167,8 @@ else
{ {
db.EnergyTypes.Remove(target); db.EnergyTypes.Remove(target);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
NavState.NotifyEnergyTypesChanged();
} }
await LoadAsync(); await LoadAsync();
+19 -20
View File
@@ -2,29 +2,28 @@
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options @inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Settings</PageTitle> <PageTitle>MeterVault — @S.Nav_Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText> <MudText Typo="Typo.h4" Class="mb-2">@S.Nav_Settings</MudText>
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true"> <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 @S.Settings_EffectiveLead <b>@S.Settings_EffectiveEmphasis</b> @S.Settings_EffectiveRest
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the (<code>MeterVault__Key</code> / <code>Section__Key</code>) @S.Settings_EffectiveTail
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
</MudAlert> </MudAlert>
<MudGrid> <MudGrid>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Locale &amp; time</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Settings_LocaleAndTime</MudText>
<MudSimpleTable Dense="true"> <MudSimpleTable Dense="true">
<tbody> <tbody>
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr> <tr><td>@S.Settings_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>@S.Settings_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>@S.Common_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> <tr><td>@S.Settings_RawRetention</td><td style="text-align:right">@Loc.F(S.Settings_RetentionDays, _o.RawRetentionDays)</td></tr>
</tbody> </tbody>
</MudSimpleTable> </MudSimpleTable>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>, @S.Settings_EnvKeysLabel <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>. <code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
</MudText> </MudText>
</MudPaper> </MudPaper>
@@ -32,7 +31,7 @@
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Access &amp; ingestion</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Settings_AccessAndIngestion</MudText>
<MudSimpleTable Dense="true"> <MudSimpleTable Dense="true">
<tbody> <tbody>
<tr> <tr>
@@ -40,26 +39,26 @@
<td style="text-align:right"> <td style="text-align:right">
@if (_o.ApiKeys.Count > 0) @if (_o.ApiKeys.Count > 0)
{ {
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip> <MudChip T="string" Size="Size.Small" Color="Color.Success">@Loc.F(S.Settings_ApiKeysConfigured, _o.ApiKeys.Count)</MudChip>
} }
else if (_o.AllowAnonymousApi) else if (_o.AllowAnonymousApi)
{ {
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip> <MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.Settings_ApiOpen</MudChip>
} }
else else
{ {
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip> <MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Settings_ApiClosed</MudChip>
} }
</td> </td>
</tr> </tr>
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr> <tr><td>@S.Settings_ReverseProxyTrust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? S.Settings_On : S.Settings_Off)</td></tr>
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr> <tr><td>@S.Settings_LiveIngestionWorkers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? S.Settings_On : S.Settings_Off)</td></tr>
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr> <tr><td>@S.Settings_SeedReferenceData</td><td style="text-align:right">@(_o.SeedReferenceData ? S.Settings_On : S.Settings_Off)</td></tr>
</tbody> </tbody>
</MudSimpleTable> </MudSimpleTable>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2"> <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. @S.Settings_ApiKeysHintBefore <code>MeterVault__ApiKeys__0</code>. @S.Settings_ApiKeysHintAfter
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>. @S.Settings_ApiDocsLabel <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
</MudText> </MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
+42 -41
View File
@@ -5,12 +5,12 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Tariffs</PageTitle> <PageTitle>MeterVault — @S.Nav_Tariffs</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Tariffs</MudText> <MudText Typo="Typo.h4">@S.Nav_Tariffs</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
Add tariff @S.Tariffs_AddTariff
</MudButton> </MudButton>
</div> </div>
@@ -22,22 +22,22 @@ else
{ {
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2"> <MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
<HeaderContent> <HeaderContent>
<MudTh>Scope</MudTh> <MudTh>@S.Common_Scope</MudTh>
<MudTh>Component</MudTh> <MudTh>@S.Tariffs_Component</MudTh>
<MudTh>Value</MudTh> <MudTh>@S.Common_Value</MudTh>
<MudTh>Unit</MudTh> <MudTh>@S.Common_Unit</MudTh>
<MudTh>Valid from</MudTh> <MudTh>@S.Tariffs_ValidFrom</MudTh>
<MudTh>Valid to</MudTh> <MudTh>@S.Tariffs_ValidTo</MudTh>
<MudTh Style="text-align:right">Actions</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd> <MudTd DataLabel="@S.Common_Scope">@ScopeLabel(context)</MudTd>
<MudTd DataLabel="Component">@context.Component</MudTd> <MudTd DataLabel="@S.Tariffs_Component">@context.Component.Display()</MudTd>
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd> <MudTd DataLabel="@S.Common_Value">@Format.Number(context.Value, 4)</MudTd>
<MudTd DataLabel="Unit">@context.Unit</MudTd> <MudTd DataLabel="@S.Common_Unit">@context.Unit</MudTd>
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd> <MudTd DataLabel="@S.Tariffs_ValidFrom">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd> <MudTd DataLabel="@S.Tariffs_ValidTo">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? S.Tariffs_OpenEnded)</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
</MudTd> </MudTd>
@@ -45,24 +45,24 @@ else
</MudTable> </MudTable>
@if (_tariffs.Count == 0) @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> <MudAlert Severity="Severity.Info" Class="mt-4">@S.Tariffs_EmptyState <MudLink Href="/import">@S.Nav_Import</MudLink>.</MudAlert>
} }
} }
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText> <MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Tariffs_NewTariff : S.Tariffs_EditTariff)</MudText>
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2"> <MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="@S.Common_Scope" Class="mb-2">
@foreach (var scope in Enum.GetValues<TariffScope>()) @foreach (var scope in Enum.GetValues<TariffScope>())
{ {
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem> <MudSelectItem T="TariffScope" Value="scope">@scope.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
@if (_working.ScopeType == TariffScope.EnergyType) @if (_working.ScopeType == TariffScope.EnergyType)
{ {
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2"> <MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_EnergyType" Class="mb-2">
@foreach (var t in _energyTypes) @foreach (var t in _energyTypes)
{ {
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
@@ -71,29 +71,29 @@ else
} }
else if (_working.ScopeType == TariffScope.Meter) else if (_working.ScopeType == TariffScope.Meter)
{ {
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2"> <MudSelect T="int?" @bind-Value="_working.ScopeId" Label="@S.Common_Meter" Class="mb-2">
@foreach (var m in _meters) @foreach (var m in _meters)
{ {
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
} }
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2"> <MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="@S.Tariffs_Component" Class="mb-2">
@foreach (var component in Enum.GetValues<TariffComponent>()) @foreach (var component in Enum.GetValues<TariffComponent>())
{ {
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem> <MudSelectItem T="TariffComponent" Value="component">@component.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" /> <MudNumericField T="double" @bind-Value="_working.Value" Label="@S.Common_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.Unit" Label="@S.Tariffs_UnitLabel" Required="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" /> <MudTextField @bind-Value="_working.Currency" Label="@S.Common_Currency" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" /> <MudDatePicker @bind-Date="_working.ValidFrom" Label="@S.Tariffs_ValidFrom" Class="mb-2" />
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" /> <MudDatePicker @bind-Date="_working.ValidTo" Label="@S.Tariffs_ValidToLabel" Clearable="true" Class="mb-2" />
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" /> <MudTextField @bind-Value="_working.Notes" Label="@S.Tariffs_NotesLabel" />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton> <MudButton OnClick="@(() => _editOpen = false)">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton> <MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">@S.Common_Save</MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -117,9 +117,9 @@ else
private string ScopeLabel(Tariff t) => t.ScopeType switch private string ScopeLabel(Tariff t) => t.ScopeType switch
{ {
TariffScope.Global => "Global", TariffScope.Global => S.Tariffs_ScopeGlobal,
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}", TariffScope.EnergyType => Loc.F(S.Tariffs_ScopeType, _energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"),
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}", TariffScope.Meter => Loc.F(S.Tariffs_ScopeMeter, _meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"),
_ => t.ScopeType.ToString(), _ => t.ScopeType.ToString(),
}; };
@@ -147,13 +147,13 @@ else
{ {
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null) if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
{ {
Snackbar.Add("Unit and valid-from are required.", Severity.Warning); Snackbar.Add(S.Tariffs_UnitAndValidFromRequired, Severity.Warning);
return; return;
} }
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null) if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
{ {
Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning); Snackbar.Add(S.Tariffs_ScopeTargetRequired, Severity.Warning);
return; return;
} }
@@ -191,13 +191,14 @@ else
await db.SaveChangesAsync(); await db.SaveChangesAsync();
_editOpen = false; _editOpen = false;
Snackbar.Add("Saved.", Severity.Success); Snackbar.Add(S.Common_Saved, Severity.Success);
await LoadAsync(); await LoadAsync();
} }
private async Task DeleteAsync(Tariff tariff) 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})?")) if (!await Confirm.DeleteAsync(DialogService, S.Tariffs_DeleteTitle,
Loc.F(S.Tariffs_DeleteBody, tariff.Component.Display(), Format.Number(tariff.Value, 4), tariff.Unit)))
{ {
return; return;
} }
@@ -208,7 +209,7 @@ else
{ {
db.Tariffs.Remove(target); db.Tariffs.Remove(target);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
} }
await LoadAsync(); await LoadAsync();
+59 -29
View File
@@ -1,28 +1,41 @@
@page "/consumables" @page "/consumables"
@inject ConsumableService ConsumablesSvc @inject ConsumableService ConsumablesSvc
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Consumables</PageTitle> <PageTitle>MeterVault — @S.Consumables_PageTitle</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Oil / consumables</MudText> <MudText Typo="Typo.h4">@S.Nav_Consumables</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px"> <MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem> <MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem> <MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem> <MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
</MudSelect> </MudSelect>
</div> </div>
@* A consumable meter without a tank has no capacity or calibration, so it cannot be summarised —
but it must not just be missing from the page, with nothing saying where it went. *@
@foreach (var meter in _unconfigured)
{
<MudAlert Severity="Severity.Warning" Class="mb-3">
@Loc.F(S.Consumables_TankNotConfigured, meter.Name)
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
Href="@MeterLinks.Detail(meter.MeterId, action: MeterLinks.ActionEdit)">@S.MeterDetail_SetUpTank</MudButton>
</MudAlert>
}
@if (_items is null) @if (_items is null)
{ {
<MudProgressLinear Indeterminate="true" Color="Color.Primary" /> <MudProgressLinear Indeterminate="true" Color="Color.Primary" />
} }
else if (_items.Count == 0) else if (_items.Count == 0 && _unconfigured.Count == 0)
{ {
<MudAlert Severity="Severity.Info"> <MudAlert Severity="Severity.Info">
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference @S.Consumables_NoMetersLead <b>@MeterMode.ConsumableBalance.Display()</b> @S.Consumables_NoMetersTail
data from <MudLink Href="/import">Import</MudLink>. <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.Consumables_NoMetersOrImport
<MudLink Href="/import">@S.Nav_Import</MudLink>.
</MudAlert> </MudAlert>
} }
else else
@@ -30,23 +43,31 @@ else
@foreach (var item in _items) @foreach (var item in _items)
{ {
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText> @* The actions for a tank are the ones done standing next to it, so they sit on its card. *@
<div class="d-flex align-center flex-wrap mb-3" style="gap:.5rem">
<MudLink Href="@MeterLinks.Detail(item.MeterId)" Typo="Typo.h6">@item.Name</MudLink>
<MudSpacer />
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
Href="@MeterLinks.Event(item.MeterId, MeterEventType.TankLevel)">@S.MeterDetail_RecordTankLevel</MudButton>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.LocalShipping"
Href="@MeterLinks.Event(item.MeterId, MeterEventType.Delivery)">@S.Consumables_RecordDelivery</MudButton>
</div>
<MudGrid> <MudGrid>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_TankLevel</MudText>
<MudText Typo="Typo.h5"> <MudText Typo="Typo.h5">
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—") @(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
</MudText> </MudText>
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" /> <MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
<MudText Typo="Typo.caption" Color="Color.Secondary"> <MudText Typo="Typo.caption" Color="Color.Secondary">
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit @Loc.F(S.Consumables_FillOfCapacity, Format.Number(item.FillFraction * 100, 0), Format.Number(item.Capacity, 0), item.Unit)
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm") @if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
{ {
<text> · @Format.Number(cm, 0) cm</text> <text> · @Format.Number(cm, 0) cm</text>
} }
@if (item.LevelAsOf is { } asOf) @if (item.LevelAsOf is { } asOf)
{ {
<text> · as of @asOf.ToString("yyyy-MM-dd")</text> <text> · @Loc.F(S.Consumables_AsOf, Local(asOf).ToString("yyyy-MM-dd"))</text>
} }
</MudText> </MudText>
</MudItem> </MudItem>
@@ -54,15 +75,15 @@ else
<MudItem xs="12" md="8"> <MudItem xs="12" md="8">
<MudGrid> <MudGrid>
<MudItem xs="6" sm="3"> <MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_UsedRange</MudText>
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText> <MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
</MudItem> </MudItem>
<MudItem xs="6" sm="3"> <MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_BurnerRuntime</MudText>
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText> <MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
</MudItem> </MudItem>
<MudItem xs="6" sm="3"> <MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_EffectiveRate</MudText>
<MudText Typo="Typo.subtitle1"> <MudText Typo="Typo.subtitle1">
@if (item.FixedRate is { } fr) @if (item.FixedRate is { } fr)
{ {
@@ -77,20 +98,20 @@ else
<text>—</text> <text>—</text>
} }
</MudText> </MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">@item.RateMode.Display()</MudText>
</MudItem> </MudItem>
<MudItem xs="6" sm="3"> <MudItem xs="6" sm="3">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText> <MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
</MudItem> </MudItem>
<MudItem xs="12" sm="6"> <MudItem xs="12" sm="6">
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Consumables_ForecastEmpty</MudText>
<MudText Typo="Typo.subtitle1"> <MudText Typo="Typo.subtitle1">
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—") @(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
@if (item.AveragePerDay is { } apd) @if (item.AveragePerDay is { } apd)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
(@Format.Number(apd, 1) @item.Unit/day) @Loc.F(S.Consumables_PerDay, Format.Number(apd, 1), item.Unit)
</MudText> </MudText>
} }
</MudText> </MudText>
@@ -99,28 +120,28 @@ else
</MudItem> </MudItem>
<MudItem xs="12" md="7"> <MudItem xs="12" md="7">
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText> <MudText Typo="Typo.subtitle2" Class="mb-2">@S.Consumables_ConsumptionByMonth</MudText>
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" /> <SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
</MudItem> </MudItem>
<MudItem xs="12" md="5"> <MudItem xs="12" md="5">
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText> <MudText Typo="Typo.subtitle2" Class="mb-2">@Loc.F(S.Consumables_DeliveriesCount, item.Deliveries.Count)</MudText>
@if (item.Deliveries.Count == 0) @if (item.Deliveries.Count == 0)
{ {
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">@S.Consumables_NoDeliveries</MudText>
} }
else else
{ {
<div style="max-height:260px; overflow-y:auto"> <div style="max-height:260px; overflow-y:auto">
<MudSimpleTable Dense="true" Hover="true"> <MudSimpleTable Dense="true" Hover="true">
<thead> <thead>
<tr><th>Date</th><th style="text-align:right">Amount</th></tr> <tr><th>@S.Common_Date</th><th style="text-align:right">@S.Common_Amount</th></tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var delivery in item.Deliveries) @foreach (var delivery in item.Deliveries)
{ {
<tr> <tr>
<td>@delivery.Time.ToString("yyyy-MM-dd")</td> <td>@Local(delivery.Time).ToString("yyyy-MM-dd")</td>
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td> <td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
</tr> </tr>
} }
@@ -138,8 +159,16 @@ else
private int _months = 60; private int _months = 60;
private bool _loading; private bool _loading;
private IReadOnlyList<ConsumableSummary>? _items; private IReadOnlyList<ConsumableSummary>? _items;
private IReadOnlyList<UnconfiguredConsumable> _unconfigured = [];
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
protected override Task OnInitializedAsync() => LoadAsync(); protected override Task OnInitializedAsync()
{
_tz = LocalTimeEntry.Resolve(Options.Value.TimeZone);
return LoadAsync();
}
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
private async Task OnRangeChanged(int months) private async Task OnRangeChanged(int months)
{ {
@@ -160,6 +189,7 @@ else
{ {
var asOf = DateOnly.FromDateTime(DateTime.UtcNow); var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = asOf.AddMonths(-_months); var from = asOf.AddMonths(-_months);
_unconfigured = await ConsumablesSvc.GetUnconfiguredAsync();
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); _items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
} }
finally finally
@@ -171,9 +201,9 @@ else
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item) private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
{ {
var points = item.Months var points = item.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption)) .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Consumption))
.ToList(); .ToList();
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)]; return [new SeriesChart.SeriesDef(Loc.F(S.Consumables_UnitUsed, item.Unit), ApexCharts.SeriesType.Bar, points)];
} }
private static Color FillColor(double fraction) => fraction switch private static Color FillColor(double fraction) => fraction switch
+36 -9
View File
@@ -1,9 +1,11 @@
@page "/" @page "/"
@inject DashboardService Dash @inject DashboardService Dash
<PageTitle>MeterVault — Overview</PageTitle> <PageTitle>MeterVault — @S.Nav_Overview</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Overview</MudText> <MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Overview</MudText>
<UpdateBanner />
@if (_summary is null) @if (_summary is null)
{ {
@@ -14,28 +16,28 @@ else
<MudGrid> <MudGrid>
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">This month</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_ThisMonth</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText> <MudText Typo="Typo.h5">@Format.Euro(_summary.Month.Current)</MudText>
<DeltaChip Kpi="_summary.Month" /> <DeltaChip Kpi="_summary.Month" />
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">This year</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_ThisYear</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText> <MudText Typo="Typo.h5">@Format.Euro(_summary.Year.Current)</MudText>
<DeltaChip Kpi="_summary.Year" /> <DeltaChip Kpi="_summary.Year" />
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="4"> <MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Latest month with data</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Dashboard_LatestMonthWithData</MudText>
<MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText> <MudText Typo="Typo.h5">@Format.Euro(_summary.LatestMonthCost)</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">What costs most (this year)</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatCostsMost</MudText>
@if (_breakdown is { Count: > 0 }) @if (_breakdown is { Count: > 0 })
{ {
<CategoryDonut Slices="_breakdown" /> <CategoryDonut Slices="_breakdown" />
@@ -53,17 +55,37 @@ else
} }
else else
{ {
<MudText Typo="Typo.body2" Color="Color.Secondary">No cost data yet — import a sheet or add tariffs.</MudText> @* Names the one step that is missing, in setup order, rather than every admin page. *@
<MudText Typo="Typo.body2" Color="Color.Secondary">
@switch (_setup?.FirstGap)
{
case CostSetupGap.NoMeters:
<span>@S.Dashboard_SetupNoMeters <MudLink Href="/meters">@S.Nav_Meters</MudLink> · <MudLink Href="/import">@S.Nav_Import</MudLink></span>
break;
case CostSetupGap.NoCategories:
<span>@S.Dashboard_SetupNoCategories <MudLink Href="/admin/categories">@S.Nav_CostCategories</MudLink></span>
break;
case CostSetupGap.NoMembers:
<span>@S.Dashboard_SetupNoMembers <MudLink Href="/meters">@S.Nav_Meters</MudLink> · <MudLink Href="/admin/categories">@S.Nav_CostCategories</MudLink></span>
break;
case CostSetupGap.NoTariffs:
<span>@S.Dashboard_SetupNoTariffs <MudLink Href="/admin/tariffs">@S.Nav_Tariffs</MudLink></span>
break;
default:
<span>@S.Dashboard_SetupNoCostsThisYear</span>
break;
}
</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">What cost more / less (year vs last year)</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Dashboard_WhatChanged</MudText>
<MudSimpleTable Dense="true" Hover="true"> <MudSimpleTable Dense="true" Hover="true">
<thead> <thead>
<tr><th>Category</th><th style="text-align:right">Now</th><th style="text-align:right">Prev</th><th style="text-align:right">Δ</th></tr> <tr><th>@S.Dashboard_ColCategory</th><th style="text-align:right">@S.Dashboard_ColCurrent</th><th style="text-align:right">@S.Dashboard_ColPrevious</th><th style="text-align:right">Δ</th></tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var row in _difference) @foreach (var row in _difference)
@@ -88,6 +110,7 @@ else
private DashboardSummary? _summary; private DashboardSummary? _summary;
private IReadOnlyList<CategorySlice> _breakdown = []; private IReadOnlyList<CategorySlice> _breakdown = [];
private IReadOnlyList<DifferenceRow> _difference = []; private IReadOnlyList<DifferenceRow> _difference = [];
private CostSetup? _setup;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -96,6 +119,10 @@ else
var yearStart = new DateOnly(asOf.Year, 1, 1); var yearStart = new DateOnly(asOf.Year, 1, 1);
_breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1)); _breakdown = await Dash.GetCategoryBreakdownAsync(yearStart, asOf.AddMonths(1));
if (_breakdown.Count == 0)
{
_setup = await Dash.GetCostSetupAsync();
}
_difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1)); _difference = await Dash.GetCategoryDifferenceAsync(yearStart, yearStart.AddYears(-1), asOf.AddMonths(1));
} }
} }
+91 -67
View File
@@ -2,18 +2,19 @@
@inject FlowService Flow @inject FlowService Flow
@inject MeterVault.Infrastructure.Costing.CostService Costs @inject MeterVault.Infrastructure.Costing.CostService Costs
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle> <PageTitle>MeterVault — @(_graph?.EnergyType ?? S.EnergyView_EnergyFallback)</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <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> <MudText Typo="Typo.h4">@Loc.F(S.EnergyView_FlowTitle, _graph?.EnergyType ?? S.EnergyView_EnergyFallback)</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px"> <MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem> <MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem> <MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem> <MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
</MudSelect> </MudSelect>
</div> </div>
@@ -21,81 +22,100 @@
{ {
<MudProgressLinear Indeterminate="true" Color="Color.Primary" /> <MudProgressLinear Indeterminate="true" Color="Color.Primary" />
} }
else if (!_graph.HasData) else if (_meters.Count == 0)
{ {
<MudAlert Severity="Severity.Info"> <MudAlert Severity="Severity.Info">
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the @S.EnergyView_NoMetersIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.EnergyView_NoMetersOr
reference data from <MudLink Href="/import">Import</MudLink>. <MudLink Href="/import">@S.Nav_Import</MudLink>.
</MudAlert> </MudAlert>
} }
else else
{ {
<MudGrid Class="mb-2"> @* Meters without consumption in the range still exist — a new one, or a quiet stretch — and this
<MudItem xs="12" sm="4"> page is a natural way in to them, so the list below always renders; only the figures wait. *@
<MudPaper Class="pa-4" Elevation="2"> @if (!_graph.HasData)
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText> {
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText> <MudAlert Severity="Severity.Info" Class="mb-4">@S.EnergyView_NoDataInRange</MudAlert>
</MudPaper> }
</MudItem> else
<MudItem xs="12" sm="4"> {
<MudPaper Class="pa-4" Elevation="2"> <MudGrid Class="mb-2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText> <MudItem xs="12" sm="4">
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText> <MudPaper Class="pa-4" Elevation="2">
</MudPaper> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.EnergyView_TopLevelThroughput</MudText>
</MudItem> <MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
<MudItem xs="12" sm="4"> </MudPaper>
<MudPaper Class="pa-4" Elevation="2"> </MudItem>
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText> <MudItem xs="12" sm="4">
<MudText Typo="Typo.h5">@_meters.Count</MudText> <MudPaper Class="pa-4" Elevation="2">
</MudPaper> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_CostRange</MudText>
</MudItem> <MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
</MudGrid> </MudPaper>
</MudItem>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_Meters</MudText>
<MudText Typo="Typo.h5">@_meters.Count</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText> <MudText Typo="Typo.h6" Class="mb-1">@S.EnergyView_Flow</MudText>
@if (_graph.HasChain) @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"> <MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead> @S.EnergyView_FlowCaption
<tbody> </MudText>
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value)) <SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
{
<tr>
<td>@node.Label</td>
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
</tr>
}
</tbody>
</MudSimpleTable>
} }
} else
</MudPaper> {
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
@S.EnergyView_NoChainIntro <MudLink Href="/meters">@S.Nav_Meters</MudLink> @S.EnergyView_NoChainMiddle
<b>@S.EnergyView_NoChainUpstream</b> @S.EnergyView_NoChainRest
</MudAlert>
@if (_graph.Nodes.Count > 0)
{
<MudSimpleTable Dense="true" Hover="true">
<thead><tr><th>@S.Common_Meter</th><th style="text-align:right">@S.EnergyView_ColConsumption</th></tr></thead>
<tbody>
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
{
<tr>
<td>@(node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label)</td>
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
}
</MudPaper>
}
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Common_Meters</MudText>
<MudSimpleTable Dense="true" Hover="true"> <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> <thead><tr><th>@S.Common_Name</th><th>@S.Common_Mode</th><th>@S.EnergyView_ColUpstreamOf</th><th style="text-align:right">@S.EnergyView_ColConsumption</th><th></th></tr></thead>
<tbody> <tbody>
@foreach (var meter in _meters) @foreach (var meter in _meters)
{ {
<tr> <tr>
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td> <td><MudLink Href="@MeterLinks.Detail(meter.Id)">@meter.Name</MudLink></td>
<td>@meter.Mode</td> <td>@meter.Mode.Display()</td>
<td>@UpstreamLabel(meter.Id)</td> <td>@UpstreamLabel(meter.Id)</td>
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td> <td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
<td style="text-align:right">
@if (MeterLinks.QuickEntry(meter.Id, meter.Mode) is { } entry)
{
<MudTooltip Text="@(meter.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
<MudIconButton Icon="@(meter.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
Size="Size.Small" Color="Color.Primary" Href="@entry"
aria-label="@(meter.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
</MudTooltip>
}
</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -138,7 +158,9 @@ else
var to = asOf.AddMonths(1); var to = asOf.AddMonths(1);
var typeId = (short)Id; var typeId = (short)Id;
_graph = await Flow.GetFlowAsync(typeId, from, to); // Assigned last: the page branches on the graph being loaded, and must not render it
// against the previous type's (or an empty) meter list in between.
var graph = await Flow.GetFlowAsync(typeId, from, to);
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync(); _meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
@@ -150,14 +172,16 @@ else
.GroupBy(l => l.FromMeterId) .GroupBy(l => l.FromMeterId)
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList()); .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 zone = MeterVault.Infrastructure.Options.InstanceTimeZone.Resolve(Options.Value.TimeZone);
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero); var fromUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(from, zone);
var toUtc = MeterVault.Infrastructure.Options.InstanceTimeZone.StartOf(to, zone);
double cost = 0; double cost = 0;
foreach (var meter in _meters) foreach (var meter in _meters)
{ {
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost); cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
} }
_cost = cost; _cost = cost;
_graph = graph;
} }
finally finally
{ {
+10 -10
View File
@@ -1,27 +1,27 @@
@page "/Error" @page "/Error"
@using System.Diagnostics @using System.Diagnostics
<PageTitle>Error</PageTitle> <PageTitle>@S.Error_PageTitle</PageTitle>
<h1 class="text-danger">Error.</h1> <h1 class="text-danger">@S.Error_Heading</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2> <h2 class="text-danger">@S.Error_Message</h2>
@if (ShowRequestId) @if (ShowRequestId)
{ {
<p> <p>
<strong>Request ID:</strong> <code>@RequestId</code> <strong>@S.Error_RequestId</strong> <code>@RequestId</code>
</p> </p>
} }
<h3>Development Mode</h3> <h3>@S.Error_DevelopmentMode</h3>
<p> <p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred. @* "Development" and ASPNETCORE_ENVIRONMENT are literal environment names — emphasised, never translated. *@
@((MarkupString)Loc.F(S.Error_DevelopmentSwap, "<strong>Development</strong>"))
</p> </p>
<p> <p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong> <strong>@S.Error_DevelopmentWarning</strong>
It can result in displaying sensitive information from exceptions to end users. @S.Error_DevelopmentWarningDetail
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong> @((MarkupString)Loc.F(S.Error_DevelopmentEnableHint, "<strong>Development</strong>", "<strong>ASPNETCORE_ENVIRONMENT</strong>"))
and restarting the app.
</p> </p>
@code{ @code{
+115 -36
View File
@@ -9,21 +9,20 @@
@using MeterVault.Infrastructure.Persistence @using MeterVault.Infrastructure.Persistence
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Import</PageTitle> <PageTitle>MeterVault — @S.Nav_Import</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Import</MudText> <MudText Typo="Typo.h4" Class="mb-4">@S.Nav_Import</MudText>
<MudGrid> <MudGrid>
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6">Reference dataset</MudText> <MudText Typo="Typo.h6">@S.Import_ReferenceDataset</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3"> <MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
Load the bundled Energiebilanz sheets (electricity, water, heating oil, costs) as a @S.Import_ReferenceDatasetHelp
starter dataset with meters, tariffs and categories.
</MudText> </MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload" <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.CloudDownload"
OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded"> OnClick="LoadReferenceAsync" Disabled="_loadingReference || _referenceLoaded">
@(_referenceLoaded ? "Loaded" : "Load reference data") @(_referenceLoaded ? S.Import_ReferenceLoaded : S.Import_LoadReferenceData)
</MudButton> </MudButton>
@if (_loadingReference) @if (_loadingReference)
{ {
@@ -35,22 +34,21 @@
<MudItem xs="12" md="6"> <MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<div class="d-flex align-center justify-space-between"> <div class="d-flex align-center justify-space-between">
<MudText Typo="Typo.h6">Your own CSV</MudText> <MudText Typo="Typo.h6">@S.Import_YourOwnCsv</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard" <MudButton Variant="Variant.Filled" Color="Color.Secondary" Href="/import/wizard"
StartIcon="@Icons.Material.Filled.AutoFixHigh">Mapping wizard</MudButton> StartIcon="@Icons.Material.Filled.AutoFixHigh">@S.Import_MappingWizard</MudButton>
</div> </div>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3 mt-1"> <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 @S.Import_YourOwnCsvHelp
revertible import. Or dry-run against one of the built-in reference profiles below.
</MudText> </MudText>
<MudSelect T="string" @bind-Value="_profileName" Label="Reference profile" Dense="true" Class="mb-2"> <MudSelect T="string" @bind-Value="_profileName" Label="@S.Import_ReferenceProfile" Dense="true" Class="mb-2">
<MudSelectItem T="string" Value="@("Strom")">Electricity (Strom)</MudSelectItem> <MudSelectItem T="string" Value="@("Strom")">@S.Import_ProfileElectricity</MudSelectItem>
<MudSelectItem T="string" Value="@("Wasser")">Water (Wasser)</MudSelectItem> <MudSelectItem T="string" Value="@("Wasser")">@S.Import_ProfileWater</MudSelectItem>
<MudSelectItem T="string" Value="@("Heizöl")">Heating oil (Heizöl)</MudSelectItem> <MudSelectItem T="string" Value="@("Heizöl")">@S.Import_ProfileHeatingOil</MudSelectItem>
<MudSelectItem T="string" Value="@("Kosten")">Costs (Kosten)</MudSelectItem> <MudSelectItem T="string" Value="@("Kosten")">@S.Import_ProfileCosts</MudSelectItem>
</MudSelect> </MudSelect>
<MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload"> <MudButton HtmlTag="label" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.UploadFile" for="csvUpload">
Dry-run a reference sheet @S.Import_DryRunReferenceSheet
</MudButton> </MudButton>
<InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" /> <InputFile id="csvUpload" OnChange="PreviewAsync" accept=".csv" style="display:none" />
</MudPaper> </MudPaper>
@@ -60,20 +58,20 @@
{ {
<MudItem xs="12"> <MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Preview</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Common_Preview</MudText>
<div class="d-flex" style="gap:2rem; flex-wrap:wrap"> <div class="d-flex" style="gap:2rem; flex-wrap:wrap">
<MudText>Readings: <b>@_preview.Readings.Count</b></MudText> <MudText>@S.Common_ReadingsLabel <b>@_preview.Readings.Count</b></MudText>
<MudText>Events: <b>@_preview.Events.Count</b></MudText> <MudText>@S.Common_EventsLabel <b>@_preview.Events.Count</b></MudText>
<MudText>Manual costs: <b>@_preview.ManualCosts.Count</b></MudText> <MudText>@S.Common_ManualCostsLabel <b>@_preview.ManualCosts.Count</b></MudText>
<MudText>Skipped rows: <b>@_preview.SkippedRows</b></MudText> <MudText>@S.Common_SkippedRowsLabel <b>@_preview.SkippedRows</b></MudText>
</div> </div>
@if (_preview.Warnings.Count > 0) @if (_preview.Warnings.Count > 0)
{ {
<MudExpansionPanels Class="mt-3"> <MudExpansionPanels Class="mt-3">
<MudExpansionPanel Text="@($"{_preview.Warnings.Count} warnings")"> <MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _preview.Warnings.Count)">
@foreach (var warning in _preview.Warnings.Take(50)) @foreach (var warning in _preview.Warnings.Take(50))
{ {
<MudText Typo="Typo.body2">@warning</MudText> <MudText Typo="Typo.body2">@warning.Display()</MudText>
} }
</MudExpansionPanel> </MudExpansionPanel>
</MudExpansionPanels> </MudExpansionPanels>
@@ -84,38 +82,57 @@
<MudItem xs="12"> <MudItem xs="12">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">Recent imports</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Import_RecentImports</MudText>
@if (_batches.Count == 0) @if (_batches.Count == 0)
{ {
<MudText Typo="Typo.body2" Color="Color.Secondary">No imports yet.</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">@S.Import_NoImportsYet</MudText>
} }
else else
{ {
<MudSimpleTable Dense="true" Hover="true"> <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> <thead><tr><th>#</th><th>@S.Import_ColumnSource</th><th>@S.Import_ColumnWritesTo</th><th style="text-align:right">@S.Import_ColumnRows</th><th>@S.Import_ColumnImported</th><th>@S.Common_Status</th><th></th></tr></thead>
<tbody> <tbody>
@foreach (var batch in _batches) @foreach (var batch in _batches)
{ {
<tr> <tr>
<td>@batch.Id</td> <td>@batch.Id</td>
<td>@(batch.SourceName ?? "—")</td> <td>@(batch.SourceName ?? "—")</td>
<td>
@* What the batch wrote to, so an import can be checked where it landed — and a
revert weighed against what it will take away. *@
@if (_targets.TryGetValue(batch.Id, out var targets))
{
@foreach (var meter in targets.Meters)
{
<MudLink Href="@MeterLinks.Detail(meter.Id)" Class="mr-2">@meter.Name</MudLink>
}
@foreach (var category in targets.Categories)
{
<MudLink Href="/admin/categories" Class="mr-2" Color="Color.Secondary">@category</MudLink>
}
}
else
{
<span>—</span>
}
</td>
<td style="text-align:right">@batch.RowCount</td> <td style="text-align:right">@batch.RowCount</td>
<td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td> <td>@batch.CreatedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm")</td>
<td> <td>
@if (batch.RevertedAt is not null) @if (batch.RevertedAt is not null)
{ {
<MudChip T="string" Size="Size.Small" Color="Color.Default">reverted</MudChip> <MudChip T="string" Size="Size.Small" Color="Color.Default">@S.Import_StatusReverted</MudChip>
} }
else else
{ {
<MudChip T="string" Size="Size.Small" Color="Color.Success">active</MudChip> <MudChip T="string" Size="Size.Small" Color="Color.Success">@S.Import_StatusActive</MudChip>
} }
</td> </td>
<td style="text-align:right"> <td style="text-align:right">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error" <MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Error"
StartIcon="@Icons.Material.Filled.Undo" StartIcon="@Icons.Material.Filled.Undo"
Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)" Disabled="@(batch.RevertedAt is not null || _reverting == batch.Id)"
OnClick="@(() => RevertAsync(batch))">Revert</MudButton> OnClick="@(() => RevertAsync(batch))">@S.Import_Revert</MudButton>
</td> </td>
</tr> </tr>
} }
@@ -132,6 +149,7 @@
private string _profileName = "Strom"; private string _profileName = "Strom";
private StagedImport? _preview; private StagedImport? _preview;
private List<ImportBatch> _batches = []; private List<ImportBatch> _batches = [];
private Dictionary<int, BatchTargets> _targets = [];
private int? _reverting; private int? _reverting;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -147,13 +165,74 @@
.OrderByDescending(b => b.Id) .OrderByDescending(b => b.Id)
.Take(25) .Take(25)
.ToListAsync(); .ToListAsync();
_targets = await LoadTargetsAsync(db, [.. _batches.Where(b => b.RevertedAt is null).Select(b => b.Id)]);
} }
/// <summary>
/// The meters and cost categories each batch wrote rows for. Read from the rows themselves rather
/// than the stored mapping, which the reference import does not have and which cannot say what a
/// partly failed or reverted batch actually left behind.
/// </summary>
private static async Task<Dictionary<int, BatchTargets>> LoadTargetsAsync(MeterVaultDbContext db, List<int> batchIds)
{
if (batchIds.Count == 0)
{
return [];
}
var readingMeters = await db.Readings.AsNoTracking()
.Where(r => r.ImportBatchId != null && batchIds.Contains(r.ImportBatchId.Value))
.Select(r => new { Batch = r.ImportBatchId!.Value, r.MeterId })
.Distinct()
.ToListAsync();
var eventMeters = await db.MeterEvents.AsNoTracking()
.Where(e => e.ImportBatchId != null && batchIds.Contains(e.ImportBatchId.Value))
.Select(e => new { Batch = e.ImportBatchId!.Value, e.MeterId })
.Distinct()
.ToListAsync();
var costs = await db.ManualCosts.AsNoTracking()
.Where(c => c.ImportBatchId != null && batchIds.Contains(c.ImportBatchId.Value))
.Select(c => new { Batch = c.ImportBatchId!.Value, c.MeterId, c.CategoryId })
.Distinct()
.ToListAsync();
var meterPairs = readingMeters.Select(r => (r.Batch, MeterId: (int?)r.MeterId))
.Concat(eventMeters.Select(e => (e.Batch, MeterId: (int?)e.MeterId)))
.Concat(costs.Select(c => (c.Batch, c.MeterId)))
.Where(p => p.MeterId is not null)
.Select(p => (p.Batch, MeterId: p.MeterId!.Value))
.Distinct()
.ToList();
var categoryPairs = costs.Where(c => c.CategoryId is not null).Select(c => (c.Batch, CategoryId: c.CategoryId!.Value)).Distinct().ToList();
var meterIds = meterPairs.Select(p => p.MeterId).Distinct().ToList();
var categoryIds = categoryPairs.Select(p => p.CategoryId).Distinct().ToList();
var meterNames = await db.Meters.AsNoTracking().Where(m => meterIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => m.Name);
var categoryNames = await db.CostCategories.AsNoTracking().Where(c => categoryIds.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, c => c.Name);
return batchIds
.Select(id => (Id: id, Targets: new BatchTargets(
[.. meterPairs.Where(p => p.Batch == id && meterNames.ContainsKey(p.MeterId))
.Select(p => new MeterTarget(p.MeterId, meterNames[p.MeterId]))
.OrderBy(m => m.Name, StringComparer.CurrentCulture)],
[.. categoryPairs.Where(p => p.Batch == id && categoryNames.ContainsKey(p.CategoryId))
.Select(p => categoryNames[p.CategoryId])
.Order(StringComparer.CurrentCulture)])))
.Where(t => t.Targets.Meters.Count > 0 || t.Targets.Categories.Count > 0)
.ToDictionary(t => t.Id, t => t.Targets);
}
private sealed record MeterTarget(int Id, string Name);
private sealed record BatchTargets(IReadOnlyList<MeterTarget> Meters, IReadOnlyList<string> Categories);
private async Task RevertAsync(ImportBatch batch) private async Task RevertAsync(ImportBatch batch)
{ {
if (!await Confirm.ConfirmAsync(Dialogs, "Revert import?", if (!await Confirm.ConfirmAsync(Dialogs, S.Import_RevertConfirmTitle,
$"Delete all {batch.RowCount} rows from import #{batch.Id} ({batch.SourceName ?? "unnamed"}) and recompute the affected meters?", Loc.F(S.Import_RevertConfirmBody, batch.RowCount, batch.Id, batch.SourceName ?? S.Import_UnnamedSource),
"Revert")) S.Import_Revert))
{ {
return; return;
} }
@@ -162,12 +241,12 @@
try try
{ {
await ImportService.RevertAsync(batch.Id); await ImportService.RevertAsync(batch.Id);
Snackbar.Add($"Import #{batch.Id} reverted.", Severity.Success); Snackbar.Add(Loc.F(S.Import_BatchReverted, batch.Id), Severity.Success);
await LoadBatchesAsync(); await LoadBatchesAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Revert failed: {ex.Message}", Severity.Error); Snackbar.Add(Loc.F(S.Import_RevertFailed, ex.Message), Severity.Error);
} }
finally finally
{ {
@@ -183,12 +262,12 @@
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata"); var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
await ReferenceImporter.LoadAsync(dir); await ReferenceImporter.LoadAsync(dir);
_referenceLoaded = true; _referenceLoaded = true;
Snackbar.Add("Reference data loaded.", Severity.Success); Snackbar.Add(S.Import_ReferenceDataLoaded, Severity.Success);
await LoadBatchesAsync(); await LoadBatchesAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Import failed: {ex.Message}", Severity.Error); Snackbar.Add(Loc.F(S.Import_Failed, ex.Message), Severity.Error);
} }
finally finally
{ {
+79 -57
View File
@@ -10,29 +10,28 @@
@using MeterVault.Infrastructure.Persistence @using MeterVault.Infrastructure.Persistence
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Import wizard</PageTitle> <PageTitle>MeterVault — @S.ImportWizard_Title</PageTitle>
<div class="d-flex align-center mb-4" style="gap:1rem"> <div class="d-flex align-center mb-4" style="gap:1rem">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="Back to Import" /> <MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/import" aria-label="@S.ImportWizard_BackToImport" />
<MudText Typo="Typo.h4">Import wizard</MudText> <MudText Typo="Typo.h4">@S.ImportWizard_Title</MudText>
</div> </div>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4"> <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 @S.ImportWizard_IntroLead
commit it as a revertible import. Values may use the German dialect (decimal comma, unit suffixes, <code>Monat JJJJ</code> @S.ImportWizard_IntroOr <code>TT.MM.JJJJ</code> @S.ImportWizard_IntroTail
<code>Monat JJJJ</code> or <code>TT.MM.JJJJ</code> dates) — the same parser the reference sheets use.
</MudText> </MudText>
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap"> <div class="d-flex align-center" style="gap:1rem; flex-wrap:wrap">
<MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary" <MudButton HtmlTag="label" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload"> StartIcon="@Icons.Material.Filled.UploadFile" for="wizardUpload">
Choose CSV @S.ImportWizard_ChooseCsv
</MudButton> </MudButton>
<InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" /> <InputFile id="wizardUpload" OnChange="OnFileAsync" accept=".csv" style="display:none" />
@if (_fileName is not null) @if (_fileName is not null)
{ {
<MudText><b>@_fileName</b> — @_rows.Count rows, @_colCount columns</MudText> <MudText><b>@_fileName</b> — @Loc.F(S.ImportWizard_FileSummary, _rows.Count, _colCount)</MudText>
} }
</div> </div>
</MudPaper> </MudPaper>
@@ -40,10 +39,10 @@
@if (_colCount > 0) @if (_colCount > 0)
{ {
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">1. Parsing options</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step1Title</MudText>
<MudGrid> <MudGrid>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudSelect T="int" @bind-Value="_dateColumn" Label="Date column" Dense="true"> <MudSelect T="int" @bind-Value="_dateColumn" Label="@S.ImportWizard_DateColumn" Dense="true">
@foreach (var i in Enumerable.Range(0, _colCount)) @foreach (var i in Enumerable.Range(0, _colCount))
{ {
<MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem> <MudSelectItem T="int" Value="i">@ColLabel(i)</MudSelectItem>
@@ -51,27 +50,27 @@
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudSelect T="DateKind" @bind-Value="_dateKind" Label="Date format" Dense="true"> <MudSelect T="DateKind" @bind-Value="_dateKind" Label="@S.ImportWizard_DateFormat" Dense="true">
<MudSelectItem T="DateKind" Value="DateKind.Auto">Auto-detect</MudSelectItem> <MudSelectItem T="DateKind" Value="DateKind.Auto">@S.ImportWizard_DateAutoDetect</MudSelectItem>
<MudSelectItem T="DateKind" Value="DateKind.MonthName">Month name (Januar 2024)</MudSelectItem> <MudSelectItem T="DateKind" Value="DateKind.MonthName">@S.ImportWizard_DateMonthName</MudSelectItem>
<MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">Day (31.12.2024)</MudSelectItem> <MudSelectItem T="DateKind" Value="DateKind.DayDotMonthYear">@S.ImportWizard_DateDay</MudSelectItem>
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<MudItem xs="6" sm="6" md="2"> <MudItem xs="6" sm="6" md="2">
<MudNumericField T="int" @bind-Value="_headerRow" Label="Header row" Min="0" Margin="Margin.Dense" /> <MudNumericField T="int" @bind-Value="_headerRow" Label="@S.ImportWizard_HeaderRow" Min="0" Margin="Margin.Dense" />
</MudItem> </MudItem>
<MudItem xs="6" sm="6" md="2"> <MudItem xs="6" sm="6" md="2">
<MudNumericField T="int" @bind-Value="_firstDataRow" Label="First data row" Min="0" Margin="Margin.Dense" /> <MudNumericField T="int" @bind-Value="_firstDataRow" Label="@S.ImportWizard_FirstDataRow" Min="0" Margin="Margin.Dense" />
</MudItem> </MudItem>
<MudItem xs="12" md="2" Class="d-flex flex-column"> <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="_skipAllZero" Label="@S.ImportWizard_SkipZeroRows" Color="Color.Primary" />
<MudSwitch T="bool" @bind-Value="_detectSwaps" Label="Detect swaps" Color="Color.Primary" /> <MudSwitch T="bool" @bind-Value="_detectSwaps" Label="@S.ImportWizard_DetectSwaps" Color="Color.Primary" />
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">2. Column preview</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step2Title</MudText>
<div style="overflow-x:auto"> <div style="overflow-x:auto">
<MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%"> <MudSimpleTable Dense="true" Bordered="true" Style="min-width:100%">
<thead> <thead>
@@ -79,7 +78,7 @@
@foreach (var i in Enumerable.Range(0, _colCount)) @foreach (var i in Enumerable.Range(0, _colCount))
{ {
<th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")"> <th style="@(i == _dateColumn ? "background:var(--mud-palette-primary-hover)" : "")">
Col @i@(i == _dateColumn ? " 📅" : "") @Loc.F(S.ImportWizard_ColumnN, i)@(i == _dateColumn ? " 📅" : "")
</th> </th>
} }
</tr> </tr>
@@ -98,23 +97,23 @@
</MudSimpleTable> </MudSimpleTable>
</div> </div>
<MudText Typo="Typo.caption" Color="Color.Secondary"> <MudText Typo="Typo.caption" Color="Color.Secondary">
Faded rows are before the first data row. The 📅 column supplies the date. @S.ImportWizard_PreviewCaption
</MudText> </MudText>
</MudPaper> </MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<MudText Typo="Typo.h6" Class="mb-2">3. Map columns</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.ImportWizard_Step3Title</MudText>
<div style="overflow-x:auto"> <div style="overflow-x:auto">
<MudSimpleTable Dense="true" Style="min-width:100%"> <MudSimpleTable Dense="true" Style="min-width:100%">
<thead> <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> <tr><th>@S.ImportWizard_HeaderColumn</th><th>@S.ImportWizard_HeaderSample</th><th style="min-width:160px">@S.ImportWizard_HeaderRole</th><th style="min-width:220px">@S.Common_Target</th><th style="min-width:120px">@S.Common_Unit</th></tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var i in Enumerable.Range(0, _colCount)) @foreach (var i in Enumerable.Range(0, _colCount))
{ {
<tr> <tr>
<td> <td>
<b>Col @i</b> <b>@Loc.F(S.ImportWizard_ColumnN, i)</b>
@if (!string.IsNullOrWhiteSpace(Header(i))) @if (!string.IsNullOrWhiteSpace(Header(i)))
{ {
<br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span> <br /><span style="font-size:0.72rem; opacity:0.7">@Header(i)</span>
@@ -125,7 +124,7 @@
<MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense"> <MudSelect T="MappingRole" @bind-Value="_columns[i].Role" Dense="true" Margin="Margin.Dense">
@foreach (var role in Enum.GetValues<MappingRole>()) @foreach (var role in Enum.GetValues<MappingRole>())
{ {
<MudSelectItem T="MappingRole" Value="role">@role</MudSelectItem> <MudSelectItem T="MappingRole" Value="role">@role.Display()</MudSelectItem>
} }
</MudSelect> </MudSelect>
</td> </td>
@@ -133,7 +132,7 @@
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel) @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" <MudSelect T="int?" @bind-Value="_columns[i].MeterId" Dense="true" Margin="Margin.Dense"
Placeholder="Select meter" Clearable="true"> Placeholder="@S.ImportWizard_SelectMeter" Clearable="true">
@foreach (var m in _meters) @foreach (var m in _meters)
{ {
<MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem> <MudSelectItem T="int?" Value="m.Id">@m.Name (@m.Unit)</MudSelectItem>
@@ -143,7 +142,7 @@
else if (_columns[i].Role == MappingRole.ManualCost) else if (_columns[i].Role == MappingRole.ManualCost)
{ {
<MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense" <MudSelect T="int?" @bind-Value="_columns[i].CategoryId" Dense="true" Margin="Margin.Dense"
Placeholder="Select category" Clearable="true"> Placeholder="@S.ImportWizard_SelectCategory" Clearable="true">
@foreach (var c in _categories) @foreach (var c in _categories)
{ {
<MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem> <MudSelectItem T="int?" Value="c.Id">@c.Name</MudSelectItem>
@@ -154,7 +153,7 @@
<td> <td>
@if (_columns[i].Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel) @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" /> <MudTextField @bind-Value="_columns[i].Unit" Placeholder="@S.ImportWizard_UnitPlaceholder" Margin="Margin.Dense" />
} }
</td> </td>
</tr> </tr>
@@ -176,12 +175,12 @@
<MudPaper Class="pa-4 mb-4" Elevation="2"> <MudPaper Class="pa-4 mb-4" Elevation="2">
<div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap"> <div class="d-flex align-center mb-2" style="gap:1rem; flex-wrap:wrap">
<MudText Typo="Typo.h6">4. Preview &amp; commit</MudText> <MudText Typo="Typo.h6">@S.ImportWizard_Step4Title</MudText>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility" <MudButton Variant="Variant.Outlined" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Visibility"
OnClick="Preview">Dry-run preview</MudButton> OnClick="Preview">@S.ImportWizard_DryRunButton</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save" <MudButton Variant="Variant.Filled" Color="Color.Success" StartIcon="@Icons.Material.Filled.Save"
OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing"> OnClick="CommitAsync" Disabled="_staged is null || _staged.TotalRows == 0 || _committing">
Commit import @S.ImportWizard_CommitButton
</MudButton> </MudButton>
@if (_committing) @if (_committing)
{ {
@@ -192,24 +191,24 @@
@if (_staged is not null) @if (_staged is not null)
{ {
<div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap"> <div class="d-flex mt-2" style="gap:2rem; flex-wrap:wrap">
<MudText>Readings: <b>@_staged.Readings.Count</b></MudText> <MudText>@S.Common_ReadingsLabel <b>@_staged.Readings.Count</b></MudText>
<MudText>Events: <b>@_staged.Events.Count</b></MudText> <MudText>@S.Common_EventsLabel <b>@_staged.Events.Count</b></MudText>
<MudText>Manual costs: <b>@_staged.ManualCosts.Count</b></MudText> <MudText>@S.Common_ManualCostsLabel <b>@_staged.ManualCosts.Count</b></MudText>
<MudText>Skipped rows: <b>@_staged.SkippedRows</b></MudText> <MudText>@S.Common_SkippedRowsLabel <b>@_staged.SkippedRows</b></MudText>
</div> </div>
@if (_staged.TotalRows == 0) @if (_staged.TotalRows == 0)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2"> <MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">
Nothing staged. Check the first-data-row, date column and column mappings above. @S.ImportWizard_NothingStaged
</MudAlert> </MudAlert>
} }
@if (_staged.Warnings.Count > 0) @if (_staged.Warnings.Count > 0)
{ {
<MudExpansionPanels Class="mt-3"> <MudExpansionPanels Class="mt-3">
<MudExpansionPanel Text="@($"{_staged.Warnings.Count} warnings")"> <MudExpansionPanel Text="@Loc.F(S.Import_WarningsCount, _staged.Warnings.Count)">
@foreach (var warning in _staged.Warnings.Take(100)) @foreach (var warning in _staged.Warnings.Take(100))
{ {
<MudText Typo="Typo.body2">@warning</MudText> <MudText Typo="Typo.body2">@warning.Display()</MudText>
} }
</MudExpansionPanel> </MudExpansionPanel>
</MudExpansionPanels> </MudExpansionPanels>
@@ -243,6 +242,7 @@
private List<Meter> _meters = []; private List<Meter> _meters = [];
private List<CostCategory> _categories = []; private List<CostCategory> _categories = [];
private StagedImport? _staged; private StagedImport? _staged;
private string? _stagedMapping;
private List<string> _validationErrors = []; private List<string> _validationErrors = [];
private bool _committing; private bool _committing;
@@ -265,6 +265,7 @@
_columns = Enumerable.Range(0, _colCount).Select(_ => new ColumnState()).ToArray(); _columns = Enumerable.Range(0, _colCount).Select(_ => new ColumnState()).ToArray();
_dateColumn = 0; _dateColumn = 0;
_staged = null; _staged = null;
_stagedMapping = null;
_validationErrors = []; _validationErrors = [];
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
@@ -283,8 +284,23 @@
using var reader = new StringReader(_csvText ?? string.Empty); using var reader = new StringReader(_csvText ?? string.Empty);
_staged = CsvImporter.Stage(BuildProfile(), reader); _staged = CsvImporter.Stage(BuildProfile(), reader);
_stagedMapping = BuildMappingJson();
} }
/// <summary>
/// The mapping as persisted on the batch for provenance, and — compared against the mapping the
/// preview was staged under — the check that the two still agree.
/// </summary>
private string BuildMappingJson() => JsonSerializer.Serialize(new
{
dateColumn = _dateColumn,
dateKind = _dateKind.ToString(),
firstDataRow = _firstDataRow,
columns = _columns
.Select((c, i) => new { index = i, role = c.Role.ToString(), c.MeterId, c.CategoryId, c.Unit })
.Where(c => c.role != nameof(MappingRole.Ignore)),
});
private async Task CommitAsync() private async Task CommitAsync()
{ {
if (_staged is null || _staged.TotalRows == 0) if (_staged is null || _staged.TotalRows == 0)
@@ -300,26 +316,29 @@
return; return;
} }
// The staged rows were built from the mapping as it stood at preview time. Editing a target
// afterwards leaves them pointing at the old meter while the batch would record the new
// mapping — wrong data, with a provenance record that contradicts it and no error to notice.
// Nothing here can re-derive the rows, so refuse rather than write either version.
var mappingJson = BuildMappingJson();
if (!string.Equals(mappingJson, _stagedMapping, StringComparison.Ordinal))
{
_staged = null;
_stagedMapping = null;
_validationErrors = [S.ImportWizard_MappingChanged];
return;
}
_committing = true; _committing = true;
try 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); var batchId = await ImportService.CommitAsync(_staged, _fileName, mappingJson);
Snackbar.Add($"Imported batch #{batchId}: {_staged.TotalRows} rows staged. Consumption recomputed.", Severity.Success); Snackbar.Add(Loc.F(S.ImportWizard_CommitSuccess, batchId, _staged.TotalRows), Severity.Success);
Nav.NavigateTo("/import"); Nav.NavigateTo("/import");
} }
catch (Exception ex) catch (Exception ex)
{ {
Snackbar.Add($"Commit failed: {ex.Message}", Severity.Error); Snackbar.Add(Loc.F(S.ImportWizard_CommitFailed, ex.Message), Severity.Error);
} }
finally finally
{ {
@@ -332,7 +351,7 @@
var errors = new List<string>(); var errors = new List<string>();
if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0) if (_columns.Count(c => c.Role != MappingRole.Ignore) == 0)
{ {
errors.Add("Map at least one column to a role other than Ignore."); errors.Add(S.ImportWizard_ValidateNoMappedColumn);
} }
for (var i = 0; i < _columns.Length; i++) for (var i = 0; i < _columns.Length; i++)
@@ -340,12 +359,12 @@
var c = _columns[i]; var c = _columns[i];
if (c.Role is MappingRole.Reading or MappingRole.Delivery or MappingRole.TankLevel && c.MeterId is null) 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."); errors.Add(Loc.F(S.ImportWizard_ValidateNeedsMeter, i, c.Role.Display()));
} }
if (c.Role == MappingRole.ManualCost && c.CategoryId is null) if (c.Role == MappingRole.ManualCost && c.CategoryId is null)
{ {
errors.Add($"Col {i} (ManualCost) needs a target category."); errors.Add(Loc.F(S.ImportWizard_ValidateNeedsCategory, i));
} }
} }
@@ -359,9 +378,10 @@
foreach (var group in duplicateTargets) foreach (var group in duplicateTargets)
{ {
var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name ?? $"meter {group.Key}"; var meterName = _meters.FirstOrDefault(m => m.Id == group.Key)?.Name
var cols = string.Join(", ", group.Select(x => $"Col {x.Index}")); ?? Loc.F(S.ImportWizard_MeterFallback, group.Key);
errors.Add($"{cols} all read into '{meterName}'. Each Reading column needs its own meter."); var cols = string.Join(", ", group.Select(x => Loc.F(S.ImportWizard_ColumnN, x.Index)));
errors.Add(Loc.F(S.ImportWizard_ValidateDuplicateMeter, cols, meterName));
} }
return errors; return errors;
@@ -411,6 +431,8 @@
private string ColLabel(int col) private string ColLabel(int col)
{ {
var header = Header(col); var header = Header(col);
return string.IsNullOrWhiteSpace(header) ? $"Col {col}" : $"Col {col}: {header}"; return string.IsNullOrWhiteSpace(header)
? Loc.F(S.ImportWizard_ColumnN, col)
: Loc.F(S.ImportWizard_ColumnWithHeader, col, header);
} }
} }
File diff suppressed because it is too large Load Diff
+99 -273
View File
@@ -1,17 +1,17 @@
@page "/meters" @page "/meters"
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject MeterVault.Core.Normalization.INormalizationEngine Engine
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject NavigationManager Nav
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Meters</PageTitle> <PageTitle>MeterVault — @S.Nav_Meters</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Meters</MudText> <MudText Typo="Typo.h4">@S.Common_Meters</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))"> <MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => _editor!.OpenNewAsync())">
Add meter @S.Meters_AddMeter
</MudButton> </MudButton>
</div> </div>
@@ -21,304 +21,153 @@
} }
else else
{ {
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2"> @* The whole row opens the meter — on a phone this table collapses to cards, where a name link
is a small target between edit and delete. Grouped by energy type, because that is how people
look for a meter ("the water one"), not by the order they were created in. *@
<MudTable Items="_meters" Dense="true" Hover="true" Elevation="2" Filter="Matches"
GroupBy="_byEnergyType" OnRowClick="@((TableRowClickEventArgs<Meter> e) => Nav.NavigateTo(MeterLinks.Detail(e.Item!.Id)))"
RowClass="cursor-pointer">
<ToolBarContent>
<MudTextField T="string" @bind-Value="_search" Immediate="true" Placeholder="@S.Meters_SearchPlaceholder"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Clearable="true"
Class="mt-0" Style="max-width:420px" />
</ToolBarContent>
<HeaderContent> <HeaderContent>
<MudTh>Name</MudTh> <MudTh>@S.Common_Name</MudTh>
<MudTh>Type</MudTh> <MudTh>@S.Common_Mode</MudTh>
<MudTh>Mode</MudTh> <MudTh>@S.Common_Unit</MudTh>
<MudTh>Unit</MudTh> <MudTh>@S.Meters_Sources</MudTh>
<MudTh>Sources</MudTh> <MudTh>@S.Common_LastSeen</MudTh>
<MudTh>Last seen</MudTh> <MudTh>@S.Meters_Active</MudTh>
<MudTh>Active</MudTh> <MudTh Style="text-align:right">@S.Common_Actions</MudTh>
<MudTh Style="text-align:right">Actions</MudTh>
</HeaderContent> </HeaderContent>
<GroupHeaderTemplate>
<MudTh colspan="7" Class="mud-table-cell-custom-group">
<MudText Typo="Typo.subtitle2">@context.Key</MudText>
</MudTh>
</GroupHeaderTemplate>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd> <MudTd DataLabel="@S.Common_Name">
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd> @* The link stays a real link (keyboard, open in new tab) but must not also fire the row's
<MudTd DataLabel="Mode">@context.Mode</MudTd> click, or one tap pushes the same page onto the history twice. *@
<MudTd DataLabel="Unit">@context.Unit</MudTd> <span @onclick:stopPropagation="true"><MudLink Href="@MeterLinks.Detail(context.Id)">@context.Name</MudLink></span>
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd> @if (!context.IsActive)
<MudTd DataLabel="Last seen"> {
<MudChip T="string" Size="Size.Small" Color="Color.Warning" Class="ml-2">@S.MeterDetail_Retired</MudChip>
}
</MudTd>
<MudTd DataLabel="@S.Common_Mode" HideSmall="true">@context.Mode.Display()</MudTd>
<MudTd DataLabel="@S.Common_Unit" HideSmall="true">@context.Unit</MudTd>
<MudTd DataLabel="@S.Meters_Sources" HideSmall="true">@context.Sources.Count</MudTd>
<MudTd DataLabel="@S.Common_LastSeen" HideSmall="true">
@{ @{
var lastSeen = context.Sources.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") ?? "—") @(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
</MudTd> </MudTd>
<MudTd DataLabel="Active">@(context.IsActive ? "yes" : "no")</MudTd> <MudTd DataLabel="@S.Meters_Active" HideSmall="true">@(context.IsActive ? S.Meters_Yes : S.Meters_No)</MudTd>
<MudTd DataLabel="Actions" Style="text-align:right"> <MudTd DataLabel="@S.Common_Actions" Style="text-align:right">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" /> @* Buttons inside a clickable row must not also open the meter. *@
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" /> <div class="d-inline-flex" @onclick:stopPropagation="true">
@if (MeterLinks.QuickEntry(context.Id, context.Mode) is { } entry)
{
<MudTooltip Text="@(context.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
<MudIconButton Icon="@(context.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
Size="Size.Small" Color="Color.Primary" Href="@entry"
aria-label="@(context.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
</MudTooltip>
}
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => _editor!.OpenAsync(context.Id))" aria-label="@S.MeterDetail_EditMeter" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" aria-label="@S.Common_Delete" />
</div>
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>
<NoRecordsContent>
@if (_meters.Count > 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">@Loc.F(S.Meters_NoSearchMatch, _search)</MudText>
}
</NoRecordsContent>
</MudTable> </MudTable>
@if (_meters.Count == 0) @if (_meters.Count == 0)
{ {
<MudAlert Severity="Severity.Info" Class="mt-4"> <MudAlert Severity="Severity.Info" Class="mt-4">
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data. @S.Meters_EmptyBefore <MudLink Href="/import">@S.Nav_Import</MudLink> @S.Meters_EmptyAfter
</MudAlert> </MudAlert>
} }
} }
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions"> <MeterEditor @ref="_editor" Saved="OnSavedAsync" SwapInsteadRequested="@(id => Nav.NavigateTo(MeterLinks.Event(id, MeterEventType.MeterSwap)))" />
<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 { @code {
private List<Meter>? _meters; private List<Meter>? _meters;
private List<EnergyType> _energyTypes = []; private MeterEditor? _editor;
private List<MeterLink> _allLinks = []; private string? _search;
private bool _editOpen;
private EditModel _working = new(); private readonly TableGroupDefinition<Meter> _byEnergyType = new()
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; {
Indentation = false,
Expandable = false,
Selector = m => m.EnergyType?.DisplayName ?? "—",
};
protected override Task OnInitializedAsync() => LoadAsync(); protected override Task OnInitializedAsync() => LoadAsync();
private async Task LoadAsync() private async Task LoadAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync(); var meters = await db.Meters
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
_meters = await db.Meters
.AsNoTracking() .AsNoTracking()
.Include(m => m.EnergyType) .Include(m => m.EnergyType)
.Include(m => m.Sources) .Include(m => m.Sources)
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
.ToListAsync(); .ToListAsync();
// Grouping follows item order, so sort by the group label first. Retired meters sink to the
// bottom of their group: their history still counts, but nobody reads them any more.
_meters = meters
.OrderBy(m => m.EnergyType?.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(m => !m.IsActive)
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
} }
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle). private bool Matches(Meter meter)
private IEnumerable<Meter> AvailableUpstream()
{ {
if (_meters is null) if (string.IsNullOrWhiteSpace(_search))
{ {
return []; return true;
} }
var descendants = Descendants(_working.Id); var term = _search.Trim();
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id)); return Contains(meter.Name) || Contains(meter.SerialNumber) || Contains(meter.Location)
|| Contains(meter.EnergyType?.DisplayName) || Contains(meter.Mode.Display());
bool Contains(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
} }
private HashSet<int> Descendants(int meterId) /// <summary>A new meter goes straight to its own page, where adding readings or a source is the next step.</summary>
private async Task OnSavedAsync((int MeterId, bool Created) saved)
{ {
var result = new HashSet<int>(); if (saved.Created)
if (meterId == 0)
{ {
return result; Nav.NavigateTo(MeterLinks.Detail(saved.MeterId));
}
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; 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(); 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) private async Task DeleteAsync(Meter meter)
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id); var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id);
var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id); var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id);
var detail = readings + consumption > 0 var message = readings + consumption > 0
? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)." ? Loc.F(S.Meters_DeleteConfirmWithData, meter.Name, readings, consumption)
: ""; : Loc.F(S.Meters_DeleteConfirm, meter.Name);
if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone.")) if (!await Confirm.DeleteAsync(DialogService, S.Meters_DeleteTitle, message))
{ {
return; return;
} }
@@ -330,30 +179,7 @@ else
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync(); await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
await tx.CommitAsync(); await tx.CommitAsync();
Snackbar.Add("Deleted.", Severity.Success); Snackbar.Add(S.Common_Deleted, Severity.Success);
await LoadAsync(); 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;
}
} }
+2 -2
View File
@@ -1,5 +1,5 @@
@page "/not-found" @page "/not-found"
@layout MainLayout @layout MainLayout
<h3>Not Found</h3> <h3>@S.NotFound_Title</h3>
<p>Sorry, the content you are looking for does not exist.</p> <p>@S.NotFound_Message</p>
+25 -24
View File
@@ -2,15 +2,15 @@
@inject SolarService SolarSvc @inject SolarService SolarSvc
@using MudBlazor @using MudBlazor
<PageTitle>MeterVault — Solar / PV</PageTitle> <PageTitle>MeterVault — @S.Nav_Solar</PageTitle>
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem"> <div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
<MudText Typo="Typo.h4">Solar / PV</MudText> <MudText Typo="Typo.h4">@S.Nav_Solar</MudText>
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px"> <MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="@S.Common_Range" Dense="true" Style="max-width:200px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem> <MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem> <MudSelectItem T="int" Value="60">@S.Common_RangeLast5Years</MudSelectItem>
<MudSelectItem T="int" Value="1200">All time</MudSelectItem> <MudSelectItem T="int" Value="1200">@S.Common_RangeAllTime</MudSelectItem>
</MudSelect> </MudSelect>
</div> </div>
@@ -21,8 +21,9 @@
else if (!_summary.HasGeneration) else if (!_summary.HasGeneration)
{ {
<MudAlert Severity="Severity.Info"> <MudAlert Severity="Severity.Info">
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from @S.Solar_NoGenerationLead <b>@MeterMode.GenerationCounter.Display()</b> @S.Solar_NoGenerationTail
<MudLink Href="/import">Import</MudLink>. <MudLink Href="/meters">@S.Nav_Meters</MudLink>@S.Solar_NoGenerationOrImport
<MudLink Href="/import">@S.Nav_Import</MudLink>.
</MudAlert> </MudAlert>
} }
else else
@@ -30,53 +31,53 @@ else
<MudGrid> <MudGrid>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Generation</MudText>
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText> <MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Self-consumption</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_SelfConsumption</MudText>
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText> <MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
@if (_summary.SelfConsumptionRatio is { } ratio) @if (_summary.SelfConsumptionRatio is { } ratio)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary">@Format.Number(ratio * 100, 0)% of generation</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.Solar_ShareOfGeneration, Format.Number(ratio * 100, 0))</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Autarky</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Autarky</MudText>
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText> <MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
@if (_summary.GridImport is { } grid) @if (_summary.GridImport is { } grid)
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid draw @Format.Number(grid, 0) kWh</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.Solar_GridDraw, Format.Number(grid, 0))</MudText>
} }
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" sm="6" md="3"> <MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<MudText Typo="Typo.overline" Color="Color.Secondary">Savings (Ersparnis)</MudText> <MudText Typo="Typo.overline" Color="Color.Secondary">@S.Solar_Savings</MudText>
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText> <MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="8"> <MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Generation &amp; self-consumption</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationAndSelfConsumption</MudText>
<SeriesChart Series="_chart" Decimals="0" Height="340" /> <SeriesChart Series="_chart" Decimals="0" Height="340" />
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<MudItem xs="12" md="4"> <MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="2" Style="height:100%"> <MudPaper Class="pa-4" Elevation="2" Style="height:100%">
<MudText Typo="Typo.h6" Class="mb-2">Generation by meter</MudText> <MudText Typo="Typo.h6" Class="mb-2">@S.Solar_GenerationByMeter</MudText>
<MudSimpleTable Dense="true" Hover="true"> <MudSimpleTable Dense="true" Hover="true">
<tbody> <tbody>
@foreach (var meter in _summary.Meters) @foreach (var meter in _summary.Meters)
{ {
<tr> <tr>
<td>@meter.Name</td> <td><MudLink Href="@MeterLinks.Detail(meter.MeterId)">@meter.Name</MudLink></td>
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td> <td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
</tr> </tr>
} }
@@ -85,8 +86,8 @@ else
@if (!_summary.HasLoadContext) @if (!_summary.HasLoadContext)
{ {
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3"> <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) @S.Solar_TagMetersLead <code>total_load</code> @S.Solar_TagMetersMid <code>grid_import</code>@S.Solar_TagMetersTail
to unlock self-consumption, autarky and savings. <MudLink Href="/meters">@S.Nav_Meters</MudLink>.
</MudAlert> </MudAlert>
} }
</MudPaper> </MudPaper>
@@ -124,18 +125,18 @@ else
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); _summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
var generation = _summary.Months var generation = _summary.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation)) .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.Generation))
.ToList(); .ToList();
var series = new List<SeriesChart.SeriesDef> var series = new List<SeriesChart.SeriesDef>
{ {
new("Generation", ApexCharts.SeriesType.Bar, generation), new(S.Solar_Generation, ApexCharts.SeriesType.Bar, generation),
}; };
if (_summary.HasLoadContext) if (_summary.HasLoadContext)
{ {
var self = _summary.Months var self = _summary.Months
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0)) .Select(m => new SeriesChart.Point(Format.MonthLabel(m.Period), m.SelfConsumption ?? 0))
.ToList(); .ToList();
series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self)); series.Add(new(S.Solar_SelfConsumption, ApexCharts.SeriesType.Bar, self));
} }
_chart = series; _chart = series;
+12 -9
View File
@@ -1,18 +1,18 @@
@page "/trends" @page "/trends"
@inject DashboardService Dash @inject DashboardService Dash
<PageTitle>MeterVault — Trends</PageTitle> <PageTitle>MeterVault — @S.Nav_Trends</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Cost trend</MudText> <MudText Typo="Typo.h4" Class="mb-4">@S.Trends_Title</MudText>
<MudPaper Class="pa-4" Elevation="2"> <MudPaper Class="pa-4" Elevation="2">
<div class="d-flex align-center mb-3" style="gap:1rem"> <div class="d-flex align-center mb-3" style="gap:1rem">
<MudSelect T="int" @bind-Value="_months" Label="Range" Dense="true" Style="max-width:180px"> <MudSelect T="int" @bind-Value="_months" Label="@S.Common_Range" Dense="true" Style="max-width:180px">
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem> <MudSelectItem T="int" Value="12">@S.Common_RangeLast12Months</MudSelectItem>
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">@S.Common_RangeLast24Months</MudSelectItem>
<MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem> <MudSelectItem T="int" Value="48">@S.Trends_RangeLast48Months</MudSelectItem>
</MudSelect> </MudSelect>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">Apply</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">@S.Trends_Apply</MudButton>
</div> </div>
@if (_loading) @if (_loading)
@@ -23,14 +23,17 @@
{ {
<TrendChart Points="_points" /> <TrendChart Points="_points" />
<MudText Typo="Typo.subtitle2" Class="mt-4"> <MudText Typo="Typo.subtitle2" Class="mt-4">
Total over range: @Format.Euro(_points.Sum(p => p.Cost)) @Loc.F(S.Trends_TotalOverRange, Format.Euro(_points.Sum(p => p.Cost)))
</MudText> </MudText>
} }
</MudPaper> </MudPaper>
@code { @code {
private int _months = 24; private int _months = 24;
private bool _loading = true;
// Starts idle: LoadAsync is the one that sets it. Starting busy made the very first load bail out
// on its own guard, so the page never got past the progress bar and Apply stayed disabled.
private bool _loading;
private IReadOnlyList<TrendPoint> _points = []; private IReadOnlyList<TrendPoint> _points = [];
protected override Task OnInitializedAsync() => LoadAsync(); protected override Task OnInitializedAsync() => LoadAsync();
@@ -6,7 +6,7 @@
<ApexPointSeries TItem="CategorySlice" <ApexPointSeries TItem="CategorySlice"
Items="Slices" Items="Slices"
SeriesType="SeriesType.Donut" SeriesType="SeriesType.Donut"
Name="Cost" Name="@S.CategoryDonut_Cost"
XValue="s => s.Name" XValue="s => s.Name"
YValue="s => (decimal)Math.Round(s.Cost, 2)" /> YValue="s => (decimal)Math.Round(s.Cost, 2)" />
</ApexChart> </ApexChart>
+559
View File
@@ -0,0 +1,559 @@
@using Microsoft.EntityFrameworkCore
@using MeterVault.Core.Normalization
@using MeterVault.Infrastructure.Normalization
@using MeterVault.Infrastructure.Persistence
@inject IDbContextFactory<MeterVaultDbContext> DbFactory
@inject INormalizationEngine Engine
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@inject ISnackbar Snackbar
@* The one meter editor, shared by the meter list and the meter's own page: a meter's settings are
edited where the user is looking at it rather than only from a pencil in a list. *@
<MudDialog Visible="_open" VisibleChanged="@(v => _open = v)" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_working.Id == 0 ? S.Meters_NewMeter : Loc.F(S.Meters_EditMeter, _working.Name))</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_working.Name" Label="@S.Common_Name" Required="true" Class="mb-2" />
<MudSelect T="short" Value="_working.EnergyTypeId" ValueChanged="OnEnergyTypeChanged" Label="@S.Common_EnergyType" Class="mb-2">
@foreach (var t in _energyTypes)
{
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
}
</MudSelect>
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="@S.Meters_MeasurementMode" Class="mb-2">
@foreach (var mode in Enum.GetValues<MeterMode>())
{
<MudSelectItem T="MeterMode" Value="mode">@mode.Display()</MudSelectItem>
}
</MudSelect>
@if (_working.Mode == MeterMode.Virtual)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
@S.Meters_VirtualHelp
</MudAlert>
}
else if (_working.Mode == MeterMode.InstantRate)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
@S.Meters_InstantRateHelpBefore <b>@S.Meters_InstantRateHelpPerHour</b> @S.Meters_InstantRateHelpAfter
</MudAlert>
}
<MudTextField @bind-Value="_working.Unit" Label="@S.Common_Unit" Required="true" Class="mb-2" />
@if (_working.Mode != MeterMode.ConsumableBalance)
{
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="@S.Meters_InitialBaseline" Class="mb-2" />
}
@if (_working.Mode == MeterMode.ConsumableBalance)
{
<MudPaper Outlined="true" Class="pa-3 mb-3">
<MudText Typo="Typo.subtitle2">@S.Meters_TankSection</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@S.Meters_TankHelp</MudText>
<div class="d-flex flex-wrap" style="gap:0 1rem">
<MudNumericField T="double?" @bind-Value="_working.TankCapacity" Label="@S.Meters_TankCapacity" Min="0"
Style="min-width:140px" Class="flex-grow-1 mb-2" />
<MudTextField @bind-Value="_working.TankUnit" Label="@S.Common_Unit" Style="max-width:110px" Class="mb-2" />
</div>
<div class="d-flex flex-wrap" style="gap:0 1rem">
<MudNumericField T="double?" @bind-Value="_working.VolumePerCm" Label="@Loc.F(S.Meters_TankVolumePerCm, TankUnitLabel)" Min="0"
HelperText="@S.Meters_TankVolumePerCmHelp" Style="min-width:160px" Class="flex-grow-1 mb-2" />
<MudNumericField T="double" @bind-Value="_working.CalibrationOffset" Label="@Loc.F(S.Meters_TankOffset, TankUnitLabel)"
Disabled="@(_working.VolumePerCm is null)" Style="max-width:140px" Class="mb-2" />
</div>
<div class="d-flex flex-wrap" style="gap:0 1rem">
<MudSelect T="TankRateMode" @bind-Value="_working.RateMode" Label="@S.Meters_TankRateMode" Style="min-width:160px" Class="flex-grow-1 mb-2">
@foreach (var rateMode in Enum.GetValues<TankRateMode>())
{
<MudSelectItem T="TankRateMode" Value="rateMode">@rateMode.Display()</MudSelectItem>
}
</MudSelect>
@if (_working.RateMode == TankRateMode.Fixed)
{
<MudNumericField T="double?" @bind-Value="_working.FixedRate" Label="@Loc.F(S.Meters_TankFixedRate, TankUnitLabel)" Min="0"
Style="max-width:160px" Class="mb-2" />
}
</div>
</MudPaper>
}
<MudSelect T="string" @bind-Value="_working.Role" Label="@S.Meters_PvRole" HelperText="@S.Meters_PvRoleHelp" Class="mb-2">
<MudSelectItem T="string" Value="@("")">@S.Meters_RoleNone</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
</MudSelect>
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
Label="@S.Meters_UpstreamLabel" Class="mb-2"
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
HelperText="@S.Meters_UpstreamHelp">
@foreach (var m in AvailableUpstream())
{
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
}
</MudSelect>
@* A meter only has a cost once a category counts it, and nothing else on the way to the
dashboard says so — so the membership is chosen where the meter is set up. *@
@if (_categories.Count == 0)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">
@S.Meters_NoCostCategories <MudLink Typo="Typo.caption" Href="/admin/categories">@S.Nav_CostCategories</MudLink>
</MudText>
}
else
{
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Categories"
Label="@S.Meters_CostCategories" Class="mb-2"
MultiSelectionTextFunc="@(ids => CategoryText(ids))"
HelperText="@CategoriesHelp">
@foreach (var category in _categories)
{
<MudSelectItem T="int" Value="category.Id">@category.Name</MudSelectItem>
}
</MudSelect>
}
<MudTextField @bind-Value="_working.Location" Label="@S.Meters_Location" Class="mb-2" />
<MudTextField @bind-Value="_working.SerialNumber" Label="@S.Meters_SerialNumber" Class="mb-2" />
<div class="d-flex" style="gap:1rem">
<MudTextField @bind-Value="_working.Manufacturer" Label="@S.Meters_Manufacturer" Class="mb-2" />
<MudTextField @bind-Value="_working.Model" Label="@S.Meters_Model" Class="mb-2" />
</div>
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="@S.Meters_Active" Color="Color.Primary" />
@if (OffersSwapInstead)
{
@* Retiring and re-creating splits one meter's history in two and drops its sources, flow
links and cost categories. When the physical meter was replaced, a swap is the fix. *@
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2">
@S.Meters_RetireSwapHint
<div class="mt-2">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning"
StartIcon="@Icons.Material.Filled.SwapHoriz" OnClick="RecordSwapInsteadAsync">
@S.Meters_RecordSwapInstead
</MudButton>
</div>
</MudAlert>
}
@if (_working.Id != 0 && _working.RecomputeNeeded)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">@S.Meters_RecomputeNotice</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _open = false)" Disabled="_saving">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="_saving">@S.Common_Save</MudButton>
</DialogActions>
</MudDialog>
@code {
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
private bool _open;
private bool _saving;
private EditModel _working = new();
private List<EnergyType> _energyTypes = [];
private List<Meter> _meters = [];
private List<MeterLink> _allLinks = [];
private List<CostCategory> _categories = [];
private List<CostCategoryMember> _typeMemberships = [];
/// <summary>Raised after a save, with the meter's id and whether it was just created.</summary>
[Parameter]
public EventCallback<(int MeterId, bool Created)> Saved { get; set; }
/// <summary>
/// Raised when the user, about to retire a register, chooses to record a meter swap instead. The
/// host decides where that dialog lives; the editor has already closed without saving.
/// </summary>
[Parameter]
public EventCallback<int> SwapInsteadRequested { get; set; }
public async Task OpenNewAsync()
{
await LoadListsAsync();
var type = _energyTypes.FirstOrDefault();
_working = new EditModel();
if (type is not null)
{
ApplyTypeDefaults(type, previous: null);
}
_open = true;
StateHasChanged();
}
public async Task OpenAsync(int meterId)
{
await LoadListsAsync();
await using var db = await DbFactory.CreateDbContextAsync();
var meter = await db.Meters.AsNoTracking().FirstOrDefaultAsync(m => m.Id == meterId);
if (meter is null)
{
Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error);
return;
}
var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meterId);
var calibration = MeterConfigFactory.ParseCalibration(tank?.Calibration);
_working = new EditModel
{
Id = meter.Id,
Name = meter.Name,
EnergyTypeId = meter.EnergyTypeId,
Mode = meter.Mode,
OriginalMode = meter.Mode,
Unit = meter.Unit,
InitialBaseline = meter.InitialBaseline,
OriginalBaseline = meter.InitialBaseline,
Role = MeterMeta.Role(meter.Meta) ?? "",
Location = meter.Location,
SerialNumber = meter.SerialNumber,
Manufacturer = meter.Manufacturer,
Model = meter.Model,
IsActive = meter.IsActive,
OriginalIsActive = meter.IsActive,
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
Categories = (await db.CostCategoryMembers.AsNoTracking()
.Where(m => m.MeterId == meter.Id)
.Select(m => m.CategoryId)
.ToListAsync()).ToHashSet(),
HasTank = tank is not null,
TankCapacity = tank?.Capacity,
TankUnit = tank?.Unit ?? meter.Unit,
VolumePerCm = calibration?.VolumePerUnit,
CalibrationOffset = calibration?.Offset ?? 0,
RateMode = tank?.RateMode ?? TankRateMode.Empirical,
FixedRate = tank?.FixedRate,
};
_working.OriginalTank = _working.TankSignature;
_open = true;
StateHasChanged();
}
private string TankUnitLabel => string.IsNullOrWhiteSpace(_working.TankUnit) ? _working.Unit : _working.TankUnit.Trim();
private bool OffersSwapInstead =>
_working.Id != 0 && _working.OriginalIsActive && !_working.IsActive
&& MeterEventRules.IsMonotonic(_working.OriginalMode) && SwapInsteadRequested.HasDelegate;
private async Task LoadListsAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
_categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
_typeMemberships = await db.CostCategoryMembers.AsNoTracking().Where(m => m.EnergyTypeId != null).ToListAsync();
}
/// <summary>
/// Categories that already count this meter through its energy type. They are named rather than
/// offered as a choice: the meter cannot leave them here, only the energy type can.
/// </summary>
private string CategoriesHelp
{
get
{
var inherited = _categories
.Where(c => _typeMemberships.Any(m => m.CategoryId == c.Id && m.EnergyTypeId == _working.EnergyTypeId))
.Select(c => c.Name)
.ToList();
return inherited.Count == 0
? S.Meters_CostCategoriesHelp
: Loc.F(S.Meters_CostCategoriesInherited, string.Join(", ", inherited));
}
}
private string CategoryText(IReadOnlyList<string> ids) =>
string.Join(", ", ids.Select(idText =>
int.TryParse(idText, out var id) ? _categories.FirstOrDefault(c => c.Id == id)?.Name ?? idText : idText));
/// <summary>
/// A new meter takes its energy type's default mode and base unit, so an oil meter does not
/// silently land on "cumulative counter" in kWh. An edited meter keeps what it has.
/// </summary>
private void OnEnergyTypeChanged(short typeId)
{
var previous = _energyTypes.FirstOrDefault(t => t.Id == _working.EnergyTypeId);
_working.EnergyTypeId = typeId;
if (_working.Id == 0 && _energyTypes.FirstOrDefault(t => t.Id == typeId) is { } type)
{
ApplyTypeDefaults(type, previous);
}
}
private void ApplyTypeDefaults(EnergyType type, EnergyType? previous)
{
_working.EnergyTypeId = type.Id;
_working.Mode = type.DefaultMode;
// Only replace a unit the user has not typed themselves.
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.Unit == previous?.BaseUnit)
{
_working.Unit = type.BaseUnit;
}
}
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
private IEnumerable<Meter> AvailableUpstream()
{
var descendants = Descendants(_working.Id);
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
}
private HashSet<int> Descendants(int meterId)
{
var result = new HashSet<int>();
if (meterId == 0)
{
return result;
}
var queue = new Queue<int>();
queue.Enqueue(meterId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
{
if (result.Add(link.ToMeterId))
{
queue.Enqueue(link.ToMeterId);
}
}
}
return result;
}
private string UpstreamText(IReadOnlyList<string> ids)
{
var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
return string.Join(", ", names);
}
private async Task RecordSwapInsteadAsync()
{
var meterId = _working.Id;
_open = false;
await SwapInsteadRequested.InvokeAsync(meterId);
}
private async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
{
Snackbar.Add(S.Meters_RequiredFields, Severity.Warning);
return;
}
if (_working.Mode == MeterMode.ConsumableBalance && _working.WantsTank && _working.TankCapacity is not > 0)
{
Snackbar.Add(S.Meters_TankCapacityRequired, Severity.Warning);
return;
}
_saving = true;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
// One unit: the meter, its tank, its flow links and its categories are saved together or not at
// all — a failure halfway must not leave a meter with half its settings.
await using var tx = await db.Database.BeginTransactionAsync();
var created = _working.Id == 0;
int meterId;
if (created)
{
var meter = new Meter
{
Name = _working.Name.Trim(),
EnergyTypeId = _working.EnergyTypeId,
Mode = _working.Mode,
Unit = _working.Unit.Trim(),
InitialBaseline = _working.Mode == MeterMode.ConsumableBalance ? 0 : _working.InitialBaseline,
Meta = MeterMeta.SetRole("{}", _working.Role),
Location = Trim(_working.Location),
SerialNumber = Trim(_working.SerialNumber),
Manufacturer = Trim(_working.Manufacturer),
Model = Trim(_working.Model),
IsActive = _working.IsActive,
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
meterId = meter.Id;
await SaveTankAsync(db, meterId);
}
else
{
var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id);
existing.Name = _working.Name.Trim();
existing.EnergyTypeId = _working.EnergyTypeId;
existing.Mode = _working.Mode;
existing.Unit = _working.Unit.Trim();
existing.InitialBaseline = _working.InitialBaseline;
existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role);
existing.Location = Trim(_working.Location);
existing.SerialNumber = Trim(_working.SerialNumber);
existing.Manufacturer = Trim(_working.Manufacturer);
existing.Model = Trim(_working.Model);
existing.IsActive = _working.IsActive;
existing.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
await SaveTankAsync(db, existing.Id);
if (_working.RecomputeNeeded)
{
var normalization = new NormalizationService(db, Engine, Options);
await normalization.RecomputeMeterAsync(existing.Id, null);
await db.SaveChangesAsync();
}
meterId = existing.Id;
}
await SyncUpstreamAsync(db, meterId, _working.Upstream);
await SyncCategoriesAsync(db, meterId, _working.Categories);
await tx.CommitAsync();
_open = false;
Snackbar.Add(S.Common_Saved, Severity.Success);
await Saved.InvokeAsync((meterId, created));
}
catch (DbUpdateException)
{
// Something this dialog relied on changed meanwhile (a category or meter deleted in another
// tab). Nothing was saved; show what is there now rather than end the circuit.
Snackbar.Add(S.Meters_ChangedMeanwhile, Severity.Warning);
if (_working.Id != 0)
{
await OpenAsync(_working.Id);
}
else
{
await LoadListsAsync();
}
}
finally
{
_saving = false;
}
}
/// <summary>
/// Creates or updates the tank for a consumable meter. A tank is left alone when the meter moves
/// to another mode — its calibration is configuration somebody measured, not something to lose
/// on a misclick in a mode select.
/// </summary>
private async Task SaveTankAsync(MeterVaultDbContext db, int meterId)
{
if (_working.Mode != MeterMode.ConsumableBalance || !_working.WantsTank || _working.TankCapacity is not { } capacity)
{
return;
}
var tank = await db.Tanks.FirstOrDefaultAsync(t => t.MeterId == meterId);
if (tank is null)
{
tank = new Tank { MeterId = meterId };
db.Tanks.Add(tank);
}
tank.Capacity = capacity;
tank.Unit = TankUnitLabel;
tank.Calibration = MeterConfigFactory.SerializeCalibration(
_working.VolumePerCm is { } perCm ? new CalibrationCurve(perCm, _working.CalibrationOffset) : null);
tank.RateMode = _working.RateMode;
tank.FixedRate = _working.RateMode == TankRateMode.Fixed ? _working.FixedRate : null;
await db.SaveChangesAsync();
}
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
private static async Task SyncUpstreamAsync(MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
{
var wanted = desiredUpstream.Where(id => id != meterId).ToList();
// Against the database, not the list the dialog loaded: a meter deleted meanwhile is not linked.
var desired = (await db.Meters.Where(m => wanted.Contains(m.Id)).Select(m => m.Id).ToListAsync()).ToHashSet();
var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
{
db.MeterLinks.Remove(link);
}
foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
{
db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
}
await db.SaveChangesAsync();
}
/// <summary>
/// Reconciles the meter's own cost-category memberships to the selection. Memberships a category
/// holds through an energy type are the category's, not the meter's, and are left alone.
/// </summary>
private static async Task SyncCategoriesAsync(MeterVaultDbContext db, int meterId, IEnumerable<int> desiredCategories)
{
// Against the database, not the list the dialog loaded: a category deleted meanwhile is not re-added
// under a dangling id.
var wanted = desiredCategories.ToList();
var desired = (await db.CostCategories.Where(c => wanted.Contains(c.Id)).Select(c => c.Id).ToListAsync()).ToHashSet();
var existing = await db.CostCategoryMembers.Where(m => m.MeterId == meterId).ToListAsync();
foreach (var member in existing.Where(m => !desired.Contains(m.CategoryId)))
{
db.CostCategoryMembers.Remove(member);
}
foreach (var categoryId in desired.Where(id => existing.All(m => m.CategoryId != id)))
{
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categoryId, MeterId = meterId });
}
await db.SaveChangesAsync();
}
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private sealed class EditModel
{
public int Id { get; set; }
public string Name { get; set; } = "";
public short EnergyTypeId { get; set; }
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter;
public string Unit { get; set; } = "";
public double InitialBaseline { get; set; }
public double OriginalBaseline { get; set; }
public string Role { get; set; } = "";
public string? Location { get; set; }
public string? SerialNumber { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public bool IsActive { get; set; } = true;
public bool OriginalIsActive { get; set; } = true;
public IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
public IReadOnlyCollection<int> Categories { get; set; } = new HashSet<int>();
public bool HasTank { get; set; }
public double? TankCapacity { get; set; }
public string TankUnit { get; set; } = "";
public double? VolumePerCm { get; set; }
public double CalibrationOffset { get; set; }
public TankRateMode RateMode { get; set; } = TankRateMode.Empirical;
public double? FixedRate { get; set; }
public string OriginalTank { get; set; } = "";
/// <summary>A tank is kept once it exists, and created as soon as any of its fields is filled in.</summary>
public bool WantsTank => HasTank || TankCapacity is not null || VolumePerCm is not null || FixedRate is not null;
/// <summary>
/// The tank fields that change this meter's derived consumption: the calibration turns levels
/// into volume. The burner rate only feeds the Consumables panel's rate figure.
/// </summary>
public string TankSignature => FormattableString.Invariant($"{VolumePerCm}|{CalibrationOffset}");
public bool RecomputeNeeded =>
Mode != OriginalMode
|| Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9
|| (Mode == MeterMode.ConsumableBalance && WantsTank && TankSignature != OriginalTank);
}
}
@@ -0,0 +1,395 @@
@using System.Globalization
@using Microsoft.Extensions.DependencyInjection
@using MeterVault.Infrastructure.Ingestion
@inject IServiceScopeFactory Scopes
@inject ISnackbar Snackbar
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@inject ILogger<MeterEventDialog> Logger
@* Records a meter lifecycle event: a swap or reset on a register, a level or delivery on a tank, or a
note on anything. Everything it books is explained before saving — the old register's tail, where
later readings count from, what a dipstick level means in litres — because each of these is the
kind of entry whose mistake only shows up weeks later as a spike in a chart. *@
<MudDialog Visible="_open" VisibleChanged="OnVisibleChangedAsync" Options="_options">
<TitleContent>
<MudText Typo="Typo.h6">@Loc.F(S.MeterEvent_Title, _type.Display(), MeterName)</MudText>
</TitleContent>
<DialogContent>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="d-block mb-3">@MeterEventText.Intro(_type)</MudText>
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
<MudDatePicker Date="_when.Date" DateChanged="OnDateChangedAsync" Label="@S.Common_Date" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:150px" />
<MudTimePicker Time="_when.TimeOfDay" TimeChanged="OnTimeChangedAsync" Label="@S.MeterDetail_TimeOfDay" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:130px" />
<MudButton Size="Size.Small" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNowAsync">@S.Common_Now</MudButton>
</div>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1 mb-3">@Loc.F(S.MeterDetail_LocalTimeIn, _when.Zone.Id)</MudText>
@if (IsBoundary)
{
<MudTextField T="string" Value="_prevText" ValueChanged="@(v => _prevText = v)" Immediate="true"
Label="@Loc.F(_type == MeterEventType.MeterSwap ? S.MeterEvent_OldFinalLabel : S.MeterEvent_BeforeResetLabel, Unit)"
HelperText="@(_type == MeterEventType.MeterSwap ? S.MeterEvent_OldFinalHelp : S.MeterEvent_BeforeResetHelp)"
Error="@IsUnparseable(_prevText)" ErrorText="@S.MeterEvent_NotANumber"
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="mb-3" />
<MudTextField T="string" Value="_newText" ValueChanged="@(v => _newText = v)" Immediate="true"
Label="@Loc.F(_type == MeterEventType.MeterSwap ? S.MeterEvent_NewStartLabel : S.MeterEvent_AfterResetLabel, Unit)"
HelperText="@S.MeterEvent_NewStartHelp"
Error="@IsUnparseable(_newText)" ErrorText="@S.MeterEvent_NotANumber"
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="mb-3" />
@if (_context is { } c)
{
<MudPaper Outlined="true" Class="pa-3 mb-2">
<MudText Typo="Typo.body2">
@(c.Previous is { } previous
? Loc.F(S.MeterEvent_LastReadingBefore, Register(previous.Value), Unit, Stamp(previous.Time))
: Loc.F(S.MeterEvent_NoReadingBefore, Register(c.InitialBaseline), Unit))
</MudText>
@if (c.Tail(Parse(_prevText)) is { } tail)
{
<MudText Typo="Typo.body2" Color="@(tail < 0 ? Color.Error : Color.Default)">
@Loc.F(S.MeterEvent_TailBooked, Signed(tail), Unit)
</MudText>
}
@if (Parse(_newText) is { } start)
{
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_CountsFrom, Register(start), Unit)</MudText>
}
</MudPaper>
@if (c.ReadingsAfter > 0 && c.Next is { } next)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
@Loc.F(S.MeterEvent_ReadingsAfterWarning,
c.ReadingsAfter >= MeterEventService.ReadingsAfterCap ? $"{MeterEventService.ReadingsAfterCap}+" : c.ReadingsAfter.ToString(CultureInfo.CurrentCulture),
Register(next.Value), Unit, Stamp(next.Time))
</MudAlert>
}
@if (c.LiveSources > 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">@Loc.F(S.MeterEvent_LiveSourcesWarning, c.LiveSources)</MudAlert>
}
}
}
else if (_type is MeterEventType.TankLevel or MeterEventType.Delivery)
{
<div class="d-flex align-start" style="gap:.75rem">
<MudTextField T="string" Value="_amountText" ValueChanged="@(v => _amountText = v)" Immediate="true"
Label="@(_type == MeterEventType.TankLevel ? S.MeterEvent_LevelLabel : Loc.F(S.MeterEvent_DeliveredLabel, TankUnit))"
Error="@IsUnparseable(_amountText)" ErrorText="@S.MeterEvent_NotANumber"
Variant="Variant.Outlined" InputMode="DecimalKeyboard" Class="flex-grow-1 mb-3" />
@if (_type == MeterEventType.TankLevel)
{
<MudSelect T="bool" @bind-Value="_centimetres" Label="@S.Common_Unit" Variant="Variant.Outlined"
Disabled="@(_context?.Calibration is null)" Style="max-width:110px">
<MudSelectItem T="bool" Value="true">cm</MudSelectItem>
<MudSelectItem T="bool" Value="false">@TankUnit</MudSelectItem>
</MudSelect>
}
</div>
@if (_type == MeterEventType.TankLevel && _context is { } c)
{
@if (c.Calibration is null)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@S.MeterEvent_NoCalibrationHint</MudText>
}
<MudPaper Outlined="true" Class="pa-3 mb-2">
@if (Parse(_amountText) is { } level && _centimetres)
{
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_LevelVolume, Format.Number(c.ToVolume(level, true), 0), TankUnit)</MudText>
}
<MudText Typo="Typo.body2">
@(c.LastLevel is { } last
? Loc.F(S.MeterEvent_LastLevel, $"{Format.Number(last.Amount, 1)} {last.Unit ?? TankUnit}", Format.Number(last.Volume, 0), TankUnit, Stamp(last.Time))
: S.MeterEvent_FirstLevel)
</MudText>
@if (c.DeliveredSinceLastLevel > 0)
{
<MudText Typo="Typo.body2">@Loc.F(S.MeterEvent_DeliveredSince, Format.Number(c.DeliveredSinceLastLevel, 0), TankUnit)</MudText>
}
@if (Parse(_amountText) is { } entered && c.UsedSinceLastLevel(c.ToVolume(entered, _centimetres)) is { } used)
{
<MudText Typo="Typo.body2" Color="@(used < 0 ? Color.Warning : Color.Default)">
@(used < 0
? Loc.F(S.MeterEvent_LevelRose, Format.Number(-used, 0), TankUnit)
: Loc.F(S.MeterEvent_UsedSince, Format.Number(used, 0), TankUnit))
</MudText>
}
</MudPaper>
}
}
<MudTextField T="string" @bind-Value="_notes" Immediate="true" Lines="@(_type == MeterEventType.Note ? 3 : 1)"
Label="@(_type == MeterEventType.Note ? S.MeterEvent_NoteLabel : S.MeterEvent_NotesOptional)"
Variant="Variant.Outlined" Class="mt-1" />
@if (_when.IsSkipped)
{
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">@Loc.F(S.MeterDetail_SkippedTime, _when.Zone.Id)</MudAlert>
}
else if (_when.Utc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1))
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
}
@if (VisibleProblem is { } problem)
{
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">@MeterEventText.Problem(problem)</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CancelAsync" Disabled="_saving">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="@(!CanSave)">
@(_saving ? S.MeterDetail_Saving : S.Common_Save)
</MudButton>
</DialogActions>
</MudDialog>
@code {
/// <summary>
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
/// <c>decimal</c> is a C# keyword and Razor would read the escape in an attribute as a transition.
/// </summary>
private const InputMode DecimalKeyboard = InputMode.@decimal;
private readonly DialogOptions _options = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
private bool _open;
private bool _saving;
private MeterEventType _type = MeterEventType.Note;
private LocalTimeEntry _when = new(TimeZoneInfo.Utc);
private MeterEventContext? _context;
private DateTimeOffset? _contextFor;
private int _contextVersion;
private string? _prevText;
private string? _newText;
private string? _amountText;
private string? _notes;
private bool _centimetres;
[Parameter, EditorRequired]
public int MeterId { get; set; }
[Parameter]
public string MeterName { get; set; } = string.Empty;
/// <summary>Raised after an event was stored and the meter recomputed.</summary>
[Parameter]
public EventCallback<MeterEventType> Saved { get; set; }
/// <summary>Raised when the dialog is closed without saving.</summary>
[Parameter]
public EventCallback Cancelled { get; set; }
protected override void OnInitialized() => _when = new LocalTimeEntry(LocalTimeEntry.Resolve(Options.Value.TimeZone));
/// <summary>Opens the dialog for <paramref name="type"/>, at <paramref name="at"/> or now.</summary>
public async Task OpenAsync(MeterEventType type, DateTimeOffset? at = null)
{
_type = type;
_prevText = null;
_newText = null;
_amountText = null;
_notes = null;
_context = null;
_contextFor = null;
if (at is { } instant)
{
_when.Set(instant);
}
else
{
_when.SetNow();
}
_open = true;
StateHasChanged();
await LoadContextAsync();
if (_context is { } context)
{
if (IsBoundary)
{
// Like the reading dialog: a register's final value usually differs from the last
// reading only in its last digits, so correcting a prefill beats typing it out. With no
// reading yet, the register starts from the meter's baseline — the same place the
// normalizer measures the tail from.
_prevText = EntryText(context.RegisterBefore);
_newText = "0";
}
else if (type == MeterEventType.TankLevel)
{
_centimetres = context.Calibration is not null
&& (context.LastLevel is null || string.Equals(context.LastLevel.Unit, "cm", StringComparison.OrdinalIgnoreCase));
}
}
StateHasChanged();
}
private bool IsBoundary => MeterEventRules.IsRegisterBoundary(_type);
private string Unit => _context?.Unit ?? string.Empty;
private string TankUnit => _context?.TankUnit ?? string.Empty;
private MeterEventDraft? Draft => _when.Utc is { } utc
? new MeterEventDraft(_type, utc)
{
PrevValue = IsBoundary ? Parse(_prevText) : null,
NewValue = IsBoundary ? Parse(_newText) : null,
Amount = IsBoundary ? null : Parse(_amountText),
Unit = _type == MeterEventType.TankLevel && _centimetres ? "cm" : null,
Notes = _notes,
}
: null;
/// <summary>The service's verdict on the current input, from the context for the time shown.</summary>
private MeterEventProblem Problem =>
_context is { } context && Draft is { } draft && _contextFor == draft.Time
? MeterEventService.Validate(context, draft)
: MeterEventProblem.None;
/// <summary>
/// Problems worth an alert. An empty required field is already obvious from the disabled Save
/// button, and shouting about it before the user has typed anything is just noise.
/// </summary>
private MeterEventProblem? VisibleProblem =>
Problem is MeterEventProblem.None or MeterEventProblem.AmountRequired or MeterEventProblem.NoteRequired ? null : Problem;
private bool RequiredFilled => _type switch
{
MeterEventType.MeterSwap or MeterEventType.CounterReset => !string.IsNullOrWhiteSpace(_newText),
MeterEventType.TankLevel or MeterEventType.Delivery => !string.IsNullOrWhiteSpace(_amountText),
_ => !string.IsNullOrWhiteSpace(_notes),
};
private bool CanSave =>
!_saving
&& _context is not null
&& _when.Utc is { } utc && _contextFor == utc
&& RequiredFilled
&& !IsUnparseable(_prevText) && !IsUnparseable(_newText) && !IsUnparseable(_amountText)
&& Problem == MeterEventProblem.None;
private async Task OnDateChangedAsync(DateTime? date)
{
_when.Date = date;
await LoadContextAsync();
}
private async Task OnTimeChangedAsync(TimeSpan? time)
{
_when.TimeOfDay = time;
await LoadContextAsync();
}
private async Task SetNowAsync()
{
_when.SetNow();
await LoadContextAsync();
}
/// <summary>
/// Re-reads the surroundings of the chosen time. Versioned, because the pickers can change faster
/// than the queries return, and a late answer for an earlier time must not overwrite a newer one.
/// </summary>
private async Task LoadContextAsync()
{
if (_when.Utc is not { } utc)
{
return;
}
var version = ++_contextVersion;
await using var scope = Scopes.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<MeterEventService>();
var context = await service.GetContextAsync(MeterId, utc);
if (version == _contextVersion)
{
_context = context;
_contextFor = utc;
}
}
private async Task SaveAsync()
{
if (!CanSave || Draft is not { } draft)
{
return;
}
_saving = true;
try
{
// A scope per save: the service holds a scoped DbContext, and a circuit far outlives the
// single unit of work a save should share one with.
await using var scope = Scopes.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<MeterEventService>();
MeterEventResult result;
try
{
result = await service.RecordAsync(MeterId, draft);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The service's transaction has already rolled back; an unhandled exception here would
// take the whole circuit down for a problem the user can simply retry or correct.
Logger.LogError(ex, "Recording a {Type} event on meter {MeterId} failed", _type, MeterId);
Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error);
await LoadContextAsync();
return;
}
if (!result.Succeeded)
{
Snackbar.Add(MeterEventText.Problem(result.Problem), Severity.Error);
await LoadContextAsync();
return;
}
Snackbar.Add(MeterEventText.Saved(_type), Severity.Success);
_open = false;
await Saved.InvokeAsync(_type);
}
finally
{
_saving = false;
}
}
private async Task CancelAsync()
{
_open = false;
await Cancelled.InvokeAsync();
}
private async Task OnVisibleChangedAsync(bool visible)
{
if (!visible && _open)
{
await CancelAsync();
}
}
private string Stamp(DateTimeOffset instant) => _when.Local(instant).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
private static double? Parse(string? text) =>
!string.IsNullOrWhiteSpace(text) && ReadingEntry.TryParse(text, out var value) ? value : null;
private static bool IsUnparseable(string? text) => !string.IsNullOrWhiteSpace(text) && !ReadingEntry.TryParse(text, out _);
/// <summary>
/// A register value as editable text, to nine decimals: enough to round-trip what a sensor stored
/// (a prefill rounded to three would fail its own "not below the last reading" check), while
/// trimming binary noise such as 861.1234000000001.
/// </summary>
private static string EntryText(double value) => value.ToString("0.#########", CultureInfo.CurrentCulture);
/// <summary>A register value for display, at the same precision the checks use.</summary>
private static string Register(double value) => value.ToString("#,##0.#########", CultureInfo.CurrentCulture);
private static string Signed(double value) => $"{(value >= 0 ? "+" : "")}{Format.Number(Math.Abs(value), 3)}";
}
@@ -0,0 +1,149 @@
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@inject NavigationManager Nav
@* Reachable from every page through the app bar: type part of a name, serial number or location and
either open the meter or go straight to entering its reading. Two taps from anywhere to the
keypad, which is what standing in a basement with a phone calls for. *@
<MudDialog>
<DialogContent>
<MudTextField T="string" Value="_search" ValueChanged="@(v => _search = v)" Immediate="true" AutoFocus="true"
Placeholder="@S.MeterSearch_Placeholder" Variant="Variant.Outlined" Clearable="true"
Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search"
OnKeyDown="OnKeyDown" Class="mb-2" />
@if (_meters is null)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
}
else if (_meters.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="pa-2">@S.MeterSearch_NoMeters</MudText>
}
else
{
var matches = Matches().ToList();
@if (matches.Count == 0)
{
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="pa-2">@Loc.F(S.Meters_NoSearchMatch, _search)</MudText>
}
<div style="max-height:60vh; overflow-y:auto">
@foreach (var meter in matches.Take(MaxShown))
{
var hit = meter;
<div class="d-flex align-center px-2 py-1">
@* A focusable control of its own, beside (not around) the quick-entry button, so every
result is reachable and announced from the keyboard, not only the first. *@
<div class="flex-grow-1 mud-list-item-clickable" role="button" tabindex="0"
style="min-width:0; cursor:pointer; border-radius:4px"
@onclick="@(() => Go(MeterLinks.Detail(hit.Id)))"
@onkeydown="@(e => OnResultKey(e, hit))">
<MudText Typo="Typo.body1" Style="@(hit.IsActive ? null : "opacity:.6")">@hit.Name</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@Describe(hit)</MudText>
</div>
@if (MeterLinks.QuickEntry(hit.Id, hit.Mode) is { } entry)
{
<MudTooltip Text="@(hit.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)">
<MudIconButton Icon="@(hit.Mode == MeterMode.ConsumableBalance ? Icons.Material.Filled.Straighten : Icons.Material.Filled.EditNote)"
Color="Color.Primary" OnClick="@(() => Go(entry))"
aria-label="@(hit.Mode == MeterMode.ConsumableBalance ? S.MeterDetail_RecordTankLevel : S.MeterDetail_AddReading)" />
</MudTooltip>
}
</div>
}
</div>
@if (matches.Count > MaxShown)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block pa-2">@Loc.F(S.MeterSearch_More, MaxShown, matches.Count)</MudText>
}
}
</DialogContent>
</MudDialog>
@code {
private const int MaxShown = 30;
private List<MeterHit>? _meters;
private string? _search;
[CascadingParameter]
private IMudDialogInstance? Dialog { get; set; }
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
var meters = await db.Meters.AsNoTracking()
.Select(m => new MeterHit(m.Id, m.Name, m.EnergyType != null ? m.EnergyType.DisplayName : "—", m.Mode, m.IsActive, m.SerialNumber, m.Location))
.ToListAsync();
_meters = meters
.OrderBy(m => !m.IsActive)
.ThenBy(m => m.EnergyType, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
}
private IEnumerable<MeterHit> Matches()
{
if (_meters is null)
{
return [];
}
if (string.IsNullOrWhiteSpace(_search))
{
return _meters;
}
var term = _search.Trim();
return _meters.Where(m => Has(m.Name) || Has(m.SerialNumber) || Has(m.Location) || Has(m.EnergyType));
bool Has(string? value) => value?.Contains(term, StringComparison.CurrentCultureIgnoreCase) == true;
}
private static string Describe(MeterHit meter)
{
var parts = new List<string> { meter.EnergyType, meter.Mode.Display() };
if (!string.IsNullOrWhiteSpace(meter.SerialNumber))
{
parts.Add(Loc.F(S.MeterDetail_SerialValue, meter.SerialNumber));
}
if (!string.IsNullOrWhiteSpace(meter.Location))
{
parts.Add(meter.Location);
}
if (!meter.IsActive)
{
parts.Add(S.MeterDetail_Retired);
}
return string.Join(" · ", parts);
}
/// <summary>Enter opens the first match, so a typed name plus Enter is the whole interaction on a keyboard.</summary>
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter" && Matches().FirstOrDefault() is { } first)
{
Go(MeterLinks.Detail(first.Id));
}
}
private void OnResultKey(KeyboardEventArgs e, MeterHit hit)
{
if (e.Key is "Enter" or " ")
{
Go(MeterLinks.Detail(hit.Id));
}
}
private void Go(string href)
{
Dialog?.Close();
Nav.NavigateTo(href);
}
private sealed record MeterHit(int Id, string Name, string EnergyType, MeterMode Mode, bool IsActive, string? SerialNumber, string? Location);
}
+12 -5
View File
@@ -5,7 +5,7 @@
@if (string.IsNullOrEmpty(_svg)) @if (string.IsNullOrEmpty(_svg))
{ {
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText> <MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">@S.Sankey_NoFlow</MudBlazor.MudText>
} }
else else
{ {
@@ -102,11 +102,11 @@ else
} }
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B"); var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
var label = Nodes.ToDictionary(n => n.Id, n => n.Label); var label = Nodes.ToDictionary(n => n.Id, NodeLabel);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append(CultureInfo.InvariantCulture, 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\">"); $"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"{Enc(S.Sankey_AriaLabel)}\">");
// Ribbons first (under nodes). // Ribbons first (under nodes).
foreach (var link in Links) foreach (var link in Links)
@@ -142,7 +142,7 @@ else
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6; var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
var anchor = rightmost ? "end" : "start"; var anchor = rightmost ? "end" : "start";
var fill = Enc(node.ColorHex ?? "#607D8B"); var fill = Enc(node.ColorHex ?? "#607D8B");
var name = Enc(node.Label); var name = Enc(NodeLabel(node));
var val = Enc(Fmt(node.Value)); var val = Enc(Fmt(node.Value));
sb.Append(CultureInfo.InvariantCulture, 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>"); $"<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>");
@@ -155,7 +155,14 @@ else
return sb.ToString(); return sb.ToString();
} }
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim(); /// <summary>
/// A remainder node carries only its parent meter's name (see <see cref="FlowNode"/>); the
/// "Other (…)" framing is added here, where the reader's language is known.
/// </summary>
private static string NodeLabel(FlowNode node) =>
node.IsOther ? Loc.F(S.Flow_OtherNode, node.Label) : node.Label;
private string Fmt(double value) => $"{Format.Number(value)} {Unit}".Trim();
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
+1 -1
View File
@@ -16,7 +16,7 @@
} }
else else
{ {
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText> <MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">@S.Common_NoDataInRange</MudText>
} }
@code { @code {
+4 -3
View File
@@ -7,14 +7,14 @@
<ApexPointSeries TItem="TrendPoint" <ApexPointSeries TItem="TrendPoint"
Items="Points" Items="Points"
SeriesType="SeriesType.Bar" SeriesType="SeriesType.Bar"
Name="Monthly cost" Name="@S.TrendChart_MonthlyCost"
XValue="Label" XValue="Label"
YValue="p => (decimal)Math.Round(p.Cost, 2)" /> YValue="p => (decimal)Math.Round(p.Cost, 2)" />
</ApexChart> </ApexChart>
} }
else else
{ {
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText> <MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">@S.Common_NoDataInRange</MudText>
} }
@code { @code {
@@ -27,5 +27,6 @@ else
DataLabels = new DataLabels { Enabled = false }, DataLabels = new DataLabels { Enabled = false },
}; };
private static object Label(TrendPoint p) => p.Period.ToString("MMM yy", CultureInfo.InvariantCulture); // Qualified: ApexCharts also exports a `Format`, and this file imports it.
private static object Label(TrendPoint p) => MeterVault.App.Format.MonthLabel(p.Period);
} }
@@ -0,0 +1,125 @@
@using System.Net
@using MeterVault.Infrastructure.Update
@inject UpdateCheckService Updates
@inject UpdateRunner Runner
@inject ISnackbar Snackbar
@* No key prompt: the operator opted out of that (MeterVault__AllowInAppUpdate is the whole gate).
The confirmation stays — not as a security control, but because a stray click costs several
minutes of downtime while the rebuild runs. *@
@* Renders nothing at all unless a newer release genuinely exists — no "you are up to date" noise. *@
@if (_status is { UpdateAvailable: true, Running: { } running, Latest: { } latest })
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.SystemUpdateAlt">
<div class="d-flex flex-wrap align-center" style="gap:.75rem">
<span>@((MarkupString)Loc.F(S.UpdateBanner_ReleaseAvailable, Bold(latest), Bold(running)))</span>
@if (Runner.Availability is UpdateAvailability.Allowed)
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.SystemUpdateAlt" OnClick="@(() => _confirmOpen = true)">
@S.UpdateBanner_UpdateNow
</MudButton>
}
else
{
<code style="opacity:.85">@_command</code>
}
</div>
</MudAlert>
}
<MudDialog @bind-Visible="_confirmOpen" Options="_dialogOptions">
<TitleContent><MudText Typo="Typo.h6">@S.UpdateBanner_DialogTitle</MudText></TitleContent>
<DialogContent>
<MudText Typo="Typo.body2" Class="mb-3">
@S.UpdateBanner_DialogBody
</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _confirmOpen = false)" Disabled="_starting">@S.Common_Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="StartUpdateAsync" Disabled="_starting">
@(_starting ? S.UpdateBanner_Starting : S.UpdateBanner_UpdateNow)
</MudButton>
</DialogActions>
</MudDialog>
@code {
private UpdateStatus? _status;
private string _command = "";
private bool _confirmOpen;
private bool _starting;
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.ExtraSmall, FullWidth = true };
protected override void OnInitialized()
{
// Cached answer only — awaiting the check here would hold the dashboard's first paint open
// for the length of an HTTP timeout on a cold start, or whenever the repo is unreachable.
_status = Updates.Current;
_command = UpdateCommandHint();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
var refreshed = await Updates.GetAsync();
if (refreshed != _status)
{
_status = refreshed;
StateHasChanged();
}
}
private async Task StartUpdateAsync()
{
_starting = true;
try
{
var launch = await Runner.LaunchAsync();
Snackbar.Add(OutcomeText(launch), launch.Started ? Severity.Success : Severity.Error);
if (launch.Started)
{
_confirmOpen = false;
}
}
finally
{
_starting = false;
}
}
// UpdateLaunch.Message stays English for the log; the reader gets the outcome in their own
// language, with the launcher's own diagnostic text appended untranslated — it comes from
// systemd, not from us, and mangling it would make it unsearchable.
private static string OutcomeText(UpdateLaunch launch) => launch.Outcome switch
{
UpdateOutcome.Started => S.Update_LaunchStarted,
UpdateOutcome.NotAllowed => Loc.F(
S.Update_LaunchNotAllowed,
launch.Availability?.Display() ?? string.Empty),
UpdateOutcome.LauncherMissing => S.Update_LaunchLauncherMissing,
UpdateOutcome.LauncherFailed => launch.Detail is { Length: > 0 } detail
? Loc.F(S.Update_LaunchFailedWithDetail, detail)
: S.Update_LaunchFailed,
_ => launch.Detail is { Length: > 0 } error
? Loc.F(S.Update_LaunchFailedWithDetail, error)
: S.Update_LaunchFailed,
};
/// <summary>
/// How this particular install updates. The LXC has an <c>update</c> command; a container is
/// replaced by pulling a new image, and telling those users to run <c>update</c> would send them
/// looking for a command that does not exist.
/// </summary>
private static string UpdateCommandHint() =>
UpdateRunner.IsSupportedHere
? S.UpdateBanner_HintRunUpdate
: S.UpdateBanner_HintPullImage;
/// <summary>Emphasises a version inside the banner sentence without letting it carry markup.</summary>
private static string Bold(object value) => $"<b>{WebUtility.HtmlEncode(value.ToString())}</b>";
}
+4
View File
@@ -8,6 +8,10 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using MudBlazor @using MudBlazor
@using MeterVault.App @using MeterVault.App
@using MeterVault.App.Localization
@* UI strings live in Localization/Strings.resx and are reached through this alias: @S.Common_Save.
Compiled properties, so a stale key fails the build. @Loc.F(...) formats the {0} ones. *@
@using S = MeterVault.App.Localization.Strings
@using MeterVault.App.Components @using MeterVault.App.Components
@using MeterVault.App.Components.Layout @using MeterVault.App.Components.Layout
@using MeterVault.App.Components.Shared @using MeterVault.App.Components.Shared
+3 -2
View File
@@ -1,3 +1,4 @@
using MeterVault.App.Localization;
using MudBlazor; using MudBlazor;
namespace MeterVault.App; namespace MeterVault.App;
@@ -7,7 +8,7 @@ public static class Confirm
{ {
public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message) public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message)
{ {
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: "Delete", cancelText: "Cancel") var result = await dialog.ShowMessageBoxAsync(title, message, yesText: Strings.Common_Delete, cancelText: Strings.Common_Cancel)
.ConfigureAwait(false); .ConfigureAwait(false);
return result == true; return result == true;
} }
@@ -15,7 +16,7 @@ public static class Confirm
/// <summary>A generic yes/cancel confirmation with a caller-supplied confirm-button label.</summary> /// <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) 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") var result = await dialog.ShowMessageBoxAsync(title, message, yesText: confirmText, cancelText: Strings.Common_Cancel)
.ConfigureAwait(false); .ConfigureAwait(false);
return result == true; return result == true;
} }
+35
View File
@@ -0,0 +1,35 @@
using System.Diagnostics.CodeAnalysis;
namespace MeterVault.App;
/// <summary>
/// Unsaved dialog input that has to survive a detour to another page of the same circuit.
/// </summary>
/// <remarks>
/// A meter's source dialog sends the user off to create the connector the source needs, and the meter
/// page is disposed on the way. Keeping the draft here, keyed by what it belongs to, lets the dialog come
/// back with everything that was typed instead of empty. Scoped: it lives as long as the circuit and is
/// never shared between users.
/// </remarks>
public sealed class DraftStore
{
private readonly Dictionary<string, object> _drafts = new(StringComparer.Ordinal);
public void Save(string key, object draft) => _drafts[key] = draft;
/// <summary>Hands back a draft once; it is gone afterwards.</summary>
public bool TryTake<T>(string key, [NotNullWhen(true)] out T? draft)
where T : class
{
if (_drafts.Remove(key, out var stored) && stored is T typed)
{
draft = typed;
return true;
}
draft = null;
return false;
}
public void Discard(string key) => _drafts.Remove(key);
}
+23 -6
View File
@@ -2,18 +2,35 @@ using System.Globalization;
namespace MeterVault.App; namespace MeterVault.App;
/// <summary>Small display formatters for the UI (locale-aware formatting arrives with i18n in M7).</summary> /// <summary>
/// Small display formatters for the UI.
/// </summary>
/// <remarks>
/// Everything formats against <see cref="CultureInfo.CurrentCulture"/>, which the request
/// localization middleware sets from the reader's chosen UI language — so 1234.5 renders as
/// "1.234,5" for a German reader and "1,234.5" for an English one, from the same call site. This is
/// display only: the CSV importer still parses the spreadsheet dialect with an explicit de-DE
/// culture (<see cref="Core.Parsing.GermanNumber"/>), because that dialect is a property of the
/// files, not of who is looking at them.
/// </remarks>
public static class Format public static class Format
{ {
private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("de-DE"); /// <summary>
/// A money amount. The symbol is the euro rather than the culture's own, because the figure is
public static string Euro(double value) => value.ToString("N2", Culture) + " €"; /// in the instance's configured currency — switching UI language must not restate the amount as
/// dollars. Only the digit grouping follows the reader.
/// </summary>
public static string Euro(double value) => value.ToString("N2", CultureInfo.CurrentCulture) + " €";
public static string Number(double value, int decimals = 0) => public static string Number(double value, int decimals = 0) =>
value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), Culture); value.ToString("N" + decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.CurrentCulture);
public static string Percent(double value) => public static string Percent(double value) =>
(value >= 0 ? "+" : "") + value.ToString("N1", Culture) + " %"; (value >= 0 ? "+" : "") + value.ToString("N1", CultureInfo.CurrentCulture) + " %";
/// <summary>A month label for chart axes and period lists ("Mrz 25" / "Mar 25").</summary>
public static string MonthLabel(DateOnly month) =>
month.ToString("MMM yy", CultureInfo.CurrentCulture);
public static string DirectionIcon(int direction) => direction switch public static string DirectionIcon(int direction) => direction switch
{ {
+77
View File
@@ -0,0 +1,77 @@
namespace MeterVault.App;
/// <summary>
/// A date and time of day as typed into a pair of pickers, read in the instance timezone. Values
/// are stored UTC (SDD §10) but entered as wall-clock time, so a reading taken at 18:00 reads back
/// as 18:00 rather than as its UTC instant.
/// </summary>
/// <remarks>
/// Shared by every dialog that timestamps something by hand — a reading, a swap, a delivery — so
/// they agree on the two awkward hours of the year: a spring-forward time names no instant at all
/// and is refused, and an ambiguous autumn time resolves to standard time,
/// <see cref="TimeZoneInfo"/>'s default. Those two candidate instants are an hour apart on one night
/// a year, well inside the precision of a timestamp somebody typed.
/// </remarks>
public sealed class LocalTimeEntry(TimeZoneInfo zone)
{
public TimeZoneInfo Zone { get; } = zone;
/// <summary>Bound to the date picker; only the date part is used.</summary>
public DateTime? Date { get; set; }
/// <summary>Bound to the time picker; minute precision.</summary>
public TimeSpan? TimeOfDay { get; set; }
/// <summary>The wall-clock moment the pickers describe, or null until a date is chosen.</summary>
public DateTime? WallClock => Date is { } date ? date.Date + (TimeOfDay ?? TimeSpan.Zero) : null;
/// <summary>True when the wall-clock time falls in a spring-forward gap and so names no instant.</summary>
public bool IsSkipped =>
WallClock is { } wall && Zone.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
/// <summary>The UTC instant entered, or null while incomplete or skipped.</summary>
public DateTimeOffset? Utc
{
get
{
if (WallClock is not { } wall || IsSkipped)
{
return null;
}
var unspecified = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, Zone), TimeSpan.Zero);
}
}
/// <summary>Resolves the configured timezone id, falling back to UTC for an unknown one.</summary>
public static TimeZoneInfo Resolve(string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return TimeZoneInfo.Utc;
}
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
return TimeZoneInfo.Utc;
}
}
/// <summary>Sets the pickers to <paramref name="instant"/> in the instance timezone, dropping seconds.</summary>
public void Set(DateTimeOffset instant)
{
var local = TimeZoneInfo.ConvertTime(instant, Zone);
Date = local.Date;
TimeOfDay = new TimeSpan(local.Hour, local.Minute, 0);
}
public void SetNow() => Set(DateTimeOffset.UtcNow);
/// <summary>An instant as the instance's wall clock, for display.</summary>
public DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, Zone);
}
+63
View File
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Localization;
namespace MeterVault.App.Localization;
/// <summary>The endpoint behind the language picker in the app bar.</summary>
public static class CultureEndpoints
{
/// <summary>
/// Persists a UI language and returns the user to where they were.
/// </summary>
/// <remarks>
/// A redirect rather than an interactive state change on purpose: a Blazor Server circuit
/// captures <see cref="System.Globalization.CultureInfo.CurrentUICulture"/> from the request
/// that opened it, so switching language has to re-establish the circuit. The picker therefore
/// navigates here with <c>forceLoad</c>, this writes the culture cookie the localization
/// middleware reads, and the reload comes back in the new language.
/// </remarks>
public static IEndpointRouteBuilder MapCultureEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
endpoints.MapGet("/culture/set", (HttpContext http, string? culture, string? redirectUri) =>
{
// Only ever store a language we ship, so a hand-edited link can't park an unusable
// culture in the cookie and leave the UI stuck in fallback.
if (!Loc.TryResolve(culture, out var resolved))
{
return Results.BadRequest($"Unsupported culture '{culture}'.");
}
http.Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(resolved)),
new CookieOptions
{
Path = "/",
Expires = DateTimeOffset.UtcNow.AddYears(1),
SameSite = SameSiteMode.Lax,
// Read only by the localization middleware, never by script.
HttpOnly = true,
// A language preference is exempt from consent gating, and the app is
// self-hosted and single-user anyway.
IsEssential = true,
});
// Anything but a local path is refused rather than followed: this endpoint takes a
// redirect target from the query string, which is exactly the shape of an open redirect.
return Results.LocalRedirect(IsLocalPath(redirectUri) ? redirectUri! : "/");
})
.ExcludeFromDescription();
return endpoints;
}
/// <summary>
/// The framework's own local-URL rule: rooted, and not the "//host" or "/\host" forms browsers
/// resolve as protocol-relative absolute URLs.
/// </summary>
private static bool IsLocalPath(string? url) =>
!string.IsNullOrEmpty(url)
&& url[0] == '/'
&& (url.Length == 1 || (url[1] != '/' && url[1] != '\\'));
}
+193
View File
@@ -0,0 +1,193 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Update;
namespace MeterVault.App.Localization;
/// <summary>
/// Human wording for the domain enums the UI puts on screen.
/// </summary>
/// <remarks>
/// <para>
/// The enums themselves stay bare identifiers: they are persisted as text (SDD §5.2) and appear in
/// the REST API, so their names are part of the data contract and must not move with the reader's
/// language. This is the one place that decides how each value is <em>spoken</em>, which keeps the
/// domain layer free of presentation and gives every page the same word for the same concept —
/// a <c>TariffComponent.UnitPrice</c> is "Arbeitspreis" in the table, the dropdown and the
/// confirm dialog alike.
/// </para>
/// <para>
/// Every arm resolves a <c>Enum_&lt;Type&gt;_&lt;Value&gt;</c> resource. The fallback arms return the
/// identifier rather than throwing, so adding an enum value can never crash a dashboard — and
/// <c>EnumDisplayNameTests</c> fails the build if one is ever left without a translation, which is
/// what stops that safety net from quietly becoming the shipping behaviour.
/// </para>
/// </remarks>
public static class DisplayNames
{
/// <summary>The enums this class is responsible for; the resource-coverage test walks this list.</summary>
public static IReadOnlyList<Type> LocalizedEnums { get; } =
[
typeof(MeterMode),
typeof(ConsumptionKind),
typeof(ReadingQuality),
typeof(ReadingFlags),
typeof(MeterEventType),
typeof(SourceType),
typeof(SourceValueKind),
typeof(TariffScope),
typeof(TariffComponent),
typeof(TankRateMode),
typeof(EndpointType),
typeof(MappingRole),
typeof(UpdateAvailability),
];
public static string Display(this MeterMode value) => value switch
{
MeterMode.CumulativeCounter => Strings.Enum_MeterMode_CumulativeCounter,
MeterMode.GenerationCounter => Strings.Enum_MeterMode_GenerationCounter,
MeterMode.RuntimeCounter => Strings.Enum_MeterMode_RuntimeCounter,
MeterMode.ConsumableBalance => Strings.Enum_MeterMode_ConsumableBalance,
MeterMode.DirectDelta => Strings.Enum_MeterMode_DirectDelta,
MeterMode.InstantRate => Strings.Enum_MeterMode_InstantRate,
MeterMode.Virtual => Strings.Enum_MeterMode_Virtual,
_ => value.ToString(),
};
public static string Display(this ConsumptionKind value) => value switch
{
ConsumptionKind.Consumption => Strings.Enum_ConsumptionKind_Consumption,
ConsumptionKind.Generation => Strings.Enum_ConsumptionKind_Generation,
_ => value.ToString(),
};
public static string Display(this ReadingQuality value) => value switch
{
ReadingQuality.Measured => Strings.Enum_ReadingQuality_Measured,
ReadingQuality.Estimated => Strings.Enum_ReadingQuality_Estimated,
ReadingQuality.Manual => Strings.Enum_ReadingQuality_Manual,
ReadingQuality.Imported => Strings.Enum_ReadingQuality_Imported,
ReadingQuality.Interpolated => Strings.Enum_ReadingQuality_Interpolated,
_ => value.ToString(),
};
/// <summary>
/// A bitmask rendered as the set flags, comma-joined. <see cref="ReadingFlags.None"/> gives an
/// empty string: the readings table shows a flag column that is blank for almost every row, and
/// printing "None" a thousand times down a page is noise, not information.
/// </summary>
public static string Display(this ReadingFlags value)
{
if (value == ReadingFlags.None)
{
return string.Empty;
}
var names = new List<string>(4);
if (value.HasFlag(ReadingFlags.CounterReset))
{
names.Add(Strings.Enum_ReadingFlags_CounterReset);
}
if (value.HasFlag(ReadingFlags.MeterSwap))
{
names.Add(Strings.Enum_ReadingFlags_MeterSwap);
}
if (value.HasFlag(ReadingFlags.Anomaly))
{
names.Add(Strings.Enum_ReadingFlags_Anomaly);
}
if (value.HasFlag(ReadingFlags.MonthLabel))
{
names.Add(Strings.Enum_ReadingFlags_MonthLabel);
}
return names.Count > 0 ? string.Join(", ", names) : value.ToString();
}
public static string Display(this MeterEventType value) => value switch
{
MeterEventType.MeterSwap => Strings.Enum_MeterEventType_MeterSwap,
MeterEventType.CounterReset => Strings.Enum_MeterEventType_CounterReset,
MeterEventType.Delivery => Strings.Enum_MeterEventType_Delivery,
MeterEventType.TankLevel => Strings.Enum_MeterEventType_TankLevel,
MeterEventType.Correction => Strings.Enum_MeterEventType_Correction,
MeterEventType.Note => Strings.Enum_MeterEventType_Note,
_ => value.ToString(),
};
public static string Display(this SourceType value) => value switch
{
SourceType.Mqtt => Strings.Enum_SourceType_Mqtt,
SourceType.Tasmota => Strings.Enum_SourceType_Tasmota,
SourceType.HomeAssistant => Strings.Enum_SourceType_HomeAssistant,
SourceType.Manual => Strings.Enum_SourceType_Manual,
SourceType.Import => Strings.Enum_SourceType_Import,
SourceType.Virtual => Strings.Enum_SourceType_Virtual,
_ => value.ToString(),
};
public static string Display(this SourceValueKind value) => value switch
{
SourceValueKind.Register => Strings.Enum_SourceValueKind_Register,
SourceValueKind.Delta => Strings.Enum_SourceValueKind_Delta,
SourceValueKind.Rate => Strings.Enum_SourceValueKind_Rate,
SourceValueKind.Level => Strings.Enum_SourceValueKind_Level,
SourceValueKind.Runtime => Strings.Enum_SourceValueKind_Runtime,
_ => value.ToString(),
};
public static string Display(this TariffScope value) => value switch
{
TariffScope.Global => Strings.Enum_TariffScope_Global,
TariffScope.EnergyType => Strings.Enum_TariffScope_EnergyType,
TariffScope.Meter => Strings.Enum_TariffScope_Meter,
_ => value.ToString(),
};
public static string Display(this TariffComponent value) => value switch
{
TariffComponent.UnitPrice => Strings.Enum_TariffComponent_UnitPrice,
TariffComponent.BasePrice => Strings.Enum_TariffComponent_BasePrice,
TariffComponent.FeedIn => Strings.Enum_TariffComponent_FeedIn,
TariffComponent.Bonus => Strings.Enum_TariffComponent_Bonus,
TariffComponent.Discount => Strings.Enum_TariffComponent_Discount,
TariffComponent.Tax => Strings.Enum_TariffComponent_Tax,
_ => value.ToString(),
};
public static string Display(this TankRateMode value) => value switch
{
TankRateMode.Fixed => Strings.Enum_TankRateMode_Fixed,
TankRateMode.Empirical => Strings.Enum_TankRateMode_Empirical,
_ => value.ToString(),
};
public static string Display(this EndpointType value) => value switch
{
EndpointType.MqttBroker => Strings.Enum_EndpointType_MqttBroker,
EndpointType.HomeAssistant => Strings.Enum_EndpointType_HomeAssistant,
_ => value.ToString(),
};
public static string Display(this UpdateAvailability value) => value switch
{
UpdateAvailability.Allowed => Strings.Enum_UpdateAvailability_Allowed,
UpdateAvailability.NotEnabled => Strings.Enum_UpdateAvailability_NotEnabled,
UpdateAvailability.NotSupportedHere => Strings.Enum_UpdateAvailability_NotSupportedHere,
_ => value.ToString(),
};
public static string Display(this MappingRole value) => value switch
{
MappingRole.Ignore => Strings.Enum_MappingRole_Ignore,
MappingRole.Reading => Strings.Enum_MappingRole_Reading,
MappingRole.Delivery => Strings.Enum_MappingRole_Delivery,
MappingRole.TankLevel => Strings.Enum_MappingRole_TankLevel,
MappingRole.ManualCost => Strings.Enum_MappingRole_ManualCost,
_ => value.ToString(),
};
}
+36
View File
@@ -0,0 +1,36 @@
using MeterVault.Infrastructure.Import;
namespace MeterVault.App.Localization;
/// <summary>
/// Says a staging warning in the reader's language.
/// </summary>
/// <remarks>
/// The importer emits <see cref="ImportWarning"/> values carrying an English sentence plus the
/// arguments that filled it (see <c>ImportWarnings</c>). Re-formatting from the arguments rather
/// than translating the finished sentence is what lets the numbers pick up the reader's digit
/// grouping — a register that reads <c>2.940,19</c> everywhere else must not read <c>2940.19</c>
/// only inside a warning.
/// </remarks>
public static class ImportWarningText
{
public static string Display(this ImportWarning warning)
{
ArgumentNullException.ThrowIfNull(warning);
var args = warning.Args as object?[] ?? [.. warning.Args];
return warning.Kind switch
{
ImportWarningKind.RowSkipped => Loc.F(Strings.ImportWarning_RowSkipped, args),
ImportWarningKind.UnparseableDate => Loc.F(Strings.ImportWarning_UnparseableDate, args),
ImportWarningKind.UnitMismatch => Loc.F(Strings.ImportWarning_UnitMismatch, args),
ImportWarningKind.RegisterDropped => Loc.F(Strings.ImportWarning_RegisterDropped, args),
ImportWarningKind.RegisterDroppedWithAmount =>
Loc.F(Strings.ImportWarning_RegisterDroppedWithAmount, args),
// A kind added later still says something useful rather than blanking the panel.
_ => warning.Message,
};
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.Globalization;
namespace MeterVault.App.Localization;
/// <summary>
/// UI-language plumbing around <see cref="Strings"/>, the strongly-typed accessor MSBuild generates
/// from <c>Strings.resx</c> (see the <c>EmbeddedResource</c> block in the project file).
/// </summary>
/// <remarks>
/// <para>
/// The neutral resource is English and every other language ships as a satellite assembly, so an
/// <c>Accept-Language</c> we don't translate degrades to English rather than to raw resource keys.
/// Strings are referenced as compiled properties (<c>S.Common_Save</c>), not string lookups, so a
/// key that no longer exists is a build error instead of a mystery label at runtime.
/// </para>
/// <para>
/// Number and date <em>formatting</em> follows <see cref="CultureInfo.CurrentCulture"/> and the UI
/// language follows <see cref="CultureInfo.CurrentUICulture"/>; the request-localization middleware
/// sets both from the same choice, so the two never disagree.
/// </para>
/// </remarks>
public static class Loc
{
/// <summary>Cultures the UI ships translations for. The first entry is the neutral fallback.</summary>
public static IReadOnlyList<string> SupportedCultures { get; } = ["en", "de"];
/// <summary>Formats a resource carrying <c>{0}</c>-style placeholders in the request's culture.</summary>
public static string F(string format, params object?[] args) =>
string.Format(CultureInfo.CurrentCulture, format, args);
/// <summary>
/// Maps a requested language onto one we actually ship, matching on the two-letter tag so
/// <c>de-AT</c> and <c>de-CH</c> get German instead of falling through to English.
/// </summary>
/// <returns><c>true</c> when the request named a language we translate.</returns>
public static bool TryResolve(string? requested, out string resolved)
{
resolved = SupportedCultures[0];
if (string.IsNullOrWhiteSpace(requested))
{
return false;
}
var trimmed = requested.Trim();
var separator = trimmed.IndexOfAny(['-', '_']);
var language = separator < 0 ? trimmed : trimmed[..separator];
foreach (var supported in SupportedCultures)
{
if (string.Equals(supported, language, StringComparison.OrdinalIgnoreCase))
{
resolved = supported;
return true;
}
}
return false;
}
/// <summary>The display name of a supported culture, written in that language ("Deutsch", "English").</summary>
public static string DisplayName(string culture) =>
CultureInfo.GetCultureInfo(culture).NativeName;
}
+58
View File
@@ -0,0 +1,58 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Ingestion;
namespace MeterVault.App.Localization;
/// <summary>How meter-event outcomes are worded — one place, shared by the event dialog and the meter page.</summary>
public static class MeterEventText
{
public static string Problem(MeterEventProblem problem) => problem switch
{
MeterEventProblem.None => string.Empty,
MeterEventProblem.UnknownMeter => Strings.MeterDetail_MeterGone,
MeterEventProblem.NotRecordableForMode => Strings.MeterEvent_ProblemNotForMode,
MeterEventProblem.AmountRequired => Strings.MeterEvent_ProblemAmountRequired,
MeterEventProblem.AmountOutOfRange => Strings.MeterEvent_ProblemAmountOutOfRange,
MeterEventProblem.NoteRequired => Strings.MeterEvent_ProblemNoteRequired,
MeterEventProblem.LevelNeedsCalibration => Strings.MeterEvent_ProblemNeedsCalibration,
MeterEventProblem.LevelAtSameTime => Strings.MeterEvent_ProblemLevelAtSameTime,
MeterEventProblem.LaterBoundaryDependsOnIt => Strings.MeterEvent_ProblemLaterBoundary,
MeterEventProblem.OldRegisterBelowPreviousReading => Strings.MeterEvent_ProblemBelowPrevious,
MeterEventProblem.ReadingAtSameTime => Strings.MeterEvent_ProblemReadingAtSameTime,
MeterEventProblem.BoundaryAlreadyRecorded => Strings.MeterEvent_ProblemAlreadyRecorded,
MeterEventProblem.StartReadingRejected => Strings.MeterEvent_ProblemStartRejected,
MeterEventProblem.NotFound => Strings.MeterEvent_ProblemNotFound,
MeterEventProblem.Imported => Strings.MeterEvent_ProblemImported,
MeterEventProblem.NotManual => Strings.MeterEvent_ProblemNotManual,
_ => problem.ToString(),
};
public static string Saved(MeterEventType type) => type switch
{
MeterEventType.MeterSwap => Strings.MeterEvent_SavedSwap,
MeterEventType.CounterReset => Strings.MeterEvent_SavedReset,
MeterEventType.TankLevel => Strings.MeterEvent_SavedTankLevel,
MeterEventType.Delivery => Strings.MeterEvent_SavedDelivery,
_ => Strings.MeterEvent_SavedNote,
};
public static string Intro(MeterEventType type) => type switch
{
MeterEventType.MeterSwap => Strings.MeterEvent_IntroSwap,
MeterEventType.CounterReset => Strings.MeterEvent_IntroReset,
MeterEventType.TankLevel => Strings.MeterEvent_IntroTankLevel,
MeterEventType.Delivery => Strings.MeterEvent_IntroDelivery,
_ => Strings.MeterEvent_IntroNote,
};
/// <summary>The Material icon for an event type, so menus and the events list read at a glance.</summary>
public static string Icon(MeterEventType type) => type switch
{
MeterEventType.MeterSwap => MudBlazor.Icons.Material.Filled.SwapHoriz,
MeterEventType.CounterReset => MudBlazor.Icons.Material.Filled.RestartAlt,
MeterEventType.TankLevel => MudBlazor.Icons.Material.Filled.Straighten,
MeterEventType.Delivery => MudBlazor.Icons.Material.Filled.LocalShipping,
MeterEventType.Correction => MudBlazor.Icons.Material.Filled.Build,
_ => MudBlazor.Icons.Material.Filled.StickyNote2,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
using System.Globalization;
using MeterVault.Core.Domain;
namespace MeterVault.App;
/// <summary>
/// Addresses into a meter's page. The meter page is where every per-meter task lives — readings,
/// swaps, tank levels, sources, settings — so other screens link straight to the tab and the action
/// rather than to the top of the page and a hunt for the right button.
/// </summary>
/// <remarks>
/// <c>/meters/{id}?tab=events&amp;action=swap</c> opens the Events tab with the swap dialog up. The
/// action is consumed once and dropped from the address, so reloading the page does not reopen it.
/// </remarks>
public static class MeterLinks
{
public const string TabReadings = "readings";
public const string TabConsumption = "consumption";
public const string TabEvents = "events";
public const string TabTariffs = "tariffs";
public const string TabSources = "sources";
public const string ActionReading = "reading";
public const string ActionEdit = "edit";
public const string ActionSource = "source";
/// <summary>Query keys that preset the source dialog: the source to edit, its type, its connector.</summary>
public const string ParamSource = "source";
public const string ParamSourceType = "type";
public const string ParamConnector = "connector";
/// <summary>Tab keys in the order the meter page renders its panels.</summary>
public static readonly IReadOnlyList<string> Tabs = [TabReadings, TabConsumption, TabEvents, TabTariffs, TabSources];
public static string Detail(int meterId, string? tab = null, string? action = null)
{
var query = new List<string>(2);
if (!string.IsNullOrEmpty(tab))
{
query.Add($"tab={Uri.EscapeDataString(tab)}");
}
if (!string.IsNullOrEmpty(action))
{
query.Add($"action={Uri.EscapeDataString(action)}");
}
return query.Count == 0 ? $"/meters/{meterId}" : $"/meters/{meterId}?{string.Join('&', query)}";
}
/// <summary>
/// The Sources tab with the source dialog open: a new source, or <paramref name="sourceId"/> for an
/// existing one, optionally already set to a source type and connector. This is where the connector
/// page returns to once the connector a source was waiting for exists.
/// </summary>
public static string Source(int meterId, int? sourceId = null, SourceType? sourceType = null, int? connectorId = null)
{
var link = Detail(meterId, TabSources, ActionSource);
if (sourceId is { } source)
{
link += $"&{ParamSource}={source.ToString(CultureInfo.InvariantCulture)}";
}
if (sourceType is { } type)
{
link += $"&{ParamSourceType}={type}";
}
if (connectorId is { } connector)
{
link += $"&{ParamConnector}={connector.ToString(CultureInfo.InvariantCulture)}";
}
return link;
}
/// <summary>
/// The connector page with a new connector of <paramref name="endpointType"/> open, for a source that
/// has none yet. Saving it leads back to the source dialog with the new connector picked.
/// </summary>
/// <remarks>
/// The way back is the meter's id, not a URL, so the connector page cannot be made to redirect
/// anywhere but a meter page.
/// </remarks>
public static string NewConnector(int meterId, int? sourceId, SourceType sourceType, EndpointType endpointType) =>
$"/admin/connectors?new={endpointType}{ReturnQuery(meterId, sourceId, sourceType)}";
/// <summary>The connector page with an existing connector open — to enable it — and the same way back.</summary>
public static string EditConnector(int meterId, int? sourceId, SourceType sourceType, int connectorId) =>
$"/admin/connectors?edit={connectorId.ToString(CultureInfo.InvariantCulture)}{ReturnQuery(meterId, sourceId, sourceType)}";
private static string ReturnQuery(int meterId, int? sourceId, SourceType sourceType) =>
$"&meter={meterId.ToString(CultureInfo.InvariantCulture)}"
+ (sourceId is { } source ? $"&{ParamSource}={source.ToString(CultureInfo.InvariantCulture)}" : "")
+ $"&{ParamSourceType}={sourceType}";
/// <summary>The meter page's Events tab with the dialog for <paramref name="type"/> open.</summary>
public static string Event(int meterId, MeterEventType type) => Detail(meterId, TabEvents, ActionFor(type));
/// <summary>
/// The one-tap data entry for a meter: a reading for a register, a tank level for a tank, and
/// nothing for a virtual meter, which has nothing to enter.
/// </summary>
public static string? QuickEntry(int meterId, MeterMode mode) => mode switch
{
MeterMode.Virtual => null,
MeterMode.ConsumableBalance => Event(meterId, MeterEventType.TankLevel),
_ => Detail(meterId, TabReadings, ActionReading),
};
public static string ActionFor(MeterEventType type) => type switch
{
MeterEventType.MeterSwap => "swap",
MeterEventType.CounterReset => "reset",
MeterEventType.Delivery => "delivery",
MeterEventType.TankLevel => "tank-level",
MeterEventType.Correction => "correction",
_ => "note",
};
/// <summary>The event an action names, or null if it names something else (or nothing).</summary>
public static MeterEventType? EventFor(string? action)
{
foreach (var type in Enum.GetValues<MeterEventType>())
{
if (string.Equals(ActionFor(type), action, StringComparison.OrdinalIgnoreCase))
{
return type;
}
}
return null;
}
/// <summary>The panel index for a tab key; unknown or missing keys open the first tab.</summary>
public static int TabIndex(string? tab)
{
for (var i = 0; i < Tabs.Count; i++)
{
if (string.Equals(Tabs[i], tab, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return 0;
}
}
+16
View File
@@ -26,4 +26,20 @@
<Content Include="..\..\sampledata\*.csv" Link="sampledata\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" /> <Content Include="..\..\sampledata\*.csv" Link="sampledata\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<!-- UI strings (SDD §12, M7). The neutral resx generates a strongly-typed `Strings` class, so
components reference compiled properties (S.Common_Save) instead of string keys: a renamed or
deleted string breaks the build rather than silently rendering its key. Generation runs in
MSBuild, not the IDE designer, so `dotnet build` alone reproduces it on any platform.
Translations live in Strings.<culture>.resx and ship as satellite assemblies. -->
<ItemGroup>
<EmbeddedResource Update="Localization\Strings.resx">
<Generator>MSBuild:Compile</Generator>
<StronglyTypedFileName>$(IntermediateOutputPath)\Strings.Designer.cs</StronglyTypedFileName>
<StronglyTypedLanguage>CSharp</StronglyTypedLanguage>
<StronglyTypedNamespace>MeterVault.App.Localization</StronglyTypedNamespace>
<StronglyTypedClassName>Strings</StronglyTypedClassName>
<PublicClass>true</PublicClass>
</EmbeddedResource>
</ItemGroup>
</Project> </Project>
+13
View File
@@ -0,0 +1,13 @@
namespace MeterVault.App;
/// <summary>
/// Per-circuit signal that something the navigation menu lists has changed. The menu lives in the
/// layout, which Blazor keeps for the whole circuit, so without this a new or renamed energy type
/// would not appear in it until the browser reloaded.
/// </summary>
public sealed class NavState
{
public event Action? EnergyTypesChanged;
public void NotifyEnergyTypesChanged() => EnergyTypesChanged?.Invoke();
}
+51 -3
View File
@@ -1,5 +1,6 @@
using MeterVault.App.Api; using MeterVault.App.Api;
using MeterVault.App.Components; using MeterVault.App.Components;
using MeterVault.App.Localization;
using MeterVault.Infrastructure; using MeterVault.Infrastructure;
using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
@@ -24,6 +25,8 @@ try
builder.Services.Configure<MeterVaultOptions>( builder.Services.Configure<MeterVaultOptions>(
builder.Configuration.GetSection(MeterVaultOptions.SectionName)); builder.Configuration.GetSection(MeterVaultOptions.SectionName));
// One zone id for .NET and PostgreSQL alike: a Windows id works for the first and not the second.
builder.Services.PostConfigure<MeterVaultOptions>(o => o.TimeZone = InstanceTimeZone.Canonical(o.TimeZone));
var connectionString = builder.Configuration.GetConnectionString("Default") var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault"; ?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
@@ -71,7 +74,10 @@ try
builder.Services.AddMeterVaultIngestion(); builder.Services.AddMeterVaultIngestion();
} }
builder.Services.AddLocalization();
builder.Services.AddMudServices(); builder.Services.AddMudServices();
builder.Services.AddScoped<MeterVault.App.NavState>();
builder.Services.AddScoped<MeterVault.App.DraftStore>();
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
@@ -100,6 +106,24 @@ try
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy. // that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
app.UseAntiforgery(); app.UseAntiforgery();
// UI language (SDD §12, M7). Must run before MapRazorComponents: a Blazor Server circuit takes
// its culture from the request that opens it, so this is what every render downstream sees.
// Order of preference is the culture cookie the picker writes, then Accept-Language, then
// MeterVault__Locale — an instance can be pinned to one language, and a user can still switch.
// TryResolve yields the neutral fallback when it fails, so defaultCulture is usable either way.
if (!Loc.TryResolve(options.Locale, out var defaultCulture) && !string.IsNullOrWhiteSpace(options.Locale))
{
Log.Warning(
"MeterVault__Locale is '{Locale}', which has no translations; falling back to '{Fallback}'. "
+ "Supported: {Supported}",
options.Locale, defaultCulture, string.Join(", ", Loc.SupportedCultures));
}
app.UseRequestLocalization(new RequestLocalizationOptions()
.SetDefaultCulture(defaultCulture)
.AddSupportedCultures([.. Loc.SupportedCultures])
.AddSupportedUICultures([.. Loc.SupportedCultures]));
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1")); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1"));
@@ -108,6 +132,7 @@ try
.AddInteractiveServerRenderMode(); .AddInteractiveServerRenderMode();
app.MapMeterVaultApi(); app.MapMeterVaultApi();
app.MapCultureEndpoints();
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9). // Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
@@ -126,9 +151,14 @@ finally
static async Task MigrateDatabaseAsync(WebApplication app) static async Task MigrateDatabaseAsync(WebApplication app)
{ {
var options = app.Configuration var options = app.Services.GetRequiredService<Microsoft.Extensions.Options.IOptions<MeterVaultOptions>>().Value;
.GetSection(MeterVaultOptions.SectionName)
.Get<MeterVaultOptions>() ?? new MeterVaultOptions(); // Months are divided and bucketed in this zone. An id nobody knows would silently divide in UTC while
// every database query that buckets by it fails, so say so where the operator will look.
if (!TimeZoneInfo.TryFindSystemTimeZoneById(options.TimeZone, out _))
{
Log.Error("MeterVault:TimeZone '{TimeZone}' is not a known timezone id (expected an IANA id such as Europe/Berlin)", options.TimeZone);
}
if (!options.RunMigrationsAtStartup) if (!options.RunMigrationsAtStartup)
{ {
@@ -139,6 +169,14 @@ static async Task MigrateDatabaseAsync(WebApplication app)
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>(); var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
await db.Database.MigrateAsync().ConfigureAwait(false); await db.Database.MigrateAsync().ConfigureAwait(false);
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false); await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
var zoneKnownToDatabase = await db.Database
.SqlQuery<bool>($"SELECT EXISTS (SELECT 1 FROM pg_timezone_names WHERE name = {options.TimeZone}) AS \"Value\"")
.SingleAsync().ConfigureAwait(false);
if (!zoneKnownToDatabase)
{
Log.Error("MeterVault:TimeZone '{TimeZone}' is not a timezone PostgreSQL knows; every chart and cost query will fail", options.TimeZone);
}
Log.Information("Database migrations applied and defaults seeded"); Log.Information("Database migrations applied and defaults seeded");
if (options.SeedReferenceData) if (options.SeedReferenceData)
@@ -148,6 +186,16 @@ static async Task MigrateDatabaseAsync(WebApplication app)
await importer.LoadAsync(dir).ConfigureAwait(false); await importer.LoadAsync(dir).ConfigureAwait(false);
Log.Information("Reference dataset ensured (SeedReferenceData=true)"); Log.Information("Reference dataset ensured (SeedReferenceData=true)");
} }
// After the data is in place: stored consumption is derived, so when the engine starts booking
// readings differently, existing meters are rebuilt once rather than only as new readings arrive.
var rebuilt = await scope.ServiceProvider
.GetRequiredService<MeterVault.Infrastructure.Normalization.NormalizationUpgrade>()
.RunAsync().ConfigureAwait(false);
if (rebuilt > 0)
{
Log.Information("Recomputed consumption for {Meters} meter(s) after a normalization change", rebuilt);
}
} }
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary> /// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
+200
View File
@@ -0,0 +1,200 @@
using System.Globalization;
using System.Text;
namespace MeterVault.App;
/// <summary>
/// The edit buffer behind the manual-reading keypad. Holds the value as text rather than a number
/// so a half-typed entry ("12345," while the decimals are still coming) is representable and no
/// intermediate state gets rounded away by a numeric binding.
/// </summary>
/// <remarks>
/// <para>
/// Separator handling is deliberately lenient rather than culture-strict: the same field is driven
/// by the on-screen keypad (always a comma), by an Android keyboard (comma on a German locale, dot
/// on an English one) and by a desktop numpad, so a value has to survive either character. The rule
/// is that only the <em>last</em> separator is decimal and earlier ones are grouping — "1.234,5"
/// and "1,234.5" both give 1234.5. A lone separator is therefore always decimal ("1.234" → 1.234),
/// which is what the keypad emits and what an English keyboard means; nobody types thousands
/// separators into a meter register, and the dialog echoes the parsed value back formatted, so a
/// misread is visible before saving. This differs from
/// <see cref="Core.Parsing.GermanNumber"/> on purpose: that one parses spreadsheet exports, where a
/// lone dot really is a thousands separator.
/// </para>
/// <para>
/// <see cref="IsPristine"/> gives the buffer calculator behaviour. The dialog opens prefilled with
/// the meter's last reading; the first digit key then replaces it outright (a fresh reading), while
/// backspace edits it in place (only the last few digits of a register usually move). Without that
/// distinction one of the two workflows always costs a full retype on a phone.
/// </para>
/// </remarks>
public sealed class ReadingEntry
{
/// <summary>Wide enough for any real register plus decimals; stops a stuck key growing the string.</summary>
public const int MaxLength = 18;
private const char Separator = ',';
public string Text { get; private set; } = string.Empty;
/// <summary>True while the buffer still holds the untouched prefill, so the next digit replaces it.</summary>
public bool IsPristine { get; private set; }
/// <summary>The entered number, or null while the buffer is empty or not yet a valid number.</summary>
public double? Value => TryParse(Text, out var value) ? value : null;
/// <summary>Seeds the buffer with a meter's last reading, marked pristine.</summary>
public void Prefill(double value)
{
// "0.#########" keeps a register readable (12345,6) without inventing precision the meter
// never had, yet round-trips a sensor's four or five decimals — rounded to three, the prefill
// would read as lower than the stored reading and be flagged as a decrease before a key is
// pressed. Invariant then swapped so the buffer only ever contains one separator glyph.
Text = value.ToString("0.#########", CultureInfo.InvariantCulture).Replace('.', Separator);
IsPristine = true;
}
public void AppendDigit(char digit)
{
if (!char.IsAsciiDigit(digit))
{
return;
}
ReplacePrefillOnFirstKey();
// A leading zero is never meaningful on a register, and letting it stand makes "0" then "5"
// read as "05" — replace it instead, exactly like a calculator.
if (Text == "0")
{
Text = digit.ToString();
return;
}
if (Text.Length < MaxLength)
{
Text += digit;
}
}
public void AppendSeparator()
{
ReplacePrefillOnFirstKey();
if (Text.Contains(Separator, StringComparison.Ordinal))
{
return;
}
// "," alone parses as nothing, so lead with the zero the user means.
Text = Text.Length == 0 ? "0" + Separator : Text + Separator;
}
/// <summary>
/// Deletes the last character. Unlike a digit key this keeps the prefill rather than clearing
/// it — reading a register usually means correcting its final digits, not retyping all of them.
/// </summary>
public void Backspace()
{
IsPristine = false;
if (Text.Length > 0)
{
Text = Text[..^1];
}
}
public void Clear()
{
Text = string.Empty;
IsPristine = false;
}
/// <summary>
/// Accepts free text from the keyboard-bound field, keeping only characters that can form a
/// number. Junk is dropped rather than rejected so typing never dead-ends mid-value.
/// </summary>
public void SetText(string? raw)
{
IsPristine = false;
if (string.IsNullOrEmpty(raw))
{
Text = string.Empty;
return;
}
var builder = new StringBuilder(Math.Min(raw.Length, MaxLength));
foreach (var c in raw)
{
if (builder.Length >= MaxLength)
{
break;
}
if (char.IsAsciiDigit(c) || c is '.' or ',' || (c == '-' && builder.Length == 0))
{
builder.Append(c);
}
}
Text = builder.ToString();
}
/// <summary>Parses a buffer as described on the type: last separator decimal, earlier ones grouping.</summary>
public static bool TryParse(string? text, out double value)
{
value = 0;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var raw = text.Trim();
var negative = raw[0] == '-';
if (negative || raw[0] == '+')
{
raw = raw[1..];
}
var lastSeparator = raw.LastIndexOfAny(['.', ',']);
var builder = new StringBuilder(raw.Length);
var digits = 0;
for (var i = 0; i < raw.Length; i++)
{
var c = raw[i];
if (char.IsAsciiDigit(c))
{
builder.Append(c);
digits++;
}
else if (c is '.' or ',')
{
if (i == lastSeparator)
{
builder.Append('.');
}
}
else
{
return false;
}
}
if (digits == 0
|| !double.TryParse(builder.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
{
return false;
}
value = negative ? -parsed : parsed;
return true;
}
private void ReplacePrefillOnFirstKey()
{
if (IsPristine)
{
Text = string.Empty;
IsPristine = false;
}
}
}
+7
View File
@@ -53,6 +53,13 @@ public enum ReadingFlags
CounterReset = 1, CounterReset = 1,
MeterSwap = 2, MeterSwap = 2,
Anomaly = 4, Anomaly = 4,
/// <summary>
/// An imported row whose date was a month name ("Mai 2026"), stamped on the 1st: it carries the
/// register at the end of that month, not at the instant it is stamped. Set by the importer, which
/// is the only place that still knows whether the date cell named a month or a day.
/// </summary>
MonthLabel = 8,
} }
/// <summary>Discrete meter lifecycle/correction events (SDD meter_event.event_type).</summary> /// <summary>Discrete meter lifecycle/correction events (SDD meter_event.event_type).</summary>
+44
View File
@@ -0,0 +1,44 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// Which lifecycle events a meter can meaningfully record, decided by its measurement mode — the
/// same dispatch the normalizers use, so the UI never offers an event that would change nothing.
/// </summary>
/// <remarks>
/// <see cref="MeterEventType.Correction"/> is deliberately never offered: no normalizer reads it, so
/// recording one would look like a fix while leaving every derived number untouched.
/// </remarks>
public static class MeterEventRules
{
private static readonly MeterEventType[] RegisterEvents =
[MeterEventType.MeterSwap, MeterEventType.CounterReset, MeterEventType.Note];
private static readonly MeterEventType[] TankEvents =
[MeterEventType.TankLevel, MeterEventType.Delivery, MeterEventType.Note];
private static readonly MeterEventType[] NoteOnly = [MeterEventType.Note];
/// <summary>The event types worth recording for a meter in <paramref name="mode"/>, most common first.</summary>
public static IReadOnlyList<MeterEventType> RecordableFor(MeterMode mode) => mode switch
{
MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => RegisterEvents,
MeterMode.ConsumableBalance => TankEvents,
_ => NoteOnly,
};
public static bool CanRecord(MeterMode mode, MeterEventType type) => RecordableFor(mode).Contains(type);
/// <summary>A swap or reset: an instant where a register legitimately starts over.</summary>
public static bool IsRegisterBoundary(MeterEventType type) =>
type is MeterEventType.MeterSwap or MeterEventType.CounterReset;
/// <summary>
/// Whether raw readings drive this meter's consumption. A tank is driven by level and delivery
/// events, and a virtual meter by other meters, so a reading typed against either changes nothing.
/// </summary>
public static bool TakesReadings(MeterMode mode) => mode is not (MeterMode.ConsumableBalance or MeterMode.Virtual);
/// <summary>Whether the register may only count up, so a lower value needs a swap or reset to explain it.</summary>
public static bool IsMonotonic(MeterMode mode) =>
mode is MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter;
}
+30
View File
@@ -0,0 +1,30 @@
namespace MeterVault.Core.Domain;
/// <summary>
/// Which connector a live source is served by. Routing is endpoint-scoped: a source without a
/// connector of the right kind has no connection details and silently never ingests.
/// </summary>
public static class SourceRouting
{
/// <summary>
/// The connector kind a source type needs, or null if it needs none (manual/import/virtual).
/// Tasmota has no endpoint kind of its own — it is served by an MQTT broker connector.
/// </summary>
public static EndpointType? RequiredEndpoint(SourceType sourceType) => sourceType switch
{
SourceType.HomeAssistant => EndpointType.HomeAssistant,
SourceType.Mqtt or SourceType.Tasmota => EndpointType.MqttBroker,
_ => null,
};
/// <summary>Whether a connector of <paramref name="endpointType"/> can serve a source of <paramref name="sourceType"/>.</summary>
public static bool Serves(EndpointType endpointType, SourceType sourceType) =>
RequiredEndpoint(sourceType) == endpointType;
/// <summary>The source type a new source on a connector of this kind starts as.</summary>
public static SourceType DefaultSourceFor(EndpointType endpointType) => endpointType switch
{
EndpointType.HomeAssistant => SourceType.HomeAssistant,
_ => SourceType.Mqtt,
};
}
+216
View File
@@ -0,0 +1,216 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Normalization;
/// <summary>
/// Attributes a register delta to the calendar months it actually accrued in.
/// </summary>
/// <remarks>
/// <para>
/// A reading is an instant, and the consumption between two readings happened over the time between
/// them. Booking the whole delta at the closing reading puts it in the wrong month whenever the
/// interval crosses a month boundary: a reading on 1 August and the next on 16 September would show
/// six weeks of use in September and none in August. So an interval that crosses one or more local
/// month boundaries is divided at those boundaries, in proportion to elapsed time, and every share is
/// stamped inside the month it belongs to. The meter recorded a total, not a shape, so a divided
/// interval's rows are marked <see cref="ReadingQuality.Estimated"/>; the parts always sum to the
/// original, so nothing is created or lost.
/// </para>
/// <para>
/// Imported monthly tables are the exception that keeps the golden fixtures reconciling (SDD §13). A
/// row labelled "Mai 2026" carries the register at the <em>end</em> of May and May's consumption — the
/// sheet is filled after the month closes — but the importer stamps it on the 1st so that it files
/// under its month, and flags it <see cref="ReadingFlags.MonthLabel"/>. <see cref="EffectiveTime"/>
/// reads such a <see cref="IsMonthLabel">month label</see> as the end of its month. Two consecutive
/// labels then span exactly their closing month and book unchanged, while a live reading that follows
/// the last imported row counts from the end of that row's month instead of claiming it a second time.
/// A day-dated row ("01.08.2026") is an instant like any other reading, even though it is stamped at
/// the same midnight — which is why label status is recorded at import rather than read off the clock.
/// </para>
/// <para>
/// Months are the instance's local months (SDD §10), because that is how every chart buckets them: a
/// UTC split would let a reading taken shortly after local midnight on the 1st file a whole month's
/// use under the new month.
/// </para>
/// </remarks>
public static class GapAttribution
{
/// <summary>
/// True for a row from an imported monthly table (<see cref="ReadingFlags.MonthLabel"/>). Such a
/// timestamp names a month, not the moment of reading. A hand correction of the row keeps that
/// meaning; a live source writing to the same instant clears the flag (see the ingestion upsert).
/// </summary>
public static bool IsMonthLabel(Reading reading)
{
ArgumentNullException.ThrowIfNull(reading);
return reading.Flags.HasFlag(ReadingFlags.MonthLabel);
}
/// <summary>
/// The instant a reading describes: its timestamp, or for a <see cref="IsMonthLabel">month label</see>
/// the local midnight that ends the labelled month.
/// </summary>
public static DateTimeOffset EffectiveTime(Reading reading, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(reading);
ArgumentNullException.ThrowIfNull(zone);
return IsMonthLabel(reading)
? LocalMidnight(LabelledMonth(reading).AddMonths(1), zone)
: reading.Time;
}
/// <summary>
/// Where a reading's own consumption row is stamped: its timestamp, unless it is a month label whose
/// timestamp falls outside the labelled local month. In a zone behind UTC, 00:00 UTC on the 1st is
/// still the previous local month, so such a label is stamped at the start of its month instead.
/// </summary>
public static DateTimeOffset StampTime(Reading reading, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(reading);
ArgumentNullException.ThrowIfNull(zone);
if (!IsMonthLabel(reading))
{
return reading.Time;
}
var monthStart = LabelMonthStart(reading, zone);
return reading.Time >= monthStart && reading.Time < EffectiveTime(reading, zone) ? reading.Time : monthStart;
}
/// <summary>The local midnight that starts the month a label names.</summary>
public static DateTimeOffset LabelMonthStart(Reading reading, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(reading);
ArgumentNullException.ThrowIfNull(zone);
return LocalMidnight(LabelledMonth(reading), zone);
}
/// <summary>
/// The instant a local calendar day starts in <paramref name="zone"/>, in UTC. Readers turn the dates
/// of a requested range into instants with this, so a range covers exactly the local months its rows
/// are bucketed and stamped in.
/// </summary>
public static DateTimeOffset LocalMidnight(DateOnly date, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
return LocalMidnight(date.ToDateTime(TimeOnly.MinValue), zone);
}
/// <summary>
/// Divides <paramref name="amount"/>, accrued over <c>[from, to)</c>, across the local calendar
/// months it touches, in proportion to the time spent in each.
/// </summary>
/// <param name="closingStamp">
/// Where the closing reading's own row goes (<see cref="StampTime"/>). The share for the month it
/// falls in keeps it, so an interval inside a single month produces exactly the row it always did. A
/// share in any other month is stamped at the last second of that month — inside it, and strictly
/// between the two readings.
/// </param>
public static IReadOnlyList<GapSegment> Attribute(
DateTimeOffset from, DateTimeOffset to, DateTimeOffset closingStamp, double amount, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(zone);
if (to <= from)
{
return [new GapSegment(closingStamp.ToUniversalTime(), amount)];
}
var total = to - from;
var segments = new List<GapSegment>();
var cursor = from;
var assigned = 0d;
while (cursor < to)
{
var monthStart = MonthStartContaining(cursor, zone);
var monthEnd = NextMonthStart(monthStart, zone);
var segmentEnd = monthEnd < to ? monthEnd : to;
var last = segmentEnd >= to;
if (segmentEnd <= cursor)
{
// Zone data that contradicts itself around a transition must never stall the walk: book
// the rest here rather than loop without advancing.
segmentEnd = to;
last = true;
}
// The final share takes the remainder, so rounding never creates or destroys energy.
var share = last ? amount - assigned : amount * ((segmentEnd - cursor) / total);
var stamp = closingStamp >= monthStart && closingStamp < monthEnd
? closingStamp
: InsideSegment(cursor, segmentEnd, monthEnd);
// UTC, like every stored instant (SDD §10): the boundaries are computed as local wall-clock
// times, and PostgreSQL's timestamptz accepts only zero offsets from Npgsql.
segments.Add(new GapSegment(stamp.ToUniversalTime(), share));
assigned += share;
cursor = segmentEnd;
}
return segments;
}
/// <summary>The month a label names — its UTC month, which is what the importer wrote.</summary>
private static DateTime LabelledMonth(Reading reading)
{
var utc = reading.Time.UtcDateTime;
return new DateTime(utc.Year, utc.Month, 1);
}
/// <summary>The last second of the month, or the segment's midpoint when the segment is shorter than that.</summary>
private static DateTimeOffset InsideSegment(DateTimeOffset start, DateTimeOffset end, DateTimeOffset monthEnd)
{
var lastSecond = (end < monthEnd ? end : monthEnd).AddSeconds(-1);
return lastSecond > start ? lastSecond : start + ((end - start) / 2);
}
private static DateTimeOffset MonthStartContaining(DateTimeOffset instant, TimeZoneInfo zone)
{
var local = TimeZoneInfo.ConvertTime(instant, zone);
return LocalMidnight(new DateTime(local.Year, local.Month, 1), zone);
}
private static DateTimeOffset NextMonthStart(DateTimeOffset monthStart, TimeZoneInfo zone)
{
var local = TimeZoneInfo.ConvertTime(monthStart, zone);
return LocalMidnight(new DateTime(local.Year, local.Month, 1).AddMonths(1), zone);
}
/// <summary>
/// Midnight at the start of a local date as an instant. A zone whose clocks jump forward at midnight
/// has no such moment on that day; the first moment that does exist is the month's real start. A zone
/// whose clocks fall back at midnight has it twice; the first of the two is.
/// </summary>
private static DateTimeOffset LocalMidnight(DateTime date, TimeZoneInfo zone)
{
var day = DateTime.SpecifyKind(date.Date, DateTimeKind.Unspecified);
var wall = day;
while (zone.IsInvalidTime(wall))
{
wall = wall.AddMinutes(30);
}
// The larger offset is the earlier UTC instant, i.e. the first occurrence of the wall-clock time.
var offset = zone.IsAmbiguousTime(wall) ? zone.GetAmbiguousTimeOffsets(wall).Max() : zone.GetUtcOffset(wall);
var instant = new DateTimeOffset(wall, offset).ToUniversalTime();
// Some zone data reports, for a midnight right at a transition, the offset from after the change
// — naming an instant that is still the evening before (America/Asuncion, Europe/Volgograd). The
// day starts at the first instant whose local date is that day. Bounded: offsets move by hours.
for (var step = 0; step < 4 * 26 && TimeZoneInfo.ConvertTime(instant, zone).DateTime < day; step++)
{
instant = instant.AddMinutes(15);
}
return instant;
}
}
/// <summary>One month's share of an interval: the instant it is stamped at and the amount attributed.</summary>
public sealed record GapSegment(DateTimeOffset Time, double Amount);
@@ -16,6 +16,12 @@ public sealed class NormalizationContext
public IReadOnlyList<MeterEvent> Events { get; init; } = []; public IReadOnlyList<MeterEvent> Events { get; init; } = [];
/// <summary>
/// The instance timezone. Consumption is attributed to local calendar months — the months every
/// chart buckets by (SDD §10) — so an interval is divided at local, not UTC, month boundaries.
/// </summary>
public TimeZoneInfo TimeZone { get; init; } = TimeZoneInfo.Utc;
/// <summary>Referenced meters' consumption series, keyed by meter id (virtual meters only).</summary> /// <summary>Referenced meters' consumption series, keyed by meter id (virtual meters only).</summary>
public IReadOnlyDictionary<int, IReadOnlyList<Consumption>> ReferencedSeries { get; init; } public IReadOnlyDictionary<int, IReadOnlyList<Consumption>> ReferencedSeries { get; init; }
= new Dictionary<int, IReadOnlyList<Consumption>>(); = new Dictionary<int, IReadOnlyList<Consumption>>();
+28 -1
View File
@@ -36,6 +36,33 @@ public sealed class NormalizationEngine : INormalizationEngine
throw new NotSupportedException($"No normalizer registered for mode {context.Meter.Mode}."); throw new NotSupportedException($"No normalizer registered for mode {context.Meter.Mode}.");
} }
return [.. normalizer.Normalize(context)]; return Coalesce(normalizer.Normalize(context));
}
/// <summary>
/// One row per (time, kind), in time order. A stored row is keyed by meter, time and kind, so two
/// rows on the same instant would make the whole recompute fail — and with it every ingest for the
/// meter. Rows can meet when a share stamped at a month's last second lands on a reading taken at
/// exactly that second, or at a local midnight a label is stamped at. Their amounts add up: the
/// total is what the readings say, only its placement is shared.
/// </summary>
private static List<Consumption> Coalesce(IEnumerable<Consumption> rows)
{
var byKey = new Dictionary<(DateTimeOffset Time, ConsumptionKind Kind), Consumption>();
foreach (var row in rows)
{
if (byKey.TryGetValue((row.Time, row.Kind), out var existing))
{
existing.Amount += row.Amount;
existing.Quality = ReadingQuality.Estimated;
existing.ImportBatchId ??= row.ImportBatchId;
}
else
{
byKey[(row.Time, row.Kind)] = row;
}
}
return [.. byKey.Values.OrderBy(r => r.Time).ThenBy(r => r.Kind)];
} }
} }
@@ -10,11 +10,21 @@ namespace MeterVault.Core.Normalization.Normalizers;
/// spreadsheet's month-one full register, e.g. Haus 411, Auto 3755);</item> /// spreadsheet's month-one full register, e.g. Haus 411, Auto 3755);</item>
/// <item>meter swap → an explicit <c>Amount</c> override if given (how the water swap …861→2 /// <item>meter swap → an explicit <c>Amount</c> override if given (how the water swap …861→2
/// reconciles to 12), otherwise <c>(oldFinal prev) + (curr newInitial)</c>;</item> /// reconciles to 12), otherwise <c>(oldFinal prev) + (curr newInitial)</c>;</item>
/// <item>counter reset → baseline restarts at <c>NewValue</c> (default 0);</item> /// <item>counter reset → baseline restarts at <c>NewValue</c> (default 0); a <c>PrevValue</c>, when
/// given, is the register's last value before the reset and books the stretch since the previous
/// reading, exactly like a swap — without it that stretch is simply not counted;</item>
/// <item>unexplained decrease → 0 with an anomaly flagged (never a silent negative), rebaselined /// <item>unexplained decrease → 0 with an anomaly flagged (never a silent negative), rebaselined
/// to the current value.</item> /// to the current value;</item>
/// <item>a plain increase over an interval that crosses a local month boundary → divided across
/// those months by elapsed time and marked estimated (<see cref="GapAttribution"/>), so a reading
/// on 16 September does not file August's use under September. Consecutive imported month rows
/// span exactly one month and are never divided.</item>
/// </list> /// </list>
/// </summary> /// </summary>
/// <remarks>
/// "Ascending" is the order of <see cref="ReadingTimeline"/>: an imported month row describes the
/// register at the end of its month, so it comes after live readings taken during that month.
/// </remarks>
public abstract class CounterNormalizerBase : IMeterNormalizer public abstract class CounterNormalizerBase : IMeterNormalizer
{ {
public abstract MeterMode Mode { get; } public abstract MeterMode Mode { get; }
@@ -25,38 +35,37 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
{ {
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
var readings = context.Readings.OrderBy(r => r.Time).ToList(); var zone = context.TimeZone;
if (readings.Count == 0) var timeline = ReadingTimeline.Build(context.Readings, zone);
if (timeline.Readings.Count == 0)
{ {
yield break; yield break;
} }
var swaps = context.Events var swaps = RegisterBoundary.Of(context.Events);
.Where(e => e.EventType is MeterEventType.MeterSwap or MeterEventType.CounterReset)
.OrderBy(e => e.Time)
.ToList();
double previous = context.Meter.InitialBaseline; double previous = context.Meter.InitialBaseline;
DateTimeOffset? previousTime = null; DateTimeOffset? previousEffective = null;
foreach (var reading in readings) foreach (var (reading, effective) in timeline.Readings)
{ {
var quality = reading.Quality == ReadingQuality.Measured ? ReadingQuality.Measured : reading.Quality; var quality = reading.Quality == ReadingQuality.Measured ? ReadingQuality.Measured : reading.Quality;
var swap = FindEvent(swaps, previousTime, reading.Time); var swap = RegisterBoundary.Find(swaps, previousEffective, effective, timeline.BoundaryTime);
double amount; double amount;
var plainIncrease = false;
if (swap is { EventType: MeterEventType.MeterSwap }) if (swap is { EventType: MeterEventType.MeterSwap })
{ {
amount = swap.Amount amount = swap.Amount ?? RegisterBoundary.Advance(swap, previous, reading.Value);
?? ((swap.PrevValue ?? previous) - previous) + (reading.Value - (swap.NewValue ?? 0));
} }
else if (swap is { EventType: MeterEventType.CounterReset }) else if (swap is { EventType: MeterEventType.CounterReset })
{ {
amount = reading.Value - (swap.NewValue ?? 0); amount = RegisterBoundary.Advance(swap, previous, reading.Value);
} }
else if (reading.Value >= previous) else if (reading.Value >= previous)
{ {
amount = reading.Value - previous; amount = reading.Value - previous;
plainIncrease = true;
} }
else else
{ {
@@ -65,32 +74,32 @@ public abstract class CounterNormalizerBase : IMeterNormalizer
quality = ReadingQuality.Estimated; quality = ReadingQuality.Estimated;
} }
yield return new Consumption // Only a plain increase is attributed across months (SDD §7.1). A swap or reset amount is an
// explicit correction booked at its event; a rejected decrease contributes nothing; the
// first reading has no interval behind it; and fanning a zero out across months just adds
// rows that say nothing.
var stamp = GapAttribution.StampTime(reading, zone);
var segments = plainIncrease && Math.Abs(amount) > 1e-9 && previousEffective is { } from
? GapAttribution.Attribute(from, effective, stamp, amount, zone)
: [new GapSegment(stamp.ToUniversalTime(), amount)];
foreach (var segment in segments)
{ {
MeterId = context.Meter.MeterId, yield return new Consumption
Time = reading.Time, {
Amount = amount, MeterId = context.Meter.MeterId,
Kind = Kind, Time = segment.Time,
Quality = quality, Amount = segment.Amount,
ImportBatchId = reading.ImportBatchId, Kind = Kind,
}; // A divided interval's total is measured; only its distribution across the months is
// inferred.
Quality = segments.Count > 1 ? ReadingQuality.Estimated : quality,
ImportBatchId = reading.ImportBatchId,
};
}
previous = reading.Value; previous = reading.Value;
previousTime = reading.Time; previousEffective = effective;
} }
} }
private static MeterEvent? FindEvent(List<MeterEvent> events, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive)
{
foreach (var e in events)
{
var afterOk = afterExclusive is null || e.Time > afterExclusive.Value;
if (afterOk && e.Time <= upToInclusive)
{
return e;
}
}
return null;
}
} }
@@ -4,7 +4,8 @@ namespace MeterVault.Core.Normalization.Normalizers;
/// <summary> /// <summary>
/// The source already reports increments (SDD §5.2 <c>direct_delta</c>): each reading value is the /// The source already reports increments (SDD §5.2 <c>direct_delta</c>): each reading value is the
/// consumption for its interval, used verbatim. /// consumption for its interval, used verbatim. An imported month row ("August 2026" = 20) is that
/// month's consumption and is stamped inside it (<see cref="GapAttribution.StampTime"/>).
/// </summary> /// </summary>
public sealed class DirectDeltaNormalizer : IMeterNormalizer public sealed class DirectDeltaNormalizer : IMeterNormalizer
{ {
@@ -19,7 +20,7 @@ public sealed class DirectDeltaNormalizer : IMeterNormalizer
yield return new Consumption yield return new Consumption
{ {
MeterId = context.Meter.MeterId, MeterId = context.Meter.MeterId,
Time = reading.Time, Time = GapAttribution.StampTime(reading, context.TimeZone).ToUniversalTime(),
Amount = reading.Value, Amount = reading.Value,
Kind = ConsumptionKind.Consumption, Kind = ConsumptionKind.Consumption,
Quality = reading.Quality, Quality = reading.Quality,
@@ -0,0 +1,62 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Normalization.Normalizers;
/// <summary>
/// The register math shared by every normalizer that reads a monotonic register: a
/// <see cref="MeterEventType.MeterSwap"/> or <see cref="MeterEventType.CounterReset"/> is a point
/// where the register legitimately starts over, and the interval that contains it is booked as the
/// old register's tail plus the new register's advance rather than as a raw difference.
/// </summary>
internal static class RegisterBoundary
{
/// <summary>The swap/reset events of a meter, oldest first — the only events that move a register.</summary>
public static List<MeterEvent> Of(IEnumerable<MeterEvent> events) =>
events
.Where(e => e.EventType is MeterEventType.MeterSwap or MeterEventType.CounterReset)
.OrderBy(e => e.Time)
.ToList();
/// <summary>
/// The first boundary inside the reading interval <c>(afterExclusive, upToInclusive]</c>. A reading
/// taken at exactly the boundary's instant belongs to the new register, which is what lets a swap
/// recorded together with the new meter's start reading book cleanly at that instant.
/// </summary>
public static MeterEvent? Find(List<MeterEvent> boundaries, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive) =>
Find(boundaries, afterExclusive, upToInclusive, e => e.Time);
/// <summary>
/// As <see cref="Find(List{MeterEvent}, DateTimeOffset?, DateTimeOffset)"/>, placing each boundary on the
/// timeline at <paramref name="timeOf"/> — for a series walked in effective time rather than stamps.
/// </summary>
public static MeterEvent? Find(
List<MeterEvent> boundaries, DateTimeOffset? afterExclusive, DateTimeOffset upToInclusive, Func<MeterEvent, DateTimeOffset> timeOf)
{
foreach (var e in boundaries)
{
var time = timeOf(e);
var afterOk = afterExclusive is null || time > afterExclusive.Value;
if (afterOk && time <= upToInclusive)
{
return e;
}
}
return null;
}
/// <summary>
/// How far the register moved across a boundary: the old register from the previous reading up to
/// its final value (<see cref="MeterEvent.PrevValue"/>, defaulting to the previous reading, i.e. no
/// tail), plus the new register from its start value (<see cref="MeterEvent.NewValue"/>, default 0)
/// up to the current reading.
/// </summary>
/// <remarks>
/// The start value never counts above the current reading. A swap detected in a monthly table records
/// the new register at the end of that month as its "start"; when a live reading earlier in the month
/// is the first on the new register, it is already below that value, and the advance is zero, not
/// negative.
/// </remarks>
public static double Advance(MeterEvent boundary, double previous, double current) =>
((boundary.PrevValue ?? previous) - previous) + (current - Math.Min(boundary.NewValue ?? 0, current));
}
@@ -9,6 +9,15 @@ namespace MeterVault.Core.Normalization.Normalizers;
/// the tank level-Δ (<see cref="ConsumableBalanceNormalizer"/>); a runtime meter's Δhours feeds /// the tank level-Δ (<see cref="ConsumableBalanceNormalizer"/>); a runtime meter's Δhours feeds
/// the empirical L/h analytic and is not double-counted as litres. /// the empirical L/h analytic and is not double-counted as litres.
/// </summary> /// </summary>
/// <remarks>
/// An hour counter is a register like any other, so a replaced burner or a reset counter is a
/// <see cref="MeterEventType.MeterSwap"/> / <see cref="MeterEventType.CounterReset"/> handled with the
/// same register math as <see cref="CounterNormalizerBase"/>: the interval containing the boundary
/// books the old counter's tail plus the new counter's advance. Without one, a decrease still books
/// nothing rather than a negative runtime. Readings are walked in <see cref="ReadingTimeline"/> order and
/// a month row's hours are stamped inside the month it names, like any other register; the hours are
/// not divided across months.
/// </remarks>
public sealed class RuntimeCounterNormalizer : IMeterNormalizer public sealed class RuntimeCounterNormalizer : IMeterNormalizer
{ {
public MeterMode Mode => MeterMode.RuntimeCounter; public MeterMode Mode => MeterMode.RuntimeCounter;
@@ -18,19 +27,36 @@ public sealed class RuntimeCounterNormalizer : IMeterNormalizer
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
var rate = context.Meter.Tank is { RateMode: TankRateMode.Fixed, FixedRate: { } r } ? r : 1d; var rate = context.Meter.Tank is { RateMode: TankRateMode.Fixed, FixedRate: { } r } ? r : 1d;
var readings = context.Readings.OrderBy(x => x.Time).ToList(); var timeline = ReadingTimeline.Build(context.Readings, context.TimeZone);
var boundaries = RegisterBoundary.Of(context.Events);
double previous = context.Meter.InitialBaseline; double previous = context.Meter.InitialBaseline;
foreach (var reading in readings) DateTimeOffset? previousTime = null;
foreach (var (reading, effective) in timeline.Readings)
{ {
var deltaHours = reading.Value >= previous ? reading.Value - previous : 0d; var boundary = RegisterBoundary.Find(boundaries, previousTime, effective, timeline.BoundaryTime);
double amount;
if (boundary is { EventType: MeterEventType.MeterSwap, Amount: { } explicitAmount })
{
amount = explicitAmount;
}
else
{
var deltaHours = boundary is not null
? RegisterBoundary.Advance(boundary, previous, reading.Value)
: reading.Value - previous;
amount = Math.Max(0d, deltaHours) * rate;
}
previous = reading.Value; previous = reading.Value;
previousTime = effective;
yield return new Consumption yield return new Consumption
{ {
MeterId = context.Meter.MeterId, MeterId = context.Meter.MeterId,
Time = reading.Time, Time = GapAttribution.StampTime(reading, context.TimeZone).ToUniversalTime(),
Amount = deltaHours * rate, Amount = amount,
Kind = ConsumptionKind.Consumption, Kind = ConsumptionKind.Consumption,
Quality = reading.Quality, Quality = reading.Quality,
ImportBatchId = reading.ImportBatchId, ImportBatchId = reading.ImportBatchId,
+71
View File
@@ -0,0 +1,71 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Normalization;
/// <summary>
/// A meter's readings in the order they describe the register, and where its swaps and resets sit on
/// that order. Shared by every normalizer that reads a register, and by the checks that must agree with
/// them (the decrease guard, the event dialog), so none of them orders readings differently.
/// </summary>
/// <remarks>
/// <para>
/// The order is effective time (<see cref="GapAttribution.EffectiveTime"/>): an imported month row
/// describes the register at the end of its month, so it comes after live readings taken during that
/// month even though it is stamped on the 1st. Walking stamps instead would take the end-of-month value
/// first, see the live readings as a drop, and count the month twice.
/// </para>
/// <para>
/// A swap or reset the importer detected in a monthly table is stored at that row's stamp, but the row
/// only says the register started over at some point during its month. It is placed at the start of
/// the labelled local month, so it applies to the month's first reading: the month row itself when the
/// table stands alone, or the first live reading of that month when there are any — which is already
/// on the new register.
/// </para>
/// </remarks>
public sealed class ReadingTimeline
{
private readonly Dictionary<DateTimeOffset, DateTimeOffset> _labelMonthStarts;
private ReadingTimeline(IReadOnlyList<TimelineReading> readings, Dictionary<DateTimeOffset, DateTimeOffset> labelMonthStarts)
{
Readings = readings;
_labelMonthStarts = labelMonthStarts;
}
/// <summary>The readings, oldest first by effective time, then by stamp.</summary>
public IReadOnlyList<TimelineReading> Readings { get; }
public static ReadingTimeline Build(IEnumerable<Reading> readings, TimeZoneInfo zone)
{
ArgumentNullException.ThrowIfNull(readings);
ArgumentNullException.ThrowIfNull(zone);
var ordered = readings
.Select(r => new TimelineReading(r, GapAttribution.EffectiveTime(r, zone)))
.OrderBy(r => r.Effective)
.ThenBy(r => r.Reading.Time)
.ToList();
var labelMonthStarts = new Dictionary<DateTimeOffset, DateTimeOffset>();
foreach (var entry in ordered.Where(r => GapAttribution.IsMonthLabel(r.Reading)))
{
labelMonthStarts.TryAdd(entry.Reading.Time, GapAttribution.LabelMonthStart(entry.Reading, zone));
}
return new ReadingTimeline(ordered, labelMonthStarts);
}
/// <summary>
/// Where a swap or reset sits on the timeline: its own time, or — when it is stamped exactly at a
/// month row — just after the start of that row's local month.
/// </summary>
public DateTimeOffset BoundaryTime(MeterEvent boundary)
{
ArgumentNullException.ThrowIfNull(boundary);
return _labelMonthStarts.TryGetValue(boundary.Time, out var monthStart) ? monthStart.AddTicks(1) : boundary.Time;
}
}
/// <summary>A reading and the instant it describes.</summary>
public readonly record struct TimelineReading(Reading Reading, DateTimeOffset Effective);
+18 -7
View File
@@ -1,8 +1,10 @@
using Dapper; using Dapper;
using MeterVault.Core.Costing; using MeterVault.Core.Costing;
using MeterVault.Core.Domain; using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Costing; namespace MeterVault.Infrastructure.Costing;
@@ -13,10 +15,18 @@ namespace MeterVault.Infrastructure.Costing;
/// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext /// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext
/// factory (short-lived context per operation) so it is safe from a Blazor circuit. /// factory (short-lived context per operation) so it is safe from a Blazor circuit.
/// </summary> /// </summary>
public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFactory) public sealed class CostService(
IDbContextFactory<MeterVaultDbContext> contextFactory, IOptions<MeterVaultOptions>? options = null)
{ {
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory; private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
/// <summary>
/// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
/// intervals at, so a share stamped at a month's last second is read back under that month.
/// </summary>
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync( public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync(
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -31,7 +41,7 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFa
} }
var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, cancellationToken).ConfigureAwait(false); var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, _timeZone, cancellationToken).ConfigureAwait(false);
var results = new List<MeterCostBucket>(); var results = new List<MeterCostBucket>();
foreach (var period in series.Keys.OrderBy(k => k)) foreach (var period in series.Keys.OrderBy(k => k))
@@ -85,8 +95,9 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFa
} }
} }
var fromDate = DateOnly.FromDateTime(from.Date); // Manual costs are dated in local months; the instants passed in are local midnights.
var toDate = DateOnly.FromDateTime(to.Date); var fromDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(from, _zone).Date);
var toDate = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(to, _zone).Date);
var manualCosts = await db.ManualCosts.AsNoTracking() var manualCosts = await db.ManualCosts.AsNoTracking()
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate) .Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate)
.ToListAsync(cancellationToken).ConfigureAwait(false); .ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -100,7 +111,7 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFa
} }
private static async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync( private static async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket, MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket, string tz,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var interval = bucket switch var interval = bucket switch
@@ -111,13 +122,13 @@ public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFa
}; };
var sql = var sql =
$"SELECT (time_bucket(INTERVAL '{interval}', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " + $"SELECT (time_bucket(INTERVAL '{interval}', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " +
"kind, sum(amount) AS amount " + "kind, sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period, kind"; "GROUP BY period, kind";
var connection = db.Database.GetDbConnection(); var connection = db.Database.GetDbConnection();
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken); var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<ConsumptionRow>(command).ConfigureAwait(false); var rows = await connection.QueryAsync<ConsumptionRow>(command).ConfigureAwait(false);
var result = new Dictionary<DateOnly, (double, double)>(); var result = new Dictionary<DateOnly, (double, double)>();
@@ -5,6 +5,9 @@ namespace MeterVault.Infrastructure.Dashboard;
/// <summary>One recorded delivery into a consumable store.</summary> /// <summary>One recorded delivery into a consumable store.</summary>
public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit); public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit);
/// <summary>A consumable-balance meter with no tank yet, so it has no level, fill or forecast to show.</summary>
public sealed record UnconfiguredConsumable(int MeterId, string Name);
/// <summary>One month of consumable draw.</summary> /// <summary>One month of consumable draw.</summary>
public sealed record ConsumableMonth(DateOnly Period, double Consumption); public sealed record ConsumableMonth(DateOnly Period, double Consumption);
@@ -2,8 +2,10 @@ using Dapper;
using MeterVault.Core.Domain; using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard; namespace MeterVault.Infrastructure.Dashboard;
@@ -15,11 +17,19 @@ namespace MeterVault.Infrastructure.Dashboard;
/// runtime hours of same-energy-type <see cref="MeterMode.RuntimeCounter"/> meters. DbContext /// runtime hours of same-energy-type <see cref="MeterMode.RuntimeCounter"/> meters. DbContext
/// factory keeps it Blazor-circuit safe. /// factory keeps it Blazor-circuit safe.
/// </summary> /// </summary>
public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService) public sealed class ConsumableService(
IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService, IOptions<MeterVaultOptions>? options = null)
{ {
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory; private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
private readonly CostService _costService = costService; private readonly CostService _costService = costService;
/// <summary>
/// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
/// intervals at, so a share stamped at a month's last second is read back under that month.
/// </summary>
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<IReadOnlyList<ConsumableSummary>> GetConsumablesAsync( public async Task<IReadOnlyList<ConsumableSummary>> GetConsumablesAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default) DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{ {
@@ -44,12 +54,28 @@ public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> con
return summaries; return summaries;
} }
/// <summary>
/// Consumable-balance meters that have no tank row. <see cref="GetConsumablesAsync"/> has to skip
/// them — capacity and calibration come from the tank — so without this they would simply be
/// missing from the panel, with nothing saying why.
/// </summary>
public async Task<IReadOnlyList<UnconfiguredConsumable>> GetUnconfiguredAsync(CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return await db.Meters.AsNoTracking()
.Where(m => m.Mode == MeterMode.ConsumableBalance && !db.Tanks.Any(t => t.MeterId == m.Id))
.OrderBy(m => m.Name)
.Select(m => new UnconfiguredConsumable(m.Id, m.Name))
.ToListAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<ConsumableSummary> BuildAsync( private async Task<ConsumableSummary> BuildAsync(
MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken) MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken)
{ {
var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration; var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration;
var fromUtc = ToUtc(from); var fromUtc = InstanceTimeZone.StartOf(from, _zone);
var toUtc = ToUtc(to); var toUtc = InstanceTimeZone.StartOf(to, _zone);
var events = await db.MeterEvents.AsNoTracking() var events = await db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery)) .Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery))
@@ -103,7 +129,7 @@ public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> con
var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false)) var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false))
.Sum(c => c.Cost); .Sum(c => c.Cost);
var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
return new ConsumableSummary( return new ConsumableSummary(
meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf, meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf,
@@ -152,16 +178,16 @@ public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> con
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0; .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
private static async Task<IReadOnlyList<ConsumableMonth>> MonthlyConsumptionAsync( private static async Task<IReadOnlyList<ConsumableMonth>> MonthlyConsumptionAsync(
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, string tz, CancellationToken cancellationToken)
{ {
const string sql = const string sql =
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " + "SELECT (time_bucket(INTERVAL '1 month', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " +
"sum(amount) AS amount " + "sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period ORDER BY period"; "GROUP BY period ORDER BY period";
var connection = db.Database.GetDbConnection(); var connection = db.Database.GetDbConnection();
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken); var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false); var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList(); return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList();
} }
@@ -173,5 +199,4 @@ public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> con
return isCentimetres && calibration is not null ? calibration.ToVolume(value) : value; 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);
} }
@@ -17,6 +17,35 @@ public sealed record DashboardSummary(DateOnly AsOf, CostKpi Month, CostKpi Year
/// <summary>One slice of the cost breakdown / "what costs most" view.</summary> /// <summary>One slice of the cost breakdown / "what costs most" view.</summary>
public sealed record CategorySlice(string Name, string? ColorHex, double Cost); public sealed record CategorySlice(string Name, string? ColorHex, double Cost);
/// <summary>The first thing missing before the dashboard can show a cost, in the order they are set up.</summary>
public enum CostSetupGap
{
None,
NoMeters,
NoCategories,
NoMembers,
NoTariffs,
}
/// <summary>
/// What exists of the chain a cost figure needs: a meter, a category that counts it, and a price.
/// </summary>
/// <remarks>
/// Manual costs stand in for the meter side only on an instance without meters. Once there are meters,
/// their chain is diagnosed on its own: the diagnosis only runs when this year shows no cost, so any
/// manual cost there is from another year and explains nothing about the meters.
/// </remarks>
public sealed record CostSetup(bool HasMeters, bool HasCategories, bool HasMembers, bool HasTariffs, bool HasManualCosts)
{
public CostSetupGap FirstGap =>
!HasMeters && !HasManualCosts ? CostSetupGap.NoMeters
: !HasCategories ? CostSetupGap.NoCategories
: !HasMeters ? CostSetupGap.None
: !HasMembers ? CostSetupGap.NoMembers
: !HasTariffs ? CostSetupGap.NoTariffs
: CostSetupGap.None;
}
/// <summary>A point on a monthly cost/consumption trend.</summary> /// <summary>A point on a monthly cost/consumption trend.</summary>
public sealed record TrendPoint(DateOnly Period, double Cost); public sealed record TrendPoint(DateOnly Period, double Cost);
@@ -1,6 +1,8 @@
using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard; namespace MeterVault.Infrastructure.Dashboard;
@@ -10,11 +12,18 @@ namespace MeterVault.Infrastructure.Dashboard;
/// only aggregated cost — never the raw hypertable. Uses a DbContext factory (short-lived context /// only aggregated cost — never the raw hypertable. Uses a DbContext factory (short-lived context
/// per operation) so it is safe from a Blazor circuit. /// per operation) so it is safe from a Blazor circuit.
/// </summary> /// </summary>
public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService) public sealed class DashboardService(
IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService, IOptions<MeterVaultOptions>? options = null)
{ {
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory; private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
private readonly CostService _costService = costService; private readonly CostService _costService = costService;
/// <summary>
/// The instance timezone: periods are local months, so their bounds are local midnights — the same
/// months consumption is divided and bucketed in.
/// </summary>
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<DashboardSummary> GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default) public async Task<DashboardSummary> GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default)
{ {
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
@@ -36,6 +45,24 @@ public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> cont
return new DashboardSummary(asOf, month, year, latest); return new DashboardSummary(asOf, month, year, latest);
} }
/// <summary>
/// Which part of the cost setup exists, so an empty dashboard can name the step that is missing
/// instead of listing every admin page. Existence checks only.
/// </summary>
public async Task<CostSetup> GetCostSetupAsync(CancellationToken cancellationToken = default)
{
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
return new CostSetup(
HasMeters: await db.Meters.AnyAsync(cancellationToken).ConfigureAwait(false),
HasCategories: await db.CostCategories.AnyAsync(cancellationToken).ConfigureAwait(false),
// A membership counts only if it reaches a meter: an energy type with no meters costs nothing.
HasMembers: await db.CostCategoryMembers
.AnyAsync(m => m.MeterId != null || db.Meters.Any(x => x.EnergyTypeId == m.EnergyTypeId), cancellationToken)
.ConfigureAwait(false),
HasTariffs: await db.Tariffs.AnyAsync(cancellationToken).ConfigureAwait(false),
HasManualCosts: await db.ManualCosts.AnyAsync(cancellationToken).ConfigureAwait(false));
}
public async Task<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync( public async Task<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default) DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{ {
@@ -124,12 +151,13 @@ public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> cont
return 0; return 0;
} }
var monthStart = new DateOnly(latest.Value.Year, latest.Value.Month, 1); var local = TimeZoneInfo.ConvertTime(latest.Value, _zone);
var monthStart = new DateOnly(local.Year, local.Month, 1);
return await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false); return await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false);
} }
private static async Task<List<int>> ActiveMeterIdsAsync(MeterVaultDbContext db, CancellationToken cancellationToken) => private static async Task<List<int>> ActiveMeterIdsAsync(MeterVaultDbContext db, CancellationToken cancellationToken) =>
await db.Meters.AsNoTracking().Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false); await db.Meters.AsNoTracking().Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero); private DateTimeOffset ToUtc(DateOnly date) => InstanceTimeZone.StartOf(date, _zone);
} }
@@ -1,6 +1,11 @@
namespace MeterVault.Infrastructure.Dashboard; namespace MeterVault.Infrastructure.Dashboard;
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary> /// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
/// <param name="Label">
/// A meter's name — for a remainder node (<paramref name="IsOther"/>) the <em>parent</em> meter's
/// name. The surrounding wording ("Other (…)") is the UI's to supply, because it is the only layer
/// that knows the reader's language; see <c>Flow_OtherNode</c>.
/// </param>
public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId); 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> /// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary>
+12 -5
View File
@@ -1,6 +1,8 @@
using MeterVault.Core.Domain; using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard; namespace MeterVault.Infrastructure.Dashboard;
@@ -12,12 +14,16 @@ namespace MeterVault.Infrastructure.Dashboard;
/// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory /// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory
/// keeps it Blazor-circuit safe. /// keeps it Blazor-circuit safe.
/// </summary> /// </summary>
public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFactory) public sealed class FlowService(
IDbContextFactory<MeterVaultDbContext> contextFactory, IOptions<MeterVaultOptions>? options = null)
{ {
private const double Epsilon = 0.01; private const double Epsilon = 0.01;
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory; private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
/// <summary>The instance timezone a requested date range starts and ends in.</summary>
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<FlowGraph> GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default) public async Task<FlowGraph> GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{ {
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
@@ -30,8 +36,8 @@ public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFa
} }
var meterIds = meters.Select(m => m.Id).ToHashSet(); var meterIds = meters.Select(m => m.Id).ToHashSet();
var fromUtc = ToUtc(from); var fromUtc = InstanceTimeZone.StartOf(from, _zone);
var toUtc = ToUtc(to); var toUtc = InstanceTimeZone.StartOf(to, _zone);
// A meter's flow value is its throughput: consumption OR generation output — so a generation // 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 // meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter
@@ -115,7 +121,9 @@ public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFa
if (remainder > Epsilon) if (remainder > Epsilon)
{ {
var otherId = $"other{meter.Id}"; var otherId = $"other{meter.Id}";
nodes.Add(new FlowNode(otherId, $"Other ({meter.Name})", remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null)); // Just the parent's name: the "Other (…)" phrasing is added by the UI, which is
// where the reader's language is known.
nodes.Add(new FlowNode(otherId, meter.Name, remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder)); flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder));
} }
} }
@@ -154,5 +162,4 @@ public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFa
private static string NodeId(int meterId) => $"m{meterId}"; 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);
} }
@@ -14,7 +14,8 @@ public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost)
/// <summary> /// <summary>
/// A meter framed the way it is actually read: what it used this period, how that compares with the /// A meter framed the way it is actually read: what it used this period, how that compares with the
/// last one, and where the year is heading. Amounts are generation for a generation counter and /// last one, and where the year is heading. Amounts are generation for a generation counter and
/// consumption otherwise, so <see cref="Label"/> says which. /// consumption otherwise, so <see cref="Kind"/> says which — as the enum, not a word, because the
/// wording belongs to whichever language the reader picked.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Month- and year-to-date are compared against a <em>projection</em> of the current period rather /// Month- and year-to-date are compared against a <em>projection</em> of the current period rather
@@ -22,7 +23,7 @@ public sealed record MeterMonthPoint(DateOnly Month, double Amount, double Cost)
/// collapse in usage when nothing has changed. Projections are flagged so the UI can mark them. /// collapse in usage when nothing has changed. Projections are flagged so the UI can mark them.
/// </remarks> /// </remarks>
public sealed record MeterPeriodView( public sealed record MeterPeriodView(
string Label, ConsumptionKind Kind,
string Unit, string Unit,
string Currency, string Currency,
double MonthToDate, double MonthToDate,
@@ -45,12 +46,17 @@ public sealed record MeterPeriodView(
public bool HasHistory => Last12Months.Count > 0; public bool HasHistory => Last12Months.Count > 0;
/// <summary>
/// Percentage change is only meaningful against a positive baseline. Dividing by a negative one
/// inverts the sign — a net-export meter going from 100 to 150 would report "+50% more used"
/// when it exported half as much again — so those report no basis rather than a confident lie.
/// </summary>
private static double? Ratio(double current, double previous) => private static double? Ratio(double current, double previous) =>
Math.Abs(previous) < 1e-9 ? null : (current - previous) / previous; previous <= 1e-9 ? null : (current - previous) / previous;
} }
/// <summary>A meter lifecycle/correction event row.</summary> /// <summary>A meter lifecycle/correction event row. <see cref="ImportBatchId"/> marks one only its batch can remove.</summary>
public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes); public sealed record EventRow(int Id, DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes, int? ImportBatchId);
/// <summary>A tariff applicable to the meter (own / energy-type / global scope), for the timeline.</summary> /// <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); public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
@@ -85,4 +91,6 @@ public sealed record MeterDetailView(
IReadOnlyList<ConsumptionDetailRow> RecentConsumption, IReadOnlyList<ConsumptionDetailRow> RecentConsumption,
IReadOnlyList<EventRow> Events, IReadOnlyList<EventRow> Events,
IReadOnlyList<TariffRow> Tariffs, IReadOnlyList<TariffRow> Tariffs,
int SourceCount); int SourceCount,
short EnergyTypeId,
bool HasTank);
@@ -59,8 +59,8 @@ public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> co
var events = await db.MeterEvents.AsNoTracking() var events = await db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meterId) .Where(e => e.MeterId == meterId)
.OrderByDescending(e => e.Time) .OrderByDescending(e => e.Time).ThenByDescending(e => e.Id)
.Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes)) .Select(e => new EventRow(e.Id, e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes, e.ImportBatchId))
.ToListAsync(cancellationToken).ConfigureAwait(false); .ToListAsync(cancellationToken).ConfigureAwait(false);
var energyTypeId = meter.EnergyTypeId; var energyTypeId = meter.EnergyTypeId;
@@ -72,12 +72,14 @@ public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> co
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo)) .Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
.ToListAsync(cancellationToken).ConfigureAwait(false); .ToListAsync(cancellationToken).ConfigureAwait(false);
var hasTank = await db.Tanks.AsNoTracking().AnyAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false);
return new MeterDetailView( return new MeterDetailView(
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit, meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive, meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
readingCount, consumptionCount, readingCount, consumptionCount,
first?.Time, last?.Time, first?.Value, last?.Value, first?.Time, last?.Time, first?.Value, last?.Value,
totalConsumption, totalGeneration, totalConsumption, totalGeneration,
recentReadings, recentConsumption, events, tariffs, meter.Sources.Count); recentReadings, recentConsumption, events, tariffs, meter.Sources.Count, meter.EnergyTypeId, hasTank);
} }
} }
@@ -43,6 +43,14 @@ public sealed class MeterPeriodService(
return null; return null;
} }
// Virtual meters are evaluated on read and only materialize into `consumption` when a cost
// category references them (SDD §14.1), so summing that table would report a confident zero
// for a meter that is working fine. Report nothing and let the page say why.
if (meter.Mode == MeterMode.Virtual)
{
return null;
}
var tz = ResolveTimeZone(); var tz = ResolveTimeZone();
var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz).Date); var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz).Date);
@@ -78,7 +86,7 @@ public sealed class MeterPeriodService(
var yearToDateCost = SumYear(costs, today.Year); var yearToDateCost = SumYear(costs, today.Year);
return new MeterPeriodView( return new MeterPeriodView(
Label: isGeneration ? "Generation" : "Consumption", Kind: kind,
Unit: meter.Unit, Unit: meter.Unit,
Currency: _options.Currency, Currency: _options.Currency,
MonthToDate: monthToDate, MonthToDate: monthToDate,
+20 -11
View File
@@ -1,8 +1,10 @@
using Dapper; using Dapper;
using MeterVault.Core.Costing; using MeterVault.Core.Costing;
using MeterVault.Core.Domain; using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Dashboard; namespace MeterVault.Infrastructure.Dashboard;
@@ -11,12 +13,20 @@ namespace MeterVault.Infrastructure.Dashboard;
/// <see cref="MeterMode.GenerationCounter"/> meter; self-consumption / autarky / savings are derived /// <see cref="MeterMode.GenerationCounter"/> meter; self-consumption / autarky / savings are derived
/// from the meters tagged <see cref="MeterRoles.TotalLoad"/> and <see cref="MeterRoles.GridImport"/> /// 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 /// — 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. /// (monthly, in the instance timezone) via Dapper; safe from a Blazor circuit via a DbContext factory.
/// </summary> /// </summary>
public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextFactory) public sealed class SolarService(
IDbContextFactory<MeterVaultDbContext> contextFactory, IOptions<MeterVaultOptions>? options = null)
{ {
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory; private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
/// <summary>
/// The instance timezone months and days are bucketed in (SDD §10) — the zone normalization divides
/// intervals at, so a share stamped at a month's last second is read back under that month.
/// </summary>
private readonly string _timeZone = (options?.Value ?? new MeterVaultOptions()).TimeZone;
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve((options?.Value ?? new MeterVaultOptions()).TimeZone);
public async Task<SolarSummary> GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default) public async Task<SolarSummary> GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{ {
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
@@ -26,22 +36,22 @@ public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextF
var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad); var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad);
var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport); var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport);
var fromUtc = ToUtc(from); var fromUtc = InstanceTimeZone.StartOf(from, _zone);
var toUtc = ToUtc(to); var toUtc = InstanceTimeZone.StartOf(to, _zone);
// Monthly generation per generation meter. // Monthly generation per generation meter.
var genByMeter = new Dictionary<int, IReadOnlyDictionary<DateOnly, double>>(); var genByMeter = new Dictionary<int, IReadOnlyDictionary<DateOnly, double>>();
foreach (var meter in generationMeters) foreach (var meter in generationMeters)
{ {
genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
} }
var loadByMonth = loadMeter is null var loadByMonth = loadMeter is null
? null ? null
: await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); : await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
var gridByMonth = gridMeter is null var gridByMonth = gridMeter is null
? null ? null
: await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false); : await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, _timeZone, cancellationToken).ConfigureAwait(false);
var tariffs = gridMeter is null var tariffs = gridMeter is null
? [] ? []
@@ -98,19 +108,18 @@ public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextF
} }
private static async Task<IReadOnlyDictionary<DateOnly, double>> MonthlyAsync( private static async Task<IReadOnlyDictionary<DateOnly, double>> MonthlyAsync(
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, string tz, CancellationToken cancellationToken)
{ {
const string sql = const string sql =
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " + "SELECT (time_bucket(INTERVAL '1 month', \"time\", @tz) AT TIME ZONE @tz)::date AS period, " +
"sum(amount) AS amount " + "sum(amount) AS amount " +
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period"; "GROUP BY period";
var connection = db.Database.GetDbConnection(); var connection = db.Database.GetDbConnection();
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken); var command = new CommandDefinition(sql, new { meterId, from, to, tz }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false); var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
return rows.ToDictionary(r => r.Period, r => r.Amount); 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);
} }
@@ -28,10 +28,12 @@ public static class DependencyInjection
services.AddSingleton<INormalizationEngine>(_ => NormalizationEngine.CreateDefault()); services.AddSingleton<INormalizationEngine>(_ => NormalizationEngine.CreateDefault());
services.AddScoped<NormalizationService>(); services.AddScoped<NormalizationService>();
services.AddScoped<NormalizationUpgrade>();
services.AddScoped<CsvImporter>(); services.AddScoped<CsvImporter>();
services.AddScoped<ImportService>(); services.AddScoped<ImportService>();
services.AddScoped<ReferenceDataImporter>(); services.AddScoped<ReferenceDataImporter>();
services.AddScoped<IngestionService>(); services.AddScoped<IngestionService>();
services.AddScoped<MeterEventService>();
services.AddScoped<MqttMessageRouter>(); services.AddScoped<MqttMessageRouter>();
// HttpClient + HA tester are available even with live ingestion off, so the admin // HttpClient + HA tester are available even with live ingestion off, so the admin
// "Test connection" works without the background workers running. // "Test connection" works without the background workers running.
@@ -40,6 +42,9 @@ public static class DependencyInjection
// Singleton: wraps one IDataProtector, and the ingestion workers (themselves singletons) // Singleton: wraps one IDataProtector, and the ingestion workers (themselves singletons)
// resolve connector secrets on every reconnect. // resolve connector secrets on every reconnect.
services.AddSingleton<Security.SecretProtector>(); services.AddSingleton<Security.SecretProtector>();
// Singleton: it caches the last answer so the dashboard never waits on a remote call.
services.AddSingleton<Update.UpdateCheckService>();
services.AddSingleton<Update.UpdateRunner>();
services.AddScoped<Costing.CostService>(); services.AddScoped<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>(); services.AddScoped<Dashboard.DashboardService>();
services.AddScoped<Dashboard.SolarService>(); services.AddScoped<Dashboard.SolarService>();
+34 -18
View File
@@ -31,29 +31,29 @@ public sealed class CsvImporter
staged.SkippedRows++; staged.SkippedRows++;
if (!reason.StartsWith("blank", StringComparison.Ordinal)) if (!reason.StartsWith("blank", StringComparison.Ordinal))
{ {
staged.Warnings.Add($"Row {r + 1}: skipped ({reason})."); staged.Warnings.Add(ImportWarnings.RowSkipped(r + 1, reason));
} }
continue; continue;
} }
if (!TryGetDate(row, profile, out var time)) if (!TryGetDate(row, profile, out var time, out var monthLabel))
{ {
staged.SkippedRows++; staged.SkippedRows++;
staged.Warnings.Add($"Row {r + 1}: unparseable date, skipped."); staged.Warnings.Add(ImportWarnings.UnparseableDate(r + 1));
continue; continue;
} }
foreach (var column in profile.Columns) foreach (var column in profile.Columns)
{ {
StageColumn(column, row, time, r, staged, previousByMeter, profile.DetectCumulativeSwaps); StageColumn(column, row, time, monthLabel, r, staged, previousByMeter, profile.DetectCumulativeSwaps);
} }
} }
return staged; return staged;
} }
private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time, private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time, bool monthLabel,
int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps) int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
{ {
if (column.Role == MappingRole.Ignore || column.Index >= row.Count) if (column.Role == MappingRole.Ignore || column.Index >= row.Count)
@@ -70,7 +70,7 @@ public sealed class CsvImporter
switch (column.Role) switch (column.Role)
{ {
case MappingRole.Reading: case MappingRole.Reading:
StageReading(column, row, cell, time, rowIndex, staged, previousByMeter, detectSwaps); StageReading(column, row, cell, time, monthLabel, rowIndex, staged, previousByMeter, detectSwaps);
break; break;
case MappingRole.Delivery: case MappingRole.Delivery:
@@ -126,7 +126,7 @@ public sealed class CsvImporter
} }
private static void StageReading(ColumnMapping column, IReadOnlyList<string> row, string cell, private static void StageReading(ColumnMapping column, IReadOnlyList<string> row, string cell,
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps) DateTimeOffset time, bool monthLabel, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
{ {
var split = ValueCell.Split(cell); var split = ValueCell.Split(cell);
if (split is null) if (split is null)
@@ -140,8 +140,7 @@ public sealed class CsvImporter
if (column.Unit is not null && split.Value.Unit is not null if (column.Unit is not null && split.Value.Unit is not null
&& !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase)) && !string.Equals(column.Unit, split.Value.Unit, StringComparison.OrdinalIgnoreCase))
{ {
staged.Warnings.Add( staged.Warnings.Add(ImportWarnings.UnitMismatch(rowIndex + 1, column.Unit, split.Value.Unit));
$"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'.");
} }
if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous) if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
@@ -157,9 +156,9 @@ public sealed class CsvImporter
Amount = override_, Amount = override_,
Notes = "Auto-detected register decrease (meter swap / reset candidate).", Notes = "Auto-detected register decrease (meter swap / reset candidate).",
}); });
staged.Warnings.Add( staged.Warnings.Add(override_ is { } amount
$"Row {rowIndex + 1}: register for meter {meterId} dropped {previous}→{value}; " + ? ImportWarnings.RegisterDroppedWithAmount(rowIndex + 1, meterId, previous, value, amount)
(override_ is null ? "swap event created (verify)." : $"swap consumption set to {override_}.")); : ImportWarnings.RegisterDropped(rowIndex + 1, meterId, previous, value));
} }
staged.Readings.Add(new Reading staged.Readings.Add(new Reading
@@ -168,6 +167,7 @@ public sealed class CsvImporter
Time = time, Time = time,
Value = value, Value = value,
Quality = ReadingQuality.Imported, Quality = ReadingQuality.Imported,
Flags = monthLabel ? ReadingFlags.MonthLabel : ReadingFlags.None,
}); });
previousByMeter[meterId] = value; previousByMeter[meterId] = value;
} }
@@ -182,9 +182,16 @@ public sealed class CsvImporter
return GermanNumber.TryParse(row[source], out var value) ? value : null; return GermanNumber.TryParse(row[source], out var value) ? value : null;
} }
private static bool TryGetDate(IReadOnlyList<string> row, MappingProfile profile, out DateTimeOffset time) /// <param name="monthLabel">
/// Whether the cell named a month ("Mai 2026") stamped on its 1st, rather than a day. Normalization
/// reads such a row as the register at the end of that month (<see cref="ReadingFlags.MonthLabel"/>);
/// a day-dated "01.08.2026" at the same midnight is an instant. Months anchored to their last day
/// are stamped where they describe, so they are not labels.
/// </param>
private static bool TryGetDate(IReadOnlyList<string> row, MappingProfile profile, out DateTimeOffset time, out bool monthLabel)
{ {
time = default; time = default;
monthLabel = false;
if (profile.DateColumn >= row.Count) if (profile.DateColumn >= row.Count)
{ {
return false; return false;
@@ -192,12 +199,21 @@ public sealed class CsvImporter
var raw = row[profile.DateColumn]; var raw = row[profile.DateColumn];
DateOnly date; DateOnly date;
var parsed = profile.DateKind switch bool parsed;
switch (profile.DateKind)
{ {
DateKind.MonthName => TryParseMonthAnchored(raw, profile.AnchorMonthsToEnd, out date), case DateKind.MonthName:
DateKind.DayDotMonthYear => GermanDate.TryParseDay(raw, out date), parsed = TryParseMonthAnchored(raw, profile.AnchorMonthsToEnd, out date);
_ => ImportDate.TryResolve(raw, profile.AnchorMonthsToEnd, out date), monthLabel = parsed && !profile.AnchorMonthsToEnd;
}; break;
case DateKind.DayDotMonthYear:
parsed = GermanDate.TryParseDay(raw, out date);
break;
default:
parsed = ImportDate.TryResolve(raw, profile.AnchorMonthsToEnd, out date);
monthLabel = parsed && !profile.AnchorMonthsToEnd && !GermanDate.TryParseDay(raw, out _);
break;
}
if (!parsed) if (!parsed)
{ {
@@ -19,6 +19,7 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n
{ {
ArgumentNullException.ThrowIfNull(staged); ArgumentNullException.ThrowIfNull(staged);
GuardDuplicateReadings(staged); GuardDuplicateReadings(staged);
await GuardExistingReadingsAsync(staged, cancellationToken).ConfigureAwait(false);
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
@@ -115,6 +116,50 @@ public sealed class ImportService(MeterVaultDbContext db, NormalizationService n
$"timestamp ({sample}). Check that no two mapped columns target the same meter."); $"timestamp ({sample}). Check that no two mapped columns target the same meter.");
} }
/// <summary>
/// Rejects readings that already exist in the database, which is what re-importing an overlapping
/// CSV produces — the commonest real duplicate, and one the in-batch guard cannot see because the
/// staged set is internally unique. Left to the database it surfaces as EF's
/// "An error occurred while saving the entity changes", naming neither meter nor date.
/// </summary>
/// <remarks>
/// Checks one meter at a time, bounded by that meter's staged time range, so the query stays
/// proportional to the overlap rather than to the table.
/// </remarks>
private async Task GuardExistingReadingsAsync(StagedImport staged, CancellationToken cancellationToken)
{
var clashes = new List<string>();
foreach (var group in staged.Readings.GroupBy(r => r.MeterId))
{
var times = group.Select(r => r.Time).ToHashSet();
var from = times.Min();
var to = times.Max();
var existing = await _db.Readings.AsNoTracking()
.Where(r => r.MeterId == group.Key && r.Time >= from && r.Time <= to)
.Select(r => r.Time)
.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var time in existing.Where(times.Contains).Take(3))
{
clashes.Add($"meter {group.Key} at {time:yyyy-MM-dd}");
}
if (clashes.Count >= 3)
{
break;
}
}
if (clashes.Count > 0)
{
throw new InvalidOperationException(
$"This import would overwrite readings that already exist ({string.Join("; ", clashes)}). "
+ "Revert the earlier batch on the Import page first, or narrow the file's date range.");
}
}
private static IEnumerable<int> AffectedMeters(StagedImport staged) => private static IEnumerable<int> AffectedMeters(StagedImport staged) =>
staged.Readings.Select(r => r.MeterId) staged.Readings.Select(r => r.MeterId)
.Concat(staged.Events.Select(e => e.MeterId)) .Concat(staged.Events.Select(e => e.MeterId))
@@ -0,0 +1,74 @@
using System.Globalization;
namespace MeterVault.Infrastructure.Import;
/// <summary>What a staging warning is about.</summary>
public enum ImportWarningKind
{
/// <summary>A row matched a skip rule (a summary line, an all-zero placeholder). Args: row, reason.</summary>
RowSkipped,
/// <summary>The date cell parsed as neither <c>Monat JJJJ</c> nor <c>TT.MM.JJJJ</c>. Args: row.</summary>
UnparseableDate,
/// <summary>The value carried a different unit suffix than the column expects. Args: row, expected, found.</summary>
UnitMismatch,
/// <summary>A register went backwards; a swap event was staged for review. Args: row, meter, previous, new.</summary>
RegisterDropped,
/// <summary>Same, with the consumption across the swap taken from the sheet. Args: row, meter, previous, new, amount.</summary>
RegisterDroppedWithAmount,
}
/// <summary>
/// One thing worth telling the user about a staged row.
/// </summary>
/// <param name="Message">
/// The English sentence, kept for logs and any non-UI caller.
/// </param>
/// <param name="Args">
/// The values that filled it, in order. The UI re-formats them into its own language's sentence, so
/// numbers pick up the reader's digit grouping instead of being frozen into a string here.
/// </param>
public sealed record ImportWarning(ImportWarningKind Kind, string Message, IReadOnlyList<object?> Args)
{
/// <summary>Falls back to the English sentence, so a plain <c>ToString()</c> is still useful.</summary>
public override string ToString() => Message;
}
/// <summary>
/// Builds the warnings the CSV importer emits.
/// </summary>
/// <remarks>
/// Each kind has exactly one English format string here, and the arguments travel alongside the
/// rendered sentence — so the importer stays free of any notion of who is reading, and the admin UI
/// can say the same thing in German without this assembly growing a resource file.
/// </remarks>
internal static class ImportWarnings
{
public static ImportWarning RowSkipped(int row, string reason) =>
Create(ImportWarningKind.RowSkipped, "Row {0}: skipped ({1}).", row, reason);
public static ImportWarning UnparseableDate(int row) =>
Create(ImportWarningKind.UnparseableDate, "Row {0}: unparseable date, skipped.", row);
public static ImportWarning UnitMismatch(int row, string expected, string found) =>
Create(ImportWarningKind.UnitMismatch, "Row {0}: expected unit '{1}' but found '{2}'.", row, expected, found);
public static ImportWarning RegisterDropped(int row, int meterId, double previous, double current) =>
Create(
ImportWarningKind.RegisterDropped,
"Row {0}: register for meter {1} dropped {2}→{3}; swap event created (verify).",
row, meterId, previous, current);
public static ImportWarning RegisterDroppedWithAmount(
int row, int meterId, double previous, double current, double amount) =>
Create(
ImportWarningKind.RegisterDroppedWithAmount,
"Row {0}: register for meter {1} dropped {2}→{3}; swap consumption set to {4}.",
row, meterId, previous, current, amount);
private static ImportWarning Create(ImportWarningKind kind, string format, params object?[] args) =>
new(kind, string.Format(CultureInfo.InvariantCulture, format, args), args);
}
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class StagedImport
public List<ManualCost> ManualCosts { get; } = []; public List<ManualCost> ManualCosts { get; } = [];
public List<string> Warnings { get; } = []; public List<ImportWarning> Warnings { get; } = [];
public int SkippedRows { get; set; } public int SkippedRows { get; set; }
@@ -3,8 +3,55 @@ using Microsoft.Extensions.Logging;
namespace MeterVault.Infrastructure.Ingestion; namespace MeterVault.Infrastructure.Ingestion;
/// <summary>Which way a Home Assistant connectivity test ended.</summary>
/// <remarks>
/// The verdict is shown to whoever clicked "Test connection", so it has to be sayable in their
/// language — and this assembly has no business knowing what that is. The outcome travels as a
/// value and the admin UI supplies the words.
/// </remarks>
public enum HaTestOutcome
{
/// <summary>
/// No test was run — the caller decided beforehand (no token configured, base URL edited since
/// saving) and put its own, already-localized wording in <see cref="HaTestResult.Message"/>.
/// The default, so a result built by the UI needs no ceremony to say so.
/// </summary>
Precondition,
/// <summary>Reachable and the token was accepted; no entity was named to sample.</summary>
Connected,
/// <summary>Reachable and the named entity returned a number — see <see cref="HaTestResult.SampleValue"/>.</summary>
ConnectedWithValue,
/// <summary>No base URL was given.</summary>
BaseUrlMissing,
/// <summary>No token was available to test with.</summary>
TokenMissing,
/// <summary>HA answered, but not with success. <see cref="HaTestResult.Detail"/> carries the status.</summary>
HttpError,
/// <summary>Reachable, but the named entity has no numeric state (unavailable/unknown/non-numeric).</summary>
NoNumericState,
/// <summary>The request threw. <see cref="HaTestResult.Detail"/> carries the exception message.</summary>
RequestFailed,
}
/// <summary>Outcome of a Home Assistant connectivity test.</summary> /// <summary>Outcome of a Home Assistant connectivity test.</summary>
public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null); /// <param name="Message">The English summary, kept for logs and non-UI callers.</param>
/// <param name="Outcome">The same verdict as a value, for a UI that has to phrase it in some language.</param>
/// <param name="EntityId">The entity that was sampled, when one was named.</param>
/// <param name="Detail">Diagnostic text (HTTP status, exception message). Not ours to translate.</param>
public sealed record HaTestResult(
bool Ok,
string Message,
double? SampleValue = null,
HaTestOutcome Outcome = HaTestOutcome.Precondition,
string? EntityId = null,
string? Detail = null);
/// <summary> /// <summary>
/// Verifies a Home Assistant connection from the admin UI: checks the base URL + token against /// Verifies a Home Assistant connection from the admin UI: checks the base URL + token against
@@ -26,12 +73,12 @@ public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILo
{ {
if (string.IsNullOrWhiteSpace(baseUrl)) if (string.IsNullOrWhiteSpace(baseUrl))
{ {
return new HaTestResult(false, "Base URL is required."); return new HaTestResult(false, "Base URL is required.", Outcome: HaTestOutcome.BaseUrlMissing);
} }
if (string.IsNullOrWhiteSpace(token)) if (string.IsNullOrWhiteSpace(token))
{ {
return new HaTestResult(false, "No token available to test."); return new HaTestResult(false, "No token available to test.", Outcome: HaTestOutcome.TokenMissing);
} }
var client = _httpClientFactory.CreateClient(); var client = _httpClientFactory.CreateClient();
@@ -44,23 +91,29 @@ public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILo
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}."); return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}.",
Outcome: HaTestOutcome.HttpError,
Detail: $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim());
} }
if (string.IsNullOrWhiteSpace(entityId)) if (string.IsNullOrWhiteSpace(entityId))
{ {
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted."); return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.",
Outcome: HaTestOutcome.Connected);
} }
var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false); var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false);
return state is { } value return state is { } value
? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.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)."); HaTestOutcome.ConnectedWithValue, entityId)
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).",
Outcome: HaTestOutcome.NoNumericState, EntityId: entityId);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl); _logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl);
return new HaTestResult(false, $"Connection failed: {ex.Message}"); return new HaTestResult(false, $"Connection failed: {ex.Message}",
Outcome: HaTestOutcome.RequestFailed, Detail: ex.Message);
} }
} }
} }
@@ -56,15 +56,31 @@ public sealed class IngestionService(
return IngestionOutcome.RejectedDecrease; return IngestionOutcome.RejectedDecrease;
} }
var outcome = await UpsertAsync(meter, utc, value, source.Id, cancellationToken).ConfigureAwait(false); var outcome = await UpsertAsync(meter, utc, value, source.Id, quality: null, ReadingFlags.None, cancellationToken).ConfigureAwait(false);
await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false); await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false);
await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false); await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false);
return outcome; return outcome;
} }
/// <summary>Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings).</summary> /// <summary>Ingests directly against a meter (REST push, or a hand-entered reading from the UI).</summary>
/// <param name="renormalize">
/// False to skip deriving consumption, for callers ingesting a batch into one meter: recomputing
/// rewrites the meter's entire series, so doing it per reading is quadratic in batch size. Such a
/// caller must recompute the affected meters itself once the batch is in.
/// </param>
/// <param name="quality">
/// Provenance to stamp on the row. Null keeps the default for a new row and leaves an existing
/// row's quality alone — a source re-reporting a timestamp must not silently relabel a reading
/// somebody entered by hand or that came from an import.
/// </param>
/// <param name="flags">
/// Annotations to add to the row, e.g. <see cref="ReadingFlags.MeterSwap"/> on the new register's
/// start value written together with a swap. Added to an existing row's flags, never cleared.
/// </param>
public async Task<IngestionOutcome> IngestByMeterAsync( public async Task<IngestionOutcome> IngestByMeterAsync(
int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken = default) int meterId, DateTimeOffset time, double value, bool renormalize = true,
ReadingQuality? quality = null, ReadingFlags flags = ReadingFlags.None,
CancellationToken cancellationToken = default)
{ {
var meter = await _db.Meters var meter = await _db.Meters
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
@@ -80,12 +96,23 @@ public sealed class IngestionService(
return IngestionOutcome.RejectedDecrease; return IngestionOutcome.RejectedDecrease;
} }
var outcome = await UpsertAsync(meter, utc, value, sourceId: null, cancellationToken).ConfigureAwait(false); var outcome = await UpsertAsync(meter, utc, value, sourceId: null, quality, flags, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false); if (renormalize)
{
await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false);
}
return outcome; return outcome;
} }
/// <summary>
/// Derives consumption for one meter after a batch of readings has been written. The public
/// counterpart to skipping <c>renormalize</c> on each individual ingest.
/// </summary>
public Task RenormalizeMeterAsync(int meterId, CancellationToken cancellationToken = default) =>
RecomputeAtomicallyAsync(meterId, cancellationToken);
/// <summary> /// <summary>
/// Derives consumption from the reading just written. Without this a live-ingested reading sits /// Derives consumption from the reading just written. Without this a live-ingested reading sits
/// in <c>reading</c> forever and every derived figure — consumption, generation, cost — stays /// in <c>reading</c> forever and every derived figure — consumption, generation, cost — stays
@@ -110,12 +137,42 @@ public sealed class IngestionService(
// The reading must already be persisted: RecomputeMeterAsync re-reads the meter's readings // The reading must already be persisted: RecomputeMeterAsync re-reads the meter's readings
// from the database, so anything still pending in the change tracker would be missed. // from the database, so anything still pending in the change tracker would be missed.
await RecomputeAtomicallyAsync(meterId, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Rebuilds a meter's consumption series as one atomic unit.
/// </summary>
/// <remarks>
/// <see cref="Normalization.NormalizationService.RecomputeMeterAsync"/> clears the series with
/// <c>ExecuteDelete</c>, which commits on its own when no transaction is ambient, and only then
/// adds the rebuilt rows. Without a transaction around both halves the meter has *no*
/// consumption in between: a dashboard read in that window reports zero, and a crash or a
/// cancelled request makes the loss permanent — for data the SDD treats as the long-term source
/// of truth (§5.5). Import and the events API already wrap their recomputes this way; live
/// ingestion was the path that did not.
///
/// Respects an ambient transaction rather than nesting, so callers that already opened one keep
/// a single unit of work.
/// </remarks>
private async Task RecomputeAtomicallyAsync(int meterId, CancellationToken cancellationToken)
{
if (_db.Database.CurrentTransaction is not null)
{
await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return;
}
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false); await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
} }
private async Task<IngestionOutcome> UpsertAsync( private async Task<IngestionOutcome> UpsertAsync(
Meter meter, DateTimeOffset utc, double value, int? sourceId, CancellationToken cancellationToken) Meter meter, DateTimeOffset utc, double value, int? sourceId, ReadingQuality? quality,
ReadingFlags flags, CancellationToken cancellationToken)
{ {
var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc) var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc)
?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false); ?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false);
@@ -128,39 +185,50 @@ public sealed class IngestionService(
Time = utc, Time = utc,
Value = value, Value = value,
SourceId = sourceId, SourceId = sourceId,
Quality = ReadingQuality.Measured, Quality = quality ?? ReadingQuality.Measured,
Flags = flags,
}); });
return IngestionOutcome.Written; return IngestionOutcome.Written;
} }
existing.Value = value; existing.Value = value;
existing.SourceId = sourceId ?? existing.SourceId; existing.SourceId = sourceId ?? existing.SourceId;
existing.Flags |= flags;
if (quality is { } stamp)
{
existing.Quality = stamp;
}
// An imported month row stamped at this instant describes the end of its month. Correcting it by
// hand or by re-import keeps that meaning; a live value, or the start reading of a swap, is what
// the register showed at this very instant, so the row stops being a month row.
if (existing.Flags.HasFlag(ReadingFlags.MonthLabel)
&& (quality is not (ReadingQuality.Manual or ReadingQuality.Imported)
|| (flags & (ReadingFlags.MeterSwap | ReadingFlags.CounterReset)) != 0))
{
existing.Flags &= ~ReadingFlags.MonthLabel;
existing.Quality = quality ?? ReadingQuality.Measured;
}
return IngestionOutcome.Updated; return IngestionOutcome.Updated;
} }
private async Task<bool> IsSpuriousDecreaseAsync( private async Task<bool> IsSpuriousDecreaseAsync(
int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken) int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken)
{ {
var previous = await _db.Readings // The previous reading on the normalizer's timeline, not by stamp: a month row stamped on the 1st
.Where(r => r.MeterId == meterId && r.Time < time) // describes the end of its month and must not reject a lower reading taken during that month.
.OrderByDescending(r => r.Time) var neighbours = await RegisterNeighbours.FindAsync(_db, meterId, time, _normalization.TimeZone, cancellationToken)
.Select(r => new { r.Value, r.Time }) .ConfigureAwait(false);
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (previous is null || value >= previous.Value) if (neighbours.Previous is not { } previous || value >= previous.Reading.Value)
{ {
return false; return false;
} }
// Only a reset/swap in the window (previousReading, thisReading] explains the decrease — // Only a reset/swap in the window (previousReading, thisReading] explains the decrease —
// an old historical reset must not permanently disable the guard. // an old historical reset must not permanently disable the guard.
var explained = await _db.MeterEvents.AnyAsync( return !neighbours.BoundaryAfterPreviousUpTo(time);
e => e.MeterId == meterId
&& (e.EventType == MeterEventType.CounterReset || e.EventType == MeterEventType.MeterSwap)
&& e.Time > previous.Time && e.Time <= time,
cancellationToken).ConfigureAwait(false);
return !explained;
} }
private async Task UpdateSourceStatusAsync( private async Task UpdateSourceStatusAsync(
@@ -0,0 +1,491 @@
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>What the user entered for a meter event, before it is checked and stored.</summary>
public sealed record MeterEventDraft(MeterEventType Type, DateTimeOffset Time)
{
/// <summary>Delivered volume, or the tank level in <see cref="Unit"/>.</summary>
public double? Amount { get; init; }
/// <summary>Swap/reset: the old register's final value. Null books no tail.</summary>
public double? PrevValue { get; init; }
/// <summary>Swap/reset: the new register's start value. Null means 0.</summary>
public double? NewValue { get; init; }
/// <summary>Tank level only: <c>cm</c> for a dipstick reading, otherwise the tank's volume unit.</summary>
public string? Unit { get; init; }
public string? Notes { get; init; }
}
/// <summary>Why an event could not be recorded or removed. <see cref="None"/> means it was.</summary>
public enum MeterEventProblem
{
None,
UnknownMeter,
NotRecordableForMode,
AmountRequired,
AmountOutOfRange,
NoteRequired,
LevelNeedsCalibration,
LevelAtSameTime,
OldRegisterBelowPreviousReading,
ReadingAtSameTime,
BoundaryAlreadyRecorded,
StartReadingRejected,
NotFound,
Imported,
NotManual,
LaterBoundaryDependsOnIt,
}
/// <summary>A reading's instant and value.</summary>
public sealed record ReadingPoint(DateTimeOffset Time, double Value);
/// <summary>A recorded tank level, with its volume after calibration.</summary>
public sealed record TankLevelPoint(DateTimeOffset Time, double Amount, string? Unit, double Volume);
/// <summary>
/// Everything around a candidate event time that decides whether the event is valid and what it will
/// book — read once so the dialog can explain the outcome before saving, and the save can re-check it.
/// </summary>
public sealed record MeterEventContext(
int MeterId,
MeterMode Mode,
string Unit,
double InitialBaseline,
ReadingPoint? Previous,
bool ReadingAtTime,
ReadingPoint? Next,
int ReadingsAfter,
bool BoundaryInWindow,
int LiveSources,
string TankUnit,
CalibrationCurve? Calibration,
TankLevelPoint? LastLevel,
double DeliveredSinceLastLevel)
{
/// <summary>
/// The register value the old register's tail is measured from: the last reading before the
/// event, or — with none — the meter's initial baseline, exactly where the normalizer starts.
/// </summary>
public double RegisterBefore => Previous?.Value ?? InitialBaseline;
/// <summary>How much of the old register a swap/reset books: its final value less <see cref="RegisterBefore"/>.</summary>
public double? Tail(double? finalValue) => finalValue is { } final ? final - RegisterBefore : null;
public double ToVolume(double amount, bool centimetres) =>
centimetres && Calibration is { } curve ? curve.ToVolume(amount) : amount;
/// <summary>Draw since the last level: that level plus deliveries since, less the new volume.</summary>
public double? UsedSinceLastLevel(double volume) =>
LastLevel is { } last ? last.Volume + DeliveredSinceLastLevel - volume : null;
}
/// <summary>An event recording outcome.</summary>
public sealed record MeterEventResult(MeterEventProblem Problem, int? EventId = null)
{
public bool Succeeded => Problem == MeterEventProblem.None;
}
/// <summary>
/// Records and removes meter lifecycle events from the UI — swaps, resets, tank levels, deliveries and
/// notes — and the manual corrections that go with them, always recomputing the meter in the same
/// transaction so no derived number is ever left describing the old history.
/// </summary>
/// <remarks>
/// <para>
/// A swap or reset is stored the way the golden water fixture expresses one: the event at instant T
/// carrying the old register's final value and the new register's start value, plus a manual reading
/// of that start value at exactly T. The normalizer's boundary window is <c>(previousReading, reading]</c>,
/// so the reading at T books the old register's tail there and every later reading counts from the
/// new register's start. It also means the meter's latest reading <em>is</em> the new register, so the
/// next manual entry is prefilled and checked against the right number.
/// </para>
/// <para>
/// The obvious alternative — writing the old final value as the reading at T — books the old register
/// twice and then rejects every new-register reading, which is why the service, not the page, owns this.
/// </para>
/// </remarks>
public sealed class MeterEventService(
MeterVaultDbContext db, IngestionService ingestion, NormalizationService normalization)
{
/// <summary>Enough to warn about a backdated event; counting further is just a slower query.</summary>
public const int ReadingsAfterCap = 1000;
private const double Tolerance = 1e-9;
private readonly MeterVaultDbContext _db = db;
private readonly IngestionService _ingestion = ingestion;
private readonly NormalizationService _normalization = normalization;
/// <summary>Reads the surroundings of <paramref name="time"/> for a meter, or null if the meter is gone.</summary>
public async Task<MeterEventContext?> GetContextAsync(
int meterId, DateTimeOffset time, CancellationToken cancellationToken = default)
{
var meter = await _db.Meters.AsNoTracking()
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
if (meter is null)
{
return null;
}
var utc = time.ToUniversalTime();
var readings = _db.Readings.AsNoTracking().Where(r => r.MeterId == meterId);
// Neighbours as the normalizer orders them, so the tail the dialog shows is the tail that is booked
// — a month row stamped on the 1st describes the end of its month.
var neighbours = await RegisterNeighbours.FindAsync(_db, meterId, utc, _normalization.TimeZone, cancellationToken)
.ConfigureAwait(false);
var previous = neighbours.Previous is { } p ? new ReadingPoint(p.Reading.Time, p.Reading.Value) : null;
var next = neighbours.Next is { } n ? new ReadingPoint(n.Reading.Time, n.Reading.Value) : null;
var readingAtTime = await readings.AnyAsync(r => r.Time == utc, cancellationToken).ConfigureAwait(false);
var readingsAfter = next is null
? 0
: await readings.Where(r => r.Time > utc).Take(ReadingsAfterCap).CountAsync(cancellationToken).ConfigureAwait(false);
// A second swap/reset between the same two readings would never be applied: the normalizer
// takes the first boundary in an interval. Scoped to the surrounding readings, so a genuine
// earlier swap — which has its own start reading after it — does not block a later one.
var boundaryInWindow = neighbours.BoundaryAfterPreviousUpTo(neighbours.Next?.Effective);
var liveSources = await _db.MeterSources.AsNoTracking()
.CountAsync(s => s.MeterId == meterId && s.IsEnabled
&& (s.SourceType == SourceType.HomeAssistant || s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota),
cancellationToken).ConfigureAwait(false);
var tank = await _db.Tanks.AsNoTracking()
.FirstOrDefaultAsync(t => t.MeterId == meterId, cancellationToken).ConfigureAwait(false);
var calibration = MeterConfigFactory.ParseCalibration(tank?.Calibration);
TankLevelPoint? lastLevel = null;
double delivered = 0;
if (meter.Mode == MeterMode.ConsumableBalance)
{
var level = await _db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meterId && e.EventType == MeterEventType.TankLevel && e.Time <= utc)
.OrderByDescending(e => e.Time)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (level is not null)
{
var centimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase);
var amount = level.Amount ?? 0;
lastLevel = new TankLevelPoint(level.Time, amount, level.Unit,
centimetres && calibration is not null ? calibration.ToVolume(amount) : amount);
var since = level.Time;
delivered = await _db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meterId && e.EventType == MeterEventType.Delivery && e.Time > since && e.Time <= utc)
.SumAsync(e => e.Amount ?? 0, cancellationToken).ConfigureAwait(false);
}
}
return new MeterEventContext(
meter.Id, meter.Mode, meter.Unit, meter.InitialBaseline, previous, readingAtTime, next, readingsAfter, boundaryInWindow,
liveSources, tank?.Unit ?? meter.Unit, calibration, lastLevel, delivered);
}
/// <summary>
/// Checks a draft against its context without touching the database — the same verdict the save
/// reaches, so the dialog can show it while the user is still typing.
/// </summary>
public static MeterEventProblem Validate(MeterEventContext context, MeterEventDraft draft)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(draft);
if (!MeterEventRules.CanRecord(context.Mode, draft.Type))
{
return MeterEventProblem.NotRecordableForMode;
}
switch (draft.Type)
{
case MeterEventType.MeterSwap:
case MeterEventType.CounterReset:
if (!IsFinite(draft.PrevValue) || !IsFinite(draft.NewValue))
{
return MeterEventProblem.AmountOutOfRange;
}
if (context.ReadingAtTime)
{
return MeterEventProblem.ReadingAtSameTime;
}
if (context.BoundaryInWindow)
{
return MeterEventProblem.BoundaryAlreadyRecorded;
}
// Below the last reading the old register would book negative consumption — that is a
// typo in one of the two numbers, never a real meter.
return context.Tail(draft.PrevValue) is < -Tolerance
? MeterEventProblem.OldRegisterBelowPreviousReading
: MeterEventProblem.None;
case MeterEventType.Delivery:
return draft.Amount switch
{
null => MeterEventProblem.AmountRequired,
{ } amount when !double.IsFinite(amount) || amount <= 0 => MeterEventProblem.AmountOutOfRange,
_ => MeterEventProblem.None,
};
case MeterEventType.TankLevel:
if (draft.Amount is not { } level)
{
return MeterEventProblem.AmountRequired;
}
if (!double.IsFinite(level) || level < 0)
{
return MeterEventProblem.AmountOutOfRange;
}
// Two levels at one instant would each yield a consumption row for the same
// (meter, time) — one of them is a mistake, and the recompute cannot store both.
if (context.LastLevel is { } last && last.Time == draft.Time.ToUniversalTime())
{
return MeterEventProblem.LevelAtSameTime;
}
// Without a calibration a centimetre level would be read as litres.
return IsCentimetres(draft.Unit) && context.Calibration is null
? MeterEventProblem.LevelNeedsCalibration
: MeterEventProblem.None;
case MeterEventType.Note:
return string.IsNullOrWhiteSpace(draft.Notes) ? MeterEventProblem.NoteRequired : MeterEventProblem.None;
default:
return MeterEventProblem.NotRecordableForMode;
}
}
/// <summary>Records an event (and, for a swap/reset, the new register's start reading) and recomputes the meter.</summary>
public async Task<MeterEventResult> RecordAsync(
int meterId, MeterEventDraft draft, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(draft);
var utc = draft.Time.ToUniversalTime();
var context = await GetContextAsync(meterId, utc, cancellationToken).ConfigureAwait(false);
if (context is null)
{
return new MeterEventResult(MeterEventProblem.UnknownMeter);
}
var problem = Validate(context, draft);
if (problem != MeterEventProblem.None)
{
return new MeterEventResult(problem);
}
var boundary = MeterEventRules.IsRegisterBoundary(draft.Type);
var meterEvent = new MeterEvent
{
MeterId = meterId,
Time = utc,
EventType = draft.Type,
Amount = draft.Type is MeterEventType.Delivery or MeterEventType.TankLevel ? draft.Amount : null,
PrevValue = boundary ? draft.PrevValue : null,
NewValue = boundary ? draft.NewValue ?? 0 : null,
Unit = draft.Type switch
{
MeterEventType.TankLevel when IsCentimetres(draft.Unit) => "cm",
MeterEventType.TankLevel or MeterEventType.Delivery => context.TankUnit,
MeterEventType.MeterSwap or MeterEventType.CounterReset => context.Unit,
_ => null,
},
Notes = string.IsNullOrWhiteSpace(draft.Notes) ? null : draft.Notes.Trim(),
};
await using var tx = await BeginOwnTransactionAsync(cancellationToken).ConfigureAwait(false);
_db.MeterEvents.Add(meterEvent);
// Saved before the start reading: the ingestion guard finds the swap in the database, not in
// the change tracker, and without it the new register's lower value is a spurious decrease.
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (boundary)
{
var flag = draft.Type == MeterEventType.MeterSwap ? ReadingFlags.MeterSwap : ReadingFlags.CounterReset;
var outcome = await _ingestion.IngestByMeterAsync(
meterId, utc, meterEvent.NewValue ?? 0, renormalize: false,
quality: ReadingQuality.Manual, flags: flag, cancellationToken: cancellationToken).ConfigureAwait(false);
if (outcome != IngestionOutcome.Written)
{
// Checked just above, so only a reading that arrived in between gets here. Leave
// nothing half-recorded: without its start reading the swap would book at whatever
// reading comes next.
_db.ChangeTracker.Clear();
return new MeterEventResult(MeterEventProblem.StartReadingRejected);
}
}
if (draft.Type != MeterEventType.Note)
{
await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
if (tx is not null)
{
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
}
return new MeterEventResult(MeterEventProblem.None, meterEvent.Id);
}
/// <summary>
/// Removes a hand-recorded event and recomputes the meter. A swap/reset also takes its start
/// reading with it — but only while that reading is still the untouched value the swap wrote, so a
/// real reading later entered at the same instant survives.
/// </summary>
/// <remarks>Imported events belong to their batch and are removed by reverting it.</remarks>
public async Task<MeterEventResult> DeleteEventAsync(
int meterId, int eventId, CancellationToken cancellationToken = default)
{
var meterEvent = await _db.MeterEvents
.FirstOrDefaultAsync(e => e.Id == eventId && e.MeterId == meterId, cancellationToken).ConfigureAwait(false);
if (meterEvent is null)
{
return new MeterEventResult(MeterEventProblem.NotFound);
}
if (meterEvent.ImportBatchId is not null)
{
return new MeterEventResult(MeterEventProblem.Imported, eventId);
}
Reading? startReading = null;
if (MeterEventRules.IsRegisterBoundary(meterEvent.EventType))
{
var flag = meterEvent.EventType == MeterEventType.MeterSwap ? ReadingFlags.MeterSwap : ReadingFlags.CounterReset;
var time = meterEvent.Time;
var candidate = await _db.Readings
.FirstOrDefaultAsync(r => r.MeterId == meterId && r.Time == time, cancellationToken).ConfigureAwait(false);
if (candidate is { Quality: ReadingQuality.Manual }
&& candidate.Flags.HasFlag(flag)
&& Math.Abs(candidate.Value - (meterEvent.NewValue ?? 0)) < Tolerance)
{
startReading = candidate;
}
}
// Checked before anything changes, so a refusal leaves even a caller-owned transaction untouched.
if (startReading is not null
&& await BoundaryDependsOnReadingAtAsync(meterId, startReading.Time, cancellationToken).ConfigureAwait(false))
{
return new MeterEventResult(MeterEventProblem.LaterBoundaryDependsOnIt, eventId);
}
await using var tx = await BeginOwnTransactionAsync(cancellationToken).ConfigureAwait(false);
if (startReading is not null)
{
_db.Readings.Remove(startReading);
}
_db.MeterEvents.Remove(meterEvent);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (meterEvent.EventType != MeterEventType.Note)
{
await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
if (tx is not null)
{
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
}
return new MeterEventResult(MeterEventProblem.None, eventId);
}
/// <summary>
/// Deletes a reading somebody typed by hand, then recomputes the meter. The escape hatch for a
/// mistyped register, which otherwise blocks every later reading as a decrease.
/// </summary>
/// <remarks>
/// Only <see cref="ReadingQuality.Manual"/> rows: a measured reading is the audit truth a source
/// reported, and an imported one is removed by reverting its batch.
/// </remarks>
public async Task<MeterEventResult> DeleteManualReadingAsync(
int meterId, DateTimeOffset time, CancellationToken cancellationToken = default)
{
var utc = time.ToUniversalTime();
var reading = await _db.Readings
.FirstOrDefaultAsync(r => r.MeterId == meterId && r.Time == utc, cancellationToken).ConfigureAwait(false);
if (reading is null)
{
return new MeterEventResult(MeterEventProblem.NotFound);
}
if (reading.Quality != ReadingQuality.Manual)
{
return new MeterEventResult(MeterEventProblem.NotManual);
}
if (await BoundaryDependsOnReadingAtAsync(meterId, utc, cancellationToken).ConfigureAwait(false))
{
return new MeterEventResult(MeterEventProblem.LaterBoundaryDependsOnIt);
}
await using var tx = await BeginOwnTransactionAsync(cancellationToken).ConfigureAwait(false);
_db.Readings.Remove(reading);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (tx is not null)
{
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
}
return new MeterEventResult(MeterEventProblem.None);
}
/// <summary>
/// Whether a swap/reset lies in the interval that begins at the reading at <paramref name="readingTime"/>,
/// i.e. in <c>(readingTime, next reading]</c>. That boundary's old-register tail was measured against
/// this reading; removing it would re-measure the tail against an older one — a negative amount if the
/// register had moved on — or merge two boundaries into one interval, where only the first applies.
/// The later swap/reset has to go first.
/// </summary>
private async Task<bool> BoundaryDependsOnReadingAtAsync(int meterId, DateTimeOffset readingTime, CancellationToken cancellationToken)
{
var nextReading = await _db.Readings.AsNoTracking()
.Where(r => r.MeterId == meterId && r.Time > readingTime)
.OrderBy(r => r.Time)
.Select(r => (DateTimeOffset?)r.Time)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
return await _db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meterId
&& (e.EventType == MeterEventType.MeterSwap || e.EventType == MeterEventType.CounterReset)
&& e.Time > readingTime)
.Where(e => nextReading == null || e.Time <= nextReading)
.AnyAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Opens a transaction unless the caller already holds one. Disposing it uncommitted rolls back,
/// which is what every early return above relies on; a caller-owned transaction is the caller's
/// to commit or roll back.
/// </summary>
private async Task<Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction?> BeginOwnTransactionAsync(
CancellationToken cancellationToken) =>
_db.Database.CurrentTransaction is null
? await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false)
: null;
private static bool IsCentimetres(string? unit) => string.Equals(unit?.Trim(), "cm", StringComparison.OrdinalIgnoreCase);
private static bool IsFinite(double? value) => value is null || double.IsFinite(value.Value);
}
@@ -0,0 +1,83 @@
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// The readings either side of an instant, in the order the normalizer walks them
/// (<see cref="ReadingTimeline"/>), and the swaps or resets between them.
/// </summary>
/// <remarks>
/// The decrease guard and the event dialog judge a reading against its neighbours. Picking those by
/// stamp would disagree with the engine wherever a month row is involved: "August 2026" is stamped on
/// 1 August but describes 31 August, so a reading taken on 20 August comes before it, not after.
/// </remarks>
internal static class RegisterNeighbours
{
/// <summary>
/// How far a month row's place on the timeline can lie from its stamp. Readings stamped further
/// away than this from the instant cannot be its neighbours unless nothing nearer exists.
/// </summary>
private static readonly TimeSpan LabelReach = TimeSpan.FromDays(33);
public static async Task<Neighbours> FindAsync(
MeterVaultDbContext db, int meterId, DateTimeOffset utc, TimeZoneInfo zone, CancellationToken cancellationToken)
{
var readings = db.Readings.AsNoTracking().Where(r => r.MeterId == meterId);
var windowStart = utc - LabelReach;
var windowEnd = utc + LabelReach;
var nearby = await readings
.Where(r => r.Time >= windowStart && r.Time <= windowEnd && r.Time != utc)
.ToListAsync(cancellationToken).ConfigureAwait(false);
var before = await readings.Where(r => r.Time < windowStart).OrderByDescending(r => r.Time)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
var after = await readings.Where(r => r.Time > windowEnd).OrderBy(r => r.Time)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
IEnumerable<Reading> candidates = nearby;
if (before is not null)
{
candidates = candidates.Append(before);
}
if (after is not null)
{
candidates = candidates.Append(after);
}
var timeline = ReadingTimeline.Build(candidates, zone);
// A reading stamped at the instant sorts after a month row whose month ends exactly then, as in the engine.
static bool IsBefore(TimelineReading entry, DateTimeOffset instant) =>
entry.Effective < instant || (entry.Effective == instant && entry.Reading.Time < instant);
var previous = timeline.Readings.LastOrDefault(e => IsBefore(e, utc));
var next = timeline.Readings.FirstOrDefault(e => !IsBefore(e, utc));
var lower = previous.Reading is null ? (DateTimeOffset?)null : previous.Effective;
var upper = next.Reading is null ? (DateTimeOffset?)null : next.Effective;
var events = await db.MeterEvents.AsNoTracking()
.Where(e => e.MeterId == meterId
&& (e.EventType == MeterEventType.MeterSwap || e.EventType == MeterEventType.CounterReset))
.Where(e => lower == null || e.Time > lower.Value - LabelReach)
.Where(e => upper == null || e.Time <= upper.Value + LabelReach)
.ToListAsync(cancellationToken).ConfigureAwait(false);
return new Neighbours(
previous.Reading is null ? null : previous,
next.Reading is null ? null : next,
[.. events.Select(e => (Event: e, Time: timeline.BoundaryTime(e)))]);
}
/// <summary>The neighbours of an instant, and every swap or reset near them placed on the same timeline.</summary>
public sealed record Neighbours(
TimelineReading? Previous, TimelineReading? Next, IReadOnlyList<(MeterEvent Event, DateTimeOffset Time)> Boundaries)
{
/// <summary>Whether a swap or reset sits in <c>(Previous, upToInclusive]</c> — the interval it would explain.</summary>
public bool BoundaryAfterPreviousUpTo(DateTimeOffset? upToInclusive) =>
Boundaries.Any(b => (Previous is not { } p || b.Time > p.Effective) && (upToInclusive is null || b.Time <= upToInclusive));
}
}
@@ -31,7 +31,11 @@ public static class MeterConfigFactory
}; };
} }
private static CalibrationCurve? ParseCalibration(string? json) /// <summary>
/// Reads a tank's stored calibration (<c>{"volumePerUnit": 46.667, "offset": 0}</c>), or null when
/// there is none or it is unreadable — a level is then taken as already being a volume.
/// </summary>
public static CalibrationCurve? ParseCalibration(string? json)
{ {
if (string.IsNullOrWhiteSpace(json)) if (string.IsNullOrWhiteSpace(json))
{ {
@@ -56,6 +60,12 @@ public static class MeterConfigFactory
} }
} }
/// <summary>The stored form of a calibration, in the shape <see cref="ParseCalibration"/> reads back; null clears it.</summary>
public static string? SerializeCalibration(CalibrationCurve? calibration) =>
calibration is null
? null
: JsonSerializer.Serialize(new { volumePerUnit = calibration.VolumePerUnit, offset = calibration.Offset });
private static VirtualSpec? ParseVirtual(Meter meter) private static VirtualSpec? ParseVirtual(Meter meter)
{ {
if (meter.Mode != MeterMode.Virtual || string.IsNullOrWhiteSpace(meter.Meta)) if (meter.Mode != MeterMode.Virtual || string.IsNullOrWhiteSpace(meter.Meta))
@@ -1,7 +1,9 @@
using MeterVault.Core.Domain; using MeterVault.Core.Domain;
using MeterVault.Core.Normalization; using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Normalization; namespace MeterVault.Infrastructure.Normalization;
@@ -10,10 +12,19 @@ namespace MeterVault.Infrastructure.Normalization;
/// consumption wholesale (consumption is a pure function of readings + events), and replaces the /// consumption wholesale (consumption is a pure function of readings + events), and replaces the
/// stored rows. Virtual meters are skipped here — they are computed on read (SDD §14.1). /// stored rows. Virtual meters are skipped here — they are computed on read (SDD §14.1).
/// </summary> /// </summary>
public sealed class NormalizationService(MeterVaultDbContext db, INormalizationEngine engine) /// <remarks>
/// Without options (tests, or a hand-built instance) months are UTC months; the application always
/// passes the configured instance timezone, so stored consumption files under the months the charts show.
/// </remarks>
public sealed class NormalizationService(
MeterVaultDbContext db, INormalizationEngine engine, IOptions<MeterVaultOptions>? options = null)
{ {
private readonly MeterVaultDbContext _db = db; private readonly MeterVaultDbContext _db = db;
private readonly INormalizationEngine _engine = engine; private readonly INormalizationEngine _engine = engine;
private readonly TimeZoneInfo _zone = InstanceTimeZone.Resolve(options?.Value.TimeZone);
/// <summary>The zone months are divided in: the configured one, or UTC when it is missing or unknown.</summary>
public TimeZoneInfo TimeZone => _zone;
/// <summary> /// <summary>
/// Recomputes and replaces the consumption series for one meter from all its current readings /// Recomputes and replaces the consumption series for one meter from all its current readings
@@ -40,7 +51,7 @@ public sealed class NormalizationService(MeterVaultDbContext db, INormalizationE
.OrderBy(e => e.Time) .OrderBy(e => e.Time)
.ToListAsync(cancellationToken).ConfigureAwait(false); .ToListAsync(cancellationToken).ConfigureAwait(false);
var context = new NormalizationContext { Meter = config, Readings = readings, Events = events }; var context = new NormalizationContext { Meter = config, Readings = readings, Events = events, TimeZone = _zone };
var consumption = _engine.Normalize(context); var consumption = _engine.Normalize(context);
await _db.Consumption.Where(c => c.MeterId == meterId) await _db.Consumption.Where(c => c.MeterId == meterId)
@@ -0,0 +1,345 @@
using System.Text.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace MeterVault.Infrastructure.Normalization;
/// <summary>
/// Rebuilds every meter's stored consumption once after the normalization rules — or the timezone they
/// divide months in — change.
/// </summary>
/// <remarks>
/// <para>
/// Consumption is a pure function of readings and events, recomputed per meter whenever that meter
/// ingests something. A rule change therefore reaches a meter only at its next reading — a meter read
/// once a month would keep showing the old attribution for weeks. Recording the revision and zone the
/// stored data was built with, and rebuilding when either differs, applies the change to everything at
/// the next start instead. Runs as part of the startup migration step.
/// </para>
/// <para>
/// A meter that fails to rebuild is logged and remembered, never fatal: startup continues, and the next
/// start retries just those meters. One bad series must not keep the whole application down.
/// </para>
/// </remarks>
public sealed class NormalizationUpgrade(
MeterVaultDbContext db, NormalizationService normalization, ILogger<NormalizationUpgrade> logger)
{
/// <summary>The <c>app_setting</c> key holding the revision stored consumption was computed with.</summary>
public const string SettingKey = "normalization_revision";
/// <summary>The <c>app_setting</c> key holding the timezone id stored consumption was divided in.</summary>
public const string ZoneSettingKey = "normalization_zone";
/// <summary>The <c>app_setting</c> key listing meters whose rebuild failed and is retried at the next start.</summary>
public const string PendingSettingKey = "normalization_pending";
/// <summary>
/// Bump whenever the engine books existing readings differently.
/// 2: consumption between two readings is divided across the local months it spans; imported month
/// rows are flagged as such and read as the end of their month.
/// </summary>
public const int CurrentRevision = 2;
private const int ProgressEvery = 100;
/// <summary>
/// How long one statement of the upgrade may take. Rewriting a long series, or updating rows that sit
/// in compressed chunks, outlasts Npgsql's 30-second default on a real dataset — and a timeout there
/// would leave stored consumption half-derived until the next start.
/// </summary>
private static readonly TimeSpan StatementTimeout = TimeSpan.FromMinutes(15);
private readonly MeterVaultDbContext _db = db;
private readonly NormalizationService _normalization = normalization;
private readonly ILogger<NormalizationUpgrade> _logger = logger;
/// <summary>
/// Rebuilds all meters if stored consumption predates <see cref="CurrentRevision"/> or another
/// timezone, otherwise only meters left over from a failed rebuild. Returns how many were rebuilt.
/// </summary>
public async Task<int> RunAsync(CancellationToken cancellationToken = default)
{
var previousTimeout = _db.Database.GetCommandTimeout();
_db.Database.SetCommandTimeout(StatementTimeout);
try
{
return await RebuildAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_db.Database.SetCommandTimeout(previousTimeout);
}
}
private async Task<int> RebuildAsync(CancellationToken cancellationToken)
{
var storedRevision = await ReadAsync<int?>(SettingKey, cancellationToken).ConfigureAwait(false);
var storedZone = await ReadAsync<string>(ZoneSettingKey, cancellationToken).ConfigureAwait(false);
var pending = await ReadAsync<int[]>(PendingSettingKey, cancellationToken).ConfigureAwait(false) ?? [];
var zone = _normalization.TimeZone.Id;
var outdated = storedRevision is not { } revision || revision < CurrentRevision || storedZone != zone;
if (!outdated && pending.Length == 0)
{
return 0;
}
// Month rows must be marked before anything is rebuilt: rebuilt without the marks, every imported
// monthly table would shift a month. If marking fails, nothing is rebuilt or recorded, and the
// whole upgrade is retried at the next start — the application still starts.
if (storedRevision is not >= 2 && !await MarkMonthLabelsAsync(cancellationToken).ConfigureAwait(false))
{
return 0;
}
List<int> meterIds = outdated
? await _db.Meters.AsNoTracking()
.Where(m => m.Mode != MeterMode.Virtual)
.OrderBy(m => m.Id)
.Select(m => m.Id)
.ToListAsync(cancellationToken).ConfigureAwait(false)
: [.. pending.Order()];
_logger.LogInformation(
"Rebuilding stored consumption for {Meters} meter(s) (normalization revision {Revision}, timezone {Zone})",
meterIds.Count, CurrentRevision, zone);
var failed = new List<int>();
for (var i = 0; i < meterIds.Count; i++)
{
var meterId = meterIds[i];
try
{
// One transaction per meter: the rebuild deletes then re-inserts, and a meter must never be
// left without consumption — but one meter's failure should not undo all the others.
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await _normalization.RecomputeMeterAsync(meterId, batchId: null, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
failed.Add(meterId);
_logger.LogError(ex, "Could not rebuild consumption for meter {MeterId}; it is retried at the next start", meterId);
}
finally
{
_db.ChangeTracker.Clear();
}
if ((i + 1) % ProgressEvery == 0)
{
_logger.LogInformation("Rebuilt {Done} of {Total} meter(s)", i + 1, meterIds.Count);
}
}
await WriteAsync(SettingKey, CurrentRevision, cancellationToken).ConfigureAwait(false);
await WriteAsync(ZoneSettingKey, zone, cancellationToken).ConfigureAwait(false);
await WriteAsync(PendingSettingKey, failed.ToArray(), cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (failed.Count > 0)
{
_logger.LogWarning(
"Consumption for {Failed} meter(s) could not be rebuilt and keeps its previous attribution until the next start: {MeterIds}",
failed.Count, string.Join(", ", failed));
}
else
{
_logger.LogInformation("Stored consumption rebuilt to normalization revision {Revision}", CurrentRevision);
}
return meterIds.Count - failed.Count;
}
/// <summary>
/// Flags the rows of earlier imports that came from monthly tables as <see cref="ReadingFlags.MonthLabel"/>,
/// which the importer only records since revision 2. Returns false when that could not be done.
/// </summary>
/// <remarks>
/// What a batch's date column held is known from its stored mapping: a reference profile by name, or
/// the wizard's date format. The wizard's default is auto-detection, which does not say per row whether
/// "August 2026" or "01.08.2026" was written. Such a batch is read as a monthly table when every one of
/// its readings is stamped at midnight on the 1st across at least two months — how those rows were
/// attributed before, and what a month-name sheet looks like. That is logged per batch, because a
/// day-dated sheet read on the 1st every time looks the same; it is fixed by re-importing it with the
/// day format.
/// </remarks>
private async Task<bool> MarkMonthLabelsAsync(CancellationToken cancellationToken)
{
try
{
var batches = await _db.ImportBatches.AsNoTracking()
.Select(b => new { b.Id, b.SourceName, b.Mapping })
.ToListAsync(cancellationToken).ConfigureAwait(false);
var kinds = batches.ToDictionary(b => b.Id, b => DatesOf(b.Mapping));
var ids = kinds.Where(k => k.Value == BatchDates.Months).Select(k => k.Key).ToList();
var autoIds = kinds.Where(k => k.Value == BatchDates.AutoDetected).Select(k => k.Key).ToArray();
var label = (int)ReadingFlags.MonthLabel;
var imported = (short)ReadingQuality.Imported;
if (autoIds.Length > 0)
{
var monthly = await _db.Database.SqlQuery<int>(
$"""
SELECT import_batch_id AS "Value" FROM reading
WHERE import_batch_id = ANY({autoIds}) AND quality = {imported}
GROUP BY import_batch_id
HAVING bool_and(date_trunc('month', "time" AT TIME ZONE 'UTC') = "time" AT TIME ZONE 'UTC')
AND count(DISTINCT date_trunc('month', "time" AT TIME ZONE 'UTC')) >= 2
""").ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var batch in batches.Where(b => monthly.Contains(b.Id)))
{
_logger.LogWarning(
"Import batch {BatchId} ({Source}) had its dates auto-detected and every row on the 1st of a month; it is read as a monthly table. If its dates were days, revert it and import it again with the day format",
batch.Id, batch.SourceName);
}
ids.AddRange(monthly);
}
if (ids.Count == 0)
{
return true;
}
var marked = 0;
foreach (var batchId in ids)
{
marked += await MarkBatchAsync(batchId, label, imported, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation("Marked {Rows} imported reading(s) from {Batches} monthly table(s) as month rows", marked, ids.Count);
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Could not mark imported month rows; stored consumption is not rebuilt, and the upgrade is retried at the next start");
return false;
}
}
/// <summary>
/// Marks one batch's month rows. Kept to a single batch, and bounded by the meters and the time span
/// that batch actually wrote, because <c>reading</c> is a compressed hypertable: without those bounds
/// PostgreSQL has to decompress every chunk to answer, which on a long polled series is minutes of
/// work for a handful of rows. <c>meter_id</c> is the compression's segment key and <c>time</c> its
/// chunk key, so both prune. The decompression cap is lifted for the statement itself, since a monthly
/// table overlapping densely polled data still exceeds it.
/// </summary>
private async Task<int> MarkBatchAsync(int batchId, int label, short imported, CancellationToken cancellationToken)
{
var rows = _db.Readings.AsNoTracking().Where(r => r.ImportBatchId == batchId);
var meters = await rows.Select(r => r.MeterId).Distinct().ToArrayAsync(cancellationToken).ConfigureAwait(false);
if (meters.Length == 0)
{
return 0;
}
var from = await rows.MinAsync(r => r.Time, cancellationToken).ConfigureAwait(false);
var to = await rows.MaxAsync(r => r.Time, cancellationToken).ConfigureAwait(false);
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await _db.Database.ExecuteSqlRawAsync(
"SET LOCAL timescaledb.max_tuples_decompressed_per_dml_transaction = 0", cancellationToken).ConfigureAwait(false);
var marked = await _db.Database.ExecuteSqlInterpolatedAsync(
$"""
UPDATE reading SET flags = flags | {label}
WHERE import_batch_id = {batchId} AND quality = {imported} AND (flags & {label}) = 0
AND meter_id = ANY({meters}) AND "time" >= {from} AND "time" <= {to}
AND date_trunc('month', "time" AT TIME ZONE 'UTC') = "time" AT TIME ZONE 'UTC'
""",
cancellationToken).ConfigureAwait(false);
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
return marked;
}
private enum BatchDates
{
Days,
Months,
AutoDetected,
}
private static BatchDates DatesOf(string? mapping)
{
if (string.IsNullOrWhiteSpace(mapping))
{
return BatchDates.Days;
}
try
{
using var doc = JsonDocument.Parse(mapping);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
return BatchDates.Days;
}
if (root.TryGetProperty("profile", out var profileName) && profileName.ValueKind == JsonValueKind.String)
{
MappingProfile[] reference =
[ReferenceProfiles.Electricity(), ReferenceProfiles.Water(), ReferenceProfiles.HeatingOil(), ReferenceProfiles.Costs()];
return reference.Any(p =>
p.Name == profileName.GetString() && p.DateKind == DateKind.MonthName && !p.AnchorMonthsToEnd)
? BatchDates.Months
: BatchDates.Days;
}
if (!root.TryGetProperty("dateKind", out var dateKind) || dateKind.ValueKind != JsonValueKind.String)
{
return BatchDates.Days;
}
return dateKind.GetString() switch
{
nameof(DateKind.MonthName) => BatchDates.Months,
nameof(DateKind.Auto) => BatchDates.AutoDetected,
_ => BatchDates.Days,
};
}
catch (JsonException)
{
return BatchDates.Days;
}
}
private async Task<T?> ReadAsync<T>(string key, CancellationToken cancellationToken)
{
var setting = await _db.AppSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == key, cancellationToken).ConfigureAwait(false);
if (setting is null)
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(setting.Value);
}
catch (JsonException)
{
// Unreadable counts as absent: the worst outcome is one rebuild too many.
return default;
}
}
/// <summary>Stages a setting as JSON — the column is jsonb.</summary>
private async Task WriteAsync<T>(string key, T value, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(value);
var setting = await _db.AppSettings.FirstOrDefaultAsync(s => s.Key == key, cancellationToken).ConfigureAwait(false);
if (setting is null)
{
_db.AppSettings.Add(new AppSetting { Key = key, Value = json });
}
else
{
setting.Value = json;
}
}
}
@@ -0,0 +1,40 @@
using MeterVault.Core.Normalization;
namespace MeterVault.Infrastructure.Options;
/// <summary>
/// The instance timezone (<see cref="MeterVaultOptions.TimeZone"/>, SDD §10) as every part of the
/// application uses it: the normalizer divides months in it, readers bucket in it and turn requested
/// dates into instants with it. One resolution, so none of them disagrees about where a month starts.
/// </summary>
public static class InstanceTimeZone
{
/// <summary>The configured zone, or UTC when <paramref name="id"/> is empty or not a known zone.</summary>
public static TimeZoneInfo Resolve(string? id)
{
if (string.IsNullOrWhiteSpace(id))
{
return TimeZoneInfo.Utc;
}
return TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone) ? zone : TimeZoneInfo.Utc;
}
/// <summary>
/// The IANA id for a configured zone. .NET also accepts Windows ids ("W. Europe Standard Time"), but
/// PostgreSQL buckets by IANA names only, so a Windows id is translated rather than passed to SQL.
/// Anything unrecognised is returned unchanged, to be reported at startup.
/// </summary>
public static string Canonical(string id)
{
if (!TimeZoneInfo.TryFindSystemTimeZoneById(id, out var zone) || zone.HasIanaId)
{
return id;
}
return TimeZoneInfo.TryConvertWindowsIdToIanaId(zone.Id, out var iana) ? iana : id;
}
/// <summary>The instant a local date starts — the lower bound a range of that date begins at.</summary>
public static DateTimeOffset StartOf(DateOnly date, TimeZoneInfo zone) => GapAttribution.LocalMidnight(date, zone);
}
@@ -32,9 +32,33 @@ public sealed class MeterVaultOptions
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary> /// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
public int RawRetentionDays { get; set; } = 1095; public int RawRetentionDays { get; set; } = 1095;
/// <summary>
/// Allow an update to be triggered from the UI/API. <b>Off by default, and deliberately.</b> The
/// updater builds whatever is on the branch and the LXC runs this app as root, so enabling it
/// turns a valid API key into arbitrary code execution on the host. This flag is the only gate:
/// with it on, anything that can reach the app can trigger a rebuild and restart. Only sensible
/// where the UI is behind an authenticating proxy or on a network you fully trust.
/// </summary>
public bool AllowInAppUpdate { get; set; }
/// <summary>
/// Compare the running build against the newest published release and show a banner when behind.
/// Set false for an air-gapped instance, or one that should make no outbound requests at all.
/// </summary>
public bool UpdateCheckEnabled { get; set; } = true;
/// <summary>
/// Tag listing consulted by the update check. Points at the project's own Gitea, not a vendor
/// endpoint — nothing about the instance is sent, it is a plain GET of a public tag list.
/// Repoint it at a fork, or blank it to disable the check as surely as the flag above.
/// </summary>
public string UpdateCheckUrl { get; set; } =
"https://git.finalfactory.de/api/v1/repos/FinalFactory/MeterVault/tags?limit=50";
/// <summary> /// <summary>
/// API keys accepted on the <c>X-Api-Key</c> header for the REST API (SDD §9). Provide via env /// API keys accepted on the <c>X-Api-Key</c> header for the REST API (SDD §9). Provide via env
/// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty means the API is open (dev only). /// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty closes the REST API unless
/// <see cref="AllowAnonymousApi"/> opens it explicitly.
/// </summary> /// </summary>
public IList<string> ApiKeys { get; set; } = []; public IList<string> ApiKeys { get; set; } = [];
@@ -0,0 +1,931 @@
// <auto-generated />
using System;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(MeterVaultDbContext))]
[Migration("20260718174732_BindUnboundMqttSourcesToSoleEnabledBroker")]
partial class BindUnboundMqttSourcesToSoleEnabledBroker
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("key");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("value");
b.HasKey("Key")
.HasName("pk_app_setting");
b.ToTable("app_setting", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
{
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<short>("Kind")
.HasColumnType("smallint")
.HasColumnName("kind");
b.Property<double>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<short>("Quality")
.HasColumnType("smallint")
.HasColumnName("quality");
b.HasKey("MeterId", "Time", "Kind")
.HasName("pk_consumption");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_consumption_import_batch_id");
b.ToTable("consumption", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ColorHex")
.HasColumnType("text")
.HasColumnName("color_hex");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("name");
b.Property<int>("Sort")
.HasColumnType("integer")
.HasColumnName("sort");
b.HasKey("Id")
.HasName("pk_cost_category");
b.ToTable("cost_category", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CategoryId")
.HasColumnType("integer")
.HasColumnName("category_id");
b.Property<short?>("EnergyTypeId")
.HasColumnType("smallint")
.HasColumnName("energy_type_id");
b.Property<int?>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.HasKey("Id")
.HasName("pk_cost_category_member");
b.HasIndex("CategoryId")
.HasDatabaseName("ix_cost_category_member_category_id");
b.HasIndex("EnergyTypeId")
.HasDatabaseName("ix_cost_category_member_energy_type_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_cost_category_member_meter_id");
b.ToTable("cost_category_member", null, t =>
{
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
});
});
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
{
b.Property<short>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("smallint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
b.Property<string>("BaseUnit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("base_unit");
b.Property<string>("ColorHex")
.HasColumnType("text")
.HasColumnName("color_hex");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("DefaultMode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("default_mode");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("display_name");
b.Property<string>("Icon")
.HasColumnType("text")
.HasColumnName("icon");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("key");
b.HasKey("Id")
.HasName("pk_energy_type");
b.HasIndex("Key")
.IsUnique()
.HasDatabaseName("ix_energy_type_key");
b.ToTable("energy_type", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Mapping")
.HasColumnType("jsonb")
.HasColumnName("mapping");
b.Property<DateTimeOffset?>("RevertedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("reverted_at");
b.Property<int>("RowCount")
.HasColumnType("integer")
.HasColumnName("row_count");
b.Property<string>("SourceName")
.HasColumnType("text")
.HasColumnName("source_name");
b.HasKey("Id")
.HasName("pk_import_batch");
b.ToTable("import_batch", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Config")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is_enabled");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("LastStatus")
.HasColumnType("text")
.HasColumnName("last_status");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("name");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_ingestion_endpoint");
b.ToTable("ingestion_endpoint", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<double>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<int?>("CategoryId")
.HasColumnType("integer")
.HasColumnName("category_id");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("currency");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<int?>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<DateOnly>("PeriodEnd")
.HasColumnType("date")
.HasColumnName("period_end");
b.Property<DateOnly>("PeriodStart")
.HasColumnType("date")
.HasColumnName("period_start");
b.HasKey("Id")
.HasName("pk_manual_cost");
b.HasIndex("CategoryId")
.HasDatabaseName("ix_manual_cost_category_id");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_manual_cost_import_batch_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_manual_cost_meter_id");
b.ToTable("manual_cost", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<short>("EnergyTypeId")
.HasColumnType("smallint")
.HasColumnName("energy_type_id");
b.Property<double>("InitialBaseline")
.HasColumnType("double precision")
.HasColumnName("initial_baseline");
b.Property<DateOnly?>("InstalledAt")
.HasColumnType("date")
.HasColumnName("installed_at");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is_active");
b.Property<string>("Location")
.HasColumnType("text")
.HasColumnName("location");
b.Property<string>("Manufacturer")
.HasColumnType("text")
.HasColumnName("manufacturer");
b.Property<string>("Meta")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("meta")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("mode");
b.Property<string>("Model")
.HasColumnType("text")
.HasColumnName("model");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<DateOnly?>("RetiredAt")
.HasColumnType("date")
.HasColumnName("retired_at");
b.Property<string>("SerialNumber")
.HasColumnType("text")
.HasColumnName("serial_number");
b.Property<string>("Unit")
.IsRequired()
.HasColumnType("text")
.HasColumnName("unit");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id")
.HasName("pk_meter");
b.HasIndex("EnergyTypeId", "IsActive")
.HasDatabaseName("ix_meter_energy_type_id_is_active");
b.ToTable("meter", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<double?>("Amount")
.HasColumnType("double precision")
.HasColumnName("amount");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("event_type");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<string>("Meta")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("meta")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<double?>("NewValue")
.HasColumnType("double precision")
.HasColumnName("new_value");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<double?>("PrevValue")
.HasColumnType("double precision")
.HasColumnName("prev_value");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<string>("Unit")
.HasColumnType("text")
.HasColumnName("unit");
b.HasKey("Id")
.HasName("pk_meter_event");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_meter_event_import_batch_id");
b.HasIndex("MeterId", "Time")
.HasDatabaseName("ix_meter_event_meter_id_time");
b.ToTable("meter_event", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromMeterId")
.HasColumnType("integer")
.HasColumnName("from_meter_id");
b.Property<int>("ToMeterId")
.HasColumnType("integer")
.HasColumnName("to_meter_id");
b.HasKey("Id")
.HasName("pk_meter_link");
b.HasIndex("ToMeterId")
.HasDatabaseName("ix_meter_link_to_meter_id");
b.HasIndex("FromMeterId", "ToMeterId")
.IsUnique()
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
b.ToTable("meter_link", null, t =>
{
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
});
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Config")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("config")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int?>("EndpointId")
.HasColumnType("integer")
.HasColumnName("endpoint_id");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is_enabled");
b.Property<DateTimeOffset?>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("LastStatus")
.HasColumnType("text")
.HasColumnName("last_status");
b.Property<double?>("LastValue")
.HasColumnType("double precision")
.HasColumnName("last_value");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<double>("Offset")
.HasColumnType("double precision")
.HasColumnName("offset");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<double>("Scale")
.ValueGeneratedOnAdd()
.HasColumnType("double precision")
.HasDefaultValue(1.0)
.HasColumnName("scale");
b.Property<string>("SourceType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("source_type");
b.Property<string>("ValueKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("value_kind");
b.HasKey("Id")
.HasName("pk_meter_source");
b.HasIndex("EndpointId")
.HasDatabaseName("ix_meter_source_endpoint_id");
b.HasIndex("MeterId")
.HasDatabaseName("ix_meter_source_meter_id");
b.ToTable("meter_source", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
{
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<DateTimeOffset>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.Property<int>("Flags")
.HasColumnType("integer")
.HasColumnName("flags");
b.Property<int?>("ImportBatchId")
.HasColumnType("integer")
.HasColumnName("import_batch_id");
b.Property<short>("Quality")
.HasColumnType("smallint")
.HasColumnName("quality");
b.Property<int?>("SourceId")
.HasColumnType("integer")
.HasColumnName("source_id");
b.Property<double>("Value")
.HasColumnType("double precision")
.HasColumnName("value");
b.HasKey("MeterId", "Time")
.HasName("pk_reading");
b.HasIndex("ImportBatchId")
.HasDatabaseName("ix_reading_import_batch_id");
b.ToTable("reading", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset?>("CachedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("cached_at");
b.Property<double?>("CachedBalance")
.HasColumnType("double precision")
.HasColumnName("cached_balance");
b.Property<string>("Calibration")
.HasColumnType("jsonb")
.HasColumnName("calibration");
b.Property<double>("Capacity")
.HasColumnType("double precision")
.HasColumnName("capacity");
b.Property<double?>("FixedRate")
.HasColumnType("double precision")
.HasColumnName("fixed_rate");
b.Property<double?>("LowThreshold")
.HasColumnType("double precision")
.HasColumnName("low_threshold");
b.Property<int>("MeterId")
.HasColumnType("integer")
.HasColumnName("meter_id");
b.Property<string>("RateMode")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("rate_mode");
b.Property<double?>("ReorderThreshold")
.HasColumnType("double precision")
.HasColumnName("reorder_threshold");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("unit");
b.HasKey("Id")
.HasName("pk_tank");
b.HasIndex("MeterId")
.IsUnique()
.HasDatabaseName("ix_tank_meter_id");
b.ToTable("tank", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Component")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("component");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)")
.HasColumnName("currency");
b.Property<string>("Notes")
.HasColumnType("text")
.HasColumnName("notes");
b.Property<int?>("ScopeId")
.HasColumnType("integer")
.HasColumnName("scope_id");
b.Property<string>("ScopeType")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("scope_type");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)")
.HasColumnName("unit");
b.Property<DateOnly>("ValidFrom")
.HasColumnType("date")
.HasColumnName("valid_from");
b.Property<DateOnly?>("ValidTo")
.HasColumnType("date")
.HasColumnName("valid_to");
b.Property<double>("Value")
.HasColumnType("double precision")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_tariff");
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
b.ToTable("tariff", (string)null);
});
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_consumption_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
{
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
.WithMany("Members")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
.WithMany()
.HasForeignKey("EnergyTypeId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_cost_category_member_meter_meter_id");
b.Navigation("Category");
});
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
{
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_manual_cost_cost_category_category_id");
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_manual_cost_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
.WithMany("Meters")
.HasForeignKey("EnergyTypeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_meter_energy_type_energy_type_id");
b.Navigation("EnergyType");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_event_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
.WithMany()
.HasForeignKey("FromMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_from_meter_id");
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
.WithMany()
.HasForeignKey("ToMeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_link_meter_to_meter_id");
b.Navigation("FromMeter");
b.Navigation("ToMeter");
});
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
{
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
.WithMany()
.HasForeignKey("EndpointId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
.WithMany("Sources")
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_meter_source_meter_meter_id");
b.Navigation("Endpoint");
b.Navigation("Meter");
});
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", null)
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_reading_meter_meter_id");
});
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
{
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
.WithMany()
.HasForeignKey("MeterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tank_meter_meter_id");
b.Navigation("Meter");
});
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
{
b.Navigation("Meters");
});
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
{
b.Navigation("Sources");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BindUnboundMqttSourcesToSoleEnabledBroker : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Corrects BindUnboundMqttSourcesToSoleBroker, which counted brokers without regard to
// is_enabled. An instance with one live broker plus a disabled leftover counted two,
// declined to backfill on the grounds that the old routing was "ambiguous", and left its
// sources unbound — which under endpoint-scoped routing means permanently, silently dead.
//
// That reasoning was wrong for exactly this shape: MqttIngestionWorker only ever
// connected to enabled endpoints, so with a single enabled broker the mapping was never
// ambiguous. Re-run the backfill counting only enabled brokers.
//
// Idempotent and safe to follow the original: it touches only rows still NULL, so
// anything the first migration bound, or an operator has since bound by hand, is left
// alone. Instances that were already correct match nothing and are unaffected.
migrationBuilder.Sql("""
UPDATE meter_source AS s
SET endpoint_id = sole.id
FROM (SELECT id FROM ingestion_endpoint WHERE type = 'MqttBroker' AND is_enabled) AS sole
WHERE s.endpoint_id IS NULL
AND s.source_type IN ('Mqtt', 'Tasmota')
AND (SELECT count(*) FROM ingestion_endpoint WHERE type = 'MqttBroker' AND is_enabled) = 1;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Intentionally empty, as for the migration this corrects: the rows it bound cannot be
// told apart from ones bound by hand, so clearing them would discard real configuration.
}
}
}
@@ -0,0 +1,95 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace MeterVault.Infrastructure.Update;
/// <summary>
/// A released version, parsed from either the running assembly or a git tag name.
/// </summary>
/// <remarks>
/// Deliberately not <see cref="System.Version"/>. Tags are written <c>vX.Y.Z</c> while the assembly
/// carries <c>X.Y.Z</c> — often with a <c>+commithash</c> suffix from the build — and comparing
/// those as strings, or letting System.Version see a 4th component it invents as -1, produces an
/// "update available" banner that never clears. Parsing to exactly three numbers makes the
/// comparison total and boring.
/// </remarks>
public readonly record struct ReleaseVersion(int Major, int Minor, int Patch) : IComparable<ReleaseVersion>
{
public static bool TryParse(string? text, out ReleaseVersion version)
{
version = default;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var value = text.Trim();
// Build metadata ("0.2.0+3f1a9c") and prerelease suffixes ("0.2.0-rc.1") are not part of the
// ordering here: a prerelease tag compares equal to its release, so it never nags.
var cut = value.IndexOfAny(['+', '-']);
if (cut >= 0)
{
value = value[..cut];
}
value = value.TrimStart('v', 'V');
var parts = value.Split('.');
if (parts.Length != 3)
{
return false;
}
if (!int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var major)
|| !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var minor)
|| !int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var patch))
{
return false;
}
version = new ReleaseVersion(major, minor, patch);
return true;
}
/// <summary>Picks the highest parseable version from a list of tag names, ignoring the rest.</summary>
public static bool TryPickLatest(IEnumerable<string?> tagNames, [NotNullWhen(true)] out ReleaseVersion? latest)
{
ArgumentNullException.ThrowIfNull(tagNames);
ReleaseVersion? best = null;
foreach (var name in tagNames)
{
if (TryParse(name, out var parsed) && (best is null || parsed > best.Value))
{
best = parsed;
}
}
latest = best;
return best is not null;
}
public int CompareTo(ReleaseVersion other)
{
var major = Major.CompareTo(other.Major);
if (major != 0)
{
return major;
}
var minor = Minor.CompareTo(other.Minor);
return minor != 0 ? minor : Patch.CompareTo(other.Patch);
}
public static bool operator <(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) < 0;
public static bool operator >(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) > 0;
public static bool operator <=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) <= 0;
public static bool operator >=(ReleaseVersion left, ReleaseVersion right) => left.CompareTo(right) >= 0;
public override string ToString() =>
string.Create(CultureInfo.InvariantCulture, $"{Major}.{Minor}.{Patch}");
}
@@ -0,0 +1,154 @@
using System.Reflection;
using System.Text.Json;
using MeterVault.Infrastructure.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Update;
/// <summary>What the instance is running, and what the newest published release is.</summary>
/// <param name="Running">Version of the running build, or null if the assembly carries none.</param>
/// <param name="Latest">Newest release tag seen, or null if the check has not succeeded.</param>
/// <param name="CheckedAt">When the last successful check completed.</param>
public sealed record UpdateStatus(ReleaseVersion? Running, ReleaseVersion? Latest, DateTimeOffset? CheckedAt)
{
/// <summary>True only when both versions are known and the published one is genuinely newer.</summary>
public bool UpdateAvailable => Running is { } running && Latest is { } latest && latest > running;
}
/// <summary>
/// Compares the running build against the newest tag in the source repository, so an instance can
/// say it is behind (SDD §12 releases are driven by the VERSION file).
/// </summary>
/// <remarks>
/// Singleton with a cached result: the dashboard renders on every navigation and must never wait on,
/// or fail because of, a remote call. A failed check keeps serving the last good answer and is
/// retried on a short backoff rather than per page view — an instance with no outbound access should
/// cost one failed request every few minutes, not one per render.
/// </remarks>
public sealed class UpdateCheckService(
IHttpClientFactory httpClientFactory,
IOptions<MeterVaultOptions> options,
ILogger<UpdateCheckService> logger)
{
private static readonly TimeSpan SuccessTtl = TimeSpan.FromHours(6);
private static readonly TimeSpan FailureTtl = TimeSpan.FromMinutes(15);
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
private readonly MeterVaultOptions _options = options.Value;
private readonly ILogger<UpdateCheckService> _logger = logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private UpdateStatus _status = new(RunningVersion(), null, null);
private DateTimeOffset _nextCheck = DateTimeOffset.MinValue;
/// <summary>The version of the running build, or null when the assembly carries no usable one.</summary>
/// <remarks>
/// Reads MeterVault's own assembly rather than <see cref="Assembly.GetEntryAssembly"/>, which is
/// whatever process happens to be hosting — the test runner under <c>dotnet test</c>, whose
/// version parses fine and would report a confident, wrong answer. Every project shares the
/// VERSION stamp from Directory.Build.props, so this is the release version wherever it runs.
/// </remarks>
public static ReleaseVersion? RunningVersion()
{
var informational = typeof(UpdateCheckService).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
return ReleaseVersion.TryParse(informational, out var version) ? version : null;
}
/// <summary>
/// The last known status, without touching the network. Lets a page paint immediately and fill
/// the banner in afterwards, instead of holding first render open for the length of an HTTP
/// timeout on a cold start.
/// </summary>
public UpdateStatus Current => _status;
/// <summary>
/// The current status, refreshing at most once per TTL. Never throws: a check that cannot reach
/// the repository leaves the banner absent rather than breaking the page that asked.
/// </summary>
public async Task<UpdateStatus> GetAsync(CancellationToken cancellationToken = default)
{
if (!_options.UpdateCheckEnabled || DateTimeOffset.UtcNow < _nextCheck)
{
return _status;
}
// One caller refreshes; the rest take the cached answer rather than queueing behind it, so a
// slow endpoint cannot stack up render-blocking waits.
if (!await _gate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return _status;
}
try
{
if (DateTimeOffset.UtcNow < _nextCheck)
{
return _status;
}
var latest = await FetchLatestTagAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null)
{
_status = new UpdateStatus(RunningVersion(), latest, DateTimeOffset.UtcNow);
_nextCheck = DateTimeOffset.UtcNow + SuccessTtl;
}
else
{
_nextCheck = DateTimeOffset.UtcNow + FailureTtl;
}
return _status;
}
finally
{
_gate.Release();
}
}
private async Task<ReleaseVersion?> FetchLatestTagAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.UpdateCheckUrl))
{
return null;
}
try
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(10);
using var response = await client
.GetAsync(new Uri(_options.UpdateCheckUrl), cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug("Update check returned {Status}", (int)response.StatusCode);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
// Gitea's /tags returns an array of objects with a "name". Order is not guaranteed to be
// semver, so take the highest rather than the first.
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
return null;
}
var names = document.RootElement.EnumerateArray()
.Select(e => e.TryGetProperty("name", out var name) ? name.GetString() : null);
return ReleaseVersion.TryPickLatest(names, out var latest) ? latest : null;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or UriFormatException)
{
// Offline, DNS gone, repository moved, unexpected payload: all mean "cannot tell", which
// is a missing banner, not an error the operator needs to see on every page.
_logger.LogDebug(ex, "Update check failed");
return null;
}
}
}
+166
View File
@@ -0,0 +1,166 @@
using System.Diagnostics;
using MeterVault.Infrastructure.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MeterVault.Infrastructure.Update;
/// <summary>Why an in-app update cannot be started, or <see cref="Allowed"/> if it can.</summary>
public enum UpdateAvailability
{
Allowed,
/// <summary>The operator has not set <c>MeterVault__AllowInAppUpdate</c>.</summary>
NotEnabled,
/// <summary>This install has no update mechanism — a container is replaced, not updated in place.</summary>
NotSupportedHere,
}
/// <summary>Which way an attempt to launch the updater ended.</summary>
/// <remarks>
/// The UI needs to say this in the reader's language, and this assembly has no business knowing
/// what that language is — so the outcome travels as a value and the wording is chosen upstairs.
/// </remarks>
public enum UpdateOutcome
{
/// <summary>The transient unit is running; the service restarts when the rebuild finishes.</summary>
Started,
/// <summary><see cref="UpdateRunner.Availability"/> refused before anything was launched.</summary>
NotAllowed,
/// <summary><c>systemd-run</c> could not be started at all.</summary>
LauncherMissing,
/// <summary><c>systemd-run</c> ran and exited non-zero — most often an update already in flight.</summary>
LauncherFailed,
/// <summary>Launching threw. <see cref="UpdateLaunch.Detail"/> carries the exception message.</summary>
LaunchError,
}
/// <summary>Outcome of trying to launch the updater.</summary>
/// <param name="Message">
/// The English summary. Kept as-is for logs and any non-UI caller, so a translation can never change
/// what an operator reads in a log line.
/// </param>
/// <param name="Outcome">The same result as a value, for a UI that has to phrase it in some language.</param>
/// <param name="Availability">Why it was refused, when <paramref name="Outcome"/> is <see cref="UpdateOutcome.NotAllowed"/>.</param>
/// <param name="Detail">Diagnostic text (stderr, an exception message). Never translated — it isn't ours.</param>
public sealed record UpdateLaunch(
bool Started,
string Message,
UpdateOutcome Outcome = UpdateOutcome.LaunchError,
UpdateAvailability? Availability = null,
string? Detail = null);
/// <summary>
/// Starts the in-container updater on request, gated hard.
/// </summary>
/// <remarks>
/// This is the most dangerous thing in the codebase, so the reasoning is written down. The updater
/// runs <c>git reset --hard</c> and <c>dotnet publish</c> against whatever is on the branch, then
/// restarts the service, and in the LXC the app runs as root.
///
/// <see cref="MeterVaultOptions.AllowInAppUpdate"/> is the only gate, by explicit operator choice —
/// no key, no prompt. With it on, anything that can reach the UI can trigger a rebuild-and-restart:
/// on the realistic threat model that is a repeatable denial of service (minutes of downtime and a
/// pegged CPU per request) rather than code injection, since the build comes from the operator's own
/// repository — but it becomes full remote code execution if that repository is ever compromised.
/// With it off there is no code path to launch at all, which is why it defaults off and why the
/// check is repeated inside <see cref="LaunchAsync"/> rather than trusted to callers.
/// </remarks>
public sealed class UpdateRunner(IOptions<MeterVaultOptions> options, ILogger<UpdateRunner> logger)
{
private const string UpdateCommandPath = "/usr/bin/update";
private const string TransientUnit = "metervault-update";
private readonly MeterVaultOptions _options = options.Value;
private readonly ILogger<UpdateRunner> _logger = logger;
/// <summary>True where an in-place update exists at all — the LXC install, not a container.</summary>
public static bool IsSupportedHere => OperatingSystem.IsLinux() && File.Exists(UpdateCommandPath);
/// <summary>Whether an update could be started, ignoring whether any particular caller may.</summary>
public UpdateAvailability Availability
{
get
{
if (!_options.AllowInAppUpdate)
{
return UpdateAvailability.NotEnabled;
}
return IsSupportedHere ? UpdateAvailability.Allowed : UpdateAvailability.NotSupportedHere;
}
}
/// <summary>
/// Launches the updater detached from this process and returns immediately.
/// </summary>
/// <remarks>
/// The updater stops and restarts the service, so a child of this process would be killed
/// half-way through — leaving the app down with a partially published build. <c>systemd-run</c>
/// puts it in its own transient unit, which survives us dying and is what makes "click, wait,
/// come back" possible at all. Callers must have checked <see cref="Availability"/> and
/// <see cref="IsAuthorised"/> first; this re-checks availability rather than trusting them.
/// </remarks>
public async Task<UpdateLaunch> LaunchAsync(CancellationToken cancellationToken = default)
{
if (Availability is not UpdateAvailability.Allowed)
{
return new UpdateLaunch(false, $"Update cannot be started: {Availability}.",
UpdateOutcome.NotAllowed, Availability);
}
try
{
var start = new ProcessStartInfo("systemd-run")
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
// --collect reaps the unit when it finishes, so a second update is not blocked by the
// corpse of the first.
start.ArgumentList.Add("--collect");
start.ArgumentList.Add($"--unit={TransientUnit}");
start.ArgumentList.Add("--description=MeterVault in-app update");
start.ArgumentList.Add(UpdateCommandPath);
using var process = Process.Start(start);
if (process is null)
{
return new UpdateLaunch(false, "Could not start systemd-run.", UpdateOutcome.LauncherMissing);
}
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
var error = (await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false)).Trim();
// Most likely an update already running: the unit name is taken until it is collected.
_logger.LogWarning("systemd-run failed ({ExitCode}): {Error}", process.ExitCode, error);
return new UpdateLaunch(false,
string.IsNullOrWhiteSpace(error) ? "Could not start the update." : error,
UpdateOutcome.LauncherFailed, Detail: string.IsNullOrWhiteSpace(error) ? null : error);
}
// Deliberately loud. With no key there is no caller to attribute this to, so the log is
// the only record that it happened at all — and the update restarts the app, so nothing
// held in memory survives.
_logger.LogWarning("In-app update started as transient unit {Unit}", TransientUnit);
return new UpdateLaunch(true,
"Update started. The service restarts when the rebuild finishes — this usually takes a few minutes.",
UpdateOutcome.Started);
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException or IOException)
{
_logger.LogWarning(ex, "Could not launch the updater");
return new UpdateLaunch(false, $"Could not launch the updater: {ex.Message}",
UpdateOutcome.LaunchError, Detail: ex.Message);
}
}
}
@@ -72,6 +72,54 @@ public sealed class CumulativeCounterNormalizerTests
Assert.Equal([10d, 50d, 30d, 50d], result.Select(c => c.Amount)); Assert.Equal([10d, 50d, 30d, 50d], result.Select(c => c.Amount));
} }
[Fact]
public void Counter_reset_with_a_final_register_books_the_stretch_before_the_reset()
{
// The register climbed 150 → 170 after the February reading, then reset to 0 and reached 30
// by March. Knowing the 170 turns the reset from "lose 20" into the swap formula: 20 + 30.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh", InitialBaseline = 90 },
Readings =
[
Reading(1, Month(2023, 1), 100),
Reading(1, Month(2023, 2), 150),
Reading(1, Month(2023, 3), 30),
Reading(1, Month(2023, 4), 80),
],
Events = [Reset(1, Month(2023, 3), newValue: 0, prevValue: 170)],
};
var result = _engine.Normalize(ctx);
Assert.Equal([10d, 50d, 50d, 50d], result.Select(c => c.Amount));
}
[Fact]
public void Swap_recorded_with_the_new_meters_start_reading_at_the_same_instant_books_the_tail_there()
{
// What the meter page records for "the meter was swapped today": the swap event and a reading of
// the new register's start value, both at the swap instant. The old meter's tail (873 861)
// lands at the swap, and the next reading counts from the new register's start.
var swapAt = Month(2023, 3).AddDays(16).AddHours(9);
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 10, Mode = MeterMode.CumulativeCounter, Unit = "m3", InitialBaseline = 848 },
Readings =
[
DayReading(10, Month(2023, 3), 861),
Reading(10, swapAt, 2),
Reading(10, swapAt.AddDays(1), 2.5),
],
Events = [Swap(10, swapAt, prevValue: 873, newValue: 2)],
};
var result = _engine.Normalize(ctx);
Assert.Equal([13d, 12d, 0.5d], result.Select(c => c.Amount));
Assert.Equal(swapAt, result[1].Time);
}
[Fact] [Fact]
public void Unexplained_decrease_yields_zero_and_marks_quality() public void Unexplained_decrease_yields_zero_and_marks_quality()
{ {
+519
View File
@@ -0,0 +1,519 @@
using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using static MeterVault.Core.Tests.TestData;
namespace MeterVault.Core.Tests;
/// <summary>
/// Consumption between two readings belongs to the months it accrued in. What these pin down: an
/// interval crossing a local month boundary is divided by elapsed time; an imported monthly table
/// still produces exactly one unchanged row per row, because that is what reconciles against the
/// reference spreadsheet (SDD §13).
/// </summary>
public sealed class GapAttributionTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private readonly INormalizationEngine _engine = NormalizationEngine.CreateDefault();
private static DateTimeOffset BerlinTime(int year, int month, int day, int hour = 0, int minute = 0) =>
new(new DateTime(year, month, day, hour, minute, 0), Berlin.GetUtcOffset(new DateTime(year, month, day, hour, minute, 0)));
private static Reading Manual(DateTimeOffset time, double value) =>
new() { MeterId = 1, Time = time, Value = value, Quality = ReadingQuality.Manual };
private static string LocalMonth(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, Berlin).ToString("yyyy-MM");
[Fact]
public void Readings_from_1_August_to_16_September_are_divided_between_the_two_months()
{
// The reported case: nothing read in between, so September used to carry all 46 days.
var august1 = BerlinTime(2026, 8, 1, 9, 0);
var september16 = BerlinTime(2026, 9, 16, 18, 0);
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings = [Manual(august1, 700), Manual(september16, 746)],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
var interval = result.Skip(1).ToList();
Assert.Equal(2, interval.Count);
Assert.Equal(["2026-08", "2026-09"], interval.Select(c => LocalMonth(c.Time)));
Assert.Equal(46, interval.Sum(c => c.Amount), 9);
var total = september16 - august1;
var inAugust = BerlinTime(2026, 9, 1) - august1;
Assert.Equal(46 * (inAugust / total), interval[0].Amount, 9);
Assert.All(interval, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
// September's share still sits on the reading that closed the interval.
Assert.Equal(september16, interval[1].Time);
}
[Fact]
public void An_interval_inside_one_month_is_one_row_at_its_reading_with_its_own_quality()
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings = [Manual(BerlinTime(2026, 9, 1, 8), 700), Manual(BerlinTime(2026, 9, 16, 18), 710)],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(2, result.Count);
Assert.Equal(BerlinTime(2026, 9, 16, 18), result[1].Time);
Assert.Equal(ReadingQuality.Manual, result[1].Quality);
}
[Fact]
public void Months_are_local_so_a_reading_just_after_local_midnight_does_not_take_the_month_with_it()
{
// 1 September 00:30 in Berlin is still 31 August in UTC. Split at UTC boundaries this interval
// would be one row stamped in local September carrying all of August.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings = [Manual(BerlinTime(2026, 8, 1), 700), Manual(BerlinTime(2026, 9, 1, 0, 30), 731)],
TimeZone = Berlin,
};
var interval = _engine.Normalize(ctx).Skip(1).ToList();
Assert.Equal(["2026-08", "2026-09"], interval.Select(c => LocalMonth(c.Time)));
Assert.True(interval[0].Amount > 30.9, $"August got only {interval[0].Amount}");
}
[Fact]
public void A_reading_exactly_at_local_midnight_on_the_first_books_wholly_to_the_month_before()
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings = [Manual(BerlinTime(2026, 8, 1), 700), Manual(BerlinTime(2026, 9, 1), 731)],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(2, result.Count);
Assert.Equal("2026-08", LocalMonth(result[1].Time));
Assert.Equal(31, result[1].Amount, 9);
Assert.Equal(ReadingQuality.Manual, result[1].Quality);
}
[Fact]
public void An_ordinary_imported_monthly_series_produces_one_unchanged_row_per_reading()
{
// The reference-data shape: rows stamped 00:00 UTC on the 1st. It must not gain rows, move
// them, or lose its quality — in UTC or in the instance timezone.
foreach (var zone in new[] { TimeZoneInfo.Utc, Berlin })
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2022, 9), 0),
Reading(1, Month(2022, 10), 411),
Reading(1, Month(2022, 11), 1153),
Reading(1, Month(2022, 12), 1968),
],
TimeZone = zone,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(4, result.Count);
Assert.DoesNotContain(result, c => c.Quality == ReadingQuality.Estimated);
Assert.Equal([0, 411, 742, 815], result.Select(c => c.Amount).ToArray());
Assert.Equal([Month(2022, 9), Month(2022, 10), Month(2022, 11), Month(2022, 12)], result.Select(c => c.Time));
}
}
[Fact]
public void A_month_label_is_an_imported_row_the_importer_flagged_as_a_month()
{
Assert.True(GapAttribution.IsMonthLabel(Reading(1, Month(2026, 5), 1)));
Assert.False(GapAttribution.IsMonthLabel(DayReading(1, Month(2026, 5), 1)));
// Correcting a typo in an imported month row by hand does not turn it into a reading on the 1st.
Assert.True(GapAttribution.IsMonthLabel(new Reading
{
Time = Month(2026, 5), Quality = ReadingQuality.Manual, Flags = ReadingFlags.MonthLabel,
}));
// "Mai 2026" is the register at the end of May.
Assert.Equal(BerlinTime(2026, 6, 1), GapAttribution.EffectiveTime(Reading(1, Month(2026, 5), 1), Berlin));
}
[Fact]
public void A_day_dated_import_on_the_first_is_an_instant_not_a_month()
{
// A meter log imported with "15.07.2026" and "01.08.2026", then read by hand on 16 September. The
// row on the 1st is stamped at the same midnight as a month label but means that day.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings =
[
DayReading(1, new DateTimeOffset(2026, 7, 15, 0, 0, 0, TimeSpan.Zero), 600),
DayReading(1, Month(2026, 8), 700),
Manual(BerlinTime(2026, 9, 16, 18), 746),
],
TimeZone = Berlin,
};
var byMonth = _engine.Normalize(ctx).Skip(1)
.GroupBy(c => LocalMonth(c.Time))
.ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
// 15 July to 1 August 00:00 UTC ends at 02:00 local on the 1st: two hours of the 100 in August, not 65.
Assert.True(byMonth["2026-07"] > 99.4, $"July got only {byMonth["2026-07"]}");
// ...and the six weeks after it are shared between August and September.
Assert.True(byMonth["2026-08"] is > 30 and < 31, $"August got {byMonth["2026-08"]}");
Assert.True(byMonth["2026-09"] is > 15 and < 16, $"September got {byMonth["2026-09"]}");
Assert.Equal(146, byMonth.Values.Sum(), 6);
}
[Fact]
public void A_monthly_table_imported_after_live_readings_does_not_count_its_month_twice()
{
// Read by hand on 1 August and 16 September; later the sheet rows "Juli" and "August" are imported.
// "August 2026" = 731 is the register at the end of August, so it belongs after the reading taken
// on 1 August even though it is stamped at midnight that day.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m3" },
Readings =
[
Reading(1, Month(2026, 7), 699),
Reading(1, Month(2026, 8), 731),
Manual(BerlinTime(2026, 8, 1, 9), 700),
Manual(BerlinTime(2026, 9, 16, 18), 746),
],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
var byMonth = result.GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
Assert.Equal(746, result.Sum(c => c.Amount), 6);
Assert.Equal(32, byMonth["2026-08"], 6); // 1 on the morning of the 1st, 31 through the month
Assert.Equal(15, byMonth["2026-09"], 6);
Assert.DoesNotContain(result, c => c.Amount < 0);
}
[Fact]
public void Behind_utc_imported_months_stay_in_their_own_local_month()
{
// In New York, 00:00 UTC on the 1st is still the evening before. The water sheet rows, including
// the swap in March, must each land in the month they name: none doubled, none empty.
var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
Readings =
[
Reading(1, Month(2022, 12), 834),
Reading(1, Month(2023, 1), 848),
Reading(1, Month(2023, 2), 861),
Reading(1, Month(2023, 3), 2),
Reading(1, Month(2023, 4), 15),
],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
TimeZone = newYork,
};
var result = _engine.Normalize(ctx).ToList();
string NewYorkMonth(DateTimeOffset t) => TimeZoneInfo.ConvertTime(t, newYork).ToString("yyyy-MM");
Assert.Equal(
["2022-12", "2023-01", "2023-02", "2023-03", "2023-04"],
result.Select(c => NewYorkMonth(c.Time)));
Assert.Equal([834, 14, 13, 12, 13], result.Select(c => c.Amount).ToArray());
}
[Fact]
public void Behind_utc_a_live_reading_next_to_imported_months_never_duplicates_a_stored_row()
{
// Imported "Juni" and "Juli", and HA polled at exactly local midnight on 1 July, the instant the
// July row is stamped at. Two rows on one key used to fail the whole recompute; they now add up.
var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
var julyMidnight = new DateTimeOffset(2026, 7, 1, 4, 0, 0, TimeSpan.Zero); // 00:00 EDT
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2026, 6), 1000),
Reading(1, Month(2026, 7), 1300),
new Reading { MeterId = 1, Time = julyMidnight, Value = 1005, Quality = ReadingQuality.Measured },
new Reading { MeterId = 1, Time = new DateTimeOffset(2026, 8, 10, 16, 0, 0, TimeSpan.Zero), Value = 1420, Quality = ReadingQuality.Measured },
],
TimeZone = newYork,
};
var result = _engine.Normalize(ctx).ToList();
var july = result.Where(c => TimeZoneInfo.ConvertTime(c.Time, newYork).ToString("yyyy-MM") == "2026-07").ToList();
Assert.Equal(result.Count, result.Select(c => (c.Time, c.Kind)).Distinct().Count());
Assert.Equal(1420, result.Sum(c => c.Amount), 6);
Assert.Equal(300, july.Sum(c => c.Amount), 6);
}
[Fact]
public void A_swap_detected_in_a_monthly_table_applies_to_the_first_live_reading_of_that_month()
{
// HA already read the new water meter on 10 and 25 March; later the sheet is imported and the
// importer detects the swap at the "Maerz" row. The old tail must not be measured from 1.5.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
Readings =
[
Reading(1, Month(2023, 1), 848),
Reading(1, Month(2023, 2), 861),
Manual(BerlinTime(2023, 3, 10, 12), 0.5),
Manual(BerlinTime(2023, 3, 25, 12), 1.5),
Reading(1, Month(2023, 3), 2),
Reading(1, Month(2023, 4), 15),
],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2)],
TimeZone = Berlin,
};
var byMonth = _engine.Normalize(ctx).GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
Assert.Equal(1.5, byMonth["2023-03"], 6);
Assert.Equal(13, byMonth["2023-04"], 6);
}
[Fact]
public void A_reset_at_a_month_rows_stamp_applies_where_the_month_begins()
{
// A reset posted at 00:00 UTC on 1 August, with the August row and two live August readings.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2026, 7), 699),
Manual(BerlinTime(2026, 8, 5, 12), 5),
Manual(BerlinTime(2026, 8, 20, 12), 20),
Reading(1, Month(2026, 8), 31),
],
Events = [Reset(1, Month(2026, 8), newValue: 0, prevValue: 700)],
TimeZone = Berlin,
};
var byMonth = _engine.Normalize(ctx).GroupBy(c => LocalMonth(c.Time)).ToDictionary(g => g.Key, g => g.Sum(c => c.Amount));
Assert.Equal(32, byMonth["2026-08"], 6);
}
[Fact]
public void Burner_hours_follow_the_same_month_rows_as_registers()
{
// "Juli" = 960 h and "August" = 1000 h imported, plus live readings on 15 August and 16 September.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.RuntimeCounter, Unit = "h" },
Readings =
[
Reading(1, Month(2026, 7), 960),
Reading(1, Month(2026, 8), 1000),
Manual(BerlinTime(2026, 8, 15, 12), 990),
Manual(BerlinTime(2026, 9, 16, 12), 1020),
],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(1020, result.Sum(c => c.Amount), 6);
Assert.DoesNotContain(result, c => c.Amount < 0);
}
[Fact]
public void Behind_utc_month_rows_of_deltas_and_hours_stay_in_their_own_month()
{
var newYork = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
string NewYorkMonth(DateTimeOffset t) => TimeZoneInfo.ConvertTime(t, newYork).ToString("yyyy-MM");
foreach (var mode in new[] { MeterMode.DirectDelta, MeterMode.RuntimeCounter })
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = mode, Unit = "h" },
Readings = [Reading(1, Month(2026, 7), 960), Reading(1, Month(2026, 8), 1000)],
TimeZone = newYork,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(["2026-07", "2026-08"], result.Select(c => NewYorkMonth(c.Time)));
}
}
[Theory]
[InlineData("America/Asuncion", 2023, 8)]
[InlineData("America/Asuncion", 2017, 8)]
[InlineData("Europe/Volgograd", 2018, 11)]
[InlineData("Pacific/Apia", 2011, 11)]
[InlineData("Asia/Amman", 2005, 2)]
[InlineData("Asia/Damascus", 2011, 2)]
public void Months_at_a_transition_the_zone_data_contradicts_still_divide_and_finish(string zoneId, int year, int month)
{
// Zone data that reports a midnight's offset from after the change used to stall the month walk
// forever — an import or the startup rebuild that never returned.
if (!TimeZoneInfo.TryFindSystemTimeZoneById(zoneId, out var zone))
{
return;
}
var from = new DateTimeOffset(year, month, 10, 12, 0, 0, TimeSpan.Zero);
var to = from.AddMonths(3);
var work = Task.Run(() => GapAttribution.Attribute(from, to, to, 90, zone));
Assert.True(work.Wait(TimeSpan.FromSeconds(5)), "Attribution did not finish.");
Assert.Equal(90, work.Result.Sum(s => s.Amount), 6);
Assert.All(work.Result, s => Assert.True(s.Time > from && s.Time <= to, $"{s.Time:O} lies outside the interval"));
}
[Fact]
public void Where_clocks_fall_back_at_midnight_the_month_starts_at_the_first_midnight()
{
// Havana leaves daylight time at 01:00 on 1 November 2026, so 00:00 happens twice. October ends at
// the first one; stamping October's share after it would file it under November.
var havana = TimeZoneInfo.FindSystemTimeZoneById("America/Havana");
var from = new DateTimeOffset(2026, 10, 20, 16, 0, 0, TimeSpan.Zero);
var to = new DateTimeOffset(2026, 11, 20, 17, 0, 0, TimeSpan.Zero);
var segments = GapAttribution.Attribute(from, to, to, 31, havana);
Assert.Equal(2, segments.Count);
Assert.True(segments[0].Time < new DateTimeOffset(2026, 11, 1, 4, 0, 0, TimeSpan.Zero), $"October's share is stamped {segments[0].Time:O}");
}
[Fact]
public void A_live_reading_after_the_last_imported_row_counts_from_the_end_of_that_rows_month()
{
// Solar 1 read monthly up to "Mai 2026", then one live reading on 18 July. May's use is already
// in the May row; the 714.5 kWh since belongs to June and July — not a third of it back in May.
var july18 = new DateTimeOffset(2026, 7, 18, 15, 33, 0, TimeSpan.Zero);
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.GenerationCounter, Unit = "kWh" },
Readings =
[
Reading(1, Month(2026, 4), 10308),
Reading(1, Month(2026, 5), 10731),
new Reading { MeterId = 1, Time = july18, Value = 11445.5, Quality = ReadingQuality.Measured },
],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
var gap = result.Skip(2).ToList();
Assert.Equal(["2026-06", "2026-07"], gap.Select(c => LocalMonth(c.Time)));
Assert.Equal(714.5, gap.Sum(c => c.Amount), 6);
var june = BerlinTime(2026, 7, 1) - BerlinTime(2026, 6, 1);
Assert.Equal(714.5 * (june / (july18 - BerlinTime(2026, 6, 1))), gap[0].Amount, 6);
Assert.Equal(11445.5, result.Sum(c => c.Amount), 6);
}
[Fact]
public void A_skipped_month_in_an_imported_table_is_shared_between_the_months_it_covers()
{
// "Januar" then "April": the 900 accrued over February, March and April.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Reading(1, Month(2023, 1), 1000), Reading(1, Month(2023, 4), 1900)],
};
var spread = _engine.Normalize(ctx).Skip(1).ToList();
Assert.Equal(["2023-02", "2023-03", "2023-04"], spread.Select(c => c.Time.UtcDateTime.ToString("yyyy-MM")));
Assert.Equal(900, spread.Sum(c => c.Amount), 6);
Assert.Equal(900 * (28d / 89d), spread[0].Amount, 6); // Feb 28 + Mar 31 + Apr 30 days
Assert.All(spread, c => Assert.Equal(ReadingQuality.Estimated, c.Quality));
// April's share keeps the April row's own timestamp.
Assert.Equal(Month(2023, 4), spread[^1].Time);
}
[Fact]
public void Attribution_preserves_the_total_and_stamps_every_share_inside_its_month()
{
var from = BerlinTime(2026, 5, 20, 7);
var to = BerlinTime(2026, 8, 3, 21);
var segments = GapAttribution.Attribute(from, to, to, 1000.1, Berlin);
Assert.Equal(["2026-05", "2026-06", "2026-07", "2026-08"], segments.Select(s => LocalMonth(s.Time)));
Assert.Equal(1000.1, segments.Sum(s => s.Amount), 9);
Assert.All(segments, s => Assert.True(s.Time > from && s.Time <= to));
Assert.Equal(segments.Count, segments.Select(s => s.Time).Distinct().Count());
}
[Fact]
public void An_unchanged_register_across_months_does_not_fan_out_into_empty_rows()
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Reading(1, Month(2023, 1), 500), Reading(1, Month(2023, 5), 500)],
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(2, result.Count);
Assert.Equal(0, result[^1].Amount, 6);
}
[Fact]
public void A_rejected_decrease_across_months_stays_a_single_row()
{
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "kWh" },
Readings = [Reading(1, Month(2023, 1), 900), Reading(1, Month(2023, 5), 100)],
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(2, result.Count);
Assert.Equal(0, result[^1].Amount, 6);
Assert.Equal(Month(2023, 5), result[^1].Time);
}
[Fact]
public void A_swap_across_months_keeps_its_explicit_amount_in_one_row()
{
// Swap amounts are corrections booked at the event (the water …861 → 2 case reconciles to
// 12). Apportioning one would silently rewrite a number the operator supplied.
var ctx = new NormalizationContext
{
Meter = new MeterConfig { MeterId = 1, Mode = MeterMode.CumulativeCounter, Unit = "m³" },
Readings =
[
Reading(1, Month(2023, 1), 861),
Reading(1, Month(2023, 5), 15),
],
Events = [Swap(1, Month(2023, 3), prevValue: 861, newValue: 2, amount: 12)],
TimeZone = Berlin,
};
var result = _engine.Normalize(ctx).ToList();
Assert.Equal(2, result.Count);
Assert.Equal(12, result[^1].Amount, 6);
Assert.NotEqual(ReadingQuality.Estimated, result[^1].Quality);
}
}
+46
View File
@@ -0,0 +1,46 @@
using MeterVault.Core.Domain;
namespace MeterVault.Core.Tests;
/// <summary>
/// The event menu on a meter page is driven by these rules, so they pin which events each mode can
/// actually use — an event the normalizer ignores must never be offered as if it fixed something.
/// </summary>
public sealed class MeterEventRulesTests
{
[Theory]
[InlineData(MeterMode.CumulativeCounter)]
[InlineData(MeterMode.GenerationCounter)]
[InlineData(MeterMode.RuntimeCounter)]
public void Registers_offer_swap_and_reset(MeterMode mode)
{
Assert.Equal([MeterEventType.MeterSwap, MeterEventType.CounterReset, MeterEventType.Note], MeterEventRules.RecordableFor(mode));
Assert.True(MeterEventRules.IsMonotonic(mode));
Assert.True(MeterEventRules.TakesReadings(mode));
}
[Fact]
public void A_tank_offers_level_and_delivery_and_takes_no_readings()
{
Assert.Equal([MeterEventType.TankLevel, MeterEventType.Delivery, MeterEventType.Note],
MeterEventRules.RecordableFor(MeterMode.ConsumableBalance));
Assert.False(MeterEventRules.TakesReadings(MeterMode.ConsumableBalance));
Assert.False(MeterEventRules.CanRecord(MeterMode.ConsumableBalance, MeterEventType.MeterSwap));
}
[Theory]
[InlineData(MeterMode.DirectDelta)]
[InlineData(MeterMode.InstantRate)]
[InlineData(MeterMode.Virtual)]
public void Modes_without_a_register_only_take_notes(MeterMode mode) =>
Assert.Equal([MeterEventType.Note], MeterEventRules.RecordableFor(mode));
[Fact]
public void Correction_is_never_offered_because_nothing_reads_it()
{
foreach (var mode in Enum.GetValues<MeterMode>())
{
Assert.DoesNotContain(MeterEventType.Correction, MeterEventRules.RecordableFor(mode));
}
}
}

Some files were not shown because too many files have changed in this diff Show More