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>
181 lines
5.9 KiB
Python
181 lines
5.9 KiB
Python
"""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 0–100 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]
|