Files
schmidt.florian 8550ed8d9e
ci / build-test (push) Successful in 1m16s
Ingestion: Home Assistant WebSocket push path
HomeAssistantWebSocketWorker holds a persistent state_changed subscription per HA endpoint that opts in via the connector's WebSocket toggle (HaEndpointConfig.UseWebSocket): auth handshake, subscribe, ingest in real time, capped-backoff reconnect. The REST poll worker skips WS endpoints so each is served once. HaWebSocketProtocol holds the pure handshake/parse logic. Verified by 11 protocol unit tests + a live integration test against an in-process fake HA server. CLAUDE.md updated.

Claude-Session: https://claude.ai/code/session_01Kib2MniVFbD95fkgLgBBnB
2026-07-17 11:05:01 +02:00

120 lines
5.0 KiB
C#

using System.Text.Json;
using MeterVault.Infrastructure.Ingestion;
namespace MeterVault.Integration.Tests.Ingestion;
/// <summary>
/// The pure Home Assistant WebSocket protocol helpers: URL derivation, the auth/subscribe frames,
/// and reading the entity id + numeric value out of a <c>state_changed</c> event (the transport is
/// exercised separately). No network — these are the parts that must be provably correct.
/// </summary>
public sealed class HaWebSocketProtocolTests
{
[Theory]
[InlineData("http://ha.local:8123", "ws", "ha.local", 8123)]
[InlineData("http://ha.local:8123/", "ws", "ha.local", 8123)]
[InlineData("https://ha.example.com", "wss", "ha.example.com", 443)]
public void WebSocketUri_maps_scheme_and_appends_api_path(string baseUrl, string scheme, string host, int port)
{
var uri = HaWebSocketProtocol.WebSocketUri(baseUrl);
Assert.Equal(scheme, uri.Scheme);
Assert.Equal(host, uri.Host);
Assert.Equal(port, uri.Port);
Assert.Equal("/api/websocket", uri.AbsolutePath);
}
[Fact]
public void AuthMessage_carries_type_and_token()
{
using var doc = JsonDocument.Parse(HaWebSocketProtocol.AuthMessage("secret-token"));
Assert.Equal("auth", doc.RootElement.GetProperty("type").GetString());
Assert.Equal("secret-token", doc.RootElement.GetProperty("access_token").GetString());
}
[Fact]
public void SubscribeStateChanged_requests_state_changed_events()
{
using var doc = JsonDocument.Parse(HaWebSocketProtocol.SubscribeStateChanged(7));
Assert.Equal(7, doc.RootElement.GetProperty("id").GetInt32());
Assert.Equal("subscribe_events", doc.RootElement.GetProperty("type").GetString());
Assert.Equal("state_changed", doc.RootElement.GetProperty("event_type").GetString());
}
[Theory]
[InlineData("{\"type\":\"auth_required\",\"ha_version\":\"2024.6\"}", true, false, false)]
[InlineData("{\"type\":\"auth_ok\"}", false, true, false)]
[InlineData("{\"type\":\"auth_invalid\",\"message\":\"bad token\"}", false, false, true)]
public void Auth_message_types_are_recognized(string json, bool required, bool ok, bool invalid)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(required, HaWebSocketProtocol.IsAuthRequired(root));
Assert.Equal(ok, HaWebSocketProtocol.IsAuthOk(root));
Assert.Equal(invalid, HaWebSocketProtocol.IsAuthInvalid(root));
}
[Fact]
public void TryReadStateChanged_extracts_entity_and_numeric_state()
{
using var doc = JsonDocument.Parse(StateChangedEvent(state: "1234.5"));
Assert.True(HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out var entityId, out var newState));
Assert.Equal("sensor.house_power", entityId);
var parsed = HaStateClient.ParseStateElement(newState, attribute: null);
Assert.NotNull(parsed);
Assert.Equal(1234.5, parsed!.Value.Value, 3);
Assert.Equal(new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.Zero), parsed.Value.Time);
}
[Fact]
public void TryReadStateChanged_reads_a_named_attribute()
{
using var doc = JsonDocument.Parse(StateChangedEvent(state: "on"));
Assert.True(HaWebSocketProtocol.TryReadStateChanged(doc.RootElement, out _, out var newState));
// state "on" is non-numeric, but the 'current' attribute is a number.
Assert.Null(HaStateClient.ParseStateElement(newState, attribute: null));
var byAttribute = HaStateClient.ParseStateElement(newState, attribute: "current");
Assert.Equal(42, byAttribute!.Value.Value, 3);
}
[Fact]
public void TryReadStateChanged_ignores_removed_entities_and_other_events()
{
using var removed = JsonDocument.Parse(
"{\"type\":\"event\",\"event\":{\"event_type\":\"state_changed\",\"data\":{\"entity_id\":\"sensor.x\",\"new_state\":null}}}");
Assert.False(HaWebSocketProtocol.TryReadStateChanged(removed.RootElement, out _, out _));
using var other = JsonDocument.Parse(
"{\"type\":\"event\",\"event\":{\"event_type\":\"call_service\",\"data\":{}}}");
Assert.False(HaWebSocketProtocol.TryReadStateChanged(other.RootElement, out _, out _));
using var result = JsonDocument.Parse("{\"id\":1,\"type\":\"result\",\"success\":true}");
Assert.False(HaWebSocketProtocol.TryReadStateChanged(result.RootElement, out _, out _));
}
private static string StateChangedEvent(string state) => $$"""
{
"id": 1,
"type": "event",
"event": {
"event_type": "state_changed",
"data": {
"entity_id": "sensor.house_power",
"new_state": {
"entity_id": "sensor.house_power",
"state": "{{state}}",
"attributes": { "unit_of_measurement": "W", "current": 42 },
"last_updated": "2024-06-15T10:00:00+00:00"
}
}
}
}
""";
}