mirror of
https://github.com/ruvnet/RuView
synced 2026-06-20 12:03: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>
195 lines
6.7 KiB
Python
195 lines
6.7 KiB
Python
"""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
|