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
This commit is contained in:
2026-08-01 10:25:05 +02:00
parent 1f575c9da2
commit af786c7b28
8 changed files with 782 additions and 12 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**. MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
**Status: implemented (M0M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo. **Status: implemented (M0M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
## Source of truth ## Source of truth
+1 -1
View File
@@ -45,7 +45,7 @@ public static class ApiEndpoints
{ {
// Normalize once per meter after the batch, not per reading: a recompute rewrites the // 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. // 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, ct)) switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, renormalize: false, cancellationToken: ct))
{ {
case IngestionOutcome.Written: written++; touched.Add(r.MeterId); break; case IngestionOutcome.Written: written++; touched.Add(r.MeterId); break;
case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break; case IngestionOutcome.Updated: updated++; touched.Add(r.MeterId); break;
+326 -4
View File
@@ -5,7 +5,10 @@
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@inject NavigationManager Nav @inject NavigationManager Nav
@inject IServiceScopeFactory Scopes
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.DependencyInjection
@using MeterVault.Infrastructure.Ingestion @using MeterVault.Infrastructure.Ingestion
@using MudBlazor @using MudBlazor
@@ -24,6 +27,18 @@
} }
else else
{ {
@* Big touch targets and tabular digits for the manual-reading dialog: it is used standing at a
meter on a phone, where the default input sizes are fiddly. *@
<style>
.mv-reading-value input { font-size: 1.9rem; text-align: right; font-variant-numeric: tabular-nums; }
/* nowrap pins it to exactly two lines, so a long unit or a big delta cannot spill over and
move the keypad; the full wording is repeated in the alert below the fold. */
.mv-reading-verdict { display: flex; flex-direction: column; min-height: 2.6rem; }
.mv-reading-verdict > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mv-keypad { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
.mv-keypad .mud-button { height: 56px; font-size: 1.35rem; }
</style>
<div class="d-flex align-center mb-4" style="gap:.75rem"> <div class="d-flex align-center mb-4" style="gap:.75rem">
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" /> <MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
<MudText Typo="Typo.h4">@_detail.Name</MudText> <MudText Typo="Typo.h4">@_detail.Name</MudText>
@@ -138,20 +153,38 @@ else
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2"> <MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")"> <MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
@if (_detail.Mode == MeterMode.Virtual)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
A virtual meter is an expression over other meters, so it stores no readings of its own —
enter the reading on the meter the expression refers to.
</MudText>
}
else
{
<div class="d-flex justify-end mb-2">
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add" OnClick="OpenReading">
Add reading
</MudButton>
</div>
}
@if (_detail.RecentReadings.Count == 0) @if (_detail.RecentReadings.Count == 0)
{ {
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
} }
else else
{ {
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText> <MudText Typo="Typo.caption" Color="Color.Secondary">
Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id.
</MudText>
<MudSimpleTable Dense="true" Hover="true" Class="mt-2"> <MudSimpleTable Dense="true" Hover="true" Class="mt-2">
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead> <thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
<tbody> <tbody>
@foreach (var r in _detail.RecentReadings) @foreach (var r in _detail.RecentReadings)
{ {
<tr> <tr>
<td>@r.Time.ToString("yyyy-MM-dd HH:mm")</td> <td>@Local(r.Time).ToString("yyyy-MM-dd HH:mm")</td>
<td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td> <td style="text-align:right">@Format.Number(r.Value, 2) @_detail.Unit</td>
<td>@QualityChip(r.Quality)</td> <td>@QualityChip(r.Quality)</td>
<td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td> <td>@(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
@@ -176,7 +209,7 @@ else
@foreach (var c in _detail.RecentConsumption) @foreach (var c in _detail.RecentConsumption)
{ {
<tr> <tr>
<td>@c.Time.ToString("yyyy-MM-dd HH:mm")</td> <td>@Local(c.Time).ToString("yyyy-MM-dd HH:mm")</td>
<td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td> <td style="text-align:right">@Format.Number(c.Amount, 2) @_detail.Unit</td>
<td>@c.Kind</td> <td>@c.Kind</td>
<td>@QualityChip(c.Quality)</td> <td>@QualityChip(c.Quality)</td>
@@ -200,7 +233,7 @@ else
@foreach (var e in _detail.Events) @foreach (var e in _detail.Events)
{ {
<tr> <tr>
<td>@e.Time.ToString("yyyy-MM-dd")</td> <td>@Local(e.Time).ToString("yyyy-MM-dd")</td>
<td>@e.Type</td> <td>@e.Type</td>
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td> <td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td> <td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
@@ -288,6 +321,93 @@ else
</MudTabPanel> </MudTabPanel>
</MudTabs> </MudTabs>
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">Add reading — @_detail.Name</MudText>
</TitleContent>
<DialogContent>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mb-2">@LastReadingCaption()</MudText>
<MudTextField T="string" Value="_entry.Text" ValueChanged="OnReadingTyped" Immediate="true"
Label="@($"Reading ({_detail.Unit})")" Variant="Variant.Outlined"
InputMode="DecimalKeyboard" Class="mv-reading-value" Clearable="true" />
@* Fixed height, and above the keypad on purpose. The verdict on a value has to be visible
while it is being typed — the keypad pushes anything below it off a phone screen — but
anything that grows or shrinks here would move the keys out from under the user's
thumb mid-entry. So the slot is always the same size whether or not it says anything. *@
<div class="mv-reading-verdict mt-1 mb-3">
<MudText Typo="Typo.caption" Color="@(_entry.Value is null ? Color.Error : Color.Secondary)">
@(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : "Enter a value")
</MudText>
@if (ChangeSinceLast is { } change)
{
<MudText Typo="Typo.caption" Color="@(WouldBeRejected ? Color.Warning : Color.Secondary)">
@ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "")
</MudText>
}
</div>
<div class="mv-keypad mb-3">
@foreach (var key in Keypad)
{
var pressed = key;
<MudButton Variant="Variant.Outlined" OnClick="@(() => PressKey(pressed))">@pressed</MudButton>
}
</div>
<div class="d-flex align-center flex-wrap" style="gap:.75rem">
<MudDatePicker @bind-Date="_readingDate" Label="Date" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:150px" />
<MudTimePicker @bind-Time="_readingTime" Label="Time" Variant="Variant.Outlined"
Class="flex-grow-1" Style="min-width:130px" />
<MudButton Size="Size.Small" Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.Schedule" OnClick="SetNow">Now</MudButton>
</div>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">Local time in @_tz.Id.</MudText>
@* Everything below here can reflow freely: the dialog's buttons sit outside this scroll
area, so nothing the user is aiming at moves. *@
@if (EnteredTimeSkipped)
{
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time.
</MudAlert>
}
@if (WouldBeRejected)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
Below the last reading (@Format.Number(_detail.LastReadingValue ?? 0, 2) @_detail.Unit) on a
register that only counts up, so it will be rejected. If the meter was swapped or reset,
record that on the Events tab first.
</MudAlert>
}
@if (ReplacesRecentReading)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
This meter already has a reading at that time — saving replaces its value.
</MudAlert>
}
@if (IsFuture)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">That time is in the future.</MudAlert>
}
else if (IsBackdated)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
Backdated before the latest reading — consumption from there on is recomputed.
</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
@(_readingSaving ? "Saving…" : "Save reading")
</MudButton>
</DialogActions>
</MudDialog>
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions"> <MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText> <MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
@@ -366,11 +486,34 @@ else
private SourceEdit _sourceEdit = new(); private SourceEdit _sourceEdit = new();
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
private bool _readingOpen;
private bool _readingSaving;
private readonly ReadingEntry _entry = new();
private DateTime? _readingDate;
private TimeSpan? _readingTime;
private TimeZoneInfo _tz = TimeZoneInfo.Utc;
/// <summary>Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace.</summary>
private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"];
/// <summary>
/// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because
/// <c>decimal</c> is a C# keyword and Razor would read the required <c>@</c> escape in an
/// attribute as a transition.
/// </summary>
private const InputMode DecimalKeyboard = InputMode.@decimal;
private static readonly MeterMode[] MonotonicModes =
[MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter];
protected override void OnInitialized() => _tz = ResolveTimeZone(Options.Value.TimeZone);
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
_detail = null; _detail = null;
_periods = null; _periods = null;
_notFound = false; _notFound = false;
_readingOpen = false;
_detail = await Details.GetAsync(Id); _detail = await Details.GetAsync(Id);
_notFound = _detail is null; _notFound = _detail is null;
if (_detail is not null) if (_detail is not null)
@@ -380,6 +523,22 @@ else
} }
} }
// Readings are stored UTC (SDD §10) and shown in the instance timezone, so a value entered at
// 18:00 reads back as 18:00 rather than as its UTC instant.
private static TimeZoneInfo ResolveTimeZone(string id)
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
return TimeZoneInfo.Utc;
}
}
private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz);
/// <summary> /// <summary>
/// "+12%" / "4%" against the previous period. Less is better for consumption and worse for /// "+12%" / "4%" against the previous period. Less is better for consumption and worse for
/// generation, so colour is left to the caller's context rather than hardcoded green/red here. /// generation, so colour is left to the caller's context rather than hardcoded green/red here.
@@ -406,6 +565,169 @@ else
+ "background:var(--mud-palette-primary); border-radius:2px 2px 0 0"; + "background:var(--mud-palette-primary); border-radius:2px 2px 0 0";
} }
private void OpenReading()
{
if (_detail is null)
{
return;
}
SetNow();
// Prefilling the last reading is what makes this quick standing at the meter: a register only
// moves in its final digits, so backspace-and-retype beats keying six digits from scratch.
// Falls back to the configured baseline while the meter has no readings at all.
_entry.Prefill(_detail.LastReadingValue ?? _detail.InitialBaseline);
_readingOpen = true;
}
private void SetNow()
{
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, _tz);
_readingDate = now.Date;
_readingTime = new TimeSpan(now.Hour, now.Minute, 0);
}
private void OnReadingTyped(string? value) => _entry.SetText(value);
private void PressKey(string key)
{
switch (key)
{
case "⌫":
_entry.Backspace();
break;
case ",":
_entry.AppendSeparator();
break;
default:
_entry.AppendDigit(key[0]);
break;
}
}
private string LastReadingCaption()
{
if (_detail is not { } detail)
{
return string.Empty;
}
return detail is { LastReadingValue: { } value, LastReadingTime: { } time }
? $"Last reading {Format.Number(value, 2)} {detail.Unit} on {Local(time):yyyy-MM-dd HH:mm}."
: $"No readings yet — prefilled with this meter's baseline ({Format.Number(detail.InitialBaseline, 2)} {detail.Unit}).";
}
/// <summary>The wall-clock instant the two pickers describe, read in the instance timezone.</summary>
private DateTime? EnteredWallClock =>
_readingDate is { } date ? date.Date + (_readingTime ?? TimeSpan.Zero) : null;
/// <summary>
/// True when the chosen local time falls in a spring-forward gap and so names no instant at all.
/// Converting it would throw, so the dialog blocks the save and says why instead.
/// </summary>
private bool EnteredTimeSkipped =>
EnteredWallClock is { } wall && _tz.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified));
/// <remarks>
/// An ambiguous autumn hour resolves to standard time, <see cref="TimeZoneInfo"/>'s default. The
/// two candidate instants are an hour apart on one hour of one night a year — well inside the
/// precision of a timestamp somebody typed by hand.
/// </remarks>
private DateTimeOffset? EnteredUtc
{
get
{
if (EnteredWallClock is not { } wall || EnteredTimeSkipped)
{
return null;
}
var unspecified = DateTime.SpecifyKind(wall, DateTimeKind.Unspecified);
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(unspecified, _tz), TimeSpan.Zero);
}
}
private bool IsMonotonic => _detail is not null && Array.IndexOf(MonotonicModes, _detail.Mode) >= 0;
private bool IsBackdated => EnteredUtc is { } entered && _detail?.LastReadingTime is { } last && entered < last;
// A minute of slack so "now" never trips the future warning on a slow round trip.
private bool IsFuture => EnteredUtc is { } entered && entered > DateTimeOffset.UtcNow.AddMinutes(1);
private double? ChangeSinceLast =>
!IsBackdated && _entry.Value is { } value && _detail?.LastReadingValue is { } last ? value - last : null;
/// <summary>
/// Mirrors the ingestion guard closely enough to warn before saving rather than after. The
/// service compares against the reading immediately before the entered time; this page only
/// holds the latest one, so a backdated entry gets no verdict rather than a wrong one.
/// </summary>
private bool WouldBeRejected =>
IsMonotonic && !IsBackdated && _entry.Value is { } value
&& _detail?.LastReadingValue is { } last && value < last;
/// <summary>
/// Whether saving would overwrite a reading the page already lists. Bounded to the loaded rows,
/// so it is a heads-up rather than a guarantee — the save reports what actually happened.
/// </summary>
private bool ReplacesRecentReading =>
EnteredUtc is { } entered && _detail is not null && _detail.RecentReadings.Any(r => r.Time == entered);
private bool CanSaveReading =>
!_readingSaving && _entry.Value is not null && EnteredWallClock is not null && !EnteredTimeSkipped;
private string ChangeSinceText(double change) =>
Math.Abs(change) < 1e-9
? "no change since last reading"
: $"{(change > 0 ? "+" : "")}{Format.Number(Math.Abs(change), 2)} {_detail?.Unit} since last reading";
private async Task SaveReadingAsync()
{
if (_detail is null || _entry.Value is not { } value || EnteredUtc is not { } utc)
{
return;
}
_readingSaving = true;
try
{
// A scope per operation: IngestionService holds a scoped DbContext, and a Blazor circuit
// long outlives the unit of work a single save should share one with.
await using var scope = Scopes.CreateAsyncScope();
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
var outcome = await ingestion.IngestByMeterAsync(
Id, utc, value, renormalize: true, quality: ReadingQuality.Manual);
switch (outcome)
{
case IngestionOutcome.Written:
Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
break;
case IngestionOutcome.Updated:
Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success);
break;
case IngestionOutcome.RejectedDecrease:
// Leave the dialog open: the typed value is still on screen to correct, and the
// alternative fix — recording a reset or swap — is a decision, not a retry.
Snackbar.Add(
"Rejected — below the previous reading on a register that only counts up. "
+ "Record a counter reset or meter swap first.", Severity.Error);
return;
default:
Snackbar.Add("This meter no longer exists.", Severity.Error);
return;
}
_readingOpen = false;
_detail = await Details.GetAsync(Id);
_periods = _detail is null ? null : await Periods.GetAsync(Id);
}
finally
{
_readingSaving = false;
}
}
private async Task LoadSourcesAsync() private async Task LoadSourcesAsync()
{ {
await using var db = await DbFactory.CreateDbContextAsync(); await using var db = await DbFactory.CreateDbContextAsync();
+198
View File
@@ -0,0 +1,198 @@
using System.Globalization;
using System.Text;
namespace MeterVault.App;
/// <summary>
/// The edit buffer behind the manual-reading keypad. Holds the value as text rather than a number
/// so a half-typed entry ("12345," while the decimals are still coming) is representable and no
/// intermediate state gets rounded away by a numeric binding.
/// </summary>
/// <remarks>
/// <para>
/// Separator handling is deliberately lenient rather than culture-strict: the same field is driven
/// by the on-screen keypad (always a comma), by an Android keyboard (comma on a German locale, dot
/// on an English one) and by a desktop numpad, so a value has to survive either character. The rule
/// is that only the <em>last</em> separator is decimal and earlier ones are grouping — "1.234,5"
/// and "1,234.5" both give 1234.5. A lone separator is therefore always decimal ("1.234" → 1.234),
/// which is what the keypad emits and what an English keyboard means; nobody types thousands
/// separators into a meter register, and the dialog echoes the parsed value back formatted, so a
/// misread is visible before saving. This differs from
/// <see cref="Core.Parsing.GermanNumber"/> on purpose: that one parses spreadsheet exports, where a
/// lone dot really is a thousands separator.
/// </para>
/// <para>
/// <see cref="IsPristine"/> gives the buffer calculator behaviour. The dialog opens prefilled with
/// the meter's last reading; the first digit key then replaces it outright (a fresh reading), while
/// backspace edits it in place (only the last few digits of a register usually move). Without that
/// distinction one of the two workflows always costs a full retype on a phone.
/// </para>
/// </remarks>
public sealed class ReadingEntry
{
/// <summary>Wide enough for any real register plus decimals; stops a stuck key growing the string.</summary>
public const int MaxLength = 18;
private const char Separator = ',';
public string Text { get; private set; } = string.Empty;
/// <summary>True while the buffer still holds the untouched prefill, so the next digit replaces it.</summary>
public bool IsPristine { get; private set; }
/// <summary>The entered number, or null while the buffer is empty or not yet a valid number.</summary>
public double? Value => TryParse(Text, out var value) ? value : null;
/// <summary>Seeds the buffer with a meter's last reading, marked pristine.</summary>
public void Prefill(double value)
{
// "0.###" keeps a register readable (12345,6) without inventing precision the meter
// never had; invariant then swapped so the buffer only ever contains one separator glyph.
Text = value.ToString("0.###", CultureInfo.InvariantCulture).Replace('.', Separator);
IsPristine = true;
}
public void AppendDigit(char digit)
{
if (!char.IsAsciiDigit(digit))
{
return;
}
ReplacePrefillOnFirstKey();
// A leading zero is never meaningful on a register, and letting it stand makes "0" then "5"
// read as "05" — replace it instead, exactly like a calculator.
if (Text == "0")
{
Text = digit.ToString();
return;
}
if (Text.Length < MaxLength)
{
Text += digit;
}
}
public void AppendSeparator()
{
ReplacePrefillOnFirstKey();
if (Text.Contains(Separator, StringComparison.Ordinal))
{
return;
}
// "," alone parses as nothing, so lead with the zero the user means.
Text = Text.Length == 0 ? "0" + Separator : Text + Separator;
}
/// <summary>
/// Deletes the last character. Unlike a digit key this keeps the prefill rather than clearing
/// it — reading a register usually means correcting its final digits, not retyping all of them.
/// </summary>
public void Backspace()
{
IsPristine = false;
if (Text.Length > 0)
{
Text = Text[..^1];
}
}
public void Clear()
{
Text = string.Empty;
IsPristine = false;
}
/// <summary>
/// Accepts free text from the keyboard-bound field, keeping only characters that can form a
/// number. Junk is dropped rather than rejected so typing never dead-ends mid-value.
/// </summary>
public void SetText(string? raw)
{
IsPristine = false;
if (string.IsNullOrEmpty(raw))
{
Text = string.Empty;
return;
}
var builder = new StringBuilder(Math.Min(raw.Length, MaxLength));
foreach (var c in raw)
{
if (builder.Length >= MaxLength)
{
break;
}
if (char.IsAsciiDigit(c) || c is '.' or ',' || (c == '-' && builder.Length == 0))
{
builder.Append(c);
}
}
Text = builder.ToString();
}
/// <summary>Parses a buffer as described on the type: last separator decimal, earlier ones grouping.</summary>
public static bool TryParse(string? text, out double value)
{
value = 0;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var raw = text.Trim();
var negative = raw[0] == '-';
if (negative || raw[0] == '+')
{
raw = raw[1..];
}
var lastSeparator = raw.LastIndexOfAny(['.', ',']);
var builder = new StringBuilder(raw.Length);
var digits = 0;
for (var i = 0; i < raw.Length; i++)
{
var c = raw[i];
if (char.IsAsciiDigit(c))
{
builder.Append(c);
digits++;
}
else if (c is '.' or ',')
{
if (i == lastSeparator)
{
builder.Append('.');
}
}
else
{
return false;
}
}
if (digits == 0
|| !double.TryParse(builder.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed))
{
return false;
}
value = negative ? -parsed : parsed;
return true;
}
private void ReplacePrefillOnFirstKey()
{
if (IsPristine)
{
Text = string.Empty;
IsPristine = false;
}
}
}
@@ -56,21 +56,26 @@ 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, 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"> /// <param name="renormalize">
/// False to skip deriving consumption, for callers ingesting a batch into one meter: recomputing /// 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 /// 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. /// caller must recompute the affected meters itself once the batch is in.
/// </param> /// </param>
/// <param name="quality">
/// Provenance to stamp on the row. Null keeps the default for a new row and leaves an existing
/// row's quality alone — a source re-reporting a timestamp must not silently relabel a reading
/// somebody entered by hand or that came from an import.
/// </param>
public async Task<IngestionOutcome> IngestByMeterAsync( public async Task<IngestionOutcome> IngestByMeterAsync(
int meterId, DateTimeOffset time, double value, bool renormalize = true, int meterId, DateTimeOffset time, double value, bool renormalize = true,
CancellationToken cancellationToken = default) ReadingQuality? quality = null, 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);
@@ -86,7 +91,7 @@ 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, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (renormalize) if (renormalize)
{ {
@@ -161,7 +166,8 @@ public sealed class IngestionService(
} }
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,
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);
@@ -174,13 +180,18 @@ public sealed class IngestionService(
Time = utc, Time = utc,
Value = value, Value = value,
SourceId = sourceId, SourceId = sourceId,
Quality = ReadingQuality.Measured, Quality = quality ?? ReadingQuality.Measured,
}); });
return IngestionOutcome.Written; return IngestionOutcome.Written;
} }
existing.Value = value; existing.Value = value;
existing.SourceId = sourceId ?? existing.SourceId; existing.SourceId = sourceId ?? existing.SourceId;
if (quality is { } stamp)
{
existing.Quality = stamp;
}
return IngestionOutcome.Updated; return IngestionOutcome.Updated;
} }
@@ -105,6 +105,12 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
var response = await client.GetAsync(new Uri(path, UriKind.Relative)); var response = await client.GetAsync(new Uri(path, UriKind.Relative));
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
// Manual entry is reachable without an API key or a CSV: the Readings tab of a real
// (non-virtual) meter offers it, prefilled with that meter's last register value.
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
.Content.ReadAsStringAsync();
Assert.Contains("Add reading", meterPage, StringComparison.Ordinal);
} }
finally finally
{ {
@@ -199,6 +199,69 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
await CleanupAsync(db, meterId); await CleanupAsync(db, meterId);
} }
[Fact]
public async Task A_hand_entered_reading_is_stamped_manual_and_normalizes_immediately()
{
// The meter-detail "Add reading" path: provenance has to survive, otherwise a value somebody
// walked to the meter to read is indistinguishable from one a sensor reported.
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
var written = await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.Written, written);
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(ReadingQuality.Manual, reading.Quality);
Assert.True(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
// Correcting a typo re-enters the same timestamp: value replaced, still manual.
var updated = await service.IngestByMeterAsync(meterId, T0, 1100, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.Updated, updated);
var corrected = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(1100d, corrected.Value, 6);
Assert.Equal(ReadingQuality.Manual, corrected.Quality);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_source_reporting_the_same_timestamp_does_not_relabel_a_hand_entered_reading()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
await service.IngestAsync(sourceId, T0, 1200); // same instant, this time from the broker
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(1200d, reading.Value, 6); // the newer value still wins...
Assert.Equal(ReadingQuality.Manual, reading.Quality); // ...but provenance is not silently rewritten
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_hand_entered_decrease_on_a_counter_is_rejected_like_any_other()
{
// The dialog warns before saving, but the guard is what actually protects the series: a
// mistyped register must not silently wipe out a month of consumption.
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
var outcome = await service.IngestByMeterAsync(
meterId, T0.AddDays(30), 100, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == T0.AddDays(30)));
await CleanupAsync(db, meterId);
}
private static IngestionService NewIngestion(MeterVaultDbContext db) => private static IngestionService NewIngestion(MeterVaultDbContext db) =>
new(db, new MeterVault.Infrastructure.Normalization.NormalizationService( new(db, new MeterVault.Infrastructure.Normalization.NormalizationService(
db, MeterVault.Core.Normalization.NormalizationEngine.CreateDefault())); db, MeterVault.Core.Normalization.NormalizationEngine.CreateDefault()));
@@ -0,0 +1,170 @@
using MeterVault.App;
namespace MeterVault.Integration.Tests;
/// <summary>
/// The manual-reading keypad buffer: the pure part of entering a reading by hand at the meter.
/// No database — these are the rules that decide what number gets stored, so they are pinned here
/// rather than left to a render test.
/// </summary>
public sealed class ReadingEntryTests
{
[Theory]
// The keypad's own output, and what a German keyboard produces.
[InlineData("12345,6", 12345.6)]
[InlineData("12345,", 12345)]
// An English keyboard: a lone dot is decimal, because nobody groups thousands on a register.
[InlineData("12345.6", 12345.6)]
[InlineData("1.234", 1.234)]
// Both separators present: the last one is decimal, whichever dialect it came from.
[InlineData("1.234,5", 1234.5)]
[InlineData("1,234.5", 1234.5)]
[InlineData("1.234.567,89", 1234567.89)]
[InlineData("0", 0)]
[InlineData("-12,5", -12.5)]
public void Parses_either_dialect_taking_the_last_separator_as_decimal(string text, double expected)
{
Assert.True(ReadingEntry.TryParse(text, out var value));
Assert.Equal(expected, value, 9);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(",")]
[InlineData("-")]
[InlineData("12 345")]
[InlineData("abc")]
public void Rejects_input_that_is_not_a_number(string text) =>
Assert.False(ReadingEntry.TryParse(text, out _));
[Fact]
public void Prefill_shows_the_last_reading_in_the_dialect_the_keypad_types()
{
var entry = new ReadingEntry();
entry.Prefill(12345.6);
Assert.Equal("12345,6", entry.Text);
Assert.True(entry.IsPristine);
Assert.Equal(12345.6, entry.Value!.Value, 9);
}
[Fact]
public void Prefill_does_not_invent_precision_the_meter_never_reported()
{
var entry = new ReadingEntry();
entry.Prefill(2287);
Assert.Equal("2287", entry.Text);
}
[Fact]
public void First_digit_after_a_prefill_starts_a_fresh_value()
{
// Reading a completely different register: typing must not append to the prefill.
var entry = new ReadingEntry();
entry.Prefill(12345.6);
entry.AppendDigit('9');
Assert.Equal("9", entry.Text);
Assert.False(entry.IsPristine);
}
[Fact]
public void Backspace_after_a_prefill_edits_it_in_place()
{
// The walk-up case: only the last digits of a register have moved, so backspace keeps the
// leading digits instead of clearing them the way a digit key does.
var entry = new ReadingEntry();
entry.Prefill(12345.6);
entry.Backspace(); // "12345,"
entry.Backspace(); // "12345"
entry.AppendDigit('8');
entry.AppendSeparator();
entry.AppendDigit('9');
Assert.Equal("123458,9", entry.Text);
Assert.Equal(123458.9, entry.Value!.Value, 9);
}
[Fact]
public void Accepts_at_most_one_separator()
{
var entry = new ReadingEntry();
entry.AppendDigit('1');
entry.AppendSeparator();
entry.AppendSeparator();
entry.AppendDigit('5');
Assert.Equal("1,5", entry.Text);
}
[Fact]
public void A_leading_separator_becomes_a_leading_zero()
{
var entry = new ReadingEntry();
entry.AppendSeparator();
entry.AppendDigit('5');
Assert.Equal("0,5", entry.Text);
Assert.Equal(0.5, entry.Value!.Value, 9);
}
[Fact]
public void A_leading_zero_is_replaced_rather_than_kept()
{
var entry = new ReadingEntry();
entry.AppendDigit('0');
entry.AppendDigit('7');
Assert.Equal("7", entry.Text);
}
[Fact]
public void Clear_empties_the_buffer_and_leaves_no_value()
{
var entry = new ReadingEntry();
entry.Prefill(500);
entry.Clear();
Assert.Equal(string.Empty, entry.Text);
Assert.False(entry.IsPristine);
Assert.Null(entry.Value);
}
[Fact]
public void Backspace_on_an_empty_buffer_is_a_no_op()
{
var entry = new ReadingEntry();
entry.Backspace();
Assert.Equal(string.Empty, entry.Text);
Assert.Null(entry.Value);
}
[Fact]
public void Typed_text_keeps_only_what_can_form_a_number()
{
var entry = new ReadingEntry();
entry.Prefill(1);
entry.SetText("1 234,5 kWh");
Assert.Equal("1234,5", entry.Text);
Assert.False(entry.IsPristine);
Assert.Equal(1234.5, entry.Value!.Value, 9);
}
[Fact]
public void A_stuck_key_cannot_grow_the_buffer_without_bound()
{
var entry = new ReadingEntry();
for (var i = 0; i < ReadingEntry.MaxLength + 10; i++)
{
entry.AppendDigit('9');
}
Assert.Equal(ReadingEntry.MaxLength, entry.Text.Length);
}
}