chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"""ADR-117 P4 — Asyncio WebSocket client for the sensing-server.
|
||||
|
||||
The Rust sensing-server (`v2/crates/wifi-densepose-sensing-server`)
|
||||
broadcasts three structured message types over `ws://<host>:<port>/ws/sensing`:
|
||||
|
||||
| `type` field | Source line in main.rs | Payload shape |
|
||||
|---|---|---|
|
||||
| `connection_established` | 2596 | `{node_id, version, capabilities}` |
|
||||
| `pose_data` | 2655 | `{node_id, timestamp, persons: [...], confidence}` |
|
||||
| `edge_vitals` | 4548 | `{node_id, presence, fall_detected, motion, breathing_rate_bpm, heartrate_bpm, ...}` |
|
||||
|
||||
`SensingClient` is a pure-Python asyncio wrapper around `websockets>=12`
|
||||
that connects, decodes JSON, and yields typed dataclasses.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from wifi_densepose.client import SensingClient, EdgeVitalsMessage
|
||||
|
||||
async def main():
|
||||
async with SensingClient("ws://localhost:8765/ws/sensing") as client:
|
||||
async for msg in client.stream():
|
||||
if isinstance(msg, EdgeVitalsMessage):
|
||||
print(f"BR={msg.breathing_rate_bpm}, HR={msg.heartrate_bpm}")
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, AsyncIterator, Optional
|
||||
|
||||
# Defer import — only fail at construction time, not at module load.
|
||||
try:
|
||||
import websockets # type: ignore[import-not-found]
|
||||
from websockets.exceptions import ConnectionClosed # type: ignore[import-not-found]
|
||||
_WEBSOCKETS_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover
|
||||
_WEBSOCKETS_AVAILABLE = False
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─── Typed messages ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensingMessage:
|
||||
"""Base class for typed sensing-server messages. The original JSON
|
||||
payload is preserved in ``raw`` for forward-compatibility with
|
||||
fields not yet modelled here."""
|
||||
type: str
|
||||
raw: dict[str, Any] = field(default_factory=dict, hash=False, compare=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConnectionEstablishedMessage(SensingMessage):
|
||||
"""First message after a successful WS handshake. Lets the client
|
||||
discover the node ID and capability flags without making a separate
|
||||
REST call."""
|
||||
node_id: str = ""
|
||||
version: str = ""
|
||||
capabilities: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EdgeVitalsMessage(SensingMessage):
|
||||
"""Vital-sign telemetry fused from the edge-vitals path
|
||||
(ADR-021/ADR-110). Optional fields may be ``None`` when the
|
||||
upstream channel hasn't produced a measurement yet."""
|
||||
node_id: str = ""
|
||||
presence: bool = False
|
||||
fall_detected: bool = False
|
||||
motion: float = 0.0
|
||||
breathing_rate_bpm: Optional[float] = None
|
||||
heartrate_bpm: Optional[float] = None
|
||||
n_persons: int = 0
|
||||
motion_energy: float = 0.0
|
||||
presence_score: float = 0.0
|
||||
rssi: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PoseDataMessage(SensingMessage):
|
||||
"""17-keypoint pose data broadcast at the sensing-server's frame
|
||||
cadence. Persons are a list of opaque dicts — typed PoseEstimate
|
||||
decoding lives in the P2 bindings; the WS client passes through."""
|
||||
node_id: str = ""
|
||||
timestamp: float = 0.0
|
||||
persons: tuple[dict[str, Any], ...] = ()
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
# ─── Decoder ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _decode(raw_text: str) -> SensingMessage:
|
||||
"""Decode a single WS frame into a typed message.
|
||||
|
||||
Unknown ``type`` values yield a plain ``SensingMessage`` rather
|
||||
than raising — the sensing-server is on a faster release cadence
|
||||
than this client, and unknown types should not break the stream.
|
||||
"""
|
||||
obj = json.loads(raw_text)
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError(f"sensing-server emitted non-dict payload: {type(obj).__name__}")
|
||||
mtype = obj.get("type", "")
|
||||
if mtype == "connection_established":
|
||||
return ConnectionEstablishedMessage(
|
||||
type=mtype,
|
||||
raw=obj,
|
||||
node_id=obj.get("node_id", ""),
|
||||
version=obj.get("version", ""),
|
||||
capabilities=tuple(obj.get("capabilities", ())),
|
||||
)
|
||||
if mtype == "edge_vitals":
|
||||
return EdgeVitalsMessage(
|
||||
type=mtype,
|
||||
raw=obj,
|
||||
node_id=obj.get("node_id", ""),
|
||||
presence=bool(obj.get("presence", False)),
|
||||
fall_detected=bool(obj.get("fall_detected", False)),
|
||||
motion=float(obj.get("motion", 0.0)),
|
||||
breathing_rate_bpm=(
|
||||
float(obj["breathing_rate_bpm"])
|
||||
if obj.get("breathing_rate_bpm") is not None else None
|
||||
),
|
||||
heartrate_bpm=(
|
||||
float(obj["heartrate_bpm"])
|
||||
if obj.get("heartrate_bpm") is not None else None
|
||||
),
|
||||
n_persons=int(obj.get("n_persons", 0)),
|
||||
motion_energy=float(obj.get("motion_energy", 0.0)),
|
||||
presence_score=float(obj.get("presence_score", 0.0)),
|
||||
rssi=(float(obj["rssi"]) if obj.get("rssi") is not None else None),
|
||||
)
|
||||
if mtype == "pose_data":
|
||||
persons = obj.get("persons", ())
|
||||
return PoseDataMessage(
|
||||
type=mtype,
|
||||
raw=obj,
|
||||
node_id=obj.get("node_id", ""),
|
||||
timestamp=float(obj.get("timestamp", 0.0)),
|
||||
persons=tuple(persons) if isinstance(persons, list) else (),
|
||||
confidence=float(obj.get("confidence", 0.0)),
|
||||
)
|
||||
return SensingMessage(type=mtype, raw=obj)
|
||||
|
||||
|
||||
# ─── Client ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SensingClient:
|
||||
"""Asyncio WebSocket client for the RuView sensing-server.
|
||||
|
||||
Usage as async context manager:
|
||||
|
||||
```python
|
||||
async with SensingClient("ws://localhost:8765/ws/sensing") as c:
|
||||
async for msg in c.stream():
|
||||
...
|
||||
```
|
||||
|
||||
The client does NOT auto-reconnect — if you want resilience, wrap
|
||||
the ``async with`` in your own retry loop. Auto-reconnect logic is
|
||||
application-specific (e.g., "retry forever" for a long-running
|
||||
automation vs "fail fast" for a CLI tool that should exit).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
ping_interval: float = 20.0,
|
||||
ping_timeout: float = 20.0,
|
||||
max_size: int = 16 * 1024 * 1024,
|
||||
) -> None:
|
||||
if not _WEBSOCKETS_AVAILABLE:
|
||||
raise ImportError(
|
||||
"SensingClient requires the `websockets` package. Install with "
|
||||
"`pip install \"wifi-densepose[client]\"` to enable the client extras."
|
||||
)
|
||||
self.url = url
|
||||
self._ping_interval = ping_interval
|
||||
self._ping_timeout = ping_timeout
|
||||
self._max_size = max_size
|
||||
self._ws: Any = None # websockets.WebSocketClientProtocol — typed Any to avoid import cost
|
||||
|
||||
async def __aenter__(self) -> "SensingClient":
|
||||
self._ws = await websockets.connect(
|
||||
self.url,
|
||||
ping_interval=self._ping_interval,
|
||||
ping_timeout=self._ping_timeout,
|
||||
max_size=self._max_size,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Idempotent connection close."""
|
||||
if self._ws is not None:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception as e: # pragma: no cover — best-effort close
|
||||
log.debug("ignored WS close error: %r", e)
|
||||
self._ws = None
|
||||
|
||||
async def stream(self) -> AsyncIterator[SensingMessage]:
|
||||
"""Yield typed messages until the server closes the connection
|
||||
or the context is exited.
|
||||
|
||||
Decode failures on individual frames are logged at WARN and
|
||||
swallowed — a malformed frame should not terminate the stream
|
||||
(the next frame may be fine)."""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("SensingClient not connected. Use `async with` first.")
|
||||
try:
|
||||
async for frame in self._ws:
|
||||
if isinstance(frame, bytes):
|
||||
frame = frame.decode("utf-8", errors="replace")
|
||||
try:
|
||||
yield _decode(frame)
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
log.warning("dropping malformed sensing-server frame: %r", e)
|
||||
except ConnectionClosed:
|
||||
# Graceful EOF — exit the iterator normally.
|
||||
return
|
||||
|
||||
async def send_ping(self) -> None:
|
||||
"""Send an application-level ping. The sensing-server replies
|
||||
with `{"type": "pong"}` (main.rs:2698)."""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("SensingClient not connected. Use `async with` first.")
|
||||
await self._ws.send(json.dumps({"type": "ping"}))
|
||||
|
||||
async def recv_one(self, *, timeout: Optional[float] = None) -> SensingMessage:
|
||||
"""Receive a single decoded message. Convenience for short
|
||||
scripts and tests that don't need an async generator."""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("SensingClient not connected. Use `async with` first.")
|
||||
if timeout is None:
|
||||
frame = await self._ws.recv()
|
||||
else:
|
||||
frame = await asyncio.wait_for(self._ws.recv(), timeout=timeout)
|
||||
if isinstance(frame, bytes):
|
||||
frame = frame.decode("utf-8", errors="replace")
|
||||
return _decode(frame)
|
||||
Reference in New Issue
Block a user