using MeterVault.Core.Domain; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace MeterVault.Infrastructure.Ingestion; /// /// Routes an incoming MQTT message to every enabled MQTT/Tasmota source that is bound to the /// delivering broker and 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. /// /// /// The endpoint predicate is load-bearing, not defensive: topic filters routinely overlap between /// brokers (every Tasmota install publishes tele/+/SENSOR), so matching on topic alone would /// let a message from one broker be ingested by a source bound to another. /// public sealed class MqttMessageRouter( MeterVaultDbContext db, IngestionService ingestion, ILogger logger) { private readonly MeterVaultDbContext _db = db; private readonly IngestionService _ingestion = ingestion; private readonly ILogger _logger = logger; public async Task RouteAsync( int endpointId, string topic, string payload, CancellationToken cancellationToken = default) { var sources = await _db.MeterSources .Where(s => s.IsEnabled && (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota) && s.EndpointId == endpointId) .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; } }