Compare commits
6
Commits
v0.1.0
...
92438348e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92438348e7 | ||
|
|
ecadbe1477 | ||
|
|
85d650a8f5 | ||
|
|
7e34aeccc8 | ||
|
|
09cd435c2b | ||
|
|
1282acf82c |
@@ -5,7 +5,6 @@
|
|||||||
.git/
|
.git/
|
||||||
.github/
|
.github/
|
||||||
docs/
|
docs/
|
||||||
sampledata/
|
|
||||||
tests/
|
tests/
|
||||||
**/*.user
|
**/*.user
|
||||||
**/appsettings.*.Local.json
|
**/appsettings.*.Local.json
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
|
MeterVault is a self-hosted, local-first energy & utility metering platform: it ingests meter data from Home Assistant, Tasmota and MQTT on a schedule, stores every reading timestamped and immutable, normalizes it into consumption, and turns it into cost dashboards. Energy types (electricity, water, heating oil, gas, …) and meters are **user-defined, never hardcoded**.
|
||||||
|
|
||||||
**Status: implemented (M0–M7).** The full solution is built and green — five projects, ~95 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). Remaining refinements (HA WebSocket push, dedicated PV/oil dashboard panels, full admin CRUD, full de-DE UI localization) are noted at the end of their milestone commits.
|
**Status: implemented (M0–M7) + SDD §8 panels.** The full solution is built and green — five projects, ~108 tests, working Docker deploy. `docs/SDD.md` remains the design reference; the milestone map (§12) matches the git history (M0…M7 commits). The dedicated **PV/Solar** (`/solar`), **Oil/consumable** (`/consumables`) and **meter-detail** (`/meters/{id}`) views (SDD §8.4–§8.6) are implemented as read models in `Infrastructure/Dashboard` (`SolarService`, `ConsumableService`, `MeterDetailService`) — PV meters are found by `Mode == GenerationCounter` and grid/load meters by a `role` tag in `Meter.Meta` (`MeterRoles`/`MeterMeta`), so nothing is hardcoded by name. **Admin write-CRUD** (SDD §8.7) is implemented as MudBlazor inline-dialog pages: energy types, meters (+ recompute on mode/baseline change), a meter's ingest sources (meter-detail Sources tab), tariffs, cost categories + members, and connectors (`ingestion_endpoint`, secrets by env-var reference only). `/admin/settings` is a read-only effective-config view (settings are env-driven and reproducible, not DB-stored). **Home Assistant reading** is configured here: an HA connector (`BaseUrl` + `TokenEnv`) + an HA source (entity id) drives `HomeAssistantWorker`'s REST poll; `HaConnectionTester` powers the connector "Test connection" button. **Meter topology & flow**: `MeterLink` (a directed `from→to` edge; a downstream meter is a *subsection* of an upstream one, multi-parent allowed) drives a per-energy-type page `/energy/{id}` with a hand-rolled SVG **Sankey** (`SankeyChart.razor`, since ApexCharts has no Sankey type) computed by `FlowService` (link value = downstream consumption, split proportionally across multiple parents; unaccounted remainder → an "Other" node). Upstream meters are wired cycle-safely in the meter editor; the nav lists a link per energy type. Remaining refinements (HA WebSocket *push* — REST poll works today; commit-arbitrary-CSV-from-UI needs a meter-mapping wizard; full de-DE UI-string localization) are noted at their commits. Set `MeterVault__SeedReferenceData=true` (compose: `METERVAULT_SEED=true`) for a one-command populated demo.
|
||||||
|
|
||||||
## Source of truth
|
## Source of truth
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,18 @@ full design.
|
|||||||
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
per meter; **cost categories** decoupled from energy types; meterless manual costs.
|
||||||
- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw.
|
- **Continuous aggregates** (daily/monthly/yearly, local timezone) so dashboards never scan raw.
|
||||||
- **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/
|
- **Dashboard**: cost KPIs with period-over-period deltas, "what costs most", a "what cost more/
|
||||||
less" difference view, trends, meter list, one-click reference-data load, CSV dry-run.
|
less" difference view, trends, a **PV/Solar panel** (generation, self-consumption, autarky %,
|
||||||
|
savings), an **oil/consumable panel** (tank gauge, deliveries, burner runtime, effective L/h,
|
||||||
|
forecast-to-empty) and a **per-meter detail view** (raw readings, consumption, sources, tariff
|
||||||
|
timeline, events), one-click reference-data load, CSV dry-run.
|
||||||
|
- **Per-energy-type flow pages** (Electricity, Water, …): a **Sankey diagram** of the meter chain —
|
||||||
|
a downstream meter is a *subsection* of an upstream one (main → car, pool, garden, …), arrow
|
||||||
|
thickness ∝ amount, with an auto-computed "Other/unmetered" remainder. Meters can have several
|
||||||
|
upstreams (a merge, e.g. grid + solar → house).
|
||||||
|
- **Admin UI**: full create/edit/delete for energy types, meters (with consumption recompute on
|
||||||
|
mode/baseline change, and cycle-safe upstream-meter wiring), ingest sources, tariffs, cost
|
||||||
|
categories, and MQTT/Home-Assistant connectors; a "Test connection" for Home Assistant;
|
||||||
|
effective-settings view.
|
||||||
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
|
- **REST API + OpenAPI/Swagger**, API-key auth, reverse-proxy trust (Authelia/Traefik).
|
||||||
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
- **JSON config export/import** for portability; Docker Compose + multi-arch image.
|
||||||
|
|
||||||
@@ -31,6 +42,7 @@ full design.
|
|||||||
```bash
|
```bash
|
||||||
docker compose -f deploy/docker-compose.yml up -d
|
docker compose -f deploy/docker-compose.yml up -d
|
||||||
# open http://localhost:8080 → Import → "Load reference data" for a populated demo
|
# open http://localhost:8080 → Import → "Load reference data" for a populated demo
|
||||||
|
# ...or start pre-populated: METERVAULT_SEED=true docker compose -f deploy/docker-compose.yml up -d
|
||||||
# API docs at http://localhost:8080/swagger
|
# API docs at http://localhost:8080/swagger
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,6 +56,7 @@ Configuration is via environment variables (`Section__Key` double-underscore map
|
|||||||
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
|
| `MeterVault__AllowAnonymousApi` | `true` to open the REST API without a key (trusted LAN only) |
|
||||||
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
| `MeterVault__ReverseProxyTrust` | `true` to honour `X-Forwarded-User` behind an auth proxy |
|
||||||
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
| `MeterVault__EnableLiveIngestion` | `false` to disable the MQTT/HA workers |
|
||||||
|
| `MeterVault__SeedReferenceData` | `true` to load the bundled demo dataset on first start (idempotent) |
|
||||||
|
|
||||||
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
|
The REST API is **closed by default**: with no `ApiKeys` configured and `AllowAnonymousApi` off, it
|
||||||
returns 401. Set at least one API key (or open it explicitly for a trusted network).
|
returns 401. Set at least one API key (or open it explicitly for a trusted network).
|
||||||
|
|||||||
+3
-1
@@ -11,8 +11,10 @@ COPY src/Infrastructure/MeterVault.Infrastructure.csproj src/Infrastructure/
|
|||||||
COPY src/App/MeterVault.App.csproj src/App/
|
COPY src/App/MeterVault.App.csproj src/App/
|
||||||
RUN dotnet restore src/App/MeterVault.App.csproj
|
RUN dotnet restore src/App/MeterVault.App.csproj
|
||||||
|
|
||||||
# Build & publish.
|
# Build & publish. sampledata/ must be present so the App csproj's linked Content glob
|
||||||
|
# (..\..\sampledata\*.csv) resolves at publish time — otherwise "Load reference data" ships empty.
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
COPY sampledata/ ./sampledata/
|
||||||
RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish src/App/MeterVault.App.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
# Debian-based runtime (keeps full ICU — required by the de-DE CSV importer; do not use
|
# Debian-based runtime (keeps full ICU — required by the de-DE CSV importer; do not use
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ services:
|
|||||||
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
MeterVault__TimeZone: ${METERVAULT_TIMEZONE:-Europe/Berlin}
|
||||||
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
MeterVault__Currency: ${METERVAULT_CURRENCY:-EUR}
|
||||||
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
|
MeterVault__Locale: ${METERVAULT_LOCALE:-en}
|
||||||
|
# Set true for a populated demo: loads the bundled Energiebilanz dataset on first start
|
||||||
|
# (idempotent). Leave false for a clean instance.
|
||||||
|
MeterVault__SeedReferenceData: ${METERVAULT_SEED:-false}
|
||||||
# REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN):
|
# REST API is closed by default. Set a key to enable it (or AllowAnonymousApi on a trusted LAN):
|
||||||
# MeterVault__ApiKeys__0: your-secret-key
|
# MeterVault__ApiKeys__0: your-secret-key
|
||||||
# MeterVault__AllowAnonymousApi: "true"
|
# MeterVault__AllowAnonymousApi: "true"
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||||
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
|
<link rel="stylesheet" href="@Assets["MeterVault.App.styles.css"]" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
<HeadOutlet />
|
<HeadOutlet @rendermode="InteractiveServer" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<Routes />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
<MudLayout>
|
<MudLayout>
|
||||||
<MudAppBar Elevation="1" Dense="true">
|
<MudAppBar Elevation="1" Dense="true">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start"
|
||||||
|
OnClick="@(() => _drawerOpen = !_drawerOpen)" aria-label="Toggle navigation" />
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
<MudIcon Icon="@Icons.Material.Filled.Bolt" Class="mr-2" />
|
||||||
<MudText Typo="Typo.h6">MeterVault</MudText>
|
<MudText Typo="Typo.h6">MeterVault</MudText>
|
||||||
<MudSpacer />
|
<MudSpacer />
|
||||||
|
|||||||
@@ -1,11 +1,55 @@
|
|||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MeterVault.Core.Domain
|
||||||
|
|
||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
||||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
||||||
|
|
||||||
|
@foreach (var type in _energyTypes)
|
||||||
|
{
|
||||||
|
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
|
||||||
|
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
|
||||||
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
||||||
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
<MudNavLink Href="/import" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Import</MudNavLink>
|
||||||
<MudDivider Class="my-2" />
|
<MudDivider Class="my-2" />
|
||||||
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
<MudNavGroup Title="Admin" Icon="@Icons.Material.Filled.Settings" Expanded="false">
|
||||||
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
|
<MudNavLink Href="/admin/energy-types" Icon="@Icons.Material.Filled.Category">Energy types</MudNavLink>
|
||||||
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
|
<MudNavLink Href="/admin/tariffs" Icon="@Icons.Material.Filled.Euro">Tariffs</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/categories" Icon="@Icons.Material.Filled.Folder">Cost categories</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/connectors" Icon="@Icons.Material.Filled.SettingsInputComponent">Connectors</MudNavLink>
|
||||||
|
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
|
||||||
</MudNavGroup>
|
</MudNavGroup>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<EnergyType> _energyTypes = [];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Nav must never break the layout — a DB hiccup just hides the per-type links.
|
||||||
|
_energyTypes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
|
||||||
|
private static string TypeIcon(string? icon) => icon switch
|
||||||
|
{
|
||||||
|
"bolt" => Icons.Material.Filled.Bolt,
|
||||||
|
"water_drop" => Icons.Material.Filled.WaterDrop,
|
||||||
|
"local_gas_station" => Icons.Material.Filled.LocalGasStation,
|
||||||
|
"gas_meter" => Icons.Material.Filled.GasMeter,
|
||||||
|
"thermostat" => Icons.Material.Filled.Thermostat,
|
||||||
|
_ => Icons.Material.Filled.Bolt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
@page "/admin/categories"
|
||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — Cost categories</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">Cost categories</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
Add category
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_categories is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable Items="_categories" Dense="true" Hover="true" Elevation="2">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Name</MudTh>
|
||||||
|
<MudTh>Sort</MudTh>
|
||||||
|
<MudTh>Members</MudTh>
|
||||||
|
<MudTh Style="text-align:right">Actions</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="Name">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||||
|
{
|
||||||
|
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||||
|
}
|
||||||
|
@context.Name
|
||||||
|
</MudTd>
|
||||||
|
<MudTd DataLabel="Sort">@context.Sort</MudTd>
|
||||||
|
<MudTd DataLabel="Members">@MemberSummary(context)</MudTd>
|
||||||
|
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New category" : $"Edit {_working.Name}")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #ff9800)" Class="mb-2" />
|
||||||
|
<MudNumericField T="int" @bind-Value="_working.Sort" Label="Sort order" Class="mb-2" />
|
||||||
|
|
||||||
|
@if (_working.Id != 0)
|
||||||
|
{
|
||||||
|
<MudDivider Class="my-3" />
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">Members</MudText>
|
||||||
|
@if (_members.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">No members yet — add a meter or an energy type.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudList T="string" Dense="true">
|
||||||
|
@foreach (var m in _members)
|
||||||
|
{
|
||||||
|
<MudListItem T="string">
|
||||||
|
<div class="d-flex align-center justify-space-between">
|
||||||
|
<span>@MemberLabel(m)</span>
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small" OnClick="@(() => RemoveMemberAsync(m.Id))" />
|
||||||
|
</div>
|
||||||
|
</MudListItem>
|
||||||
|
}
|
||||||
|
</MudList>
|
||||||
|
}
|
||||||
|
<div class="d-flex align-center mt-2" style="gap:.5rem; flex-wrap:wrap">
|
||||||
|
<MudSelect T="int?" @bind-Value="_addMeterId" Label="Add meter" Dense="true" Style="min-width:180px">
|
||||||
|
@foreach (var meter in _meters)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int?" Value="@((int?)meter.Id)">@meter.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudButton Size="Size.Small" OnClick="AddMeterMemberAsync" Disabled="_addMeterId is null">Add</MudButton>
|
||||||
|
<MudSelect T="int?" @bind-Value="_addTypeId" Label="Add energy type" Dense="true" Style="min-width:180px">
|
||||||
|
@foreach (var t in _energyTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudButton Size="Size.Small" OnClick="AddTypeMemberAsync" Disabled="_addTypeId is null">Add</MudButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Save the category first to add members.</MudAlert>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">Close</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<CostCategory>? _categories;
|
||||||
|
private List<Meter> _meters = [];
|
||||||
|
private List<EnergyType> _energyTypes = [];
|
||||||
|
private List<CostCategoryMember> _members = [];
|
||||||
|
private bool _editOpen;
|
||||||
|
private EditModel _working = new();
|
||||||
|
private int? _addMeterId;
|
||||||
|
private int? _addTypeId;
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
_categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
|
||||||
|
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||||
|
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MemberSummary(CostCategory c)
|
||||||
|
{
|
||||||
|
var meters = c.Members.Count(m => m.MeterId is not null);
|
||||||
|
var types = c.Members.Count(m => m.EnergyTypeId is not null);
|
||||||
|
return meters + types == 0 ? "—" : $"{meters} meter(s), {types} type(s)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string MemberLabel(CostCategoryMember m) =>
|
||||||
|
m.MeterId is { } meterId ? $"Meter: {_meters.FirstOrDefault(x => x.Id == meterId)?.Name ?? $"#{meterId}"}"
|
||||||
|
: m.EnergyTypeId is { } typeId ? $"Type: {_energyTypes.FirstOrDefault(x => x.Id == typeId)?.DisplayName ?? $"#{typeId}"}"
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
private void OpenEdit(CostCategory? category)
|
||||||
|
{
|
||||||
|
if (category is null)
|
||||||
|
{
|
||||||
|
_working = new EditModel();
|
||||||
|
_members = [];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_working = new EditModel { Id = category.Id, Name = category.Name, ColorHex = category.ColorHex, Sort = category.Sort };
|
||||||
|
_members = [.. category.Members];
|
||||||
|
}
|
||||||
|
_addMeterId = null;
|
||||||
|
_addTypeId = null;
|
||||||
|
_editOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||||
|
{
|
||||||
|
Snackbar.Add("Name is required.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
if (_working.Id == 0)
|
||||||
|
{
|
||||||
|
var category = new CostCategory { Name = _working.Name.Trim(), ColorHex = Trim(_working.ColorHex), Sort = _working.Sort };
|
||||||
|
db.CostCategories.Add(category);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
// Re-open on the new category so members can be added.
|
||||||
|
Snackbar.Add("Saved. Add members below.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
OpenEdit(_categories!.First(c => c.Id == category.Id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await db.CostCategories.FirstAsync(c => c.Id == _working.Id);
|
||||||
|
existing.Name = _working.Name.Trim();
|
||||||
|
existing.ColorHex = Trim(_working.ColorHex);
|
||||||
|
existing.Sort = _working.Sort;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_editOpen = false;
|
||||||
|
Snackbar.Add("Saved.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddMeterMemberAsync()
|
||||||
|
{
|
||||||
|
if (_addMeterId is not { } meterId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
if (!await db.CostCategoryMembers.AnyAsync(m => m.CategoryId == _working.Id && m.MeterId == meterId))
|
||||||
|
{
|
||||||
|
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = _working.Id, MeterId = meterId });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
_addMeterId = null;
|
||||||
|
await ReloadMembersAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddTypeMemberAsync()
|
||||||
|
{
|
||||||
|
if (_addTypeId is not { } typeId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
if (!await db.CostCategoryMembers.AnyAsync(m => m.CategoryId == _working.Id && m.EnergyTypeId == (short)typeId))
|
||||||
|
{
|
||||||
|
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = _working.Id, EnergyTypeId = (short)typeId });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
_addTypeId = null;
|
||||||
|
await ReloadMembersAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveMemberAsync(int memberId)
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
await db.CostCategoryMembers.Where(m => m.Id == memberId).ExecuteDeleteAsync();
|
||||||
|
await ReloadMembersAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReloadMembersAsync()
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
_members = await db.CostCategoryMembers.AsNoTracking().Where(m => m.CategoryId == _working.Id).ToListAsync();
|
||||||
|
_categories = await db.CostCategories.AsNoTracking().Include(c => c.Members).OrderBy(c => c.Sort).ThenBy(c => c.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(CostCategory category)
|
||||||
|
{
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, "Delete category",
|
||||||
|
$"Delete '{category.Name}' and its {category.Members.Count} membership(s)? Manual costs in this category are kept but unlinked."))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
// Members cascade with the category; manual_cost.category_id is SetNull.
|
||||||
|
await db.CostCategories.Where(c => c.Id == category.Id).ExecuteDeleteAsync();
|
||||||
|
Snackbar.Add("Deleted.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private sealed class EditModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string? ColorHex { get; set; }
|
||||||
|
public int Sort { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
@page "/admin/connectors"
|
||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject MeterVault.Infrastructure.Ingestion.HaConnectionTester HaTester
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — Connectors</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">Connectors</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
Add connector
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||||
|
Secrets are never stored here. Credentials/tokens are referenced by the <b>name of an environment variable</b>
|
||||||
|
(or Docker secret) resolved at runtime.
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
@if (_endpoints is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable Items="_endpoints" Dense="true" Hover="true" Elevation="2">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>Name</MudTh>
|
||||||
|
<MudTh>Type</MudTh>
|
||||||
|
<MudTh>Enabled</MudTh>
|
||||||
|
<MudTh>Last status</MudTh>
|
||||||
|
<MudTh>Last seen</MudTh>
|
||||||
|
<MudTh Style="text-align:right">Actions</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||||
|
<MudTd DataLabel="Type">@context.Type</MudTd>
|
||||||
|
<MudTd DataLabel="Enabled">@(context.IsEnabled ? "yes" : "no")</MudTd>
|
||||||
|
<MudTd DataLabel="Last status">@(context.LastStatus ?? "—")</MudTd>
|
||||||
|
<MudTd DataLabel="Last seen">@(context.LastSeenAt?.ToString("yyyy-MM-dd HH:mm") ?? "—")</MudTd>
|
||||||
|
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
@if (_endpoints.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Normal" Class="mt-4">No connectors yet. Add an MQTT broker or a Home Assistant connection.</MudAlert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New connector" : $"Edit {_working.Name}")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudSelect T="EndpointType" @bind-Value="_working.Type" Label="Type" Class="mb-2">
|
||||||
|
@foreach (var type in Enum.GetValues<EndpointType>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="EndpointType" Value="type">@type</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||||
|
|
||||||
|
@if (_working.Type == EndpointType.HomeAssistant)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.BaseUrl" Label="Base URL (e.g. http://homeassistant.local:8123)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.TokenEnv" Label="Token env-var name (e.g. HA_TOKEN)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.TestEntityId" Label="Test entity id (optional, e.g. sensor.house_power)" Class="mb-2" />
|
||||||
|
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.NetworkCheck" OnClick="TestHaAsync" Disabled="_testing" Class="mb-2">
|
||||||
|
Test connection
|
||||||
|
</MudButton>
|
||||||
|
@if (_testing)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mb-2" />
|
||||||
|
}
|
||||||
|
@if (_testResult is not null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="@(_testResult.Ok ? Severity.Success : Severity.Error)" Dense="true" Class="mb-2">@_testResult.Message</MudAlert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_working.Host" Label="Host" Class="mb-2" />
|
||||||
|
<MudNumericField T="int" @bind-Value="_working.Port" Label="Port" Class="mb-2" />
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.Tls" Label="TLS" Color="Color.Primary" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.UsernameEnv" Label="Username env-var name (optional)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.PasswordEnv" Label="Password env-var name (optional)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.ExtraTopics" Label="Extra topics (comma-separated, optional)" Class="mb-2" />
|
||||||
|
}
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<IngestionEndpoint>? _endpoints;
|
||||||
|
private bool _editOpen;
|
||||||
|
private bool _testing;
|
||||||
|
private HaTestResult? _testResult;
|
||||||
|
private EditModel _working = new();
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
_endpoints = await db.IngestionEndpoints.AsNoTracking().OrderBy(e => e.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEdit(IngestionEndpoint? endpoint)
|
||||||
|
{
|
||||||
|
_testResult = null;
|
||||||
|
if (endpoint is null)
|
||||||
|
{
|
||||||
|
_working = new EditModel();
|
||||||
|
}
|
||||||
|
else if (endpoint.Type == EndpointType.HomeAssistant)
|
||||||
|
{
|
||||||
|
var ha = HaEndpointConfig.Parse(endpoint.Config);
|
||||||
|
_working = new EditModel
|
||||||
|
{
|
||||||
|
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||||
|
BaseUrl = ha.BaseUrl, TokenEnv = ha.TokenEnv,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var mqtt = EndpointConfig.Parse(endpoint.Config);
|
||||||
|
_working = new EditModel
|
||||||
|
{
|
||||||
|
Id = endpoint.Id, Type = endpoint.Type, Name = endpoint.Name, IsEnabled = endpoint.IsEnabled,
|
||||||
|
Host = mqtt.Host, Port = mqtt.Port, Tls = mqtt.Tls,
|
||||||
|
UsernameEnv = mqtt.UsernameEnv, PasswordEnv = mqtt.PasswordEnv,
|
||||||
|
ExtraTopics = string.Join(", ", mqtt.ExtraTopics),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_editOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TestHaAsync()
|
||||||
|
{
|
||||||
|
_testing = true;
|
||||||
|
_testResult = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_testResult = await HaTester.TestAsync(_working.BaseUrl, _working.TokenEnv, _working.TestEntityId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_working.Name))
|
||||||
|
{
|
||||||
|
Snackbar.Add("Name is required.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = _working.Type == EndpointType.HomeAssistant
|
||||||
|
? new HaEndpointConfig { BaseUrl = Trim(_working.BaseUrl), TokenEnv = Trim(_working.TokenEnv) }.ToJson()
|
||||||
|
: new EndpointConfig
|
||||||
|
{
|
||||||
|
Host = string.IsNullOrWhiteSpace(_working.Host) ? "localhost" : _working.Host.Trim(),
|
||||||
|
Port = _working.Port,
|
||||||
|
Tls = _working.Tls,
|
||||||
|
UsernameEnv = Trim(_working.UsernameEnv),
|
||||||
|
PasswordEnv = Trim(_working.PasswordEnv),
|
||||||
|
ExtraTopics = SplitTopics(_working.ExtraTopics),
|
||||||
|
}.ToJson();
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
if (_working.Id == 0)
|
||||||
|
{
|
||||||
|
db.IngestionEndpoints.Add(new IngestionEndpoint
|
||||||
|
{
|
||||||
|
Type = _working.Type, Name = _working.Name.Trim(), Config = config, IsEnabled = _working.IsEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await db.IngestionEndpoints.FirstAsync(e => e.Id == _working.Id);
|
||||||
|
existing.Type = _working.Type;
|
||||||
|
existing.Name = _working.Name.Trim();
|
||||||
|
existing.Config = config;
|
||||||
|
existing.IsEnabled = _working.IsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_editOpen = false;
|
||||||
|
Snackbar.Add("Saved.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(IngestionEndpoint endpoint)
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var sourceCount = await db.MeterSources.CountAsync(s => s.EndpointId == endpoint.Id);
|
||||||
|
var note = sourceCount > 0 ? $" {sourceCount} source(s) reference it and will be unlinked." : "";
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, "Delete connector", $"Delete '{endpoint.Name}'?{note}"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.IngestionEndpoints.Where(e => e.Id == endpoint.Id).ExecuteDeleteAsync();
|
||||||
|
Snackbar.Add("Deleted.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> SplitTopics(string? csv) =>
|
||||||
|
string.IsNullOrWhiteSpace(csv) ? [] : [.. csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
|
||||||
|
|
||||||
|
private sealed class EditModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public EndpointType Type { get; set; } = EndpointType.HomeAssistant;
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
// Home Assistant
|
||||||
|
public string? BaseUrl { get; set; }
|
||||||
|
public string? TokenEnv { get; set; }
|
||||||
|
public string? TestEntityId { get; set; }
|
||||||
|
|
||||||
|
// MQTT broker
|
||||||
|
public string? Host { get; set; } = "localhost";
|
||||||
|
public int Port { get; set; } = 1883;
|
||||||
|
public bool Tls { get; set; }
|
||||||
|
public string? UsernameEnv { get; set; }
|
||||||
|
public string? PasswordEnv { get; set; }
|
||||||
|
public string? ExtraTopics { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
@page "/admin/energy-types"
|
@page "/admin/energy-types"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Energy types</PageTitle>
|
<PageTitle>MeterVault — Energy types</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">Energy types</MudText>
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">Energy types</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
Add energy type
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_types is null)
|
@if (_types is null)
|
||||||
{
|
{
|
||||||
@@ -19,22 +26,159 @@ else
|
|||||||
<MudTh>Display name</MudTh>
|
<MudTh>Display name</MudTh>
|
||||||
<MudTh>Base unit</MudTh>
|
<MudTh>Base unit</MudTh>
|
||||||
<MudTh>Default mode</MudTh>
|
<MudTh>Default mode</MudTh>
|
||||||
|
<MudTh Style="text-align:right">Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Key">@context.Key</MudTd>
|
<MudTd DataLabel="Key">@context.Key</MudTd>
|
||||||
<MudTd DataLabel="Display name">@context.DisplayName</MudTd>
|
<MudTd DataLabel="Display name">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.ColorHex))
|
||||||
|
{
|
||||||
|
<span style="display:inline-block;width:.8em;height:.8em;border-radius:2px;margin-right:.4em;background:@context.ColorHex"></span>
|
||||||
|
}
|
||||||
|
@context.DisplayName
|
||||||
|
</MudTd>
|
||||||
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
<MudTd DataLabel="Base unit">@context.BaseUnit</MudTd>
|
||||||
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
<MudTd DataLabel="Default mode">@context.DefaultMode</MudTd>
|
||||||
|
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||||
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New energy type" : $"Edit {_working.DisplayName}")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_working.Key" Label="Key (stable machine key, e.g. electricity)" Required="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.DisplayName" Label="Display name" Required="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.BaseUnit" Label="Base unit (kWh, m3, L, h)" Required="true" Class="mb-2" />
|
||||||
|
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Default mode" Class="mb-2">
|
||||||
|
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField @bind-Value="_working.Icon" Label="Icon (optional)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.ColorHex" Label="Color hex (optional, e.g. #4caf50)" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<EnergyType>? _types;
|
private List<EnergyType>? _types;
|
||||||
|
private bool _editOpen;
|
||||||
|
private EditModel _working = new();
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
_types = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
_types = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OpenEdit(EnergyType? type)
|
||||||
|
{
|
||||||
|
_working = type is null
|
||||||
|
? new EditModel()
|
||||||
|
: new EditModel
|
||||||
|
{
|
||||||
|
Id = type.Id,
|
||||||
|
Key = type.Key,
|
||||||
|
DisplayName = type.DisplayName,
|
||||||
|
BaseUnit = type.BaseUnit,
|
||||||
|
Mode = type.DefaultMode,
|
||||||
|
Icon = type.Icon,
|
||||||
|
ColorHex = type.ColorHex,
|
||||||
|
};
|
||||||
|
_editOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_working.Key) || string.IsNullOrWhiteSpace(_working.DisplayName) || string.IsNullOrWhiteSpace(_working.BaseUnit))
|
||||||
|
{
|
||||||
|
Snackbar.Add("Key, display name and base unit are required.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
if (await db.EnergyTypes.AnyAsync(t => t.Key == _working.Key && t.Id != _working.Id))
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Key '{_working.Key}' is already in use.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_working.Id == 0)
|
||||||
|
{
|
||||||
|
db.EnergyTypes.Add(new EnergyType
|
||||||
|
{
|
||||||
|
Key = _working.Key.Trim(),
|
||||||
|
DisplayName = _working.DisplayName.Trim(),
|
||||||
|
BaseUnit = _working.BaseUnit.Trim(),
|
||||||
|
DefaultMode = _working.Mode,
|
||||||
|
Icon = string.IsNullOrWhiteSpace(_working.Icon) ? null : _working.Icon,
|
||||||
|
ColorHex = string.IsNullOrWhiteSpace(_working.ColorHex) ? null : _working.ColorHex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await db.EnergyTypes.FirstAsync(t => t.Id == _working.Id);
|
||||||
|
existing.Key = _working.Key.Trim();
|
||||||
|
existing.DisplayName = _working.DisplayName.Trim();
|
||||||
|
existing.BaseUnit = _working.BaseUnit.Trim();
|
||||||
|
existing.DefaultMode = _working.Mode;
|
||||||
|
existing.Icon = string.IsNullOrWhiteSpace(_working.Icon) ? null : _working.Icon;
|
||||||
|
existing.ColorHex = string.IsNullOrWhiteSpace(_working.ColorHex) ? null : _working.ColorHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_editOpen = false;
|
||||||
|
Snackbar.Add("Saved.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(EnergyType type)
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var meterCount = await db.Meters.CountAsync(m => m.EnergyTypeId == type.Id);
|
||||||
|
if (meterCount > 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add($"Cannot delete '{type.DisplayName}': {meterCount} meter(s) still use it.", Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, "Delete energy type", $"Delete '{type.DisplayName}'? This cannot be undone."))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = await db.EnergyTypes.FirstOrDefaultAsync(t => t.Id == type.Id);
|
||||||
|
if (target is not null)
|
||||||
|
{
|
||||||
|
db.EnergyTypes.Remove(target);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
Snackbar.Add("Deleted.", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EditModel
|
||||||
|
{
|
||||||
|
public short Id { get; set; }
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
public string BaseUnit { get; set; } = "";
|
||||||
|
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
public string? ColorHex { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@page "/admin/settings"
|
||||||
|
@inject Microsoft.Extensions.Options.IOptions<MeterVault.Infrastructure.Options.MeterVaultOptions> Options
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — Settings</PageTitle>
|
||||||
|
|
||||||
|
<MudText Typo="Typo.h4" Class="mb-2">Settings</MudText>
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mb-4" Dense="true">
|
||||||
|
These are the <b>effective</b> settings the running instance is using. They are configured via environment
|
||||||
|
variables (<code>MeterVault__Key</code> / <code>Section__Key</code>) or Docker/compose, not stored in the
|
||||||
|
database — so config stays reproducible and secrets never land in the DB. Change them in your compose/env and restart.
|
||||||
|
</MudAlert>
|
||||||
|
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Locale & time</MudText>
|
||||||
|
<MudSimpleTable Dense="true">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Timezone</td><td style="text-align:right"><code>@_o.TimeZone</code></td></tr>
|
||||||
|
<tr><td>Locale</td><td style="text-align:right"><code>@_o.Locale</code></td></tr>
|
||||||
|
<tr><td>Currency</td><td style="text-align:right"><code>@_o.Currency</code></td></tr>
|
||||||
|
<tr><td>Raw-reading retention</td><td style="text-align:right">@_o.RawRetentionDays days</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||||
|
Env keys: <code>MeterVault__TimeZone</code>, <code>MeterVault__Locale</code>,
|
||||||
|
<code>MeterVault__Currency</code>, <code>MeterVault__RawRetentionDays</code>.
|
||||||
|
</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="6">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Access & ingestion</MudText>
|
||||||
|
<MudSimpleTable Dense="true">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>REST API</td>
|
||||||
|
<td style="text-align:right">
|
||||||
|
@if (_o.ApiKeys.Count > 0)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Success">@_o.ApiKeys.Count key(s) configured</MudChip>
|
||||||
|
}
|
||||||
|
else if (_o.AllowAnonymousApi)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Warning">open (anonymous)</MudChip>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Default">closed (401)</MudChip>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td>Reverse-proxy trust</td><td style="text-align:right">@(_o.ReverseProxyTrust ? "on" : "off")</td></tr>
|
||||||
|
<tr><td>Live ingestion workers</td><td style="text-align:right">@(_o.EnableLiveIngestion ? "on" : "off")</td></tr>
|
||||||
|
<tr><td>Seed reference data on start</td><td style="text-align:right">@(_o.SeedReferenceData ? "on" : "off")</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-2">
|
||||||
|
Set API keys with <code>MeterVault__ApiKeys__0</code>. Keys themselves are never shown here.
|
||||||
|
API docs at <MudLink Href="/swagger" Target="_blank">/swagger</MudLink>.
|
||||||
|
</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private MeterVault.Infrastructure.Options.MeterVaultOptions _o = new();
|
||||||
|
|
||||||
|
protected override void OnInitialized() => _o = Options.Value;
|
||||||
|
}
|
||||||
@@ -1,20 +1,23 @@
|
|||||||
@page "/admin/tariffs"
|
@page "/admin/tariffs"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Tariffs</PageTitle>
|
<PageTitle>MeterVault — Tariffs</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">Tariffs</MudText>
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">Tariffs</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
Add tariff
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_tariffs is null)
|
@if (_tariffs is null)
|
||||||
{
|
{
|
||||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
}
|
}
|
||||||
else if (_tariffs.Count == 0)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Info">No tariffs yet. Load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
<MudTable Items="_tariffs" Dense="true" Hover="true" Elevation="2">
|
||||||
@@ -25,26 +28,203 @@ else
|
|||||||
<MudTh>Unit</MudTh>
|
<MudTh>Unit</MudTh>
|
||||||
<MudTh>Valid from</MudTh>
|
<MudTh>Valid from</MudTh>
|
||||||
<MudTh>Valid to</MudTh>
|
<MudTh>Valid to</MudTh>
|
||||||
|
<MudTh Style="text-align:right">Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Scope">@context.ScopeType @(context.ScopeId is { } id ? $"#{id}" : "")</MudTd>
|
<MudTd DataLabel="Scope">@ScopeLabel(context)</MudTd>
|
||||||
<MudTd DataLabel="Component">@context.Component</MudTd>
|
<MudTd DataLabel="Component">@context.Component</MudTd>
|
||||||
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
<MudTd DataLabel="Value">@Format.Number(context.Value, 4)</MudTd>
|
||||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||||
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
<MudTd DataLabel="Valid from">@context.ValidFrom.ToString("yyyy-MM-dd")</MudTd>
|
||||||
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
<MudTd DataLabel="Valid to">@(context.ValidTo?.ToString("yyyy-MM-dd") ?? "open")</MudTd>
|
||||||
|
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||||
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
|
@if (_tariffs.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Class="mt-4">No tariffs yet. Add one, or load the reference data from <MudLink Href="/import">Import</MudLink>.</MudAlert>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New tariff" : "Edit tariff")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudSelect T="TariffScope" @bind-Value="_working.ScopeType" Label="Scope" Class="mb-2">
|
||||||
|
@foreach (var scope in Enum.GetValues<TariffScope>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="TariffScope" Value="scope">@scope</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (_working.ScopeType == TariffScope.EnergyType)
|
||||||
|
{
|
||||||
|
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Energy type" Class="mb-2">
|
||||||
|
@foreach (var t in _energyTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int?" Value="@((int?)t.Id)">@t.DisplayName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
}
|
||||||
|
else if (_working.ScopeType == TariffScope.Meter)
|
||||||
|
{
|
||||||
|
<MudSelect T="int?" @bind-Value="_working.ScopeId" Label="Meter" Class="mb-2">
|
||||||
|
@foreach (var m in _meters)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int?" Value="@((int?)m.Id)">@m.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
}
|
||||||
|
<MudSelect T="TariffComponent" @bind-Value="_working.Component" Label="Component" Class="mb-2">
|
||||||
|
@foreach (var component in Enum.GetValues<TariffComponent>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="TariffComponent" Value="component">@component</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudNumericField T="double" @bind-Value="_working.Value" Label="Value" Format="0.####" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Unit" Label="Unit (e.g. EUR/kWh, EUR/m3, EUR/month)" Required="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Currency" Label="Currency" Class="mb-2" />
|
||||||
|
<MudDatePicker @bind-Date="_working.ValidFrom" Label="Valid from" Class="mb-2" />
|
||||||
|
<MudDatePicker @bind-Date="_working.ValidTo" Label="Valid to (empty = open-ended)" Clearable="true" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Notes" Label="Notes (optional)" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Tariff>? _tariffs;
|
private List<Tariff>? _tariffs;
|
||||||
|
private List<EnergyType> _energyTypes = [];
|
||||||
|
private List<Meter> _meters = [];
|
||||||
|
private bool _editOpen;
|
||||||
|
private EditModel _working = new();
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
_tariffs = await db.Tariffs.AsNoTracking()
|
_tariffs = await db.Tariffs.AsNoTracking().OrderBy(t => t.Component).ThenBy(t => t.ValidFrom).ToListAsync();
|
||||||
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||||
.ToListAsync();
|
_meters = await db.Meters.AsNoTracking().OrderBy(m => m.Name).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ScopeLabel(Tariff t) => t.ScopeType switch
|
||||||
|
{
|
||||||
|
TariffScope.Global => "Global",
|
||||||
|
TariffScope.EnergyType => $"Type: {_energyTypes.FirstOrDefault(x => x.Id == t.ScopeId)?.DisplayName ?? $"#{t.ScopeId}"}",
|
||||||
|
TariffScope.Meter => $"Meter: {_meters.FirstOrDefault(x => x.Id == t.ScopeId)?.Name ?? $"#{t.ScopeId}"}",
|
||||||
|
_ => t.ScopeType.ToString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private void OpenEdit(Tariff? tariff)
|
||||||
|
{
|
||||||
|
_working = tariff is null
|
||||||
|
? new EditModel { ValidFrom = DateTime.Today }
|
||||||
|
: new EditModel
|
||||||
|
{
|
||||||
|
Id = tariff.Id,
|
||||||
|
ScopeType = tariff.ScopeType,
|
||||||
|
ScopeId = tariff.ScopeId,
|
||||||
|
Component = tariff.Component,
|
||||||
|
Value = tariff.Value,
|
||||||
|
Unit = tariff.Unit,
|
||||||
|
Currency = tariff.Currency,
|
||||||
|
ValidFrom = tariff.ValidFrom.ToDateTime(TimeOnly.MinValue),
|
||||||
|
ValidTo = tariff.ValidTo?.ToDateTime(TimeOnly.MinValue),
|
||||||
|
Notes = tariff.Notes,
|
||||||
|
};
|
||||||
|
_editOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_working.Unit) || _working.ValidFrom is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Unit and valid-from are required.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_working.ScopeType != TariffScope.Global && _working.ScopeId is null)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Select the energy type or meter this tariff applies to.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeId = _working.ScopeType == TariffScope.Global ? null : _working.ScopeId;
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
if (_working.Id == 0)
|
||||||
|
{
|
||||||
|
db.Tariffs.Add(new Tariff
|
||||||
|
{
|
||||||
|
ScopeType = _working.ScopeType,
|
||||||
|
ScopeId = scopeId,
|
||||||
|
Component = _working.Component,
|
||||||
|
Value = _working.Value,
|
||||||
|
Unit = _working.Unit.Trim(),
|
||||||
|
Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim(),
|
||||||
|
ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value),
|
||||||
|
ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null,
|
||||||
|
Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var existing = await db.Tariffs.FirstAsync(t => t.Id == _working.Id);
|
||||||
|
existing.ScopeType = _working.ScopeType;
|
||||||
|
existing.ScopeId = scopeId;
|
||||||
|
existing.Component = _working.Component;
|
||||||
|
existing.Value = _working.Value;
|
||||||
|
existing.Unit = _working.Unit.Trim();
|
||||||
|
existing.Currency = string.IsNullOrWhiteSpace(_working.Currency) ? "EUR" : _working.Currency.Trim();
|
||||||
|
existing.ValidFrom = DateOnly.FromDateTime(_working.ValidFrom.Value);
|
||||||
|
existing.ValidTo = _working.ValidTo is { } to ? DateOnly.FromDateTime(to) : null;
|
||||||
|
existing.Notes = string.IsNullOrWhiteSpace(_working.Notes) ? null : _working.Notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_editOpen = false;
|
||||||
|
Snackbar.Add("Saved.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Tariff tariff)
|
||||||
|
{
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, "Delete tariff", $"Delete this {tariff.Component} tariff ({Format.Number(tariff.Value, 4)} {tariff.Unit})?"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var target = await db.Tariffs.FirstOrDefaultAsync(t => t.Id == tariff.Id);
|
||||||
|
if (target is not null)
|
||||||
|
{
|
||||||
|
db.Tariffs.Remove(target);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
Snackbar.Add("Deleted.", Severity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EditModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public TariffScope ScopeType { get; set; } = TariffScope.EnergyType;
|
||||||
|
public int? ScopeId { get; set; }
|
||||||
|
public TariffComponent Component { get; set; } = TariffComponent.UnitPrice;
|
||||||
|
public double Value { get; set; }
|
||||||
|
public string Unit { get; set; } = "EUR/kWh";
|
||||||
|
public string Currency { get; set; } = "EUR";
|
||||||
|
public DateTime? ValidFrom { get; set; }
|
||||||
|
public DateTime? ValidTo { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
@page "/consumables"
|
||||||
|
@inject ConsumableService ConsumablesSvc
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — Consumables</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">Oil / consumables</MudText>
|
||||||
|
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||||
|
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_items is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else if (_items.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">
|
||||||
|
No consumable meters found. Add a meter with mode <b>ConsumableBalance</b> and a tank, or load the reference
|
||||||
|
data from <MudLink Href="/import">Import</MudLink>.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var item in _items)
|
||||||
|
{
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-3">@item.Name</MudText>
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Tank level</MudText>
|
||||||
|
<MudText Typo="Typo.h5">
|
||||||
|
@(item.CurrentLevel is { } l ? $"{Format.Number(l, 0)} {item.Unit}" : "—")
|
||||||
|
</MudText>
|
||||||
|
<MudProgressLinear Color="@FillColor(item.FillFraction)" Value="@(item.FillFraction * 100)" Class="my-2" Size="Size.Large" />
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
|
@Format.Number(item.FillFraction * 100, 0)% of @Format.Number(item.Capacity, 0) @item.Unit
|
||||||
|
@if (item.PhysicalLevel is { } cm && item.PhysicalUnit is "cm")
|
||||||
|
{
|
||||||
|
<text> · @Format.Number(cm, 0) cm</text>
|
||||||
|
}
|
||||||
|
@if (item.LevelAsOf is { } asOf)
|
||||||
|
{
|
||||||
|
<text> · as of @asOf.ToString("yyyy-MM-dd")</text>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Used (range)</MudText>
|
||||||
|
<MudText Typo="Typo.subtitle1">@Format.Number(item.ConsumptionInRange, 0) @item.Unit</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Burner runtime</MudText>
|
||||||
|
<MudText Typo="Typo.subtitle1">@(item.BurnerHours is { } h ? $"{Format.Number(h, 0)} h" : "—")</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Effective rate</MudText>
|
||||||
|
<MudText Typo="Typo.subtitle1">
|
||||||
|
@if (item.FixedRate is { } fr)
|
||||||
|
{
|
||||||
|
<text>@Format.Number(fr, 2) @item.Unit/h</text>
|
||||||
|
}
|
||||||
|
else if (item.EffectiveRate is { } er)
|
||||||
|
{
|
||||||
|
<text>@Format.Number(er, 2) @item.Unit/h</text>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<text>—</text>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">@(item.RateMode)</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||||
|
<MudText Typo="Typo.subtitle1">@Format.Euro(item.CostInRange)</MudText>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Forecast to empty</MudText>
|
||||||
|
<MudText Typo="Typo.subtitle1">
|
||||||
|
@(item.ForecastEmpty is { } fe ? fe.ToString("yyyy-MM-dd") : "—")
|
||||||
|
@if (item.AveragePerDay is { } apd)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="d-inline">
|
||||||
|
(@Format.Number(apd, 1) @item.Unit/day)
|
||||||
|
</MudText>
|
||||||
|
}
|
||||||
|
</MudText>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="7">
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">Consumption by month</MudText>
|
||||||
|
<SeriesChart Series="@ChartFor(item)" Decimals="0" Height="260" />
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="5">
|
||||||
|
<MudText Typo="Typo.subtitle2" Class="mb-2">Deliveries (@item.Deliveries.Count)</MudText>
|
||||||
|
@if (item.Deliveries.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">No deliveries recorded.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="max-height:260px; overflow-y:auto">
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th style="text-align:right">Amount</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var delivery in item.Deliveries)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@delivery.Time.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td style="text-align:right">@Format.Number(delivery.Amount, 0) @(delivery.Unit ?? item.Unit)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private int _months = 60;
|
||||||
|
private bool _loading;
|
||||||
|
private IReadOnlyList<ConsumableSummary>? _items;
|
||||||
|
|
||||||
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task OnRangeChanged(int months)
|
||||||
|
{
|
||||||
|
_months = months;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
if (_loading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loading = true;
|
||||||
|
_items = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var from = asOf.AddMonths(-_months);
|
||||||
|
_items = await ConsumablesSvc.GetConsumablesAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<SeriesChart.SeriesDef> ChartFor(ConsumableSummary item)
|
||||||
|
{
|
||||||
|
var points = item.Months
|
||||||
|
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Consumption))
|
||||||
|
.ToList();
|
||||||
|
return [new SeriesChart.SeriesDef($"{item.Unit} used", ApexCharts.SeriesType.Bar, points)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color FillColor(double fraction) => fraction switch
|
||||||
|
{
|
||||||
|
< 0.15 => Color.Error,
|
||||||
|
< 0.30 => Color.Warning,
|
||||||
|
_ => Color.Success,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject DashboardService Dash
|
@inject DashboardService Dash
|
||||||
|
|
||||||
<PageTitle>MeterVault — Overview</PageTitle>
|
<PageTitle>MeterVault — Overview</PageTitle>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
@page "/energy/{Id:int}"
|
||||||
|
@inject FlowService Flow
|
||||||
|
@inject MeterVault.Infrastructure.Costing.CostService Costs
|
||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
|
||||||
|
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||||
|
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_graph is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else if (!_graph.HasData)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">
|
||||||
|
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
|
||||||
|
reference data from <MudLink Href="/import">Import</MudLink>.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudGrid Class="mb-2">
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level throughput</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@_meters.Count</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
|
||||||
|
@if (_graph.HasChain)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||||
|
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
|
||||||
|
</MudText>
|
||||||
|
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="@_graph.Unit" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||||
|
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
|
||||||
|
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
|
||||||
|
</MudAlert>
|
||||||
|
@if (_graph.Nodes.Count > 0)
|
||||||
|
{
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@node.Label</td>
|
||||||
|
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var meter in _meters)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
|
||||||
|
<td>@meter.Mode</td>
|
||||||
|
<td>@UpstreamLabel(meter.Id)</td>
|
||||||
|
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
</MudPaper>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
private int _months = 60;
|
||||||
|
private bool _loading;
|
||||||
|
private FlowGraph? _graph;
|
||||||
|
private double _cost;
|
||||||
|
private List<Meter> _meters = [];
|
||||||
|
private Dictionary<int, List<string>> _downstream = [];
|
||||||
|
|
||||||
|
protected override Task OnParametersSetAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task OnRangeChanged(int months)
|
||||||
|
{
|
||||||
|
_months = months;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
if (_loading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loading = true;
|
||||||
|
_graph = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
|
||||||
|
var to = asOf.AddMonths(1);
|
||||||
|
var typeId = (short)Id;
|
||||||
|
|
||||||
|
_graph = await Flow.GetFlowAsync(typeId, from, to);
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
|
||||||
|
var links = await db.MeterLinks.AsNoTracking()
|
||||||
|
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
|
||||||
|
.ToListAsync();
|
||||||
|
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
|
||||||
|
_downstream = links
|
||||||
|
.GroupBy(l => l.FromMeterId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
|
||||||
|
|
||||||
|
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
double cost = 0;
|
||||||
|
foreach (var meter in _meters)
|
||||||
|
{
|
||||||
|
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
|
||||||
|
}
|
||||||
|
_cost = cost;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
|
||||||
|
|
||||||
|
private string UpstreamLabel(int meterId) =>
|
||||||
|
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/import"
|
@page "/import"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
|
@inject MeterVault.Infrastructure.Import.ReferenceDataImporter ReferenceImporter
|
||||||
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
|
@inject MeterVault.Infrastructure.Import.CsvImporter CsvImporter
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
@page "/meters/{Id:int}"
|
||||||
|
@inject MeterDetailService Details
|
||||||
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MeterVault.Infrastructure.Ingestion
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — Meter</PageTitle>
|
||||||
|
|
||||||
|
@if (_detail is null)
|
||||||
|
{
|
||||||
|
@if (_notFound)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Warning">Meter #@Id not found. <MudLink Href="/meters">Back to meters</MudLink></MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex align-center mb-4" style="gap:.75rem">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack" Href="/meters" Size="Size.Small" />
|
||||||
|
<MudText Typo="Typo.h4">@_detail.Name</MudText>
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Primary">@_detail.EnergyType</MudChip>
|
||||||
|
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@_detail.Mode</MudChip>
|
||||||
|
@if (!_detail.IsActive)
|
||||||
|
{
|
||||||
|
<MudChip T="string" Size="Size.Small" Color="Color.Warning">retired</MudChip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MudGrid Class="mb-2">
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudPaper Class="pa-3" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Consumption</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalConsumption, 0) @_detail.Unit</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
@if (_detail.TotalGeneration != 0)
|
||||||
|
{
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudPaper Class="pa-3" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@Format.Number(_detail.TotalGeneration, 0) @_detail.Unit</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
}
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudPaper Class="pa-3" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Readings</MudText>
|
||||||
|
<MudText Typo="Typo.h6">@_detail.ReadingCount</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||||
|
@(_detail.FirstReadingTime?.ToString("yyyy-MM") ?? "—") … @(_detail.LastReadingTime?.ToString("yyyy-MM") ?? "—")
|
||||||
|
</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="6" sm="3">
|
||||||
|
<MudPaper Class="pa-3" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Register span</MudText>
|
||||||
|
<MudText Typo="Typo.h6">
|
||||||
|
@(_detail.FirstReadingValue is { } f ? Format.Number(f, 0) : "—") →
|
||||||
|
@(_detail.LastReadingValue is { } l ? Format.Number(l, 0) : "—")
|
||||||
|
</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">baseline @Format.Number(_detail.InitialBaseline, 0)</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
|
||||||
|
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" Class="mt-2">
|
||||||
|
<MudTabPanel Text="@($"Readings ({_detail.ReadingCount})")">
|
||||||
|
@if (_detail.RecentReadings.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">No raw readings.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentReadings.Count (raw, immutable audit truth).</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||||
|
<thead><tr><th>Time</th><th style="text-align:right">Value</th><th>Quality</th><th>Flags</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var r in _detail.RecentReadings)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@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 == MeterVault.Core.Domain.ReadingFlags.None ? "" : r.Flags.ToString())</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@($"Consumption ({_detail.ConsumptionCount})")">
|
||||||
|
@if (_detail.RecentConsumption.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">No normalized consumption yet.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Most recent @_detail.RecentConsumption.Count normalized deltas.</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Hover="true" Class="mt-2">
|
||||||
|
<thead><tr><th>Time</th><th style="text-align:right">Amount</th><th>Kind</th><th>Quality</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var c in _detail.RecentConsumption)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@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</td>
|
||||||
|
<td>@QualityChip(c.Quality)</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@($"Events ({_detail.Events.Count})")">
|
||||||
|
@if (_detail.Events.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">No events (swaps, deliveries, corrections).</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead><tr><th>Time</th><th>Type</th><th style="text-align:right">Amount</th><th style="text-align:right">Prev→New</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var e in _detail.Events)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@e.Time.ToString("yyyy-MM-dd")</td>
|
||||||
|
<td>@e.Type</td>
|
||||||
|
<td style="text-align:right">@(e.Amount is { } a ? $"{Format.Number(a, 2)} {e.Unit}" : "—")</td>
|
||||||
|
<td style="text-align:right">@(e.PrevValue is { } p ? $"{Format.Number(p, 0)}→{Format.Number(e.NewValue ?? 0, 0)}" : "—")</td>
|
||||||
|
<td>@e.Notes</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@($"Tariffs ({_detail.Tariffs.Count})")">
|
||||||
|
@if (_detail.Tariffs.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">No applicable tariffs.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead><tr><th>Scope</th><th>Component</th><th style="text-align:right">Value</th><th>Unit</th><th>From</th><th>To</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var t in _detail.Tariffs)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@t.Scope @(t.ScopeId is { } id ? $"#{id}" : "")</td>
|
||||||
|
<td>@t.Component</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") ?? "open")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
}
|
||||||
|
</MudTabPanel>
|
||||||
|
|
||||||
|
<MudTabPanel Text="@($"Sources ({_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))">
|
||||||
|
Add source
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
@if (_sources.Count == 0)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="Color.Secondary">No ingest sources bound to this meter. Add one to pull from MQTT/Tasmota or Home Assistant.</MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<thead><tr><th>Type</th><th>Target</th><th>Enabled</th><th>Last seen</th><th style="text-align:right">Last value</th><th>Status</th><th style="text-align:right">Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var s in _sources)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@s.SourceType</td>
|
||||||
|
<td>@SourceTarget(s)</td>
|
||||||
|
<td>@(s.IsEnabled ? "yes" : "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="_sourceOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_sourceEdit.Id == 0 ? "New source" : "Edit source")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudSelect T="SourceType" @bind-Value="_sourceEdit.SourceType" Label="Source type" Class="mb-2">
|
||||||
|
@foreach (var type in Enum.GetValues<SourceType>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="SourceType" Value="type">@type</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (_sourceEdit.SourceType is SourceType.HomeAssistant or SourceType.Mqtt or SourceType.Tasmota)
|
||||||
|
{
|
||||||
|
<MudSelect T="int?" @bind-Value="_sourceEdit.EndpointId" Label="Connector" Clearable="true" Class="mb-2">
|
||||||
|
@foreach (var e in _endpoints)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int?" Value="@((int?)e.Id)">@e.Name (@e.Type)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
}
|
||||||
|
@if (_sourceEdit.SourceType == SourceType.HomeAssistant)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_sourceEdit.EntityId" Label="Entity id (e.g. sensor.house_power)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_sourceEdit.Attribute" Label="Attribute (optional; blank = state)" Class="mb-2" />
|
||||||
|
<MudNumericField T="int?" @bind-Value="_sourceEdit.PollSeconds" Label="Poll interval (seconds)" Class="mb-2" />
|
||||||
|
}
|
||||||
|
else if (_sourceEdit.SourceType is SourceType.Mqtt or SourceType.Tasmota)
|
||||||
|
{
|
||||||
|
<MudTextField @bind-Value="_sourceEdit.Topic" Label="MQTT topic (e.g. tele/plug1/SENSOR)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_sourceEdit.Path" Label="Value path (e.g. ENERGY.Total; blank = bare scalar)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_sourceEdit.TimePath" Label="Time path (optional, e.g. Time)" Class="mb-2" />
|
||||||
|
}
|
||||||
|
<MudSelect T="SourceValueKind" @bind-Value="_sourceEdit.ValueKind" Label="Value kind" Class="mb-2">
|
||||||
|
@foreach (var kind in Enum.GetValues<SourceValueKind>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="SourceValueKind" Value="kind">@kind</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<div class="d-flex" style="gap:1rem">
|
||||||
|
<MudNumericField T="double" @bind-Value="_sourceEdit.Scale" Label="Scale" Class="mb-2" />
|
||||||
|
<MudNumericField T="double" @bind-Value="_sourceEdit.Offset" Label="Offset" Class="mb-2" />
|
||||||
|
<MudNumericField T="int" @bind-Value="_sourceEdit.Priority" Label="Priority" Class="mb-2" />
|
||||||
|
</div>
|
||||||
|
<MudSwitch T="bool" @bind-Value="_sourceEdit.IsEnabled" Label="Enabled" Color="Color.Primary" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _sourceOpen = false)">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveSourceAsync">Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
private MeterDetailView? _detail;
|
||||||
|
private bool _notFound;
|
||||||
|
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 };
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
_detail = null;
|
||||||
|
_notFound = false;
|
||||||
|
_detail = await Details.GetAsync(Id);
|
||||||
|
_notFound = _detail is null;
|
||||||
|
if (_detail is not null)
|
||||||
|
{
|
||||||
|
await LoadSourcesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 OpenSource(MeterSource? source)
|
||||||
|
{
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
_sourceEdit = new SourceEdit();
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
PollSeconds = config.PollSeconds,
|
||||||
|
Topic = config.Topic,
|
||||||
|
Path = config.Path,
|
||||||
|
TimePath = config.TimePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_sourceOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSourceAsync()
|
||||||
|
{
|
||||||
|
var config = new SourceConfig
|
||||||
|
{
|
||||||
|
EntityId = Trim(_sourceEdit.EntityId),
|
||||||
|
Attribute = Trim(_sourceEdit.Attribute),
|
||||||
|
PollSeconds = _sourceEdit.PollSeconds,
|
||||||
|
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;
|
||||||
|
Snackbar.Add("Source saved.", Severity.Success);
|
||||||
|
await LoadSourcesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSourceAsync(MeterSource source)
|
||||||
|
{
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, "Delete source", $"Delete this {source.SourceType} source?"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
await db.MeterSources.Where(s => s.Id == source.Id).ExecuteDeleteAsync();
|
||||||
|
Snackbar.Add("Source deleted.", Severity.Success);
|
||||||
|
await LoadSourcesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private sealed class SourceEdit
|
||||||
|
{
|
||||||
|
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? PollSeconds { 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</MudChip>;
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
@page "/meters"
|
@page "/meters"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||||
|
@inject MeterVault.Core.Normalization.INormalizationEngine Engine
|
||||||
|
@inject ISnackbar Snackbar
|
||||||
|
@inject IDialogService DialogService
|
||||||
@using Microsoft.EntityFrameworkCore
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
<PageTitle>MeterVault — Meters</PageTitle>
|
<PageTitle>MeterVault — Meters</PageTitle>
|
||||||
|
|
||||||
<MudText Typo="Typo.h4" Class="mb-4">Meters</MudText>
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">Meters</MudText>
|
||||||
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => OpenEdit(null))">
|
||||||
|
Add meter
|
||||||
|
</MudButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (_meters is null)
|
@if (_meters is null)
|
||||||
{
|
{
|
||||||
@@ -21,40 +29,112 @@ else
|
|||||||
<MudTh>Unit</MudTh>
|
<MudTh>Unit</MudTh>
|
||||||
<MudTh>Sources</MudTh>
|
<MudTh>Sources</MudTh>
|
||||||
<MudTh>Last seen</MudTh>
|
<MudTh>Last seen</MudTh>
|
||||||
|
<MudTh>Active</MudTh>
|
||||||
|
<MudTh Style="text-align:right">Actions</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
<MudTd DataLabel="Name"><MudLink Href="@($"/meters/{context.Id}")">@context.Name</MudLink></MudTd>
|
||||||
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
<MudTd DataLabel="Type">@context.EnergyType?.DisplayName</MudTd>
|
||||||
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
<MudTd DataLabel="Mode">@context.Mode</MudTd>
|
||||||
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
<MudTd DataLabel="Unit">@context.Unit</MudTd>
|
||||||
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
<MudTd DataLabel="Sources">@context.Sources.Count</MudTd>
|
||||||
<MudTd DataLabel="Last seen">
|
<MudTd DataLabel="Last seen">
|
||||||
@{
|
@{
|
||||||
var lastSeen = context.Sources
|
var lastSeen = context.Sources.Where(s => s.LastSeenAt != null).Select(s => s.LastSeenAt).DefaultIfEmpty(null).Max();
|
||||||
.Where(s => s.LastSeenAt != null)
|
|
||||||
.Select(s => s.LastSeenAt)
|
|
||||||
.DefaultIfEmpty(null)
|
|
||||||
.Max();
|
|
||||||
}
|
}
|
||||||
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
@(lastSeen?.ToString("yyyy-MM-dd HH:mm") ?? "—")
|
||||||
</MudTd>
|
</MudTd>
|
||||||
|
<MudTd DataLabel="Active">@(context.IsActive ? "yes" : "no")</MudTd>
|
||||||
|
<MudTd DataLabel="Actions" Style="text-align:right">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => OpenEdit(context))" />
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteAsync(context))" />
|
||||||
|
</MudTd>
|
||||||
</RowTemplate>
|
</RowTemplate>
|
||||||
</MudTable>
|
</MudTable>
|
||||||
|
|
||||||
@if (_meters.Count == 0)
|
@if (_meters.Count == 0)
|
||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Info" Class="mt-4">
|
<MudAlert Severity="Severity.Info" Class="mt-4">
|
||||||
No meters yet. Go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
No meters yet. Add one, or go to <MudLink Href="/import">Import</MudLink> to load the reference data.
|
||||||
</MudAlert>
|
</MudAlert>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<MudDialog @bind-Visible="_editOpen" Options="_dialogOptions">
|
||||||
|
<TitleContent>
|
||||||
|
<MudText Typo="Typo.h6">@(_working.Id == 0 ? "New meter" : $"Edit {_working.Name}")</MudText>
|
||||||
|
</TitleContent>
|
||||||
|
<DialogContent>
|
||||||
|
<MudTextField @bind-Value="_working.Name" Label="Name" Required="true" Class="mb-2" />
|
||||||
|
<MudSelect T="short" @bind-Value="_working.EnergyTypeId" Label="Energy type" Class="mb-2">
|
||||||
|
@foreach (var t in _energyTypes)
|
||||||
|
{
|
||||||
|
<MudSelectItem T="short" Value="t.Id">@t.DisplayName</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudSelect T="MeterMode" @bind-Value="_working.Mode" Label="Measurement mode" Class="mb-2">
|
||||||
|
@foreach (var mode in Enum.GetValues<MeterMode>())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="MeterMode" Value="mode">@mode</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
@if (_working.Mode == MeterMode.Virtual)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-2">
|
||||||
|
Virtual "sum" meter — it has no readings of its own. In the flow view it equals the sum of the
|
||||||
|
upstream meters you select below (e.g. Sum Solar = Solar 1 + Solar 2).
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
<MudTextField @bind-Value="_working.Unit" Label="Unit" Required="true" Class="mb-2" />
|
||||||
|
<MudNumericField T="double" @bind-Value="_working.InitialBaseline" Label="Initial register baseline" Class="mb-2" />
|
||||||
|
<MudSelect T="string" @bind-Value="_working.Role" Label="PV role (optional)" Class="mb-2">
|
||||||
|
<MudSelectItem T="string" Value="@("")">— none —</MudSelectItem>
|
||||||
|
<MudSelectItem T="string" Value="@MeterRoles.TotalLoad">total_load</MudSelectItem>
|
||||||
|
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
|
||||||
|
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
|
||||||
|
Label="Sub-meter of (upstream meters)" Class="mb-2"
|
||||||
|
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
||||||
|
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
|
||||||
|
@foreach (var m in AvailableUpstream())
|
||||||
|
{
|
||||||
|
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
|
||||||
|
<div class="d-flex" style="gap:1rem">
|
||||||
|
<MudTextField @bind-Value="_working.Manufacturer" Label="Manufacturer (optional)" Class="mb-2" />
|
||||||
|
<MudTextField @bind-Value="_working.Model" Label="Model (optional)" Class="mb-2" />
|
||||||
|
</div>
|
||||||
|
<MudSwitch T="bool" @bind-Value="_working.IsActive" Label="Active" Color="Color.Primary" />
|
||||||
|
@if (_working.Id != 0 && _working.RecomputeNeeded)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-2">Mode/baseline changed — consumption will be recomputed on save.</MudAlert>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<MudButton OnClick="@(() => _editOpen = false)">Cancel</MudButton>
|
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync">Save</MudButton>
|
||||||
|
</DialogActions>
|
||||||
|
</MudDialog>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Meter>? _meters;
|
private List<Meter>? _meters;
|
||||||
|
private List<EnergyType> _energyTypes = [];
|
||||||
|
private List<MeterLink> _allLinks = [];
|
||||||
|
private bool _editOpen;
|
||||||
|
private EditModel _working = new();
|
||||||
|
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||||
|
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
|
||||||
_meters = await db.Meters
|
_meters = await db.Meters
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(m => m.EnergyType)
|
.Include(m => m.EnergyType)
|
||||||
@@ -62,4 +142,210 @@ else
|
|||||||
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
|
.OrderBy(m => m.EnergyTypeId).ThenBy(m => m.Name)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
|
||||||
|
private IEnumerable<Meter> AvailableUpstream()
|
||||||
|
{
|
||||||
|
if (_meters is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var descendants = Descendants(_working.Id);
|
||||||
|
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashSet<int> Descendants(int meterId)
|
||||||
|
{
|
||||||
|
var result = new HashSet<int>();
|
||||||
|
if (meterId == 0)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var queue = new Queue<int>();
|
||||||
|
queue.Enqueue(meterId);
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var current = queue.Dequeue();
|
||||||
|
foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
|
||||||
|
{
|
||||||
|
if (result.Add(link.ToMeterId))
|
||||||
|
{
|
||||||
|
queue.Enqueue(link.ToMeterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string UpstreamText(IReadOnlyList<string> ids)
|
||||||
|
{
|
||||||
|
var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
|
||||||
|
return string.Join(", ", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEdit(Meter? meter)
|
||||||
|
{
|
||||||
|
if (meter is null)
|
||||||
|
{
|
||||||
|
_working = new EditModel { EnergyTypeId = _energyTypes.FirstOrDefault()?.Id ?? 0 };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_working = new EditModel
|
||||||
|
{
|
||||||
|
Id = meter.Id,
|
||||||
|
Name = meter.Name,
|
||||||
|
EnergyTypeId = meter.EnergyTypeId,
|
||||||
|
Mode = meter.Mode,
|
||||||
|
OriginalMode = meter.Mode,
|
||||||
|
Unit = meter.Unit,
|
||||||
|
InitialBaseline = meter.InitialBaseline,
|
||||||
|
OriginalBaseline = meter.InitialBaseline,
|
||||||
|
Role = MeterMeta.Role(meter.Meta) ?? "",
|
||||||
|
Location = meter.Location,
|
||||||
|
SerialNumber = meter.SerialNumber,
|
||||||
|
Manufacturer = meter.Manufacturer,
|
||||||
|
Model = meter.Model,
|
||||||
|
IsActive = meter.IsActive,
|
||||||
|
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_editOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_working.Name) || string.IsNullOrWhiteSpace(_working.Unit) || _working.EnergyTypeId == 0)
|
||||||
|
{
|
||||||
|
Snackbar.Add("Name, energy type and unit are required.", Severity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
int meterId;
|
||||||
|
if (_working.Id == 0)
|
||||||
|
{
|
||||||
|
var meter = new Meter
|
||||||
|
{
|
||||||
|
Name = _working.Name.Trim(),
|
||||||
|
EnergyTypeId = _working.EnergyTypeId,
|
||||||
|
Mode = _working.Mode,
|
||||||
|
Unit = _working.Unit.Trim(),
|
||||||
|
InitialBaseline = _working.InitialBaseline,
|
||||||
|
Meta = MeterMeta.SetRole("{}", _working.Role),
|
||||||
|
Location = Trim(_working.Location),
|
||||||
|
SerialNumber = Trim(_working.SerialNumber),
|
||||||
|
Manufacturer = Trim(_working.Manufacturer),
|
||||||
|
Model = Trim(_working.Model),
|
||||||
|
IsActive = _working.IsActive,
|
||||||
|
};
|
||||||
|
db.Meters.Add(meter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
meterId = meter.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
var existing = await db.Meters.FirstAsync(m => m.Id == _working.Id);
|
||||||
|
existing.Name = _working.Name.Trim();
|
||||||
|
existing.EnergyTypeId = _working.EnergyTypeId;
|
||||||
|
existing.Mode = _working.Mode;
|
||||||
|
existing.Unit = _working.Unit.Trim();
|
||||||
|
existing.InitialBaseline = _working.InitialBaseline;
|
||||||
|
existing.Meta = MeterMeta.SetRole(existing.Meta, _working.Role);
|
||||||
|
existing.Location = Trim(_working.Location);
|
||||||
|
existing.SerialNumber = Trim(_working.SerialNumber);
|
||||||
|
existing.Manufacturer = Trim(_working.Manufacturer);
|
||||||
|
existing.Model = Trim(_working.Model);
|
||||||
|
existing.IsActive = _working.IsActive;
|
||||||
|
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (_working.RecomputeNeeded)
|
||||||
|
{
|
||||||
|
var normalization = new MeterVault.Infrastructure.Normalization.NormalizationService(db, Engine);
|
||||||
|
await normalization.RecomputeMeterAsync(existing.Id, null);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
|
meterId = existing.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SyncUpstreamAsync(db, meterId, _working.Upstream);
|
||||||
|
|
||||||
|
_editOpen = false;
|
||||||
|
Snackbar.Add("Saved.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
|
||||||
|
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
|
||||||
|
{
|
||||||
|
var desired = desiredUpstream.Where(id => id != meterId).ToHashSet();
|
||||||
|
var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
|
||||||
|
|
||||||
|
foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
|
||||||
|
{
|
||||||
|
db.MeterLinks.Remove(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
|
||||||
|
{
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAsync(Meter meter)
|
||||||
|
{
|
||||||
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
var readings = await db.Readings.CountAsync(r => r.MeterId == meter.Id);
|
||||||
|
var consumption = await db.Consumption.CountAsync(c => c.MeterId == meter.Id);
|
||||||
|
var detail = readings + consumption > 0
|
||||||
|
? $" This will also delete {readings} reading(s) and {consumption} consumption row(s)."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!await Confirm.DeleteAsync(DialogService, "Delete meter", $"Delete '{meter.Name}'?{detail} This cannot be undone."))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
// reading/consumption FKs are Restrict — remove them first; events/sources/tank/members cascade.
|
||||||
|
await db.Consumption.Where(c => c.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||||
|
await db.Readings.Where(r => r.MeterId == meter.Id).ExecuteDeleteAsync();
|
||||||
|
await db.Meters.Where(m => m.Id == meter.Id).ExecuteDeleteAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
|
||||||
|
Snackbar.Add("Deleted.", Severity.Success);
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private sealed class EditModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public short EnergyTypeId { get; set; }
|
||||||
|
public MeterMode Mode { get; set; } = MeterMode.CumulativeCounter;
|
||||||
|
public MeterMode OriginalMode { get; set; } = MeterMode.CumulativeCounter;
|
||||||
|
public string Unit { get; set; } = "";
|
||||||
|
public double InitialBaseline { get; set; }
|
||||||
|
public double OriginalBaseline { get; set; }
|
||||||
|
public string Role { get; set; } = "";
|
||||||
|
public string? Location { get; set; }
|
||||||
|
public string? SerialNumber { get; set; }
|
||||||
|
public string? Manufacturer { get; set; }
|
||||||
|
public string? Model { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
|
||||||
|
|
||||||
|
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
@page "/solar"
|
||||||
|
@inject SolarService SolarSvc
|
||||||
|
@using MudBlazor
|
||||||
|
|
||||||
|
<PageTitle>MeterVault — Solar / PV</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||||
|
<MudText Typo="Typo.h4">Solar / PV</MudText>
|
||||||
|
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||||
|
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||||
|
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||||
|
</MudSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_summary is null)
|
||||||
|
{
|
||||||
|
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||||
|
}
|
||||||
|
else if (!_summary.HasGeneration)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">
|
||||||
|
No generation meters found. Add a meter with mode <b>GenerationCounter</b>, or load the reference data from
|
||||||
|
<MudLink Href="/import">Import</MudLink>.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudGrid>
|
||||||
|
<MudItem xs="12" sm="6" md="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Generation</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@Format.Number(_summary.Generation, 0) kWh</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Self-consumption</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@(_summary.SelfConsumption is { } s ? $"{Format.Number(s, 0)} kWh" : "—")</MudText>
|
||||||
|
@if (_summary.SelfConsumptionRatio is { } ratio)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">@Format.Number(ratio * 100, 0)% of generation</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Autarky</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@(_summary.Autarky is { } a ? $"{Format.Number(a * 100, 0)} %" : "—")</MudText>
|
||||||
|
@if (_summary.GridImport is { } grid)
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Grid draw @Format.Number(grid, 0) kWh</MudText>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
<MudItem xs="12" sm="6" md="3">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2">
|
||||||
|
<MudText Typo="Typo.overline" Color="Color.Secondary">Savings (Ersparnis)</MudText>
|
||||||
|
<MudText Typo="Typo.h5">@(_summary.Savings is { } sav ? Format.Euro(sav) : "—")</MudText>
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="8">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Generation & self-consumption</MudText>
|
||||||
|
<SeriesChart Series="_chart" Decimals="0" Height="340" />
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
<MudItem xs="12" md="4">
|
||||||
|
<MudPaper Class="pa-4" Elevation="2" Style="height:100%">
|
||||||
|
<MudText Typo="Typo.h6" Class="mb-2">Generation by meter</MudText>
|
||||||
|
<MudSimpleTable Dense="true" Hover="true">
|
||||||
|
<tbody>
|
||||||
|
@foreach (var meter in _summary.Meters)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@meter.Name</td>
|
||||||
|
<td style="text-align:right">@Format.Number(meter.Generation, 0) kWh</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</MudSimpleTable>
|
||||||
|
@if (!_summary.HasLoadContext)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info" Dense="true" Class="mt-3">
|
||||||
|
Tag a meter <code>total_load</code> and one <code>grid_import</code> (in meter metadata)
|
||||||
|
to unlock self-consumption, autarky and savings.
|
||||||
|
</MudAlert>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private int _months = 60;
|
||||||
|
private bool _loading;
|
||||||
|
private SolarSummary? _summary;
|
||||||
|
private IReadOnlyList<SeriesChart.SeriesDef> _chart = [];
|
||||||
|
|
||||||
|
protected override Task OnInitializedAsync() => LoadAsync();
|
||||||
|
|
||||||
|
private async Task OnRangeChanged(int months)
|
||||||
|
{
|
||||||
|
_months = months;
|
||||||
|
await LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAsync()
|
||||||
|
{
|
||||||
|
if (_loading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loading = true;
|
||||||
|
_summary = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var from = asOf.AddMonths(-_months);
|
||||||
|
_summary = await SolarSvc.GetSummaryAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
|
||||||
|
|
||||||
|
var generation = _summary.Months
|
||||||
|
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.Generation))
|
||||||
|
.ToList();
|
||||||
|
var series = new List<SeriesChart.SeriesDef>
|
||||||
|
{
|
||||||
|
new("Generation", ApexCharts.SeriesType.Bar, generation),
|
||||||
|
};
|
||||||
|
if (_summary.HasLoadContext)
|
||||||
|
{
|
||||||
|
var self = _summary.Months
|
||||||
|
.Select(m => new SeriesChart.Point(m.Period.ToString("MMM yy", System.Globalization.CultureInfo.InvariantCulture), m.SelfConsumption ?? 0))
|
||||||
|
.ToList();
|
||||||
|
series.Add(new("Self-consumption", ApexCharts.SeriesType.Bar, self));
|
||||||
|
}
|
||||||
|
|
||||||
|
_chart = series;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
@page "/trends"
|
@page "/trends"
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject DashboardService Dash
|
@inject DashboardService Dash
|
||||||
|
|
||||||
<PageTitle>MeterVault — Trends</PageTitle>
|
<PageTitle>MeterVault — Trends</PageTitle>
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
@using System.Globalization
|
||||||
|
@using System.Text
|
||||||
|
@using System.Net
|
||||||
|
@using MeterVault.Infrastructure.Dashboard
|
||||||
|
|
||||||
|
@if (string.IsNullOrEmpty(_svg))
|
||||||
|
{
|
||||||
|
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="width:100%; overflow-x:auto">
|
||||||
|
@((MarkupString)_svg)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private const double W = 1000;
|
||||||
|
private const double NodeWidth = 16;
|
||||||
|
private const double NodeGap = 12;
|
||||||
|
private const double LeftPad = 8;
|
||||||
|
private const double RightPad = 8;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public IReadOnlyList<FlowNode> Nodes { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public IReadOnlyList<FlowLink> Links { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string Unit { get; set; } = "";
|
||||||
|
|
||||||
|
private string _svg = "";
|
||||||
|
|
||||||
|
protected override void OnParametersSet() => _svg = BuildSvg();
|
||||||
|
|
||||||
|
private string BuildSvg()
|
||||||
|
{
|
||||||
|
if (Nodes.Count == 0)
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxDepth = Nodes.Max(n => n.Depth);
|
||||||
|
var columns = Nodes.GroupBy(n => n.Depth).ToDictionary(g => g.Key, g => g.OrderByDescending(n => n.Value).ToList());
|
||||||
|
var maxCount = columns.Values.Max(c => c.Count);
|
||||||
|
var height = Math.Max(320, (maxCount * 46) + 40);
|
||||||
|
|
||||||
|
// One value→pixel scale so every column fits (flow is conserved → column totals are ~equal;
|
||||||
|
// the densest column with the most gaps constrains the scale).
|
||||||
|
var scale = double.MaxValue;
|
||||||
|
foreach (var col in columns.Values)
|
||||||
|
{
|
||||||
|
var sum = col.Sum(n => n.Value);
|
||||||
|
if (sum > 0)
|
||||||
|
{
|
||||||
|
scale = Math.Min(scale, (height - ((col.Count - 1) * NodeGap) - 20) / sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (double.IsInfinity(scale) || scale <= 0)
|
||||||
|
{
|
||||||
|
scale = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var colStep = maxDepth == 0 ? 0 : (W - LeftPad - RightPad - NodeWidth) / maxDepth;
|
||||||
|
var geo = new Dictionary<string, NodeGeo>();
|
||||||
|
foreach (var (depth, col) in columns)
|
||||||
|
{
|
||||||
|
var heights = col.Select(n => Math.Max(3, n.Value * scale)).ToList();
|
||||||
|
var colHeight = heights.Sum() + ((col.Count - 1) * NodeGap);
|
||||||
|
var y = (height - colHeight) / 2;
|
||||||
|
var x = LeftPad + (depth * colStep);
|
||||||
|
for (var i = 0; i < col.Count; i++)
|
||||||
|
{
|
||||||
|
geo[col[i].Id] = new NodeGeo(x, y, heights[i]);
|
||||||
|
y += heights[i] + NodeGap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ribbon band offsets: order each source's out-links by target y, each target's in-links by source y.
|
||||||
|
var srcOffset = new Dictionary<string, double>();
|
||||||
|
var dstOffset = new Dictionary<string, double>();
|
||||||
|
var srcBand = new Dictionary<FlowLink, double>();
|
||||||
|
var dstBand = new Dictionary<FlowLink, double>();
|
||||||
|
foreach (var group in Links.GroupBy(l => l.From))
|
||||||
|
{
|
||||||
|
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.To, out var g) ? g.Y : 0))
|
||||||
|
{
|
||||||
|
srcBand[link] = srcOffset.GetValueOrDefault(group.Key);
|
||||||
|
srcOffset[group.Key] = srcBand[link] + (link.Value * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var group in Links.GroupBy(l => l.To))
|
||||||
|
{
|
||||||
|
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.From, out var g) ? g.Y : 0))
|
||||||
|
{
|
||||||
|
dstBand[link] = dstOffset.GetValueOrDefault(group.Key);
|
||||||
|
dstOffset[group.Key] = dstBand[link] + (link.Value * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
|
||||||
|
var label = Nodes.ToDictionary(n => n.Id, n => n.Label);
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append(CultureInfo.InvariantCulture,
|
||||||
|
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"Flow diagram\">");
|
||||||
|
|
||||||
|
// Ribbons first (under nodes).
|
||||||
|
foreach (var link in Links)
|
||||||
|
{
|
||||||
|
if (!geo.TryGetValue(link.From, out var s) || !geo.TryGetValue(link.To, out var t))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var band = link.Value * scale;
|
||||||
|
var sy0 = s.Y + srcBand[link];
|
||||||
|
var ty0 = t.Y + dstBand[link];
|
||||||
|
var sx = s.X + NodeWidth;
|
||||||
|
var tx = t.X;
|
||||||
|
var midX = (sx + tx) / 2;
|
||||||
|
var path =
|
||||||
|
$"M{F(sx)},{F(sy0)} C{F(midX)},{F(sy0)} {F(midX)},{F(ty0)} {F(tx)},{F(ty0)} " +
|
||||||
|
$"L{F(tx)},{F(ty0 + band)} C{F(midX)},{F(ty0 + band)} {F(midX)},{F(sy0 + band)} {F(sx)},{F(sy0 + band)} Z";
|
||||||
|
var tip = Enc($"{label.GetValueOrDefault(link.From)} → {label.GetValueOrDefault(link.To)}: {Fmt(link.Value)}");
|
||||||
|
sb.Append(CultureInfo.InvariantCulture,
|
||||||
|
$"<path d=\"{path}\" fill=\"{Enc(color.GetValueOrDefault(link.From, "#607D8B"))}\" fill-opacity=\"0.38\"><title>{tip}</title></path>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes + labels.
|
||||||
|
foreach (var node in Nodes)
|
||||||
|
{
|
||||||
|
if (!geo.TryGetValue(node.Id, out var g))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rightmost = node.Depth == maxDepth;
|
||||||
|
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
|
||||||
|
var anchor = rightmost ? "end" : "start";
|
||||||
|
var fill = Enc(node.ColorHex ?? "#607D8B");
|
||||||
|
var name = Enc(node.Label);
|
||||||
|
var val = Enc(Fmt(node.Value));
|
||||||
|
sb.Append(CultureInfo.InvariantCulture,
|
||||||
|
$"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>");
|
||||||
|
sb.Append(CultureInfo.InvariantCulture,
|
||||||
|
$"<text x=\"{F(labelX)}\" y=\"{F(g.Y + (g.H / 2))}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" font-size=\"13\" fill=\"currentColor\">" +
|
||||||
|
$"<tspan>{name}</tspan><tspan x=\"{F(labelX)}\" dy=\"15\" font-size=\"11\" fill-opacity=\"0.65\">{val}</tspan></text>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append("</svg>");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim();
|
||||||
|
|
||||||
|
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private static string Enc(string value) => WebUtility.HtmlEncode(value);
|
||||||
|
|
||||||
|
private readonly record struct NodeGeo(double X, double Y, double H);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@using ApexCharts
|
||||||
|
|
||||||
|
@if (HasData)
|
||||||
|
{
|
||||||
|
<ApexChart TItem="SeriesChart.Point" Options="_options" Height="@Height">
|
||||||
|
@foreach (var series in Series)
|
||||||
|
{
|
||||||
|
<ApexPointSeries TItem="SeriesChart.Point"
|
||||||
|
Items="series.Points"
|
||||||
|
SeriesType="series.Type"
|
||||||
|
Name="@series.Name"
|
||||||
|
XValue="p => p.Label"
|
||||||
|
YValue="p => (decimal)Math.Round(p.Value, Decimals)" />
|
||||||
|
}
|
||||||
|
</ApexChart>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">No data in this range.</MudText>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>A single (label, value) point in a series.</summary>
|
||||||
|
public sealed record Point(string Label, double Value);
|
||||||
|
|
||||||
|
/// <summary>A named series rendered as bars or a line over the shared category axis.</summary>
|
||||||
|
public sealed record SeriesDef(string Name, SeriesType Type, IReadOnlyList<Point> Points);
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public IReadOnlyList<SeriesDef> Series { get; set; } = [];
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int Height { get; set; } = 300;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public int Decimals { get; set; } = 2;
|
||||||
|
|
||||||
|
private bool HasData => Series.Any(s => s.Points.Count > 0);
|
||||||
|
|
||||||
|
private readonly ApexChartOptions<SeriesChart.Point> _options = new()
|
||||||
|
{
|
||||||
|
Theme = new Theme { Mode = Mode.Dark },
|
||||||
|
DataLabels = new DataLabels { Enabled = false },
|
||||||
|
Legend = new Legend { Position = LegendPosition.Top },
|
||||||
|
Stroke = new Stroke { Width = 3, Curve = Curve.Smooth },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using MudBlazor;
|
||||||
|
|
||||||
|
namespace MeterVault.App;
|
||||||
|
|
||||||
|
/// <summary>Small wrapper over MudBlazor's message box for delete/confirm prompts in admin pages.</summary>
|
||||||
|
public static class Confirm
|
||||||
|
{
|
||||||
|
public static async Task<bool> DeleteAsync(IDialogService dialog, string title, string message)
|
||||||
|
{
|
||||||
|
var result = await dialog.ShowMessageBoxAsync(title, message, yesText: "Delete", cancelText: "Cancel")
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return result == true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,14 @@ static async Task MigrateDatabaseAsync(WebApplication app)
|
|||||||
await db.Database.MigrateAsync().ConfigureAwait(false);
|
await db.Database.MigrateAsync().ConfigureAwait(false);
|
||||||
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
await DatabaseSeeder.SeedAsync(db).ConfigureAwait(false);
|
||||||
Log.Information("Database migrations applied and defaults seeded");
|
Log.Information("Database migrations applied and defaults seeded");
|
||||||
|
|
||||||
|
if (options.SeedReferenceData)
|
||||||
|
{
|
||||||
|
var importer = scope.ServiceProvider.GetRequiredService<MeterVault.Infrastructure.Import.ReferenceDataImporter>();
|
||||||
|
var dir = Path.Combine(AppContext.BaseDirectory, "sampledata");
|
||||||
|
await importer.LoadAsync(dir).ConfigureAwait(false);
|
||||||
|
Log.Information("Reference dataset ensured (SeedReferenceData=true)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
/// <summary>Exposed for WebApplicationFactory-based integration tests.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A directed flow edge in the meter topology: energy/water measured by <see cref="ToMeter"/> is a
|
||||||
|
/// <em>subsection</em> of the flow through <see cref="FromMeter"/> (upstream → downstream). Not an
|
||||||
|
/// addition — a sub-meter shows where an upstream meter's flow goes. Multiple edges into one meter
|
||||||
|
/// model a merge (e.g. house load fed by grid + solar); multiple edges out model a split
|
||||||
|
/// (main → car, pool, …). Drives the per-energy-type flow (Sankey) view.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MeterLink
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Upstream meter (the larger flow this edge draws from).</summary>
|
||||||
|
public int FromMeterId { get; set; }
|
||||||
|
|
||||||
|
public Meter? FromMeter { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Downstream meter (the subsection).</summary>
|
||||||
|
public int ToMeterId { get; set; }
|
||||||
|
|
||||||
|
public Meter? ToMeter { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A meter's optional role in an energy system, stored under <c>role</c> in <see cref="Meter.Meta"/>.
|
||||||
|
/// Roles let analytic panels (e.g. the PV self-consumption/autarky view) find the relevant meters
|
||||||
|
/// by configuration rather than by hardcoded names — nothing domain-specific is baked into code
|
||||||
|
/// (SDD §5.2, §8.4). A PV install typically tags one meter <see cref="TotalLoad"/> and one
|
||||||
|
/// <see cref="GridImport"/>; self-consumption is then <c>total_load − grid_import</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class MeterRoles
|
||||||
|
{
|
||||||
|
/// <summary>Meter measuring the site's total consumption (all loads).</summary>
|
||||||
|
public const string TotalLoad = "total_load";
|
||||||
|
|
||||||
|
/// <summary>Meter measuring energy drawn from the grid.</summary>
|
||||||
|
public const string GridImport = "grid_import";
|
||||||
|
|
||||||
|
/// <summary>Meter measuring energy exported to the grid.</summary>
|
||||||
|
public const string GridExport = "grid_export";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Typed reads over a meter's free-form <c>Meta</c> JSON (jsonb). Tolerant of malformed
|
||||||
|
/// or empty JSON — returns null rather than throwing, so a bad blob never breaks a dashboard.</summary>
|
||||||
|
public static class MeterMeta
|
||||||
|
{
|
||||||
|
/// <summary>The meter's configured <c>role</c> (see <see cref="MeterRoles"/>), or null if unset/invalid.</summary>
|
||||||
|
public static string? Role(string? meta) => ReadString(meta, "role");
|
||||||
|
|
||||||
|
/// <summary>Reads a top-level string property from the meta JSON; null if absent or unparsable.</summary>
|
||||||
|
public static string? ReadString(string? meta, string property)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(meta))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(meta);
|
||||||
|
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||||
|
&& doc.RootElement.TryGetProperty(property, out var value)
|
||||||
|
&& value.ValueKind == JsonValueKind.String
|
||||||
|
? value.GetString()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set to <paramref name="role"/>.</summary>
|
||||||
|
public static string WithRole(string? meta, string role) => SetRole(meta, role);
|
||||||
|
|
||||||
|
/// <summary>Returns <paramref name="meta"/> with <c>role</c> set, or removed when null/empty.</summary>
|
||||||
|
public static string SetRole(string? meta, string? role)
|
||||||
|
{
|
||||||
|
var map = ToMap(meta);
|
||||||
|
if (string.IsNullOrWhiteSpace(role))
|
||||||
|
{
|
||||||
|
map.Remove("role");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
map["role"] = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> ToMap(string? meta)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(meta))
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<Dictionary<string, object?>>(meta) ?? new Dictionary<string, object?>();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>One recorded delivery into a consumable store.</summary>
|
||||||
|
public sealed record DeliveryRow(DateTimeOffset Time, double Amount, string? Unit);
|
||||||
|
|
||||||
|
/// <summary>One month of consumable draw.</summary>
|
||||||
|
public sealed record ConsumableMonth(DateOnly Period, double Consumption);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The oil / consumable panel read model (SDD §8.5) for one <see cref="MeterMode.ConsumableBalance"/>
|
||||||
|
/// meter: current tank level (physical + volume), fill vs capacity, deliveries, associated burner
|
||||||
|
/// runtime, effective L/h (fixed or empirical), forecast-to-empty, tariff cost and a monthly series.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ConsumableSummary(
|
||||||
|
int MeterId,
|
||||||
|
string Name,
|
||||||
|
string Unit,
|
||||||
|
double Capacity,
|
||||||
|
double? CurrentLevel,
|
||||||
|
double? PhysicalLevel,
|
||||||
|
string? PhysicalUnit,
|
||||||
|
DateTimeOffset? LevelAsOf,
|
||||||
|
double FillFraction,
|
||||||
|
double ConsumptionInRange,
|
||||||
|
double? BurnerHours,
|
||||||
|
double? EffectiveRate,
|
||||||
|
double? FixedRate,
|
||||||
|
TankRateMode RateMode,
|
||||||
|
double? AveragePerDay,
|
||||||
|
DateOnly? ForecastEmpty,
|
||||||
|
double CostInRange,
|
||||||
|
IReadOnlyList<DeliveryRow> Deliveries,
|
||||||
|
IReadOnlyList<ConsumableMonth> Months);
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using Dapper;
|
||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using MeterVault.Infrastructure.Normalization;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read model for the oil / consumable panel (SDD §8.5). Works for any
|
||||||
|
/// <see cref="MeterMode.ConsumableBalance"/> meter backed by a <see cref="Tank"/> — heating oil is
|
||||||
|
/// only the reference case. Current level is the latest dipstick reading (cm calibrated to volume)
|
||||||
|
/// plus deliveries recorded since; the effective burn rate pairs the consumable's litres with the
|
||||||
|
/// runtime hours of same-energy-type <see cref="MeterMode.RuntimeCounter"/> meters. DbContext
|
||||||
|
/// factory keeps it Blazor-circuit safe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConsumableService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
|
private readonly CostService _costService = costService;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ConsumableSummary>> GetConsumablesAsync(
|
||||||
|
DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var meters = await db.Meters.AsNoTracking()
|
||||||
|
.Where(m => m.Mode == MeterMode.ConsumableBalance)
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var summaries = new List<ConsumableSummary>();
|
||||||
|
foreach (var meter in meters)
|
||||||
|
{
|
||||||
|
var tank = await db.Tanks.AsNoTracking().FirstOrDefaultAsync(t => t.MeterId == meter.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (tank is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries.Add(await BuildAsync(db, meter, tank, from, to, cancellationToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ConsumableSummary> BuildAsync(
|
||||||
|
MeterVaultDbContext db, Meter meter, Tank tank, DateOnly from, DateOnly to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var calibration = MeterConfigFactory.FromMeter(meter, tank).Tank?.Calibration;
|
||||||
|
var fromUtc = ToUtc(from);
|
||||||
|
var toUtc = ToUtc(to);
|
||||||
|
|
||||||
|
var events = await db.MeterEvents.AsNoTracking()
|
||||||
|
.Where(e => e.MeterId == meter.Id && (e.EventType == MeterEventType.TankLevel || e.EventType == MeterEventType.Delivery))
|
||||||
|
.OrderBy(e => e.Time)
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var lastLevel = events.LastOrDefault(e => e.EventType == MeterEventType.TankLevel);
|
||||||
|
double? currentLevel = null;
|
||||||
|
double? physicalLevel = null;
|
||||||
|
string? physicalUnit = null;
|
||||||
|
DateTimeOffset? levelAsOf = null;
|
||||||
|
if (lastLevel is not null)
|
||||||
|
{
|
||||||
|
physicalLevel = lastLevel.Amount;
|
||||||
|
physicalUnit = lastLevel.Unit;
|
||||||
|
levelAsOf = lastLevel.Time;
|
||||||
|
var volume = ToVolume(lastLevel, calibration);
|
||||||
|
// Deliveries recorded after the last dipstick raise the actual contents.
|
||||||
|
var since = events.Where(e => e.EventType == MeterEventType.Delivery && e.Time > lastLevel.Time).Sum(e => e.Amount ?? 0);
|
||||||
|
currentLevel = volume + since;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fillFraction = tank.Capacity > 0 && currentLevel is { } level
|
||||||
|
? Math.Clamp(level / tank.Capacity, 0, 1)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var deliveries = events
|
||||||
|
.Where(e => e.EventType == MeterEventType.Delivery)
|
||||||
|
.OrderByDescending(e => e.Time)
|
||||||
|
.Select(e => new DeliveryRow(e.Time, e.Amount ?? 0, e.Unit))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var consumptionInRange = await SumConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Burner runtime: same-energy-type runtime meters feed this consumable's L/h analytic.
|
||||||
|
var runtimeMeterIds = await db.Meters.AsNoTracking()
|
||||||
|
.Where(m => m.EnergyTypeId == meter.EnergyTypeId && m.Mode == MeterMode.RuntimeCounter)
|
||||||
|
.Select(m => m.Id)
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
double? burnerHours = null;
|
||||||
|
foreach (var id in runtimeMeterIds)
|
||||||
|
{
|
||||||
|
burnerHours = (burnerHours ?? 0) + await SumConsumptionAsync(db, id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
double? effectiveRate = burnerHours is > 0 ? consumptionInRange / burnerHours : null;
|
||||||
|
double? fixedRate = tank.RateMode == TankRateMode.Fixed ? tank.FixedRate : null;
|
||||||
|
|
||||||
|
var (averagePerDay, forecastEmpty) = await ForecastAsync(db, meter.Id, currentLevel, levelAsOf, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var costInRange = (await _costService.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month, cancellationToken).ConfigureAwait(false))
|
||||||
|
.Sum(c => c.Cost);
|
||||||
|
|
||||||
|
var months = await MonthlyConsumptionAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new ConsumableSummary(
|
||||||
|
meter.Id, meter.Name, tank.Unit, tank.Capacity, currentLevel, physicalLevel, physicalUnit, levelAsOf,
|
||||||
|
fillFraction, consumptionInRange, burnerHours, effectiveRate, fixedRate, tank.RateMode,
|
||||||
|
averagePerDay, forecastEmpty, costInRange, deliveries, months);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recent burn rate and a forecast-to-empty anchored at the last level reading, using
|
||||||
|
/// the trailing 365 days of consumption (delivery-only early history would otherwise skew it).</summary>
|
||||||
|
private static async Task<(double? AveragePerDay, DateOnly? ForecastEmpty)> ForecastAsync(
|
||||||
|
MeterVaultDbContext db, int meterId, double? currentLevel, DateTimeOffset? levelAsOf, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var latest = await db.Consumption.AsNoTracking()
|
||||||
|
.Where(c => c.MeterId == meterId)
|
||||||
|
.OrderByDescending(c => c.Time)
|
||||||
|
.Select(c => (DateTimeOffset?)c.Time)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (latest is null || currentLevel is not { } level || level <= 0)
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var windowStart = latest.Value.AddDays(-365);
|
||||||
|
var recent = await db.Consumption.AsNoTracking()
|
||||||
|
.Where(c => c.MeterId == meterId && c.Time > windowStart && c.Time <= latest.Value)
|
||||||
|
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||||
|
if (recent <= 0)
|
||||||
|
{
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var averagePerDay = recent / 365.0;
|
||||||
|
var anchor = levelAsOf ?? latest.Value;
|
||||||
|
var daysToEmpty = level / averagePerDay;
|
||||||
|
// Guard against absurd horizons (near-zero burn) that overflow DateTime.
|
||||||
|
var forecast = daysToEmpty < 365 * 100
|
||||||
|
? DateOnly.FromDateTime(anchor.UtcDateTime.AddDays(daysToEmpty))
|
||||||
|
: (DateOnly?)null;
|
||||||
|
return (averagePerDay, forecast);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<double> SumConsumptionAsync(
|
||||||
|
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken) =>
|
||||||
|
await db.Consumption.AsNoTracking()
|
||||||
|
.Where(c => c.MeterId == meterId && c.Time >= from && c.Time < to)
|
||||||
|
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<ConsumableMonth>> MonthlyConsumptionAsync(
|
||||||
|
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql =
|
||||||
|
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
|
||||||
|
"sum(amount) AS amount " +
|
||||||
|
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||||
|
"GROUP BY period ORDER BY period";
|
||||||
|
|
||||||
|
var connection = db.Database.GetDbConnection();
|
||||||
|
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
|
||||||
|
return rows.Select(r => new ConsumableMonth(r.Period, r.Amount)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ToVolume(MeterEvent level, MeterVault.Core.Normalization.CalibrationCurve? calibration)
|
||||||
|
{
|
||||||
|
var value = level.Amount ?? 0;
|
||||||
|
var isCentimetres = string.Equals(level.Unit, "cm", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return isCentimetres && calibration is not null ? calibration.ToVolume(value) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>A node in the flow graph: a meter, or a synthetic "Other/unmetered" remainder.</summary>
|
||||||
|
public sealed record FlowNode(string Id, string Label, double Value, int Depth, string? ColorHex, bool IsOther, int? MeterId);
|
||||||
|
|
||||||
|
/// <summary>A directed flow edge with the quantity that flows along it, in the energy type's base unit.</summary>
|
||||||
|
public sealed record FlowLink(string From, string To, double Value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The per-energy-type flow graph (SDD-style topology view): meters as nodes sized by consumption,
|
||||||
|
/// directed edges sized by the flow along each configured link, plus "Other" remainders where an
|
||||||
|
/// upstream meter's flow isn't fully accounted for by its sub-meters. Rendered as a Sankey diagram.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record FlowGraph(
|
||||||
|
short EnergyTypeId,
|
||||||
|
string EnergyType,
|
||||||
|
string Unit,
|
||||||
|
double Total,
|
||||||
|
IReadOnlyList<FlowNode> Nodes,
|
||||||
|
IReadOnlyList<FlowLink> Links)
|
||||||
|
{
|
||||||
|
public bool HasData => Nodes.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>True when meters are actually chained (not just a flat, unlinked list).</summary>
|
||||||
|
public bool HasChain => Links.Count > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the per-energy-type flow graph (Sankey) from the meter topology (<see cref="MeterLink"/>)
|
||||||
|
/// and consumption over a period. Each meter is a node sized by its consumption; each configured
|
||||||
|
/// edge carries the downstream meter's consumption (split proportionally when a meter has several
|
||||||
|
/// upstreams); the unaccounted remainder under a meter becomes a synthetic "Other" node. Nothing is
|
||||||
|
/// hardcoded per energy type — it works for electricity, water, gas, … alike. DbContext factory
|
||||||
|
/// keeps it Blazor-circuit safe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FlowService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||||
|
{
|
||||||
|
private const double Epsilon = 0.01;
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
|
|
||||||
|
public async Task<FlowGraph> GetFlowAsync(short energyTypeId, DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var energyType = await db.EnergyTypes.AsNoTracking().FirstOrDefaultAsync(t => t.Id == energyTypeId, cancellationToken).ConfigureAwait(false);
|
||||||
|
var meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (energyType is null || meters.Count == 0)
|
||||||
|
{
|
||||||
|
return new FlowGraph(energyTypeId, energyType?.DisplayName ?? "", energyType?.BaseUnit ?? "", 0, [], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
var meterIds = meters.Select(m => m.Id).ToHashSet();
|
||||||
|
var fromUtc = ToUtc(from);
|
||||||
|
var toUtc = ToUtc(to);
|
||||||
|
|
||||||
|
// A meter's flow value is its throughput: consumption OR generation output — so a generation
|
||||||
|
// meter (solar) can act as a source feeding downstream meters (grid + solar → house). A meter
|
||||||
|
// is normally one kind, so summing both kinds is that meter's flow. Negatives (savings/balance
|
||||||
|
// virtual meters) are clamped to 0 — a flow ribbon can't be negative.
|
||||||
|
var sums = await db.Consumption.AsNoTracking()
|
||||||
|
.Where(c => c.Time >= fromUtc && c.Time < toUtc)
|
||||||
|
.GroupBy(c => c.MeterId)
|
||||||
|
.Select(g => new { MeterId = g.Key, Total = g.Sum(x => x.Amount) })
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var value = sums.Where(s => meterIds.Contains(s.MeterId)).ToDictionary(s => s.MeterId, s => Math.Max(0, s.Total));
|
||||||
|
double V(int id) => value.GetValueOrDefault(id);
|
||||||
|
|
||||||
|
var links = await db.MeterLinks.AsNoTracking()
|
||||||
|
.Where(l => meterIds.Contains(l.FromMeterId) && meterIds.Contains(l.ToMeterId))
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var parents = meters.ToDictionary(m => m.Id, _ => new List<int>());
|
||||||
|
var children = meters.ToDictionary(m => m.Id, _ => new List<int>());
|
||||||
|
foreach (var link in links)
|
||||||
|
{
|
||||||
|
children[link.FromMeterId].Add(link.ToMeterId);
|
||||||
|
parents[link.ToMeterId].Add(link.FromMeterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var depth = ComputeDepths(meters.Select(m => m.Id).ToList(), parents, children);
|
||||||
|
|
||||||
|
// Aggregate ("sum") meters: a Virtual-mode meter has no measurements of its own — in the flow
|
||||||
|
// it is the sum of its upstream meters (e.g. "Sum Solar" = Solar 1 + Solar 2). Resolve these in
|
||||||
|
// topological (depth) order so each aggregate sees its already-resolved upstream values.
|
||||||
|
var aggregates = meters.Where(m => m.Mode == MeterMode.Virtual).Select(m => m.Id).ToHashSet();
|
||||||
|
foreach (var id in meters.Select(m => m.Id).OrderBy(id => depth.GetValueOrDefault(id)))
|
||||||
|
{
|
||||||
|
if (aggregates.Contains(id))
|
||||||
|
{
|
||||||
|
value[id] = parents[id].Sum(V);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link value: a child's consumption flows in from its parent(s); with several parents it is
|
||||||
|
// split proportionally to the parents' own consumption (equal split if those are all zero).
|
||||||
|
var flowLinks = new List<FlowLink>();
|
||||||
|
var outgoingByParent = meters.ToDictionary(m => m.Id, _ => 0d);
|
||||||
|
foreach (var (childId, parentIds) in parents)
|
||||||
|
{
|
||||||
|
if (parentIds.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentTotal = parentIds.Sum(V);
|
||||||
|
foreach (var parentId in parentIds)
|
||||||
|
{
|
||||||
|
var share = parentIds.Count == 1 ? 1d
|
||||||
|
: parentTotal > Epsilon ? V(parentId) / parentTotal
|
||||||
|
: 1d / parentIds.Count;
|
||||||
|
var linkValue = V(childId) * share;
|
||||||
|
if (linkValue > Epsilon)
|
||||||
|
{
|
||||||
|
flowLinks.Add(new FlowLink(NodeId(parentId), NodeId(childId), linkValue));
|
||||||
|
outgoingByParent[parentId] += linkValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes = new List<FlowNode>();
|
||||||
|
foreach (var meter in meters)
|
||||||
|
{
|
||||||
|
// Keep a meter node if it carries flow or participates in the topology.
|
||||||
|
if (V(meter.Id) <= Epsilon && children[meter.Id].Count == 0 && parents[meter.Id].Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.Add(new FlowNode(NodeId(meter.Id), meter.Name, V(meter.Id), depth.GetValueOrDefault(meter.Id), energyType.ColorHex, false, meter.Id));
|
||||||
|
|
||||||
|
// Unaccounted remainder under a meter with sub-meters → "Other".
|
||||||
|
if (children[meter.Id].Count > 0)
|
||||||
|
{
|
||||||
|
var remainder = V(meter.Id) - outgoingByParent[meter.Id];
|
||||||
|
if (remainder > Epsilon)
|
||||||
|
{
|
||||||
|
var otherId = $"other{meter.Id}";
|
||||||
|
nodes.Add(new FlowNode(otherId, $"Other ({meter.Name})", remainder, depth.GetValueOrDefault(meter.Id) + 1, "#78909C", true, null));
|
||||||
|
flowLinks.Add(new FlowLink(NodeId(meter.Id), otherId, remainder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = meters.Where(m => parents[m.Id].Count == 0).Sum(m => V(m.Id));
|
||||||
|
return new FlowGraph(energyTypeId, energyType.DisplayName, energyType.BaseUnit, total, nodes, flowLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Longest-path depth from the roots (Kahn topological relaxation); robust to stray cycles.</summary>
|
||||||
|
private static Dictionary<int, int> ComputeDepths(
|
||||||
|
List<int> ids, Dictionary<int, List<int>> parents, Dictionary<int, List<int>> children)
|
||||||
|
{
|
||||||
|
var depth = ids.ToDictionary(id => id, _ => 0);
|
||||||
|
var indegree = ids.ToDictionary(id => id, id => parents[id].Count);
|
||||||
|
var queue = new Queue<int>(ids.Where(id => indegree[id] == 0));
|
||||||
|
var processed = 0;
|
||||||
|
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var node = queue.Dequeue();
|
||||||
|
processed++;
|
||||||
|
foreach (var child in children[node])
|
||||||
|
{
|
||||||
|
depth[child] = Math.Max(depth[child], depth[node] + 1);
|
||||||
|
if (--indegree[child] == 0)
|
||||||
|
{
|
||||||
|
queue.Enqueue(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any nodes left (a cycle) keep depth 0 — the admin prevents cycles, this is just a guard.
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NodeId(int meterId) => $"m{meterId}";
|
||||||
|
|
||||||
|
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>A raw reading row for the meter-detail table.</summary>
|
||||||
|
public sealed record ReadingRow(DateTimeOffset Time, double Value, ReadingQuality Quality, ReadingFlags Flags);
|
||||||
|
|
||||||
|
/// <summary>A normalized consumption row for the meter-detail table.</summary>
|
||||||
|
public sealed record ConsumptionDetailRow(DateTimeOffset Time, double Amount, ConsumptionKind Kind, ReadingQuality Quality);
|
||||||
|
|
||||||
|
/// <summary>A meter lifecycle/correction event row.</summary>
|
||||||
|
public sealed record EventRow(DateTimeOffset Time, MeterEventType Type, double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
|
||||||
|
|
||||||
|
/// <summary>A tariff applicable to the meter (own / energy-type / global scope), for the timeline.</summary>
|
||||||
|
public sealed record TariffRow(TariffScope Scope, int? ScopeId, TariffComponent Component, double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The meter-detail read model (SDD §8.6): identity, register span, totals, recent raw readings
|
||||||
|
/// and normalized consumption (measured-vs-estimated markers via quality), the applicable tariff
|
||||||
|
/// timeline, and lifecycle events (swaps/deliveries/corrections). Source management loads the
|
||||||
|
/// source entities directly (they are editable), so it is not part of this read model.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record MeterDetailView(
|
||||||
|
int Id,
|
||||||
|
string Name,
|
||||||
|
string EnergyType,
|
||||||
|
MeterMode Mode,
|
||||||
|
string Unit,
|
||||||
|
string? Location,
|
||||||
|
string? SerialNumber,
|
||||||
|
string? Manufacturer,
|
||||||
|
string? Model,
|
||||||
|
double InitialBaseline,
|
||||||
|
bool IsActive,
|
||||||
|
int ReadingCount,
|
||||||
|
int ConsumptionCount,
|
||||||
|
DateTimeOffset? FirstReadingTime,
|
||||||
|
DateTimeOffset? LastReadingTime,
|
||||||
|
double? FirstReadingValue,
|
||||||
|
double? LastReadingValue,
|
||||||
|
double TotalConsumption,
|
||||||
|
double TotalGeneration,
|
||||||
|
IReadOnlyList<ReadingRow> RecentReadings,
|
||||||
|
IReadOnlyList<ConsumptionDetailRow> RecentConsumption,
|
||||||
|
IReadOnlyList<EventRow> Events,
|
||||||
|
IReadOnlyList<TariffRow> Tariffs,
|
||||||
|
int SourceCount);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read model for the meter-detail view (SDD §8.6). Bounds the raw-reading and consumption pulls
|
||||||
|
/// (this is the one place the UI touches raw rows) and gathers source status, the applicable tariff
|
||||||
|
/// timeline and lifecycle events. DbContext factory keeps it Blazor-circuit safe.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MeterDetailService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||||
|
{
|
||||||
|
private const int MaxRows = 200;
|
||||||
|
|
||||||
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
|
|
||||||
|
public async Task<MeterDetailView?> GetAsync(int meterId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var meter = await db.Meters.AsNoTracking()
|
||||||
|
.Include(m => m.EnergyType)
|
||||||
|
.Include(m => m.Sources)
|
||||||
|
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (meter is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var readingCount = await db.Readings.AsNoTracking().CountAsync(r => r.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
||||||
|
var consumptionCount = await db.Consumption.AsNoTracking().CountAsync(c => c.MeterId == meterId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var first = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
||||||
|
.OrderBy(r => r.Time).Select(r => new { r.Time, r.Value })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var last = await db.Readings.AsNoTracking().Where(r => r.MeterId == meterId)
|
||||||
|
.OrderByDescending(r => r.Time).Select(r => new { r.Time, r.Value })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var totalConsumption = await db.Consumption.AsNoTracking()
|
||||||
|
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Consumption)
|
||||||
|
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||||
|
var totalGeneration = await db.Consumption.AsNoTracking()
|
||||||
|
.Where(c => c.MeterId == meterId && c.Kind == ConsumptionKind.Generation)
|
||||||
|
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false) ?? 0;
|
||||||
|
|
||||||
|
var recentReadings = await db.Readings.AsNoTracking()
|
||||||
|
.Where(r => r.MeterId == meterId)
|
||||||
|
.OrderByDescending(r => r.Time).Take(MaxRows)
|
||||||
|
.Select(r => new ReadingRow(r.Time, r.Value, r.Quality, r.Flags))
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var recentConsumption = await db.Consumption.AsNoTracking()
|
||||||
|
.Where(c => c.MeterId == meterId)
|
||||||
|
.OrderByDescending(c => c.Time).Take(MaxRows)
|
||||||
|
.Select(c => new ConsumptionDetailRow(c.Time, c.Amount, c.Kind, c.Quality))
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var events = await db.MeterEvents.AsNoTracking()
|
||||||
|
.Where(e => e.MeterId == meterId)
|
||||||
|
.OrderByDescending(e => e.Time)
|
||||||
|
.Select(e => new EventRow(e.Time, e.EventType, e.Amount, e.PrevValue, e.NewValue, e.Unit, e.Notes))
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var energyTypeId = meter.EnergyTypeId;
|
||||||
|
var tariffs = await db.Tariffs.AsNoTracking()
|
||||||
|
.Where(t => t.ScopeType == TariffScope.Global
|
||||||
|
|| (t.ScopeType == TariffScope.EnergyType && t.ScopeId == energyTypeId)
|
||||||
|
|| (t.ScopeType == TariffScope.Meter && t.ScopeId == meterId))
|
||||||
|
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
|
||||||
|
.Select(t => new TariffRow(t.ScopeType, t.ScopeId, t.Component, t.Value, t.Unit, t.ValidFrom, t.ValidTo))
|
||||||
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new MeterDetailView(
|
||||||
|
meter.Id, meter.Name, meter.EnergyType?.DisplayName ?? "—", meter.Mode, meter.Unit,
|
||||||
|
meter.Location, meter.SerialNumber, meter.Manufacturer, meter.Model, meter.InitialBaseline, meter.IsActive,
|
||||||
|
readingCount, consumptionCount,
|
||||||
|
first?.Time, last?.Time, first?.Value, last?.Value,
|
||||||
|
totalConsumption, totalGeneration,
|
||||||
|
recentReadings, recentConsumption, events, tariffs, meter.Sources.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>One month of the PV panel: generation, and (when role-tagged meters exist) the
|
||||||
|
/// self-consumption / grid-draw / savings split that reproduces the sheet's Netz-Einsparung column.</summary>
|
||||||
|
public sealed record SolarMonth(
|
||||||
|
DateOnly Period,
|
||||||
|
double Generation,
|
||||||
|
double? SelfConsumption,
|
||||||
|
double? GridImport,
|
||||||
|
double? TotalLoad,
|
||||||
|
double? Savings);
|
||||||
|
|
||||||
|
/// <summary>Per-generation-meter total over the selected period (for the ranked list).</summary>
|
||||||
|
public sealed record GenerationMeterRow(int MeterId, string Name, double Generation);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The PV / solar panel read model (SDD §8.4): total generation plus, when the install has tagged
|
||||||
|
/// a <c>total_load</c> and <c>grid_import</c> meter, self-consumption, autarky %, self-consumption %
|
||||||
|
/// and savings (Ersparnis). Derived metrics are null when no role config exists.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SolarSummary(
|
||||||
|
double Generation,
|
||||||
|
double? TotalLoad,
|
||||||
|
double? GridImport,
|
||||||
|
double? SelfConsumption,
|
||||||
|
double? Autarky,
|
||||||
|
double? SelfConsumptionRatio,
|
||||||
|
double? Savings,
|
||||||
|
IReadOnlyList<GenerationMeterRow> Meters,
|
||||||
|
IReadOnlyList<SolarMonth> Months)
|
||||||
|
{
|
||||||
|
/// <summary>True when the install has the role-tagged meters needed for self-consumption metrics.</summary>
|
||||||
|
public bool HasLoadContext => TotalLoad is not null && GridImport is not null;
|
||||||
|
|
||||||
|
/// <summary>True when at least one generation meter exists.</summary>
|
||||||
|
public bool HasGeneration => Meters.Count > 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using Dapper;
|
||||||
|
using MeterVault.Core.Costing;
|
||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read model for the PV / solar panel (SDD §8.4). Generation comes from every
|
||||||
|
/// <see cref="MeterMode.GenerationCounter"/> meter; self-consumption / autarky / savings are derived
|
||||||
|
/// from the meters tagged <see cref="MeterRoles.TotalLoad"/> and <see cref="MeterRoles.GridImport"/>
|
||||||
|
/// — so nothing is hardcoded by meter name. Reads only the aggregated consumption hypertable
|
||||||
|
/// (monthly, Europe/Berlin) via Dapper; safe from a Blazor circuit via a DbContext factory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SolarService(IDbContextFactory<MeterVaultDbContext> contextFactory)
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
|
||||||
|
|
||||||
|
public async Task<SolarSummary> GetSummaryAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var meters = await db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var generationMeters = meters.Where(m => m.Mode == MeterMode.GenerationCounter).ToList();
|
||||||
|
var loadMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.TotalLoad);
|
||||||
|
var gridMeter = meters.FirstOrDefault(m => MeterMeta.Role(m.Meta) == MeterRoles.GridImport);
|
||||||
|
|
||||||
|
var fromUtc = ToUtc(from);
|
||||||
|
var toUtc = ToUtc(to);
|
||||||
|
|
||||||
|
// Monthly generation per generation meter.
|
||||||
|
var genByMeter = new Dictionary<int, IReadOnlyDictionary<DateOnly, double>>();
|
||||||
|
foreach (var meter in generationMeters)
|
||||||
|
{
|
||||||
|
genByMeter[meter.Id] = await MonthlyAsync(db, meter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadByMonth = loadMeter is null
|
||||||
|
? null
|
||||||
|
: await MonthlyAsync(db, loadMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||||
|
var gridByMonth = gridMeter is null
|
||||||
|
? null
|
||||||
|
: await MonthlyAsync(db, gridMeter.Id, fromUtc, toUtc, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var tariffs = gridMeter is null
|
||||||
|
? []
|
||||||
|
: await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Union of all months that carry any data.
|
||||||
|
var periods = new SortedSet<DateOnly>();
|
||||||
|
foreach (var series in genByMeter.Values)
|
||||||
|
{
|
||||||
|
periods.UnionWith(series.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadByMonth is not null)
|
||||||
|
{
|
||||||
|
periods.UnionWith(loadByMonth.Keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
var months = new List<SolarMonth>();
|
||||||
|
foreach (var period in periods)
|
||||||
|
{
|
||||||
|
var generation = genByMeter.Values.Sum(s => s.GetValueOrDefault(period));
|
||||||
|
|
||||||
|
double? load = loadByMonth?.GetValueOrDefault(period);
|
||||||
|
double? grid = gridByMonth?.GetValueOrDefault(period);
|
||||||
|
double? self = load is not null && grid is not null ? load - grid : null;
|
||||||
|
|
||||||
|
double? savings = null;
|
||||||
|
if (self is { } selfValue && gridMeter is not null)
|
||||||
|
{
|
||||||
|
var price = TariffResolver.ResolveValue(
|
||||||
|
tariffs, TariffComponent.UnitPrice, gridMeter.Id, gridMeter.EnergyTypeId,
|
||||||
|
new DateOnly(period.Year, period.Month, 15));
|
||||||
|
savings = selfValue * price;
|
||||||
|
}
|
||||||
|
|
||||||
|
months.Add(new SolarMonth(period, generation, self, grid, load, savings));
|
||||||
|
}
|
||||||
|
|
||||||
|
var meterRows = generationMeters
|
||||||
|
.Select(m => new GenerationMeterRow(m.Id, m.Name, genByMeter[m.Id].Values.Sum()))
|
||||||
|
.OrderByDescending(r => r.Generation)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var totalGeneration = meterRows.Sum(r => r.Generation);
|
||||||
|
double? totalLoad = loadByMonth?.Values.Sum();
|
||||||
|
double? totalGrid = gridByMonth?.Values.Sum();
|
||||||
|
double? totalSelf = totalLoad is not null && totalGrid is not null ? totalLoad - totalGrid : null;
|
||||||
|
double? autarky = totalSelf is not null && totalLoad is > 0 ? totalSelf / totalLoad : null;
|
||||||
|
double? selfRatio = totalSelf is not null && totalGeneration > 0 ? totalSelf / totalGeneration : null;
|
||||||
|
double? totalSavings = months.Any(m => m.Savings is not null) ? months.Sum(m => m.Savings ?? 0) : null;
|
||||||
|
|
||||||
|
return new SolarSummary(
|
||||||
|
totalGeneration, totalLoad, totalGrid, totalSelf, autarky, selfRatio, totalSavings, meterRows, months);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyDictionary<DateOnly, double>> MonthlyAsync(
|
||||||
|
MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql =
|
||||||
|
"SELECT (time_bucket(INTERVAL '1 month', \"time\", 'Europe/Berlin') AT TIME ZONE 'Europe/Berlin')::date AS period, " +
|
||||||
|
"sum(amount) AS amount " +
|
||||||
|
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
|
||||||
|
"GROUP BY period";
|
||||||
|
|
||||||
|
var connection = db.Database.GetDbConnection();
|
||||||
|
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
|
||||||
|
var rows = await connection.QueryAsync<(DateOnly Period, double Amount)>(command).ConfigureAwait(false);
|
||||||
|
return rows.ToDictionary(r => r.Period, r => r.Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
}
|
||||||
@@ -33,8 +33,16 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<ReferenceDataImporter>();
|
services.AddScoped<ReferenceDataImporter>();
|
||||||
services.AddScoped<IngestionService>();
|
services.AddScoped<IngestionService>();
|
||||||
services.AddScoped<MqttMessageRouter>();
|
services.AddScoped<MqttMessageRouter>();
|
||||||
|
// HttpClient + HA tester are available even with live ingestion off, so the admin
|
||||||
|
// "Test connection" works without the background workers running.
|
||||||
|
services.AddHttpClient();
|
||||||
|
services.AddScoped<HaConnectionTester>();
|
||||||
services.AddScoped<Costing.CostService>();
|
services.AddScoped<Costing.CostService>();
|
||||||
services.AddScoped<Dashboard.DashboardService>();
|
services.AddScoped<Dashboard.DashboardService>();
|
||||||
|
services.AddScoped<Dashboard.SolarService>();
|
||||||
|
services.AddScoped<Dashboard.ConsumableService>();
|
||||||
|
services.AddScoped<Dashboard.MeterDetailService>();
|
||||||
|
services.AddScoped<Dashboard.FlowService>();
|
||||||
services.AddScoped<Backup.ExportService>();
|
services.AddScoped<Backup.ExportService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fail fast BEFORE creating the marker meter: if the CSVs are missing (e.g. not shipped in
|
||||||
|
// the image) we must not seed a half-loaded dataset that IsLoadedAsync then reports as done.
|
||||||
|
EnsureSampleFilesPresent(sampleDataDirectory);
|
||||||
|
|
||||||
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
|
await DatabaseSeeder.SeedAsync(_db, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false);
|
var electricity = await EnergyTypeIdAsync("electricity", cancellationToken).ConfigureAwait(false);
|
||||||
@@ -47,8 +51,15 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
|||||||
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
|
var wasser = Meter("Zähler Wasser", water, MeterMode.CumulativeCounter, "m3", initialBaseline: 820);
|
||||||
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
|
var oilTank = Meter("Öltank", oil, MeterMode.ConsumableBalance, "L");
|
||||||
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
|
var burner = Meter("Brenner", oil, MeterMode.RuntimeCounter, "h");
|
||||||
|
// A virtual "sum" meter: no readings of its own — in the flow view it equals Solar 1 + Solar 2.
|
||||||
|
var sumSolar = Meter("Summe Solar", electricity, MeterMode.Virtual, "kWh");
|
||||||
|
|
||||||
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner);
|
// Tag the PV meters' roles (config, not hardcoded names) so the Solar panel can derive
|
||||||
|
// self-consumption = total_load − grid_import and savings generically (SDD §8.4).
|
||||||
|
haus.Meta = MeterMeta.WithRole(haus.Meta, MeterRoles.TotalLoad);
|
||||||
|
netz.Meta = MeterMeta.WithRole(netz.Meta, MeterRoles.GridImport);
|
||||||
|
|
||||||
|
_db.Meters.AddRange(haus, netz, auto, solar1, solar2, wasser, oilTank, burner, sumSolar);
|
||||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
_db.Tanks.Add(new Tank
|
_db.Tanks.Add(new Tank
|
||||||
@@ -59,6 +70,16 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
|||||||
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
|
Calibration = $"{{\"volumePerUnit\":{ReferenceProfiles.OilLitresPerCm.ToString(System.Globalization.CultureInfo.InvariantCulture)}}}",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Demo flow chain (electricity /energy view): Solar 1 + Solar 2 → Summe Solar; then
|
||||||
|
// Grid + Summe Solar → Haus → Auto + "Other". The remainder under Grid/Summe Solar is the
|
||||||
|
// input that didn't reach the house load (solar export / battery / inverter losses).
|
||||||
|
_db.MeterLinks.AddRange(
|
||||||
|
new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id },
|
||||||
|
new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id },
|
||||||
|
new MeterLink { FromMeterId = netz.Id, ToMeterId = haus.Id },
|
||||||
|
new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = haus.Id },
|
||||||
|
new MeterLink { FromMeterId = haus.Id, ToMeterId = auto.Id });
|
||||||
|
|
||||||
AddElectricityTariffs(electricity);
|
AddElectricityTariffs(electricity);
|
||||||
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
|
AddTariff(TariffScope.EnergyType, water, 5.00, "EUR/m3", new DateOnly(2022, 11, 1));
|
||||||
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
|
// Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
|
||||||
@@ -86,12 +107,32 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
|
|||||||
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
|
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly string[] RequiredFiles = [ElectricityFile, WaterFile, OilFile, CostsFile];
|
||||||
|
|
||||||
|
/// <summary>Throws a clear error if the sample directory or any reference CSV is missing, so a
|
||||||
|
/// failed load surfaces to the user instead of silently seeding meters with no data.</summary>
|
||||||
|
private static void EnsureSampleFilesPresent(string sampleDataDirectory)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(sampleDataDirectory))
|
||||||
|
{
|
||||||
|
throw new DirectoryNotFoundException(
|
||||||
|
$"Reference-data directory not found: '{sampleDataDirectory}'. The bundled Energiebilanz CSVs are missing from this deployment.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing = RequiredFiles.Where(f => !File.Exists(Path.Combine(sampleDataDirectory, f))).ToList();
|
||||||
|
if (missing.Count > 0)
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"Reference CSV(s) missing from '{sampleDataDirectory}': {string.Join(", ", missing)}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
|
private async Task ImportSheetAsync(string dir, string file, MappingProfile profile, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = Path.Combine(dir, file);
|
var path = Path.Combine(dir, file);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
return;
|
throw new FileNotFoundException($"Reference CSV disappeared during import: '{path}'.", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
StagedImport staged;
|
StagedImport staged;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ public sealed record EndpointConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||||
|
|
||||||
public string? ResolveUsername() => Resolve(UsernameEnv);
|
public string? ResolveUsername() => Resolve(UsernameEnv);
|
||||||
|
|
||||||
public string? ResolvePassword() => Resolve(PasswordEnv);
|
public string? ResolvePassword() => Resolve(PasswordEnv);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Ingestion;
|
||||||
|
|
||||||
|
/// <summary>Outcome of a Home Assistant connectivity test.</summary>
|
||||||
|
public sealed record HaTestResult(bool Ok, string Message, double? SampleValue = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies a Home Assistant connection from the admin UI: checks the base URL + resolved token
|
||||||
|
/// against <c>GET /api/</c>, and optionally reads one entity's state. Confirms the app can actually
|
||||||
|
/// read HA before a source is relied upon (SDD §6.2). Token is resolved by reference (env var).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HaConnectionTester(IHttpClientFactory httpClientFactory, ILogger<HaConnectionTester> logger)
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||||
|
private readonly ILogger<HaConnectionTester> _logger = logger;
|
||||||
|
|
||||||
|
public async Task<HaTestResult> TestAsync(
|
||||||
|
string? baseUrl, string? tokenEnv, string? entityId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
{
|
||||||
|
return new HaTestResult(false, "Base URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tokenEnv))
|
||||||
|
{
|
||||||
|
return new HaTestResult(false, "Token env-var name is required (the token is resolved from it at runtime).");
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = Environment.GetEnvironmentVariable(tokenEnv);
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return new HaTestResult(false, $"Environment variable '{tokenEnv}' is not set on the server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl.TrimEnd('/')}/api/");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new HaTestResult(false, $"HA returned {(int)response.StatusCode} {response.ReasonPhrase}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(entityId))
|
||||||
|
{
|
||||||
|
return new HaTestResult(true, "Connected — Home Assistant API reachable and token accepted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = await new HaStateClient(client).GetStateAsync(baseUrl, token, entityId, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
return state is { } value
|
||||||
|
? new HaTestResult(true, $"Connected — {entityId} = {value.Value}.", value.Value)
|
||||||
|
: new HaTestResult(false, $"Connected, but '{entityId}' has no numeric state (unavailable/unknown or non-numeric).");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Home Assistant connection test failed for {BaseUrl}", baseUrl);
|
||||||
|
return new HaTestResult(false, $"Connection failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Ingestion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for a Home Assistant
|
||||||
|
/// connection (SDD §6.2). The long-lived token is stored by reference only: <see cref="TokenEnv"/>
|
||||||
|
/// names an environment variable resolved at runtime — never the token itself (SDD §6.4).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record HaEndpointConfig
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Base URL of the Home Assistant instance, e.g. <c>http://homeassistant.local:8123</c>.</summary>
|
||||||
|
public string? BaseUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Name of the environment variable holding the long-lived access token.</summary>
|
||||||
|
public string? TokenEnv { get; init; }
|
||||||
|
|
||||||
|
public static HaEndpointConfig Parse(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return new HaEndpointConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<HaEndpointConfig>(json, Options) ?? new HaEndpointConfig();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return new HaEndpointConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||||
|
|
||||||
|
/// <summary>Resolves the token from the referenced environment variable (null if unset).</summary>
|
||||||
|
public string? ResolveToken() =>
|
||||||
|
string.IsNullOrWhiteSpace(TokenEnv) ? null : Environment.GetEnvironmentVariable(TokenEnv);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
|
||||||
using MeterVault.Core.Domain;
|
using MeterVault.Core.Domain;
|
||||||
using MeterVault.Infrastructure.Persistence;
|
using MeterVault.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -99,10 +98,8 @@ public sealed class HomeAssistantWorker(
|
|||||||
HaStateClient client, IngestionService ingestion, IngestionEndpoint endpoint,
|
HaStateClient client, IngestionService ingestion, IngestionEndpoint endpoint,
|
||||||
MeterSource source, SourceConfig config, CancellationToken cancellationToken)
|
MeterSource source, SourceConfig config, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var endpointConfig = ParseHaEndpoint(endpoint.Config);
|
var endpointConfig = HaEndpointConfig.Parse(endpoint.Config);
|
||||||
var token = string.IsNullOrWhiteSpace(endpointConfig.TokenEnv)
|
var token = endpointConfig.ResolveToken();
|
||||||
? null
|
|
||||||
: Environment.GetEnvironmentVariable(endpointConfig.TokenEnv);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(endpointConfig.BaseUrl) || string.IsNullOrWhiteSpace(token)
|
if (string.IsNullOrWhiteSpace(endpointConfig.BaseUrl) || string.IsNullOrWhiteSpace(token)
|
||||||
|| string.IsNullOrWhiteSpace(config.EntityId))
|
|| string.IsNullOrWhiteSpace(config.EntityId))
|
||||||
@@ -125,28 +122,4 @@ public sealed class HomeAssistantWorker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static HaEndpoint ParseHaEndpoint(string? json)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
|
||||||
{
|
|
||||||
return new HaEndpoint();
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<HaEndpoint>(json,
|
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new HaEndpoint();
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return new HaEndpoint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record HaEndpoint
|
|
||||||
{
|
|
||||||
public string? BaseUrl { get; init; }
|
|
||||||
|
|
||||||
public string? TokenEnv { get; init; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ public sealed class MeterVaultOptions
|
|||||||
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
|
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
|
||||||
public bool RunMigrationsAtStartup { get; set; } = true;
|
public bool RunMigrationsAtStartup { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load the bundled Energiebilanz reference dataset on startup if the database has none yet
|
||||||
|
/// (idempotent — guarded by a marker meter). Off by default; set <c>MeterVault__SeedReferenceData=true</c>
|
||||||
|
/// for a one-command populated demo/test instance.
|
||||||
|
/// </summary>
|
||||||
|
public bool SeedReferenceData { get; set; }
|
||||||
|
|
||||||
/// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary>
|
/// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary>
|
||||||
public bool EnableLiveIngestion { get; set; } = true;
|
public bool EnableLiveIngestion { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
|
|||||||
public DbSet<EnergyType> EnergyTypes => Set<EnergyType>();
|
public DbSet<EnergyType> EnergyTypes => Set<EnergyType>();
|
||||||
public DbSet<Meter> Meters => Set<Meter>();
|
public DbSet<Meter> Meters => Set<Meter>();
|
||||||
public DbSet<MeterSource> MeterSources => Set<MeterSource>();
|
public DbSet<MeterSource> MeterSources => Set<MeterSource>();
|
||||||
|
public DbSet<MeterLink> MeterLinks => Set<MeterLink>();
|
||||||
public DbSet<Reading> Readings => Set<Reading>();
|
public DbSet<Reading> Readings => Set<Reading>();
|
||||||
public DbSet<Consumption> Consumption => Set<Consumption>();
|
public DbSet<Consumption> Consumption => Set<Consumption>();
|
||||||
public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>();
|
public DbSet<MeterEvent> MeterEvents => Set<MeterEvent>();
|
||||||
@@ -74,6 +75,16 @@ public sealed class MeterVaultDbContext(DbContextOptions<MeterVaultDbContext> op
|
|||||||
e.HasIndex(x => x.MeterId);
|
e.HasIndex(x => x.MeterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
b.Entity<MeterLink>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("meter_link");
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.HasOne(x => x.FromMeter).WithMany().HasForeignKey(x => x.FromMeterId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne(x => x.ToMeter).WithMany().HasForeignKey(x => x.ToMeterId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasIndex(x => new { x.FromMeterId, x.ToMeterId }).IsUnique();
|
||||||
|
e.ToTable(t => t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id"));
|
||||||
|
});
|
||||||
|
|
||||||
// Hypertable — the (meter_id, time) PK contains the partition column (time),
|
// Hypertable — the (meter_id, time) PK contains the partition column (time),
|
||||||
// which Timescale requires. Converted to a hypertable in a raw-SQL migration.
|
// which Timescale requires. Converted to a hypertable in a raw-SQL migration.
|
||||||
b.Entity<Reading>(e =>
|
b.Entity<Reading>(e =>
|
||||||
|
|||||||
+931
@@ -0,0 +1,931 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(MeterVaultDbContext))]
|
||||||
|
[Migration("20260714114901_AddMeterLinks")]
|
||||||
|
partial class AddMeterLinks
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "timescaledb");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.AppSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("key");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("Key")
|
||||||
|
.HasName("pk_app_setting");
|
||||||
|
|
||||||
|
b.ToTable("app_setting", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("MeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("meter_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Time")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("time");
|
||||||
|
|
||||||
|
b.Property<short>("Kind")
|
||||||
|
.HasColumnType("smallint")
|
||||||
|
.HasColumnName("kind");
|
||||||
|
|
||||||
|
b.Property<double>("Amount")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("amount");
|
||||||
|
|
||||||
|
b.Property<int?>("ImportBatchId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("import_batch_id");
|
||||||
|
|
||||||
|
b.Property<short>("Quality")
|
||||||
|
.HasColumnType("smallint")
|
||||||
|
.HasColumnName("quality");
|
||||||
|
|
||||||
|
b.HasKey("MeterId", "Time", "Kind")
|
||||||
|
.HasName("pk_consumption");
|
||||||
|
|
||||||
|
b.HasIndex("ImportBatchId")
|
||||||
|
.HasDatabaseName("ix_consumption_import_batch_id");
|
||||||
|
|
||||||
|
b.ToTable("consumption", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ColorHex")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("color_hex");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("Sort")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("sort");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_cost_category");
|
||||||
|
|
||||||
|
b.ToTable("cost_category", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("category_id");
|
||||||
|
|
||||||
|
b.Property<short?>("EnergyTypeId")
|
||||||
|
.HasColumnType("smallint")
|
||||||
|
.HasColumnName("energy_type_id");
|
||||||
|
|
||||||
|
b.Property<int?>("MeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("meter_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_cost_category_member");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId")
|
||||||
|
.HasDatabaseName("ix_cost_category_member_category_id");
|
||||||
|
|
||||||
|
b.HasIndex("EnergyTypeId")
|
||||||
|
.HasDatabaseName("ix_cost_category_member_energy_type_id");
|
||||||
|
|
||||||
|
b.HasIndex("MeterId")
|
||||||
|
.HasDatabaseName("ix_cost_category_member_meter_id");
|
||||||
|
|
||||||
|
b.ToTable("cost_category_member", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_cost_category_member_target", "meter_id IS NOT NULL OR energy_type_id IS NOT NULL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||||
|
{
|
||||||
|
b.Property<short>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("smallint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<short>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("BaseUnit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)")
|
||||||
|
.HasColumnName("base_unit");
|
||||||
|
|
||||||
|
b.Property<string>("ColorHex")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("color_hex");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("default_mode");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("display_name");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("icon");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)")
|
||||||
|
.HasColumnName("key");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_energy_type");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_energy_type_key");
|
||||||
|
|
||||||
|
b.ToTable("energy_type", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.ImportBatch", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<string>("Mapping")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("mapping");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevertedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("reverted_at");
|
||||||
|
|
||||||
|
b.Property<int>("RowCount")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("row_count");
|
||||||
|
|
||||||
|
b.Property<string>("SourceName")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("source_name");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_import_batch");
|
||||||
|
|
||||||
|
b.ToTable("import_batch", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.IngestionEndpoint", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Config")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("config")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("is_enabled");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("last_seen_at");
|
||||||
|
|
||||||
|
b.Property<string>("LastStatus")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("last_status");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_ingestion_endpoint");
|
||||||
|
|
||||||
|
b.ToTable("ingestion_endpoint", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<double>("Amount")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("amount");
|
||||||
|
|
||||||
|
b.Property<int?>("CategoryId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("category_id");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)")
|
||||||
|
.HasColumnName("currency");
|
||||||
|
|
||||||
|
b.Property<int?>("ImportBatchId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("import_batch_id");
|
||||||
|
|
||||||
|
b.Property<int?>("MeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("meter_id");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("notes");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("PeriodEnd")
|
||||||
|
.HasColumnType("date")
|
||||||
|
.HasColumnName("period_end");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("PeriodStart")
|
||||||
|
.HasColumnType("date")
|
||||||
|
.HasColumnName("period_start");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_manual_cost");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId")
|
||||||
|
.HasDatabaseName("ix_manual_cost_category_id");
|
||||||
|
|
||||||
|
b.HasIndex("ImportBatchId")
|
||||||
|
.HasDatabaseName("ix_manual_cost_import_batch_id");
|
||||||
|
|
||||||
|
b.HasIndex("MeterId")
|
||||||
|
.HasDatabaseName("ix_manual_cost_meter_id");
|
||||||
|
|
||||||
|
b.ToTable("manual_cost", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<short>("EnergyTypeId")
|
||||||
|
.HasColumnType("smallint")
|
||||||
|
.HasColumnName("energy_type_id");
|
||||||
|
|
||||||
|
b.Property<double>("InitialBaseline")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("initial_baseline");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("InstalledAt")
|
||||||
|
.HasColumnType("date")
|
||||||
|
.HasColumnName("installed_at");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true)
|
||||||
|
.HasColumnName("is_active");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("location");
|
||||||
|
|
||||||
|
b.Property<string>("Manufacturer")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("manufacturer");
|
||||||
|
|
||||||
|
b.Property<string>("Meta")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("meta")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("Mode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("mode");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("RetiredAt")
|
||||||
|
.HasColumnType("date")
|
||||||
|
.HasColumnName("retired_at");
|
||||||
|
|
||||||
|
b.Property<string>("SerialNumber")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("serial_number");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("unit");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_meter");
|
||||||
|
|
||||||
|
b.HasIndex("EnergyTypeId", "IsActive")
|
||||||
|
.HasDatabaseName("ix_meter_energy_type_id_is_active");
|
||||||
|
|
||||||
|
b.ToTable("meter", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<double?>("Amount")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("amount");
|
||||||
|
|
||||||
|
b.Property<string>("EventType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("event_type");
|
||||||
|
|
||||||
|
b.Property<int?>("ImportBatchId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("import_batch_id");
|
||||||
|
|
||||||
|
b.Property<string>("Meta")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("meta")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<int>("MeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("meter_id");
|
||||||
|
|
||||||
|
b.Property<double?>("NewValue")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("new_value");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("notes");
|
||||||
|
|
||||||
|
b.Property<double?>("PrevValue")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("prev_value");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Time")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("time");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("unit");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_meter_event");
|
||||||
|
|
||||||
|
b.HasIndex("ImportBatchId")
|
||||||
|
.HasDatabaseName("ix_meter_event_import_batch_id");
|
||||||
|
|
||||||
|
b.HasIndex("MeterId", "Time")
|
||||||
|
.HasDatabaseName("ix_meter_event_meter_id_time");
|
||||||
|
|
||||||
|
b.ToTable("meter_event", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("FromMeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("from_meter_id");
|
||||||
|
|
||||||
|
b.Property<int>("ToMeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("to_meter_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_meter_link");
|
||||||
|
|
||||||
|
b.HasIndex("ToMeterId")
|
||||||
|
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||||
|
|
||||||
|
b.HasIndex("FromMeterId", "ToMeterId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||||
|
|
||||||
|
b.ToTable("meter_link", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Config")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("config")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<int?>("EndpointId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("endpoint_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsEnabled")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("is_enabled");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("last_seen_at");
|
||||||
|
|
||||||
|
b.Property<string>("LastStatus")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("last_status");
|
||||||
|
|
||||||
|
b.Property<double?>("LastValue")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("last_value");
|
||||||
|
|
||||||
|
b.Property<int>("MeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("meter_id");
|
||||||
|
|
||||||
|
b.Property<double>("Offset")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("offset");
|
||||||
|
|
||||||
|
b.Property<int>("Priority")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("priority");
|
||||||
|
|
||||||
|
b.Property<double>("Scale")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasDefaultValue(1.0)
|
||||||
|
.HasColumnName("scale");
|
||||||
|
|
||||||
|
b.Property<string>("SourceType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("source_type");
|
||||||
|
|
||||||
|
b.Property<string>("ValueKind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)")
|
||||||
|
.HasColumnName("value_kind");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_meter_source");
|
||||||
|
|
||||||
|
b.HasIndex("EndpointId")
|
||||||
|
.HasDatabaseName("ix_meter_source_endpoint_id");
|
||||||
|
|
||||||
|
b.HasIndex("MeterId")
|
||||||
|
.HasDatabaseName("ix_meter_source_meter_id");
|
||||||
|
|
||||||
|
b.ToTable("meter_source", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("MeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("meter_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Time")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("time");
|
||||||
|
|
||||||
|
b.Property<int>("Flags")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("flags");
|
||||||
|
|
||||||
|
b.Property<int?>("ImportBatchId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("import_batch_id");
|
||||||
|
|
||||||
|
b.Property<short>("Quality")
|
||||||
|
.HasColumnType("smallint")
|
||||||
|
.HasColumnName("quality");
|
||||||
|
|
||||||
|
b.Property<int?>("SourceId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("source_id");
|
||||||
|
|
||||||
|
b.Property<double>("Value")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("MeterId", "Time")
|
||||||
|
.HasName("pk_reading");
|
||||||
|
|
||||||
|
b.HasIndex("ImportBatchId")
|
||||||
|
.HasDatabaseName("ix_reading_import_batch_id");
|
||||||
|
|
||||||
|
b.ToTable("reading", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CachedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("cached_at");
|
||||||
|
|
||||||
|
b.Property<double?>("CachedBalance")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("cached_balance");
|
||||||
|
|
||||||
|
b.Property<string>("Calibration")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("calibration");
|
||||||
|
|
||||||
|
b.Property<double>("Capacity")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("capacity");
|
||||||
|
|
||||||
|
b.Property<double?>("FixedRate")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("fixed_rate");
|
||||||
|
|
||||||
|
b.Property<double?>("LowThreshold")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("low_threshold");
|
||||||
|
|
||||||
|
b.Property<int>("MeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("meter_id");
|
||||||
|
|
||||||
|
b.Property<string>("RateMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)")
|
||||||
|
.HasColumnName("rate_mode");
|
||||||
|
|
||||||
|
b.Property<double?>("ReorderThreshold")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("reorder_threshold");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)")
|
||||||
|
.HasColumnName("unit");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_tank");
|
||||||
|
|
||||||
|
b.HasIndex("MeterId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_tank_meter_id");
|
||||||
|
|
||||||
|
b.ToTable("tank", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Tariff", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Component")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)")
|
||||||
|
.HasColumnName("component");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("character varying(8)")
|
||||||
|
.HasColumnName("currency");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("notes");
|
||||||
|
|
||||||
|
b.Property<int?>("ScopeId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("scope_id");
|
||||||
|
|
||||||
|
b.Property<string>("ScopeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)")
|
||||||
|
.HasColumnName("scope_type");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("character varying(16)")
|
||||||
|
.HasColumnName("unit");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("ValidFrom")
|
||||||
|
.HasColumnType("date")
|
||||||
|
.HasColumnName("valid_from");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("ValidTo")
|
||||||
|
.HasColumnType("date")
|
||||||
|
.HasColumnName("valid_to");
|
||||||
|
|
||||||
|
b.Property<double>("Value")
|
||||||
|
.HasColumnType("double precision")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_tariff");
|
||||||
|
|
||||||
|
b.HasIndex("ScopeType", "ScopeId", "Component", "ValidFrom")
|
||||||
|
.HasDatabaseName("ix_tariff_scope_type_scope_id_component_valid_from");
|
||||||
|
|
||||||
|
b.ToTable("tariff", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Consumption", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_consumption_meter_meter_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.CostCategoryMember", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.CostCategory", "Category")
|
||||||
|
.WithMany("Members")
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_cost_category_member_cost_category_category_id");
|
||||||
|
|
||||||
|
b.HasOne("MeterVault.Core.Domain.EnergyType", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EnergyTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.HasConstraintName("fk_cost_category_member_energy_type_energy_type_id");
|
||||||
|
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.HasConstraintName("fk_cost_category_member_meter_meter_id");
|
||||||
|
|
||||||
|
b.Navigation("Category");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.ManualCost", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.CostCategory", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull)
|
||||||
|
.HasConstraintName("fk_manual_cost_cost_category_category_id");
|
||||||
|
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MeterId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull)
|
||||||
|
.HasConstraintName("fk_manual_cost_meter_meter_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.EnergyType", "EnergyType")
|
||||||
|
.WithMany("Meters")
|
||||||
|
.HasForeignKey("EnergyTypeId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_meter_energy_type_energy_type_id");
|
||||||
|
|
||||||
|
b.Navigation("EnergyType");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterEvent", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FromMeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||||
|
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ToMeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||||
|
|
||||||
|
b.Navigation("FromMeter");
|
||||||
|
|
||||||
|
b.Navigation("ToMeter");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EndpointId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull)
|
||||||
|
.HasConstraintName("fk_meter_source_ingestion_endpoints_endpoint_id");
|
||||||
|
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||||
|
.WithMany("Sources")
|
||||||
|
.HasForeignKey("MeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_meter_source_meter_meter_id");
|
||||||
|
|
||||||
|
b.Navigation("Endpoint");
|
||||||
|
|
||||||
|
b.Navigation("Meter");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Reading", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_reading_meter_meter_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Tank", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", "Meter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_tank_meter_meter_id");
|
||||||
|
|
||||||
|
b.Navigation("Meter");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.CostCategory", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Members");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.EnergyType", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Meters");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.Meter", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Sources");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MeterVault.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMeterLinks : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "meter_link",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
from_meter_id = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
to_meter_id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_meter_link", x => x.id);
|
||||||
|
table.CheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_meter_link_meter_from_meter_id",
|
||||||
|
column: x => x.from_meter_id,
|
||||||
|
principalTable: "meter",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_meter_link_meter_to_meter_id",
|
||||||
|
column: x => x.to_meter_id,
|
||||||
|
principalTable: "meter",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_meter_link_from_meter_id_to_meter_id",
|
||||||
|
table: "meter_link",
|
||||||
|
columns: new[] { "from_meter_id", "to_meter_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_meter_link_to_meter_id",
|
||||||
|
table: "meter_link",
|
||||||
|
column: "to_meter_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "meter_link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -499,6 +499,39 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("meter_event", (string)null);
|
b.ToTable("meter_event", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("FromMeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("from_meter_id");
|
||||||
|
|
||||||
|
b.Property<int>("ToMeterId")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("to_meter_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_meter_link");
|
||||||
|
|
||||||
|
b.HasIndex("ToMeterId")
|
||||||
|
.HasDatabaseName("ix_meter_link_to_meter_id");
|
||||||
|
|
||||||
|
b.HasIndex("FromMeterId", "ToMeterId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_meter_link_from_meter_id_to_meter_id");
|
||||||
|
|
||||||
|
b.ToTable("meter_link", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_meter_link_distinct", "from_meter_id <> to_meter_id");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -812,6 +845,27 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
|
|||||||
.HasConstraintName("fk_meter_event_meter_meter_id");
|
.HasConstraintName("fk_meter_event_meter_meter_id");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterLink", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", "FromMeter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FromMeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_meter_link_meter_from_meter_id");
|
||||||
|
|
||||||
|
b.HasOne("MeterVault.Core.Domain.Meter", "ToMeter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ToMeterId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_meter_link_meter_to_meter_id");
|
||||||
|
|
||||||
|
b.Navigation("FromMeter");
|
||||||
|
|
||||||
|
b.Navigation("ToMeter");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
modelBuilder.Entity("MeterVault.Core.Domain.MeterSource", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
b.HasOne("MeterVault.Core.Domain.IngestionEndpoint", "Endpoint")
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
|
||||||
|
namespace MeterVault.Core.Tests;
|
||||||
|
|
||||||
|
public sealed class MeterMetaTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Role_reads_configured_role()
|
||||||
|
{
|
||||||
|
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role("{\"role\":\"grid_import\"}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("{}")]
|
||||||
|
[InlineData("not json")]
|
||||||
|
[InlineData("{\"role\":123}")]
|
||||||
|
[InlineData("[1,2,3]")]
|
||||||
|
public void Role_is_null_when_absent_or_malformed(string meta)
|
||||||
|
{
|
||||||
|
Assert.Null(MeterMeta.Role(meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithRole_sets_role_and_preserves_other_keys()
|
||||||
|
{
|
||||||
|
var updated = MeterMeta.WithRole("{\"expression\":\"a-b\"}", MeterRoles.TotalLoad);
|
||||||
|
|
||||||
|
Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role(updated));
|
||||||
|
Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithRole_overwrites_existing_role()
|
||||||
|
{
|
||||||
|
var updated = MeterMeta.WithRole("{\"role\":\"grid_import\"}", MeterRoles.GridExport);
|
||||||
|
|
||||||
|
Assert.Equal(MeterRoles.GridExport, MeterMeta.Role(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithRole_handles_empty_meta()
|
||||||
|
{
|
||||||
|
var updated = MeterMeta.WithRole("", MeterRoles.GridImport);
|
||||||
|
|
||||||
|
Assert.Equal(MeterRoles.GridImport, MeterMeta.Role(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void SetRole_removes_role_when_blank_and_keeps_other_keys(string? role)
|
||||||
|
{
|
||||||
|
var updated = MeterMeta.SetRole("{\"role\":\"grid_import\",\"expression\":\"a-b\"}", role);
|
||||||
|
|
||||||
|
Assert.Null(MeterMeta.Role(updated));
|
||||||
|
Assert.Equal("a-b", MeterMeta.ReadString(updated, "expression"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using MeterVault.Infrastructure.Costing;
|
using MeterVault.Infrastructure.Costing;
|
||||||
|
using MeterVault.Infrastructure.Dashboard;
|
||||||
using MeterVault.Infrastructure.Import;
|
using MeterVault.Infrastructure.Import;
|
||||||
using MeterVault.Infrastructure.Persistence;
|
using MeterVault.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -43,6 +44,44 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
|||||||
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
|
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panel read models compute real figures from the reference data (SDD §8.4–§8.6).
|
||||||
|
int hausId;
|
||||||
|
short electricityTypeId;
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var services = scope.ServiceProvider;
|
||||||
|
var wide = new DateOnly(1997, 1, 1);
|
||||||
|
var toEnd = new DateOnly(2027, 1, 1);
|
||||||
|
|
||||||
|
var solar = await services.GetRequiredService<SolarService>().GetSummaryAsync(wide, toEnd);
|
||||||
|
Assert.True(solar.HasGeneration);
|
||||||
|
Assert.True(solar.Generation > 0);
|
||||||
|
// Haus (total_load) + Netz (grid_import) are role-tagged, so self-consumption/savings resolve.
|
||||||
|
Assert.True(solar.HasLoadContext);
|
||||||
|
Assert.NotNull(solar.SelfConsumption);
|
||||||
|
Assert.NotNull(solar.Savings);
|
||||||
|
|
||||||
|
var consumables = await services.GetRequiredService<ConsumableService>().GetConsumablesAsync(wide, toEnd);
|
||||||
|
var oil = Assert.Single(consumables);
|
||||||
|
Assert.True(oil.CurrentLevel is > 0);
|
||||||
|
Assert.NotEmpty(oil.Deliveries);
|
||||||
|
Assert.True(oil.ConsumptionInRange > 0);
|
||||||
|
|
||||||
|
await using var db = fx.CreateContext();
|
||||||
|
hausId = await db.Meters.Where(m => m.Name == "Zähler Haus").Select(m => m.Id).FirstAsync();
|
||||||
|
var detail = await services.GetRequiredService<MeterDetailService>().GetAsync(hausId);
|
||||||
|
Assert.NotNull(detail);
|
||||||
|
Assert.True(detail!.ReadingCount > 0);
|
||||||
|
Assert.True(detail.TotalConsumption > 0);
|
||||||
|
|
||||||
|
// Flow graph: the demo Haus → Auto chain yields a link + an "Other (Haus)" remainder.
|
||||||
|
electricityTypeId = await db.EnergyTypes.Where(t => t.Key == "electricity").Select(t => t.Id).FirstAsync();
|
||||||
|
var flow = await services.GetRequiredService<FlowService>()
|
||||||
|
.GetFlowAsync(electricityTypeId, new DateOnly(1997, 1, 1), new DateOnly(2027, 1, 1));
|
||||||
|
Assert.True(flow.HasChain);
|
||||||
|
Assert.Contains(flow.Nodes, n => n.IsOther);
|
||||||
|
}
|
||||||
|
|
||||||
using var client = factory.CreateClient();
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
var overview = await client.GetAsync(new Uri("/", UriKind.Relative));
|
var overview = await client.GetAsync(new Uri("/", UriKind.Relative));
|
||||||
@@ -55,7 +94,13 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
|||||||
Assert.Contains("This year", html, StringComparison.Ordinal);
|
Assert.Contains("This year", html, StringComparison.Ordinal);
|
||||||
Assert.Contains("Latest month with data", html, StringComparison.Ordinal);
|
Assert.Contains("Latest month with data", html, StringComparison.Ordinal);
|
||||||
|
|
||||||
foreach (var path in new[] { "/meters", "/trends", "/import", "/admin/tariffs", "/admin/energy-types" })
|
foreach (var path in new[]
|
||||||
|
{
|
||||||
|
"/meters", "/trends", "/solar", "/consumables", "/import",
|
||||||
|
"/admin/tariffs", "/admin/energy-types", "/admin/categories",
|
||||||
|
"/admin/connectors", "/admin/settings", $"/meters/{hausId}",
|
||||||
|
$"/energy/{electricityTypeId}",
|
||||||
|
})
|
||||||
{
|
{
|
||||||
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
|
var response = await client.GetAsync(new Uri(path, UriKind.Relative));
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
@@ -70,6 +115,7 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
|
|||||||
|
|
||||||
private static async Task ClearDataAsync(MeterVaultDbContext db)
|
private static async Task ClearDataAsync(MeterVaultDbContext db)
|
||||||
{
|
{
|
||||||
|
await db.MeterLinks.ExecuteDeleteAsync();
|
||||||
await db.Consumption.ExecuteDeleteAsync();
|
await db.Consumption.ExecuteDeleteAsync();
|
||||||
await db.Readings.ExecuteDeleteAsync();
|
await db.Readings.ExecuteDeleteAsync();
|
||||||
await db.MeterEvents.ExecuteDeleteAsync();
|
await db.MeterEvents.ExecuteDeleteAsync();
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using MeterVault.Core.Domain;
|
||||||
|
using MeterVault.Infrastructure.Dashboard;
|
||||||
|
using MeterVault.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace MeterVault.Integration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full
|
||||||
|
/// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the
|
||||||
|
/// child's consumption proportionally to the parents' own consumption.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Timescale")]
|
||||||
|
public sealed class FlowServiceTests(TimescaleFixture fx)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Single_parent_chain_makes_other_remainder()
|
||||||
|
{
|
||||||
|
await using var db = fx.CreateContext();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = await SeedTypeAsync(db, "flow_elec_a");
|
||||||
|
var main = await AddMeterAsync(db, "Main", type);
|
||||||
|
var car = await AddMeterAsync(db, "Car", type);
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await AddConsumptionAsync(db, main.Id, 100);
|
||||||
|
await AddConsumptionAsync(db, car.Id, 30);
|
||||||
|
|
||||||
|
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||||
|
|
||||||
|
Assert.Equal(100, graph.Total, 1);
|
||||||
|
var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}");
|
||||||
|
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
|
||||||
|
var other = Assert.Single(graph.Nodes, n => n.IsOther);
|
||||||
|
Assert.Equal(70, other.Value, 1); // 100 − 30
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await ClearAsync(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Two_parents_split_child_proportionally()
|
||||||
|
{
|
||||||
|
await using var db = fx.CreateContext();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = await SeedTypeAsync(db, "flow_elec_b");
|
||||||
|
var grid = await AddMeterAsync(db, "Grid", type);
|
||||||
|
var solar = await AddMeterAsync(db, "Solar draw", type);
|
||||||
|
var house = await AddMeterAsync(db, "House", type);
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await AddConsumptionAsync(db, grid.Id, 75);
|
||||||
|
await AddConsumptionAsync(db, solar.Id, 25);
|
||||||
|
await AddConsumptionAsync(db, house.Id, 40);
|
||||||
|
|
||||||
|
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||||
|
|
||||||
|
// House (40) splits 75:25 → 30 from grid, 10 from solar.
|
||||||
|
Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||||
|
Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await ClearAsync(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Generation_meter_counts_as_source()
|
||||||
|
{
|
||||||
|
await using var db = fx.CreateContext();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = await SeedTypeAsync(db, "flow_elec_c");
|
||||||
|
var grid = await AddMeterAsync(db, "Grid", type);
|
||||||
|
var solar = await AddMeterAsync(db, "Solar", type);
|
||||||
|
var house = await AddMeterAsync(db, "House", type);
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await AddConsumptionAsync(db, grid.Id, 75); // grid import
|
||||||
|
await AddConsumptionAsync(db, solar.Id, 30, ConsumptionKind.Generation); // solar generation
|
||||||
|
await AddConsumptionAsync(db, house.Id, 40); // house load
|
||||||
|
|
||||||
|
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||||
|
|
||||||
|
// Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar.
|
||||||
|
Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||||
|
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||||
|
// Remainder across grid+solar = (75+30) − 40 = 65 (export + battery/inverter losses).
|
||||||
|
Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await ClearAsync(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Virtual_sum_meter_aggregates_its_upstreams()
|
||||||
|
{
|
||||||
|
await using var db = fx.CreateContext();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = await SeedTypeAsync(db, "flow_elec_d");
|
||||||
|
var solar1 = await AddMeterAsync(db, "Solar 1", type);
|
||||||
|
var solar2 = await AddMeterAsync(db, "Solar 2", type);
|
||||||
|
var sumSolar = await AddMeterAsync(db, "Sum Solar", type, MeterMode.Virtual);
|
||||||
|
var grid = await AddMeterAsync(db, "Grid", type);
|
||||||
|
var house = await AddMeterAsync(db, "House", type);
|
||||||
|
// Solar1 + Solar2 → Sum Solar ; Grid + Sum Solar → House.
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = solar1.Id, ToMeterId = sumSolar.Id });
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = solar2.Id, ToMeterId = sumSolar.Id });
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
|
||||||
|
db.MeterLinks.Add(new MeterLink { FromMeterId = sumSolar.Id, ToMeterId = house.Id });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await AddConsumptionAsync(db, solar1.Id, 15, ConsumptionKind.Generation);
|
||||||
|
await AddConsumptionAsync(db, solar2.Id, 15, ConsumptionKind.Generation);
|
||||||
|
await AddConsumptionAsync(db, grid.Id, 75);
|
||||||
|
await AddConsumptionAsync(db, house.Id, 40);
|
||||||
|
|
||||||
|
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
|
||||||
|
|
||||||
|
// Sum Solar has no readings but equals Solar 1 + Solar 2 = 30.
|
||||||
|
Assert.Equal(30, graph.Nodes.Single(n => n.MeterId == sumSolar.Id).Value, 1);
|
||||||
|
// House (40) splits across Grid (75) and Sum Solar (30) → 40*30/105 from solar.
|
||||||
|
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{sumSolar.Id}" && l.To == $"m{house.Id}").Value, 1);
|
||||||
|
// No spurious remainder under Solar 1/2 (their whole output flows into Sum Solar).
|
||||||
|
Assert.DoesNotContain(graph.Nodes, n => n.IsOther && n.Id == $"other{solar1.Id}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await ClearAsync(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<short> SeedTypeAsync(MeterVaultDbContext db, string key)
|
||||||
|
{
|
||||||
|
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
|
||||||
|
db.EnergyTypes.Add(type);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return type.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Meter> AddMeterAsync(MeterVaultDbContext db, string name, short type, MeterMode mode = MeterMode.DirectDelta)
|
||||||
|
{
|
||||||
|
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = mode, Unit = "kWh" };
|
||||||
|
db.Meters.Add(meter);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return meter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption)
|
||||||
|
{
|
||||||
|
db.Consumption.Add(new Consumption
|
||||||
|
{
|
||||||
|
MeterId = meterId,
|
||||||
|
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
|
||||||
|
Amount = amount,
|
||||||
|
Kind = kind,
|
||||||
|
Quality = ReadingQuality.Manual,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ClearAsync(MeterVaultDbContext db)
|
||||||
|
{
|
||||||
|
await db.MeterLinks.ExecuteDeleteAsync();
|
||||||
|
await db.Consumption.ExecuteDeleteAsync();
|
||||||
|
await db.Meters.ExecuteDeleteAsync();
|
||||||
|
await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using MeterVault.Infrastructure.Ingestion;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace MeterVault.Integration.Tests.Ingestion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The HA connection tester must fail closed on missing config <em>before</em> any network call —
|
||||||
|
/// so a misconfigured connector gives a clear message, never an exception. The stub factory throws
|
||||||
|
/// if the tester ever tries to create an HttpClient, proving these branches never reach the network.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HaConnectionTesterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Missing_base_url_fails_without_network()
|
||||||
|
{
|
||||||
|
var result = await NewTester().TestAsync(baseUrl: "", tokenEnv: "SOME_TOKEN", entityId: null);
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Contains("Base URL", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Missing_token_env_fails_without_network()
|
||||||
|
{
|
||||||
|
var result = await NewTester().TestAsync(baseUrl: "http://ha.local:8123", tokenEnv: "", entityId: null);
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Contains("env-var", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unset_token_env_var_fails_without_network()
|
||||||
|
{
|
||||||
|
var result = await NewTester().TestAsync(
|
||||||
|
baseUrl: "http://ha.local:8123", tokenEnv: "METERVAULT_DEFINITELY_UNSET_TOKEN_VAR", entityId: null);
|
||||||
|
|
||||||
|
Assert.False(result.Ok);
|
||||||
|
Assert.Contains("is not set", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HaConnectionTester NewTester() =>
|
||||||
|
new(new ThrowingHttpClientFactory(), NullLogger<HaConnectionTester>.Instance);
|
||||||
|
|
||||||
|
private sealed class ThrowingHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
public HttpClient CreateClient(string name) =>
|
||||||
|
throw new InvalidOperationException("Network must not be touched for a config-guard failure.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user