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,115 @@
|
||||
using MeterVault.Core.Domain;
|
||||
using MeterVault.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>The outcome of ingesting one reading.</summary>
|
||||
public enum IngestionOutcome
|
||||
{
|
||||
Written,
|
||||
Updated,
|
||||
RejectedDecrease,
|
||||
UnknownSource,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a single incoming reading (SDD §6.1, FR-4): applies the source's scale/offset, is
|
||||
/// idempotent on (meter_id, time), and guards monotonic registers against spurious decreases
|
||||
/// unless an active reset/swap event explains them. Updates the source's last-seen status.
|
||||
/// Consumption normalization is recomputed separately (batch/scheduled), not per message.
|
||||
/// </summary>
|
||||
public sealed class IngestionService(MeterVaultDbContext db)
|
||||
{
|
||||
private static readonly HashSet<MeterMode> MonotonicModes =
|
||||
[MeterMode.CumulativeCounter, MeterMode.GenerationCounter, MeterMode.RuntimeCounter];
|
||||
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
|
||||
public async Task<IngestionOutcome> IngestAsync(
|
||||
int sourceId, DateTimeOffset time, double rawValue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _db.MeterSources
|
||||
.FirstOrDefaultAsync(s => s.Id == sourceId, cancellationToken).ConfigureAwait(false);
|
||||
if (source is null)
|
||||
{
|
||||
return IngestionOutcome.UnknownSource;
|
||||
}
|
||||
|
||||
var meter = await _db.Meters
|
||||
.FirstOrDefaultAsync(m => m.Id == source.MeterId, cancellationToken).ConfigureAwait(false);
|
||||
if (meter is null)
|
||||
{
|
||||
return IngestionOutcome.UnknownSource;
|
||||
}
|
||||
|
||||
var value = (rawValue * source.Scale) + source.Offset;
|
||||
var utc = time.ToUniversalTime();
|
||||
|
||||
if (MonotonicModes.Contains(meter.Mode)
|
||||
&& await IsSpuriousDecreaseAsync(meter.Id, utc, value, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
await UpdateSourceStatusAsync(source, utc, value, "rejected: decrease", cancellationToken).ConfigureAwait(false);
|
||||
return IngestionOutcome.RejectedDecrease;
|
||||
}
|
||||
|
||||
var existing = await _db.Readings
|
||||
.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
IngestionOutcome outcome;
|
||||
if (existing is null)
|
||||
{
|
||||
_db.Readings.Add(new Reading
|
||||
{
|
||||
MeterId = meter.Id,
|
||||
Time = utc,
|
||||
Value = value,
|
||||
SourceId = source.Id,
|
||||
Quality = ReadingQuality.Measured,
|
||||
});
|
||||
outcome = IngestionOutcome.Written;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Value = value;
|
||||
existing.SourceId = source.Id;
|
||||
outcome = IngestionOutcome.Updated;
|
||||
}
|
||||
|
||||
await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private async Task<bool> IsSpuriousDecreaseAsync(
|
||||
int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken)
|
||||
{
|
||||
var previous = await _db.Readings
|
||||
.Where(r => r.MeterId == meterId && r.Time < time)
|
||||
.OrderByDescending(r => r.Time)
|
||||
.Select(r => (double?)r.Value)
|
||||
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (previous is null || value >= previous.Value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A reset/swap event between the previous reading and this one explains the decrease.
|
||||
var explained = await _db.MeterEvents.AnyAsync(
|
||||
e => e.MeterId == meterId
|
||||
&& (e.EventType == MeterEventType.CounterReset || e.EventType == MeterEventType.MeterSwap)
|
||||
&& e.Time <= time,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return !explained;
|
||||
}
|
||||
|
||||
private async Task UpdateSourceStatusAsync(
|
||||
MeterSource source, DateTimeOffset time, double value, string status, CancellationToken cancellationToken)
|
||||
{
|
||||
source.LastSeenAt = time;
|
||||
source.LastValue = value;
|
||||
source.LastStatus = status;
|
||||
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user