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