Polish/audit: fix bugs found by 3 subsystem audits
ci / build-test (push) Successful in 2m45s

Correctness/data:
- Fix demo cost double-count: reference importer no longer imports the Kosten Strom/Wasser
  columns for categories that are metered (only Heizung), so Wasser rollup is 70€ not 140€.
- Spurious-decrease guard: only a reset/swap in the window (prevReading, thisReading] explains
  a decrease — an old historical reset no longer permanently disables the guard.
- Gate swap auto-detection on MappingProfile.DetectCumulativeSwaps (flag was ignored).
- Prorate basePrice by bucket length (day/month/year); guard virtual expressions against NaN/Inf.

Concurrency/infra:
- Blazor: register a DbContextFactory; CostService/DashboardService and the read pages now use
  short-lived per-operation contexts (no shared circuit DbContext); guard Trends re-entrancy.
- /events: wrap event insert + consumption recompute in one transaction (atomic); 404 (not 500)
  on unknown meter.
- MQTT worker: subscribe to newly-added topics on each tick; move client cleanup into finally.
- Migrations: CREATE MATERIALIZED VIEW IF NOT EXISTS + if_not_exists on CAgg/compression/
  hypertable calls (re-run-safe after a mid-migration crash).
- HA worker: prune stale poll-schedule entries; export: null dangling ImportBatchIds on restore.

API/security:
- API fail-closed by default: with no keys and AllowAnonymousApi off, /api/v1 returns 401
  (protects /export and /import). New MeterVault:AllowAnonymousApi opt-in.
- Cap /readings batch at 5000; report ignored (unknown-meter) count; enums as strings in JSON.

