mirror of
https://github.com/ruvnet/RuView
synced 2026-06-19 11:53:19 +00:00
f21daf9aa8
New sub-package `wifi_densepose.client` (no PyO3, no Rust deps):
- ws.SensingClient — asyncio websockets>=12 wrapper for the Rust
sensing-server /ws/sensing endpoint. Yields typed dataclasses
(ConnectionEstablishedMessage, EdgeVitalsMessage, PoseDataMessage)
with raw-payload fallback for forward-compat with unknown types.
Malformed frames log+drop without breaking the stream.
- mqtt.RuViewMqttClient — paho-mqtt v2 wrapper using the explicit
CallbackAPIVersion.VERSION2 API. Per-instance unique client_id by
default (rumqttc memory lesson). MQTT v5-spec-correct topic
wildcard matcher: + as whole-level wildcard, # matches the prefix
itself plus all sub-levels. Auto-resubscribes on reconnect.
Handler exceptions are caught and logged so a misbehaving callback
can't crash the network loop.
- primitives.SemanticPrimitiveListener — typed router for the 10
HA-MIND fused inference outputs from ADR-115 §3.12
(SomeoneSleeping, PossibleDistress, RoomActive, ElderlyInactivity-
Anomaly, MeetingInProgress, BathroomOccupied, FallRiskElevated,
BedExit, NoMovementSafety, MultiRoomTransition). Decodes both
JSON payloads with confidence+explanation AND plain HA state
strings ("ON"/"OFF"/numeric). Pluggable into RuViewMqttClient.
- ha.HABlueprintHelper — read-only parser for the
homeassistant/<kind>/wifi_densepose_<node>/<id>/config payload
family. Aggregator queries: entities_for_node, by_device_class,
nodes. Useful for blueprint authors + dashboard introspection.
Test coverage (63 new tests, 156 total in Python suite):
- test_client_ha — 18 tests (topic+payload parsing, aggregator)
- test_client_primitives — 13 tests (enum coverage, listener routing)
- test_client_mqtt — 17 tests (matcher parametrize, dispatch path,
on_connect, exception isolation) — no broker needed
- test_client_ws — 6 tests including end-to-end against an in-process
websockets.serve() fixture exercising all 4 message types plus a
malformed-frame survival check
Post-bridge wheel size: 238 KB (well under ADR §5.4 5 MB budget).
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md §5.6
Refs: docs/adr/ADR-115-home-assistant-integration.md §3.12
Refs: #785
Co-Authored-By: claude-flow <ruv@ruv.net>
206 lines
7.0 KiB
Python
206 lines
7.0 KiB
Python
"""ADR-117 P4 — Tests for HA-DISCO payload parsing.
|
|
|
|
Pure parsing tests — no MQTT broker needed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from wifi_densepose.client import (
|
|
HABlueprintHelper,
|
|
HaDiscoveryPayload,
|
|
HaEntity,
|
|
)
|
|
from wifi_densepose.client.ha import (
|
|
parse_discovery_payload,
|
|
parse_discovery_topic,
|
|
)
|
|
|
|
|
|
# Real discovery payloads pulled from ADR-115 §3 (formatted for test
|
|
# readability; payloads are otherwise verbatim).
|
|
_PRESENCE_TOPIC = "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/config"
|
|
_PRESENCE_BODY = {
|
|
"name": "Presence",
|
|
"unique_id": "wifi_densepose_aabbccddeeff_presence",
|
|
"object_id": "wifi_densepose_aabbccddeeff_presence",
|
|
"state_topic": "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/state",
|
|
"availability_topic": "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/availability",
|
|
"device_class": "occupancy",
|
|
"icon": "mdi:motion-sensor",
|
|
}
|
|
|
|
_HEART_RATE_TOPIC = "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/config"
|
|
_HEART_RATE_BODY = {
|
|
"name": "Heart rate",
|
|
"unique_id": "wifi_densepose_aabbccddeeff_heart_rate",
|
|
"state_topic": "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/state",
|
|
"state_class": "measurement",
|
|
"unit_of_measurement": "bpm",
|
|
"icon": "mdi:heart-pulse",
|
|
"json_attributes_topic": "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/state",
|
|
}
|
|
|
|
|
|
# ─── Topic parsing ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_discovery_topic_binary_sensor() -> None:
|
|
out = parse_discovery_topic(_PRESENCE_TOPIC)
|
|
assert out == ("binary_sensor", "aabbccddeeff", "presence")
|
|
|
|
|
|
def test_parse_discovery_topic_sensor() -> None:
|
|
out = parse_discovery_topic(_HEART_RATE_TOPIC)
|
|
assert out == ("sensor", "aabbccddeeff", "heart_rate")
|
|
|
|
|
|
def test_parse_discovery_topic_event() -> None:
|
|
out = parse_discovery_topic(
|
|
"homeassistant/event/wifi_densepose_aabbccddeeff/fall/config"
|
|
)
|
|
assert out == ("event", "aabbccddeeff", "fall")
|
|
|
|
|
|
def test_parse_discovery_topic_returns_none_for_non_discovery() -> None:
|
|
assert parse_discovery_topic("homeassistant/binary_sensor/foo/state") is None
|
|
assert parse_discovery_topic("ruview/aabbccddeeff/raw/edge_vitals") is None
|
|
assert parse_discovery_topic("") is None
|
|
|
|
|
|
# ─── Payload parsing ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_discovery_payload_from_dict() -> None:
|
|
out = parse_discovery_payload(_PRESENCE_TOPIC, _PRESENCE_BODY)
|
|
assert out is not None
|
|
assert out.entity_kind == "binary_sensor"
|
|
assert out.node_id == "aabbccddeeff"
|
|
assert out.object_id == "presence"
|
|
assert out.payload["device_class"] == "occupancy"
|
|
|
|
|
|
def test_parse_discovery_payload_from_bytes() -> None:
|
|
raw = json.dumps(_PRESENCE_BODY).encode("utf-8")
|
|
out = parse_discovery_payload(_PRESENCE_TOPIC, raw)
|
|
assert out is not None
|
|
assert out.payload["unique_id"] == "wifi_densepose_aabbccddeeff_presence"
|
|
|
|
|
|
def test_parse_discovery_payload_from_string() -> None:
|
|
raw = json.dumps(_PRESENCE_BODY)
|
|
out = parse_discovery_payload(_PRESENCE_TOPIC, raw)
|
|
assert out is not None
|
|
assert out.entity_kind == "binary_sensor"
|
|
|
|
|
|
def test_parse_discovery_payload_rejects_malformed_json() -> None:
|
|
assert parse_discovery_payload(_PRESENCE_TOPIC, "{ broken: json") is None
|
|
|
|
|
|
def test_parse_discovery_payload_rejects_non_object_root() -> None:
|
|
assert parse_discovery_payload(_PRESENCE_TOPIC, "[1, 2, 3]") is None
|
|
|
|
|
|
def test_parse_discovery_payload_returns_none_for_non_discovery_topic() -> None:
|
|
assert parse_discovery_payload(
|
|
"ruview/aabbccddeeff/raw/edge_vitals",
|
|
_PRESENCE_BODY,
|
|
) is None
|
|
|
|
|
|
# ─── HaEntity projection ─────────────────────────────────────────────
|
|
|
|
|
|
def test_ha_entity_from_payload_extracts_fields() -> None:
|
|
p = HaDiscoveryPayload(
|
|
entity_kind="sensor",
|
|
node_id="aabbccddeeff",
|
|
object_id="heart_rate",
|
|
payload=_HEART_RATE_BODY,
|
|
)
|
|
e = HaEntity.from_payload(p)
|
|
assert e.entity_kind == "sensor"
|
|
assert e.unique_id == "wifi_densepose_aabbccddeeff_heart_rate"
|
|
assert e.unit_of_measurement == "bpm"
|
|
assert e.icon == "mdi:heart-pulse"
|
|
assert e.json_attributes_topic == _HEART_RATE_BODY["json_attributes_topic"]
|
|
|
|
|
|
def test_ha_entity_handles_missing_optional_fields() -> None:
|
|
p = HaDiscoveryPayload(
|
|
entity_kind="event",
|
|
node_id="aabbccddeeff",
|
|
object_id="bed_exit",
|
|
payload={"unique_id": "wifi_densepose_aabbccddeeff_bed_exit"},
|
|
)
|
|
e = HaEntity.from_payload(p)
|
|
assert e.unique_id == "wifi_densepose_aabbccddeeff_bed_exit"
|
|
assert e.device_class == ""
|
|
assert e.unit_of_measurement == ""
|
|
|
|
|
|
# ─── HABlueprintHelper aggregation ───────────────────────────────────
|
|
|
|
|
|
def _populated_helper() -> HABlueprintHelper:
|
|
h = HABlueprintHelper()
|
|
h.add_payload(_PRESENCE_TOPIC, _PRESENCE_BODY)
|
|
h.add_payload(_HEART_RATE_TOPIC, _HEART_RATE_BODY)
|
|
# Same fields but a different node
|
|
h.add_payload(
|
|
"homeassistant/binary_sensor/wifi_densepose_ff00ff00ff00/presence/config",
|
|
{**_PRESENCE_BODY, "unique_id": "wifi_densepose_ff00ff00ff00_presence"},
|
|
)
|
|
return h
|
|
|
|
|
|
def test_helper_starts_empty() -> None:
|
|
h = HABlueprintHelper()
|
|
assert len(h) == 0
|
|
assert h.nodes() == []
|
|
assert h.all_payloads() == []
|
|
|
|
|
|
def test_helper_aggregates_multiple_payloads() -> None:
|
|
h = _populated_helper()
|
|
assert len(h) == 3
|
|
assert h.nodes() == ["aabbccddeeff", "ff00ff00ff00"]
|
|
|
|
|
|
def test_helper_entities_for_node() -> None:
|
|
h = _populated_helper()
|
|
entities = h.entities_for_node("aabbccddeeff")
|
|
object_ids = sorted(e.object_id for e in entities)
|
|
assert object_ids == ["heart_rate", "presence"]
|
|
|
|
|
|
def test_helper_by_device_class() -> None:
|
|
h = _populated_helper()
|
|
occupancy_entities = h.by_device_class("occupancy")
|
|
assert len(occupancy_entities) == 2 # presence on both nodes
|
|
assert {e.node_id for e in occupancy_entities} == {"aabbccddeeff", "ff00ff00ff00"}
|
|
|
|
|
|
def test_helper_remove() -> None:
|
|
h = _populated_helper()
|
|
assert h.remove("aabbccddeeff", "binary_sensor", "presence") is True
|
|
assert h.remove("aabbccddeeff", "binary_sensor", "presence") is False # no-op
|
|
assert len(h) == 2
|
|
|
|
|
|
def test_helper_rejects_non_discovery_topics() -> None:
|
|
h = HABlueprintHelper()
|
|
ok = h.add_payload("ruview/aabbccddeeff/raw/edge_vitals", _PRESENCE_BODY)
|
|
assert ok is False
|
|
assert len(h) == 0
|
|
|
|
|
|
def test_helper_in_operator() -> None:
|
|
h = _populated_helper()
|
|
assert ("aabbccddeeff", "binary_sensor", "presence") in h
|
|
assert ("nonexistent", "binary_sensor", "presence") not in h
|