Two threads that ended up in the same files. One is navigation: a meter
swap that happened today had no click path at all, and most per-meter
tasks were reachable only by knowing which admin page owned them. The
other is attribution: readings on 1 August and 16 September showed six
weeks of water under September and nothing under August.
Meter events from the UI
Swap, counter reset, tank level, delivery and note are recorded through
MeterEventService rather than ad-hoc inserts, so the dialog's verdict and
the saved result come from the same Validate call, and every record or
delete recomputes the meter inside one transaction. MeterEventRules
decides which events a mode offers -- a tank has no register to swap, and
Correction is offered nowhere because nothing reads it.
A swap is stored as the event at T plus a manual reading of the new
register's start value at exactly T. That pairing is the whole trick: the
boundary window is (previousReading, reading], so the old register's tail
books at T and every later reading counts from the new start. Writing the
old final value as the reading at T instead -- the obvious thing -- double
counts the tail and then rejects every reading the new register produces.
Deleting a swap removes that start reading only while it is still the
untouched start value, and only Manual readings can be deleted at all.
Navigation
The meter page is now the hub: primary entry by mode, a "Record event"
menu, and Edit through a shared MeterEditor that also owns tank setup.
Other pages link into it with MeterLinks (/meters/{id}?tab=...&action=...),
whose action is consumed once after the interactive render and dropped
from the address -- the reverse order flashes the dialog and closes it,
because a circuit's first location change dismisses every open dialog.
The app bar gains a "Find a meter" dialog with the same quick entry.
A source that has no usable connector now links to creating (or enabling)
one and comes back to the same source dialog with the connector picked
and everything typed still there; the draft survives in a circuit-scoped
DraftStore, and the way back is a meter id rather than a URL, so the page
cannot be made to redirect anywhere else. The connector list shows which
meters use each connector, import batches list the meters and categories
they wrote to, the meter editor owns the meter's own cost categories, and
the dashboard's empty cost panel names the first missing step instead of
listing every admin page.
Months
A reading is an instant, and what it measures accrued over the time since
the previous one. Booking the whole delta at the closing reading misfiles
it whenever the interval crosses a month boundary, so a plain increase is
now divided at local month boundaries in proportion to elapsed time, each
share stamped inside its month and marked estimated: the meter recorded a
total, not a shape. The parts always sum to the original.
Imported monthly tables are the exception that keeps the golden fixtures
reconciling. "Mai 2026" carries the register at the end of May but is
stamped on the 1st, so the importer -- the only place that still knows
whether the date cell named a month or a day -- flags it MonthLabel, and
the engine reads it as the end of its month. Inferring that from the
stamp instead would catch day-dated rows: a sheet with "01.08.2026" in it
is not a monthly table, and reading it as one moves two thirds of July
into August.
ReadingTimeline is the single ordering built on that: effective time,
then stamp. The register normalizers walk it, and so do the decrease
guard and the event dialog, which is what stops them disagreeing about
which reading is "previous" -- a sheet imported after live readings of the
same month used to count that month twice, and a mid-month reading below
the month's end value was rejected as a drop. A swap detected in a
monthly table applies from the start of that local month, i.e. to the
first reading in it, and a recorded start value never counts above the
reading it lands on.
Every reader buckets in the configured timezone rather than a hardcoded
one, and turns a requested date into that zone's local midnight, so the
divided shares are read back under the months they were stamped in. The
zone id is normalised to its IANA form, because .NET accepts a Windows id
that PostgreSQL will not bucket by, and both are checked at startup.
Stored consumption is derived, so a rule change reaches a meter only at
its next reading -- weeks, for a meter read monthly. NormalizationUpgrade
records the revision and zone the stored series was built with and
rebuilds everything once at startup when either differs, each meter in
its own transaction. A meter that fails is logged, kept in
normalization_pending and retried at the next start: one bad series must
never keep the application down.
What an operator sees once
Existing charts change on the first start after the update: months that
carried a neighbour's use give it back. Rows of earlier imports from
monthly tables are marked as such before anything is recomputed, and if
that marking fails nothing is rebuilt or recorded, so the upgrade simply
runs again next time rather than shifting every imported month by one. A
wizard import whose date format was left on auto-detect is treated as a
monthly table when all of its rows sit on the 1st across at least two
months -- exactly how those rows were attributed before -- and each such
batch is named in the log, because a day-dated sheet always read on the
1st looks identical; revert and re-import it with the day format if that
is what it was.
Tests: 120 unit and 230 integration, including the reference fixtures,
which still reconcile month for month.
1482 lines
69 KiB
Plaintext
1482 lines
69 KiB
Plaintext
@page "/meters/{Id:int}"
|
||
@inject MeterDetailService Details
|
||
@inject MeterPeriodService Periods
|
||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||
@inject ISnackbar Snackbar
|
||
@inject IDialogService DialogService
|
||
@inject NavigationManager Nav
|
||
@inject IServiceScopeFactory Scopes
|
||
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||
@inject ILogger<MeterDetail> Logger
|
||
@inject DraftStore Drafts
|
||
@implements IDisposable
|
||
@using System.Globalization
|
||
@using Microsoft.EntityFrameworkCore
|
||
@using Microsoft.Extensions.DependencyInjection
|
||
@using MeterVault.Infrastructure.Ingestion
|
||
@using MudBlazor
|
||
|
||
<PageTitle>MeterVault — @(_detail?.Name ?? S.Common_Meter)</PageTitle>
|
||
|
||
@if (_detail is null)
|
||
{
|
||
@if (_notFound)
|
||
{
|
||
<MudAlert Severity="Severity.Warning">@Loc.F(S.MeterDetail_NotFound, Id) <MudLink Href="/meters">@S.MeterDetail_BackToMeters</MudLink></MudAlert>
|
||
}
|
||
else
|
||
{
|
||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||
}
|
||
}
|
||
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>
|
||
|
||
@* 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. *@
|
||
<div class="d-flex align-center flex-wrap mb-1" style="gap:.5rem .75rem">
|
||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" aria-label="@S.MeterDetail_BackToMeters" />
|
||
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
||
<MudTooltip Text="@Loc.F(S.MeterDetail_EnergyTypeFlow, _detail.EnergyType)">
|
||
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Href="@($"/energy/{_detail.EnergyTypeId}")">@_detail.EnergyType</MudChip>
|
||
</MudTooltip>
|
||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode.Display()</MudChip>
|
||
@if (!_detail.IsActive)
|
||
{
|
||
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@S.MeterDetail_Retired</MudChip>
|
||
}
|
||
<MudSpacer />
|
||
<div class="d-flex align-center flex-wrap" style="gap:.5rem">
|
||
@if (TakesReadings)
|
||
{
|
||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.EditNote" OnClick="OpenReading">
|
||
@S.MeterDetail_AddReading
|
||
</MudButton>
|
||
}
|
||
else if (_detail.Mode == MeterMode.ConsumableBalance)
|
||
{
|
||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Straighten"
|
||
OnClick="@(() => OpenEventAsync(MeterEventType.TankLevel))">
|
||
@S.MeterDetail_RecordTankLevel
|
||
</MudButton>
|
||
}
|
||
<MudMenu Label="@S.MeterDetail_RecordEvent" Variant="Variant.Outlined" Color="Color.Primary"
|
||
EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Dense="true">
|
||
@foreach (var type in MeterEventRules.RecordableFor(_detail.Mode))
|
||
{
|
||
var chosen = type;
|
||
<MudMenuItem Icon="@MeterEventText.Icon(chosen)" OnClick="@(() => OpenEventAsync(chosen))">@($"{chosen.Display()}…")</MudMenuItem>
|
||
}
|
||
</MudMenu>
|
||
<MudTooltip Text="@S.MeterDetail_EditMeter">
|
||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Variant="Variant.Outlined" Size="Size.Medium"
|
||
OnClick="@(() => _editor!.OpenAsync(Id))" aria-label="@S.MeterDetail_EditMeter" />
|
||
</MudTooltip>
|
||
</div>
|
||
</div>
|
||
@if (IdentityLine() is { Length: > 0 } identity)
|
||
{
|
||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">@identity</MudText>
|
||
}
|
||
else
|
||
{
|
||
<div class="mb-3"></div>
|
||
}
|
||
|
||
@if (_detail.Mode == MeterMode.ConsumableBalance && !_detail.HasTank)
|
||
{
|
||
<MudAlert Severity="Severity.Warning" Class="mb-3">
|
||
@S.MeterDetail_NoTankConfigured
|
||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Warning" Class="ml-2"
|
||
OnClick="@(() => _editor!.OpenAsync(Id))">@S.MeterDetail_SetUpTank</MudButton>
|
||
</MudAlert>
|
||
}
|
||
else if (IsUnstarted)
|
||
{
|
||
@* A brand-new meter has nothing to show yet; say what makes it useful instead of a page of dashes. *@
|
||
<MudAlert Severity="Severity.Info" Class="mb-3">
|
||
@S.MeterDetail_GetStarted
|
||
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||
@if (TakesReadings)
|
||
{
|
||
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.EditNote" OnClick="OpenReading">@S.MeterDetail_AddFirstReading</MudButton>
|
||
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.SettingsInputComponent" OnClick="OpenNewSource">@S.MeterDetail_ConnectSource</MudButton>
|
||
}
|
||
else
|
||
{
|
||
<MudButton Size="Size.Small" Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Straighten"
|
||
OnClick="@(() => OpenEventAsync(MeterEventType.TankLevel))">@S.MeterDetail_RecordTankLevel</MudButton>
|
||
}
|
||
</div>
|
||
</MudAlert>
|
||
}
|
||
|
||
@if (_periods is { } p)
|
||
{
|
||
<MudGrid Class="mb-2">
|
||
<MudItem xs="12" sm="6" md="3">
|
||
<MudPaper Class="pa-3" Elevation="2">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">@Loc.F(S.MeterDetail_LabelThisMonth, p.Kind.Display())</MudText>
|
||
<MudText Typo="Typo.h6">@Format.Number(p.MonthToDate, 0) @p.Unit</MudText>
|
||
@if (p.MonthIsPartial)
|
||
{
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||
@Loc.F(S.MeterDetail_ProjectedByMonthEnd, Format.Number(p.MonthProjected, 0), p.Unit)
|
||
</MudText>
|
||
}
|
||
</MudPaper>
|
||
</MudItem>
|
||
<MudItem xs="12" sm="6" md="3">
|
||
<MudPaper Class="pa-3" Elevation="2">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_VsLastMonth</MudText>
|
||
<MudText Typo="Typo.h6">@ChangeText(p.MonthChange)</MudText>
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||
@Loc.F(S.MeterDetail_LastMonthValue, Format.Number(p.LastMonth, 0), p.Unit)
|
||
</MudText>
|
||
</MudPaper>
|
||
</MudItem>
|
||
<MudItem xs="12" sm="6" md="3">
|
||
<MudPaper Class="pa-3" Elevation="2">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_ThisYear</MudText>
|
||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDate, 0) @p.Unit</MudText>
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||
@Loc.F(S.MeterDetail_VsLastYear, ChangeText(p.YearChange), Format.Number(p.LastYear, 0))
|
||
</MudText>
|
||
</MudPaper>
|
||
</MudItem>
|
||
<MudItem xs="12" sm="6" md="3">
|
||
<MudPaper Class="pa-3" Elevation="2">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_CostThisYear</MudText>
|
||
<MudText Typo="Typo.h6">@Format.Number(p.YearToDateCost, 2) @p.Currency</MudText>
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||
@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)) : "")
|
||
</MudText>
|
||
</MudPaper>
|
||
</MudItem>
|
||
</MudGrid>
|
||
|
||
@if (p.HasHistory)
|
||
{
|
||
<MudPaper Class="pa-3 mb-2" Elevation="2">
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.Common_RangeLast12Months</MudText>
|
||
<div class="d-flex align-end mt-2" style="gap:.35rem; height:110px">
|
||
@foreach (var m in p.Last12Months)
|
||
{
|
||
<div class="d-flex flex-column align-center" style="flex:1; height:100%">
|
||
<div style="flex:1; display:flex; align-items:flex-end; width:100%">
|
||
<div title="@($"{m.Month:yyyy-MM}: {Format.Number(m.Amount, 0)} {p.Unit}")"
|
||
style="@BarStyle(m.Amount, p.Last12Months)"></div>
|
||
</div>
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">@m.Month.ToString("MMM")</MudText>
|
||
</div>
|
||
}
|
||
</div>
|
||
</MudPaper>
|
||
}
|
||
}
|
||
|
||
@if (_periods is null && _detail.Mode == MeterMode.Virtual)
|
||
{
|
||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||
@S.MeterDetail_VirtualNotice <MudLink Href="@($"/energy/{_detail.EnergyTypeId}")">@S.MeterDetail_VirtualNoticeFlow</MudLink>
|
||
</MudAlert>
|
||
}
|
||
|
||
<MudExpansionPanels Elevation="0" Class="mb-2">
|
||
<MudExpansionPanel Text="@S.MeterDetail_RegisterDetails">
|
||
<div class="d-flex flex-wrap" style="gap:2rem">
|
||
<div>
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_RegisterSpan</MudText>
|
||
<MudText Typo="Typo.body1">
|
||
@(_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))
|
||
</MudText>
|
||
</div>
|
||
<div>
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_Readings</MudText>
|
||
<MudText Typo="Typo.body1">
|
||
@_detail.ReadingCount ·
|
||
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||
</MudText>
|
||
</div>
|
||
<div>
|
||
<MudText Typo="Typo.overline" Color="Color.Secondary">@S.MeterDetail_LifetimeTotal</MudText>
|
||
<MudText Typo="Typo.body1">
|
||
@Format.Number(_detail.TotalGeneration != 0 ? _detail.TotalGeneration : _detail.TotalConsumption, 0) @_detail.Unit
|
||
</MudText>
|
||
</div>
|
||
</div>
|
||
</MudExpansionPanel>
|
||
</MudExpansionPanels>
|
||
|
||
<MudTabs @bind-ActivePanelIndex="_tabIndex" Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabReadings, _detail.ReadingCount)">
|
||
@if (_detail.Mode == MeterMode.Virtual)
|
||
{
|
||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||
@S.MeterDetail_VirtualNoReadings
|
||
</MudText>
|
||
}
|
||
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. *@
|
||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||
@S.MeterDetail_TankUsesEvents
|
||
<MudButton Size="Size.Small" Variant="Variant.Text" Color="Color.Primary" Class="ml-2"
|
||
OnClick="@(() => _tabIndex = MeterLinks.TabIndex(MeterLinks.TabEvents))">@S.MeterDetail_GoToEvents</MudButton>
|
||
</MudAlert>
|
||
}
|
||
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">
|
||
@S.MeterDetail_AddReading
|
||
</MudButton>
|
||
</div>
|
||
}
|
||
@if (_detail.RecentReadings.Count == 0)
|
||
{
|
||
@if (_detail.Mode != MeterMode.ConsumableBalance)
|
||
{
|
||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoRawReadings</MudText>
|
||
}
|
||
}
|
||
else
|
||
{
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||
@Loc.F(S.MeterDetail_RecentReadingsCaption, _detail.RecentReadings.Count, _tz.Id)
|
||
</MudText>
|
||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||
<thead><tr><th>@S.MeterDetail_Time</th><th style="text-align:right">@S.Common_Value</th><th>@S.MeterDetail_Quality</th><th>@S.MeterDetail_Flags</th><th></th></tr></thead>
|
||
<tbody>
|
||
@foreach (var r in _detail.RecentReadings)
|
||
{
|
||
<tr>
|
||
<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>@QualityChip(r.Quality)</td>
|
||
<td>@r.Flags.Display()</td>
|
||
<td style="text-align:right">
|
||
@if (r.Quality == ReadingQuality.Manual)
|
||
{
|
||
<MudTooltip Text="@S.MeterDetail_DeleteReading">
|
||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||
OnClick="@(() => DeleteReadingAsync(r))" aria-label="@S.MeterDetail_DeleteReading" />
|
||
</MudTooltip>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</MudSimpleTable>
|
||
}
|
||
</MudTabPanel>
|
||
|
||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabConsumption, _detail.ConsumptionCount)">
|
||
@if (_detail.RecentConsumption.Count == 0)
|
||
{
|
||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoConsumption</MudText>
|
||
}
|
||
else
|
||
{
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">@Loc.F(S.MeterDetail_RecentConsumptionCaption, _detail.RecentConsumption.Count)</MudText>
|
||
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||
<thead><tr><th>@S.MeterDetail_Time</th><th style="text-align:right">@S.Common_Amount</th><th>@S.MeterDetail_Kind</th><th>@S.MeterDetail_Quality</th></tr></thead>
|
||
<tbody>
|
||
@foreach (var c in _detail.RecentConsumption)
|
||
{
|
||
<tr>
|
||
<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>@c.Kind.Display()</td>
|
||
<td>@QualityChip(c.Quality)</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</MudSimpleTable>
|
||
}
|
||
</MudTabPanel>
|
||
|
||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabEvents, _detail.Events.Count)">
|
||
<div class="d-flex align-center justify-space-between flex-wrap mb-2" style="gap:.5rem">
|
||
<MudText Typo="Typo.body2" Color="Color.Secondary">@EventsHint</MudText>
|
||
<MudMenu Label="@S.MeterDetail_RecordEvent" Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
|
||
StartIcon="@Icons.Material.Filled.Add" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Dense="true">
|
||
@foreach (var type in MeterEventRules.RecordableFor(_detail.Mode))
|
||
{
|
||
var chosen = type;
|
||
<MudMenuItem Icon="@MeterEventText.Icon(chosen)" OnClick="@(() => OpenEventAsync(chosen))">@($"{chosen.Display()}…")</MudMenuItem>
|
||
}
|
||
</MudMenu>
|
||
</div>
|
||
@if (_detail.Events.Count == 0)
|
||
{
|
||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoEvents</MudText>
|
||
}
|
||
else
|
||
{
|
||
<MudSimpleTable Dense="true" Hover="true">
|
||
<thead><tr><th>@S.MeterDetail_Time</th><th>@S.Common_Type</th><th style="text-align:right">@S.Common_Amount</th><th style="text-align:right">@S.MeterDetail_PrevNew</th><th>@S.MeterDetail_Notes</th><th></th></tr></thead>
|
||
<tbody>
|
||
@foreach (var e in _detail.Events)
|
||
{
|
||
<tr>
|
||
<td style="white-space:nowrap">@Local(e.Time).ToString("yyyy-MM-dd HH:mm")</td>
|
||
<td style="white-space:nowrap">
|
||
<MudIcon Icon="@MeterEventText.Icon(e.Type)" Size="Size.Small" Class="mr-1" Style="vertical-align:middle" />@e.Type.Display()
|
||
</td>
|
||
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
||
<td style="text-align:right; white-space:nowrap">@PrevNewText(e)</td>
|
||
<td>@e.Notes</td>
|
||
<td style="text-align:right">
|
||
@if (e.ImportBatchId is not null)
|
||
{
|
||
<MudTooltip Text="@S.MeterDetail_ImportedEventHint">
|
||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Href="/import">@S.MeterDetail_Imported</MudChip>
|
||
</MudTooltip>
|
||
}
|
||
else
|
||
{
|
||
<MudTooltip Text="@S.MeterDetail_DeleteEvent">
|
||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
|
||
OnClick="@(() => DeleteEventAsync(e))" aria-label="@S.MeterDetail_DeleteEvent" />
|
||
</MudTooltip>
|
||
}
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</MudSimpleTable>
|
||
}
|
||
</MudTabPanel>
|
||
|
||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabTariffs, _detail.Tariffs.Count)">
|
||
<div class="d-flex justify-end mb-2">
|
||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary" Href="/admin/tariffs"
|
||
StartIcon="@Icons.Material.Filled.Euro">@S.MeterDetail_ManageTariffs</MudButton>
|
||
</div>
|
||
@if (_detail.Tariffs.Count == 0)
|
||
{
|
||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoTariffs</MudText>
|
||
}
|
||
else
|
||
{
|
||
<MudSimpleTable Dense="true" Hover="true">
|
||
<thead><tr><th>@S.Common_Scope</th><th>@S.MeterDetail_Component</th><th style="text-align:right">@S.Common_Value</th><th>@S.Common_Unit</th><th>@S.MeterDetail_From</th><th>@S.MeterDetail_To</th></tr></thead>
|
||
<tbody>
|
||
@foreach (var t in _detail.Tariffs)
|
||
{
|
||
<tr>
|
||
<td>@ScopeText(t)</td>
|
||
<td>@t.Component.Display()</td>
|
||
<td style="text-align:right">@Format.Number(t.Value, 4)</td>
|
||
<td>@t.Unit</td>
|
||
<td>@t.ValidFrom.ToString("yyyy-MM-dd")</td>
|
||
<td>@(t.ValidTo?.ToString("yyyy-MM-dd") ?? S.MeterDetail_TariffOpenEnd)</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</MudSimpleTable>
|
||
}
|
||
</MudTabPanel>
|
||
|
||
<MudTabPanel Text="@Loc.F(S.MeterDetail_TabSources, _sources.Count)">
|
||
<div class="d-flex justify-end mb-2">
|
||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenSource(null))">
|
||
@S.MeterDetail_AddSource
|
||
</MudButton>
|
||
</div>
|
||
@if (_sources.Count == 0)
|
||
{
|
||
<MudText Typo="Typo.body2" Color="Color.Secondary">@S.MeterDetail_NoSources</MudText>
|
||
}
|
||
else
|
||
{
|
||
<MudSimpleTable Dense="true" Hover="true">
|
||
<thead><tr><th>@S.Common_Type</th><th>@S.Common_Target</th><th>@S.MeterDetail_Connector</th><th>@S.Common_Enabled</th><th>@S.Common_LastSeen</th><th style="text-align:right">@S.MeterDetail_LastValue</th><th>@S.Common_Status</th><th style="text-align:right">@S.Common_Actions</th></tr></thead>
|
||
<tbody>
|
||
@foreach (var s in _sources)
|
||
{
|
||
<tr>
|
||
<td>@s.SourceType.Display()</td>
|
||
<td>@SourceTarget(s)</td>
|
||
<td>
|
||
@{ var problem = ConnectorProblem(s); }
|
||
@if (problem is null)
|
||
{
|
||
@(_endpoints.FirstOrDefault(e => e.Id == s.EndpointId)?.Name ?? "—")
|
||
}
|
||
else
|
||
{
|
||
<MudTooltip Text="@problem">
|
||
<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Text"
|
||
Icon="@Icons.Material.Filled.LinkOff">@problem</MudChip>
|
||
</MudTooltip>
|
||
}
|
||
</td>
|
||
<td>@(s.IsEnabled ? S.MeterDetail_Yes : S.MeterDetail_No)</td>
|
||
<td>@(s.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</td>
|
||
<td style="text-align:right">@(s.LastValue is { } v ? Format.Number(v, 2) : "—")</td>
|
||
<td>@(s.LastStatus ?? "—")</td>
|
||
<td style="text-align:right">
|
||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenSource(s))" />
|
||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteSourceAsync(s))" />
|
||
</td>
|
||
</tr>
|
||
}
|
||
</tbody>
|
||
</MudSimpleTable>
|
||
}
|
||
</MudTabPanel>
|
||
</MudTabs>
|
||
|
||
<MudDialog @bind-Visible="_readingOpen" Options="_dialogOptions">
|
||
<TitleContent>
|
||
<MudText Typo="Typo.h6">@Loc.F(S.MeterDetail_AddReadingTitle, _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="@Loc.F(S.MeterDetail_ReadingLabel, _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}" : S.MeterDetail_EnterValue)
|
||
</MudText>
|
||
@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. *@
|
||
<MudText Typo="Typo.caption" Color="Color.Warning">@S.MeterDetail_SwappedOrResetHint</MudText>
|
||
}
|
||
else if (ChangeSinceLast is { } change)
|
||
{
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary">@ChangeSinceText(change)</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="_readingWhen.Date" Label="@S.Common_Date" Variant="Variant.Outlined"
|
||
Class="flex-grow-1" Style="min-width:150px" />
|
||
<MudTimePicker @bind-Time="_readingWhen.TimeOfDay" Label="@S.MeterDetail_TimeOfDay" Variant="Variant.Outlined"
|
||
Class="flex-grow-1" Style="min-width:130px" />
|
||
<MudButton Size="Size.Small" Variant="Variant.Text"
|
||
StartIcon="@Icons.Material.Filled.Schedule" OnClick="@(() => _readingWhen.SetNow())">@S.Common_Now</MudButton>
|
||
</div>
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-block mt-1">@Loc.F(S.MeterDetail_LocalTimeIn, _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 (_readingWhen.IsSkipped)
|
||
{
|
||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-3">
|
||
@Loc.F(S.MeterDetail_SkippedTime, _tz.Id)
|
||
</MudAlert>
|
||
}
|
||
@if (WouldBeRejected)
|
||
{
|
||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-3">
|
||
@Loc.F(S.MeterDetail_DecreaseWarning, Format.Number(_detail.LastReadingValue ?? 0, 2), _detail.Unit)
|
||
<div class="d-flex flex-wrap mt-2" style="gap:.5rem">
|
||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.SwapHoriz"
|
||
OnClick="@(() => SwitchToEventAsync(MeterEventType.MeterSwap))">@S.MeterDetail_RecordSwap</MudButton>
|
||
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Warning" StartIcon="@Icons.Material.Filled.RestartAlt"
|
||
OnClick="@(() => SwitchToEventAsync(MeterEventType.CounterReset))">@S.MeterDetail_RecordReset</MudButton>
|
||
</div>
|
||
</MudAlert>
|
||
}
|
||
@if (ReplacesSwapStart)
|
||
{
|
||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_ReplaceSwapStartNotice</MudAlert>
|
||
}
|
||
else if (ReplacesRecentReading)
|
||
{
|
||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||
@S.MeterDetail_ReplaceNotice
|
||
</MudAlert>
|
||
}
|
||
@if (IsFuture)
|
||
{
|
||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">@S.MeterDetail_FutureTime</MudAlert>
|
||
}
|
||
else if (IsBackdated)
|
||
{
|
||
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||
@S.MeterDetail_BackdatedNotice
|
||
</MudAlert>
|
||
}
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<MudButton OnClick="@(() => _readingOpen = false)" Disabled="_readingSaving">@S.Common_Cancel</MudButton>
|
||
<MudButton Color="Color.Primary" Variant="Variant.Filled" Size="Size.Large"
|
||
OnClick="SaveReadingAsync" Disabled="@(!CanSaveReading)">
|
||
@(_readingSaving ? S.MeterDetail_Saving : S.MeterDetail_SaveReading)
|
||
</MudButton>
|
||
</DialogActions>
|
||
</MudDialog>
|
||
|
||
<MudDialog @bind-Visible="_sourceOpen" Options="_dialogOptions">
|
||
<TitleContent>
|
||
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? S.MeterDetail_NewSource : S.MeterDetail_EditSource)</MudText>
|
||
</TitleContent>
|
||
<DialogContent>
|
||
<MudSelect T="SourceType" Value="_sourceEdit.SourceType" ValueChanged="OnSourceTypeChanged" Label="@S.MeterDetail_SourceType" Class="mb-2">
|
||
@foreach (var type in Enum.GetValues<SourceType>())
|
||
{
|
||
<MudSelectItem T="SourceType" Value="type">@type.Display()</MudSelectItem>
|
||
}
|
||
</MudSelect>
|
||
@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)
|
||
{
|
||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||
@if (_endpoints.FirstOrDefault(e => e.Type == needed && !e.IsEnabled) is { } disabled)
|
||
{
|
||
<span>@Loc.F(S.MeterDetail_ConnectorOnlyDisabled, disabled.Name) <MudLink Href="@MeterLinks.EditConnector(Id, SourceIdOrNull, _sourceEdit.SourceType, disabled.Id)">@S.MeterDetail_EnableConnectorLink</MudLink></span>
|
||
}
|
||
else
|
||
{
|
||
<span>@Loc.F(S.MeterDetail_NoConnectorYet, needed.Display()) <MudLink Href="@MeterLinks.NewConnector(Id, SourceIdOrNull, _sourceEdit.SourceType, needed)">@S.MeterDetail_CreateConnectorLink</MudLink> @S.MeterDetail_CreateConnectorHint</span>
|
||
}
|
||
</MudAlert>
|
||
}
|
||
else
|
||
{
|
||
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="@S.MeterDetail_Connector" Required="true" Class="mb-1">
|
||
@foreach (var e in usable)
|
||
{
|
||
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name</MudSelectItem>
|
||
}
|
||
</MudSelect>
|
||
<div class="mb-2">
|
||
<MudLink Typo="Typo.caption" Href="@MeterLinks.NewConnector(Id, SourceIdOrNull, _sourceEdit.SourceType, needed)">
|
||
@S.MeterDetail_AnotherConnector
|
||
</MudLink>
|
||
</div>
|
||
}
|
||
}
|
||
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
|
||
{
|
||
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="@S.MeterDetail_EntityIdLabel" Class="mb-2" />
|
||
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="@S.MeterDetail_AttributeLabel" Class="mb-2" />
|
||
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollMinutes" Label="@S.MeterDetail_PollIntervalLabel" Class="mb-1" />
|
||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2 d-block">
|
||
@S.MeterDetail_PollHint
|
||
</MudText>
|
||
}
|
||
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
||
{
|
||
<MudTextField @bind-Value="_sourceEdit.Topic" Label="@S.MeterDetail_TopicLabel" Class="mb-2" />
|
||
<MudTextField @bind-Value="_sourceEdit.Path" Label="@S.MeterDetail_ValuePathLabel" Class="mb-2" />
|
||
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="@S.MeterDetail_TimePathLabel" Class="mb-2" />
|
||
}
|
||
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="@S.MeterDetail_ValueKind" Class="mb-2">
|
||
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||
{
|
||
<MudSelectItem T="SourceValueKind" Value="kind">@kind.Display()</MudSelectItem>
|
||
}
|
||
</MudSelect>
|
||
<div class="d-flex" style="gap:1rem">
|
||
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="@S.MeterDetail_Scale" Class="mb-2" />
|
||
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="@S.MeterDetail_Offset" Class="mb-2" />
|
||
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="@S.MeterDetail_Priority" Class="mb-2" />
|
||
</div>
|
||
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="@S.Common_Enabled" Color="Color.Primary" />
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<MudButton OnClick="CancelSource">@S.Common_Cancel</MudButton>
|
||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">@S.Common_Save</MudButton>
|
||
</DialogActions>
|
||
</MudDialog>
|
||
|
||
<MeterEventDialog @ref="_eventDialog" MeterId="Id" MeterName="@_detail.Name" Saved="OnEventSavedAsync" Cancelled="OnEventCancelled" />
|
||
}
|
||
|
||
<MeterEditor @ref="_editor" Saved="OnMeterSavedAsync" SwapInsteadRequested="@(_ => OpenEventAsync(MeterEventType.MeterSwap))" />
|
||
|
||
@code {
|
||
[Parameter]
|
||
public int Id { get; set; }
|
||
|
||
/// <summary>Which tab to open: one of <see cref="MeterLinks.Tabs"/>.</summary>
|
||
[SupplyParameterFromQuery(Name = "tab")]
|
||
public string? Tab { get; set; }
|
||
|
||
/// <summary>A dialog to open once the page is interactive; see <see cref="MeterLinks"/>.</summary>
|
||
[SupplyParameterFromQuery(Name = "action")]
|
||
public string? Action { get; set; }
|
||
|
||
/// <summary>With the source action: the existing source to open instead of a new one.</summary>
|
||
[SupplyParameterFromQuery(Name = MeterLinks.ParamSource)]
|
||
public int? SourceParam { get; set; }
|
||
|
||
/// <summary>With the source action: the source type to preset.</summary>
|
||
[SupplyParameterFromQuery(Name = MeterLinks.ParamSourceType)]
|
||
public string? SourceTypeParam { get; set; }
|
||
|
||
/// <summary>With the source action: the connector to preselect, typically one just created for it.</summary>
|
||
[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<MeterSource> _sources = [];
|
||
private List<IngestionEndpoint> _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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private (string Text, DateTimeOffset? At)? _resumeReading;
|
||
|
||
/// <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;
|
||
|
||
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<SourceType>(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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens a deep-linked dialog. After render, because only the interactive render can show one — a
|
||
/// prerendered page has no circuit to drive it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The action is dropped from the address <em>first</em> 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.
|
||
/// </remarks>
|
||
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<string, object?>
|
||
{
|
||
["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<string>(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)}",
|
||
};
|
||
|
||
/// <summary>
|
||
/// "+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.
|
||
/// </summary>
|
||
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<MeterMonthPoint> 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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private bool BoundaryExplainsDecrease =>
|
||
EnteredUtc is { } entered && _detail is { LastReadingTime: { } last }
|
||
&& _detail.Events.Any(e => MeterEventRules.IsRegisterBoundary(e.Type) && e.Time > last && e.Time <= entered);
|
||
|
||
/// <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 && !BoundaryExplainsDecrease && _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);
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<IngestionService>();
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private async Task RunServiceAsync(Func<MeterEventService, Task<MeterEventResult>> operation, string successText, string what)
|
||
{
|
||
try
|
||
{
|
||
await using var scope = Scopes.CreateAsyncScope();
|
||
var result = await operation(scope.ServiceProvider.GetRequiredService<MeterEventService>());
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<SourceEdit>(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;
|
||
}
|
||
}
|
||
|
||
/// <summary>The source being edited, or null for a new one — what a detour to the connector page returns to.</summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<IngestionEndpoint> ConnectorsFor(EndpointType type) =>
|
||
_endpoints.Where(e => e.Type == type && e.IsEnabled).ToList();
|
||
|
||
/// <summary>
|
||
/// 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".
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>What a link presets in the source dialog; see <see cref="MeterLinks.Source"/>.</summary>
|
||
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) =>@<MudChip T="string" Size="Size.Small" Variant="Variant.Text"
|
||
Color="@(quality == ReadingQuality.Measured ? Color.Success : quality == ReadingQuality.Estimated ? Color.Warning : Color.Default)">@quality.Display()</MudChip>;
|
||
}
|