9740bc64c9
Firmware QEMU Tests (ADR-061) / QEMU Test (edge-tier1) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (full-adr060) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (tdm-3node) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / Swarm Test (ADR-062) (push) Has been skipped
npm packages / tools/ruview-mcp (node 22) (push) Failing after 1s
nvsim-server → ghcr.io / build-and-publish (push) Failing after 1s
ruview-swarm CI guard / tests (full+train) (push) Failing after 2s
Bench Regression Guard / bench compile-verify (--no-run) (push) Failing after 0s
Bench Regression Guard / bench fast-run (informational, non-gating) (push) Has been skipped
Firmware CI / Verify version.txt matches release tag (push) Has been skipped
Dashboard a11y + cross-browser / a11y (push) Failing after 0s
nvsim Dashboard → GitHub Pages / build-and-deploy (push) Failing after 2s
Firmware CI / Build firmware (esp32s3 / 4mb) (push) Failing after 15s
Firmware QEMU Tests (ADR-061) / Build Espressif QEMU (push) Failing after 1s
Firmware QEMU Tests (ADR-061) / Fuzz Testing (ADR-061 Layer 6) (push) Failing after 1s
Continuous Deployment / Pre-deployment Checks (push) Has been skipped
Firmware CI / Build firmware (esp32c6 / c6-4mb) (push) Failing after 15s
Firmware CI / Build firmware (esp32s3 / 8mb) (push) Failing after 15s
Firmware QEMU Tests (ADR-061) / QEMU Test (boundary-max) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (boundary-min) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (default) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (edge-tier0) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / NVS Matrix Generation (push) Failing after 1s
Security Scanning / Security Policy Compliance (push) Failing after 0s
Security Scanning / Dependency Vulnerability Scan (push) Failing after 0s
Security Scanning / Static Application Security Testing (push) Failing after 1s
Security Scanning / Infrastructure Security Scan (push) Failing after 1s
Security Scanning / Secret Scanning (push) Failing after 1s
npm packages / harness/ruview (node 22) (push) Failing after 17s
Security Scanning / License Compliance Scan (push) Failing after 1s
Security Scanning / Container Security Scan (push) Failing after 4s
three.js demos → GitHub Pages / build-and-deploy (push) Failing after 1s
Verify Pipeline Determinism / Verify Pipeline Determinism (3.11) (push) Failing after 1s
Fix-Marker Regression Guard / Verify fix markers (push) Failing after 1s
ADR-115 MQTT integration tests / mqtt-integration (push) Failing after 1s
npm packages / harness/ruview (node 20) (push) Failing after 1s
npm packages / tools/ruview-mcp (node 20) (push) Failing after 1s
npm packages / tools/ruview-cli (node 20) (push) Failing after 1s
npm packages / tools/ruview-cli (node 22) (push) Failing after 1s
BFLD MQTT Integration / cargo test --features mqtt (live mosquitto) (push) Failing after 29s
ruview-swarm CI guard / build train_marl bin (push) Failing after 2s
ruview-swarm CI guard / clippy (-D warnings, --no-deps) (push) Failing after 3s
ruview-swarm CI guard / tests (ruflo) (push) Failing after 1s
ruview-swarm CI guard / tests (train) (push) Failing after 2s
ruview-swarm CI guard / tests (default) (push) Failing after 2s
Point Cloud Viewer → GitHub Pages / build-and-deploy (push) Failing after 8s
ruview-swarm CI guard / ITAR / publish guard (push) Failing after 0s
wifi-densepose sensing-server → Docker Hub + ghcr.io / build · push · smoke-test (push) Failing after 1s
Continuous Deployment / Deploy to Production (push) Has been cancelled
Continuous Deployment / Rollback Deployment (push) Has been cancelled
Continuous Deployment / Post-deployment Monitoring (push) Has been cancelled
Continuous Deployment / Notify Deployment Status (push) Has been cancelled
Continuous Deployment / Deploy to Staging (push) Has been cancelled
Security Scanning / Security Report (push) Has been cancelled
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
|