diff --git a/CLAUDE.md b/CLAUDE.md index 17887cf..a0ceb5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**. -**Status: implemented (M0–M7) + 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 (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). **Manual readings** are entered from the meter-detail Readings tab ("Add reading"): a touch-first dialog prefilled with the meter's last register value and the current local time, with an on-screen keypad for phone entry at the meter, a live parsed-value + delta-since-last readout, and the decrease guard surfaced before saving. It goes through `IngestionService.IngestByMeterAsync(quality: Manual)`, so it is stamped `ReadingQuality.Manual` and renormalizes inline like any other ingest — the layout of that dialog deliberately reserves fixed space for its verdict line, because anything that reflows moves the keys out from under the user's thumb mid-entry. `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll, or — with the connector's **WebSocket push** toggle (`HaEndpointConfig.UseWebSocket`) — `HomeAssistantWebSocketWorker` holds a persistent `state_changed` subscription and ingests in real time (the poll worker skips WS endpoints, so each is served once; `HaWebSocketProtocol` is the pure, unit-tested handshake/parse logic). `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. **CSV mapping wizard** (`/import/wizard`): upload an arbitrary CSV, map columns → meters/roles, dry-run preview, then commit as a revertible `import_batch` (the `/import` page lists batches with one-click revert). The `instant_rate` mode is normalized (`InstantRateNormalizer`: rate integrated over time, trapezoidal). Remaining refinement: full de-DE UI-string localization (data parsing is already de-DE) — noted at its commit. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo. ## Source of truth diff --git a/src/App/Api/ApiEndpoints.cs b/src/App/Api/ApiEndpoints.cs index 6bed5d0..18403f6 100644 --- a/src/App/Api/ApiEndpoints.cs +++ b/src/App/Api/ApiEndpoints.cs @@ -45,7 +45,7 @@ public static class ApiEndpoints { // 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, 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.Updated: updated++; touched.Add(r.MeterId); break; diff --git a/src/App/Components/Pages/MeterDetail.razor b/src/App/Components/Pages/MeterDetail.razor index d3f8dee..e9ff6e1 100644 --- a/src/App/Components/Pages/MeterDetail.razor +++ b/src/App/Components/Pages/MeterDetail.razor @@ -5,7 +5,10 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Nav +@inject IServiceScopeFactory Scopes +@inject Microsoft.Extensions.Options.IOptions Options @using Microsoft.EntityFrameworkCore +@using Microsoft.Extensions.DependencyInjection @using MeterVault.Infrastructure.Ingestion @using MudBlazor @@ -24,6 +27,18 @@ } 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. *@ + +
@_detail.Name @@ -138,20 +153,38 @@ else + @if (_detail.Mode == MeterMode.Virtual) + { + + 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. + + } + else + { +
+ + Add reading + +
+ } @if (_detail.RecentReadings.Count == 0) { No raw readings. } else { - Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). + + Most recent @_detail.RecentReadings.Count (raw, immutable audit truth). Times in @_tz.Id. + TimeValueQualityFlags @foreach (var r in _detail.RecentReadings) { - @r.Time.ToString("yyyy-MM-dd HH:mm") + @Local(r.Time).ToString("yyyy-MM-dd HH:mm") @Format.Number(r.Value, 2) @_detail.Unit @QualityChip(r.Quality) @(r.Flags == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString()) @@ -176,7 +209,7 @@ else @foreach (var c in _detail.RecentConsumption) { - @c.Time.ToString("yyyy-MM-dd HH:mm") + @Local(c.Time).ToString("yyyy-MM-dd HH:mm") @Format.Number(c.Amount, 2) @_detail.Unit @c.Kind @QualityChip(c.Quality) @@ -200,7 +233,7 @@ else @foreach (var e in _detail.Events) { - @e.Time.ToString("yyyy-MM-dd") + @Local(e.Time).ToString("yyyy-MM-dd") @e.Type @(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—") @(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—") @@ -288,6 +321,93 @@ else
+ + + Add reading — @_detail.Name + + + @LastReadingCaption() + + + + @* 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. *@ +
+ + @(_entry.Value is { } parsed ? $"= {Format.Number(parsed, 3)} {_detail.Unit}" : "Enter a value") + + @if (ChangeSinceLast is { } change) + { + + @ChangeSinceText(change)@(WouldBeRejected ? " — will be rejected" : "") + + } +
+ +
+ @foreach (var key in Keypad) + { + var pressed = key; + @pressed + } +
+ +
+ + + Now +
+ Local time in @_tz.Id. + + @* 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) + { + + That clock time never happened in @_tz.Id — the clocks moved forward. Pick another time. + + } + @if (WouldBeRejected) + { + + 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. + + } + @if (ReplacesRecentReading) + { + + This meter already has a reading at that time — saving replaces its value. + + } + @if (IsFuture) + { + That time is in the future. + } + else if (IsBackdated) + { + + Backdated before the latest reading — consumption from there on is recomputed. + + } +
+ + Cancel + + @(_readingSaving ? "Saving…" : "Save reading") + + +
+ @(_sourceEdit.Id == 0 ? "New source" : "Edit source") @@ -366,11 +486,34 @@ else private SourceEdit _sourceEdit = new(); private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; + private bool _readingOpen; + private bool _readingSaving; + private readonly ReadingEntry _entry = new(); + private DateTime? _readingDate; + private TimeSpan? _readingTime; + private TimeZoneInfo _tz = TimeZoneInfo.Utc; + + /// Phone-dialpad order, ending in the row the thumb reaches last: separator, zero, backspace. + private static readonly string[] Keypad = ["7", "8", "9", "4", "5", "6", "1", "2", "3", ",", "0", "⌫"]; + + /// + /// A decimal keyboard, so a phone offers the separator. Hoisted out of the markup because + /// decimal is a C# keyword and Razor would read the required @ escape in an + /// attribute as a transition. + /// + private const InputMode DecimalKeyboard = InputMode.@decimal; + + private static readonly MeterMode[] MonotonicModes = + [MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter]; + + protected override void OnInitialized() => _tz = ResolveTimeZone(Options.Value.TimeZone); + protected override async Task OnParametersSetAsync() { _detail = null; _periods = null; _notFound = false; + _readingOpen = false; _detail = await Details.GetAsync(Id); _notFound = _detail is null; if (_detail is not null) @@ -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); + /// /// "+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. @@ -406,6 +565,169 @@ else + "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})."; + } + + /// The wall-clock instant the two pickers describe, read in the instance timezone. + private DateTime? EnteredWallClock => + _readingDate is { } date ? date.Date + (_readingTime ?? TimeSpan.Zero) : null; + + /// + /// 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. + /// + private bool EnteredTimeSkipped => + EnteredWallClock is { } wall && _tz.IsInvalidTime(DateTime.SpecifyKind(wall, DateTimeKind.Unspecified)); + + /// + /// An ambiguous autumn hour resolves to standard time, '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. + /// + 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; + + /// + /// 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. + /// + private bool WouldBeRejected => + IsMonotonic && !IsBackdated && _entry.Value is { } value + && _detail?.LastReadingValue is { } last && value < last; + + /// + /// 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. + /// + 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(); + var outcome = await ingestion.IngestByMeterAsync( + Id, utc, value, renormalize: true, quality: ReadingQuality.Manual); + + switch (outcome) + { + case IngestionOutcome.Written: + Snackbar.Add($"Reading saved: {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success); + break; + case IngestionOutcome.Updated: + Snackbar.Add($"Replaced the reading at that time with {Format.Number(value, 2)} {_detail.Unit}.", Severity.Success); + break; + case IngestionOutcome.RejectedDecrease: + // Leave the dialog open: the typed value is still on screen to correct, and the + // alternative fix — recording a reset or swap — is a decision, not a retry. + Snackbar.Add( + "Rejected — below the previous reading on a register that only counts up. " + + "Record a counter reset or meter swap first.", Severity.Error); + return; + default: + Snackbar.Add("This meter no longer exists.", Severity.Error); + return; + } + + _readingOpen = false; + _detail = await Details.GetAsync(Id); + _periods = _detail is null ? null : await Periods.GetAsync(Id); + } + finally + { + _readingSaving = false; + } + } + private async Task LoadSourcesAsync() { await using var db = await DbFactory.CreateDbContextAsync(); diff --git a/src/App/ReadingEntry.cs b/src/App/ReadingEntry.cs new file mode 100644 index 0000000..6965eb4 --- /dev/null +++ b/src/App/ReadingEntry.cs @@ -0,0 +1,198 @@ +using System.Globalization; +using System.Text; + +namespace MeterVault.App; + +/// +/// 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. +/// +/// +/// +/// 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 last 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 +/// on purpose: that one parses spreadsheet exports, where a +/// lone dot really is a thousands separator. +/// +/// +/// 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. +/// +/// +public sealed class ReadingEntry +{ + /// Wide enough for any real register plus decimals; stops a stuck key growing the string. + public const int MaxLength = 18; + + private const char Separator = ','; + + public string Text { get; private set; } = string.Empty; + + /// True while the buffer still holds the untouched prefill, so the next digit replaces it. + public bool IsPristine { get; private set; } + + /// The entered number, or null while the buffer is empty or not yet a valid number. + public double? Value => TryParse(Text, out var value) ? value : null; + + /// Seeds the buffer with a meter's last reading, marked pristine. + 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; + } + + /// + /// 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. + /// + public void Backspace() + { + IsPristine = false; + if (Text.Length > 0) + { + Text = Text[..^1]; + } + } + + public void Clear() + { + Text = string.Empty; + IsPristine = false; + } + + /// + /// 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. + /// + 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(); + } + + /// Parses a buffer as described on the type: last separator decimal, earlier ones grouping. + 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; + } + } +} diff --git a/src/Infrastructure/Ingestion/IngestionService.cs b/src/Infrastructure/Ingestion/IngestionService.cs index 48b5955..dc73e81 100644 --- a/src/Infrastructure/Ingestion/IngestionService.cs +++ b/src/Infrastructure/Ingestion/IngestionService.cs @@ -56,21 +56,26 @@ public sealed class IngestionService( return IngestionOutcome.RejectedDecrease; } - var outcome = await UpsertAsync(meter, utc, value, source.Id, cancellationToken).ConfigureAwait(false); + var outcome = await UpsertAsync(meter, utc, value, source.Id, quality: null, cancellationToken).ConfigureAwait(false); await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false); await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false); return outcome; } - /// Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings). + /// Ingests directly against a meter (REST push, or a hand-entered reading from the UI). /// /// 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. /// + /// + /// 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. + /// public async Task IngestByMeterAsync( int meterId, DateTimeOffset time, double value, bool renormalize = true, - CancellationToken cancellationToken = default) + ReadingQuality? quality = null, CancellationToken cancellationToken = default) { var meter = await _db.Meters .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); @@ -86,7 +91,7 @@ public sealed class IngestionService( return IngestionOutcome.RejectedDecrease; } - var outcome = await UpsertAsync(meter, utc, value, sourceId: null, cancellationToken).ConfigureAwait(false); + var outcome = await UpsertAsync(meter, utc, value, sourceId: null, quality, cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); if (renormalize) { @@ -161,7 +166,8 @@ public sealed class IngestionService( } private async Task UpsertAsync( - Meter meter, DateTimeOffset utc, double value, int? sourceId, CancellationToken cancellationToken) + Meter meter, DateTimeOffset utc, double value, int? sourceId, ReadingQuality? quality, + CancellationToken cancellationToken) { var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc) ?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false); @@ -174,13 +180,18 @@ public sealed class IngestionService( Time = utc, Value = value, SourceId = sourceId, - Quality = ReadingQuality.Measured, + Quality = quality ?? ReadingQuality.Measured, }); return IngestionOutcome.Written; } existing.Value = value; existing.SourceId = sourceId ?? existing.SourceId; + if (quality is { } stamp) + { + existing.Quality = stamp; + } + return IngestionOutcome.Updated; } diff --git a/tests/Integration.Tests/DashboardRenderTests.cs b/tests/Integration.Tests/DashboardRenderTests.cs index af9dc3f..25ee43e 100644 --- a/tests/Integration.Tests/DashboardRenderTests.cs +++ b/tests/Integration.Tests/DashboardRenderTests.cs @@ -105,6 +105,12 @@ public sealed class DashboardRenderTests(TimescaleFixture fx) var response = await client.GetAsync(new Uri(path, UriKind.Relative)); response.EnsureSuccessStatusCode(); } + + // Manual entry is reachable without an API key or a CSV: the Readings tab of a real + // (non-virtual) meter offers it, prefilled with that meter's last register value. + var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative))) + .Content.ReadAsStringAsync(); + Assert.Contains("Add reading", meterPage, StringComparison.Ordinal); } finally { diff --git a/tests/Integration.Tests/Ingestion/IngestionServiceTests.cs b/tests/Integration.Tests/Ingestion/IngestionServiceTests.cs index 0958419..4cbaf6c 100644 --- a/tests/Integration.Tests/Ingestion/IngestionServiceTests.cs +++ b/tests/Integration.Tests/Ingestion/IngestionServiceTests.cs @@ -199,6 +199,69 @@ public sealed class IngestionServiceTests(TimescaleFixture fx) await CleanupAsync(db, meterId); } + [Fact] + public async Task A_hand_entered_reading_is_stamped_manual_and_normalizes_immediately() + { + // The meter-detail "Add reading" path: provenance has to survive, otherwise a value somebody + // walked to the meter to read is indistinguishable from one a sensor reported. + await using var db = fx.CreateContext(); + var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter); + var service = NewIngestion(db); + + var written = await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual); + + Assert.Equal(IngestionOutcome.Written, written); + var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0); + Assert.Equal(ReadingQuality.Manual, reading.Quality); + Assert.True(await db.Consumption.AnyAsync(c => c.MeterId == meterId)); + + // Correcting a typo re-enters the same timestamp: value replaced, still manual. + var updated = await service.IngestByMeterAsync(meterId, T0, 1100, quality: ReadingQuality.Manual); + + Assert.Equal(IngestionOutcome.Updated, updated); + var corrected = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0); + Assert.Equal(1100d, corrected.Value, 6); + Assert.Equal(ReadingQuality.Manual, corrected.Quality); + + await CleanupAsync(db, meterId); + } + + [Fact] + public async Task A_source_reporting_the_same_timestamp_does_not_relabel_a_hand_entered_reading() + { + await using var db = fx.CreateContext(); + var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter); + var service = NewIngestion(db); + + await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual); + await service.IngestAsync(sourceId, T0, 1200); // same instant, this time from the broker + + var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0); + Assert.Equal(1200d, reading.Value, 6); // the newer value still wins... + Assert.Equal(ReadingQuality.Manual, reading.Quality); // ...but provenance is not silently rewritten + + await CleanupAsync(db, meterId); + } + + [Fact] + public async Task A_hand_entered_decrease_on_a_counter_is_rejected_like_any_other() + { + // The dialog warns before saving, but the guard is what actually protects the series: a + // mistyped register must not silently wipe out a month of consumption. + await using var db = fx.CreateContext(); + var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter); + var service = NewIngestion(db); + + await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual); + var outcome = await service.IngestByMeterAsync( + meterId, T0.AddDays(30), 100, quality: ReadingQuality.Manual); + + Assert.Equal(IngestionOutcome.RejectedDecrease, outcome); + Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == T0.AddDays(30))); + + await CleanupAsync(db, meterId); + } + private static IngestionService NewIngestion(MeterVaultDbContext db) => new(db, new MeterVault.Infrastructure.Normalization.NormalizationService( db, MeterVault.Core.Normalization.NormalizationEngine.CreateDefault())); diff --git a/tests/Integration.Tests/ReadingEntryTests.cs b/tests/Integration.Tests/ReadingEntryTests.cs new file mode 100644 index 0000000..1d3d35e --- /dev/null +++ b/tests/Integration.Tests/ReadingEntryTests.cs @@ -0,0 +1,170 @@ +using MeterVault.App; + +namespace MeterVault.Integration.Tests; + +/// +/// 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. +/// +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); + } +}