feat(adr-117/p4): pure-Python WS/MQTT client layer

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>
This commit is contained in:
ruv
2026-05-24 11:31:29 -04:00
parent 2d29359809
commit f21daf9aa8
10 changed files with 1816 additions and 1 deletions
+205
View File
@@ -0,0 +1,205 @@
"""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
+208
View File
@@ -0,0 +1,208 @@
"""ADR-117 P4 — Tests for RuViewMqttClient.
These tests do NOT bring up a broker — they exercise:
1. Topic-wildcard matching (`_topic_matches`)
2. Client construction + handler registration
3. The callback path by directly invoking the paho callback methods
with synthesized messages
End-to-end broker integration is a P4-followon item (the mosquitto
patterns from memory [[feedback_mqtt_integration_test_patterns]] go
there). This file keeps unit coverage tight without requiring a
broker on every CI run.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
import pytest
from wifi_densepose.client import RuViewMqttClient
from wifi_densepose.client.mqtt import _topic_matches
# ─── Topic wildcard matcher ──────────────────────────────────────────
@pytest.mark.parametrize("pattern,topic,expected", [
("ruview/+/raw/edge_vitals", "ruview/aabb/raw/edge_vitals", True),
("ruview/+/raw/edge_vitals", "ruview/aabb/cooked/edge_vitals", False),
("ruview/+/raw/+", "ruview/aabb/raw/pose", True),
("ruview/+/raw/+", "ruview/aabb/raw/pose/extra", False),
# Per MQTT v5 §4.7.1.2: `+` is a whole-level wildcard only — mid-
# segment `+` is a literal `+` character, not a wildcard. The
# spec-correct way to wildcard the third segment of the HA
# discovery topic is `homeassistant/+/+/+/config`.
("homeassistant/+/+/+/config",
"homeassistant/binary_sensor/wifi_densepose_aabb/presence/config", True),
# `wifi_densepose_+` is therefore NOT a wildcard — it matches the
# literal string only. Asserting that behaviour stays stable.
("homeassistant/+/wifi_densepose_+/+/config",
"homeassistant/binary_sensor/wifi_densepose_aabb/presence/config", False),
("ruview/#", "ruview/aabb/raw/edge_vitals", True),
# Per MQTT v5 §4.7.1.2: `<prefix>/#` ALSO matches the bare
# `<prefix>` itself (it represents "this topic and all sub-topics").
("ruview/#", "ruview", True),
("ruview/+/raw/#", "ruview/aabb/raw/pose/extra", True),
("exact/topic", "exact/topic", True),
("exact/topic", "exact/topic/extra", False),
("a/b/c", "a/b", False),
])
def test_topic_matches(pattern: str, topic: str, expected: bool) -> None:
assert _topic_matches(pattern, topic) is expected
# ─── RuViewMqttClient construction ──────────────────────────────────
def test_client_constructs_with_defaults() -> None:
c = RuViewMqttClient()
assert c.broker_host == "localhost"
assert c.broker_port == 1883
assert c.connected is False
assert c.client_id.startswith("wifi-densepose-client-")
def test_client_unique_client_id_per_instance() -> None:
"""Per the rumqttc memory lesson — each instance needs a unique
client_id so parallel tests don't kick each other off the broker."""
c1 = RuViewMqttClient()
c2 = RuViewMqttClient()
assert c1.client_id != c2.client_id
def test_client_accepts_explicit_client_id() -> None:
c = RuViewMqttClient(client_id="explicit-id")
assert c.client_id == "explicit-id"
# ─── Handler registration ────────────────────────────────────────────
def test_handler_registration_stores_callback() -> None:
c = RuViewMqttClient()
seen: list[Any] = []
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: seen.append((t, p)))
# Internal state — we're allowed to inspect since the handler
# path needs to be unit-testable without a broker.
assert "ruview/+/raw/edge_vitals" in c._handlers
def test_handler_unregister_drops_callback() -> None:
c = RuViewMqttClient()
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: None)
c.unsubscribe_handler("ruview/+/raw/edge_vitals")
assert "ruview/+/raw/edge_vitals" not in c._handlers
# ─── Callback dispatch (synthesized) ─────────────────────────────────
def _fake_message(topic: str, body: Any) -> Any:
"""Synthesize a paho-mqtt MQTTMessage-ish object."""
if isinstance(body, (dict, list)):
payload_bytes = json.dumps(body).encode("utf-8")
elif isinstance(body, bytes):
payload_bytes = body
else:
payload_bytes = str(body).encode("utf-8")
return SimpleNamespace(topic=topic, payload=payload_bytes)
def test_message_dispatch_to_matching_handler() -> None:
c = RuViewMqttClient()
received: list[tuple[str, Any]] = []
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: received.append((t, p)))
msg = _fake_message(
"ruview/aabbccddeeff/raw/edge_vitals",
{"breathing_rate_bpm": 14.0, "heartrate_bpm": 72.0, "presence": True},
)
c._on_message(None, None, msg)
assert len(received) == 1
topic, payload = received[0]
assert topic == "ruview/aabbccddeeff/raw/edge_vitals"
assert payload["breathing_rate_bpm"] == 14.0
def test_message_dispatch_ignores_non_matching_topic() -> None:
c = RuViewMqttClient()
received: list[Any] = []
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: received.append(p))
msg = _fake_message("ruview/aabb/raw/pose", {"persons": []})
c._on_message(None, None, msg)
assert received == []
def test_message_dispatch_falls_back_to_bytes_on_non_json() -> None:
c = RuViewMqttClient()
received: list[Any] = []
c.on_message("custom/binary/+", lambda t, p: received.append(p))
msg = _fake_message("custom/binary/data", b"\x00\x01\x02not-json")
c._on_message(None, None, msg)
assert received == [b"\x00\x01\x02not-json"]
def test_handler_exception_does_not_propagate() -> None:
"""A misbehaving user callback must not crash the paho network
loop — exceptions are caught and logged."""
c = RuViewMqttClient()
seen_after_crash: list[Any] = []
def crashing(_topic: str, _p: Any) -> None:
raise RuntimeError("simulated callback crash")
c.on_message("crashy/topic", crashing)
c.on_message("safe/topic", lambda t, p: seen_after_crash.append(p))
# First, the crashing handler — must NOT raise out of _on_message.
c._on_message(None, None, _fake_message("crashy/topic", "anything"))
# Then the safe handler — must still fire on a subsequent message.
c._on_message(None, None, _fake_message("safe/topic", {"x": 1}))
assert seen_after_crash == [{"x": 1}]
def test_multiple_handlers_for_overlapping_patterns_all_fire() -> None:
c = RuViewMqttClient()
a_received: list[Any] = []
b_received: list[Any] = []
c.on_message("ruview/+/raw/+", lambda t, p: a_received.append(p))
c.on_message("ruview/aabb/raw/edge_vitals", lambda t, p: b_received.append(p))
msg = _fake_message("ruview/aabb/raw/edge_vitals", {"presence": True})
c._on_message(None, None, msg)
assert len(a_received) == 1
assert len(b_received) == 1
# ─── on_connect path ─────────────────────────────────────────────────
def test_on_connect_sets_event_and_subscribes() -> None:
c = RuViewMqttClient()
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: None)
# Stub the paho client so we can capture subscribe() calls.
subscribed: list[str] = []
stub = SimpleNamespace(subscribe=lambda pattern: subscribed.append(pattern))
c._on_connect(stub, None, None, 0)
assert c.connected is True
assert subscribed == ["ruview/+/raw/edge_vitals"]
def test_on_connect_with_nonzero_rc_does_not_set_connected() -> None:
c = RuViewMqttClient()
stub = SimpleNamespace(subscribe=lambda pattern: None)
c._on_connect(stub, None, None, 5) # CONNACK fail
assert c.connected is False
+180
View File
@@ -0,0 +1,180 @@
"""ADR-117 P4 — Tests for the HA-MIND semantic primitive listener.
Pure routing tests — no MQTT broker needed.
"""
from __future__ import annotations
import json
from wifi_densepose.client import (
SemanticPrimitive,
SemanticPrimitiveEvent,
SemanticPrimitiveListener,
)
# ─── SemanticPrimitive enum ──────────────────────────────────────────
def test_enum_covers_all_10_v1_primitives() -> None:
expected = {
"someone_sleeping",
"possible_distress",
"room_active",
"elderly_inactivity",
"meeting_in_progress",
"bathroom_occupied",
"fall_risk_elevated",
"bed_exit",
"no_movement_safety",
"multi_room_transition",
}
actual = {p.value for p in SemanticPrimitive}
assert actual == expected
def test_enum_from_object_id_round_trips() -> None:
for p in SemanticPrimitive:
assert SemanticPrimitive.from_object_id(p.value) is p
def test_enum_from_object_id_returns_none_for_unknown() -> None:
assert SemanticPrimitive.from_object_id("garbage") is None
# ─── Listener routing ────────────────────────────────────────────────
def test_listener_dispatches_to_specific_handler() -> None:
listener = SemanticPrimitiveListener()
received: list[SemanticPrimitiveEvent] = []
listener.on(SemanticPrimitive.SomeoneSleeping, received.append)
evt = listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/someone_sleeping/state",
json.dumps({"state": "ON", "confidence": 0.92, "explanation": ["motion<5%"]}),
)
assert evt is not None
assert evt.kind is SemanticPrimitive.SomeoneSleeping
assert evt.node_id == "aabb"
assert evt.state == "ON"
assert evt.confidence == 0.92
assert evt.explanation == ("motion<5%",)
assert len(received) == 1
assert received[0] is evt
def test_listener_on_any_fires_for_every_primitive() -> None:
listener = SemanticPrimitiveListener()
seen: list[SemanticPrimitiveEvent] = []
listener.on_any(seen.append)
listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
json.dumps({"state": "ON"}),
)
listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/bathroom_occupied/state",
json.dumps({"state": "OFF"}),
)
assert len(seen) == 2
assert seen[0].kind is SemanticPrimitive.RoomActive
assert seen[1].kind is SemanticPrimitive.BathroomOccupied
def test_listener_specific_handler_does_not_fire_for_other_primitives() -> None:
listener = SemanticPrimitiveListener()
received: list[SemanticPrimitiveEvent] = []
listener.on(SemanticPrimitive.PossibleDistress, received.append)
listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/someone_sleeping/state",
json.dumps({"state": "ON"}),
)
assert received == []
def test_listener_decodes_plain_state_string() -> None:
"""HA convention: binary_sensors that don't carry attributes emit
plain strings ('ON' / 'OFF'). We must accept that too."""
listener = SemanticPrimitiveListener()
evt = listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
"ON",
)
assert evt is not None
assert evt.state == "ON"
assert evt.confidence == 0.0 # not provided in plain string
assert evt.explanation == ()
def test_listener_decodes_numeric_sensor_state() -> None:
"""fall_risk_elevated is a 0100 sensor — verify numeric string."""
listener = SemanticPrimitiveListener()
evt = listener.handle_mqtt_message(
"homeassistant/sensor/wifi_densepose_aabb/fall_risk_elevated/state",
"73",
)
assert evt is not None
assert evt.kind is SemanticPrimitive.FallRiskElevated
assert evt.state == "73"
def test_listener_decodes_bytes_payload() -> None:
listener = SemanticPrimitiveListener()
evt = listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/state",
b"ON",
)
assert evt is not None
assert evt.state == "ON"
def test_listener_ignores_non_state_topics() -> None:
listener = SemanticPrimitiveListener()
assert listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/room_active/config",
json.dumps({"name": "Room Active"}),
) is None
def test_listener_ignores_unknown_slug() -> None:
listener = SemanticPrimitiveListener()
assert listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/unknown_primitive/state",
"ON",
) is None
def test_listener_ignores_non_wifi_densepose_node() -> None:
listener = SemanticPrimitiveListener()
# third segment doesn't start with wifi_densepose_
assert listener.handle_mqtt_message(
"homeassistant/binary_sensor/aqara_fp2/room_active/state",
"ON",
) is None
def test_listener_explanation_string_is_normalised_to_tuple() -> None:
"""Producers may send `explanation` as a single string by mistake;
accept that and wrap in a 1-tuple so downstream code can iterate
uniformly."""
listener = SemanticPrimitiveListener()
evt = listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aabb/possible_distress/state",
json.dumps({"state": "ON", "explanation": "HR=120 baseline=80"}),
)
assert evt is not None
assert evt.explanation == ("HR=120 baseline=80",)
def test_event_is_frozen() -> None:
evt = SemanticPrimitiveEvent(
kind=SemanticPrimitive.SomeoneSleeping,
node_id="aabb",
state="ON",
)
import pytest
with pytest.raises((AttributeError, Exception)): # FrozenInstanceError subclass
evt.state = "OFF" # type: ignore[misc]
+195
View File
@@ -0,0 +1,195 @@
"""ADR-117 P4 — End-to-end test for SensingClient against an in-process
WS server.
We spin up a real `websockets.serve()` server in the same event loop,
send the four message types defined in ADR-115 §1, and assert the
client decodes them into the right dataclasses. No mocks — the only
moving part this test does NOT exercise is the actual sensing-server
binary, but the wire protocol is the contract under test here.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
import pytest
import websockets
from wifi_densepose.client import (
ConnectionEstablishedMessage,
EdgeVitalsMessage,
PoseDataMessage,
SensingClient,
SensingMessage,
)
# ─── In-process WS server fixture ────────────────────────────────────
_FIXTURE_MESSAGES = [
{
"type": "connection_established",
"node_id": "test-node-001",
"version": "0.7.4",
"capabilities": ["edge_vitals", "pose_data"],
},
{
"type": "edge_vitals",
"node_id": "test-node-001",
"presence": True,
"fall_detected": False,
"motion": 0.21,
"breathing_rate_bpm": 14.5,
"heartrate_bpm": 72.3,
"n_persons": 1,
"motion_energy": 0.034,
"presence_score": 0.91,
"rssi": -42.0,
},
{
"type": "pose_data",
"node_id": "test-node-001",
"timestamp": 1700000000.5,
"persons": [{"id": 1, "keypoints": []}],
"confidence": 0.88,
},
# Unknown type — should NOT crash the stream; should yield a plain
# SensingMessage.
{
"type": "future_message_type_not_yet_modelled",
"extra": "data",
},
]
async def _handler(websocket: Any) -> None:
for msg in _FIXTURE_MESSAGES:
await websocket.send(json.dumps(msg))
# Send one malformed frame to assert the client logs+drops it
# rather than crashing the stream.
await websocket.send("{not valid json")
# And one final "real" message so the test can confirm the stream
# survived the malformed one.
await websocket.send(json.dumps({"type": "edge_vitals", "node_id": "post-bad-frame"}))
@pytest.fixture
async def ws_server() -> Any:
"""Start a websocket server on a random port; yield the bound URL."""
server = await websockets.serve(_handler, "127.0.0.1", 0)
# Get the bound port (host="127.0.0.1" returns one socket).
port = server.sockets[0].getsockname()[1] # type: ignore[union-attr]
try:
yield f"ws://127.0.0.1:{port}/ws/sensing"
finally:
server.close()
await server.wait_closed()
# ─── End-to-end stream test ──────────────────────────────────────────
async def test_sensing_client_decodes_all_message_types(ws_server: str) -> None:
received: list[SensingMessage] = []
async with SensingClient(ws_server) as client:
async for msg in client.stream():
received.append(msg)
if len(received) >= len(_FIXTURE_MESSAGES) + 1: # +1 for post-bad-frame
break
# connection_established → typed
assert isinstance(received[0], ConnectionEstablishedMessage)
assert received[0].node_id == "test-node-001"
assert received[0].version == "0.7.4"
assert "edge_vitals" in received[0].capabilities
# edge_vitals → typed with full fields
assert isinstance(received[1], EdgeVitalsMessage)
assert received[1].presence is True
assert received[1].fall_detected is False
assert received[1].breathing_rate_bpm == 14.5
assert received[1].heartrate_bpm == 72.3
assert received[1].n_persons == 1
assert received[1].rssi == -42.0
# pose_data → typed
assert isinstance(received[2], PoseDataMessage)
assert received[2].timestamp == 1700000000.5
assert len(received[2].persons) == 1
assert received[2].confidence == 0.88
# Unknown type → plain SensingMessage (forward-compat)
assert type(received[3]) is SensingMessage # exact base class
assert received[3].type == "future_message_type_not_yet_modelled"
assert received[3].raw["extra"] == "data"
# After the malformed frame: the stream should have survived and
# yielded the post-bad-frame message.
assert isinstance(received[4], EdgeVitalsMessage)
assert received[4].node_id == "post-bad-frame"
async def test_sensing_client_recv_one(ws_server: str) -> None:
async with SensingClient(ws_server) as client:
msg = await client.recv_one(timeout=2.0)
assert isinstance(msg, ConnectionEstablishedMessage)
async def test_sensing_client_raises_when_used_without_context() -> None:
client = SensingClient("ws://127.0.0.1:1/") # never connects
with pytest.raises(RuntimeError, match="not connected"):
await client.recv_one(timeout=0.1)
with pytest.raises(RuntimeError, match="not connected"):
async for _ in client.stream():
pass
async def test_sensing_client_close_is_idempotent(ws_server: str) -> None:
client = SensingClient(ws_server)
await client.__aenter__()
await client.close()
await client.close() # second close is a no-op
def test_sensing_client_decoder_directly() -> None:
"""The decoder is pure — exercise it without bringing up a WS
server, so we have a fast unit test for the type mapping."""
from wifi_densepose.client.ws import _decode
msg = _decode(json.dumps({
"type": "edge_vitals",
"node_id": "x",
"presence": True,
"fall_detected": False,
"motion": 1.5,
}))
assert isinstance(msg, EdgeVitalsMessage)
assert msg.presence is True
assert msg.motion == 1.5
assert msg.breathing_rate_bpm is None # not present → None, not 0.0
assert msg.heartrate_bpm is None
assert msg.rssi is None
def test_sensing_client_decoder_handles_None_subfields() -> None:
"""When the sensing-server explicitly emits null for HR/BR (no
measurement yet), the client should propagate None, not crash."""
from wifi_densepose.client.ws import _decode
msg = _decode(json.dumps({
"type": "edge_vitals",
"node_id": "x",
"presence": False,
"fall_detected": False,
"motion": 0.0,
"breathing_rate_bpm": None,
"heartrate_bpm": None,
"rssi": None,
}))
assert isinstance(msg, EdgeVitalsMessage)
assert msg.breathing_rate_bpm is None
assert msg.heartrate_bpm is None
assert msg.rssi is None