using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text; 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; /// /// Real-time Home Assistant ingestion via the WebSocket API (SDD §6.2 push path). For each enabled /// HA endpoint whose config sets UseWebSocket, holds a persistent connection that authenticates, /// subscribes to state_changed events, and ingests changes for the endpoint's configured /// entities as they happen. Reconnects with capped backoff. Endpoints without UseWebSocket stay /// on the REST poll worker () — each endpoint is served by exactly one. /// Tokens are resolved from environment variables named in the endpoint config, never stored plaintext. /// public sealed class HomeAssistantWebSocketWorker( IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService { private static readonly TimeSpan SuperviseInterval = TimeSpan.FromSeconds(15); private static readonly TimeSpan InitialBackoff = TimeSpan.FromSeconds(2); private static readonly TimeSpan MaxBackoff = TimeSpan.FromSeconds(60); private static readonly TimeSpan EntityMapTtl = TimeSpan.FromSeconds(30); private readonly IServiceScopeFactory _scopeFactory = scopeFactory; private readonly ILogger _logger = logger; private readonly ConcurrentDictionary _connections = new(); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(SuperviseInterval); do { try { await SuperviseAsync(stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { _logger.LogWarning(ex, "Home Assistant WebSocket supervisor tick failed; will retry"); } } while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)); } /// Starts a connection loop for each WebSocket-enabled endpoint that isn't already running. private async Task SuperviseAsync(CancellationToken stoppingToken) { List wsEndpointIds; await using (var scope = _scopeFactory.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var endpoints = await db.IngestionEndpoints .Where(e => e.IsEnabled && e.Type == EndpointType.HomeAssistant) .ToListAsync(stoppingToken).ConfigureAwait(false); wsEndpointIds = endpoints .Where(e => HaEndpointConfig.Parse(e.Config).UseWebSocket) .Select(e => e.Id) .ToList(); } // Reap finished connection loops (endpoint disabled/removed, or terminal failure). foreach (var id in _connections.Where(kv => kv.Value.IsCompleted).Select(kv => kv.Key).ToList()) { _connections.TryRemove(id, out _); } foreach (var endpointId in wsEndpointIds) { // Fire-and-forget: the loop stores its own Task in _connections and self-terminates when // the endpoint is disabled/removed; the supervisor reaps completed entries above. _ = _connections.GetOrAdd(endpointId, id => Task.Run(() => RunConnectionAsync(id, stoppingToken), stoppingToken)); } } /// Connect → listen → reconnect loop for one endpoint, until it is disabled or the app stops. private async Task RunConnectionAsync(int endpointId, CancellationToken stoppingToken) { var backoff = InitialBackoff; while (!stoppingToken.IsCancellationRequested) { HaEndpointConfig config; string? token; await using (var scope = _scopeFactory.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var endpoint = await db.IngestionEndpoints .FirstOrDefaultAsync(e => e.Id == endpointId, stoppingToken).ConfigureAwait(false); if (endpoint is null || !endpoint.IsEnabled) { return; // gone/disabled — stop; the supervisor will restart it if it comes back. } config = HaEndpointConfig.Parse(endpoint.Config); if (!config.UseWebSocket) { return; // switched to poll mode. } token = config.ResolveToken(); } if (string.IsNullOrWhiteSpace(config.BaseUrl) || string.IsNullOrWhiteSpace(token)) { _logger.LogWarning("HA WebSocket endpoint {EndpointId} missing base URL or token; retrying", endpointId); } else { try { await ListenAsync(endpointId, config.BaseUrl!, token!, stoppingToken).ConfigureAwait(false); backoff = InitialBackoff; // clean close → reset backoff. } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } catch (Exception ex) { _logger.LogWarning(ex, "HA WebSocket connection to endpoint {EndpointId} dropped; reconnecting", endpointId); await UpdateEndpointStatusAsync(endpointId, "disconnected", CancellationToken.None).ConfigureAwait(false); } } try { await Task.Delay(backoff, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } backoff = TimeSpan.FromSeconds(Math.Min(MaxBackoff.TotalSeconds, backoff.TotalSeconds * 2)); } } private async Task ListenAsync(int endpointId, string baseUrl, string token, CancellationToken stoppingToken) { using var ws = new ClientWebSocket(); await ws.ConnectAsync(HaWebSocketProtocol.WebSocketUri(baseUrl), stoppingToken).ConfigureAwait(false); // Handshake: auth_required → auth → auth_ok. using (var required = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false)) { if (!HaWebSocketProtocol.IsAuthRequired(required.RootElement)) { // Some setups may not send auth_required first; proceed to auth regardless. _logger.LogDebug("HA WebSocket did not send auth_required first (type={Type})", HaWebSocketProtocol.MessageType(required.RootElement)); } } await SendAsync(ws, HaWebSocketProtocol.AuthMessage(token), stoppingToken).ConfigureAwait(false); using (var authResult = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false)) { if (!HaWebSocketProtocol.IsAuthOk(authResult.RootElement)) { throw new InvalidOperationException( $"Home Assistant WebSocket auth failed (type={HaWebSocketProtocol.MessageType(authResult.RootElement)})."); } } await SendAsync(ws, HaWebSocketProtocol.SubscribeStateChanged(1), stoppingToken).ConfigureAwait(false); await UpdateEndpointStatusAsync(endpointId, "connected (ws)", stoppingToken).ConfigureAwait(false); _logger.LogInformation("HA WebSocket connected for endpoint {EndpointId}", endpointId); var entityMap = await LoadEntityMapAsync(endpointId, stoppingToken).ConfigureAwait(false); var mapLoadedAt = DateTimeOffset.UtcNow; while (!stoppingToken.IsCancellationRequested && ws.State == WebSocketState.Open) { using var doc = await ReceiveJsonAsync(ws, stoppingToken).ConfigureAwait(false); if (!HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out var entityId, out var newState)) { continue; } if (DateTimeOffset.UtcNow - mapLoadedAt > EntityMapTtl) { entityMap = await LoadEntityMapAsync(endpointId, stoppingToken).ConfigureAwait(false); mapLoadedAt = DateTimeOffset.UtcNow; } if (!entityMap.TryGetValue(entityId, out var sources)) { continue; } foreach (var (sourceId, attribute) in sources) { if (HaStateClient.ParseStateElement(newState, attribute) is { } state) { await IngestAsync(sourceId, state, stoppingToken).ConfigureAwait(false); } } } } /// Maps each configured entity id to the sources (and optional attribute) that read it. private async Task>> LoadEntityMapAsync( int endpointId, CancellationToken cancellationToken) { await using var scope = _scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var sources = await db.MeterSources.AsNoTracking() .Where(s => s.IsEnabled && s.SourceType == SourceType.HomeAssistant && s.EndpointId == endpointId) .ToListAsync(cancellationToken).ConfigureAwait(false); var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var source in sources) { var config = SourceConfig.Parse(source.Config); if (string.IsNullOrWhiteSpace(config.EntityId)) { continue; } if (!map.TryGetValue(config.EntityId, out var list)) { map[config.EntityId] = list = []; } list.Add((source.Id, config.Attribute)); } return map; } private async Task IngestAsync(int sourceId, HaState state, CancellationToken cancellationToken) { await using var scope = _scopeFactory.CreateAsyncScope(); var ingestion = scope.ServiceProvider.GetRequiredService(); await ingestion.IngestAsync(sourceId, state.Time, state.Value, cancellationToken).ConfigureAwait(false); } private async Task UpdateEndpointStatusAsync(int endpointId, string status, CancellationToken cancellationToken) { try { await using var scope = _scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var endpoint = await db.IngestionEndpoints .FirstOrDefaultAsync(e => e.Id == endpointId, cancellationToken).ConfigureAwait(false); if (endpoint is not null) { endpoint.LastStatus = status; endpoint.LastSeenAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } } catch (Exception ex) { _logger.LogDebug(ex, "Failed to update HA endpoint {EndpointId} status", endpointId); } } private static Task SendAsync(ClientWebSocket ws, string json, CancellationToken cancellationToken) => ws.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, endOfMessage: true, cancellationToken); /// Reads one (possibly fragmented) text message and parses it as JSON. private static async Task ReceiveJsonAsync(ClientWebSocket ws, CancellationToken cancellationToken) { using var buffer = new MemoryStream(); var chunk = new byte[8192]; WebSocketReceiveResult result; do { result = await ws.ReceiveAsync(chunk, cancellationToken).ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { throw new InvalidOperationException( $"Home Assistant closed the WebSocket ({result.CloseStatus}: {result.CloseStatusDescription})."); } buffer.Write(chunk, 0, result.Count); } while (!result.EndOfMessage); buffer.Position = 0; return await JsonDocument.ParseAsync(buffer, cancellationToken: cancellationToken).ConfigureAwait(false); } }