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
+1
View File
@@ -14,6 +14,7 @@
<PackageVersion Include="CsvHelper" Version="33.1.0" />
<PackageVersion Include="MQTTnet" Version="5.2.0.1603" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
</ItemGroup>
<ItemGroup Label="App / UI">
+7
View File
@@ -26,6 +26,13 @@ try
?? "Host=localhost;Port=5432;Database=metervault;Username=metervault;Password=metervault";
builder.Services.AddMeterVaultInfrastructure(connectionString);
var options = builder.Configuration.GetSection(MeterVaultOptions.SectionName).Get<MeterVaultOptions>()
?? new MeterVaultOptions();
if (options.EnableLiveIngestion)
{
builder.Services.AddMeterVaultIngestion();
}
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
+15
View File
@@ -1,5 +1,6 @@
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -24,7 +25,21 @@ public static class DependencyInjection
services.AddScoped<NormalizationService>();
services.AddScoped<CsvImporter>();
services.AddScoped<ImportService>();
services.AddScoped<IngestionService>();
services.AddScoped<MqttMessageRouter>();
return services;
}
/// <summary>
/// Registers the live-ingestion background workers (MQTT/Tasmota + Home Assistant). Kept
/// separate from <see cref="AddMeterVaultInfrastructure"/> so tests can opt out of brokers.
/// </summary>
public static IServiceCollection AddMeterVaultIngestion(this IServiceCollection services)
{
services.AddHttpClient();
services.AddHostedService<MqttIngestionWorker>();
services.AddHostedService<HomeAssistantWorker>();
return services;
}
}
@@ -0,0 +1,55 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// The parsed <see cref="Core.Domain.IngestionEndpoint.Config"/> JSON for an MQTT broker. Secrets
/// are stored by reference only (SDD §6.4): <see cref="UsernameEnv"/>/<see cref="PasswordEnv"/>
/// name environment variables resolved at runtime, never plaintext credentials in the database.
/// </summary>
public sealed record EndpointConfig
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public string Host { get; init; } = "localhost";
public int Port { get; init; } = 1883;
public bool Tls { get; init; }
/// <summary>Extra topic filters to subscribe (beyond the sources' own topics), e.g. <c>tele/+/SENSOR</c>.</summary>
public IReadOnlyList<string> ExtraTopics { get; init; } = [];
public string? UsernameEnv { get; init; }
public string? PasswordEnv { get; init; }
public static EndpointConfig Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new EndpointConfig();
}
try
{
return JsonSerializer.Deserialize<EndpointConfig>(json, Options) ?? new EndpointConfig();
}
catch (JsonException)
{
return new EndpointConfig();
}
}
public string? ResolveUsername() => Resolve(UsernameEnv);
public string? ResolvePassword() => Resolve(PasswordEnv);
private static string? Resolve(string? envVarName) =>
string.IsNullOrWhiteSpace(envVarName) ? null : Environment.GetEnvironmentVariable(envVarName);
}
@@ -0,0 +1,78 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Text.Json;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>A resolved Home Assistant state: numeric value and the entity's last-updated time.</summary>
public readonly record struct HaState(double Value, DateTimeOffset Time);
/// <summary>
/// Reads a single Home Assistant entity state over the REST API
/// (<c>GET /api/states/{entity_id}</c> with a long-lived bearer token) and parses the numeric
/// state or a named attribute (SDD §6.2). This is the poll fallback to the WebSocket push path.
/// </summary>
public sealed class HaStateClient(HttpClient httpClient)
{
private readonly HttpClient _httpClient = httpClient;
public async Task<HaState?> GetStateAsync(
string baseUrl, string token, string entityId, string? attribute,
CancellationToken cancellationToken = default)
{
var url = $"{baseUrl.TrimEnd('/')}/api/states/{entityId}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return ParseState(await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false), attribute);
}
internal static HaState? ParseState(JsonDocument document, string? attribute)
{
var root = document.RootElement;
double value;
if (string.IsNullOrWhiteSpace(attribute))
{
if (!root.TryGetProperty("state", out var state) || !TryReadNumber(state, out value))
{
return null; // 'unavailable', 'unknown', or non-numeric.
}
}
else
{
if (!root.TryGetProperty("attributes", out var attrs)
|| !attrs.TryGetProperty(attribute, out var attr)
|| !TryReadNumber(attr, out value))
{
return null;
}
}
var time = DateTimeOffset.UtcNow;
if (root.TryGetProperty("last_updated", out var lastUpdated)
&& lastUpdated.ValueKind == JsonValueKind.String
&& DateTimeOffset.TryParse(lastUpdated.GetString(), CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
{
time = parsed;
}
return new HaState(value, time);
}
private static bool TryReadNumber(JsonElement element, out double value)
{
value = 0;
return element.ValueKind switch
{
JsonValueKind.Number => element.TryGetDouble(out value),
JsonValueKind.String => double.TryParse(element.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out value),
_ => false,
};
}
}
@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using System.Text.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// Polls Home Assistant entity states on each source's configured interval and ingests them
/// (SDD §6.2 REST fallback). Home Assistant can alternatively push to MQTT (handled by
/// <see cref="MqttIngestionWorker"/>) or push to the app's REST API. Tokens are resolved from
/// environment variables named in the endpoint config — never stored in the database.
/// </summary>
public sealed class HomeAssistantWorker(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpClientFactory,
ILogger<HomeAssistantWorker> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(10);
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
private readonly ILogger<HomeAssistantWorker> _logger = logger;
private readonly ConcurrentDictionary<int, DateTimeOffset> _nextPoll = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
do
{
try
{
await PollDueSourcesAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Home Assistant poll tick failed; will retry");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
private async Task PollDueSourcesAsync(CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
var ingestion = scope.ServiceProvider.GetRequiredService<IngestionService>();
var client = new HaStateClient(_httpClientFactory.CreateClient());
var endpoints = await db.IngestionEndpoints
.Where(e => e.IsEnabled && e.Type == EndpointType.HomeAssistant)
.ToDictionaryAsync(e => e.Id, cancellationToken).ConfigureAwait(false);
if (endpoints.Count == 0)
{
return;
}
var sources = await db.MeterSources
.Where(s => s.IsEnabled && s.SourceType == SourceType.HomeAssistant && s.EndpointId != null)
.ToListAsync(cancellationToken).ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
foreach (var source in sources)
{
if (_nextPoll.TryGetValue(source.Id, out var due) && due > now)
{
continue;
}
if (source.EndpointId is not { } endpointId || !endpoints.TryGetValue(endpointId, out var endpoint))
{
continue;
}
var config = SourceConfig.Parse(source.Config);
var interval = TimeSpan.FromSeconds(Math.Max(5, config.PollSeconds ?? 60));
_nextPoll[source.Id] = now + interval;
await PollSourceAsync(client, ingestion, endpoint, source, config, cancellationToken).ConfigureAwait(false);
}
}
private async Task PollSourceAsync(
HaStateClient client, IngestionService ingestion, IngestionEndpoint endpoint,
MeterSource source, SourceConfig config, CancellationToken cancellationToken)
{
var endpointConfig = ParseHaEndpoint(endpoint.Config);
var token = string.IsNullOrWhiteSpace(endpointConfig.TokenEnv)
? null
: Environment.GetEnvironmentVariable(endpointConfig.TokenEnv);
if (string.IsNullOrWhiteSpace(endpointConfig.BaseUrl) || string.IsNullOrWhiteSpace(token)
|| string.IsNullOrWhiteSpace(config.EntityId))
{
return;
}
try
{
var state = await client.GetStateAsync(
endpointConfig.BaseUrl, token, config.EntityId, config.Attribute, cancellationToken).ConfigureAwait(false);
if (state is { } value)
{
await ingestion.IngestAsync(source.Id, value.Time, value.Value, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Home Assistant poll failed for entity {EntityId}", config.EntityId);
}
}
private static HaEndpoint ParseHaEndpoint(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new HaEndpoint();
}
try
{
return JsonSerializer.Deserialize<HaEndpoint>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new HaEndpoint();
}
catch (JsonException)
{
return new HaEndpoint();
}
}
private sealed record HaEndpoint
{
public string? BaseUrl { get; init; }
public string? TokenEnv { get; init; }
}
}
@@ -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);
}
}
@@ -0,0 +1,176 @@
using System.Collections.Concurrent;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MQTTnet;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// Maintains a persistent MQTTnet connection to each enabled MQTT broker endpoint, subscribes to
/// the union of enabled sources' topics (plus any extra Tasmota patterns), and routes each message
/// through <see cref="MqttMessageRouter"/> (SDD §6.1, FR-4). Degrades gracefully: broker outages
/// are logged and retried on the next tick rather than crashing the app.
/// </summary>
public sealed class MqttIngestionWorker(
IServiceScopeFactory scopeFactory, ILogger<MqttIngestionWorker> logger) : BackgroundService
{
private static readonly TimeSpan ReconnectInterval = TimeSpan.FromSeconds(15);
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<MqttIngestionWorker> _logger = logger;
private readonly MqttClientFactory _factory = new();
private readonly ConcurrentDictionary<int, IMqttClient> _clients = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(ReconnectInterval);
do
{
try
{
await EnsureConnectionsAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MQTT ingestion tick failed; will retry");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
foreach (var client in _clients.Values)
{
try
{
if (client.IsConnected)
{
await client.DisconnectAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error disconnecting MQTT client during shutdown");
}
client.Dispose();
}
}
private async Task EnsureConnectionsAsync(CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MeterVaultDbContext>();
var endpoints = await db.IngestionEndpoints
.Where(e => e.IsEnabled && e.Type == EndpointType.MqttBroker)
.ToListAsync(cancellationToken).ConfigureAwait(false);
foreach (var endpoint in endpoints)
{
var client = _clients.GetOrAdd(endpoint.Id, _ => CreateClient());
if (client.IsConnected)
{
continue;
}
var topics = await ResolveTopicsAsync(db, endpoint, cancellationToken).ConfigureAwait(false);
await ConnectAndSubscribeAsync(endpoint, client, topics, cancellationToken).ConfigureAwait(false);
}
}
private IMqttClient CreateClient()
{
var client = _factory.CreateMqttClient();
client.ApplicationMessageReceivedAsync += OnMessageAsync;
return client;
}
private async Task ConnectAndSubscribeAsync(
IngestionEndpoint endpoint, IMqttClient client, IReadOnlyList<string> topics, CancellationToken cancellationToken)
{
var config = EndpointConfig.Parse(endpoint.Config);
var builder = new MqttClientOptionsBuilder()
.WithTcpServer(config.Host, config.Port)
.WithCleanSession();
var username = config.ResolveUsername();
if (!string.IsNullOrWhiteSpace(username))
{
builder = builder.WithCredentials(username, config.ResolvePassword() ?? string.Empty);
}
if (config.Tls)
{
builder = builder.WithTlsOptions(o => { });
}
try
{
await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false);
foreach (var topic in topics)
{
await client.SubscribeAsync(topic, cancellationToken: cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation("MQTT connected to {Host}:{Port} ({TopicCount} topics)",
config.Host, config.Port, topics.Count);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MQTT broker {Host}:{Port} unreachable; ingestion degraded",
config.Host, config.Port);
}
}
private static async Task<IReadOnlyList<string>> ResolveTopicsAsync(
MeterVaultDbContext db, IngestionEndpoint endpoint, CancellationToken cancellationToken)
{
var sourceConfigs = await db.MeterSources
.Where(s => s.IsEnabled
&& (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota)
&& (s.EndpointId == endpoint.Id || s.EndpointId == null))
.Select(s => s.Config)
.ToListAsync(cancellationToken).ConfigureAwait(false);
var topics = new HashSet<string>(StringComparer.Ordinal);
foreach (var raw in sourceConfigs)
{
var topic = SourceConfig.Parse(raw).Topic;
if (!string.IsNullOrWhiteSpace(topic))
{
topics.Add(topic);
}
}
foreach (var extra in EndpointConfig.Parse(endpoint.Config).ExtraTopics)
{
topics.Add(extra);
}
return [.. topics];
}
private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args)
{
var topic = args.ApplicationMessage.Topic;
var payload = args.ApplicationMessage.ConvertPayloadToString() ?? string.Empty;
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var router = scope.ServiceProvider.GetRequiredService<MqttMessageRouter>();
await router.RouteAsync(topic, payload).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to route MQTT message on topic {Topic}", topic);
}
}
}
@@ -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;
}
}
@@ -0,0 +1,47 @@
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// Matches an MQTT topic against a subscription filter using the standard <c>+</c> (single level)
/// and <c>#</c> (multi level, trailing only) wildcards. Used to route an incoming message to the
/// meter sources whose configured topic filter covers it (e.g. <c>tele/+/SENSOR</c>).
/// </summary>
public static class MqttTopicMatcher
{
public static bool Matches(string? filter, string? topic)
{
if (string.IsNullOrEmpty(filter) || string.IsNullOrEmpty(topic))
{
return false;
}
if (string.Equals(filter, topic, StringComparison.Ordinal))
{
return true;
}
var filterLevels = filter.Split('/');
var topicLevels = topic.Split('/');
for (var i = 0; i < filterLevels.Length; i++)
{
var f = filterLevels[i];
if (f == "#")
{
// '#' must be the last level and matches the remainder (including zero levels).
return i == filterLevels.Length - 1;
}
if (i >= topicLevels.Length)
{
return false;
}
if (f != "+" && !string.Equals(f, topicLevels[i], StringComparison.Ordinal))
{
return false;
}
}
return filterLevels.Length == topicLevels.Length;
}
}
@@ -0,0 +1,100 @@
using System.Globalization;
using System.Text.Json;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// Extracts a numeric value and optional timestamp from a JSON message payload via a dot-path
/// (SDD §6.1). Tasmota energy lives under <c>ENERGY.Total</c> / <c>ENERGY.Today</c> /
/// <c>ENERGY.Power</c>; generic sensors are addressed by path. A bare numeric payload (no JSON)
/// is accepted when the path is empty — Home Assistant states are often just <c>"123.4"</c>.
/// </summary>
public static class PayloadExtractor
{
public static bool TryExtractValue(string payload, string? path, out double value)
{
value = 0;
if (string.IsNullOrWhiteSpace(payload))
{
return false;
}
// Bare scalar (HA state, plain sensor) when no path is given.
if (string.IsNullOrWhiteSpace(path))
{
return TryParseNumber(payload.Trim(), out value);
}
try
{
using var doc = JsonDocument.Parse(payload);
if (!TryNavigate(doc.RootElement, path, out var element))
{
return false;
}
return TryReadNumber(element, out value);
}
catch (JsonException)
{
return false;
}
}
public static bool TryExtractTime(string payload, string? timePath, out DateTimeOffset time)
{
time = default;
if (string.IsNullOrWhiteSpace(payload) || string.IsNullOrWhiteSpace(timePath))
{
return false;
}
try
{
using var doc = JsonDocument.Parse(payload);
if (!TryNavigate(doc.RootElement, timePath, out var element) || element.ValueKind != JsonValueKind.String)
{
return false;
}
var raw = element.GetString();
return DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out time);
}
catch (JsonException)
{
return false;
}
}
private static bool TryNavigate(JsonElement root, string path, out JsonElement result)
{
result = root;
foreach (var segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (result.ValueKind != JsonValueKind.Object || !result.TryGetProperty(segment, out result))
{
return false;
}
}
return true;
}
private static bool TryReadNumber(JsonElement element, out double value)
{
switch (element.ValueKind)
{
case JsonValueKind.Number:
return element.TryGetDouble(out value);
case JsonValueKind.String:
return TryParseNumber(element.GetString(), out value);
default:
value = 0;
return false;
}
}
private static bool TryParseNumber(string? raw, out double value) =>
double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out value);
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MeterVault.Infrastructure.Ingestion;
/// <summary>
/// The parsed <see cref="Core.Domain.MeterSource.Config"/> JSON. Which fields matter depends on the
/// source type: MQTT/Tasmota use <see cref="Topic"/>/<see cref="Path"/>/<see cref="TimePath"/>;
/// Home Assistant uses <see cref="EntityId"/>/<see cref="Attribute"/>/<see cref="PollSeconds"/>.
/// </summary>
public sealed record SourceConfig
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>MQTT topic filter this source listens on (e.g. <c>tele/plug1/SENSOR</c>).</summary>
public string? Topic { get; init; }
/// <summary>Dot-path to the value in the payload (e.g. <c>ENERGY.Total</c>). Null = bare scalar.</summary>
public string? Path { get; init; }
/// <summary>Dot-path to a timestamp in the payload (Tasmota <c>Time</c>). Null = use receive time.</summary>
public string? TimePath { get; init; }
/// <summary>Home Assistant entity id (e.g. <c>sensor.house_power</c>).</summary>
public string? EntityId { get; init; }
/// <summary>Home Assistant attribute name; null reads the entity state.</summary>
public string? Attribute { get; init; }
/// <summary>Home Assistant REST poll interval in seconds (fallback when not using WebSocket push).</summary>
public int? PollSeconds { get; init; }
public static SourceConfig Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new SourceConfig();
}
try
{
return JsonSerializer.Deserialize<SourceConfig>(json, Options) ?? new SourceConfig();
}
catch (JsonException)
{
return new SourceConfig();
}
}
}
@@ -5,6 +5,9 @@
<PackageReference Include="EFCore.NamingConventions" />
<PackageReference Include="Dapper" />
<PackageReference Include="CsvHelper" />
<PackageReference Include="MQTTnet" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>
@@ -19,6 +19,9 @@ public sealed class MeterVaultOptions
/// <summary>Run EF migrations on startup. Disable for tests that migrate out-of-band.</summary>
public bool RunMigrationsAtStartup { get; set; } = true;
/// <summary>Start the MQTT/Home Assistant ingestion workers. Disable for tests.</summary>
public bool EnableLiveIngestion { get; set; } = true;
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
public int RawRetentionDays { get; set; } = 1095;
}
@@ -0,0 +1,134 @@
using System.Text.Json;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeterVault.Integration.Tests.Ingestion;
[Collection("Timescale")]
public sealed class IngestionServiceTests(TimescaleFixture fx)
{
private static readonly DateTimeOffset T0 = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Writes_and_updates_idempotently_with_scale_and_offset()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter, scale: 0.001, offset: 0);
var service = new IngestionService(db);
// 1000 raw × 0.001 = 1.0.
Assert.Equal(IngestionOutcome.Written, await service.IngestAsync(sourceId, T0, 1000));
Assert.Equal(IngestionOutcome.Updated, await service.IngestAsync(sourceId, T0, 2000)); // same time → update
var reading = await db.Readings.SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(2.0, reading.Value, 6);
var source = await db.MeterSources.SingleAsync(s => s.Id == sourceId);
Assert.Equal("ok", source.LastStatus);
Assert.Equal(2.0, source.LastValue!.Value, 6);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Rejects_spurious_decrease_on_a_cumulative_register()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = new IngestionService(db);
await service.IngestAsync(sourceId, T0, 500);
var outcome = await service.IngestAsync(sourceId, T0.AddHours(1), 400); // decrease, no event
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == T0.AddHours(1)));
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Allows_decrease_when_a_swap_event_explains_it()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = new IngestionService(db);
await service.IngestAsync(sourceId, T0, 500);
db.MeterEvents.Add(new MeterEvent
{
MeterId = meterId,
Time = T0.AddMinutes(30),
EventType = MeterEventType.MeterSwap,
PrevValue = 500,
NewValue = 0,
});
await db.SaveChangesAsync();
var outcome = await service.IngestAsync(sourceId, T0.AddHours(1), 20); // new meter reads low
Assert.Equal(IngestionOutcome.Written, outcome);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task Mqtt_router_ingests_a_tasmota_payload()
{
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter, topic: "tele/plug7/SENSOR");
var router = new MqttMessageRouter(db, new IngestionService(db), NullLogger<MqttMessageRouter>.Instance);
var routed = await router.RouteAsync(
"tele/plug7/SENSOR",
"""{"Time":"2024-03-01T10:00:00","ENERGY":{"Total":8421.0}}""");
Assert.Equal(1, routed);
var reading = await db.Readings.SingleAsync(r => r.MeterId == meterId);
Assert.Equal(8421.0, reading.Value, 3);
Assert.Equal(new DateTimeOffset(2024, 3, 1, 10, 0, 0, TimeSpan.Zero), reading.Time);
await CleanupAsync(db, meterId);
}
private static async Task<(int MeterId, int SourceId)> SetupAsync(
MeterVaultDbContext db, MeterMode mode, double scale = 1, double offset = 0,
string topic = "tele/x/SENSOR", string? path = "ENERGY.Total")
{
await DatabaseSeeder.SeedAsync(db);
var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity");
var meter = new Meter
{
Name = $"ingest-{Guid.NewGuid():N}",
EnergyTypeId = type.Id,
Mode = mode,
Unit = "kWh",
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
var source = new MeterSource
{
MeterId = meter.Id,
SourceType = SourceType.Tasmota,
ValueKind = SourceValueKind.Register,
Scale = scale,
Offset = offset,
Config = JsonSerializer.Serialize(new { topic, path }),
};
db.MeterSources.Add(source);
await db.SaveChangesAsync();
return (meter.Id, source.Id);
}
private static async Task CleanupAsync(MeterVaultDbContext db, int meterId)
{
await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync();
await db.MeterEvents.Where(e => e.MeterId == meterId).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync();
}
}
@@ -0,0 +1,73 @@
using System.Text.Json;
using MeterVault.Infrastructure.Ingestion;
namespace MeterVault.Integration.Tests.Ingestion;
/// <summary>Pure ingestion helpers — no broker or database needed.</summary>
public sealed class PayloadExtractionTests
{
private const string TasmotaSensor =
"""{"Time":"2024-01-15T12:30:00","ENERGY":{"Total":1234.56,"Today":1.2,"Power":50}}""";
[Fact]
public void Extracts_tasmota_energy_total()
{
Assert.True(PayloadExtractor.TryExtractValue(TasmotaSensor, "ENERGY.Total", out var value));
Assert.Equal(1234.56, value, 3);
}
[Fact]
public void Extracts_tasmota_time()
{
Assert.True(PayloadExtractor.TryExtractTime(TasmotaSensor, "Time", out var time));
Assert.Equal(new DateTimeOffset(2024, 1, 15, 12, 30, 0, TimeSpan.Zero), time);
}
[Fact]
public void Extracts_bare_scalar_when_no_path()
{
Assert.True(PayloadExtractor.TryExtractValue("42.7", null, out var value));
Assert.Equal(42.7, value, 3);
}
[Fact]
public void Missing_path_yields_false()
{
Assert.False(PayloadExtractor.TryExtractValue(TasmotaSensor, "ENERGY.Nope", out _));
}
[Theory]
[InlineData("tele/+/SENSOR", "tele/plug1/SENSOR", true)]
[InlineData("tele/plug1/SENSOR", "tele/plug1/SENSOR", true)]
[InlineData("tele/#", "tele/plug1/SENSOR", true)]
[InlineData("tele/+/SENSOR", "tele/plug1/STATE", false)]
[InlineData("tele/+/SENSOR", "tele/a/b/SENSOR", false)]
[InlineData("home/#", "office/x", false)]
public void Topic_matcher_follows_mqtt_wildcards(string filter, string topic, bool expected)
{
Assert.Equal(expected, MqttTopicMatcher.Matches(filter, topic));
}
[Fact]
public void Ha_state_client_parses_numeric_state()
{
using var doc = JsonDocument.Parse(
"""{"entity_id":"sensor.p","state":"987.6","attributes":{"unit":"kWh"},"last_updated":"2024-02-01T08:00:00+00:00"}""");
var state = HaStateClient.ParseState(doc, attribute: null);
Assert.NotNull(state);
Assert.Equal(987.6, state!.Value.Value, 3);
Assert.Equal(new DateTimeOffset(2024, 2, 1, 8, 0, 0, TimeSpan.Zero), state.Value.Time);
}
[Fact]
public void Ha_state_client_parses_attribute_and_rejects_unavailable()
{
using var doc = JsonDocument.Parse(
"""{"entity_id":"sensor.p","state":"unavailable","attributes":{"power":123.4}}""");
Assert.Null(HaStateClient.ParseState(doc, attribute: null)); // non-numeric state
Assert.Equal(123.4, HaStateClient.ParseState(doc, "power")!.Value.Value, 3);
}
}
@@ -14,5 +14,6 @@ public sealed class MeterVaultAppFactory(string connectionString) : WebApplicati
builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:Default", connectionString);
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");
builder.UseSetting("MeterVault:EnableLiveIngestion", "false");
}
}