@page "/meters/{Id:int}" @inject MeterDetailService Details @inject MeterPeriodService Periods @inject Microsoft.EntityFrameworkCore.IDbContextFactory DbFactory @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Nav @inject IServiceScopeFactory Scopes @inject Microsoft.Extensions.Options.IOptions Options @inject ILogger Logger @inject DraftStore Drafts @implements IDisposable @using System.Globalization @using Microsoft.EntityFrameworkCore @using Microsoft.Extensions.DependencyInjection @using MeterVault.Infrastructure.Ingestion @using MudBlazor MeterVault — @(_detail?.Name ?? S.Common_Meter) @if (_detail is null) { @if (_notFound) { @Loc.F(S.MeterDetail_NotFound, Id) @S.MeterDetail_BackToMeters } 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. *@ @* The header carries the meter's actions, above the figures: on a phone the tabs sit a long scroll down, and the things people come here to do — enter a reading, record a swap, fix a setting — should not depend on finding the right tab first. *@
@_detail.Name @_detail.EnergyType @_detail.Mode.Display() @if (!_detail.IsActive) { @S.MeterDetail_Retired }
@if (TakesReadings) { @S.MeterDetail_AddReading } else if (_detail.Mode == MeterMode.ConsumableBalance) { @S.MeterDetail_RecordTankLevel } @foreach (var type in MeterEventRules.RecordableFor(_detail.Mode)) { var chosen = type; @($"{chosen.Display()}…") }
@if (IdentityLine() is { Length: > 0 } identity) { @identity } else {
} @if (_detail.Mode == MeterMode.ConsumableBalance && !_detail.HasTank) { @S.MeterDetail_NoTankConfigured @S.MeterDetail_SetUpTank } else if (IsUnstarted) { @* A brand-new meter has nothing to show yet; say what makes it useful instead of a page of dashes. *@ @S.MeterDetail_GetStarted
@if (TakesReadings) { @S.MeterDetail_AddFirstReading @S.MeterDetail_ConnectSource } else { @S.MeterDetail_RecordTankLevel }
} @if (_periods is { } p) { @Loc.F(S.MeterDetail_LabelThisMonth, p.Kind.Display()) @Format.Number(p.MonthToDate, 0) @p.Unit @if (p.MonthIsPartial) { @Loc.F(S.MeterDetail_ProjectedByMonthEnd, Format.Number(p.MonthProjected, 0), p.Unit) } @S.MeterDetail_VsLastMonth @ChangeText(p.MonthChange) @Loc.F(S.MeterDetail_LastMonthValue, Format.Number(p.LastMonth, 0), p.Unit) @S.Common_ThisYear @Format.Number(p.YearToDate, 0) @p.Unit @Loc.F(S.MeterDetail_VsLastYear, ChangeText(p.YearChange), Format.Number(p.LastYear, 0)) @S.MeterDetail_CostThisYear @Format.Number(p.YearToDateCost, 2) @p.Currency @Loc.F(S.MeterDetail_ProjectedFullYear, Format.Number(p.YearProjectedCost, 0), p.Currency) @(p.LastYearCost > 0 ? Loc.F(S.MeterDetail_LastYearCost, Format.Number(p.LastYearCost, 0)) : "") @if (p.HasHistory) { @S.Common_RangeLast12Months
@foreach (var m in p.Last12Months) {
@m.Month.ToString("MMM")
}
} } @if (_periods is null && _detail.Mode == MeterMode.Virtual) { @S.MeterDetail_VirtualNotice @S.MeterDetail_VirtualNoticeFlow }
@S.MeterDetail_RegisterSpan @(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") → @(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—") @Loc.F(S.MeterDetail_BaselineValue, Format.Number(_detail.InitialBaseline, 0))
@S.MeterDetail_Readings @_detail.ReadingCount · @(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
@S.MeterDetail_LifetimeTotal @Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
@if (_detail.Mode == MeterMode.Virtual) { @S.MeterDetail_VirtualNoReadings } else if (_detail.Mode == MeterMode.ConsumableBalance) { @* A tank's consumption comes from level and delivery events; a reading typed here would save cleanly and change nothing, so the tab sends the user where it counts. *@ @S.MeterDetail_TankUsesEvents @S.MeterDetail_GoToEvents } else {
@S.MeterDetail_AddReading
} @if (_detail.RecentReadings.Count == 0) { @if (_detail.Mode != MeterMode.ConsumableBalance) { @S.MeterDetail_NoRawReadings } } else { @Loc.F(S.MeterDetail_RecentReadingsCaption, _detail.RecentReadings.Count, _tz.Id) @S.MeterDetail_Time@S.Common_Value@S.MeterDetail_Quality@S.MeterDetail_Flags @foreach (var r in _detail.RecentReadings) { @Local(r.Time).ToString("yyyy-MM-dd HH:mm") @Format.Number(r.Value, 2) @_detail.Unit @QualityChip(r.Quality) @r.Flags.Display() @if (r.Quality == ReadingQuality.Manual) { } } }
@if (_detail.RecentConsumption.Count == 0) { @S.MeterDetail_NoConsumption } else { @Loc.F(S.MeterDetail_RecentConsumptionCaption, _detail.RecentConsumption.Count) @S.MeterDetail_Time@S.Common_Amount@S.MeterDetail_Kind@S.MeterDetail_Quality @foreach (var c in _detail.RecentConsumption) { @Local(c.Time).ToString("yyyy-MM-dd HH:mm") @Format.Number(c.Amount, 2) @_detail.Unit @c.Kind.Display() @QualityChip(c.Quality) } }
@EventsHint @foreach (var type in MeterEventRules.RecordableFor(_detail.Mode)) { var chosen = type; @($"{chosen.Display()}…") }
@if (_detail.Events.Count == 0) { @S.MeterDetail_NoEvents } else { @S.MeterDetail_Time@S.Common_Type@S.Common_Amount@S.MeterDetail_PrevNew@S.MeterDetail_Notes @foreach (var e in _detail.Events) { @Local(e.Time).ToString("yyyy-MM-dd HH:mm") @e.Type.Display() @(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—") @PrevNewText(e) @e.Notes @if (e.ImportBatchId is not null) { @S.MeterDetail_Imported } else { } } }
@S.MeterDetail_ManageTariffs
@if (_detail.Tariffs.Count == 0) { @S.MeterDetail_NoTariffs } else { @S.Common_Scope@S.MeterDetail_Component@S.Common_Value@S.Common_Unit@S.MeterDetail_From@S.MeterDetail_To @foreach (var t in _detail.Tariffs) { @ScopeText(t) @t.Component.Display() @Format.Number(t.Value, 4) @t.Unit @t.ValidFrom.ToString("yyyy-MM-dd") @(t.ValidTo?.ToString("yyyy-MM-dd") ?? S.MeterDetail_TariffOpenEnd) } }
@S.MeterDetail_AddSource
@if (_sources.Count == 0) { @S.MeterDetail_NoSources } else { @S.Common_Type@S.Common_Target@S.MeterDetail_Connector@S.Common_Enabled@S.Common_LastSeen@S.MeterDetail_LastValue@S.Common_Status@S.Common_Actions @foreach (var s in _sources) { @s.SourceType.Display() @SourceTarget(s) @{ var problem = ConnectorProblem(s); } @if (problem is null) { @(_endpoints.FirstOrDefault(e => e.Id == s.EndpointId)?.Name ?? "—") } else { @problem } @(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_No) @(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—") @(s.LastValue is { } v ? Format.Number(v, 2) : "—") @(s.LastStatus ?? "—") } }
@Loc.F(S.MeterDetail_AddReadingTitle, _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}" : S.MeterDetail_EnterValue) @if (WouldBeRejected) { @* Names the likely cause, but deliberately is not a button: this line shows for most of an ordinary entry (every prefix of 12351 is below 12345) and sits just above the keypad, so a slightly high tap on the top keys would leave the reading mid-entry. The swap and reset buttons are in the alert below and on the rejection message. *@ @S.MeterDetail_SwappedOrResetHint } else if (ChangeSinceLast is { } change) { @ChangeSinceText(change) }
@foreach (var key in Keypad) { var pressed = key; @pressed }
@S.Common_Now
@Loc.F(S.MeterDetail_LocalTimeIn, _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 (_readingWhen.IsSkipped) { @Loc.F(S.MeterDetail_SkippedTime, _tz.Id) } @if (WouldBeRejected) { @Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit)
@S.MeterDetail_RecordSwap @S.MeterDetail_RecordReset
} @if (ReplacesSwapStart) { @S.MeterDetail_ReplaceSwapStartNotice } else if (ReplacesRecentReading) { @S.MeterDetail_ReplaceNotice } @if (IsFuture) { @S.MeterDetail_FutureTime } else if (IsBackdated) { @S.MeterDetail_BackdatedNotice }
@S.Common_Cancel @(_readingSaving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
@(_sourceEdit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource) @foreach (var type in Enum.GetValues()) { @type.Display() } @if (SourceRouting.RequiredEndpoint(_sourceEdit.SourceType) is { } needed) { @* Every way to a missing connector leads back here with it picked, so setting one up is a detour rather than a dead end that loses the meter. *@ var usable = ConnectorsFor(needed); if (usable.Count == 0) { @if (_endpoints.FirstOrDefault(e => e.Type == needed && !e.IsEnabled) is { } disabled) { @Loc.F(S.MeterDetail_ConnectorOnlyDisabled, disabled.Name) @S.MeterDetail_EnableConnectorLink } else { @Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) @S.MeterDetail_CreateConnectorLink @S.MeterDetail_CreateConnectorHint } } else { @foreach (var e in usable) { @e.Name }
@S.MeterDetail_AnotherConnector
} } @if (_sourceEdit.SourceType == SourceType.HomeAssistant) { @S.MeterDetail_PollHint } else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota) { } @foreach (var kind in Enum.GetValues()) { @kind.Display() }
@S.Common_Cancel @S.Common_Save
} @code { [Parameter] public int Id { get; set; } /// Which tab to open: one of . [SupplyParameterFromQuery(Name = "tab")] public string? Tab { get; set; } /// A dialog to open once the page is interactive; see . [SupplyParameterFromQuery(Name = "action")] public string? Action { get; set; } /// With the source action: the existing source to open instead of a new one. [SupplyParameterFromQuery(Name = MeterLinks.ParamSource)] public int? SourceParam { get; set; } /// With the source action: the source type to preset. [SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)] public string? SourceTypeParam { get; set; } /// With the source action: the connector to preselect, typically one just created for it. [SupplyParameterFromQuery(Name = MeterLinks.ParamConnector)] public int? ConnectorParam { get; set; } private MeterDetailView? _detail; private MeterPeriodView? _periods; private bool _notFound; private int? _loadedId; private string? _appliedTab; private string? _pendingAction; private SourcePreset? _pendingSource; private bool _droppingAction; private bool _refreshBeforeAction; private int _tabIndex; private List _sources = []; private List _endpoints = []; private bool _sourceOpen; private SourceEdit _sourceEdit = new(); private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true }; private MeterEventDialog? _eventDialog; private MeterEditor? _editor; private bool _readingOpen; private bool _readingSaving; private readonly ReadingEntry _entry = new(); private LocalTimeEntry _readingWhen = new(TimeZoneInfo.Utc); private TimeZoneInfo _tz = TimeZoneInfo.Utc; /// /// A reading typed before the user detoured into recording a swap. It is handed back to the /// reading dialog once the swap is saved, so the detour costs no retyping. /// private (string Text, DateTimeOffset? At)? _resumeReading; /// 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; protected override void OnInitialized() { _tz = LocalTimeEntry.Resolve(Options.Value.TimeZone); _readingWhen = new LocalTimeEntry(_tz); } protected override async Task OnParametersSetAsync() { // Only a different meter reloads. The query string changes too — a deep link into a tab, or // the action being dropped once consumed — and neither should blank and refetch the page. var freshLoad = _loadedId != Id; if (freshLoad) { _detail = null; _periods = null; _notFound = false; _readingOpen = false; _resumeReading = null; // Per-meter view state: another meter opens on its first tab, and an action meant for the // previous meter (say, a stale link to one that no longer exists) must not fire on this one. _tabIndex = 0; _pendingAction = null; _pendingSource = null; _droppingAction = false; _refreshBeforeAction = false; _detail = await Details.GetAsync(Id); _notFound = _detail is null; if (_detail is not null) { _periods = await Periods.GetAsync(Id); await LoadSourcesAsync(); } _loadedId = Id; } // A link's tab wins when it changes, or when the link also carries an action; otherwise the tab // the user clicked since is kept, including when the action is dropped from the address. if (Tab is not null && (!string.Equals(Tab, _appliedTab, StringComparison.OrdinalIgnoreCase) || !string.IsNullOrEmpty(Action))) { _tabIndex = MeterLinks.TabIndex(Tab); } _appliedTab = Tab; if (!string.IsNullOrEmpty(Action)) { _pendingAction = Action; _pendingSource = new SourcePreset( SourceParam, Enum.TryParse(SourceTypeParam, ignoreCase: true, out var sourceType) ? sourceType : null, ConnectorParam); // Arriving on a page already showing this meter: reload first, so the dialog is prefilled // from what is stored now rather than from when the page was opened. _refreshBeforeAction |= !freshLoad; } else { _droppingAction = false; } } /// /// Opens a deep-linked dialog. After render, because only the interactive render can show one — a /// prerendered page has no circuit to drive it. /// /// /// The action is dropped from the address first and the dialog opened once that navigation /// has come back. The other order fails on a fresh load (bookmark, shared link, new tab): a circuit's /// first location change makes MudBlazor's dialog provider dismiss every open dialog, so the dialog /// would flash and close. Dropping it also means a reload does not reopen it. /// protected override async Task OnAfterRenderAsync(bool firstRender) { if (_pendingAction is not { } action || _detail is null) { return; } if (!string.IsNullOrEmpty(Action)) { if (!_droppingAction) { _droppingAction = true; Nav.NavigateTo(Nav.GetUriWithQueryParameters(new Dictionary { ["action"] = null, [MeterLinks.ParamSource] = null, [MeterLinks.ParamSourceType] = null, [MeterLinks.ParamConnector] = null, }), replace: true); } return; } _pendingAction = null; var sourcePreset = _pendingSource; _pendingSource = null; if (_refreshBeforeAction) { _refreshBeforeAction = false; await ReloadAsync(); if (_detail is null) { StateHasChanged(); return; } } switch (action.ToLowerInvariant()) { case MeterLinks.ActionReading when TakesReadings: OpenReading(); break; case MeterLinks.ActionEdit: await _editor!.OpenAsync(Id); break; case MeterLinks.ActionSource: OpenSourceFromLink(sourcePreset); break; default: if (MeterLinks.EventFor(action) is { } type && MeterEventRules.CanRecord(_detail.Mode, type)) { await OpenEventAsync(type); } break; } StateHasChanged(); } private bool TakesReadings => _detail is not null && MeterEventRules.TakesReadings(_detail.Mode); private bool IsUnstarted => _detail is { ReadingCount: 0, Events.Count: 0 } && _sources.Count == 0 && _detail.Mode != MeterMode.Virtual; private string EventsHint => _detail?.Mode switch { MeterMode.ConsumableBalance => S.MeterDetail_EventsHintTank, MeterMode.CumulativeCounter or MeterMode.GenerationCounter or MeterMode.RuntimeCounter => S.MeterDetail_EventsHintRegister, _ => S.MeterDetail_EventsHintNote, }; private DateTimeOffset Local(DateTimeOffset instant) => TimeZoneInfo.ConvertTime(instant, _tz); private string IdentityLine() { if (_detail is null) { return string.Empty; } var parts = new List(3); if (!string.IsNullOrWhiteSpace(_detail.SerialNumber)) { parts.Add(Loc.F(S.MeterDetail_SerialValue, _detail.SerialNumber)); } if (!string.IsNullOrWhiteSpace(_detail.Location)) { parts.Add(_detail.Location); } var device = string.Join(' ', new[] { _detail.Manufacturer, _detail.Model }.Where(s => !string.IsNullOrWhiteSpace(s))); if (device.Length > 0) { parts.Add(device); } return string.Join(" · ", parts); } private string ScopeText(TariffRow tariff) => tariff.Scope switch { TariffScope.Meter => S.MeterDetail_ScopeThisMeter, TariffScope.EnergyType => $"{tariff.Scope.Display()}: {_detail?.EnergyType}", _ => tariff.Scope.Display(), }; private static string PrevNewText(EventRow e) => (e.PrevValue, e.NewValue) switch { (null, null) => "—", ({ } prev, var next) => $"{Format.Number(prev, 2)} → {Format.Number(next ?? 0, 2)}", (null, { } next) => $"→ {Format.Number(next, 2)}", }; /// /// "+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. /// private static string ChangeText(double? change) { if (change is not { } c) { return S.MeterDetail_NoBasisYet; } return Math.Abs(c) < 0.005 ? S.MeterDetail_AboutTheSame : $"{(c > 0 ? "+" : "−")}{Format.Number(Math.Abs(c) * 100, 0)}%"; } private static string BarStyle(double amount, IReadOnlyList history) { var peak = history.Max(h => Math.Abs(h.Amount)); var fraction = peak < 1e-9 ? 0 : Math.Abs(amount) / peak; // Floor at 2% so a month with a little usage is still visibly distinct from an empty one. var height = amount == 0 ? 0 : Math.Max(2, fraction * 100); return $"width:100%; height:{height.ToString("0.#", CultureInfo.InvariantCulture)}%; " + "background:var(--mud-palette-primary); border-radius:2px 2px 0 0"; } private async Task ReloadAsync() { _detail = await Details.GetAsync(Id); _notFound = _detail is null; _periods = _detail is null ? null : await Periods.GetAsync(Id); await LoadSourcesAsync(); } private void OpenReading() { if (_detail is null || !TakesReadings) { return; } _resumeReading = null; _readingWhen.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 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 } ? Loc.F(S.MeterDetail_LastReadingCaption, Format.Number(value, 2), detail.Unit, Local(time).ToString("yyyy-MM-dd HH:mm")) : Loc.F(S.MeterDetail_NoReadingsPrefill, Format.Number(detail.InitialBaseline, 2), detail.Unit); } private DateTimeOffset? EnteredUtc => _readingWhen.Utc; private bool IsMonotonic => _detail is not null && MeterEventRules.IsMonotonic(_detail.Mode); 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; /// /// A swap or reset recorded after the latest reading and no later than the entered time: it /// explains a lower value, exactly as the ingestion guard sees it. /// private bool BoundaryExplainsDecrease => EnteredUtc is { } entered && _detail is { LastReadingTime: { } last } && _detail.Events.Any(e => MeterEventRules.IsRegisterBoundary(e.Type) && e.Time > last && e.Time <= entered); /// /// 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 && !BoundaryExplainsDecrease && _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); /// /// The reading being replaced is the new register's start value a swap wrote. Replacing it with /// the real value at that instant is correct — the swap still anchors the maths — so say that /// instead of the generic "replaces a reading", which reads like a warning. /// private bool ReplacesSwapStart => EnteredUtc is { } entered && _detail is not null && _detail.RecentReadings.Any(r => r.Time == entered && (r.Flags & (ReadingFlags.MeterSwap | ReadingFlags.CounterReset)) != 0); private bool CanSaveReading => !_readingSaving && _entry.Value is not null && _readingWhen.WallClock is not null && !_readingWhen.IsSkipped; private string ChangeSinceText(double change) => Math.Abs(change) < 1e-9 ? S.MeterDetail_NoChangeSinceLast : Loc.F(S.MeterDetail_ChangeSinceLast, $"{(change > 0 ? "+" : "−")}{Format.Number(Math.Abs(change), 2)}", _detail?.Unit); 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(Loc.F(S.MeterDetail_ReadingSaved, Format.Number(value, 2), _detail.Unit), Severity.Success); break; case IngestionOutcome.Updated: Snackbar.Add(Loc.F(S.MeterDetail_ReadingReplaced, Format.Number(value, 2), _detail.Unit), Severity.Success); break; case IngestionOutcome.RejectedDecrease: // Leave the dialog open with the value still on screen, and offer the fix right on // the message: recording a swap or reset is a decision, not a retry. Snackbar.Add(S.MeterDetail_ReadingRejected, Severity.Error, config => { config.Action = S.MeterDetail_RecordSwap; config.ActionColor = Color.Inherit; config.OnClick = _ => InvokeAsync(() => SwitchToEventAsync(MeterEventType.MeterSwap)); }); return; default: Snackbar.Add(S.MeterDetail_MeterGone, Severity.Error); return; } _readingOpen = false; _resumeReading = null; await ReloadAsync(); } finally { _readingSaving = false; } } /// /// From the reading dialog into the swap/reset dialog, at the time the reading was being entered — /// the latest the swap can have happened. The typed value is kept and handed back afterwards. /// private async Task SwitchToEventAsync(MeterEventType type) { _resumeReading = (_entry.Text, EnteredUtc); _readingOpen = false; await OpenEventAsync(type, EnteredUtc, keepResume: true); } private async Task OpenEventAsync(MeterEventType type, DateTimeOffset? at = null, bool keepResume = false) { if (_eventDialog is null) { return; } // Opened from the reading dialog, the user returns to that dialog afterwards, so leave the tab // alone; opened on its own, show the list the new event is about to appear in. if (!keepResume) { _resumeReading = null; _tabIndex = MeterLinks.TabIndex(MeterLinks.TabEvents); } await _eventDialog.OpenAsync(type, at); } private async Task OnEventSavedAsync(MeterEventType type) { await ReloadAsync(); // Back to the reading that prompted the swap, with the typed digits still there. if (_resumeReading is { } resume && MeterEventRules.IsRegisterBoundary(type) && _detail is not null) { _resumeReading = null; _entry.SetText(resume.Text); if (resume.At is { } at) { _readingWhen.Set(at); } else { _readingWhen.SetNow(); } _tabIndex = MeterLinks.TabIndex(MeterLinks.TabReadings); _readingOpen = true; } } private void OnEventCancelled() { // Abandoning the swap returns to the reading as it was, rather than silently losing it. if (_resumeReading is { } resume) { _resumeReading = null; _entry.SetText(resume.Text); if (resume.At is { } at) { _readingWhen.Set(at); } _readingOpen = true; } } private async Task OnMeterSavedAsync((int MeterId, bool Created) saved) => await ReloadAsync(); private async Task DeleteReadingAsync(ReadingRow reading) { if (_detail is null || !await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteReadingTitle, Loc.F(S.MeterDetail_DeleteReadingConfirm, Format.Number(reading.Value, 2), _detail.Unit, Local(reading.Time).ToString("yyyy-MM-dd HH:mm")))) { return; } await RunServiceAsync( service => service.DeleteManualReadingAsync(Id, reading.Time), S.MeterDetail_ReadingDeleted, "Deleting a manual reading"); } private async Task DeleteEventAsync(EventRow meterEvent) { if (_detail is null) { return; } var message = MeterEventRules.IsRegisterBoundary(meterEvent.Type) ? Loc.F(S.MeterDetail_DeleteBoundaryConfirm, meterEvent.Type.Display(), Local(meterEvent.Time).ToString("yyyy-MM-dd HH:mm")) : Loc.F(S.MeterDetail_DeleteEventConfirm, meterEvent.Type.Display(), Local(meterEvent.Time).ToString("yyyy-MM-dd HH:mm")); if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteEvent, message)) { return; } await RunServiceAsync( service => service.DeleteEventAsync(Id, meterEvent.Id), S.MeterDetail_EventDeleted, "Deleting an event"); } /// /// Runs one event-service operation in its own scope and reports the outcome. A failure has already /// been rolled back by the service's transaction, so it is reported rather than allowed to end the /// circuit. /// private async Task RunServiceAsync(Func> operation, string successText, string what) { try { await using var scope = Scopes.CreateAsyncScope(); var result = await operation(scope.ServiceProvider.GetRequiredService()); Snackbar.Add(result.Succeeded ? successText : MeterEventText.Problem(result.Problem), result.Succeeded ? Severity.Success : Severity.Error); } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.LogError(ex, "{What} on meter {MeterId} failed", what, Id); Snackbar.Add(S.MeterEvent_ActionFailed, Severity.Error); } await ReloadAsync(); } private async Task LoadSourcesAsync() { await using var db = await DbFactory.CreateDbContextAsync(); _sources = await db.MeterSources.AsNoTracking().Where(s => s.MeterId == Id).OrderBy(s => s.Priority).ToListAsync(); _endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync(); } private static string SourceTarget(MeterSource s) { var config = SourceConfig.Parse(s.Config); return s.SourceType == SourceType.HomeAssistant ? config.EntityId ?? "—" : config.Topic ?? "—"; } private void OpenNewSource() { _tabIndex = MeterLinks.TabIndex(MeterLinks.TabSources); OpenSource(null); } /// /// Opens the source dialog a link asked for — typically the way back from the connector page, with /// the source it left, its type and the connector just saved for it. /// private void OpenSourceFromLink(SourcePreset? preset) { _tabIndex = MeterLinks.TabIndex(MeterLinks.TabSources); OpenSource(preset?.SourceId is { } sourceId ? _sources.FirstOrDefault(s => s.Id == sourceId) : null); if (preset is null) { return; } // Back from the connector page: everything typed before the detour comes back with the dialog. A // link that names a source type is only ever that way back; other links open the dialog fresh. if (preset.Type is not null && Drafts.TryTake(SourceDraftKey(preset.SourceId), out var draft)) { draft.Id = _sourceEdit.Id; _sourceEdit = draft; } var connector = preset.ConnectorId is { } connectorId ? _endpoints.FirstOrDefault(e => e.Id == connectorId) : null; if ((preset.Type ?? (connector is null ? null : SourceRouting.DefaultSourceFor(connector.Type))) is { } type && type != _sourceEdit.SourceType) { OnSourceTypeChanged(type); } // Only a connector that can serve the source: a disabled or mismatched one would be refused on save. if (connector is { IsEnabled: true } && SourceRouting.Serves(connector.Type, _sourceEdit.SourceType)) { _sourceEdit.EndpointId = connector.Id; } } /// The source being edited, or null for a new one — what a detour to the connector page returns to. private int? SourceIdOrNull => _sourceEdit.Id == 0 ? null : _sourceEdit.Id; private string SourceDraftKey(int? sourceId) => $"meter:{Id.ToString(CultureInfo.InvariantCulture)}:source:{sourceId?.ToString(CultureInfo.InvariantCulture) ?? "new"}"; private void CancelSource() { _sourceOpen = false; Drafts.Discard(SourceDraftKey(SourceIdOrNull)); } /// /// Leaving the page with the source dialog open — the connector links in it do exactly that — keeps /// what was typed for the way back. Disposal runs after every input already sent has been applied. /// public void Dispose() { if (_sourceOpen && _loadedId is { } meterId && meterId == Id) { Drafts.Save(SourceDraftKey(SourceIdOrNull), _sourceEdit.Clone()); } } private void OpenSource(MeterSource? source) { if (source is null) { _sourceEdit = new SourceEdit(); OnSourceTypeChanged(_sourceEdit.SourceType); } else { var config = SourceConfig.Parse(source.Config); _sourceEdit = new SourceEdit { Id = source.Id, SourceType = source.SourceType, EndpointId = source.EndpointId, ValueKind = source.ValueKind, Scale = source.Scale, Offset = source.Offset, Priority = source.Priority, IsEnabled = source.IsEnabled, EntityId = config.EntityId, Attribute = config.Attribute, PollMinutes = config.PollMinutes, Topic = config.Topic, Path = config.Path, TimePath = config.TimePath, }; } _sourceOpen = true; } private async Task SaveSourceAsync() { // A live source without a matching connector has no connection details and would silently // never ingest, so refuse it here rather than letting it look configured. if (SourceRouting.RequiredEndpoint(_sourceEdit.SourceType) is { } needed) { var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId); if (selected is null) { Snackbar.Add(Loc.F(S.MeterDetail_PickConnector, needed.Display(), _sourceEdit.SourceType.Display()), Severity.Error); return; } if (selected.Type != needed) { Snackbar.Add( Loc.F(S.MeterDetail_ConnectorTypeMismatch, selected.Name, selected.Type.Display(), _sourceEdit.SourceType.Display(), needed.Display()), Severity.Error); return; } if (!selected.IsEnabled) { Snackbar.Add(Loc.F(S.MeterDetail_ConnectorDisabled, selected.Name), Severity.Error); return; } } else { _sourceEdit.EndpointId = null; } var config = new SourceConfig { EntityId = Trim(_sourceEdit.EntityId), Attribute = Trim(_sourceEdit.Attribute), PollMinutes = _sourceEdit.PollMinutes, Topic = Trim(_sourceEdit.Topic), Path = Trim(_sourceEdit.Path), TimePath = Trim(_sourceEdit.TimePath), }; var configJson = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); await using var db = await DbFactory.CreateDbContextAsync(); if (_sourceEdit.Id == 0) { db.MeterSources.Add(new MeterSource { MeterId = Id, SourceType = _sourceEdit.SourceType, EndpointId = _sourceEdit.EndpointId, Config = configJson, ValueKind = _sourceEdit.ValueKind, Scale = _sourceEdit.Scale, Offset = _sourceEdit.Offset, Priority = _sourceEdit.Priority, IsEnabled = _sourceEdit.IsEnabled, }); } else { var existing = await db.MeterSources.FirstAsync(s => s.Id == _sourceEdit.Id); existing.SourceType = _sourceEdit.SourceType; existing.EndpointId = _sourceEdit.EndpointId; existing.Config = configJson; existing.ValueKind = _sourceEdit.ValueKind; existing.Scale = _sourceEdit.Scale; existing.Offset = _sourceEdit.Offset; existing.Priority = _sourceEdit.Priority; existing.IsEnabled = _sourceEdit.IsEnabled; } await db.SaveChangesAsync(); _sourceOpen = false; Drafts.Discard(SourceDraftKey(SourceIdOrNull)); Snackbar.Add(S.MeterDetail_SourceSaved, Severity.Success); await LoadSourcesAsync(); } private async Task DeleteSourceAsync(MeterSource source) { if (!await Confirm.DeleteAsync(DialogService, S.MeterDetail_DeleteSourceTitle, Loc.F(S.MeterDetail_DeleteSourceConfirm, source.SourceType.Display()))) { return; } await using var db = await DbFactory.CreateDbContextAsync(); await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync(); Snackbar.Add(S.MeterDetail_SourceDeleted, Severity.Success); await LoadSourcesAsync(); } private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); // Only enabled connectors can ingest: both MQTT and HA workers filter on IsEnabled, so offering // a disabled one would produce a source that saves cleanly and then never runs. private List ConnectorsFor(EndpointType type) => _endpoints.Where(e => e.Type == type && e.IsEnabled).ToList(); /// /// Why this source cannot ingest, or null if it can. Routing is endpoint-scoped, so an unbound /// or mis-bound source is silently dead — and deleting a connector unlinks its sources, which /// used to be harmless. Without this column such a source is indistinguishable from a healthy /// one at "Enabled: yes". /// private string? ConnectorProblem(MeterSource source) { if (SourceRouting.RequiredEndpoint(source.SourceType) is not { } needed) { return null; } var endpoint = _endpoints.FirstOrDefault(e => e.Id == source.EndpointId); return endpoint switch { null => S.MeterDetail_ProblemNoConnector, { IsEnabled: false } => Loc.F(S.MeterDetail_ProblemDisabled, endpoint.Name), _ when endpoint.Type != needed => Loc.F(S.MeterDetail_ProblemTypeMismatch, endpoint.Name, endpoint.Type.Display(), needed.Display()), _ => null, }; } // Changing the source type can invalidate the chosen connector (an HA connector cannot serve an // MQTT source), so drop a selection that no longer fits rather than saving a mismatched pair. private void OnSourceTypeChanged(SourceType sourceType) { _sourceEdit.SourceType = sourceType; var needed = SourceRouting.RequiredEndpoint(sourceType); var selected = _endpoints.FirstOrDefault(e => e.Id == _sourceEdit.EndpointId); if (needed is null || (selected is not null && selected.Type != needed)) { _sourceEdit.EndpointId = null; } // Sole candidate: preselect it, so the common single-broker / single-HA setup is one click. Only // enabled ones count — the picker offers nothing else, so a disabled pick would be invisible. if (needed is { } kind && _sourceEdit.EndpointId is null) { var candidates = ConnectorsFor(kind); if (candidates.Count == 1) { _sourceEdit.EndpointId = candidates[0].Id; } } } /// What a link presets in the source dialog; see . private sealed record SourcePreset(int? SourceId, SourceType? Type, int? ConnectorId); private sealed class SourceEdit { public SourceEdit Clone() => (SourceEdit)MemberwiseClone(); public int Id { get; set; } public SourceType SourceType { get; set; } = SourceType.HomeAssistant; public int? EndpointId { get; set; } public SourceValueKind ValueKind { get; set; } = SourceValueKind.Register; public double Scale { get; set; } = 1; public double Offset { get; set; } public int Priority { get; set; } public bool IsEnabled { get; set; } = true; public string? EntityId { get; set; } public string? Attribute { get; set; } public int? PollMinutes { get; set; } = 60; public string? Topic { get; set; } public string? Path { get; set; } public string? TimePath { get; set; } } private static RenderFragment QualityChip(ReadingQuality quality) =>@@quality.Display(); }