8550ed8d9e
ci / build-test (push) Successful in 1m16s
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
120 lines
5.0 KiB
C#
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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
}
|