mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01:42 +00:00
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:
@@ -0,0 +1,194 @@
|
||||
"""ADR-117 P4 — Home Assistant MQTT-discovery payload helpers.
|
||||
|
||||
Parses the `homeassistant/<entity_kind>/wifi_densepose_<node>/<id>/config`
|
||||
discovery payloads described in ADR-115 §3 into typed Python objects so
|
||||
client code can introspect what a node is publishing without
|
||||
hand-parsing JSON.
|
||||
|
||||
This is **read-only**: we do NOT generate discovery payloads from
|
||||
Python (that's the sensing-server's job). The helper exists so a
|
||||
client (HA blueprint author, debugger, dashboard) can ask "what
|
||||
entities does this node expose?" and get a structured answer.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
from wifi_densepose.client import HaDiscoveryPayload, HABlueprintHelper
|
||||
|
||||
helper = HABlueprintHelper()
|
||||
helper.add_payload(topic, json_bytes)
|
||||
for entity in helper.entities_for_node("aabbccddeeff"):
|
||||
print(entity.entity_kind, entity.object_id, entity.unique_id)
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
# ─── Topic schema ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# Matches discovery topics like:
|
||||
# homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/config
|
||||
# homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/config
|
||||
# homeassistant/event/wifi_densepose_aabbccddeeff/fall/config
|
||||
_DISCOVERY_TOPIC_RE = re.compile(
|
||||
r"^homeassistant/"
|
||||
r"(?P<entity_kind>[A-Za-z_]+)/"
|
||||
r"wifi_densepose_(?P<node_id>[A-Za-z0-9]+)/"
|
||||
r"(?P<object_id>[A-Za-z0-9_\-]+)/"
|
||||
r"config$"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HaDiscoveryPayload:
|
||||
"""One MQTT discovery payload (config topic + JSON body)."""
|
||||
entity_kind: str # "binary_sensor", "sensor", "event", "switch", ...
|
||||
node_id: str # the node's MAC-ish identifier
|
||||
object_id: str # entity slug (e.g. "presence", "heart_rate")
|
||||
payload: dict[str, Any]
|
||||
|
||||
@property
|
||||
def topic(self) -> str:
|
||||
return (
|
||||
f"homeassistant/{self.entity_kind}/"
|
||||
f"wifi_densepose_{self.node_id}/{self.object_id}/config"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HaEntity:
|
||||
"""A user-facing view of one HA entity registered by a node."""
|
||||
entity_kind: str
|
||||
node_id: str
|
||||
object_id: str
|
||||
unique_id: str = ""
|
||||
name: str = ""
|
||||
state_topic: str = ""
|
||||
device_class: str = ""
|
||||
unit_of_measurement: str = ""
|
||||
icon: str = ""
|
||||
json_attributes_topic: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, p: HaDiscoveryPayload) -> "HaEntity":
|
||||
body = p.payload
|
||||
return cls(
|
||||
entity_kind=p.entity_kind,
|
||||
node_id=p.node_id,
|
||||
object_id=p.object_id,
|
||||
unique_id=str(body.get("unique_id", "")),
|
||||
name=str(body.get("name", "")),
|
||||
state_topic=str(body.get("state_topic", "")),
|
||||
device_class=str(body.get("device_class", "")),
|
||||
unit_of_measurement=str(body.get("unit_of_measurement", "")),
|
||||
icon=str(body.get("icon", "")),
|
||||
json_attributes_topic=str(body.get("json_attributes_topic", "")),
|
||||
)
|
||||
|
||||
|
||||
def parse_discovery_topic(topic: str) -> tuple[str, str, str] | None:
|
||||
"""Parse a discovery config topic into (entity_kind, node_id,
|
||||
object_id). Returns None for non-discovery topics."""
|
||||
m = _DISCOVERY_TOPIC_RE.match(topic)
|
||||
if not m:
|
||||
return None
|
||||
return (m.group("entity_kind"), m.group("node_id"), m.group("object_id"))
|
||||
|
||||
|
||||
def parse_discovery_payload(
|
||||
topic: str, payload: bytes | str | dict[str, Any]
|
||||
) -> HaDiscoveryPayload | None:
|
||||
"""Decode an HA discovery payload. Returns None for non-discovery
|
||||
topics OR malformed JSON; raises only on programmer error."""
|
||||
parsed = parse_discovery_topic(topic)
|
||||
if parsed is None:
|
||||
return None
|
||||
entity_kind, node_id, object_id = parsed
|
||||
body: dict[str, Any]
|
||||
if isinstance(payload, dict):
|
||||
body = payload
|
||||
else:
|
||||
if isinstance(payload, bytes):
|
||||
try:
|
||||
payload = payload.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
try:
|
||||
decoded = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(decoded, dict):
|
||||
return None
|
||||
body = decoded
|
||||
return HaDiscoveryPayload(
|
||||
entity_kind=entity_kind,
|
||||
node_id=node_id,
|
||||
object_id=object_id,
|
||||
payload=body,
|
||||
)
|
||||
|
||||
|
||||
# ─── Helper / aggregator ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class HABlueprintHelper:
|
||||
"""Aggregates HA discovery payloads observed on the bus and offers
|
||||
structured queries against them.
|
||||
|
||||
Intended use: subscribe a RuViewMqttClient to
|
||||
`homeassistant/+/wifi_densepose_+/+/config`, feed every message
|
||||
into `add_payload()`, then ask the helper "what entities does
|
||||
node X expose?" or "what binary_sensors are presence-class?".
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# (node_id, entity_kind, object_id) → HaDiscoveryPayload
|
||||
self._payloads: dict[tuple[str, str, str], HaDiscoveryPayload] = {}
|
||||
|
||||
def add_payload(self, topic: str, payload: bytes | str | dict[str, Any]) -> bool:
|
||||
"""Returns True if the payload was a valid HA discovery
|
||||
message and was stored; False otherwise."""
|
||||
parsed = parse_discovery_payload(topic, payload)
|
||||
if parsed is None:
|
||||
return False
|
||||
self._payloads[(parsed.node_id, parsed.entity_kind, parsed.object_id)] = parsed
|
||||
return True
|
||||
|
||||
def remove(self, node_id: str, entity_kind: str, object_id: str) -> bool:
|
||||
"""Drop a stored payload — useful when handling a discovery
|
||||
retain-flag clear (HA's convention for removing an entity)."""
|
||||
return self._payloads.pop((node_id, entity_kind, object_id), None) is not None
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._payloads)
|
||||
|
||||
def __contains__(self, item: tuple[str, str, str]) -> bool:
|
||||
return item in self._payloads
|
||||
|
||||
def all_payloads(self) -> list[HaDiscoveryPayload]:
|
||||
return list(self._payloads.values())
|
||||
|
||||
def entities_for_node(self, node_id: str) -> list[HaEntity]:
|
||||
return [
|
||||
HaEntity.from_payload(p)
|
||||
for p in self._payloads.values()
|
||||
if p.node_id == node_id
|
||||
]
|
||||
|
||||
def nodes(self) -> list[str]:
|
||||
return sorted({p.node_id for p in self._payloads.values()})
|
||||
|
||||
def by_device_class(self, device_class: str) -> list[HaEntity]:
|
||||
out: list[HaEntity] = []
|
||||
for p in self._payloads.values():
|
||||
e = HaEntity.from_payload(p)
|
||||
if e.device_class == device_class:
|
||||
out.append(e)
|
||||
return out
|
||||
Reference in New Issue
Block a user