+4 regression tests (guard window, API closed, /events 404, no demo double-count). 98 tests
green; Docker deploy re-verified healthy with the API fail-closed.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 12:56:51 +02:00
parent e223278771
commit a6edec2b12
28 changed files with 326 additions and 109 deletions
+4
View File
@@ -41,9 +41,13 @@ Configuration is via environment variables (`Section__Key` double-underscore map
| `ConnectionStrings__Default` | PostgreSQL/Timescale connection string | | `ConnectionStrings__Default` | PostgreSQL/Timescale connection string |
| `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) | | `MeterVault__TimeZone` | Local timezone for buckets/display (default `Europe/Berlin`) |
| `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header | | `MeterVault__ApiKeys__0` | An API key accepted on the `X-Api-Key` header |
| `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 |
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).
Secrets (broker/HA tokens) are **never** stored in the database — endpoint configs hold the *name* Secrets (broker/HA tokens) are **never** stored in the database — endpoint configs hold the *name*
of an environment variable, resolved at runtime. of an environment variable, resolved at runtime.
+3
View File
@@ -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}
# 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__AllowAnonymousApi: "true"
ports: ports:
- "${METERVAULT_PORT:-8080}:8080" - "${METERVAULT_PORT:-8080}:8080"
healthcheck: healthcheck:
+20 -4
View File
@@ -17,18 +17,25 @@ public sealed record EventPush(int MeterId, DateTimeOffset Time, MeterEventType
public sealed record TariffPush(TariffScope ScopeType, int? ScopeId, TariffComponent Component, public sealed record TariffPush(TariffScope ScopeType, int? ScopeId, TariffComponent Component,
double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo); double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
public sealed record IngestResult(int Written, int Updated, int Rejected); public sealed record IngestResult(int Written, int Updated, int Rejected, int Ignored);
/// <summary>Maps the versioned REST API. All endpoints require a valid API key (SDD §9).</summary> /// <summary>Maps the versioned REST API. All endpoints require a valid API key (SDD §9).</summary>
public static class ApiEndpoints public static class ApiEndpoints
{ {
private const int MaxReadingsPerRequest = 5000;
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
{ {
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault"); var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
api.MapPost("/readings", async (ReadingPush[] readings, IngestionService ingestion, CancellationToken ct) => api.MapPost("/readings", async (ReadingPush[] readings, IngestionService ingestion, CancellationToken ct) =>
{ {
int written = 0, updated = 0, rejected = 0; if (readings.Length > MaxReadingsPerRequest)
{
return Results.BadRequest(new { error = $"At most {MaxReadingsPerRequest} readings per request." });
}
int written = 0, updated = 0, rejected = 0, ignored = 0;
foreach (var r in readings) foreach (var r in readings)
{ {
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct)) switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct))
@@ -36,11 +43,11 @@ public static class ApiEndpoints
case IngestionOutcome.Written: written++; break; case IngestionOutcome.Written: written++; break;
case IngestionOutcome.Updated: updated++; break; case IngestionOutcome.Updated: updated++; break;
case IngestionOutcome.RejectedDecrease: rejected++; break; case IngestionOutcome.RejectedDecrease: rejected++; break;
default: break; default: ignored++; break; // unknown meter
} }
} }
return Results.Ok(new IngestResult(written, updated, rejected)); return Results.Ok(new IngestResult(written, updated, rejected, ignored));
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push."); }).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) => api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
@@ -70,6 +77,14 @@ public static class ApiEndpoints
api.MapPost("/events", async (EventPush push, MeterVaultDbContext db, api.MapPost("/events", async (EventPush push, MeterVaultDbContext db,
NormalizationService normalization, CancellationToken ct) => NormalizationService normalization, CancellationToken ct) =>
{ {
if (!await db.Meters.AnyAsync(m => m.Id == push.MeterId, ct))
{
return Results.NotFound(new { error = $"Meter {push.MeterId} does not exist." });
}
// One transaction so the event insert + consumption recompute (which deletes then
// re-inserts) are atomic — a failure must not leave the meter with no consumption.
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.MeterEvents.Add(new MeterEvent db.MeterEvents.Add(new MeterEvent
{ {
MeterId = push.MeterId, MeterId = push.MeterId,
@@ -84,6 +99,7 @@ public static class ApiEndpoints
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await normalization.RecomputeMeterAsync(push.MeterId, null, ct); await normalization.RecomputeMeterAsync(push.MeterId, null, ct);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return Results.Ok(); return Results.Ok();
}).WithSummary("Record a delivery / swap / tank level / correction and recompute the meter."); }).WithSummary("Record a delivery / swap / tank level / correction and recompute the meter.");
+5 -1
View File
@@ -17,7 +17,11 @@ public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpoin
{ {
if (_options.ApiKeys.Count == 0) if (_options.ApiKeys.Count == 0)
{ {
return await next(context).ConfigureAwait(false); // Fail closed: an unconfigured deployment must not expose export/import. Opt in explicitly.
return _options.AllowAnonymousApi
? await next(context).ConfigureAwait(false)
: Results.Problem(statusCode: StatusCodes.Status401Unauthorized,
title: "API is closed: configure MeterVault:ApiKeys or set AllowAnonymousApi=true.");
} }
var provided = context.HttpContext.Request.Headers[HeaderName].ToString(); var provided = context.HttpContext.Request.Headers[HeaderName].ToString();
@@ -1,6 +1,6 @@
@page "/admin/energy-types" @page "/admin/energy-types"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Energy types</PageTitle> <PageTitle>MeterVault — Energy types</PageTitle>
@@ -32,6 +32,9 @@ else
@code { @code {
private List<EnergyType>? _types; private List<EnergyType>? _types;
protected override async Task OnInitializedAsync() => protected override async Task OnInitializedAsync()
_types = await Db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync(); {
await using var db = await DbFactory.CreateDbContextAsync();
_types = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
}
} }
+6 -3
View File
@@ -1,6 +1,6 @@
@page "/admin/tariffs" @page "/admin/tariffs"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Tariffs</PageTitle> <PageTitle>MeterVault — Tariffs</PageTitle>
@@ -40,8 +40,11 @@ else
@code { @code {
private List<Tariff>? _tariffs; private List<Tariff>? _tariffs;
protected override async Task OnInitializedAsync() => protected override async Task OnInitializedAsync()
_tariffs = await Db.Tariffs.AsNoTracking() {
await using var db = await DbFactory.CreateDbContextAsync();
_tariffs = await db.Tariffs.AsNoTracking()
.OrderBy(t => t.Component).ThenBy(t => t.ValidFrom) .OrderBy(t => t.Component).ThenBy(t => t.ValidFrom)
.ToListAsync(); .ToListAsync();
} }
}
+3 -2
View File
@@ -1,6 +1,6 @@
@page "/meters" @page "/meters"
@rendermode InteractiveServer @rendermode InteractiveServer
@inject MeterVault.Infrastructure.Persistence.MeterVaultDbContext Db @inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
<PageTitle>MeterVault — Meters</PageTitle> <PageTitle>MeterVault — Meters</PageTitle>
@@ -54,7 +54,8 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
_meters = await Db.Meters await using var db = await DbFactory.CreateDbContextAsync();
_meters = await db.Meters
.AsNoTracking() .AsNoTracking()
.Include(m => m.EnergyType) .Include(m => m.EnergyType)
.Include(m => m.Sources) .Include(m => m.Sources)
+12 -1
View File
@@ -13,7 +13,7 @@
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem> <MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
<MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem> <MudSelectItem T="int" Value="48">Last 48 months</MudSelectItem>
</MudSelect> </MudSelect>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync">Apply</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="LoadAsync" Disabled="_loading">Apply</MudButton>
</div> </div>
@if (_loading) @if (_loading)
@@ -38,10 +38,21 @@
private async Task LoadAsync() private async Task LoadAsync()
{ {
if (_loading)
{
return; // guard against overlapping loads
}
_loading = true; _loading = true;
try
{
var asOf = DateOnly.FromDateTime(DateTime.UtcNow); var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
var from = asOf.AddMonths(-_months); var from = asOf.AddMonths(-_months);
_points = await Dash.GetMonthlyTrendAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1)); _points = await Dash.GetMonthlyTrendAsync(new DateOnly(from.Year, from.Month, 1), asOf.AddMonths(1));
}
finally
{
_loading = false; _loading = false;
} }
} }
}
+4
View File
@@ -39,6 +39,10 @@ try
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
// Serialize/accept enums as strings on the REST API (e.g. event Type "Delivery").
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()));
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c => builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new() { Title = "MeterVault API", Version = "v1" })); c.SwaggerDoc("v1", new() { Title = "MeterVault API", Version = "v1" }));
@@ -47,11 +47,17 @@ public sealed class VirtualNormalizer : IMeterNormalizer
variables[$"m{meterId}"] = byMeter[meterId].GetValueOrDefault(time); variables[$"m{meterId}"] = byMeter[meterId].GetValueOrDefault(time);
} }
var amount = evaluator.Evaluate(variables);
if (double.IsNaN(amount) || double.IsInfinity(amount))
{
amount = 0; // e.g. division by zero in a bucket — don't poison downstream sums
}
yield return new Consumption yield return new Consumption
{ {
MeterId = context.Meter.MeterId, MeterId = context.Meter.MeterId,
Time = time, Time = time,
Amount = evaluator.Evaluate(variables), Amount = amount,
Kind = ConsumptionKind.Consumption, Kind = ConsumptionKind.Consumption,
Quality = ReadingQuality.Estimated, Quality = ReadingQuality.Estimated,
}; };
@@ -118,6 +118,7 @@ public sealed class ExportService(MeterVaultDbContext db)
cost.Id = 0; cost.Id = 0;
cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null; cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null;
cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null; cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
cost.ImportBatchId = null; // the original import batch isn't part of the export
_db.ManualCosts.Add(cost); _db.ManualCosts.Add(cost);
} }
@@ -125,6 +126,7 @@ public sealed class ExportService(MeterVaultDbContext db)
{ {
meterEvent.Id = 0; meterEvent.Id = 0;
meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId); meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId);
meterEvent.ImportBatchId = null;
_db.MeterEvents.Add(meterEvent); _db.MeterEvents.Add(meterEvent);
} }
+31 -15
View File
@@ -10,24 +10,28 @@ namespace MeterVault.Infrastructure.Costing;
/// Computes cost by joining bucketed consumption with time-ranged tariffs (SDD §7.5). Consumption /// Computes cost by joining bucketed consumption with time-ranged tariffs (SDD §7.5). Consumption
/// is aggregated in SQL (Dapper, local-timezone buckets); the active price for each bucket is /// is aggregated in SQL (Dapper, local-timezone buckets); the active price for each bucket is
/// resolved in C# via <see cref="TariffResolver"/> using the month's dominant price (SDD §14.3). /// resolved in C# via <see cref="TariffResolver"/> using the month's dominant price (SDD §14.3).
/// Categories roll up their member meters' costs plus meterless manual costs. /// Categories roll up their member meters' costs plus meterless manual costs. Uses a DbContext
/// factory (short-lived context per operation) so it is safe from a Blazor circuit.
/// </summary> /// </summary>
public sealed class CostService(MeterVaultDbContext db) public sealed class CostService(IDbContextFactory<MeterVaultDbContext> contextFactory)
{ {
private readonly MeterVaultDbContext _db = db; private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync( public async Task<IReadOnlyList<MeterCostBucket>> GetMeterCostsAsync(
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket = CostBucket.Month,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var meter = await _db.Meters.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var meter = await db.Meters.AsNoTracking()
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
if (meter is null) if (meter is null)
{ {
return []; return [];
} }
var tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false); var tariffs = await db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false);
var series = await QueryConsumptionAsync(meterId, from, to, bucket, cancellationToken).ConfigureAwait(false); var series = await QueryConsumptionAsync(db, meterId, from, to, bucket, cancellationToken).ConfigureAwait(false);
var results = new List<MeterCostBucket>(); var results = new List<MeterCostBucket>();
foreach (var period in series.Keys.OrderBy(k => k)) foreach (var period in series.Keys.OrderBy(k => k))
@@ -39,7 +43,8 @@ public sealed class CostService(MeterVaultDbContext db)
var basePrice = TariffResolver.ResolveValue(tariffs, TariffComponent.BasePrice, meterId, meter.EnergyTypeId, mid); var basePrice = TariffResolver.ResolveValue(tariffs, TariffComponent.BasePrice, meterId, meter.EnergyTypeId, mid);
var feedIn = TariffResolver.ResolveValue(tariffs, TariffComponent.FeedIn, meterId, meter.EnergyTypeId, mid); var feedIn = TariffResolver.ResolveValue(tariffs, TariffComponent.FeedIn, meterId, meter.EnergyTypeId, mid);
var cost = (consumption * unitPrice) + basePrice - (generation * feedIn); // Base price is a monthly standing charge: prorate it to the bucket length.
var cost = (consumption * unitPrice) + (basePrice * MonthsInBucket(period, bucket)) - (generation * feedIn);
results.Add(new MeterCostBucket(period, consumption, generation, cost)); results.Add(new MeterCostBucket(period, consumption, generation, cost));
} }
@@ -49,7 +54,9 @@ public sealed class CostService(MeterVaultDbContext db)
public async Task<IReadOnlyList<CategoryCostBucket>> GetCategoryCostsAsync( public async Task<IReadOnlyList<CategoryCostBucket>> GetCategoryCostsAsync(
int categoryId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default) int categoryId, DateTimeOffset from, DateTimeOffset to, CancellationToken cancellationToken = default)
{ {
var members = await _db.CostCategoryMembers await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var members = await db.CostCategoryMembers.AsNoTracking()
.Where(m => m.CategoryId == categoryId) .Where(m => m.CategoryId == categoryId)
.ToListAsync(cancellationToken).ConfigureAwait(false); .ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -63,7 +70,7 @@ public sealed class CostService(MeterVaultDbContext db)
if (member.EnergyTypeId is { } energyTypeId) if (member.EnergyTypeId is { } energyTypeId)
{ {
var byType = await _db.Meters.Where(m => m.EnergyTypeId == energyTypeId).Select(m => m.Id) var byType = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == energyTypeId).Select(m => m.Id)
.ToListAsync(cancellationToken).ConfigureAwait(false); .ToListAsync(cancellationToken).ConfigureAwait(false);
meterIds.UnionWith(byType); meterIds.UnionWith(byType);
} }
@@ -78,9 +85,10 @@ public sealed class CostService(MeterVaultDbContext db)
} }
} }
var manualCosts = await _db.ManualCosts var fromDate = DateOnly.FromDateTime(from.Date);
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= DateOnly.FromDateTime(from.UtcDateTime) var toDate = DateOnly.FromDateTime(to.Date);
&& c.PeriodStart < DateOnly.FromDateTime(to.UtcDateTime)) var manualCosts = await db.ManualCosts.AsNoTracking()
.Where(c => c.CategoryId == categoryId && c.PeriodStart >= fromDate && c.PeriodStart < toDate)
.ToListAsync(cancellationToken).ConfigureAwait(false); .ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var cost in manualCosts) foreach (var cost in manualCosts)
{ {
@@ -91,8 +99,9 @@ public sealed class CostService(MeterVaultDbContext db)
return [.. totals.OrderBy(kv => kv.Key).Select(kv => new CategoryCostBucket(kv.Key, kv.Value))]; return [.. totals.OrderBy(kv => kv.Key).Select(kv => new CategoryCostBucket(kv.Key, kv.Value))];
} }
private async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync( private static async Task<Dictionary<DateOnly, (double Consumption, double Generation)>> QueryConsumptionAsync(
int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket, CancellationToken cancellationToken) MeterVaultDbContext db, int meterId, DateTimeOffset from, DateTimeOffset to, CostBucket bucket,
CancellationToken cancellationToken)
{ {
var interval = bucket switch var interval = bucket switch
{ {
@@ -107,7 +116,7 @@ public sealed class CostService(MeterVaultDbContext db)
"FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " + "FROM consumption WHERE meter_id = @meterId AND \"time\" >= @from AND \"time\" < @to " +
"GROUP BY period, kind"; "GROUP BY period, kind";
var connection = _db.Database.GetDbConnection(); var connection = db.Database.GetDbConnection();
var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken); var command = new CommandDefinition(sql, new { meterId, from, to }, cancellationToken: cancellationToken);
var rows = await connection.QueryAsync<ConsumptionRow>(command).ConfigureAwait(false); var rows = await connection.QueryAsync<ConsumptionRow>(command).ConfigureAwait(false);
@@ -123,6 +132,13 @@ public sealed class CostService(MeterVaultDbContext db)
return result; return result;
} }
private static double MonthsInBucket(DateOnly period, CostBucket bucket) => bucket switch
{
CostBucket.Day => 1.0 / DateTime.DaysInMonth(period.Year, period.Month),
CostBucket.Year => 12.0,
_ => 1.0,
};
private static DateOnly RepresentativeDate(DateOnly period, CostBucket bucket) => bucket switch private static DateOnly RepresentativeDate(DateOnly period, CostBucket bucket) => bucket switch
{ {
CostBucket.Day => period, CostBucket.Day => period,
@@ -7,36 +7,41 @@ namespace MeterVault.Infrastructure.Dashboard;
/// <summary> /// <summary>
/// Read model for the dashboard (SDD §8): overview KPIs with period-over-period deltas, the cost /// Read model for the dashboard (SDD §8): overview KPIs with period-over-period deltas, the cost
/// breakdown by category, the "what cost more / less" difference view, and monthly trends. Reads /// breakdown by category, the "what cost more / less" difference view, and monthly trends. Reads
/// only aggregated cost — never the raw hypertable. /// only aggregated cost — never the raw hypertable. Uses a DbContext factory (short-lived context
/// per operation) so it is safe from a Blazor circuit.
/// </summary> /// </summary>
public sealed class DashboardService(MeterVaultDbContext db, CostService costService) public sealed class DashboardService(IDbContextFactory<MeterVaultDbContext> contextFactory, CostService costService)
{ {
private readonly MeterVaultDbContext _db = db; private readonly IDbContextFactory<MeterVaultDbContext> _contextFactory = contextFactory;
private readonly CostService _costService = costService; private readonly CostService _costService = costService;
public async Task<DashboardSummary> GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default) public async Task<DashboardSummary> GetSummaryAsync(DateOnly asOf, CancellationToken cancellationToken = default)
{ {
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var monthStart = new DateOnly(asOf.Year, asOf.Month, 1); var monthStart = new DateOnly(asOf.Year, asOf.Month, 1);
var prevMonthStart = monthStart.AddMonths(-1); var prevMonthStart = monthStart.AddMonths(-1);
var yearStart = new DateOnly(asOf.Year, 1, 1); var yearStart = new DateOnly(asOf.Year, 1, 1);
var prevYearStart = yearStart.AddYears(-1); var prevYearStart = yearStart.AddYears(-1);
var month = new CostKpi( var month = new CostKpi(
await TotalCostAsync(monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false), await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false),
await TotalCostAsync(prevMonthStart, monthStart, cancellationToken).ConfigureAwait(false)); await TotalCostAsync(db, prevMonthStart, monthStart, cancellationToken).ConfigureAwait(false));
var year = new CostKpi( var year = new CostKpi(
await TotalCostAsync(yearStart, yearStart.AddYears(1), cancellationToken).ConfigureAwait(false), await TotalCostAsync(db, yearStart, yearStart.AddYears(1), cancellationToken).ConfigureAwait(false),
await TotalCostAsync(prevYearStart, yearStart, cancellationToken).ConfigureAwait(false)); await TotalCostAsync(db, prevYearStart, yearStart, cancellationToken).ConfigureAwait(false));
var latest = await LatestMonthCostAsync(cancellationToken).ConfigureAwait(false); var latest = await LatestMonthCostAsync(db, cancellationToken).ConfigureAwait(false);
return new DashboardSummary(asOf, month, year, latest); return new DashboardSummary(asOf, month, year, latest);
} }
public async Task<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync( public async Task<IReadOnlyList<CategorySlice>> GetCategoryBreakdownAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default) DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{ {
var categories = await _db.CostCategories.OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false); await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false);
var slices = new List<CategorySlice>(); var slices = new List<CategorySlice>();
foreach (var category in categories) foreach (var category in categories)
{ {
@@ -55,12 +60,13 @@ public sealed class DashboardService(MeterVaultDbContext db, CostService costSer
public async Task<IReadOnlyList<DifferenceRow>> GetCategoryDifferenceAsync( public async Task<IReadOnlyList<DifferenceRow>> GetCategoryDifferenceAsync(
DateOnly currentStart, DateOnly previousStart, DateOnly span, CancellationToken cancellationToken = default) DateOnly currentStart, DateOnly previousStart, DateOnly span, CancellationToken cancellationToken = default)
{ {
// span length in months from currentStart.
var months = ((span.Year - currentStart.Year) * 12) + span.Month - currentStart.Month; var months = ((span.Year - currentStart.Year) * 12) + span.Month - currentStart.Month;
var currentEnd = currentStart.AddMonths(Math.Max(1, months)); var currentEnd = currentStart.AddMonths(Math.Max(1, months));
var previousEnd = previousStart.AddMonths(Math.Max(1, months)); var previousEnd = previousStart.AddMonths(Math.Max(1, months));
var categories = await _db.CostCategories.OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false); await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var categories = await db.CostCategories.AsNoTracking().OrderBy(c => c.Sort).ToListAsync(cancellationToken).ConfigureAwait(false);
var rows = new List<DifferenceRow>(); var rows = new List<DifferenceRow>();
foreach (var category in categories) foreach (var category in categories)
{ {
@@ -78,8 +84,9 @@ public sealed class DashboardService(MeterVaultDbContext db, CostService costSer
public async Task<IReadOnlyList<TrendPoint>> GetMonthlyTrendAsync( public async Task<IReadOnlyList<TrendPoint>> GetMonthlyTrendAsync(
DateOnly from, DateOnly to, CancellationToken cancellationToken = default) DateOnly from, DateOnly to, CancellationToken cancellationToken = default)
{ {
await using var db = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
var totals = new Dictionary<DateOnly, double>(); var totals = new Dictionary<DateOnly, double>();
foreach (var meterId in await ActiveMeterIdsAsync(cancellationToken).ConfigureAwait(false)) foreach (var meterId in await ActiveMeterIdsAsync(db, cancellationToken).ConfigureAwait(false))
{ {
foreach (var bucket in await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false)) foreach (var bucket in await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false))
{ {
@@ -90,25 +97,25 @@ public sealed class DashboardService(MeterVaultDbContext db, CostService costSer
return [.. totals.OrderBy(kv => kv.Key).Select(kv => new TrendPoint(kv.Key, kv.Value))]; return [.. totals.OrderBy(kv => kv.Key).Select(kv => new TrendPoint(kv.Key, kv.Value))];
} }
private async Task<double> TotalCostAsync(DateOnly from, DateOnly to, CancellationToken cancellationToken) private async Task<double> TotalCostAsync(MeterVaultDbContext db, DateOnly from, DateOnly to, CancellationToken cancellationToken)
{ {
double total = 0; double total = 0;
foreach (var meterId in await ActiveMeterIdsAsync(cancellationToken).ConfigureAwait(false)) foreach (var meterId in await ActiveMeterIdsAsync(db, cancellationToken).ConfigureAwait(false))
{ {
var costs = await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false); var costs = await _costService.GetMeterCostsAsync(meterId, ToUtc(from), ToUtc(to), CostBucket.Month, cancellationToken).ConfigureAwait(false);
total += costs.Sum(c => c.Cost); total += costs.Sum(c => c.Cost);
} }
var manual = await _db.ManualCosts var manual = await db.ManualCosts.AsNoTracking()
.Where(c => c.PeriodStart >= from && c.PeriodStart < to) .Where(c => c.PeriodStart >= from && c.PeriodStart < to)
.SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false); .SumAsync(c => (double?)c.Amount, cancellationToken).ConfigureAwait(false);
return total + (manual ?? 0); return total + (manual ?? 0);
} }
private async Task<double> LatestMonthCostAsync(CancellationToken cancellationToken) private async Task<double> LatestMonthCostAsync(MeterVaultDbContext db, CancellationToken cancellationToken)
{ {
var latest = await _db.Consumption var latest = await db.Consumption.AsNoTracking()
.OrderByDescending(c => c.Time) .OrderByDescending(c => c.Time)
.Select(c => (DateTimeOffset?)c.Time) .Select(c => (DateTimeOffset?)c.Time)
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
@@ -118,11 +125,11 @@ public sealed class DashboardService(MeterVaultDbContext db, CostService costSer
} }
var monthStart = new DateOnly(latest.Value.Year, latest.Value.Month, 1); var monthStart = new DateOnly(latest.Value.Year, latest.Value.Month, 1);
return await TotalCostAsync(monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false); return await TotalCostAsync(db, monthStart, monthStart.AddMonths(1), cancellationToken).ConfigureAwait(false);
} }
private async Task<List<int>> ActiveMeterIdsAsync(CancellationToken cancellationToken) => private static async Task<List<int>> ActiveMeterIdsAsync(MeterVaultDbContext db, CancellationToken cancellationToken) =>
await _db.Meters.Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false); await db.Meters.AsNoTracking().Select(m => m.Id).ToListAsync(cancellationToken).ConfigureAwait(false);
private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero); private static DateTimeOffset ToUtc(DateOnly date) => new(date.Year, date.Month, date.Day, 0, 0, 0, TimeSpan.Zero);
} }
+6 -1
View File
@@ -15,11 +15,16 @@ public static class DependencyInjection
this IServiceCollection services, this IServiceCollection services,
string connectionString) string connectionString)
{ {
services.AddDbContext<MeterVaultDbContext>(options => // A factory (for per-operation contexts in Blazor components/read services — a shared
// circuit-scoped DbContext is not thread-safe) plus a scoped context (from the factory)
// for request/tick-scoped services (API, workers, importers) that inject it directly.
services.AddDbContextFactory<MeterVaultDbContext>(options =>
options options
.UseNpgsql(connectionString, npgsql => .UseNpgsql(connectionString, npgsql =>
npgsql.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName)) npgsql.MigrationsAssembly(typeof(MeterVaultDbContext).Assembly.FullName))
.UseSnakeCaseNamingConvention()); .UseSnakeCaseNamingConvention());
services.AddScoped<MeterVaultDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<MeterVaultDbContext>>().CreateDbContext());
services.AddSingleton<INormalizationEngine>(_ => NormalizationEngine.CreateDefault()); services.AddSingleton<INormalizationEngine>(_ => NormalizationEngine.CreateDefault());
services.AddScoped<NormalizationService>(); services.AddScoped<NormalizationService>();
+5 -5
View File
@@ -46,7 +46,7 @@ public sealed class CsvImporter
foreach (var column in profile.Columns) foreach (var column in profile.Columns)
{ {
StageColumn(column, row, time, r, staged, previousByMeter); StageColumn(column, row, time, r, staged, previousByMeter, profile.DetectCumulativeSwaps);
} }
} }
@@ -54,7 +54,7 @@ public sealed class CsvImporter
} }
private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time, private static void StageColumn(ColumnMapping column, IReadOnlyList<string> row, DateTimeOffset time,
int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter) int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
{ {
if (column.Role == MappingRole.Ignore || column.Index >= row.Count) if (column.Role == MappingRole.Ignore || column.Index >= row.Count)
{ {
@@ -70,7 +70,7 @@ public sealed class CsvImporter
switch (column.Role) switch (column.Role)
{ {
case MappingRole.Reading: case MappingRole.Reading:
StageReading(column, row, cell, time, rowIndex, staged, previousByMeter); StageReading(column, row, cell, time, rowIndex, staged, previousByMeter, detectSwaps);
break; break;
case MappingRole.Delivery: case MappingRole.Delivery:
@@ -126,7 +126,7 @@ public sealed class CsvImporter
} }
private static void StageReading(ColumnMapping column, IReadOnlyList<string> row, string cell, private static void StageReading(ColumnMapping column, IReadOnlyList<string> row, string cell,
DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter) DateTimeOffset time, int rowIndex, StagedImport staged, Dictionary<int, double> previousByMeter, bool detectSwaps)
{ {
var split = ValueCell.Split(cell); var split = ValueCell.Split(cell);
if (split is null) if (split is null)
@@ -144,7 +144,7 @@ public sealed class CsvImporter
$"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'."); $"Row {rowIndex + 1}: expected unit '{column.Unit}' but found '{split.Value.Unit}'.");
} }
if (previousByMeter.TryGetValue(meterId, out var previous) && value < previous) if (detectSwaps && previousByMeter.TryGetValue(meterId, out var previous) && value < previous)
{ {
var override_ = ReadSwapOverride(column, row); var override_ = ReadSwapOverride(column, row);
staged.Events.Add(new MeterEvent staged.Events.Add(new MeterEvent
@@ -61,7 +61,10 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
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));
await LinkCategoriesAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, cancellationToken).ConfigureAwait(false); // Strom and Wasser costs are computed from meters + tariffs; only Heizung comes from the
// Kosten sheet (oil has no tariff). Linking a meter AND importing its Kosten column into the
// same category would double-count, so we keep exactly one cost source per category.
await LinkStromAndWasserAsync(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
var meterIds = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, burner.Id); var meterIds = new ReferenceMeterIds(haus.Id, netz.Id, auto.Id, solar1.Id, solar2.Id, wasser.Id, oilTank.Id, burner.Id);
@@ -70,9 +73,19 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
await ImportSheetAsync(sampleDataDirectory, ElectricityFile, ReferenceProfiles.Electricity(meterIds), cancellationToken).ConfigureAwait(false); await ImportSheetAsync(sampleDataDirectory, ElectricityFile, ReferenceProfiles.Electricity(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, WaterFile, ReferenceProfiles.Water(meterIds), cancellationToken).ConfigureAwait(false); await ImportSheetAsync(sampleDataDirectory, WaterFile, ReferenceProfiles.Water(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, OilFile, ReferenceProfiles.HeatingOil(meterIds), cancellationToken).ConfigureAwait(false); await ImportSheetAsync(sampleDataDirectory, OilFile, ReferenceProfiles.HeatingOil(meterIds), cancellationToken).ConfigureAwait(false);
await ImportSheetAsync(sampleDataDirectory, CostsFile, ReferenceProfiles.Costs(categoryIds), cancellationToken).ConfigureAwait(false); await ImportSheetAsync(sampleDataDirectory, CostsFile, HeizungCostsProfile(categoryIds.Heizung), cancellationToken).ConfigureAwait(false);
} }
/// <summary>Kosten profile that imports only the Heizung column — Strom/Wasser are metered.</summary>
private static MappingProfile HeizungCostsProfile(int heizungCategoryId) => new()
{
Name = "Energiebilanz — Kosten (Heizung)",
DateColumn = 0,
DateKind = DateKind.MonthName,
FirstDataRowIndex = 1,
Columns = [new ColumnMapping { Index = 3, Role = MappingRole.ManualCost, CategoryId = heizungCategoryId }],
};
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);
@@ -121,8 +134,8 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
ValidFrom = validFrom, ValidFrom = validFrom,
}); });
private async Task LinkCategoriesAsync( private async Task LinkStromAndWasserAsync(
int haus, int netz, int auto, int solar1, int solar2, int wasser, int oilTank, CancellationToken cancellationToken) int haus, int netz, int auto, int solar1, int solar2, int wasser, CancellationToken cancellationToken)
{ {
var categories = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false); var categories = await CategoryIdsAsync(cancellationToken).ConfigureAwait(false);
foreach (var meterId in new[] { haus, netz, auto, solar1, solar2 }) foreach (var meterId in new[] { haus, netz, auto, solar1, solar2 })
@@ -131,7 +144,7 @@ public sealed class ReferenceDataImporter(MeterVaultDbContext db, ImportService
} }
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Wasser, MeterId = wasser }); _db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Wasser, MeterId = wasser });
_db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = categories.Heizung, MeterId = oilTank }); // Heizung (oil) cost comes from the imported Kosten column, not a meter — no link (avoids double-count).
} }
private async Task<ReferenceCategoryIds> CategoryIdsAsync(CancellationToken cancellationToken) private async Task<ReferenceCategoryIds> CategoryIdsAsync(CancellationToken cancellationToken)
@@ -67,6 +67,13 @@ public sealed class HomeAssistantWorker(
.Where(s => s.IsEnabled && s.SourceType == SourceType.HomeAssistant && s.EndpointId != null) .Where(s => s.IsEnabled && s.SourceType == SourceType.HomeAssistant && s.EndpointId != null)
.ToListAsync(cancellationToken).ConfigureAwait(false); .ToListAsync(cancellationToken).ConfigureAwait(false);
// Drop schedule entries for sources that are gone/disabled so the dictionary doesn't grow.
var liveIds = sources.Select(s => s.Id).ToHashSet();
foreach (var staleId in _nextPoll.Keys.Where(id => !liveIds.Contains(id)).ToList())
{
_nextPoll.TryRemove(staleId, out _);
}
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
foreach (var source in sources) foreach (var source in sources)
{ {
@@ -112,7 +112,7 @@ public sealed class IngestionService(MeterVaultDbContext db)
var previous = await _db.Readings var previous = await _db.Readings
.Where(r => r.MeterId == meterId && r.Time < time) .Where(r => r.MeterId == meterId && r.Time < time)
.OrderByDescending(r => r.Time) .OrderByDescending(r => r.Time)
.Select(r => (double?)r.Value) .Select(r => new { r.Value, r.Time })
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (previous is null || value >= previous.Value) if (previous is null || value >= previous.Value)
@@ -120,11 +120,12 @@ public sealed class IngestionService(MeterVaultDbContext db)
return false; return false;
} }
// A reset/swap event between the previous reading and this one explains the decrease. // Only a reset/swap in the window (previousReading, thisReading] explains the decrease
// an old historical reset must not permanently disable the guard.
var explained = await _db.MeterEvents.AnyAsync( var explained = await _db.MeterEvents.AnyAsync(
e => e.MeterId == meterId e => e.MeterId == meterId
&& (e.EventType == MeterEventType.CounterReset || e.EventType == MeterEventType.MeterSwap) && (e.EventType == MeterEventType.CounterReset || e.EventType == MeterEventType.MeterSwap)
&& e.Time <= time, && e.Time > previous.Time && e.Time <= time,
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
return !explained; return !explained;
@@ -24,10 +24,13 @@ public sealed class MqttIngestionWorker(
private readonly ILogger<MqttIngestionWorker> _logger = logger; private readonly ILogger<MqttIngestionWorker> _logger = logger;
private readonly MqttClientFactory _factory = new(); private readonly MqttClientFactory _factory = new();
private readonly ConcurrentDictionary<int, IMqttClient> _clients = new(); private readonly ConcurrentDictionary<int, IMqttClient> _clients = new();
private readonly ConcurrentDictionary<int, HashSet<string>> _subscribed = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
using var timer = new PeriodicTimer(ReconnectInterval); using var timer = new PeriodicTimer(ReconnectInterval);
try
{
do do
{ {
try try
@@ -44,7 +47,13 @@ public sealed class MqttIngestionWorker(
} }
} }
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
catch (OperationCanceledException)
{
// Normal shutdown while idle waiting for the next tick.
}
finally
{
foreach (var client in _clients.Values) foreach (var client in _clients.Values)
{ {
try try
@@ -62,6 +71,7 @@ public sealed class MqttIngestionWorker(
client.Dispose(); client.Dispose();
} }
} }
}
private async Task EnsureConnectionsAsync(CancellationToken cancellationToken) private async Task EnsureConnectionsAsync(CancellationToken cancellationToken)
{ {
@@ -75,13 +85,32 @@ public sealed class MqttIngestionWorker(
foreach (var endpoint in endpoints) foreach (var endpoint in endpoints)
{ {
var client = _clients.GetOrAdd(endpoint.Id, _ => CreateClient()); var client = _clients.GetOrAdd(endpoint.Id, _ => CreateClient());
if (client.IsConnected) var topics = await ResolveTopicsAsync(db, endpoint, cancellationToken).ConfigureAwait(false);
if (!client.IsConnected)
{ {
continue; _subscribed[endpoint.Id] = [];
await ConnectAndSubscribeAsync(endpoint, client, topics, cancellationToken).ConfigureAwait(false);
}
else
{
// Already connected — subscribe to any topics added since the last tick.
await SubscribeNewTopicsAsync(endpoint.Id, client, topics, cancellationToken).ConfigureAwait(false);
}
}
} }
var topics = await ResolveTopicsAsync(db, endpoint, cancellationToken).ConfigureAwait(false); private async Task SubscribeNewTopicsAsync(
await ConnectAndSubscribeAsync(endpoint, client, topics, cancellationToken).ConfigureAwait(false); int endpointId, IMqttClient client, IReadOnlyList<string> topics, CancellationToken cancellationToken)
{
var known = _subscribed.GetOrAdd(endpointId, _ => []);
foreach (var topic in topics)
{
if (known.Add(topic))
{
await client.SubscribeAsync(topic, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("MQTT subscribed to new topic {Topic} on endpoint {Endpoint}", topic, endpointId);
}
} }
} }
@@ -114,9 +143,11 @@ public sealed class MqttIngestionWorker(
try try
{ {
await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false); await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false);
var known = _subscribed.GetOrAdd(endpoint.Id, _ => []);
foreach (var topic in topics) foreach (var topic in topics)
{ {
await client.SubscribeAsync(topic, cancellationToken: cancellationToken).ConfigureAwait(false); await client.SubscribeAsync(topic, cancellationToken: cancellationToken).ConfigureAwait(false);
known.Add(topic);
} }
_logger.LogInformation("MQTT connected to {Host}:{Port} ({TopicCount} topics)", _logger.LogInformation("MQTT connected to {Host}:{Port} ({TopicCount} topics)",
@@ -33,4 +33,10 @@ public sealed class MeterVaultOptions
/// <summary>Honour <c>X-Forwarded-User</c>/<c>Remote-User</c> from a trusted reverse proxy (SDD §10).</summary> /// <summary>Honour <c>X-Forwarded-User</c>/<c>Remote-User</c> from a trusted reverse proxy (SDD §10).</summary>
public bool ReverseProxyTrust { get; set; } public bool ReverseProxyTrust { get; set; }
/// <summary>
/// Allow the REST API without any API key. Off by default: with no keys configured the API is
/// closed (401) rather than open, so an unconfigured deployment doesn't expose export/import.
/// </summary>
public bool AllowAnonymousApi { get; set; }
} }
@@ -18,8 +18,9 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
// Raw readings: 30-day chunks; the (meter_id, time) PK contains the partition column. // Raw readings: 30-day chunks; the (meter_id, time) PK contains the partition column.
// if_not_exists keeps a down-then-up cycle safe (Down leaves the hypertable in place).
migrationBuilder.Sql( migrationBuilder.Sql(
"SELECT create_hypertable('reading', 'time', chunk_time_interval => INTERVAL '30 days');"); "SELECT create_hypertable('reading', 'time', chunk_time_interval => INTERVAL '30 days', if_not_exists => TRUE);");
// Columnar compression, segmented by meter (monotonic sensor data compresses ~10-20x). // Columnar compression, segmented by meter (monotonic sensor data compresses ~10-20x).
migrationBuilder.Sql( migrationBuilder.Sql(
@@ -29,11 +30,11 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
"timescaledb.compress_orderby = 'time DESC');"); "timescaledb.compress_orderby = 'time DESC');");
migrationBuilder.Sql( migrationBuilder.Sql(
"SELECT add_compression_policy('reading', INTERVAL '30 days');"); "SELECT add_compression_policy('reading', INTERVAL '30 days', if_not_exists => true);");
// Normalized consumption: 90-day chunks. Kept effectively forever (small), so no compression policy. // Normalized consumption: 90-day chunks. Kept effectively forever (small), so no compression policy.
migrationBuilder.Sql( migrationBuilder.Sql(
"SELECT create_hypertable('consumption', 'time', chunk_time_interval => INTERVAL '90 days');"); "SELECT create_hypertable('consumption', 'time', chunk_time_interval => INTERVAL '90 days', if_not_exists => TRUE);");
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -40,8 +40,11 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
private static void CreateAggregate(MigrationBuilder builder, string name, string bucket) private static void CreateAggregate(MigrationBuilder builder, string name, string bucket)
{ {
// IF NOT EXISTS + if_not_exists keep this re-run-safe: because the statements suppress
// the transaction, a crash before __EFMigrationsHistory is written would otherwise brick
// the next migrate by re-creating an object that already exists.
builder.Sql( builder.Sql(
$"CREATE MATERIALIZED VIEW {name} WITH (timescaledb.continuous) AS " + $"CREATE MATERIALIZED VIEW IF NOT EXISTS {name} WITH (timescaledb.continuous) AS " +
$"SELECT time_bucket(INTERVAL '{bucket}', time, '{Timezone}') AS bucket, " + $"SELECT time_bucket(INTERVAL '{bucket}', time, '{Timezone}') AS bucket, " +
"meter_id, kind, sum(amount) AS amount " + "meter_id, kind, sum(amount) AS amount " +
"FROM consumption GROUP BY bucket, meter_id, kind WITH NO DATA;", "FROM consumption GROUP BY bucket, meter_id, kind WITH NO DATA;",
@@ -54,7 +57,8 @@ namespace MeterVault.Infrastructure.Persistence.Migrations
$"SELECT add_continuous_aggregate_policy('{name}', " + $"SELECT add_continuous_aggregate_policy('{name}', " +
$"start_offset => INTERVAL '{startOffset}', " + $"start_offset => INTERVAL '{startOffset}', " +
$"end_offset => INTERVAL '{endOffset}', " + $"end_offset => INTERVAL '{endOffset}', " +
"schedule_interval => INTERVAL '1 hour');", "schedule_interval => INTERVAL '1 hour', " +
"if_not_exists => true);",
suppressTransaction: true); suppressTransaction: true);
} }
} }
+31
View File
@@ -52,6 +52,37 @@ public sealed class ApiTests(TimescaleFixture fx)
} }
} }
private sealed record EventPush(int MeterId, DateTimeOffset Time, string Type,
double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
[Fact]
public async Task Api_is_closed_when_no_keys_are_configured()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString, configureApiKey: false);
using var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/readings",
new[] { new ReadingPush(1, DateTimeOffset.UtcNow, 1) });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Events_for_a_missing_meter_return_404_not_500()
{
using var factory = new MeterVaultAppFactory(fx.ConnectionString);
using var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/events")
{
Content = JsonContent.Create(new EventPush(999999, DateTimeOffset.UtcNow, "Delivery", 100, null, null, "L", null)),
};
request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact] [Fact]
public async Task Meters_endpoint_and_swagger_are_available() public async Task Meters_endpoint_and_swagger_are_available()
{ {
@@ -36,7 +36,7 @@ public sealed class CostReconciliationTests(TimescaleFixture fx)
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var costs = await new CostService(db).GetMeterCostsAsync(meterId, From, To); var costs = await new CostService(fx).GetMeterCostsAsync(meterId, From, To);
var computed = costs.ToDictionary(c => c.Period, c => c.Cost); var computed = costs.ToDictionary(c => c.Period, c => c.Cost);
var oracle = OracleByMonth(ReadRows(Water), dateColumn: 0, valueColumn: 4, firstDataRow: 1); // Kosten var oracle = OracleByMonth(ReadRows(Water), dateColumn: 0, valueColumn: 4, firstDataRow: 1); // Kosten
@@ -64,7 +64,7 @@ public sealed class CostReconciliationTests(TimescaleFixture fx)
db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = wasserCategory.Id, MeterId = meterId }); db.CostCategoryMembers.Add(new CostCategoryMember { CategoryId = wasserCategory.Id, MeterId = meterId });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var rollup = await new CostService(db).GetCategoryCostsAsync(wasserCategory.Id, From, To); var rollup = await new CostService(fx).GetCategoryCostsAsync(wasserCategory.Id, From, To);
var dec2022 = rollup.Single(r => r.Period == new DateOnly(2022, 12, 1)); var dec2022 = rollup.Single(r => r.Period == new DateOnly(2022, 12, 1));
Assert.Equal(70d, dec2022.Cost, 2); // Dez 2022: 14 m³ × 5,00 € Assert.Equal(70d, dec2022.Cost, 2); // Dez 2022: 14 m³ × 5,00 €
@@ -1,3 +1,4 @@
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Import; using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Persistence; using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -31,6 +32,15 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
Assert.Contains(await db.Meters.Select(m => m.Name).ToListAsync(), n => n == "Zähler Haus"); Assert.Contains(await db.Meters.Select(m => m.Name).ToListAsync(), n => n == "Zähler Haus");
Assert.True(await db.Meters.CountAsync() >= 8); Assert.True(await db.Meters.CountAsync() >= 8);
Assert.True(await db.Consumption.AnyAsync()); Assert.True(await db.Consumption.AnyAsync());
// Regression (audit): Wasser is metered (water tariff), the Kosten Wasser column is
// NOT imported, so the category is not double-counted — Dez 2022 = 14 m³ × 5 € = 70 €.
var wasser = await db.CostCategories.FirstAsync(c => c.Name == "Wasser");
var rollup = await new CostService(fx).GetCategoryCostsAsync(
wasser.Id,
new DateTimeOffset(2022, 12, 1, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.Zero));
Assert.Equal(70d, rollup.Sum(r => r.Cost), 1);
} }
using var client = factory.CreateClient(); using var client = factory.CreateClient();
@@ -49,6 +49,28 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
await CleanupAsync(db, meterId); await CleanupAsync(db, meterId);
} }
[Fact]
public async Task Old_reset_does_not_permanently_disable_the_decrease_guard()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = new IngestionService(db);
// A reset early on explains an early decrease...
await service.IngestAsync(sourceId, T0, 100);
db.MeterEvents.Add(new MeterEvent { MeterId = meterId, Time = T0.AddMinutes(10), EventType = MeterEventType.CounterReset, NewValue = 0 });
await db.SaveChangesAsync();
Assert.Equal(IngestionOutcome.Written, await service.IngestAsync(sourceId, T0.AddMinutes(20), 30));
await service.IngestAsync(sourceId, T0.AddHours(1), 200);
// ...but a later spurious decrease with NO event in its window must still be rejected.
var outcome = await service.IngestAsync(sourceId, T0.AddHours(2), 150);
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
await CleanupAsync(db, meterId);
}
[Fact] [Fact]
public async Task Allows_decrease_when_a_swap_event_explains_it() public async Task Allows_decrease_when_a_swap_event_explains_it()
{ {
@@ -7,7 +7,7 @@ namespace MeterVault.Integration.Tests;
/// Boots the real ASP.NET Core app in-memory against the shared Timescale container. /// Boots the real ASP.NET Core app in-memory against the shared Timescale container.
/// Migrations are already applied by <see cref="TimescaleFixture"/>, so startup migration is off. /// Migrations are already applied by <see cref="TimescaleFixture"/>, so startup migration is off.
/// </summary> /// </summary>
public sealed class MeterVaultAppFactory(string connectionString) : WebApplicationFactory<Program> public sealed class MeterVaultAppFactory(string connectionString, bool configureApiKey = true) : WebApplicationFactory<Program>
{ {
public const string ApiKey = "test-api-key"; public const string ApiKey = "test-api-key";
@@ -17,6 +17,9 @@ public sealed class MeterVaultAppFactory(string connectionString) : WebApplicati
builder.UseSetting("ConnectionStrings:Default", connectionString); builder.UseSetting("ConnectionStrings:Default", connectionString);
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false"); builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
builder.UseSetting("MeterVault:EnableLiveIngestion", "false"); builder.UseSetting("MeterVault:EnableLiveIngestion", "false");
if (configureApiKey)
{
builder.UseSetting("MeterVault:ApiKeys:0", ApiKey); builder.UseSetting("MeterVault:ApiKeys:0", ApiKey);
} }
} }
}
+4 -1
View File
@@ -10,7 +10,7 @@ namespace MeterVault.Integration.Tests;
/// The image tag is pinned: the compression DDL (add_compression_policy) was renamed toward /// The image tag is pinned: the compression DDL (add_compression_policy) was renamed toward
/// add_columnstore_policy in newer Timescale, so a floating tag would risk breaking migrations. /// add_columnstore_policy in newer Timescale, so a floating tag would risk breaking migrations.
/// </summary> /// </summary>
public sealed class TimescaleFixture : IAsyncLifetime public sealed class TimescaleFixture : IAsyncLifetime, IDbContextFactory<MeterVaultDbContext>
{ {
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder("timescale/timescaledb:2.17.2-pg16") private readonly PostgreSqlContainer _db = new PostgreSqlBuilder("timescale/timescaledb:2.17.2-pg16")
.WithDatabase("metervault") .WithDatabase("metervault")
@@ -30,6 +30,9 @@ public sealed class TimescaleFixture : IAsyncLifetime
return new MeterVaultDbContext(options); return new MeterVaultDbContext(options);
} }
/// <summary>Lets tests construct services that take an <see cref="IDbContextFactory{TContext}"/>.</summary>
public MeterVaultDbContext CreateDbContext() => CreateContext();
public async Task InitializeAsync() public async Task InitializeAsync()
{ {
await _db.StartAsync(); await _db.StartAsync();