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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user