M7: release polish — export/import, docs, easy docker push

- Easy docker push (MQTTower ergonomics + the image push it lacks): VERSION file →
  version-tag.yml (semver-guard auto-tag) → docker-publish.yml (buildx multi-arch → GHCR,
  registry centralized for one-line retarget to git.finalfactory.de) + ci.yml + build-and-push.ps1.
- Verified end to end: deploy/Dockerfile builds; docker compose stack (app + timescaledb) comes
  up healthy; /healthz and the dashboard respond in-container.
- JSON config export/import (ExportService) with id remapping on restore + GET /export, POST
  /import endpoints; round-trip test preserves meter→type, meter-scoped tariff, category links.
- README, HA/Tasmota/MQTT wiring guide (docs/wiring.md), Unraid template.
- CLAUDE.md updated to reflect the built codebase.
- i18n: locale-aware number/currency formatting (de-DE); full de UI string localization deferred.

95 tests green (56 Core + 39 integration).

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 12:25:03 +02:00
parent 9abc2937c2
commit e223278771
14 changed files with 634 additions and 12 deletions
+11
View File
@@ -112,6 +112,17 @@ public static class ApiEndpoints
.Select(s => new { s.Id, s.MeterId, Type = s.SourceType.ToString(), s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus })
.ToListAsync(ct)));
api.MapGet("/export", async (MeterVault.Infrastructure.Backup.ExportService export, CancellationToken ct) =>
Results.Text(await export.ExportJsonAsync(ct), "application/json"))
.WithSummary("Export configuration + hand-entered data as portable JSON (SDD §10).");
api.MapPost("/import", async (HttpRequest request, MeterVault.Infrastructure.Backup.ExportService export, CancellationToken ct) =>
{
using var reader = new StreamReader(request.Body);
await export.ImportJsonAsync(await reader.ReadToEndAsync(ct), ct);
return Results.Ok();
}).WithSummary("Restore a JSON export into an empty instance (remaps ids).");
return app;
}
}
@@ -0,0 +1,25 @@
using MeterVault.Core.Domain;
namespace MeterVault.Infrastructure.Backup;
/// <summary>
/// A portable JSON snapshot of MeterVault's configuration and hand-entered data (SDD §10). Covers
/// everything except the bulk time-series (raw readings / consumption), which is restored from the
/// live sources or a database dump — this keeps the export small and human-readable.
/// </summary>
public sealed class ExportDocument
{
public int SchemaVersion { get; set; } = 1;
public List<EnergyType> EnergyTypes { get; set; } = [];
public List<CostCategory> CostCategories { get; set; } = [];
public List<Meter> Meters { get; set; } = [];
public List<IngestionEndpoint> IngestionEndpoints { get; set; } = [];
public List<MeterSource> MeterSources { get; set; } = [];
public List<Tank> Tanks { get; set; } = [];
public List<Tariff> Tariffs { get; set; } = [];
public List<CostCategoryMember> CostCategoryMembers { get; set; } = [];
public List<ManualCost> ManualCosts { get; set; } = [];
public List<MeterEvent> MeterEvents { get; set; } = [];
public List<AppSetting> AppSettings { get; set; } = [];
}
+159
View File
@@ -0,0 +1,159 @@
using System.Text.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Infrastructure.Backup;
/// <summary>
/// Exports/imports the configuration snapshot (SDD §10). Import restores into an empty instance,
/// remapping surrogate ids so foreign keys stay consistent regardless of the original ids.
/// </summary>
public sealed class ExportService(MeterVaultDbContext db)
{
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = true,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
};
private readonly MeterVaultDbContext _db = db;
public async Task<string> ExportJsonAsync(CancellationToken cancellationToken = default)
{
// Load without navigation includes so serialization is a clean, cycle-free tree.
var document = new ExportDocument
{
EnergyTypes = await _db.EnergyTypes.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
CostCategories = await _db.CostCategories.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Meters = await _db.Meters.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
IngestionEndpoints = await _db.IngestionEndpoints.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
MeterSources = await _db.MeterSources.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Tanks = await _db.Tanks.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
Tariffs = await _db.Tariffs.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
CostCategoryMembers = await _db.CostCategoryMembers.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
ManualCosts = await _db.ManualCosts.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
MeterEvents = await _db.MeterEvents.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
AppSettings = await _db.AppSettings.AsNoTracking().ToListAsync(cancellationToken).ConfigureAwait(false),
};
return JsonSerializer.Serialize(document, Json);
}
public async Task ImportJsonAsync(string json, CancellationToken cancellationToken = default)
{
var doc = JsonSerializer.Deserialize<ExportDocument>(json, Json)
?? throw new InvalidOperationException("Empty or invalid export document.");
await using var tx = await _db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var typeMap = new Dictionary<short, short>();
foreach (var type in doc.EnergyTypes)
{
var old = type.Id;
type.Id = 0;
_db.EnergyTypes.Add(type);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
typeMap[old] = type.Id;
}
var categoryMap = await InsertMappedAsync(doc.CostCategories, c => c.Id, (c, _) => c.Id = 0, cancellationToken).ConfigureAwait(false);
var meterMap = new Dictionary<int, int>();
foreach (var meter in doc.Meters)
{
var old = meter.Id;
meter.Id = 0;
meter.EnergyTypeId = typeMap.GetValueOrDefault(meter.EnergyTypeId, meter.EnergyTypeId);
meter.EnergyType = null;
_db.Meters.Add(meter);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
meterMap[old] = meter.Id;
}
var endpointMap = await InsertMappedAsync(doc.IngestionEndpoints, e => e.Id, (e, _) => e.Id = 0, cancellationToken).ConfigureAwait(false);
foreach (var source in doc.MeterSources)
{
source.Id = 0;
source.MeterId = meterMap.GetValueOrDefault(source.MeterId, source.MeterId);
source.EndpointId = source.EndpointId is { } eid ? endpointMap.GetValueOrDefault(eid, eid) : null;
source.Meter = null;
source.Endpoint = null;
_db.MeterSources.Add(source);
}
foreach (var tank in doc.Tanks)
{
tank.Id = 0;
tank.MeterId = meterMap.GetValueOrDefault(tank.MeterId, tank.MeterId);
tank.Meter = null;
_db.Tanks.Add(tank);
}
foreach (var tariff in doc.Tariffs)
{
tariff.Id = 0;
tariff.ScopeId = tariff.ScopeType switch
{
TariffScope.Meter when tariff.ScopeId is { } id => meterMap.GetValueOrDefault(id, id),
TariffScope.EnergyType when tariff.ScopeId is { } id => typeMap.GetValueOrDefault((short)id, (short)id),
_ => tariff.ScopeId,
};
_db.Tariffs.Add(tariff);
}
foreach (var member in doc.CostCategoryMembers)
{
member.Id = 0;
member.CategoryId = categoryMap.GetValueOrDefault(member.CategoryId, member.CategoryId);
member.MeterId = member.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
member.EnergyTypeId = member.EnergyTypeId is { } tid ? typeMap.GetValueOrDefault(tid, tid) : null;
member.Category = null;
_db.CostCategoryMembers.Add(member);
}
foreach (var cost in doc.ManualCosts)
{
cost.Id = 0;
cost.CategoryId = cost.CategoryId is { } cid ? categoryMap.GetValueOrDefault(cid, cid) : null;
cost.MeterId = cost.MeterId is { } mid ? meterMap.GetValueOrDefault(mid, mid) : null;
_db.ManualCosts.Add(cost);
}
foreach (var meterEvent in doc.MeterEvents)
{
meterEvent.Id = 0;
meterEvent.MeterId = meterMap.GetValueOrDefault(meterEvent.MeterId, meterEvent.MeterId);
_db.MeterEvents.Add(meterEvent);
}
foreach (var setting in doc.AppSettings)
{
if (!await _db.AppSettings.AnyAsync(s => s.Key == setting.Key, cancellationToken).ConfigureAwait(false))
{
_db.AppSettings.Add(setting);
}
}
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
await tx.CommitAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<Dictionary<int, int>> InsertMappedAsync<T>(
List<T> items, Func<T, int> getId, Action<T, int> clearId, CancellationToken cancellationToken)
where T : class
{
var map = new Dictionary<int, int>();
foreach (var item in items)
{
var old = getId(item);
clearId(item, 0);
_db.Add(item);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
map[old] = getId(item);
}
return map;
}
}
@@ -30,6 +30,7 @@ public static class DependencyInjection
services.AddScoped<MqttMessageRouter>();
services.AddScoped<Costing.CostService>();
services.AddScoped<Dashboard.DashboardService>();
services.AddScoped<Backup.ExportService>();
return services;
}