M3: live ingestion (MQTT/Tasmota + Home Assistant)

- PayloadExtractor: dot-path value/time extraction (Tasmota ENERGY.Total, bare scalars).
- MqttTopicMatcher: standard +/# wildcard matching.
- IngestionService: scale/offset, idempotent upsert on (meter_id, time), and a spurious-
  decrease guard for monotonic registers (allowed only with a reset/swap event) + source
  last-seen status.
- MqttMessageRouter + MqttIngestionWorker (MQTTnet 5): per-endpoint persistent connections,
  topic subscription, graceful degradation; secrets resolved by env-var reference.
- Home Assistant: HaStateClient (REST /api/states parse) + HomeAssistantWorker polling on
  each source's interval. HA-via-MQTT also works through the MQTT path.
- Ingestion workers gated by MeterVault:EnableLiveIngestion (off in tests).

85 tests green (53 Core + 32 integration): Tasmota payload → reading verified end to end.

Follow-up (polish): HA WebSocket push (state_changed) as an alternative to REST poll;
source-topic index caching in the router.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 11:45:20 +02:00
parent 5977c81002
commit 4b0cad67df
17 changed files with 1060 additions and 0 deletions
@@ -0,0 +1,54 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// Routes an incoming MQTT message to every enabled MQTT/Tasmota source whose topic filter covers
/// it, extracts the value (and payload timestamp), and ingests it (SDD §6.1). Decoupled from the
/// broker client so it can be exercised directly against the database in tests.
/// </summary>
public sealed class MqttMessageRouter(
MeterVaultDbContext db, IngestionService ingestion, ILogger<MqttMessageRouter> logger)
{
private readonly MeterVaultDbContext _db = db;
private readonly IngestionService _ingestion = ingestion;
private readonly ILogger<MqttMessageRouter> _logger = logger;
public async Task<int> RouteAsync(string topic, string payload, CancellationToken cancellationToken = default)
{
var sources = await _db.MeterSources
.Where(s => s.IsEnabled && (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota))
.ToListAsync(cancellationToken).ConfigureAwait(false);
var routed = 0;
foreach (var source in sources)
{
var config = SourceConfig.Parse(source.Config);
if (!MqttTopicMatcher.Matches(config.Topic, topic))
{
continue;
}
if (!PayloadExtractor.TryExtractValue(payload, config.Path, out var value))
{
_logger.LogDebug("Source {SourceId}: no value at path '{Path}' in topic {Topic}",
source.Id, config.Path, topic);
continue;
}
// Prefer the payload's own time; Tasmota SENSOR messages carry "Time"; else receive time.
var timePath = config.TimePath ?? (source.SourceType == SourceType.Tasmota ? "Time" : null);
var time = PayloadExtractor.TryExtractTime(payload, timePath, out var payloadTime)
? payloadTime
: DateTimeOffset.UtcNow;
await _ingestion.IngestAsync(source.Id, time, value, cancellationToken).ConfigureAwait(false);
routed++;
}
return routed;
}
}