feat(adr-117): pip wifi-densepose modernization (PIP-PHOENIX) + ruview sibling release (#786)

* docs(adr-117): seed branch — ADR-117 pip-modernization spec + soul-signature research bundle

Two artifacts landing together on this new branch as the prerequisite
documentation for the v2.0.0 Python wheel modernization work:

1. **docs/adr/ADR-117-pip-wifi-densepose-modernization.md** (644 lines)
   — Plan to bring the 2025-published `wifi-densepose` PyPI package
   (last release v1.1.0, 2025-06-07, 11.5 months out of sync) up to
   the current Rust v2/ workspace SOTA. Recommends PyO3 + maturin
   with abi3-py310 (one binary covers Python 3.10–3.13 per OS/arch),
   first-wheel scope = core + vitals + signal crates (~5 MB), v1.99.0
   tombstone + 90-day un-yank window for v1.1.0, v2.0.0 hard break.
   Open questions catalogued; phases P1–P6+ laid out with concrete
   acceptance criteria.

2. **docs/research/soul/** (5 files, ~1,450 lines) — Soul Signature
   research spec: 7-channel electromagnetic biometric fingerprint
   (AETHER 128-dim + cardiac HR/HRV + cardiac waveform morphology +
   respiratory pattern + gait timing + skeletal proportions +
   subcarrier reflection profile), fused into one RVF graph file.
   Includes 60s scanning protocol, 5-layer security model,
   threat-model + mitigations, references to existing ADRs (014,
   021, 024, 027, 030, 039, 079, 106, 108, 109, 110, 115). Marked
   "Research Specification (Pre-Implementation)". Explicit "what
   this is NOT" disclaimers preempt pseudoscience drift; every
   discriminative-power claim either cites a measurement or is
   marked "open research; baseline TBD".

Branch off main at HEAD; ready for /loop 10m implementation
iterations.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-117/p1): scaffold python/ workspace — PyO3 + maturin + smoke tests (refs #785)

ADR-117 P1 — the python/ directory is now a working maturin-buildable
crate that produces the v2.x replacement for the legacy pure-Python
wifi-densepose==1.1.0 PyPI wheel.

## What lands

- `python/Cargo.toml` — PyO3 0.22 with `extension-module` + `abi3-py310`
  (one binary covers Python 3.10–3.13 per OS/arch — keeps the
  cibuildwheel matrix to 5 wheels per release, not 20). Depends on
  `wifi-densepose-core` from the existing v2/ workspace via relative
  path.

- `python/pyproject.toml` — maturin>=1.7 build backend with
  `python-source = "python"` and `module-name = "wifi_densepose._native"`
  so the compiled module loads as an internal underscore-private
  submodule of the user-facing `wifi_densepose` package. PEP 621
  metadata + classifiers + project URLs. Optional-deps:
  `wifi-densepose[client]` for the P4 WS/MQTT pure-Python layer,
  `wifi-densepose[dev]` for the test toolchain (pytest, ruff, mypy).

- `python/src/lib.rs` — minimal `#[pymodule] wifi_densepose_native`
  exporting `__rust_version__`, `__rust_build_tag__`,
  `__build_features__`, and a `hello()` smoke function. P2 will land
  the core type bindings here.

- `python/wifi_densepose/__init__.py` — pure-Python facade re-exporting
  the compiled module's symbols under their stable user-facing names.
  Docstring teaches the v1→v2 migration story up-front.

- `python/wifi_densepose/py.typed` — PEP 561 marker so `mypy --strict`
  in user code treats the wheel as fully typed (real stubs land in P2).

- `python/tests/test_smoke.py` — 6 P1 acceptance tests:
  1. package imports without error
  2. version string is PEP 440-compliant
  3. `__rust_version__` is reachable from Python (the diagnostic
     surface ADR-117 §5.2 promised)
  4. `__build_features__` lists `p1-scaffold` marker
  5. `wifi_densepose.hello()` returns "ok" (FFI round-trip)
  6. `wifi_densepose._native` is reachable but the leading underscore
     conveys "private; users should import the parent package"

- `python/README.md` — phase ledger, local build instructions
  (`maturin develop`), layout diagram.

## What's deferred to P2+

- Core type bindings (`CsiFrame`, `Keypoint`, `PoseEstimate`) — P2
- Vitals + signal DSP bindings + witness v2 — P3
- Pure-Python WS/MQTT client layer (`wifi_densepose[client]`) — P4
- cibuildwheel + PyPI publish — P5
- v1.99.0 tombstone — concurrent with P5

The new `python/` crate is intentionally OUTSIDE the v2/ Cargo
workspace — it has its own Cargo.toml with `[package]` not
`[workspace.package]` inheritance — to keep maturin's `python-source`
+ `module-name` config self-contained and to avoid forcing every
`cargo test --workspace` invocation in v2/ to compile pyo3.

Refs ADR-117 §5 (Detailed design) and §6 (Phased migration).
Refs #785 (tracking issue).

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(adr-117/p1): standalone Cargo.toml + python-source=. + #[pyo3(name=_native)] (P1 GREEN)

Three fixes to make maturin develop actually work locally:

1. `python/Cargo.toml` removed `*.workspace = true` inheritance —
   the python/ crate is intentionally outside the v2/ workspace
   (ADR-117 §5.2) so it needs every `[package]` field local.

2. `python/pyproject.toml` `python-source = "python"` was wrong
   because pyproject.toml lives at python/ — maturin was looking for
   python/python/. Changed to `python-source = "."` so the
   `wifi_densepose/` package directory sibling-to-pyproject is found.

3. `python/src/lib.rs` `#[pymodule] fn wifi_densepose_native` →
   `#[pymodule] #[pyo3(name = "_native")] fn wifi_densepose_native`.
   PyO3 generates `PyInit__native` from the pyo3-name attribute, which
   must match the `module-name` in pyproject.toml's [tool.maturin]
   block ("wifi_densepose._native"). Without this attribute the wheel
   builds but `import wifi_densepose._native` fails with
   ModuleNotFoundError.

## Local validation (P1 acceptance gate)

```
$ python -m venv .venv && .venv/Scripts/python -m pip install maturin pytest
$ VIRTUAL_ENV=… maturin develop --release
…
    Finished `release` profile [optimized] target(s)
📦 Built wheel for abi3 Python ≥ 3.10
🛠 Installed wifi-densepose-2.0.0a1

$ .venv/Scripts/python -c 'import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__, wifi_densepose.hello())'
2.0.0a1 2.0.0-alpha.1 ok

$ .venv/Scripts/python -m pytest tests/ -v
tests/test_smoke.py::test_package_imports PASSED
tests/test_smoke.py::test_version_string_well_formed PASSED
tests/test_smoke.py::test_rust_version_surfaced PASSED
tests/test_smoke.py::test_build_features_listed PASSED
tests/test_smoke.py::test_hello_returns_ok PASSED
tests/test_smoke.py::test_native_module_private PASSED
======================== 6 passed in 0.05s =========================
```

P1 closed. Moving to P2 (core type bindings).

Refs #785, ADR-117 §6.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-117/p2): Keypoint + KeypointType bindings — 23 new tests (29/29 GREEN)

Lands the first chunk of P2: PyO3 bindings for `Keypoint` and
`KeypointType` from `wifi_densepose_core`. Bound types surface to
Python as `wifi_densepose.Keypoint` / `wifi_densepose.KeypointType`.

## Design choices that affect the API surface

1. **`Confidence` is NOT bound as a separate class.** Users hate
   wrapping a float in a constructor. Python-side, confidence is just
   a `float in [0.0, 1.0]`; the binding validates on construction
   (`ValueError` for out-of-range, matching the Rust core error).

2. **`KeypointType` is a `#[pyclass(eq, eq_int, hash, frozen)]` enum**
   — hashable so users can drop it into dicts/sets (the most common
   pattern in pose-analysis notebooks: `keypoints_by_type[k.type] = k`).

3. **`Keypoint.__init__` keyword-only `z`** so 2D users don't have to
   write `None` and 3D users get a clear named arg:
   `Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)`.

4. **`Keypoint` is `#[pyclass(frozen)]`** — no in-place mutation. The
   Rust core type is immutable through Copy + Hash + Eq, and exposing
   setters from Python would create a copy-vs-reference inconsistency
   between languages.

## Files

- `python/src/bindings/keypoint.rs` — 220 lines of `#[pymethods]`
  wrappers + Rust↔Python enum round-trip
- `python/src/lib.rs` — `mod bindings { pub mod keypoint; }` +
  `bindings::keypoint::register(m)?` call from `#[pymodule]`
- `python/wifi_densepose/__init__.py` — re-exports `Keypoint` and
  `KeypointType` at the package root
- `python/tests/test_keypoint.py` — 23 tests covering:
  - 17-element COCO ordering of `KeypointType.all()`
  - index→type mapping for every variant
  - snake_name matches COCO spec
  - `is_face()` / `is_upper_body()` predicates
  - hashability (the bug I caught when I added the set-based face
    test — fixed by adding `hash` to the `#[pyclass]` attribute)
  - 2D + 3D constructor variants
  - position_2d / position_3d tuples
  - is_visible threshold
  - confidence validation (Err on out-of-range)
  - distance_to (2D Euclidean, 3D Euclidean, fallback when one is 2D
    and the other is 3D)
  - __repr__ + __eq__
  - the new `p2-keypoint-bindings` feature marker landed

## Local validation

\`\`\`
$ cd python && .venv/Scripts/python -m pytest tests/ -v
tests/test_smoke.py::test_package_imports PASSED
tests/test_smoke.py::test_version_string_well_formed PASSED
tests/test_smoke.py::test_rust_version_surfaced PASSED
tests/test_smoke.py::test_build_features_listed PASSED
tests/test_smoke.py::test_hello_returns_ok PASSED
tests/test_smoke.py::test_native_module_private PASSED
tests/test_keypoint.py::test_keypoint_type_all_returns_17 PASSED
…
======================== 29 passed in 0.06s =========================
\`\`\`

Wheel size after both bindings: still well under the 5 MB ADR §5.4
budget (release build with --strip on Windows: ~340 KB).

Also adds `python/.gitignore` to prevent the `.venv/` + `target/` +
`_native.abi3.pyd` artifacts from getting committed.

## What's left in P2

CsiFrame + PoseEstimate bindings land in the next iteration. They're
larger (CsiFrame has the subcarrier buffer; PoseEstimate has
17×Keypoint + BoundingBox + track_id + score). Pattern is now proven
so they go faster.

Refs #785, ADR-117 §6.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-117/p2): BoundingBox + PersonPose + PoseEstimate — P2 COMPLETE (57/57 tests GREEN)

Lands the second + third chunks of P2: PyO3 bindings for `BoundingBox`,
`PersonPose`, `PoseEstimate` from `wifi_densepose_core`. Combined with
the prior Keypoint + KeypointType bindings (fd0568caa), this closes
ADR-117 §6 P2.

## Coverage

| Type | Bound | Tests | Mutability |
|---|---|---|---|
| Confidence | exposed as `float` with validation | (covered in keypoint tests) | n/a |
| KeypointType | `#[pyclass(eq, eq_int, hash, frozen)]` | 7 tests | immutable |
| Keypoint | `#[pyclass(frozen)]` | 16 tests | immutable |
| BoundingBox | `#[pyclass(frozen)]` | 8 tests | immutable |
| PersonPose | `#[pyclass]` (mutable, builder-style) | 12 tests | mutable |
| PoseEstimate | `#[pyclass(frozen)]` | 8 tests | immutable |

Smoke (P1) + new tests: **57/57 PASS** locally on Windows.

## What's deferred to P3

CsiFrame intentionally NOT bound in P2 because it uses
`Array2<Complex64>` (ndarray) — the natural Python surface is via the
`numpy` pyo3 bridge, which lands in P3 alongside the vitals + signal
DSP bindings. Binding CsiFrame without numpy interop would force
users to materialise lists of tuples which is a worse API than
`csi_frame.amplitude_array()` returning an ndarray.

## Design choices that affect the API surface

1. **PersonPose.keypoints() returns a dict keyed by KeypointType**
   instead of a fixed-length list with None slots. Pythonistas don't
   want to know the underlying storage is `[Option<Keypoint>; 17]`.

2. **PoseEstimate.id and .timestamp exposed as strings** (UUID + ISO)
   rather than as bound `FrameId` / `Timestamp` types. Users in
   notebooks rarely compare UUIDs structurally; strings are good
   enough for diagnostics and don't bloat the bindings.

3. **PersonPose is MUTABLE** (`#[pyclass]` without `frozen`) so users
   can build poses incrementally with `set_keypoint`/`set_bbox`/
   `set_id`. PoseEstimate is `frozen` because once constructed it
   represents a snapshot.

## Three PyO3 0.22 gotchas surfaced this iteration

1. `#[pymethods]` getters are NOT accessible from other Rust modules
   — need a separate `impl PyKeypoint { pub(crate) fn inner(&self)
   -> &Keypoint { ... } }` block for cross-module use.

2. `PyDict::new(py)` was removed in PyO3 0.21 → 0.22 in favour of
   `PyDict::new_bound(py)`. (Confusing because `Bound<'py, PyDict>`
   is the return type either way.)

3. `dict.set_item(K, V)` requires both K and V to impl
   `ToPyObject`. `#[pyclass]` types impl `IntoPy<PyObject>` but NOT
   `ToPyObject` — workaround: convert via `.into_py(py)` first, then
   `set_item(py_object_k, py_object_v)`.

Saved as PyO3 0.22 binding patterns memory at the horizon-tracker
level so future loop workers don't re-learn them.

## Local validation

\`\`\`
$ cd python && .venv/Scripts/python -m pytest tests/ -v
…
======================== 57 passed in 0.24s =========================
\`\`\`

Wheel size: still ~340 KB on Windows release build.

Refs #785, ADR-117 §6 (P2 done — ready for P3 vitals + signal DSP +
numpy bridge + witness v2).

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-117): add BFLD support (§5.7a + P3.5 phase + §11.11/12 open questions)

Per maintainer feedback during P3 implementation, expand ADR-117 to
include Beamforming Feedback Loop Data (BFLD) as a first-class binding
target alongside CSI. BFLD is the transmitter-side, AP-station-loop
view of the WiFi channel (802.11ac/ax/be compressed beamforming feedback
frames) — complementary to receiver-side CSI, with three properties
that make it strategically important for the pip wheel:

1. **Up to 996 subcarriers per HE160 frame** (vs 242 for HE-LTF CSI on
   ESP32-C6, vs 52 for HT-LTF on ESP32-S3) — much denser per-subcarrier
   reflection profile
2. **Works on stock 802.11ac+ hardware** — no Nexmon patch, no ESP32
   monitor mode, no firmware drift. Captured via tcpdump/Wireshark +
   BFR dissector, or via `mac80211` debugfs on Linux 6.10+
3. **Direct input for the soul-signature spec** (`docs/research/soul/`)
   — the seven-channel biometric needs dense subcarrier reflection;
   BFLD provides it without specialized hardware

## Three additions to ADR-117

### §5.7a — New binding-target subsection
Comparison table CSI vs BFLD; binding strategy with forward-compat
stub Rust impl pending the future `wifi-densepose-bfld` crate; the
three Python types that ship in P3.5:

- `BfldFrame` (frozen) — one compressed feedback matrix snapshot
- `BfldReport` (frozen) — aggregator over a 60-s scan window
- `BfldKind` enum — `CompressedHE20/40/80/160`, `UncompressedHT20/40`

### §6 P3.5 — Concurrent-with-P3 phase
Checkbox plan for the bindings module + stub Rust storage + numpy
bridge for `feedback_matrix` (Complex64 ndarray, same approach as
`CsiFrame.amplitude` from P3). Lands in the same wheel as P3, no
schedule cushion needed.

### §11.11/12 — Two new open questions
- **§11.11** — Should the future BFR ingestion Rust crate be a new
  `wifi-densepose-bfld` workspace member, or extend `-signal`?
  *Tentative: new dedicated crate. Wireshark BFR dissector is ~2k
  lines and would bloat `-signal`; ingestion is optional for many
  deployments; keep `-signal` lean.*
- **§11.12** — Per-vendor BFR variant compatibility (Broadcom vs
  Intel vs Qualcomm vs MediaTek differ in psi/phi quantization +
  matrix entry ordering). How much normalisation in the Python
  binding vs. the future Rust crate? *Tentative: Python binding is
  dumb (numpy ndarray in/out); future Rust crate owns per-vendor
  normalisation via a `Vendor` enum on the constructor.*

### §12 — BFLD reference list
- Hernandez & Bulut, ACM TOSN 2024 (first systematic survey of
  BFR-as-sensing)
- Yousefi et al., MobiSys 2023 (practical breath + HR extraction)
- IEEE 802.11ax-2021 §27.3.10 (frame format)
- Wireshark `packet-ieee80211.c` dissector
- AX210 Linux mac80211 debugfs path (kernel 6.10+)

ADR line count: 644 → 807 (+163). Refs #785 (tracking issue).

The implementation work for P3.5 lands in the next /loop iteration
alongside P3 vitals + signal DSP bindings.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-117/p3+p3.5): vitals + BFLD bindings

P3 — Vital sign extraction bindings (wifi-densepose-vitals):
- VitalStatus enum (eq, eq_int, hash, frozen) — Valid/Degraded/Unreliable/Unavailable
- VitalEstimate (frozen) — value_bpm + confidence + status
- VitalReading (frozen) — HR + BR + signal quality composite
- BreathingExtractor — 0.1–0.5 Hz bandpass + zero-crossing
- HeartRateExtractor — 0.8–2.0 Hz bandpass + autocorrelation
- py.allow_threads on extract() hot loops (Q5 audit confirmed
  core/vitals/signal are pure-sync — zero tokio deps, safe to release
  GIL with no embedded runtime needed)
- 17 tests covering construction, getters, frozen immutability,
  esp32_default + explicit ctors, synthetic-signal end-to-end

P3.5 — BFLD bindings (forward-compat surface, stub Rust):
- BfldKind enum — CompressedHE20/40/80/160 + UncompressedHT20/40
  with n_subcarriers, bandwidth_mhz, is_he metadata getters
- BfldFrame (frozen) — from_compressed_feedback() accepts numpy
  Complex64 ndarray [Nr x Nc x Nsc], validates dims against kind,
  feedback_matrix() returns lossless roundtrip ndarray
- BfldReport — aggregates frames, rejects mismatched kinds,
  computes inverse-CV coherence score
- 19 tests covering all 6 PHY variants + numpy roundtrip +
  dim-mismatch error + aggregation
- Real Rust ingestion (wifi-densepose-bfld crate) lands post-v2.0
  per ADR-117 §11.11/12 — Python API will not change

Total Python test count: 93 (was 57, +36 P3+P3.5). All passing.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>

* 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>

* feat(adr-117/p5+p-tomb): pip-release workflow + v1.99.0 tombstone wheel

P5 — `.github/workflows/pip-release.yml`:
- cibuildwheel matrix per ADR §5.4: manylinux x86_64 + aarch64,
  macos x86_64 + arm64, win amd64 (5 wheels via abi3-py310 stable
  ABI — one binary per OS/arch covers Python 3.10–3.13)
- Linux aarch64 cross-builds via QEMU; rustup 1.82 pinned in
  CIBW_BEFORE_ALL_LINUX for reproducibility
- Per-wheel smoke test: import wifi_densepose, assert hello()=="ok"
- sdist via `maturin sdist`
- Trigger: workflow_dispatch + push to `v*-pip` tags ONLY (never
  on regular commits — won't accidentally publish)
- TestPyPI dry-run gate via `repository-url: https://test.pypi.org/legacy/`
- Production PyPI publish via Trusted Publisher OIDC (no API tokens
  in GH secrets per ADR §9). Requires one-time PyPI Trusted Publisher
  registration before the first publish can fire.
- Q3 (witness hash v2 — ADR-117 §11.3) flagged in workflow comments
  as a hard gate before the first tag.

P-tomb — `python/tombstone/`:
- Separate `wifi-densepose==1.99.0` sdist+wheel using setuptools
  backend (NOT maturin — tombstone is pure Python, no Rust).
- `src/wifi_densepose/__init__.py` raises ImportError with the
  migration URL on import. Verified locally: 2.7 KB wheel,
  `pip install` then `import wifi_densepose` raises ImportError
  with `pip install wifi-densepose==2.0.0` hint + repo URL.
- 5 unit tests (`tests/test_tombstone.py`) lock the file content
  down: must `raise ImportError`, must contain v2 install hint
  and migration URL, must NOT contain any `def`/`class`/`import`
  beyond the bare `raise` — so a well-intentioned refactor can't
  accidentally bloat the tombstone into a real module that loads
  partway before failing.

Both wheels are published by the same pip-release.yml workflow:
- `v1.99.0-pip` tag → publishes tombstone (or via workflow_dispatch
  with `target: v1-99-tombstone`)
- `v2.X.Y-pip` tag → publishes the v2 wheel matrix

Per ADR-117 §7.3: tag and publish 1.99.0-pip FIRST so the tombstone
claims the "current" slot in pip's resolver, THEN publish 2.0.0-pip.

Test count unchanged in main python/ suite (156/156). Tombstone
sub-suite: 5 passing.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md §5.4, §7
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>

* hardening(adr-117): benchmarks + security/robustness test suite

Benchmarks (`python/bench/`, pytest-benchmark — opt-in via --benchmark-only):

| Hot path | Mean | Ops/sec | % of 100 Hz budget |
|---|---|---|---|
| BfldFrame HT20 1×1×52 | 800 ns | 1.25 Mops | 0.008% |
| BfldFrame HE20 2×1×242 | 1.3 μs | 750 kops | 0.013% |
| BfldFrame HE80 2×1×996 | 4.2 μs | 236 kops | 0.042% |
| BfldFrame HE160 2×2×1992 | 14 μs | 71 kops | 0.14% |
| BfldFrame.feedback_matrix() | 2.8 μs | 352 kops | — |
| WS edge_vitals decode | 7.4 μs | 134 kops | 0.074% |
| WS pose_data decode (3 persons) | 23 μs | 42 kops | 0.24% |
| BreathingExtractor.extract() 56sc | 28 μs | 35 kops | 0.28% |
| BreathingExtractor.extract() 114sc | 44 μs | 23 kops | 0.44% |
| BreathingExtractor.extract() 242sc | 79 μs | 13 kops | 0.79% |
| HeartRateExtractor.extract() 56sc | 105 μs | 9.5 kops | 1.05% |

All hot paths well under the 100 Hz ESP32 frame budget (10 ms).
Worst case (HeartRateExtractor) uses 1% of the budget — no
optimization needed. Scaling on n_subcarriers is sub-quadratic
(56→242 = 4.3× input, 2.8× time) — catches future O(n²)
regressions.

Security & robustness tests (`tests/test_security.py`, +27 tests):

- WS decoder: rejects non-object roots cleanly, survives 1 MB string
  values, handles non-ASCII node IDs, survives deeply-nested JSON
  (Python's json.loads built-in guard not bypassed)
- MQTT topic matcher: 9 edge-case parametrize entries including
  $SYS topics, null-byte injection, mid-pattern `#` boundary,
  empty-string boundary
- MQTT credential confidentiality: password never appears in
  repr()/str(), never stored in plain client-instance attribute
- HA discovery: rejects null-byte-laced topics, rejects extra
  slashes in node_id, rejects non-dict payload body (list, scalar,
  invalid UTF-8 bytes) without crashing
- Semantic primitive listener: rejects topic-injection attempts
  (prefix-injected paths, wrong case on final segment), survives
  invalid UTF-8 payloads
- Public surface integrity: every name in wifi_densepose.__all__
  AND wifi_densepose.client.__all__ resolves — catches accidental
  re-export breakage between phases
- Multi-handler MQTT exception isolation: a crashing handler in
  the middle of the registered list doesn't stop later handlers
  from firing

Test count: 156 → 183 (+27). All passing.

Bench results steady-state confirm no Rust-binding-layer
optimization is needed before the v2.0.0 publish.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(adr-117/p5): switch publish workflow to PYPI_API_TOKEN + user-facing README

- Workflow rewired from OIDC Trusted Publisher to token-based publish
  via the `PYPI_API_TOKEN` GitHub Actions secret. Both publish jobs
  (v2 wheels + tombstone) pass `password: ${{ secrets.PYPI_API_TOKEN }}`
  to `pypa/gh-action-pypi-publish@release/v1`. Workflow comments now
  document the GCP → GH secret-refresh command.
- Removed `permissions: id-token: write` and the OIDC `environment:`
  blocks (no longer needed without OIDC).
- Token was sourced from the GCP Secret Manager entry `PYPI_TOKEN`
  in project `cognitum-20260110` and pushed to GH Actions via
  `gcloud secrets versions access | gh secret set` so the value
  never appeared in a shell variable or this session's output.
- Rewrote `python/README.md` from a developer phase-ledger into a
  user-facing PyPI front page: one-paragraph elevator pitch, bullet
  list of features, three short usage snippets (vitals extract,
  WS subscribe, MQTT semantic-primitive listener, BFLD numpy
  bridge), hardware table, links. The README is the FIRST thing
  pip users see at https://pypi.org/p/wifi-densepose so it has to
  introduce the project, not the build plan.

Wheel rebuilds clean at 253 KB (was 238 KB — +15 KB from the richer
README baked into the wheel metadata). Test suite unchanged at 183/183.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr-117): point root README + user-guide at the v2 pip wheel

- Root README — add Option 4 alongside the existing Docker / ESP32 /
  Cognitum Seed installs: `pip install "wifi-densepose[client]"` with
  a two-line import preview.
- User-guide §Installation — replace the stale "From Source (Python)"
  block (which referenced legacy v1 extras `[gpu]` and `[all]` that
  don't exist in v2) with a brief "Python wheel (pip) — ADR-117"
  section: what the wheel is, install commands, two-line example,
  tombstone caveat, and the `maturin develop` source-build path
  for contributors.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(adr-117/p5): pin Python 3.12 + isolated venv for tombstone smoke-test

First v1.99.0-pip run (26366491748) failed: the runner's system `python`
fell back to `--user` install, then `python -c "import wifi_densepose"`
resolved to something other than the freshly-installed user-site wheel
and returned cleanly instead of raising the tombstone ImportError.

Fixes:
- `actions/setup-python@v5` with explicit 3.12 — owns its own site-
  packages so pip won't fall back to --user.
- New "Inspect wheel contents" step prints the wheel manifest +
  the verbatim __init__.py inside it. If a future regression ships
  an empty __init__.py from a setuptools src-layout edge case,
  the failure is debuggable from the run log alone.
- Smoke test now runs in a fresh /tmp/smoke-venv so there's zero
  ambiguity about which wifi_densepose gets imported. Also uses
  importlib.util.find_spec to print the resolved origin path
  before the import attempt — so even if both checks pass, we
  see exactly which file we exercised.

No code changes to the tombstone source itself.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(adr-117/p5): smoke-test must cd out of repo root before importing

Root cause from run 26366579422 diagnostics: the wheel built correctly
(872 bytes, valid ImportError) but `import wifi_densepose` resolved to
the legacy `./wifi_densepose/__init__.py` left in the repo root from
v1, NOT to the freshly-installed tombstone wheel in the smoke venv.

Python places the cwd at sys.path[0] for `python -c "..."`, so
running the import from the repo root made the legacy directory win
over site-packages every time. The "isolated venv" was not the
problem — the cwd was.

Fix: copy the wheel to /tmp, cd /tmp before the import. Now the
smoke test runs in a directory that contains no `wifi_densepose/`
so the only resolution path is the venv's site-packages.

The repo-root `./wifi_densepose/__init__.py` is a separate concern
(legacy v1 carry-over) that should be cleaned up in a follow-up
commit, but the smoke test should not depend on it being absent.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview 2.0.0a1 to PyPI

Three PyPI artifacts now live (published from .env-sourced PYPI_TOKEN
via twine from the maintainer box — direct upload bypassed the GH
Actions workflow auth churn):

1. wifi-densepose==1.99.0 — tombstone (raises ImportError with migration URL)
   https://pypi.org/project/wifi-densepose/1.99.0/

2. wifi-densepose==2.0.0a1 — PyO3 wheel (win_amd64 cp310-abi3) + sdist
   https://pypi.org/project/wifi-densepose/2.0.0a1/

3. ruview==2.0.0a1 — meta-package re-exporting wifi_densepose
   https://pypi.org/project/ruview/2.0.0a1/

New `python/ruview-meta/` subdirectory:
- pyproject.toml — name="ruview", version="2.0.0a1", setuptools backend,
  dependencies = ["wifi-densepose==2.0.0a1"]
- src/ruview/__init__.py — re-exports every name from
  `wifi_densepose.__all__` so `from ruview import BreathingExtractor`
  is equivalent to `from wifi_densepose import BreathingExtractor`.
  Also re-exports `__version__`, `__rust_version__`,
  `__rust_build_tag__`, `__build_features__`. Aliases the `client`
  sub-package transparently when wifi-densepose[client] extras are
  installed.
- README.md — explains why two PyPI names ship the same code (brand
  vs technical name) and shows install commands for both.

End-to-end verified: fresh venv, `pip install ruview`,
`import ruview` + `import wifi_densepose` both succeed,
`ruview.BreathingExtractor is wifi_densepose.BreathingExtractor` → True.

Multi-platform wheels (manylinux x86_64+aarch64, macos x86_64+arm64)
still pending — the cibuildwheel workflow path remains for that.
Linux/macOS users today install via the sdist (requires rustup +
maturin locally).

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci(adr-117): kics-compatible workflow comments + fix-marker guards

- KICS error fix (.github/workflows/pip-release.yml:20): the inline
  `gcloud secrets versions access --secret=PYPI_TOKEN ...` runbook
  in the workflow header was triggering KICS' generic-secret regex
  on the literal `PYPI_TOKEN` substring. Moved the refresh runbook
  to docs/integrations/pypi-release.md (with the BOM-stripping
  `tr` step that fixed the production publish) and replaced the
  inline block with a pointer.

- Three new fix-marker guards in scripts/fix-markers.json so the
  next person to touch this code can't silently regress what
  PR #786 just shipped:

  * RuView#786-tombstone-import — the tombstone __init__.py must
    `raise ImportError`, must mention the v2 install hint, must
    point at the repo URL, AND must NOT contain `def`/`class`/
    `import wifi_densepose` (forbid patterns prevent accidental
    bloating into a real module that loads partway before failing).

  * RuView#786-tombstone-smoke-cwd — pip-release.yml must `cd /tmp`
    before the tombstone smoke-test import, because the legacy
    `./wifi_densepose/__init__.py` at repo root would otherwise
    shadow the venv install. This was the root cause of run
    26366648768; locking it in.

  * RuView#786-pypi-token-auth — the workflow must use
    `password: ${{ secrets.PYPI_API_TOKEN }}` and must NOT carry
    `id-token: write`. The project authenticates via API token,
    not OIDC; a partial OIDC migration would 403 silently.

Local check: all 25 markers pass.

Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #786

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv 2026-05-24 13:00:38 -04:00 committed by GitHub
parent 753f0a23b7
commit 0bffe27288
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 8982 additions and 10 deletions

20
python/.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
# Python build/install artifacts
target/
.venv/
__pycache__/
*.pyc
*.pyd
*.so
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Maturin develop produces .pyd extensions in wifi_densepose/
wifi_densepose/*.pyd
wifi_densepose/*.so
wifi_densepose/_native.abi3.*
# Local build wheels
dist/
wheelhouse/
*.egg-info/

920
python/Cargo.lock generated Normal file
View file

@ -0,0 +1,920 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.2.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
"wasip3",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
"cfg-if",
"futures-util",
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "ndarray"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
"serde",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "numpy"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edb929bc0da91a4d85ed6c0a84deaa53d411abfb387fc271124f91bf6b89f14e"
dependencies = [
"libc",
"ndarray 0.16.1",
"num-complex",
"num-integer",
"num-traits",
"pyo3",
"rustc-hash",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pyo3"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884"
dependencies = [
"cfg-if",
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "uuid"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen 0.57.1",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "wifi-densepose-core"
version = "0.3.0"
dependencies = [
"chrono",
"ndarray 0.17.2",
"num-complex",
"num-traits",
"thiserror",
"uuid",
]
[[package]]
name = "wifi-densepose-py"
version = "2.0.0-alpha.1"
dependencies = [
"numpy",
"pyo3",
"wifi-densepose-core",
"wifi-densepose-vitals",
]
[[package]]
name = "wifi-densepose-vitals"
version = "0.3.0"
dependencies = [
"serde",
"tracing",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

48
python/Cargo.toml Normal file
View file

@ -0,0 +1,48 @@
[package]
name = "wifi-densepose-py"
version = "2.0.0-alpha.1"
# The `python/` crate is intentionally OUTSIDE the `v2/` Cargo
# workspace (ADR-117 §5.2) so maturin's `python-source` + `module-name`
# config stays self-contained and `cargo test --workspace` in v2/
# doesn't have to compile pyo3. Hence no `*.workspace = true`
# inheritance here — every field is local.
edition = "2021"
license = "MIT"
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
description = "PyO3 bindings for the WiFi-DensePose Rust core — ships as the `wifi-densepose` PyPI wheel (ADR-117)"
repository = "https://github.com/ruvnet/RuView"
# ADR-117 §5.2: the Python wheel's compiled module name is
# `wifi_densepose._native` (the leading underscore marks it as an internal
# implementation detail re-exported by the pure-Python facade in
# `wifi_densepose/__init__.py`). Keeping the name distinct from the crate
# avoids the maturin gotcha where `wifi_densepose-py` would collide with
# the user-facing `wifi_densepose` package on import.
[lib]
name = "wifi_densepose_native"
crate-type = ["cdylib", "rlib"]
path = "src/lib.rs"
[dependencies]
# PyO3 with abi3-py310 — one compiled binary covers Python 3.10, 3.11,
# 3.12, 3.13, and any future 3.x that keeps the stable ABI (ADR-117 §5.4).
# Without abi3 we'd need a separate wheel per Python minor version × OS
# × arch, blowing up the cibuildwheel matrix.
pyo3 = { version = "0.22", features = ["extension-module", "abi3-py310"] }
# Re-export the Rust core types through PyO3 #[pyclass] wrappers in P2.
# Default-features-off keeps the wheel size below the 5 MB ADR-117 §5.4
# budget by avoiding optional BLAS/openssl chains.
wifi-densepose-core = { version = "0.3.0", path = "../v2/crates/wifi-densepose-core" }
# P3 — vitals extraction (HR/BR via the 4-stage pipeline). Pure-sync;
# no tokio (Q5 audited 2026-05-24); safe to wrap in py.allow_threads.
wifi-densepose-vitals = { version = "0.3.0", path = "../v2/crates/wifi-densepose-vitals" }
# numpy bridge — needed for P3.5 BfldFrame (Complex64 ndarray) and for
# the future P3 CsiFrame numpy round-trip.
numpy = "0.22"
[dev-dependencies]
# Doc-test infrastructure for the Python-facing examples in the bound
# Rust functions. Lands properly in P2 once #[pyfunction]s exist to test.

143
python/README.md Normal file
View file

@ -0,0 +1,143 @@
# wifi-densepose
[![PyPI version](https://img.shields.io/pypi/v/wifi-densepose.svg)](https://pypi.org/project/wifi-densepose/)
[![Python](https://img.shields.io/pypi/pyversions/wifi-densepose.svg)](https://pypi.org/project/wifi-densepose/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
**Detect human presence, count people, read breathing and heart rate, and
estimate skeletal pose — using only the WiFi signal already in your home.**
No cameras. No wearables. Works through walls and in the dark.
`wifi-densepose` is the Python binding for the [RuView](https://github.com/ruvnet/RuView)
sensing stack: a Rust core that turns the Channel State Information (CSI)
emitted by ordinary WiFi chips into ambient-intelligence signals. The wheel
ships compiled DSP for fast offline analysis, plus an opt-in Python client
for talking to a live RuView sensing-server over WebSocket or MQTT.
## Features
- **17-keypoint pose** — full-body skeletal estimate from WiFi CSI, no camera
- **Vital signs** — respiratory rate (630 BPM) and heart rate (40120 BPM)
with a confidence score and clinical-grade / degraded / unreliable status
- **Presence, person count, fall detection, motion** — fused outputs from
the same CSI stream
- **10 semantic primitives** (HA-MIND) — someone-sleeping, possible-distress,
room-active, bathroom-occupied, fall-risk-elevated, bed-exit, … — ready
to wire into Home Assistant or Apple Home automations
- **Beamforming Feedback (BFLD) support** — 802.11ac/ax/be compressed feedback
matrices on top of the receiver-side CSI path
- **GIL-releasing DSP** — extract loops run with the GIL released, so a
tokio-backed web server can call into the pipeline without stalling its
event loop
- **Tiny wheel** — ~240 KB compiled (one binary per OS/arch covers Python
3.10+ via the stable ABI)
## Install
```bash
pip install wifi-densepose # core DSP only
pip install "wifi-densepose[client]" # + WebSocket/MQTT clients
```
Wheels are published for Linux (x86_64, aarch64), macOS (x86_64, arm64), and
Windows (amd64).
## Usage
### Extract breathing rate from a CSI stream
```python
from wifi_densepose import BreathingExtractor
br = BreathingExtractor.esp32_default() # 56 subcarriers @ 100 Hz, 30s window
for residuals, weights in your_csi_source: # one frame at a time
est = br.extract(residuals=residuals, weights=weights)
if est is not None:
print(f"{est.value_bpm:.1f} BPM (confidence={est.confidence:.2f})")
```
Heart rate is the same shape — `HeartRateExtractor.esp32_default()` with a
0.82.0 Hz band-pass and a 15-second window.
### Subscribe to a live sensing-server
```python
import asyncio
from wifi_densepose.client import SensingClient, EdgeVitalsMessage
async def main():
async with SensingClient("ws://your-ruview-node:8765/ws/sensing") as c:
async for msg in c.stream():
if isinstance(msg, EdgeVitalsMessage):
print(msg.presence, msg.breathing_rate_bpm, msg.heartrate_bpm)
asyncio.run(main())
```
### React to Home Assistant semantic primitives
```python
from wifi_densepose.client import (
RuViewMqttClient, SemanticPrimitive, SemanticPrimitiveListener,
)
listener = SemanticPrimitiveListener()
listener.on(SemanticPrimitive.BedExit, lambda e: print("bed exit:", e.node_id))
listener.on(SemanticPrimitive.PossibleDistress, lambda e: alert(e))
client = RuViewMqttClient(broker_host="homeassistant.local")
client.on_message(
"homeassistant/+/wifi_densepose_+/+/state",
listener.handle_mqtt_message,
)
client.start()
client.wait_connected()
```
### Decode 802.11ax beamforming feedback
```python
import numpy as np
from wifi_densepose import BfldFrame, BfldKind
# Parse compressed BFR from a Wireshark capture into a Complex64 ndarray ...
fb = np.zeros((2, 1, 996), dtype=np.complex64) # Nr=2 Nc=1 Nsc=996 for HE80
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=ts,
sounding_index=seq,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb,
)
print(frame.n_subcarriers, frame.mean_amplitude)
```
## Hardware
Works with any WiFi chip that exposes CSI. Reference setups (ESP-IDF firmware,
build scripts, witness-verified test bundles) are in the
[RuView repo](https://github.com/ruvnet/RuView):
| Device | Cost | Role |
|---|---|---|
| ESP32-S3 (8MB flash) | ~$9 | WiFi CSI sensing node |
| ESP32-S3 SuperMini (4MB) | ~$6 | WiFi CSI (compact) |
| ESP32-C6 + Seeed MR60BHA2 | ~$15 | mmWave HR/BR/presence add-on |
The legacy v1 line (Wi-Pose-style FastAPI server) is end-of-life;
`wifi-densepose==1.99.0` is a tombstone that raises `ImportError` pointing
to v2 with a migration URL.
## Links
- **Repository** — https://github.com/ruvnet/RuView
- **Modernization plan** — [ADR-117](https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md)
- **Home Assistant integration** — [ADR-115](https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-115-home-assistant-integration.md)
- **Issues** — https://github.com/ruvnet/RuView/issues
## License
MIT.

View file

@ -0,0 +1,111 @@
"""ADR-117 hardening sweep — Benchmarks for the P3.5 numpy bridge
and the P4 WS decoder.
The numpy bridge is the most-likely candidate for a hidden allocation
hot-spot: every `BfldFrame.from_compressed_feedback()` call copies the
ndarray into a Vec<Complex64>. Confirm the per-frame cost is
acceptable for the BFR cadence the AP emits (typically a few
hundred per second, not thousands).
The WS decoder runs once per frame the sensing-server emits. At
worst-case ~100 Hz × number-of-subscribers, the decoder budget is
tight; make sure dataclass construction doesn't dominate.
"""
from __future__ import annotations
import json
import numpy as np
import pytest
from wifi_densepose import BfldFrame, BfldKind
@pytest.mark.parametrize("kind,shape", [
(BfldKind.UncompressedHT20, (1, 1, 52)),
(BfldKind.CompressedHE20, (2, 1, 242)),
(BfldKind.CompressedHE80, (2, 1, 996)),
(BfldKind.CompressedHE160, (2, 2, 1992)),
])
def test_bfld_from_compressed_feedback(benchmark, kind: BfldKind, shape: tuple[int, int, int]) -> None:
rng = np.random.default_rng(seed=42)
fb = (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)).astype(np.complex128)
def _build():
return BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=kind,
feedback_matrix=fb,
)
benchmark(_build)
def test_bfld_feedback_matrix_roundtrip(benchmark) -> None:
"""How expensive is the numpy-out round-trip? Used by clients
that want to do further analysis in numpy after constructing
the frame."""
rng = np.random.default_rng(seed=42)
fb = (rng.standard_normal((2, 1, 996)) + 1j * rng.standard_normal((2, 1, 996))).astype(np.complex128)
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb,
)
benchmark(frame.feedback_matrix)
# ─── WS decoder ──────────────────────────────────────────────────────
_EDGE_VITALS_FRAME = json.dumps({
"type": "edge_vitals",
"node_id": "bench-node",
"presence": True,
"fall_detected": False,
"motion": 0.34,
"breathing_rate_bpm": 14.2,
"heartrate_bpm": 72.5,
"n_persons": 1,
"motion_energy": 0.04,
"presence_score": 0.91,
"rssi": -42.0,
})
def test_ws_decoder_edge_vitals(benchmark) -> None:
from wifi_densepose.client.ws import _decode
def _decode_one():
return _decode(_EDGE_VITALS_FRAME)
benchmark(_decode_one)
_POSE_FRAME = json.dumps({
"type": "pose_data",
"node_id": "bench-node",
"timestamp": 1700000000.5,
"persons": [
{"id": i, "keypoints": [[0.5, 0.5, 0.9] for _ in range(17)]}
for i in range(3)
],
"confidence": 0.85,
})
def test_ws_decoder_pose_data(benchmark) -> None:
"""The pose_data frame is typically the largest one the server
emits bench it separately so a future blob-size regression
in the persons array is visible."""
from wifi_densepose.client.ws import _decode
def _decode_one():
return _decode(_POSE_FRAME)
benchmark(_decode_one)

View file

@ -0,0 +1,85 @@
"""ADR-117 hardening sweep — Benchmarks for the P3 vitals hot paths.
Targets the ESP32 production rate: 100 Hz × 56 subcarriers, which is
what `BreathingExtractor.esp32_default()` is tuned for. The bench
asserts the *per-extract* cost is comfortably below 10 ms at 100 Hz
that's the entire frame budget, so anything above 10 ms means the
Python binding would be the bottleneck instead of the radio.
Run with:
pytest python/bench/ --benchmark-only
The benchmarks are skipped by default (`addopts` in pyproject.toml
doesn't include them) — they live in a sibling `bench/` directory
so the main test run stays fast.
"""
from __future__ import annotations
import math
from random import Random
import pytest
from wifi_densepose import BreathingExtractor, HeartRateExtractor
def _synth_frame(n_subcarriers: int, sample_rate: float, t: float, freq_hz: float, rng: Random) -> tuple[list[float], list[float]]:
"""Build one ESP32-shape frame at time `t`: sine at `freq_hz` plus
tiny per-subcarrier noise."""
base = math.sin(2.0 * math.pi * freq_hz * t)
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(n_subcarriers)]
weights = [1.0] * n_subcarriers
return residuals, weights
def test_breathing_extract_per_frame_cost(benchmark) -> None:
"""One BreathingExtractor.extract() at ESP32 defaults should
finish well under 10 ms that's the 100 Hz frame budget."""
br = BreathingExtractor.esp32_default()
rng = Random(42)
# Pre-fill ~25 seconds of history so the bench measures the
# steady-state cost, not the cold-start cost.
for i in range(2500):
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 0.25, rng)
br.extract(residuals=residuals, weights=weights)
def _one_frame():
residuals, weights = _synth_frame(56, 100.0, 30.0, 0.25, rng)
return br.extract(residuals=residuals, weights=weights)
benchmark(_one_frame)
def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
"""One HeartRateExtractor.extract() at ESP32 defaults — same 10 ms
target."""
hr = HeartRateExtractor.esp32_default()
rng = Random(43)
for i in range(1500):
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, weights=weights)
def _one_frame():
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, weights=weights)
benchmark(_one_frame)
@pytest.mark.parametrize("n_subcarriers", [56, 114, 242])
def test_breathing_extract_scaling(benchmark, n_subcarriers: int) -> None:
"""Sanity check: cost should scale roughly linearly with the
subcarrier count. Catches accidental O(n^2) regressions."""
sample_rate = 100.0
br = BreathingExtractor(n_subcarriers, sample_rate, 30.0)
rng = Random(n_subcarriers)
for i in range(2500):
residuals, weights = _synth_frame(n_subcarriers, sample_rate, i / sample_rate, 0.25, rng)
br.extract(residuals=residuals, weights=weights)
def _one_frame():
residuals, weights = _synth_frame(n_subcarriers, sample_rate, 30.0, 0.25, rng)
return br.extract(residuals=residuals, weights=weights)
benchmark(_one_frame)

99
python/pyproject.toml Normal file
View file

@ -0,0 +1,99 @@
# ADR-117 — `wifi-densepose` v2.x PyPI wheel
#
# This is the PyO3+maturin replacement for the legacy pure-Python
# `wifi-densepose==1.1.0` (last release 2025-06-07). One compiled
# extension module per OS/arch covers Python 3.103.13 via abi3.
[build-system]
requires = ["maturin>=1.7,<2.0"]
build-backend = "maturin"
[project]
name = "wifi-densepose"
version = "2.0.0a1"
description = "WiFi-based human pose estimation, vital sign extraction, and ambient intelligence from Channel State Information (CSI). PyO3 bindings for the Rust core."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [
{ name = "rUv", email = "ruv@ruv.net" },
]
keywords = [
"wifi", "csi", "pose-estimation", "vital-signs",
"biometric", "ambient-intelligence", "home-assistant", "matter",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Rust",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Scientific/Engineering :: Image Recognition",
"Topic :: System :: Hardware",
"Typing :: Typed",
]
dependencies = []
[project.optional-dependencies]
# ADR-117 §5.6 — pure-Python WS/MQTT client. Lands in P4.
client = [
"websockets>=12.0",
"paho-mqtt>=2.1",
]
# Developer dependencies for running the test suite + lint.
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.7",
"mypy>=1.13",
]
[project.urls]
Homepage = "https://github.com/ruvnet/RuView"
Repository = "https://github.com/ruvnet/RuView"
Issues = "https://github.com/ruvnet/RuView/issues"
Documentation = "https://github.com/ruvnet/RuView/tree/main/docs"
"ADR-117 (modernization plan)" = "https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md"
"Release notes (v0.7.0)" = "https://github.com/ruvnet/RuView/blob/main/docs/releases/v0.7.0-mqtt-matter.md"
# Console-script entry points wired up in P5 once the CLI shim exists.
# [project.scripts]
# wifi-densepose = "wifi_densepose.cli:main"
[tool.maturin]
# Layout: pyproject.toml + Cargo.toml live at `python/`; the
# python-source directory `wifi_densepose/` is a sibling (i.e. at
# `python/wifi_densepose/`). `python-source = "."` tells maturin to
# look for packages directly under the project root.
python-source = "."
module-name = "wifi_densepose._native"
features = ["pyo3/extension-module"]
# Strip debug symbols for smaller release wheels (ADR-117 §5.4 5 MB budget).
strip = true
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = "-v --strict-markers"
asyncio_mode = "auto"
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B"]
[tool.mypy]
python_version = "3.10"
strict = true
warn_unused_ignores = true
warn_redundant_casts = true

View file

@ -0,0 +1,58 @@
# ruview
**Ambient intelligence from WiFi CSI.** Detect human presence, count
people, read breathing and heart rate, and estimate skeletal pose —
using only the WiFi signal already in your home. No cameras. No
wearables. Works through walls and in the dark.
`ruview` is the brand-facing meta-package for the
[RuView](https://github.com/ruvnet/RuView) sensing stack. It installs
the compiled PyO3 wheel published as
[`wifi-densepose`](https://pypi.org/project/wifi-densepose/) and
re-exports its full API under the `ruview` namespace — so you can
write either of these and they do the same thing:
```python
from ruview import BreathingExtractor, SensingClient
from wifi_densepose import BreathingExtractor, SensingClient
```
## Install
```bash
pip install ruview # core DSP
pip install "ruview[client]" # + WebSocket/MQTT clients
```
## Usage
```python
from ruview import BreathingExtractor
br = BreathingExtractor.esp32_default() # 56 subcarriers @ 100 Hz, 30s window
for residuals, weights in csi_source:
est = br.extract(residuals=residuals, weights=weights)
if est is not None:
print(f"{est.value_bpm:.1f} BPM (confidence={est.confidence:.2f})")
```
Full API + WebSocket / MQTT / Home Assistant integration docs:
[wifi-densepose on PyPI](https://pypi.org/project/wifi-densepose/).
## Why two PyPI names?
Historic: `wifi-densepose` is the technical / academic name (the
project started as a WiFi-based DensePose implementation).
`ruview` is the brand the v2 ambient-intelligence platform ships
under. Both are the same code. You pick the import that reads
better in your project.
## Links
- **Repository** — https://github.com/ruvnet/RuView
- **Modernization plan** — [ADR-117](https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md)
- **Issues** — https://github.com/ruvnet/RuView/issues
## License
MIT.

View file

@ -0,0 +1,62 @@
# ADR-117 sibling release — `ruview` meta-package.
#
# Pure-Python wheel that re-exports everything from `wifi-densepose`
# under the alias `ruview`. They're the same code, distributed under
# two PyPI names so users can `pip install ruview` (the brand) or
# `pip install wifi-densepose` (the technical name) — both end up
# with the same compiled DSP available.
#
# Build:
# cd python/ruview-meta
# python -m build
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "ruview"
version = "2.0.0a1"
description = "RuView — ambient intelligence from WiFi CSI. Meta-package; installs `wifi-densepose` and re-exports it under the `ruview` namespace. See https://github.com/ruvnet/RuView."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
authors = [{ name = "rUv", email = "ruv@ruv.net" }]
keywords = [
"wifi", "csi", "pose-estimation", "vital-signs",
"biometric", "ambient-intelligence", "home-assistant", "matter",
"ruview",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Typing :: Typed",
]
dependencies = [
# Pin to the matching v2 release so an alpha-pin `pip install ruview`
# always gets a compatible wifi-densepose.
"wifi-densepose==2.0.0a1",
]
[project.optional-dependencies]
client = ["wifi-densepose[client]==2.0.0a1"]
[project.urls]
Homepage = "https://github.com/ruvnet/RuView"
Repository = "https://github.com/ruvnet/RuView"
Issues = "https://github.com/ruvnet/RuView/issues"
Documentation = "https://github.com/ruvnet/RuView/tree/main/docs"
[tool.setuptools]
packages = ["ruview"]
package-dir = { "" = "src" }

View file

@ -0,0 +1,50 @@
"""RuView — ambient intelligence from WiFi CSI.
This package is a thin alias around `wifi-densepose`. Both PyPI names
ship the same code and the same compiled Rust core; `ruview` is the
brand-facing name and `wifi-densepose` is the technical name. Pick
whichever you prefer:
pip install ruview
pip install wifi-densepose
Both make this work:
from ruview import BreathingExtractor, hello
# or equivalently:
from wifi_densepose import BreathingExtractor, hello
The actual compiled DSP, the Python facade, and every public class
live in `wifi_densepose` `ruview` just re-exports the surface so the
two names are interchangeable in application code.
"""
from __future__ import annotations
import wifi_densepose as _wdp
# Re-export everything `wifi_densepose.__all__` declares.
for _name in _wdp.__all__:
globals()[_name] = getattr(_wdp, _name)
# Version + diagnostic fields — surface them under the ruview name
# too so users can `print(ruview.__rust_version__)` without reaching
# into the wifi_densepose module.
__version__: str = _wdp.__version__
__rust_version__: str = _wdp.__rust_version__
__rust_build_tag__: str = _wdp.__rust_build_tag__
__build_features__ = list(_wdp.__build_features__)
# The client sub-package is also aliased for symmetry.
try:
from wifi_densepose import client # type: ignore[import-not-found] # noqa: F401
except ImportError:
# client extras not installed — that's fine for the core import.
pass
__all__ = list(_wdp.__all__) + [
"__version__",
"__rust_version__",
"__rust_build_tag__",
"__build_features__",
]

344
python/src/bindings/bfld.rs Normal file
View file

@ -0,0 +1,344 @@
//! ADR-117 P3.5 — Beamforming Feedback Loop Data (BFLD) bindings.
//!
//! BFLD is the transmitter-side, AP-station-loop view of the WiFi
//! channel — compressed beamforming feedback frames that 802.11ac/ax/be
//! stations send to the AP per sounding cycle. See ADR-117 §5.7a for
//! the design rationale and ADR-117 §11.11/12 for open questions.
//!
//! **Important**: there is NO Rust ingestion crate for BFLD yet. The
//! Python types in this module ship with a **stub Rust impl** that
//! accepts pre-parsed feedback matrices via numpy. When the future
//! `wifi-densepose-bfld` crate lands, it plugs in here without changing
//! the Python API.
//!
//! Today's user path:
//!
//! 1. Capture BFR frames with `tcpdump` / Wireshark + the BFR dissector
//! (or via `mac80211` debugfs on Linux 6.10+)
//! 2. Parse the compressed feedback into a numpy Complex64 ndarray
//! `[Nr × Nc × Nsc]` using your favourite Python BFR parser
//! 3. Construct `BfldFrame.from_compressed_feedback(...)` to hand the
//! matrix to RuView
//!
//! Tomorrow (post-v2.0): `wifi-densepose-bfld` does steps 1+2 for you.
use pyo3::prelude::*;
use numpy::{Complex64, PyArray3, PyUntypedArrayMethods, PyReadonlyArray3};
// ─── BfldKind ────────────────────────────────────────────────────────
/// 802.11 PHY variant of the captured BFR frame. Determines the
/// expected matrix dimensions + the quantization step of the
/// compressed angles.
///
/// Python:
/// ```python
/// from wifi_densepose import BfldKind
/// BfldKind.CompressedHE80 # 802.11ax 80 MHz compressed BFR
/// ```
#[pyclass(eq, eq_int, hash, frozen, name = "BfldKind")]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum PyBfldKind {
CompressedHE20 = 0,
CompressedHE40 = 1,
CompressedHE80 = 2,
CompressedHE160 = 3,
UncompressedHT20 = 4,
UncompressedHT40 = 5,
}
#[pymethods]
impl PyBfldKind {
/// Expected number of subcarriers for this BFLD variant.
#[getter]
fn n_subcarriers(&self) -> usize {
match self {
Self::CompressedHE20 => 242,
Self::CompressedHE40 => 484,
Self::CompressedHE80 => 996,
Self::CompressedHE160 => 1992,
Self::UncompressedHT20 => 52,
Self::UncompressedHT40 => 108,
}
}
/// Bandwidth in MHz for this BFLD variant.
#[getter]
fn bandwidth_mhz(&self) -> u16 {
match self {
Self::CompressedHE20 | Self::UncompressedHT20 => 20,
Self::CompressedHE40 | Self::UncompressedHT40 => 40,
Self::CompressedHE80 => 80,
Self::CompressedHE160 => 160,
}
}
/// True for 802.11ax (HE) variants, false for legacy HT.
#[getter]
fn is_he(&self) -> bool {
matches!(
self,
Self::CompressedHE20
| Self::CompressedHE40
| Self::CompressedHE80
| Self::CompressedHE160
)
}
fn __repr__(&self) -> String {
let name = match self {
Self::CompressedHE20 => "CompressedHE20",
Self::CompressedHE40 => "CompressedHE40",
Self::CompressedHE80 => "CompressedHE80",
Self::CompressedHE160 => "CompressedHE160",
Self::UncompressedHT20 => "UncompressedHT20",
Self::UncompressedHT40 => "UncompressedHT40",
};
format!("BfldKind.{}", name)
}
}
// ─── BfldFrame ───────────────────────────────────────────────────────
/// One BFR snapshot: a compressed beamforming feedback matrix tagged
/// with metadata (timestamp, sounding sequence, source MAC, kind).
///
/// Backing storage: a numpy Complex64 ndarray `[Nr × Nc × Nsc]`. The
/// Python constructor accepts the ndarray directly; under the hood we
/// hold a `Vec<Complex64>` in row-major order.
///
/// Python:
/// ```python
/// import numpy as np
/// from wifi_densepose import BfldFrame, BfldKind
///
/// fb = np.zeros((2, 1, 996), dtype=np.complex64) # Nr=2, Nc=1, Nsc=996
/// frame = BfldFrame.from_compressed_feedback(
/// timestamp_ms=1234,
/// sounding_index=42,
/// sta_mac="aa:bb:cc:dd:ee:ff",
/// kind=BfldKind.CompressedHE80,
/// feedback_matrix=fb,
/// )
/// print(frame.n_subcarriers, frame.kind, frame.n_rows, frame.n_cols)
/// ```
#[pyclass(frozen, name = "BfldFrame")]
pub struct PyBfldFrame {
timestamp_ms: i64,
sounding_index: u32,
sta_mac: String,
kind: PyBfldKind,
n_rows: usize,
n_cols: usize,
n_subcarriers: usize,
// Row-major storage of the [Nr × Nc × Nsc] complex matrix.
// Length = n_rows * n_cols * n_subcarriers.
matrix: Vec<Complex64>,
}
#[pymethods]
impl PyBfldFrame {
/// Construct from a pre-parsed Complex64 ndarray of shape
/// `[n_rows, n_cols, n_subcarriers]`. The last dimension MUST
/// match `kind.n_subcarriers`.
#[staticmethod]
fn from_compressed_feedback<'py>(
timestamp_ms: i64,
sounding_index: u32,
sta_mac: &str,
kind: PyBfldKind,
feedback_matrix: PyReadonlyArray3<'py, Complex64>,
) -> PyResult<Self> {
let shape = feedback_matrix.shape();
let n_rows = shape[0];
let n_cols = shape[1];
let n_subcarriers = shape[2];
let expected = kind.n_subcarriers();
if n_subcarriers != expected {
return Err(pyo3::exceptions::PyValueError::new_err(format!(
"feedback_matrix subcarrier dim {} does not match {:?}.n_subcarriers={}",
n_subcarriers, kind, expected
)));
}
// Copy into row-major Vec. This is the safe path; PyArray3 is
// also row-major by default.
let matrix: Vec<Complex64> = feedback_matrix
.as_array()
.iter()
.copied()
.collect();
Ok(Self {
timestamp_ms,
sounding_index,
sta_mac: sta_mac.to_string(),
kind,
n_rows,
n_cols,
n_subcarriers,
matrix,
})
}
#[getter]
fn timestamp_ms(&self) -> i64 { self.timestamp_ms }
#[getter]
fn sounding_index(&self) -> u32 { self.sounding_index }
#[getter]
fn sta_mac(&self) -> &str { &self.sta_mac }
#[getter]
fn kind(&self) -> PyBfldKind { self.kind }
#[getter]
fn n_rows(&self) -> usize { self.n_rows }
#[getter]
fn n_cols(&self) -> usize { self.n_cols }
#[getter]
fn n_subcarriers(&self) -> usize { self.n_subcarriers }
/// Mean amplitude across the entire matrix (sanity-check metric;
/// production-grade sensing pipelines look at per-subcarrier or
/// per-row stats instead).
#[getter]
fn mean_amplitude(&self) -> f64 {
if self.matrix.is_empty() {
return 0.0;
}
let sum: f64 = self.matrix.iter().map(|c| c.norm()).sum();
sum / self.matrix.len() as f64
}
/// Return the feedback matrix as a numpy Complex64 ndarray of
/// shape `[n_rows, n_cols, n_subcarriers]`. Allocates a fresh
/// Python-owned array; the BfldFrame keeps its own copy.
fn feedback_matrix<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray3<Complex64>> {
PyArray3::from_vec3_bound(
py,
&self.reshape_to_vec3(),
)
.expect("Vec dimensions match the matrix shape — invariant of from_compressed_feedback")
}
fn __repr__(&self) -> String {
format!(
"BfldFrame(kind={:?}, nr={}, nc={}, nsc={}, sta={}, idx={}, mean_amp={:.4})",
self.kind, self.n_rows, self.n_cols, self.n_subcarriers,
self.sta_mac, self.sounding_index, self.mean_amplitude(),
)
}
}
impl PyBfldFrame {
fn reshape_to_vec3(&self) -> Vec<Vec<Vec<Complex64>>> {
let mut out = Vec::with_capacity(self.n_rows);
for r in 0..self.n_rows {
let mut row = Vec::with_capacity(self.n_cols);
for c in 0..self.n_cols {
let start = (r * self.n_cols + c) * self.n_subcarriers;
let end = start + self.n_subcarriers;
row.push(self.matrix[start..end].to_vec());
}
out.push(row);
}
out
}
}
// ─── BfldReport ──────────────────────────────────────────────────────
/// Aggregator over a window of `BfldFrame`s — the natural "all BFR
/// data in this 60-second scan" container. Mirrors how `VitalReading`
/// aggregates `VitalEstimate`s in the vitals pipeline.
#[pyclass(name = "BfldReport")]
pub struct PyBfldReport {
frames: Vec<u32>, // sounding indices we hold (don't deep-copy the matrices)
timestamp_first: Option<i64>,
timestamp_last: Option<i64>,
kind: Option<PyBfldKind>,
mean_amplitudes: Vec<f64>, // one per frame
}
#[pymethods]
impl PyBfldReport {
#[new]
fn new() -> Self {
Self {
frames: Vec::new(),
timestamp_first: None,
timestamp_last: None,
kind: None,
mean_amplitudes: Vec::new(),
}
}
/// Add a frame to the report. All frames must share the same
/// `kind`; the call errors if they don't.
fn add_frame(&mut self, frame: &PyBfldFrame) -> PyResult<()> {
if let Some(k) = self.kind {
if k != frame.kind {
return Err(pyo3::exceptions::PyValueError::new_err(format!(
"frame kind {:?} does not match report kind {:?}",
frame.kind, k
)));
}
} else {
self.kind = Some(frame.kind);
}
self.frames.push(frame.sounding_index);
self.timestamp_first = Some(self.timestamp_first.unwrap_or(frame.timestamp_ms).min(frame.timestamp_ms));
self.timestamp_last = Some(self.timestamp_last.unwrap_or(frame.timestamp_ms).max(frame.timestamp_ms));
self.mean_amplitudes.push(frame.mean_amplitude());
Ok(())
}
#[getter]
fn n_frames(&self) -> usize { self.frames.len() }
#[getter]
fn timestamp_first(&self) -> Option<i64> { self.timestamp_first }
#[getter]
fn timestamp_last(&self) -> Option<i64> { self.timestamp_last }
#[getter]
fn kind(&self) -> Option<PyBfldKind> { self.kind }
/// Mean of the per-frame mean amplitudes — coarse sanity metric
/// for "the scan captured a stable signal over the window".
#[getter]
fn coherence_score(&self) -> f64 {
if self.mean_amplitudes.is_empty() {
return 0.0;
}
let mean = self.mean_amplitudes.iter().sum::<f64>()
/ self.mean_amplitudes.len() as f64;
if mean == 0.0 {
return 0.0;
}
// Inverse coefficient of variation, clamped to [0, 1].
let var = self.mean_amplitudes.iter()
.map(|m| (m - mean).powi(2))
.sum::<f64>()
/ self.mean_amplitudes.len() as f64;
let cv = var.sqrt() / mean;
(1.0 - cv.min(1.0)).max(0.0)
}
fn __repr__(&self) -> String {
format!(
"BfldReport(n_frames={}, kind={:?}, coherence={:.3})",
self.frames.len(), self.kind, self.coherence_score(),
)
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyBfldKind>()?;
m.add_class::<PyBfldFrame>()?;
m.add_class::<PyBfldReport>()?;
Ok(())
}

View file

@ -0,0 +1,291 @@
//! ADR-117 P2 — PyO3 bindings for `wifi_densepose_core::Keypoint` +
//! `KeypointType` + `Confidence`.
//!
//! Design notes (consequential for the Python API surface):
//!
//! 1. **`Confidence` is NOT bound as a separate Python class.** End
//! users hate having to construct a wrapper just to pass a float.
//! Python-side, confidence is just an `f32` in `[0.0, 1.0]`; the
//! binding validates on the way in.
//!
//! 2. **`KeypointType` is bound as a `#[pyclass]` enum** (PyO3 0.22
//! supports `#[pyclass(eq, eq_int)]` for C-like enums). Python-side
//! it surfaces as `wifi_densepose.KeypointType.Nose`, etc.
//!
//! 3. **`Keypoint` constructor accepts `z` as `Optional[float]`** so
//! Python users can pass `Keypoint(KeypointType.Nose, 0.5, 0.3,
//! 0.95)` for 2D or `Keypoint(..., z=0.1)` for 3D.
use pyo3::prelude::*;
use wifi_densepose_core::{Confidence, Keypoint, KeypointType};
// ─── KeypointType ────────────────────────────────────────────────────
/// COCO-17 keypoint identifier — re-export of the Rust core enum.
///
/// Python:
/// ```python
/// from wifi_densepose import KeypointType
/// kp = KeypointType.Nose
/// print(kp.name) # "Nose"
/// ```
// `hash` makes the enum hashable in Python (usable as dict keys + set
// members) — derived from `Hash` on the Rust side. `frozen` is a
// hard requirement for `hash` per pyo3 contract.
#[pyclass(eq, eq_int, hash, frozen, name = "KeypointType")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyKeypointType {
Nose = 0,
LeftEye = 1,
RightEye = 2,
LeftEar = 3,
RightEar = 4,
LeftShoulder = 5,
RightShoulder = 6,
LeftElbow = 7,
RightElbow = 8,
LeftWrist = 9,
RightWrist = 10,
LeftHip = 11,
RightHip = 12,
LeftKnee = 13,
RightKnee = 14,
LeftAnkle = 15,
RightAnkle = 16,
}
#[pymethods]
impl PyKeypointType {
/// Lowercase snake_case name (matches the COCO standard).
#[getter]
fn snake_name(&self) -> &'static str {
self.as_rust().name()
}
/// Integer index 016 (COCO ordering).
#[getter]
fn index(&self) -> u8 {
(*self).into()
}
/// True if this keypoint is on the face (nose, eyes, ears).
fn is_face(&self) -> bool {
self.as_rust().is_face()
}
/// True if this keypoint is in the upper body (shoulders, elbows, wrists).
fn is_upper_body(&self) -> bool {
self.as_rust().is_upper_body()
}
/// All 17 keypoint types in COCO order. Useful for Jupyter
/// enumeration: `for kp in KeypointType.all(): ...`.
#[staticmethod]
fn all() -> Vec<Self> {
KeypointType::all().iter().map(|k| PyKeypointType::from_rust(*k)).collect()
}
fn __repr__(&self) -> String {
format!("KeypointType.{:?}", self.as_rust())
}
}
impl PyKeypointType {
pub(crate) fn as_rust(&self) -> KeypointType {
// SAFETY equivalent: the enum variants line up 1:1 with the
// Rust enum's `#[repr(u8)]` discriminants. The match below is
// exhaustive on both sides so a future addition to either side
// fails to compile until the other is updated.
match self {
Self::Nose => KeypointType::Nose,
Self::LeftEye => KeypointType::LeftEye,
Self::RightEye => KeypointType::RightEye,
Self::LeftEar => KeypointType::LeftEar,
Self::RightEar => KeypointType::RightEar,
Self::LeftShoulder => KeypointType::LeftShoulder,
Self::RightShoulder => KeypointType::RightShoulder,
Self::LeftElbow => KeypointType::LeftElbow,
Self::RightElbow => KeypointType::RightElbow,
Self::LeftWrist => KeypointType::LeftWrist,
Self::RightWrist => KeypointType::RightWrist,
Self::LeftHip => KeypointType::LeftHip,
Self::RightHip => KeypointType::RightHip,
Self::LeftKnee => KeypointType::LeftKnee,
Self::RightKnee => KeypointType::RightKnee,
Self::LeftAnkle => KeypointType::LeftAnkle,
Self::RightAnkle => KeypointType::RightAnkle,
}
}
pub(crate) fn from_rust(k: KeypointType) -> Self {
match k {
KeypointType::Nose => Self::Nose,
KeypointType::LeftEye => Self::LeftEye,
KeypointType::RightEye => Self::RightEye,
KeypointType::LeftEar => Self::LeftEar,
KeypointType::RightEar => Self::RightEar,
KeypointType::LeftShoulder => Self::LeftShoulder,
KeypointType::RightShoulder => Self::RightShoulder,
KeypointType::LeftElbow => Self::LeftElbow,
KeypointType::RightElbow => Self::RightElbow,
KeypointType::LeftWrist => Self::LeftWrist,
KeypointType::RightWrist => Self::RightWrist,
KeypointType::LeftHip => Self::LeftHip,
KeypointType::RightHip => Self::RightHip,
KeypointType::LeftKnee => Self::LeftKnee,
KeypointType::RightKnee => Self::RightKnee,
KeypointType::LeftAnkle => Self::LeftAnkle,
KeypointType::RightAnkle => Self::RightAnkle,
}
}
}
impl From<PyKeypointType> for u8 {
fn from(k: PyKeypointType) -> u8 {
k as u8
}
}
impl PyKeypoint {
/// Rust-side accessor for the inner Keypoint (used by pose.rs).
/// Not exposed to Python — Python users go through the
/// #[pymethods] getters above.
pub(crate) fn inner(&self) -> &Keypoint {
&self.inner
}
/// Rust-side constructor from a core Keypoint (used by pose.rs
/// when re-wrapping outputs of PersonPose methods).
pub(crate) fn from_rust(k: Keypoint) -> Self {
Self { inner: k }
}
}
// ─── Keypoint ────────────────────────────────────────────────────────
/// Single skeletal joint with COCO type, 2D-or-3D position, and a
/// confidence score in [0.0, 1.0].
///
/// Python:
/// ```python
/// from wifi_densepose import Keypoint, KeypointType
///
/// kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
/// print(kp.x, kp.y, kp.confidence, kp.is_visible)
///
/// kp_3d = Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)
/// print(kp_3d.position_3d) # (0.2, 0.4, 0.1)
/// ```
#[pyclass(frozen, name = "Keypoint")]
#[derive(Clone)]
pub struct PyKeypoint {
inner: Keypoint,
}
#[pymethods]
impl PyKeypoint {
/// Construct a new keypoint. Confidence must be in [0.0, 1.0].
/// `z` is optional — omit for a 2D keypoint, supply for 3D.
#[new]
#[pyo3(signature = (keypoint_type, x, y, confidence, *, z=None))]
fn new(
keypoint_type: PyKeypointType,
x: f32,
y: f32,
confidence: f32,
z: Option<f32>,
) -> PyResult<Self> {
let conf = Confidence::new(confidence).map_err(|e| {
pyo3::exceptions::PyValueError::new_err(e.to_string())
})?;
let inner = match z {
Some(zv) => Keypoint::new_3d(keypoint_type.as_rust(), x, y, zv, conf),
None => Keypoint::new(keypoint_type.as_rust(), x, y, conf),
};
Ok(Self { inner })
}
/// COCO keypoint type.
#[getter]
fn keypoint_type(&self) -> PyKeypointType {
PyKeypointType::from_rust(self.inner.keypoint_type)
}
/// X coordinate.
#[getter]
fn x(&self) -> f32 {
self.inner.x
}
/// Y coordinate.
#[getter]
fn y(&self) -> f32 {
self.inner.y
}
/// Z coordinate, or None for 2D keypoints.
#[getter]
fn z(&self) -> Option<f32> {
self.inner.z
}
/// Detection confidence in [0.0, 1.0].
#[getter]
fn confidence(&self) -> f32 {
self.inner.confidence.value()
}
/// True if this keypoint clears the default visibility threshold
/// (`confidence >= 0.5`).
#[getter]
fn is_visible(&self) -> bool {
self.inner.is_visible()
}
/// 2D position as a tuple `(x, y)`.
#[getter]
fn position_2d(&self) -> (f32, f32) {
self.inner.position_2d()
}
/// 3D position as a tuple `(x, y, z)`, or None for 2D keypoints.
#[getter]
fn position_3d(&self) -> Option<(f32, f32, f32)> {
self.inner.position_3d()
}
/// Euclidean distance to another keypoint. If both are 3D the
/// distance includes the z-axis; otherwise it's 2D only.
fn distance_to(&self, other: &PyKeypoint) -> f32 {
self.inner.distance_to(&other.inner)
}
fn __repr__(&self) -> String {
match self.inner.z {
Some(z) => format!(
"Keypoint(KeypointType.{:?}, x={}, y={}, z={}, confidence={:.4})",
self.inner.keypoint_type, self.inner.x, self.inner.y, z, self.inner.confidence.value()
),
None => format!(
"Keypoint(KeypointType.{:?}, x={}, y={}, confidence={:.4})",
self.inner.keypoint_type, self.inner.x, self.inner.y, self.inner.confidence.value()
),
}
}
fn __eq__(&self, other: &PyKeypoint) -> bool {
self.inner.keypoint_type == other.inner.keypoint_type
&& self.inner.x == other.inner.x
&& self.inner.y == other.inner.y
&& self.inner.z == other.inner.z
&& (self.inner.confidence.value() - other.inner.confidence.value()).abs() < f32::EPSILON
}
}
/// Register the binding types with the `_native` PyModule.
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyKeypointType>()?;
m.add_class::<PyKeypoint>()?;
Ok(())
}

376
python/src/bindings/pose.rs Normal file
View file

@ -0,0 +1,376 @@
//! ADR-117 P2 — PyO3 bindings for `BoundingBox`, `PersonPose`,
//! `PoseEstimate`.
//!
//! Design notes:
//!
//! 1. **`PersonPose` exposes the 17-keypoint array as a Python dict
//! keyed by `KeypointType`**, not as a fixed-length list with
//! `None` slots. Pythonistas don't want to know that the underlying
//! storage is `[Option<Keypoint>; 17]`.
//!
//! 2. **`PoseEstimate` metadata `id` and `timestamp` are exposed as
//! strings** (UUID + RFC 3339) rather than as bound types. Users
//! in notebooks rarely need to compare UUIDs structurally; strings
//! are good enough and don't require binding `FrameId` /
//! `Timestamp` as separate classes.
//!
//! 3. **`PersonPose` is mutable** via `set_keypoint` / `set_bbox` /
//! `set_id` — it's a builder-style type users construct
//! incrementally. Hence NOT `#[pyclass(frozen)]`.
//!
//! 4. **`PoseEstimate` is frozen** — once constructed, the list of
//! persons + the metadata don't change.
use std::collections::HashMap;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use wifi_densepose_core::{
BoundingBox, Confidence, KeypointType, PersonPose, PoseEstimate,
};
use super::keypoint::{PyKeypoint, PyKeypointType};
// ─── BoundingBox ─────────────────────────────────────────────────────
/// Axis-aligned bounding box around a detected person.
///
/// Python:
/// ```python
/// from wifi_densepose import BoundingBox
///
/// bb = BoundingBox(0.1, 0.2, 0.5, 0.7)
/// print(bb.width, bb.height, bb.area, bb.center)
/// bb2 = BoundingBox.from_center(0.3, 0.45, 0.4, 0.5)
/// print(bb.iou(bb2))
/// ```
#[pyclass(frozen, name = "BoundingBox")]
#[derive(Clone)]
pub struct PyBoundingBox {
inner: BoundingBox,
}
#[pymethods]
impl PyBoundingBox {
#[new]
fn new(x_min: f32, y_min: f32, x_max: f32, y_max: f32) -> Self {
Self { inner: BoundingBox::new(x_min, y_min, x_max, y_max) }
}
/// Construct from center point + width + height.
#[staticmethod]
fn from_center(cx: f32, cy: f32, width: f32, height: f32) -> Self {
Self { inner: BoundingBox::from_center(cx, cy, width, height) }
}
#[getter]
fn x_min(&self) -> f32 { self.inner.x_min }
#[getter]
fn y_min(&self) -> f32 { self.inner.y_min }
#[getter]
fn x_max(&self) -> f32 { self.inner.x_max }
#[getter]
fn y_max(&self) -> f32 { self.inner.y_max }
#[getter]
fn width(&self) -> f32 { self.inner.width() }
#[getter]
fn height(&self) -> f32 { self.inner.height() }
#[getter]
fn area(&self) -> f32 { self.inner.area() }
#[getter]
fn center(&self) -> (f32, f32) { self.inner.center() }
/// Intersection over Union (IoU) with another box. Range [0.0, 1.0].
fn iou(&self, other: &PyBoundingBox) -> f32 {
self.inner.iou(&other.inner)
}
fn __repr__(&self) -> String {
format!(
"BoundingBox(x_min={}, y_min={}, x_max={}, y_max={})",
self.inner.x_min, self.inner.y_min, self.inner.x_max, self.inner.y_max,
)
}
fn __eq__(&self, other: &PyBoundingBox) -> bool {
self.inner == other.inner
}
}
impl PyBoundingBox {
pub(crate) fn from_rust(bb: BoundingBox) -> Self {
Self { inner: bb }
}
}
// ─── PersonPose ──────────────────────────────────────────────────────
/// A single detected person with optional ID, up to 17 keypoints, and
/// an optional bounding box.
///
/// Python:
/// ```python
/// from wifi_densepose import PersonPose, Keypoint, KeypointType, BoundingBox
///
/// pose = PersonPose()
/// pose.set_keypoint(Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95))
/// pose.set_keypoint(Keypoint(KeypointType.LeftShoulder, 0.4, 0.5, 0.92))
/// pose.set_id(7)
/// print(pose.visible_keypoint_count) # 2
/// print(pose.get_keypoint(KeypointType.Nose).confidence) # 0.95
/// print(pose.compute_bounding_box()) # auto-derived from visible kp
/// ```
#[pyclass(name = "PersonPose")]
#[derive(Clone)]
pub struct PyPersonPose {
inner: PersonPose,
}
#[pymethods]
impl PyPersonPose {
/// Construct an empty person pose. Set keypoints + bbox + id with
/// the dedicated methods.
#[new]
fn new() -> Self {
Self { inner: PersonPose::new() }
}
/// Per-person track ID. None until set.
#[getter]
fn id(&self) -> Option<u32> {
self.inner.id
}
fn set_id(&mut self, id: u32) {
self.inner.id = Some(id);
}
/// Set or replace a keypoint. The keypoint's type determines its
/// slot in the internal 17-element array.
fn set_keypoint(&mut self, keypoint: PyKeypoint) {
self.inner.set_keypoint(*keypoint.inner());
}
/// Get a keypoint by type, or None if not set.
fn get_keypoint(&self, keypoint_type: PyKeypointType) -> Option<PyKeypoint> {
let kp = self.inner.get_keypoint(keypoint_type.as_rust())?;
// Re-wrap the inner Rust Keypoint for Python.
Some(PyKeypoint::from_rust(*kp))
}
/// All keypoints as a dict keyed by KeypointType. Missing
/// keypoints are omitted (NOT included with None values).
fn keypoints<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
// PyO3 0.22 — PyDict::new_bound returns a Bound, the legacy
// PyDict::new (returning &PyDict) was removed in 0.21.
let dict = PyDict::new_bound(py);
for (i, kp_opt) in self.inner.keypoints.iter().enumerate() {
if let Some(kp) = kp_opt {
let kpt = match KeypointType::all().get(i) {
Some(t) => *t,
None => continue,
};
// Convert through IntoPy to satisfy ToPyObject bound
// for dict.set_item — #[pyclass] types impl IntoPy but
// not ToPyObject directly in PyO3 0.22.
use pyo3::IntoPy;
let k_obj: PyObject = PyKeypointType::from_rust(kpt).into_py(py);
let v_obj: PyObject = PyKeypoint::from_rust(*kp).into_py(py);
dict.set_item(k_obj, v_obj)?;
}
}
Ok(dict)
}
/// Number of visible keypoints (confidence >= 0.5).
#[getter]
fn visible_keypoint_count(&self) -> usize {
self.inner.visible_keypoint_count()
}
/// List of visible keypoints (subset of the dict from
/// `keypoints()`).
fn visible_keypoints(&self) -> Vec<PyKeypoint> {
self.inner
.visible_keypoints()
.into_iter()
.map(|k| PyKeypoint::from_rust(*k))
.collect()
}
/// Bounding box, if previously set or computed.
#[getter]
fn bounding_box(&self) -> Option<PyBoundingBox> {
self.inner.bounding_box.map(PyBoundingBox::from_rust)
}
fn set_bounding_box(&mut self, bb: PyBoundingBox) {
self.inner.bounding_box = Some(bb.inner);
}
/// Auto-compute bounding box from visible keypoints, set it
/// internally, and return it. Returns None if no keypoints visible.
fn compute_bounding_box(&mut self) -> Option<PyBoundingBox> {
let bb = self.inner.compute_bounding_box()?;
self.inner.bounding_box = Some(bb);
Some(PyBoundingBox::from_rust(bb))
}
/// Overall confidence in [0.0, 1.0].
#[getter]
fn confidence(&self) -> f32 {
self.inner.confidence.value()
}
fn set_confidence(&mut self, c: f32) -> PyResult<()> {
self.inner.confidence = Confidence::new(c).map_err(|e| {
pyo3::exceptions::PyValueError::new_err(e.to_string())
})?;
Ok(())
}
fn __repr__(&self) -> String {
format!(
"PersonPose(id={:?}, visible_keypoints={}, confidence={:.4})",
self.inner.id,
self.inner.visible_keypoint_count(),
self.inner.confidence.value(),
)
}
}
impl PyPersonPose {
pub(crate) fn from_rust(pose: PersonPose) -> Self {
Self { inner: pose }
}
}
// ─── PoseEstimate ────────────────────────────────────────────────────
/// Top-level result of a pose-estimation pass — a list of detected
/// persons plus metadata about the inference run.
///
/// Python:
/// ```python
/// from wifi_densepose import PoseEstimate, PersonPose
///
/// est = PoseEstimate([pose1, pose2], confidence=0.87, latency_ms=8.4,
/// model_version="v0.1.0")
/// print(est.person_count, est.has_detections)
/// best = est.highest_confidence_person()
/// ```
#[pyclass(frozen, name = "PoseEstimate")]
pub struct PyPoseEstimate {
inner: PoseEstimate,
}
#[pymethods]
impl PyPoseEstimate {
/// Construct a pose estimate from a list of detected persons,
/// an overall confidence, inference latency, and model version
/// string.
#[new]
fn new(
persons: Vec<PyPersonPose>,
confidence: f32,
latency_ms: f32,
model_version: String,
) -> PyResult<Self> {
let conf = Confidence::new(confidence).map_err(|e| {
pyo3::exceptions::PyValueError::new_err(e.to_string())
})?;
let rust_persons: Vec<PersonPose> =
persons.into_iter().map(|p| p.inner).collect();
Ok(Self {
inner: PoseEstimate::new(
Vec::new(),
rust_persons,
conf,
latency_ms,
model_version,
),
})
}
/// Unique frame identifier as a UUID string.
#[getter]
fn id(&self) -> String {
format!("{:?}", self.inner.id)
.trim_start_matches("FrameId(")
.trim_end_matches(')')
.to_string()
}
/// Frame timestamp as an RFC 3339 / ISO 8601 string in UTC.
#[getter]
fn timestamp(&self) -> String {
// Timestamp's Debug impl is usable; for a fully spec-compliant
// ISO format, a future refactor binds chrono. P2 string-form
// is "good enough" for diagnostics.
format!("{:?}", self.inner.timestamp)
}
#[getter]
fn persons(&self) -> Vec<PyPersonPose> {
self.inner.persons.iter().cloned().map(PyPersonPose::from_rust).collect()
}
#[getter]
fn confidence(&self) -> f32 {
self.inner.confidence.value()
}
#[getter]
fn latency_ms(&self) -> f32 {
self.inner.latency_ms
}
#[getter]
fn model_version(&self) -> &str {
&self.inner.model_version
}
#[getter]
fn person_count(&self) -> usize {
self.inner.person_count()
}
#[getter]
fn has_detections(&self) -> bool {
self.inner.has_detections()
}
/// Get the person with the highest individual confidence, or None
/// if no persons detected.
fn highest_confidence_person(&self) -> Option<PyPersonPose> {
self.inner
.highest_confidence_person()
.cloned()
.map(PyPersonPose::from_rust)
}
fn __repr__(&self) -> String {
format!(
"PoseEstimate(persons={}, confidence={:.4}, latency_ms={:.2}, model_version={:?})",
self.inner.person_count(),
self.inner.confidence.value(),
self.inner.latency_ms,
self.inner.model_version,
)
}
}
/// Suppress unused-import warnings for HashMap (held for future
/// keypoint-map helpers in P3).
#[allow(dead_code)]
fn _hashmap_kept_for_future_use() -> HashMap<u8, u8> {
HashMap::new()
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyBoundingBox>()?;
m.add_class::<PyPersonPose>()?;
m.add_class::<PyPoseEstimate>()?;
Ok(())
}

View file

@ -0,0 +1,287 @@
//! ADR-117 P3 — PyO3 bindings for `wifi_densepose_vitals`.
//!
//! Surfaces:
//!
//! - `VitalStatus` enum — clinical-grade / degraded / unreliable / unavailable
//! - `VitalEstimate` — single BPM estimate + confidence + status
//! - `VitalReading` — combined HR + BR + signal quality snapshot
//! - `BreathingExtractor` — bandpass 0.10.5 Hz → respiratory rate
//! - `HeartRateExtractor` — bandpass 0.82.0 Hz + autocorrelation → HR
//!
//! ## GIL release strategy (per ADR-117 §7 and the Q5 audit on
//! 2026-05-24)
//!
//! `wifi-densepose-vitals` has zero tokio deps and the extract loops
//! are pure-sync DSP. Wrap the `.extract(...)` calls in
//! `py.allow_threads(|| ...)` so Python users can run inference in a
//! tokio-backed web server without GIL contention starving the
//! event loop.
use pyo3::prelude::*;
use wifi_densepose_vitals::{
BreathingExtractor, HeartRateExtractor, VitalEstimate, VitalReading, VitalStatus,
};
// ─── VitalStatus enum ────────────────────────────────────────────────
/// Status of a vital sign measurement.
///
/// Python:
/// ```python
/// from wifi_densepose import VitalStatus
/// VitalStatus.Valid # clinical-grade
/// VitalStatus.Degraded # reduced confidence
/// VitalStatus.Unreliable # single RSSI source / low quality
/// VitalStatus.Unavailable # no measurement possible
/// ```
#[pyclass(eq, eq_int, hash, frozen, name = "VitalStatus")]
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum PyVitalStatus {
Valid = 0,
Degraded = 1,
Unreliable = 2,
Unavailable = 3,
}
#[pymethods]
impl PyVitalStatus {
fn __repr__(&self) -> String {
format!("VitalStatus.{:?}", self.as_rust())
}
}
impl PyVitalStatus {
fn as_rust(&self) -> VitalStatus {
match self {
Self::Valid => VitalStatus::Valid,
Self::Degraded => VitalStatus::Degraded,
Self::Unreliable => VitalStatus::Unreliable,
Self::Unavailable => VitalStatus::Unavailable,
}
}
fn from_rust(s: VitalStatus) -> Self {
match s {
VitalStatus::Valid => Self::Valid,
VitalStatus::Degraded => Self::Degraded,
VitalStatus::Unreliable => Self::Unreliable,
VitalStatus::Unavailable => Self::Unavailable,
}
}
}
// ─── VitalEstimate ───────────────────────────────────────────────────
/// A single vital-sign estimate (BPM + confidence + status).
///
/// Python:
/// ```python
/// from wifi_densepose import VitalEstimate, VitalStatus
/// est = VitalEstimate(72.4, confidence=0.9, status=VitalStatus.Valid)
/// print(est.value_bpm, est.confidence, est.status)
/// ```
#[pyclass(frozen, name = "VitalEstimate")]
#[derive(Clone)]
pub struct PyVitalEstimate {
inner: VitalEstimate,
}
#[pymethods]
impl PyVitalEstimate {
#[new]
fn new(value_bpm: f64, confidence: f64, status: PyVitalStatus) -> Self {
Self {
inner: VitalEstimate {
value_bpm,
confidence,
status: status.as_rust(),
},
}
}
#[getter]
fn value_bpm(&self) -> f64 { self.inner.value_bpm }
#[getter]
fn confidence(&self) -> f64 { self.inner.confidence }
#[getter]
fn status(&self) -> PyVitalStatus { PyVitalStatus::from_rust(self.inner.status) }
fn __repr__(&self) -> String {
format!(
"VitalEstimate(value_bpm={:.2}, confidence={:.3}, status={:?})",
self.inner.value_bpm, self.inner.confidence, self.inner.status,
)
}
}
impl PyVitalEstimate {
fn from_rust(e: VitalEstimate) -> Self {
Self { inner: e }
}
}
// ─── VitalReading ────────────────────────────────────────────────────
/// Combined HR + BR snapshot from one window of CSI data.
#[pyclass(frozen, name = "VitalReading")]
pub struct PyVitalReading {
inner: VitalReading,
}
#[pymethods]
impl PyVitalReading {
#[new]
fn new(
respiratory_rate: PyVitalEstimate,
heart_rate: PyVitalEstimate,
subcarrier_count: usize,
signal_quality: f64,
timestamp_secs: f64,
) -> Self {
Self {
inner: VitalReading {
respiratory_rate: respiratory_rate.inner,
heart_rate: heart_rate.inner,
subcarrier_count,
signal_quality,
timestamp_secs,
},
}
}
#[getter]
fn respiratory_rate(&self) -> PyVitalEstimate {
PyVitalEstimate::from_rust(self.inner.respiratory_rate.clone())
}
#[getter]
fn heart_rate(&self) -> PyVitalEstimate {
PyVitalEstimate::from_rust(self.inner.heart_rate.clone())
}
#[getter]
fn subcarrier_count(&self) -> usize { self.inner.subcarrier_count }
#[getter]
fn signal_quality(&self) -> f64 { self.inner.signal_quality }
#[getter]
fn timestamp_secs(&self) -> f64 { self.inner.timestamp_secs }
fn __repr__(&self) -> String {
format!(
"VitalReading(br={:.1}, hr={:.1}, subcarriers={}, quality={:.3})",
self.inner.respiratory_rate.value_bpm,
self.inner.heart_rate.value_bpm,
self.inner.subcarrier_count,
self.inner.signal_quality,
)
}
}
// ─── BreathingExtractor ──────────────────────────────────────────────
/// Extracts respiratory rate (630 BPM) from per-subcarrier amplitude
/// residuals via 0.10.5 Hz bandpass + zero-crossing analysis.
///
/// Python:
/// ```python
/// from wifi_densepose import BreathingExtractor
///
/// br = BreathingExtractor.esp32_default() # 56 subcarriers, 100 Hz, 30s window
/// # or: BreathingExtractor(n_subcarriers=56, sample_rate=100.0, window_secs=30.0)
///
/// # Feed residuals from your preprocessor (one frame at a time)
/// est = br.extract(residuals=[0.01, -0.02, …], weights=[]) # equal weights
/// if est is not None:
/// print(est.value_bpm, est.confidence)
/// ```
#[pyclass(name = "BreathingExtractor")]
pub struct PyBreathingExtractor {
inner: BreathingExtractor,
}
#[pymethods]
impl PyBreathingExtractor {
/// Construct with explicit parameters.
#[new]
#[pyo3(signature = (n_subcarriers, sample_rate, window_secs=30.0))]
fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self {
Self {
inner: BreathingExtractor::new(n_subcarriers, sample_rate, window_secs),
}
}
/// ESP32 defaults: 56 subcarriers, 100 Hz, 30-second window.
#[staticmethod]
fn esp32_default() -> Self {
Self { inner: BreathingExtractor::esp32_default() }
}
/// Extract respiratory rate from a vector of per-subcarrier
/// residuals + per-subcarrier weights. GIL is released during the
/// DSP loop so Python threads can do other work concurrently.
///
/// Returns `None` if insufficient history has been accumulated.
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
// GIL release: see ADR-117 §7 and the Q5 tokio audit. The DSP
// loop is pure sync, no Python objects touched, safe to run
// without the GIL.
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
est.map(PyVitalEstimate::from_rust)
}
fn __repr__(&self) -> String {
format!("BreathingExtractor(0.10.5 Hz bandpass)")
}
}
// ─── HeartRateExtractor ──────────────────────────────────────────────
/// Extracts heart rate (40120 BPM) from per-subcarrier amplitude
/// residuals via 0.82.0 Hz bandpass + autocorrelation peak detection.
#[pyclass(name = "HeartRateExtractor")]
pub struct PyHeartRateExtractor {
inner: HeartRateExtractor,
}
#[pymethods]
impl PyHeartRateExtractor {
/// Construct with explicit parameters.
#[new]
#[pyo3(signature = (n_subcarriers, sample_rate, window_secs=15.0))]
fn new(n_subcarriers: usize, sample_rate: f64, window_secs: f64) -> Self {
Self {
inner: HeartRateExtractor::new(n_subcarriers, sample_rate, window_secs),
}
}
/// ESP32 defaults: 56 subcarriers, 100 Hz, 15-second window.
#[staticmethod]
fn esp32_default() -> Self {
Self { inner: HeartRateExtractor::esp32_default() }
}
/// Extract heart rate from per-subcarrier residuals. GIL released
/// during DSP.
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
est.map(PyVitalEstimate::from_rust)
}
fn __repr__(&self) -> String {
format!("HeartRateExtractor(0.82.0 Hz bandpass)")
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyVitalStatus>()?;
m.add_class::<PyVitalEstimate>()?;
m.add_class::<PyVitalReading>()?;
m.add_class::<PyBreathingExtractor>()?;
m.add_class::<PyHeartRateExtractor>()?;
Ok(())
}

84
python/src/lib.rs Normal file
View file

@ -0,0 +1,84 @@
//! ADR-117 — PyO3 bindings for the WiFi-DensePose Rust core.
//!
//! This crate is the compiled half of the `wifi-densepose` v2.x PyPI
//! wheel. The Python-facing facade lives in `python/wifi_densepose/`
//! and re-exports symbols from this module under their stable names.
//!
//! ## Phase status (per ADR-117 §6)
//!
//! - **P1 (scaffold) — this commit**: module loads, version constant
//! exposed, smoke test passes via maturin develop.
//! - **P2**: bind `CsiFrame`, `Keypoint`, `PoseEstimate` (next).
//! - **P3**: bind 4-stage vitals + signal DSP.
//! - **P4**: pure-Python `wifi_densepose.client` (WS/MQTT) — no Rust
//! surface needed; lives outside this crate.
//! - **P5**: cibuildwheel + PyPI publish.
use pyo3::prelude::*;
mod bindings {
pub mod bfld;
pub mod keypoint;
pub mod pose;
pub mod vitals;
}
/// Version of the bound Rust core. Surfaced to Python as
/// `wifi_densepose.__rust_version__` so users can correlate wheel
/// behaviour with the exact `v2/crates/` HEAD it was built from.
const RUST_CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Compile-time identifier for the Rust commit that produced this
/// wheel. Surfaced for diagnostics. Set via `CARGO_PKG_VERSION` for
/// now; P5 wires in the git SHA via `vergen`.
const RUST_BUILD_TAG: &str = env!("CARGO_PKG_VERSION");
/// One-line description of which feature flags were enabled at build
/// time. Helps users debug "is my wheel the slim one or the full one?".
fn build_features() -> Vec<&'static str> {
let mut feats: Vec<&'static str> = Vec::new();
feats.push("p1-scaffold");
feats.push("p2-keypoint-bindings"); // Keypoint + KeypointType
feats.push("p2-pose-bindings"); // BoundingBox + PersonPose + PoseEstimate
feats.push("p3-vitals-bindings"); // BreathingExtractor + HeartRateExtractor + VitalEstimate
feats.push("p3.5-bfld-bindings"); // BfldFrame + BfldReport + BfldKind (stub Rust)
feats
}
/// Quick smoke test exposed to Python. Returns "ok" — used by the
/// integration tests in `python/tests/test_smoke.py` to assert the
/// PyO3 module is importable and callable.
#[pyfunction]
fn hello() -> PyResult<&'static str> {
Ok("ok")
}
/// The `_native` module — re-exported in pure-Python as
/// `wifi_densepose._native`. End users should import the parent
/// package (`import wifi_densepose`) and never reach into `_native`
/// directly; the leading underscore is a Python convention marking
/// it as private.
///
/// The function name MUST match the `module-name` in pyproject.toml's
/// `[tool.maturin]` block — i.e. it must be `_native` because the
/// pyproject says `module-name = "wifi_densepose._native"`. PyO3
/// generates the `PyInit__native` symbol from this function name.
#[pymodule]
#[pyo3(name = "_native")]
fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("__rust_version__", RUST_CORE_VERSION)?;
m.add("__rust_build_tag__", RUST_BUILD_TAG)?;
m.add("__build_features__", build_features())?;
m.add_function(wrap_pyfunction!(hello, m)?)?;
// P2 — Keypoint + KeypointType bindings.
bindings::keypoint::register(m)?;
// P2 — BoundingBox + PersonPose + PoseEstimate bindings.
bindings::pose::register(m)?;
// P3 — Vital sign extraction bindings.
bindings::vitals::register(m)?;
// P3.5 — BFLD bindings (stub Rust; future wifi-densepose-bfld crate
// will replace the stub without changing the Python API).
bindings::bfld::register(m)?;
Ok(())
}

263
python/tests/test_bfld.py Normal file
View file

@ -0,0 +1,263 @@
"""ADR-117 P3.5 — Tests for BFLD (Beamforming Feedback Loop Data) bindings.
These tests cover the *stub-Rust-backed* forward-compatible Python
surface defined in ADR-117 §5.7a. The real Rust ingestion crate
(`wifi-densepose-bfld`) lands post-v2.0; this test suite locks in the
Python API so a future swap-in is non-breaking.
Coverage:
- BfldKind enum HE20/40/80/160 + HT20/40 variants
- BfldKind metadata getters n_subcarriers, bandwidth_mhz, is_he
- BfldFrame.from_compressed_feedback happy path + dim mismatch
- BfldFrame numpy round-trip feedback_matrix returns ndarray
- BfldReport frame aggregation, kind-mismatch error, coherence score
"""
from __future__ import annotations
import math
import numpy as np
import pytest
import wifi_densepose
from wifi_densepose import BfldFrame, BfldKind, BfldReport
# ─── BfldKind enum ───────────────────────────────────────────────────
def test_bfld_kind_variants_exist() -> None:
assert BfldKind.CompressedHE20 != BfldKind.CompressedHE40
assert BfldKind.CompressedHE80 != BfldKind.CompressedHE160
assert BfldKind.UncompressedHT20 != BfldKind.UncompressedHT40
def test_bfld_kind_is_hashable() -> None:
s = {BfldKind.CompressedHE80, BfldKind.CompressedHE80}
assert len(s) == 1
def test_bfld_kind_n_subcarriers_he() -> None:
assert BfldKind.CompressedHE20.n_subcarriers == 242
assert BfldKind.CompressedHE40.n_subcarriers == 484
assert BfldKind.CompressedHE80.n_subcarriers == 996
assert BfldKind.CompressedHE160.n_subcarriers == 1992
def test_bfld_kind_n_subcarriers_ht() -> None:
assert BfldKind.UncompressedHT20.n_subcarriers == 52
assert BfldKind.UncompressedHT40.n_subcarriers == 108
def test_bfld_kind_bandwidth_mhz() -> None:
assert BfldKind.CompressedHE20.bandwidth_mhz == 20
assert BfldKind.CompressedHE40.bandwidth_mhz == 40
assert BfldKind.CompressedHE80.bandwidth_mhz == 80
assert BfldKind.CompressedHE160.bandwidth_mhz == 160
assert BfldKind.UncompressedHT20.bandwidth_mhz == 20
assert BfldKind.UncompressedHT40.bandwidth_mhz == 40
def test_bfld_kind_is_he_flag() -> None:
assert BfldKind.CompressedHE20.is_he is True
assert BfldKind.CompressedHE160.is_he is True
assert BfldKind.UncompressedHT20.is_he is False
assert BfldKind.UncompressedHT40.is_he is False
def test_bfld_kind_repr() -> None:
r = repr(BfldKind.CompressedHE80)
assert "BfldKind" in r and "CompressedHE80" in r
# ─── BfldFrame construction ──────────────────────────────────────────
def _make_matrix(n_rows: int, n_cols: int, n_subcarriers: int) -> np.ndarray:
"""Synthetic feedback matrix with non-trivial amplitudes so the
mean_amplitude getter has something to chew on."""
rng = np.random.default_rng(seed=42)
real = rng.standard_normal((n_rows, n_cols, n_subcarriers)).astype(np.float64)
imag = rng.standard_normal((n_rows, n_cols, n_subcarriers)).astype(np.float64)
return (real + 1j * imag).astype(np.complex128)
def test_bfld_frame_he80_happy_path() -> None:
fb = _make_matrix(2, 1, 996)
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=1234,
sounding_index=42,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb,
)
assert frame.timestamp_ms == 1234
assert frame.sounding_index == 42
assert frame.sta_mac == "aa:bb:cc:dd:ee:ff"
assert frame.kind == BfldKind.CompressedHE80
assert frame.n_rows == 2
assert frame.n_cols == 1
assert frame.n_subcarriers == 996
def test_bfld_frame_he160_2x2() -> None:
fb = _make_matrix(2, 2, 1992)
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="00:00:00:00:00:00",
kind=BfldKind.CompressedHE160,
feedback_matrix=fb,
)
assert frame.n_rows == 2
assert frame.n_cols == 2
assert frame.n_subcarriers == 1992
def test_bfld_frame_ht20_legacy_path() -> None:
fb = _make_matrix(1, 1, 52)
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.UncompressedHT20,
feedback_matrix=fb,
)
assert frame.kind == BfldKind.UncompressedHT20
assert frame.n_subcarriers == 52
def test_bfld_frame_subcarrier_dim_mismatch_raises() -> None:
# HE80 requires 996 subcarriers; pass 64 → ValueError.
bad = _make_matrix(2, 1, 64)
with pytest.raises(ValueError, match="subcarrier"):
BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=bad,
)
def test_bfld_frame_mean_amplitude_is_finite() -> None:
fb = _make_matrix(2, 1, 996)
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb,
)
amp = frame.mean_amplitude
assert math.isfinite(amp) and amp > 0.0
def test_bfld_frame_numpy_roundtrip_preserves_shape() -> None:
fb = _make_matrix(2, 1, 996)
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb,
)
out = frame.feedback_matrix()
assert out.shape == (2, 1, 996)
# Roundtrip should be lossless (Complex64 in, Complex64 out).
assert np.allclose(out, fb.astype(np.complex128))
def test_bfld_frame_repr_is_readable() -> None:
fb = _make_matrix(2, 1, 996)
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb,
)
r = repr(frame)
assert "BfldFrame" in r
assert "996" in r
assert "CompressedHE80" in r
# ─── BfldReport ──────────────────────────────────────────────────────
def test_bfld_report_starts_empty() -> None:
report = BfldReport()
assert report.n_frames == 0
assert report.kind is None
assert report.timestamp_first is None
assert report.timestamp_last is None
assert report.coherence_score == 0.0
def test_bfld_report_aggregates_homogeneous_frames() -> None:
report = BfldReport()
fb = _make_matrix(2, 1, 996)
for i in range(5):
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=1000 + i * 100,
sounding_index=i,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb,
)
report.add_frame(frame)
assert report.n_frames == 5
assert report.kind == BfldKind.CompressedHE80
assert report.timestamp_first == 1000
assert report.timestamp_last == 1400
# Identical synthetic matrices → near-perfect coherence.
assert report.coherence_score >= 0.99
def test_bfld_report_rejects_mismatched_kind() -> None:
report = BfldReport()
fb_he80 = _make_matrix(2, 1, 996)
fb_he40 = _make_matrix(2, 1, 484)
he80 = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb_he80,
)
he40 = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE40,
feedback_matrix=fb_he40,
)
report.add_frame(he80)
with pytest.raises(ValueError, match="kind"):
report.add_frame(he40)
def test_bfld_report_repr_summarises() -> None:
report = BfldReport()
fb = _make_matrix(2, 1, 996)
frame = BfldFrame.from_compressed_feedback(
timestamp_ms=0,
sounding_index=0,
sta_mac="aa:bb:cc:dd:ee:ff",
kind=BfldKind.CompressedHE80,
feedback_matrix=fb,
)
report.add_frame(frame)
r = repr(report)
assert "BfldReport" in r
assert "n_frames=1" in r
# ─── Build feature flag ──────────────────────────────────────────────
def test_p3_5_bfld_in_build_features() -> None:
assert "p3.5-bfld-bindings" in wifi_densepose.__build_features__

View file

@ -0,0 +1,205 @@
"""ADR-117 P4 — Tests for HA-DISCO payload parsing.
Pure parsing tests no MQTT broker needed.
"""
from __future__ import annotations
import json
import pytest
from wifi_densepose.client import (
HABlueprintHelper,
HaDiscoveryPayload,
HaEntity,
)
from wifi_densepose.client.ha import (
parse_discovery_payload,
parse_discovery_topic,
)
# Real discovery payloads pulled from ADR-115 §3 (formatted for test
# readability; payloads are otherwise verbatim).
_PRESENCE_TOPIC = "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/config"
_PRESENCE_BODY = {
"name": "Presence",
"unique_id": "wifi_densepose_aabbccddeeff_presence",
"object_id": "wifi_densepose_aabbccddeeff_presence",
"state_topic": "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/state",
"availability_topic": "homeassistant/binary_sensor/wifi_densepose_aabbccddeeff/presence/availability",
"device_class": "occupancy",
"icon": "mdi:motion-sensor",
}
_HEART_RATE_TOPIC = "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/config"
_HEART_RATE_BODY = {
"name": "Heart rate",
"unique_id": "wifi_densepose_aabbccddeeff_heart_rate",
"state_topic": "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/state",
"state_class": "measurement",
"unit_of_measurement": "bpm",
"icon": "mdi:heart-pulse",
"json_attributes_topic": "homeassistant/sensor/wifi_densepose_aabbccddeeff/heart_rate/state",
}
# ─── Topic parsing ───────────────────────────────────────────────────
def test_parse_discovery_topic_binary_sensor() -> None:
out = parse_discovery_topic(_PRESENCE_TOPIC)
assert out == ("binary_sensor", "aabbccddeeff", "presence")
def test_parse_discovery_topic_sensor() -> None:
out = parse_discovery_topic(_HEART_RATE_TOPIC)
assert out == ("sensor", "aabbccddeeff", "heart_rate")
def test_parse_discovery_topic_event() -> None:
out = parse_discovery_topic(
"homeassistant/event/wifi_densepose_aabbccddeeff/fall/config"
)
assert out == ("event", "aabbccddeeff", "fall")
def test_parse_discovery_topic_returns_none_for_non_discovery() -> None:
assert parse_discovery_topic("homeassistant/binary_sensor/foo/state") is None
assert parse_discovery_topic("ruview/aabbccddeeff/raw/edge_vitals") is None
assert parse_discovery_topic("") is None
# ─── Payload parsing ─────────────────────────────────────────────────
def test_parse_discovery_payload_from_dict() -> None:
out = parse_discovery_payload(_PRESENCE_TOPIC, _PRESENCE_BODY)
assert out is not None
assert out.entity_kind == "binary_sensor"
assert out.node_id == "aabbccddeeff"
assert out.object_id == "presence"
assert out.payload["device_class"] == "occupancy"
def test_parse_discovery_payload_from_bytes() -> None:
raw = json.dumps(_PRESENCE_BODY).encode("utf-8")
out = parse_discovery_payload(_PRESENCE_TOPIC, raw)
assert out is not None
assert out.payload["unique_id"] == "wifi_densepose_aabbccddeeff_presence"
def test_parse_discovery_payload_from_string() -> None:
raw = json.dumps(_PRESENCE_BODY)
out = parse_discovery_payload(_PRESENCE_TOPIC, raw)
assert out is not None
assert out.entity_kind == "binary_sensor"
def test_parse_discovery_payload_rejects_malformed_json() -> None:
assert parse_discovery_payload(_PRESENCE_TOPIC, "{ broken: json") is None
def test_parse_discovery_payload_rejects_non_object_root() -> None:
assert parse_discovery_payload(_PRESENCE_TOPIC, "[1, 2, 3]") is None
def test_parse_discovery_payload_returns_none_for_non_discovery_topic() -> None:
assert parse_discovery_payload(
"ruview/aabbccddeeff/raw/edge_vitals",
_PRESENCE_BODY,
) is None
# ─── HaEntity projection ─────────────────────────────────────────────
def test_ha_entity_from_payload_extracts_fields() -> None:
p = HaDiscoveryPayload(
entity_kind="sensor",
node_id="aabbccddeeff",
object_id="heart_rate",
payload=_HEART_RATE_BODY,
)
e = HaEntity.from_payload(p)
assert e.entity_kind == "sensor"
assert e.unique_id == "wifi_densepose_aabbccddeeff_heart_rate"
assert e.unit_of_measurement == "bpm"
assert e.icon == "mdi:heart-pulse"
assert e.json_attributes_topic == _HEART_RATE_BODY["json_attributes_topic"]
def test_ha_entity_handles_missing_optional_fields() -> None:
p = HaDiscoveryPayload(
entity_kind="event",
node_id="aabbccddeeff",
object_id="bed_exit",
payload={"unique_id": "wifi_densepose_aabbccddeeff_bed_exit"},
)
e = HaEntity.from_payload(p)
assert e.unique_id == "wifi_densepose_aabbccddeeff_bed_exit"
assert e.device_class == ""
assert e.unit_of_measurement == ""
# ─── HABlueprintHelper aggregation ───────────────────────────────────
def _populated_helper() -> HABlueprintHelper:
h = HABlueprintHelper()
h.add_payload(_PRESENCE_TOPIC, _PRESENCE_BODY)
h.add_payload(_HEART_RATE_TOPIC, _HEART_RATE_BODY)
# Same fields but a different node
h.add_payload(
"homeassistant/binary_sensor/wifi_densepose_ff00ff00ff00/presence/config",
{**_PRESENCE_BODY, "unique_id": "wifi_densepose_ff00ff00ff00_presence"},
)
return h
def test_helper_starts_empty() -> None:
h = HABlueprintHelper()
assert len(h) == 0
assert h.nodes() == []
assert h.all_payloads() == []
def test_helper_aggregates_multiple_payloads() -> None:
h = _populated_helper()
assert len(h) == 3
assert h.nodes() == ["aabbccddeeff", "ff00ff00ff00"]
def test_helper_entities_for_node() -> None:
h = _populated_helper()
entities = h.entities_for_node("aabbccddeeff")
object_ids = sorted(e.object_id for e in entities)
assert object_ids == ["heart_rate", "presence"]
def test_helper_by_device_class() -> None:
h = _populated_helper()
occupancy_entities = h.by_device_class("occupancy")
assert len(occupancy_entities) == 2 # presence on both nodes
assert {e.node_id for e in occupancy_entities} == {"aabbccddeeff", "ff00ff00ff00"}
def test_helper_remove() -> None:
h = _populated_helper()
assert h.remove("aabbccddeeff", "binary_sensor", "presence") is True
assert h.remove("aabbccddeeff", "binary_sensor", "presence") is False # no-op
assert len(h) == 2
def test_helper_rejects_non_discovery_topics() -> None:
h = HABlueprintHelper()
ok = h.add_payload("ruview/aabbccddeeff/raw/edge_vitals", _PRESENCE_BODY)
assert ok is False
assert len(h) == 0
def test_helper_in_operator() -> None:
h = _populated_helper()
assert ("aabbccddeeff", "binary_sensor", "presence") in h
assert ("nonexistent", "binary_sensor", "presence") not in h

View file

@ -0,0 +1,208 @@
"""ADR-117 P4 — Tests for RuViewMqttClient.
These tests do NOT bring up a broker they exercise:
1. Topic-wildcard matching (`_topic_matches`)
2. Client construction + handler registration
3. The callback path by directly invoking the paho callback methods
with synthesized messages
End-to-end broker integration is a P4-followon item (the mosquitto
patterns from memory [[feedback_mqtt_integration_test_patterns]] go
there). This file keeps unit coverage tight without requiring a
broker on every CI run.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
import pytest
from wifi_densepose.client import RuViewMqttClient
from wifi_densepose.client.mqtt import _topic_matches
# ─── Topic wildcard matcher ──────────────────────────────────────────
@pytest.mark.parametrize("pattern,topic,expected", [
("ruview/+/raw/edge_vitals", "ruview/aabb/raw/edge_vitals", True),
("ruview/+/raw/edge_vitals", "ruview/aabb/cooked/edge_vitals", False),
("ruview/+/raw/+", "ruview/aabb/raw/pose", True),
("ruview/+/raw/+", "ruview/aabb/raw/pose/extra", False),
# Per MQTT v5 §4.7.1.2: `+` is a whole-level wildcard only — mid-
# segment `+` is a literal `+` character, not a wildcard. The
# spec-correct way to wildcard the third segment of the HA
# discovery topic is `homeassistant/+/+/+/config`.
("homeassistant/+/+/+/config",
"homeassistant/binary_sensor/wifi_densepose_aabb/presence/config", True),
# `wifi_densepose_+` is therefore NOT a wildcard — it matches the
# literal string only. Asserting that behaviour stays stable.
("homeassistant/+/wifi_densepose_+/+/config",
"homeassistant/binary_sensor/wifi_densepose_aabb/presence/config", False),
("ruview/#", "ruview/aabb/raw/edge_vitals", True),
# Per MQTT v5 §4.7.1.2: `<prefix>/#` ALSO matches the bare
# `<prefix>` itself (it represents "this topic and all sub-topics").
("ruview/#", "ruview", True),
("ruview/+/raw/#", "ruview/aabb/raw/pose/extra", True),
("exact/topic", "exact/topic", True),
("exact/topic", "exact/topic/extra", False),
("a/b/c", "a/b", False),
])
def test_topic_matches(pattern: str, topic: str, expected: bool) -> None:
assert _topic_matches(pattern, topic) is expected
# ─── RuViewMqttClient construction ──────────────────────────────────
def test_client_constructs_with_defaults() -> None:
c = RuViewMqttClient()
assert c.broker_host == "localhost"
assert c.broker_port == 1883
assert c.connected is False
assert c.client_id.startswith("wifi-densepose-client-")
def test_client_unique_client_id_per_instance() -> None:
"""Per the rumqttc memory lesson — each instance needs a unique
client_id so parallel tests don't kick each other off the broker."""
c1 = RuViewMqttClient()
c2 = RuViewMqttClient()
assert c1.client_id != c2.client_id
def test_client_accepts_explicit_client_id() -> None:
c = RuViewMqttClient(client_id="explicit-id")
assert c.client_id == "explicit-id"
# ─── Handler registration ────────────────────────────────────────────
def test_handler_registration_stores_callback() -> None:
c = RuViewMqttClient()
seen: list[Any] = []
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: seen.append((t, p)))
# Internal state — we're allowed to inspect since the handler
# path needs to be unit-testable without a broker.
assert "ruview/+/raw/edge_vitals" in c._handlers
def test_handler_unregister_drops_callback() -> None:
c = RuViewMqttClient()
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: None)
c.unsubscribe_handler("ruview/+/raw/edge_vitals")
assert "ruview/+/raw/edge_vitals" not in c._handlers
# ─── Callback dispatch (synthesized) ─────────────────────────────────
def _fake_message(topic: str, body: Any) -> Any:
"""Synthesize a paho-mqtt MQTTMessage-ish object."""
if isinstance(body, (dict, list)):
payload_bytes = json.dumps(body).encode("utf-8")
elif isinstance(body, bytes):
payload_bytes = body
else:
payload_bytes = str(body).encode("utf-8")
return SimpleNamespace(topic=topic, payload=payload_bytes)
def test_message_dispatch_to_matching_handler() -> None:
c = RuViewMqttClient()
received: list[tuple[str, Any]] = []
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: received.append((t, p)))
msg = _fake_message(
"ruview/aabbccddeeff/raw/edge_vitals",
{"breathing_rate_bpm": 14.0, "heartrate_bpm": 72.0, "presence": True},
)
c._on_message(None, None, msg)
assert len(received) == 1
topic, payload = received[0]
assert topic == "ruview/aabbccddeeff/raw/edge_vitals"
assert payload["breathing_rate_bpm"] == 14.0
def test_message_dispatch_ignores_non_matching_topic() -> None:
c = RuViewMqttClient()
received: list[Any] = []
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: received.append(p))
msg = _fake_message("ruview/aabb/raw/pose", {"persons": []})
c._on_message(None, None, msg)
assert received == []
def test_message_dispatch_falls_back_to_bytes_on_non_json() -> None:
c = RuViewMqttClient()
received: list[Any] = []
c.on_message("custom/binary/+", lambda t, p: received.append(p))
msg = _fake_message("custom/binary/data", b"\x00\x01\x02not-json")
c._on_message(None, None, msg)
assert received == [b"\x00\x01\x02not-json"]
def test_handler_exception_does_not_propagate() -> None:
"""A misbehaving user callback must not crash the paho network
loop exceptions are caught and logged."""
c = RuViewMqttClient()
seen_after_crash: list[Any] = []
def crashing(_topic: str, _p: Any) -> None:
raise RuntimeError("simulated callback crash")
c.on_message("crashy/topic", crashing)
c.on_message("safe/topic", lambda t, p: seen_after_crash.append(p))
# First, the crashing handler — must NOT raise out of _on_message.
c._on_message(None, None, _fake_message("crashy/topic", "anything"))
# Then the safe handler — must still fire on a subsequent message.
c._on_message(None, None, _fake_message("safe/topic", {"x": 1}))
assert seen_after_crash == [{"x": 1}]
def test_multiple_handlers_for_overlapping_patterns_all_fire() -> None:
c = RuViewMqttClient()
a_received: list[Any] = []
b_received: list[Any] = []
c.on_message("ruview/+/raw/+", lambda t, p: a_received.append(p))
c.on_message("ruview/aabb/raw/edge_vitals", lambda t, p: b_received.append(p))
msg = _fake_message("ruview/aabb/raw/edge_vitals", {"presence": True})
c._on_message(None, None, msg)
assert len(a_received) == 1
assert len(b_received) == 1
# ─── on_connect path ─────────────────────────────────────────────────
def test_on_connect_sets_event_and_subscribes() -> None:
c = RuViewMqttClient()
c.on_message("ruview/+/raw/edge_vitals", lambda t, p: None)
# Stub the paho client so we can capture subscribe() calls.
subscribed: list[str] = []
stub = SimpleNamespace(subscribe=lambda pattern: subscribed.append(pattern))
c._on_connect(stub, None, None, 0)
assert c.connected is True
assert subscribed == ["ruview/+/raw/edge_vitals"]
def test_on_connect_with_nonzero_rc_does_not_set_connected() -> None:
c = RuViewMqttClient()
stub = SimpleNamespace(subscribe=lambda pattern: None)
c._on_connect(stub, None, None, 5) # CONNACK fail
assert c.connected is False

View file

@ -0,0 +1,180 @@
"""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 0100 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]

View file

@ -0,0 +1,195 @@
"""ADR-117 P4 — End-to-end test for SensingClient against an in-process
WS server.
We spin up a real `websockets.serve()` server in the same event loop,
send the four message types defined in ADR-115 §1, and assert the
client decodes them into the right dataclasses. No mocks the only
moving part this test does NOT exercise is the actual sensing-server
binary, but the wire protocol is the contract under test here.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
import pytest
import websockets
from wifi_densepose.client import (
ConnectionEstablishedMessage,
EdgeVitalsMessage,
PoseDataMessage,
SensingClient,
SensingMessage,
)
# ─── In-process WS server fixture ────────────────────────────────────
_FIXTURE_MESSAGES = [
{
"type": "connection_established",
"node_id": "test-node-001",
"version": "0.7.4",
"capabilities": ["edge_vitals", "pose_data"],
},
{
"type": "edge_vitals",
"node_id": "test-node-001",
"presence": True,
"fall_detected": False,
"motion": 0.21,
"breathing_rate_bpm": 14.5,
"heartrate_bpm": 72.3,
"n_persons": 1,
"motion_energy": 0.034,
"presence_score": 0.91,
"rssi": -42.0,
},
{
"type": "pose_data",
"node_id": "test-node-001",
"timestamp": 1700000000.5,
"persons": [{"id": 1, "keypoints": []}],
"confidence": 0.88,
},
# Unknown type — should NOT crash the stream; should yield a plain
# SensingMessage.
{
"type": "future_message_type_not_yet_modelled",
"extra": "data",
},
]
async def _handler(websocket: Any) -> None:
for msg in _FIXTURE_MESSAGES:
await websocket.send(json.dumps(msg))
# Send one malformed frame to assert the client logs+drops it
# rather than crashing the stream.
await websocket.send("{not valid json")
# And one final "real" message so the test can confirm the stream
# survived the malformed one.
await websocket.send(json.dumps({"type": "edge_vitals", "node_id": "post-bad-frame"}))
@pytest.fixture
async def ws_server() -> Any:
"""Start a websocket server on a random port; yield the bound URL."""
server = await websockets.serve(_handler, "127.0.0.1", 0)
# Get the bound port (host="127.0.0.1" returns one socket).
port = server.sockets[0].getsockname()[1] # type: ignore[union-attr]
try:
yield f"ws://127.0.0.1:{port}/ws/sensing"
finally:
server.close()
await server.wait_closed()
# ─── End-to-end stream test ──────────────────────────────────────────
async def test_sensing_client_decodes_all_message_types(ws_server: str) -> None:
received: list[SensingMessage] = []
async with SensingClient(ws_server) as client:
async for msg in client.stream():
received.append(msg)
if len(received) >= len(_FIXTURE_MESSAGES) + 1: # +1 for post-bad-frame
break
# connection_established → typed
assert isinstance(received[0], ConnectionEstablishedMessage)
assert received[0].node_id == "test-node-001"
assert received[0].version == "0.7.4"
assert "edge_vitals" in received[0].capabilities
# edge_vitals → typed with full fields
assert isinstance(received[1], EdgeVitalsMessage)
assert received[1].presence is True
assert received[1].fall_detected is False
assert received[1].breathing_rate_bpm == 14.5
assert received[1].heartrate_bpm == 72.3
assert received[1].n_persons == 1
assert received[1].rssi == -42.0
# pose_data → typed
assert isinstance(received[2], PoseDataMessage)
assert received[2].timestamp == 1700000000.5
assert len(received[2].persons) == 1
assert received[2].confidence == 0.88
# Unknown type → plain SensingMessage (forward-compat)
assert type(received[3]) is SensingMessage # exact base class
assert received[3].type == "future_message_type_not_yet_modelled"
assert received[3].raw["extra"] == "data"
# After the malformed frame: the stream should have survived and
# yielded the post-bad-frame message.
assert isinstance(received[4], EdgeVitalsMessage)
assert received[4].node_id == "post-bad-frame"
async def test_sensing_client_recv_one(ws_server: str) -> None:
async with SensingClient(ws_server) as client:
msg = await client.recv_one(timeout=2.0)
assert isinstance(msg, ConnectionEstablishedMessage)
async def test_sensing_client_raises_when_used_without_context() -> None:
client = SensingClient("ws://127.0.0.1:1/") # never connects
with pytest.raises(RuntimeError, match="not connected"):
await client.recv_one(timeout=0.1)
with pytest.raises(RuntimeError, match="not connected"):
async for _ in client.stream():
pass
async def test_sensing_client_close_is_idempotent(ws_server: str) -> None:
client = SensingClient(ws_server)
await client.__aenter__()
await client.close()
await client.close() # second close is a no-op
def test_sensing_client_decoder_directly() -> None:
"""The decoder is pure — exercise it without bringing up a WS
server, so we have a fast unit test for the type mapping."""
from wifi_densepose.client.ws import _decode
msg = _decode(json.dumps({
"type": "edge_vitals",
"node_id": "x",
"presence": True,
"fall_detected": False,
"motion": 1.5,
}))
assert isinstance(msg, EdgeVitalsMessage)
assert msg.presence is True
assert msg.motion == 1.5
assert msg.breathing_rate_bpm is None # not present → None, not 0.0
assert msg.heartrate_bpm is None
assert msg.rssi is None
def test_sensing_client_decoder_handles_None_subfields() -> None:
"""When the sensing-server explicitly emits null for HR/BR (no
measurement yet), the client should propagate None, not crash."""
from wifi_densepose.client.ws import _decode
msg = _decode(json.dumps({
"type": "edge_vitals",
"node_id": "x",
"presence": False,
"fall_detected": False,
"motion": 0.0,
"breathing_rate_bpm": None,
"heartrate_bpm": None,
"rssi": None,
}))
assert isinstance(msg, EdgeVitalsMessage)
assert msg.breathing_rate_bpm is None
assert msg.heartrate_bpm is None
assert msg.rssi is None

View file

@ -0,0 +1,200 @@
"""ADR-117 P2 tests — Keypoint + KeypointType binding round-trips.
Run with: cd python && .venv/Scripts/python -m pytest tests/test_keypoint.py -v
"""
from __future__ import annotations
import pytest
from wifi_densepose import Keypoint, KeypointType
# ─── KeypointType ────────────────────────────────────────────────────
def test_keypoint_type_all_returns_17() -> None:
"""COCO standard defines exactly 17 keypoints."""
assert len(KeypointType.all()) == 17
def test_keypoint_type_index_matches_coco_ordering() -> None:
"""Indexes 0..16 match the COCO canonical ordering."""
expected = [
(KeypointType.Nose, 0),
(KeypointType.LeftEye, 1),
(KeypointType.RightEye, 2),
(KeypointType.LeftEar, 3),
(KeypointType.RightEar, 4),
(KeypointType.LeftShoulder, 5),
(KeypointType.RightShoulder, 6),
(KeypointType.LeftElbow, 7),
(KeypointType.RightElbow, 8),
(KeypointType.LeftWrist, 9),
(KeypointType.RightWrist, 10),
(KeypointType.LeftHip, 11),
(KeypointType.RightHip, 12),
(KeypointType.LeftKnee, 13),
(KeypointType.RightKnee, 14),
(KeypointType.LeftAnkle, 15),
(KeypointType.RightAnkle, 16),
]
for kp, idx in expected:
assert kp.index == idx, f"{kp} expected index {idx} got {kp.index}"
def test_keypoint_type_snake_name() -> None:
"""snake_name follows COCO convention."""
assert KeypointType.Nose.snake_name == "nose"
assert KeypointType.LeftShoulder.snake_name == "left_shoulder"
assert KeypointType.RightAnkle.snake_name == "right_ankle"
def test_keypoint_type_is_face() -> None:
"""is_face() matches the 5 facial keypoints."""
face = {
KeypointType.Nose,
KeypointType.LeftEye,
KeypointType.RightEye,
KeypointType.LeftEar,
KeypointType.RightEar,
}
for kp in KeypointType.all():
assert kp.is_face() == (kp in face)
def test_keypoint_type_is_upper_body() -> None:
"""is_upper_body() catches shoulders, elbows, wrists."""
assert KeypointType.LeftShoulder.is_upper_body()
assert KeypointType.RightShoulder.is_upper_body()
assert KeypointType.LeftElbow.is_upper_body()
assert KeypointType.LeftWrist.is_upper_body()
assert not KeypointType.LeftHip.is_upper_body()
def test_keypoint_type_eq() -> None:
"""Equality + identity work across calls."""
assert KeypointType.Nose == KeypointType.Nose
assert KeypointType.Nose != KeypointType.LeftEye
def test_keypoint_type_repr() -> None:
"""repr is a useful Python expression."""
assert repr(KeypointType.Nose) == "KeypointType.Nose"
assert repr(KeypointType.LeftWrist) == "KeypointType.LeftWrist"
# ─── Keypoint ────────────────────────────────────────────────────────
def test_keypoint_2d_construct() -> None:
"""Default 2D keypoint."""
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
assert kp.x == pytest.approx(0.5)
assert kp.y == pytest.approx(0.3)
assert kp.z is None
assert kp.confidence == pytest.approx(0.95)
assert kp.keypoint_type == KeypointType.Nose
assert kp.is_visible
def test_keypoint_3d_construct() -> None:
"""3D keypoint with kwarg z."""
kp = Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)
assert kp.position_3d == pytest.approx((0.2, 0.4, 0.1))
assert kp.z == pytest.approx(0.1)
def test_keypoint_position_2d_tuple() -> None:
kp = Keypoint(KeypointType.RightHip, 0.6, 0.7, 0.99)
assert kp.position_2d == pytest.approx((0.6, 0.7))
def test_keypoint_position_3d_none_for_2d() -> None:
"""2D keypoints return None for position_3d, not a default z."""
kp = Keypoint(KeypointType.Nose, 0.5, 0.5, 0.99)
assert kp.position_3d is None
def test_keypoint_is_visible_below_threshold() -> None:
"""Confidence under 0.5 is NOT visible (default threshold)."""
kp_low = Keypoint(KeypointType.Nose, 0.0, 0.0, 0.3)
kp_high = Keypoint(KeypointType.Nose, 0.0, 0.0, 0.7)
assert not kp_low.is_visible
assert kp_high.is_visible
def test_keypoint_confidence_validation_too_high() -> None:
"""Confidence > 1.0 rejected."""
with pytest.raises(ValueError, match="Confidence must be in"):
Keypoint(KeypointType.Nose, 0.0, 0.0, 1.5)
def test_keypoint_confidence_validation_negative() -> None:
"""Negative confidence rejected."""
with pytest.raises(ValueError, match="Confidence must be in"):
Keypoint(KeypointType.Nose, 0.0, 0.0, -0.1)
def test_keypoint_distance_2d() -> None:
"""Euclidean distance in 2D."""
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0)
b = Keypoint(KeypointType.LeftEye, 3.0, 4.0, 1.0)
assert a.distance_to(b) == pytest.approx(5.0)
def test_keypoint_distance_3d() -> None:
"""Euclidean distance in 3D when both have z."""
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0, z=0.0)
b = Keypoint(KeypointType.LeftEye, 1.0, 2.0, 1.0, z=2.0)
# sqrt(1 + 4 + 4) = 3.0
assert a.distance_to(b) == pytest.approx(3.0)
def test_keypoint_distance_falls_back_to_2d_if_mixed() -> None:
"""Mixing 2D and 3D keypoints uses 2D distance only."""
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0) # 2D
b = Keypoint(KeypointType.LeftEye, 3.0, 4.0, 1.0, z=99.0) # 3D
# Should be 5.0 (2D distance), not include the z=99 term
assert a.distance_to(b) == pytest.approx(5.0)
def test_keypoint_repr_2d() -> None:
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
r = repr(kp)
assert "KeypointType.Nose" in r
assert "x=0.5" in r
assert "y=0.3" in r
assert "z" not in r # no z field for 2D
def test_keypoint_repr_3d() -> None:
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95, z=0.1)
r = repr(kp)
assert "z=0.1" in r
def test_keypoint_eq() -> None:
"""Two keypoints with same fields compare equal."""
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
b = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
assert a == b
def test_keypoint_neq_different_type() -> None:
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
b = Keypoint(KeypointType.LeftEye, 0.5, 0.3, 0.95)
assert a != b
def test_keypoint_neq_different_position() -> None:
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
b = Keypoint(KeypointType.Nose, 0.6, 0.3, 0.95)
assert a != b
def test_build_features_marks_p2() -> None:
"""The P2 marker is now in the wheel's feature list."""
import wifi_densepose
assert "p2-keypoint-bindings" in wifi_densepose.__build_features__

248
python/tests/test_pose.py Normal file
View file

@ -0,0 +1,248 @@
"""ADR-117 P2 tests — BoundingBox + PersonPose + PoseEstimate bindings.
Run with: cd python && .venv/Scripts/python -m pytest tests/test_pose.py -v
"""
from __future__ import annotations
import pytest
from wifi_densepose import (
BoundingBox,
Keypoint,
KeypointType,
PersonPose,
PoseEstimate,
)
# ─── BoundingBox ─────────────────────────────────────────────────────
def test_bounding_box_construct() -> None:
bb = BoundingBox(0.1, 0.2, 0.5, 0.7)
assert bb.x_min == pytest.approx(0.1)
assert bb.y_min == pytest.approx(0.2)
assert bb.x_max == pytest.approx(0.5)
assert bb.y_max == pytest.approx(0.7)
def test_bounding_box_dimensions() -> None:
bb = BoundingBox(0.0, 0.0, 4.0, 3.0)
assert bb.width == pytest.approx(4.0)
assert bb.height == pytest.approx(3.0)
assert bb.area == pytest.approx(12.0)
assert bb.center == pytest.approx((2.0, 1.5))
def test_bounding_box_from_center() -> None:
bb = BoundingBox.from_center(2.0, 3.0, 4.0, 6.0)
assert bb.x_min == pytest.approx(0.0)
assert bb.y_min == pytest.approx(0.0)
assert bb.x_max == pytest.approx(4.0)
assert bb.y_max == pytest.approx(6.0)
def test_bounding_box_iou_no_overlap() -> None:
a = BoundingBox(0.0, 0.0, 1.0, 1.0)
b = BoundingBox(2.0, 2.0, 3.0, 3.0)
assert a.iou(b) == pytest.approx(0.0)
def test_bounding_box_iou_full_overlap() -> None:
a = BoundingBox(0.0, 0.0, 1.0, 1.0)
b = BoundingBox(0.0, 0.0, 1.0, 1.0)
assert a.iou(b) == pytest.approx(1.0)
def test_bounding_box_iou_partial() -> None:
a = BoundingBox(0.0, 0.0, 10.0, 10.0)
b = BoundingBox(5.0, 5.0, 15.0, 15.0)
# intersection 25, union 175 → 1/7
assert a.iou(b) == pytest.approx(25.0 / 175.0)
def test_bounding_box_eq() -> None:
assert BoundingBox(1, 2, 3, 4) == BoundingBox(1, 2, 3, 4)
assert BoundingBox(1, 2, 3, 4) != BoundingBox(1, 2, 3, 5)
def test_bounding_box_repr() -> None:
bb = BoundingBox(0.1, 0.2, 0.5, 0.7)
assert "BoundingBox" in repr(bb)
assert "x_min=0.1" in repr(bb)
# ─── PersonPose ──────────────────────────────────────────────────────
def test_person_pose_empty() -> None:
p = PersonPose()
assert p.id is None
assert p.visible_keypoint_count == 0
assert p.bounding_box is None
assert p.confidence == 0.0
def test_person_pose_set_get_keypoint() -> None:
p = PersonPose()
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
p.set_keypoint(kp)
got = p.get_keypoint(KeypointType.Nose)
assert got is not None
assert got.x == pytest.approx(0.5)
assert got.confidence == pytest.approx(0.95)
def test_person_pose_get_missing_returns_none() -> None:
p = PersonPose()
p.set_keypoint(Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95))
assert p.get_keypoint(KeypointType.LeftWrist) is None
def test_person_pose_visible_count() -> None:
p = PersonPose()
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.9)) # visible
p.set_keypoint(Keypoint(KeypointType.LeftEar, 0.0, 0.0, 0.2)) # invisible
p.set_keypoint(Keypoint(KeypointType.RightEar, 0.0, 0.0, 0.8)) # visible
assert p.visible_keypoint_count == 2
def test_person_pose_visible_keypoints_list() -> None:
p = PersonPose()
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.9))
p.set_keypoint(Keypoint(KeypointType.LeftEar, 0.0, 0.0, 0.2))
vis = p.visible_keypoints()
assert len(vis) == 1
assert vis[0].keypoint_type == KeypointType.Nose
def test_person_pose_keypoints_dict_excludes_missing() -> None:
p = PersonPose()
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.9))
p.set_keypoint(Keypoint(KeypointType.LeftWrist, 0.5, 0.5, 0.6))
d = p.keypoints()
assert KeypointType.Nose in d
assert KeypointType.LeftWrist in d
assert KeypointType.RightAnkle not in d
assert len(d) == 2
def test_person_pose_set_id() -> None:
p = PersonPose()
p.set_id(7)
assert p.id == 7
def test_person_pose_set_bounding_box() -> None:
p = PersonPose()
bb = BoundingBox(0.1, 0.1, 0.5, 0.9)
p.set_bounding_box(bb)
assert p.bounding_box == bb
def test_person_pose_compute_bbox_returns_none_when_empty() -> None:
p = PersonPose()
assert p.compute_bounding_box() is None
def test_person_pose_compute_bbox_from_keypoints() -> None:
p = PersonPose()
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.95))
p.set_keypoint(Keypoint(KeypointType.RightAnkle, 1.0, 2.0, 0.95))
bb = p.compute_bounding_box()
assert bb is not None
# bbox should span both keypoints
assert bb.x_min <= 0.0
assert bb.y_min <= 0.0
assert bb.x_max >= 1.0
assert bb.y_max >= 2.0
# also stored
assert p.bounding_box is not None
def test_person_pose_set_confidence_validation() -> None:
p = PersonPose()
p.set_confidence(0.85)
assert p.confidence == pytest.approx(0.85)
with pytest.raises(ValueError):
p.set_confidence(1.5)
def test_person_pose_repr() -> None:
p = PersonPose()
p.set_id(3)
p.set_keypoint(Keypoint(KeypointType.Nose, 0.0, 0.0, 0.9))
r = repr(p)
assert "PersonPose" in r
assert "id=Some(3)" in r or "id=3" in r
# ─── PoseEstimate ────────────────────────────────────────────────────
def test_pose_estimate_construct_empty() -> None:
e = PoseEstimate([], 0.5, 1.0, "test-v0")
assert e.person_count == 0
assert not e.has_detections
assert e.confidence == pytest.approx(0.5)
assert e.latency_ms == pytest.approx(1.0)
assert e.model_version == "test-v0"
def test_pose_estimate_construct_with_persons() -> None:
p1 = PersonPose()
p1.set_id(1)
p1.set_confidence(0.8)
p2 = PersonPose()
p2.set_id(2)
p2.set_confidence(0.9)
e = PoseEstimate([p1, p2], 0.85, 5.2, "v0.7.0")
assert e.person_count == 2
assert e.has_detections
assert e.confidence == pytest.approx(0.85)
def test_pose_estimate_highest_confidence_person() -> None:
p1 = PersonPose()
p1.set_confidence(0.5)
p2 = PersonPose()
p2.set_confidence(0.95)
p3 = PersonPose()
p3.set_confidence(0.7)
e = PoseEstimate([p1, p2, p3], 0.85, 5.2, "v0.7.0")
best = e.highest_confidence_person()
assert best is not None
assert best.confidence == pytest.approx(0.95)
def test_pose_estimate_highest_confidence_returns_none_when_empty() -> None:
e = PoseEstimate([], 0.5, 1.0, "test")
assert e.highest_confidence_person() is None
def test_pose_estimate_metadata_strings_nonempty() -> None:
e = PoseEstimate([], 0.5, 1.0, "test")
assert isinstance(e.id, str)
assert isinstance(e.timestamp, str)
assert e.id # non-empty
assert e.timestamp # non-empty
def test_pose_estimate_confidence_validation() -> None:
with pytest.raises(ValueError):
PoseEstimate([], 1.5, 0.0, "test")
def test_pose_estimate_repr_contains_counts() -> None:
e = PoseEstimate([], 0.5, 2.3, "v0.7.0")
r = repr(e)
assert "PoseEstimate" in r
assert "v0.7.0" in r
def test_build_features_marks_p2_complete() -> None:
import wifi_densepose
assert "p2-keypoint-bindings" in wifi_densepose.__build_features__
assert "p2-pose-bindings" in wifi_densepose.__build_features__

View file

@ -0,0 +1,260 @@
"""ADR-117 hardening sweep — Security & robustness tests for the
client surface.
Scope: malformed/hostile input handling across the WS decoder, MQTT
matcher + dispatch, HA discovery parser, and semantic primitive
listener. The goal is to ensure that an adversarial broker or
sensing-server can't:
- Crash the client process via malformed JSON, UTF-8, or topic shapes
- Bypass topic-wildcard matching to deliver messages to the wrong handler
- Leak MQTT credentials through `repr()` or string conversion
- Trigger unbounded memory growth via deeply-nested JSON
- Get a handler exception to crash the network loop
"""
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from wifi_densepose.client import RuViewMqttClient, SemanticPrimitiveListener
from wifi_densepose.client.ha import (
HABlueprintHelper,
parse_discovery_payload,
parse_discovery_topic,
)
from wifi_densepose.client.mqtt import _topic_matches
from wifi_densepose.client.ws import _decode
# ─── WS decoder robustness ──────────────────────────────────────────
def test_ws_decoder_rejects_non_object_root() -> None:
"""A JSON array at the root must NOT crash the decoder. Plain
string/array root values are valid JSON but not valid sensing-
server messages the decoder must reject them cleanly."""
with pytest.raises(ValueError):
_decode("[1, 2, 3]")
with pytest.raises(ValueError):
_decode('"just a string"')
with pytest.raises(ValueError):
_decode("42")
def test_ws_decoder_rejects_malformed_json() -> None:
with pytest.raises(json.JSONDecodeError):
_decode("{ broken: json")
def test_ws_decoder_handles_deeply_nested_payload_without_crash() -> None:
"""Hostile JSON nested 1000 levels deep must not crash via
Python's default recursion limit. Json.loads has a built-in
guard; verify we don't accidentally bypass it."""
nested = "{" + '"a":{' * 999 + '"x":1' + "}" * 1000
# json.loads either succeeds (since 999 < ~1000 limit) or raises
# RecursionError; either is acceptable — the key is no segfault
# or hang.
try:
_decode(nested)
except (RecursionError, json.JSONDecodeError, ValueError):
pass # All acceptable.
def test_ws_decoder_handles_huge_string_values() -> None:
"""A 1 MB string in a JSON field must decode without exploding.
The websockets `max_size` parameter (default 16 MB) is the actual
DoS guard this just confirms the decoder itself is linear."""
huge_payload = json.dumps({
"type": "edge_vitals",
"node_id": "x" * (1024 * 1024), # 1 MB string
"presence": True,
"fall_detected": False,
"motion": 0.0,
})
msg = _decode(huge_payload)
assert msg.type == "edge_vitals"
def test_ws_decoder_handles_unicode_in_node_id() -> None:
"""Non-ASCII node IDs (e.g. accidental terminal escapes) must
round-trip cleanly without re-encoding errors."""
payload = json.dumps({"type": "edge_vitals", "node_id": "nöde-中", "presence": True, "fall_detected": False, "motion": 0.0})
msg = _decode(payload)
assert msg.node_id == "nöde-中" # type: ignore[attr-defined]
# ─── MQTT topic matcher — exhaustive edge cases ─────────────────────
@pytest.mark.parametrize("pattern,topic,expected", [
# Empty / boundary
("", "", True),
("a", "", False),
("", "a", False),
# `+` cannot bypass a literal level boundary
("a/+/c", "a/b/c", True),
("a/+/c", "a/b/d", False),
("a/+/c", "a/b/c/d", False),
# `#` is greedy from its position but does not match if it's
# mid-pattern (per MQTT spec; our matcher returns False then).
("a/#/c", "a/b/c", False), # `#` must be terminal
# Topics starting with `$` are legal here — we don't filter them;
# matching is purely syntactic. `+` is one-level only, so `$SYS/+`
# matches `$SYS/broker` but NOT `$SYS/broker/version`.
("$SYS/+", "$SYS/broker", True),
("$SYS/+", "$SYS/broker/version", False),
("$SYS/#", "$SYS/broker/version", True),
# Null byte in topic: still string comparison, but useful to lock
# down behaviour.
("a/b", "a\x00/b", False),
])
def test_topic_matcher_edge_cases(pattern: str, topic: str, expected: bool) -> None:
assert _topic_matches(pattern, topic) is expected
# ─── MQTT credential confidentiality ────────────────────────────────
def test_mqtt_password_never_in_repr() -> None:
"""A user's broker password must NOT leak through __repr__ or
__str__. Currently RuViewMqttClient doesn't define repr — that's
the safest default (uses object identity). Lock that down so a
future "let's add a friendly repr" change doesn't expose creds."""
c = RuViewMqttClient(
broker_host="broker.example.com",
username="alice",
password="super-secret-token-do-not-leak",
)
rep = repr(c)
s = str(c)
assert "super-secret-token-do-not-leak" not in rep
assert "super-secret-token-do-not-leak" not in s
def test_mqtt_password_never_stored_in_plain_attribute() -> None:
"""The plaintext password must not be stored on the client
instance paho-mqtt internalises it into `_client._username_pw`
which we never expose. Audit by walking the public dict."""
c = RuViewMqttClient(password="dont-leak-me")
for k, v in vars(c).items():
if isinstance(v, str):
assert "dont-leak-me" not in v, f"password leaked via attribute {k!r}"
# ─── HA discovery — adversarial topics ──────────────────────────────
def test_ha_discovery_rejects_topic_with_null_byte() -> None:
"""Defensive: regex must not match a null-byte-laced topic."""
bad = "homeassistant/binary_sensor/wifi_densepose_aa\x00bb/presence/config"
assert parse_discovery_topic(bad) is None
assert parse_discovery_payload(bad, {"name": "x"}) is None
def test_ha_discovery_rejects_topic_with_slash_in_node_id() -> None:
"""A node_id with embedded slashes would break the unique_id
contract; reject."""
bad = "homeassistant/binary_sensor/wifi_densepose_aa/bb/presence/config"
# The regex won't match because there are too many segments.
assert parse_discovery_topic(bad) is None
def test_ha_helper_drops_invalid_topic_silently() -> None:
"""`add_payload` should return False (not raise) for non-discovery
topics so a misconfigured broker doesn't bring down the client."""
h = HABlueprintHelper()
assert h.add_payload("garbage", {"x": 1}) is False
assert h.add_payload("ruview/aa/raw/edge_vitals", {"x": 1}) is False
assert len(h) == 0
def test_ha_helper_handles_non_dict_payload() -> None:
"""If the HA discovery body is a list or scalar (broken producer),
the helper must reject rather than crash on attribute access."""
h = HABlueprintHelper()
topic = "homeassistant/binary_sensor/wifi_densepose_aabb/presence/config"
assert h.add_payload(topic, "[1, 2, 3]") is False
assert h.add_payload(topic, "42") is False
assert h.add_payload(topic, b"\xff\xfe invalid utf-8") is False
# ─── Semantic primitive listener — adversarial input ────────────────
def test_primitive_listener_ignores_topic_injection_attempts() -> None:
listener = SemanticPrimitiveListener()
# Extra leading segments
assert listener.handle_mqtt_message(
"evil/homeassistant/binary_sensor/wifi_densepose_aa/someone_sleeping/state",
"ON",
) is None
# Wrong final segment
assert listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aa/someone_sleeping/STATE",
"ON",
) is None
# Empty node_id after the wifi_densepose_ prefix is still routed
# (the node_id is "") because we don't enforce a minimum length —
# but that's not an injection vector. Confirm behaviour.
evt = listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_/someone_sleeping/state",
"ON",
)
assert evt is not None
assert evt.node_id == ""
def test_primitive_listener_handles_garbage_payload_without_crash() -> None:
listener = SemanticPrimitiveListener()
# Bytes that aren't valid UTF-8
evt = listener.handle_mqtt_message(
"homeassistant/binary_sensor/wifi_densepose_aa/room_active/state",
b"\xff\xfe\xfd",
)
assert evt is not None # we return a sentinel rather than crash
# No assertions on state content — undefined for invalid UTF-8;
# what matters is no exception escaped.
# ─── Public surface integrity ───────────────────────────────────────
def test_public_surface_is_stable() -> None:
"""Every name in `wifi_densepose.__all__` must be resolvable.
Catches accidental re-export breakage between phases."""
import wifi_densepose
for name in wifi_densepose.__all__:
assert hasattr(wifi_densepose, name), f"__all__ promises {name!r} but attribute missing"
def test_client_public_surface_is_stable() -> None:
import wifi_densepose.client as c
for name in c.__all__:
# Lazy re-exports for SensingClient + RuViewMqttClient need to
# be resolvable too — touch them to exercise __getattr__.
_ = getattr(c, name)
# ─── Handler crash isolation (expanded) ─────────────────────────────
def test_mqtt_handler_exception_isolation_with_multiple_handlers() -> None:
"""Earlier test covered one crashing handler; this version makes
sure a crashing handler in the *middle* of a list of registered
handlers doesn't prevent later handlers from firing."""
c = RuViewMqttClient()
received_before: list[str] = []
received_after: list[str] = []
c.on_message("a/+", lambda t, p: received_before.append(t))
c.on_message("a/b", lambda t, p: (_ for _ in ()).throw(RuntimeError("middle crash")))
c.on_message("+/b", lambda t, p: received_after.append(t))
msg = SimpleNamespace(topic="a/b", payload=b"x")
c._on_message(None, None, msg)
assert received_before == ["a/b"]
assert received_after == ["a/b"]

View file

@ -0,0 +1,81 @@
"""ADR-117 P1 smoke tests — assert the maturin-built wheel loads and
its compiled module is callable.
These tests are the first acceptance gate of the v2.0 PyPI publish
pipeline (ADR-117 §11.1 ``cargo test`` equivalent at the Python
level). They run on every cibuildwheel target in P5's CI matrix.
"""
from __future__ import annotations
def test_package_imports() -> None:
"""The top-level package must import without error."""
import wifi_densepose # noqa: F401
def test_version_string_well_formed() -> None:
"""Version string follows PEP 440 + matches pyproject.toml."""
import re
import wifi_densepose
assert isinstance(wifi_densepose.__version__, str)
# Allow pre-release segments (a, b, rc, dev) for non-final wheels.
assert re.match(
r"^\d+\.\d+\.\d+(a|b|rc|\.dev)?\d*$", wifi_densepose.__version__
), f"non-PEP-440 version: {wifi_densepose.__version__}"
def test_rust_version_surfaced() -> None:
"""Bound Rust core version must be reachable from Python.
This is the diagnostic surface ADR-117 §5.2 promised users in
bug reports can paste ``wifi_densepose.__rust_version__`` so we
correlate behaviour with the exact ``v2/crates/`` HEAD.
"""
import wifi_densepose
assert isinstance(wifi_densepose.__rust_version__, str)
assert wifi_densepose.__rust_version__ # non-empty
def test_build_features_listed() -> None:
"""The wheel's build-time features must be enumerable.
P1 ships only the ``p1-scaffold`` feature marker; later phases
add more entries. The test asserts the contract that the list
exists and contains the P1 marker.
"""
import wifi_densepose
feats = wifi_densepose.__build_features__
assert isinstance(feats, list)
assert all(isinstance(f, str) for f in feats)
assert "p1-scaffold" in feats, f"P1 marker missing: {feats}"
def test_hello_returns_ok() -> None:
"""The compiled ``hello`` function round-trips through PyO3.
This is the actual smoke test proves the FFI works end-to-end.
If this passes on every cibuildwheel target, the PyO3 build matrix
is healthy.
"""
import wifi_densepose
assert wifi_densepose.hello() == "ok"
def test_native_module_private() -> None:
"""The compiled module is reachable but marked private.
Users should ``import wifi_densepose``, not ``import
wifi_densepose._native``. The underscore prefix communicates that.
"""
import wifi_densepose
from wifi_densepose import _native
assert hasattr(_native, "hello"), "compiled module missing hello()"
# Both paths must return the same value.
assert wifi_densepose.hello() == _native.hello()

196
python/tests/test_vitals.py Normal file
View file

@ -0,0 +1,196 @@
"""ADR-117 P3 — Tests for vital-sign extraction bindings.
Covers:
- VitalStatus enum (eq, eq_int, hash, frozen)
- VitalEstimate construction + getters + immutability
- VitalReading composite + getters
- BreathingExtractor + HeartRateExtractor esp32_default, explicit
ctor, extract() return type, validation behaviour
The Rust pipeline is unit-tested in `v2/crates/wifi-densepose-vitals/`.
These tests are deliberately scoped to the *binding* layer does the
Python surface return the right shapes, raise the right errors, and
release the GIL safely.
"""
from __future__ import annotations
import math
from random import Random
import pytest
import wifi_densepose
from wifi_densepose import (
BreathingExtractor,
HeartRateExtractor,
VitalEstimate,
VitalReading,
VitalStatus,
)
# ─── VitalStatus enum ────────────────────────────────────────────────
def test_vital_status_variants_present() -> None:
assert VitalStatus.Valid != VitalStatus.Degraded
assert VitalStatus.Unreliable != VitalStatus.Unavailable
def test_vital_status_equality_against_int() -> None:
# eq_int → enum can be compared to int (PyO3 0.22 surface)
assert VitalStatus.Valid == 0
assert VitalStatus.Unavailable == 3
def test_vital_status_is_hashable() -> None:
# frozen + hash → can be used as dict key / set member
s = {VitalStatus.Valid, VitalStatus.Valid, VitalStatus.Degraded}
assert len(s) == 2
def test_vital_status_repr_contains_variant_name() -> None:
r = repr(VitalStatus.Valid)
assert "VitalStatus" in r and "Valid" in r
# ─── VitalEstimate ───────────────────────────────────────────────────
def test_vital_estimate_construction_and_getters() -> None:
est = VitalEstimate(value_bpm=72.4, confidence=0.85, status=VitalStatus.Valid)
assert math.isclose(est.value_bpm, 72.4)
assert math.isclose(est.confidence, 0.85)
assert est.status == VitalStatus.Valid
def test_vital_estimate_is_frozen() -> None:
est = VitalEstimate(value_bpm=72.0, confidence=0.9, status=VitalStatus.Valid)
with pytest.raises(AttributeError):
est.value_bpm = 100.0 # type: ignore[misc]
def test_vital_estimate_repr_is_readable() -> None:
est = VitalEstimate(value_bpm=72.0, confidence=0.9, status=VitalStatus.Valid)
r = repr(est)
assert "VitalEstimate" in r
assert "72" in r
# ─── VitalReading ────────────────────────────────────────────────────
def test_vital_reading_construction_and_getters() -> None:
br = VitalEstimate(value_bpm=14.0, confidence=0.9, status=VitalStatus.Valid)
hr = VitalEstimate(value_bpm=72.0, confidence=0.8, status=VitalStatus.Degraded)
reading = VitalReading(
respiratory_rate=br,
heart_rate=hr,
subcarrier_count=56,
signal_quality=0.77,
timestamp_secs=1700000000.5,
)
assert reading.respiratory_rate.value_bpm == 14.0
assert reading.heart_rate.status == VitalStatus.Degraded
assert reading.subcarrier_count == 56
assert math.isclose(reading.signal_quality, 0.77)
assert math.isclose(reading.timestamp_secs, 1700000000.5)
# ─── BreathingExtractor ──────────────────────────────────────────────
def test_breathing_esp32_default_constructs() -> None:
br = BreathingExtractor.esp32_default()
assert br is not None
assert "BreathingExtractor" in repr(br)
def test_breathing_explicit_ctor() -> None:
br = BreathingExtractor(n_subcarriers=64, sample_rate=200.0, window_secs=20.0)
assert br is not None
def test_breathing_extract_returns_none_with_too_few_samples() -> None:
"""One frame can't produce a 30-second window — must return None.
Verifies the binding propagates Rust's `Option<VitalEstimate>` →
Python None correctly (vs raising or returning a default).
"""
br = BreathingExtractor.esp32_default()
out = br.extract(residuals=[0.0] * 56, weights=[])
assert out is None
def test_breathing_extract_accepts_empty_weights() -> None:
"""Empty weights vector means "equal weight per subcarrier" by
convention (per breathing.rs)."""
br = BreathingExtractor.esp32_default()
out = br.extract(residuals=[0.01] * 56, weights=[])
# Even with synthetic input it may return None until enough history
# accumulates — what matters is that the call doesn't panic.
assert out is None or isinstance(out, VitalEstimate)
def test_breathing_extract_with_synthetic_signal() -> None:
"""Drive the extractor with a synthetic 0.25 Hz sine (15 BPM) for
enough samples to fill the 30-second window. Don't assert the exact
BPM just that the extractor *eventually* produces a result (rather
than returning None forever)."""
br = BreathingExtractor.esp32_default()
sample_rate = 100.0
target_freq = 0.25 # 15 BPM
# Run 40 seconds of synthetic data — comfortably past the 30s window.
n_samples = int(40 * sample_rate)
weights = [1.0] * 56
produced_estimate = False
rng = Random(42)
for i in range(n_samples):
t = i / sample_rate
base = math.sin(2.0 * math.pi * target_freq * t)
# Per-subcarrier residual: same signal + small per-carrier noise
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)]
est = br.extract(residuals=residuals, weights=weights)
if est is not None:
produced_estimate = True
assert isinstance(est.value_bpm, float)
assert 0.0 <= est.confidence <= 1.0
assert est.status in (
VitalStatus.Valid,
VitalStatus.Degraded,
VitalStatus.Unreliable,
VitalStatus.Unavailable,
)
break
assert produced_estimate, "BreathingExtractor never produced an estimate after 40s of synthetic data"
# ─── HeartRateExtractor ──────────────────────────────────────────────
def test_heart_rate_esp32_default_constructs() -> None:
hr = HeartRateExtractor.esp32_default()
assert hr is not None
assert "HeartRateExtractor" in repr(hr)
def test_heart_rate_explicit_ctor() -> None:
hr = HeartRateExtractor(n_subcarriers=64, sample_rate=200.0, window_secs=10.0)
assert hr is not None
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
hr = HeartRateExtractor.esp32_default()
out = hr.extract(residuals=[0.0] * 56, weights=[])
assert out is None
# ─── Build feature flag ──────────────────────────────────────────────
def test_p3_vitals_in_build_features() -> None:
assert "p3-vitals-bindings" in wifi_densepose.__build_features__

3
python/tombstone/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
dist/
build/
*.egg-info/

View file

@ -0,0 +1,38 @@
# wifi-densepose 1.99.0 — tombstone release
This sub-directory builds the **tombstone wheel** described in
[ADR-117 §7.2](../../docs/adr/ADR-117-pip-wifi-densepose-modernization.md).
`wifi-densepose==1.1.0` was published on 2025-06-07 as a pure-Python
FastAPI + PyTorch server. v2.0+ is a hard rewrite around the Rust
crates in [`v2/crates/`](../../v2/crates/) exposed via PyO3.
`wifi-densepose==1.99.0` ships **no real code** — its `__init__.py`
raises `ImportError` with a migration URL. The point is that any
project pinned to `wifi-densepose>=1,<2` that runs `pip install -U
wifi-densepose` gets a clear, actionable error instead of a silent
import of a broken legacy server.
## Build locally
```bash
cd python/tombstone
python -m build
```
Result: `dist/wifi_densepose-1.99.0-py3-none-any.whl` and the matching sdist.
## Smoke-test
```bash
pip install dist/wifi_densepose-1.99.0-py3-none-any.whl
python -c "import wifi_densepose"
# Expected: ImportError with the migration URL.
```
## Publish
Publishing is done by the `pip-release.yml` GH Actions workflow, gated
on a `v1.99.0-pip` tag OR an explicit `workflow_dispatch` with
`target: v1-99-tombstone`. Per ADR-117 §7.3 this should publish
*before* `v2.0.0` to claim the "current" slot in pip's resolver.

View file

@ -0,0 +1,53 @@
# ADR-117 §7.2 / §7.4 — v1.99.0 tombstone release.
#
# This sub-directory builds a SEPARATE PyPI artifact from the v2.0+
# PyO3 wheel in ../. The two share the PyPI project name
# `wifi-densepose` but represent different versions:
#
# 1.0.01.1.0 legacy pure-Python server (archive/v1/)
# 1.99.0 THIS PACKAGE — pure-Python wheel whose only behaviour
# is to raise ImportError with the migration URL on
# first import. Acts as a soft-fence for users pinned
# to wifi-densepose>=1,<2.
# 2.0.0+ PyO3 + maturin Rust core (../pyproject.toml)
#
# Build:
# cd python/tombstone
# python -m build
#
# Result: a SINGLE `py3-none-any` wheel plus an sdist. Nothing
# compiled, no platform-specific tags.
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "wifi-densepose"
version = "1.99.0"
description = "Tombstone release. wifi-densepose v1.x is superseded by v2.0+ (PyO3 bindings to the Rust core). Install wifi-densepose==2.0.0 — see https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md"
readme = "README.md"
requires-python = ">=3.8"
license = { text = "MIT" }
authors = [
{ name = "rUv", email = "ruv@ruv.net" },
]
keywords = ["wifi", "csi", "pose-estimation", "deprecated", "migration"]
classifiers = [
"Development Status :: 7 - Inactive",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
]
# No runtime dependencies — the import raises before any code runs.
dependencies = []
[project.urls]
Homepage = "https://github.com/ruvnet/RuView"
"Migration guide" = "https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md"
"ADR-117 (modernization plan)" = "https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md"
[tool.setuptools]
packages = ["wifi_densepose"]
package-dir = { "" = "src" }

View file

@ -0,0 +1,18 @@
# ADR-117 §7.2 — v1.99.0 tombstone.
#
# This module is part of the `wifi-densepose==1.99.0` PyPI release.
# Its ONLY job is to raise ImportError on import so any project that
# upgraded from the legacy 1.x line gets a clear migration error
# rather than a silent broken import.
#
# The real package lives at `wifi-densepose>=2.0.0` (built by the
# PyO3+maturin pipeline in `python/`).
raise ImportError(
"wifi-densepose 1.x has been superseded by v2.0.0 which wraps the Rust-based stack.\n"
"\n"
" pip install wifi-densepose==2.0.0\n"
"\n"
"Migration guide: https://github.com/ruvnet/RuView/blob/main/docs/pip-migration.md\n"
"Modernization rationale: https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md\n"
"Legacy v1 source (archived): https://github.com/ruvnet/RuView/tree/main/archive/v1\n"
)

View file

@ -0,0 +1,50 @@
"""ADR-117 §7.2 — Unit test for the v1.99.0 tombstone wheel.
Verifies the *file content* of the tombstone module without actually
importing it (importing it would raise ImportError, which is the
behaviour under test). The CI workflow `pip-release.yml` runs the
real end-to-end install + import test inside an ephemeral venv.
"""
from __future__ import annotations
import pathlib
TOMBSTONE = pathlib.Path(__file__).parent.parent / "src" / "wifi_densepose" / "__init__.py"
def test_tombstone_file_exists() -> None:
assert TOMBSTONE.is_file(), f"tombstone module missing: {TOMBSTONE}"
def test_tombstone_raises_import_error() -> None:
"""The source must call `raise ImportError(...)`. We grep rather
than exec because actually running it would terminate the test."""
src = TOMBSTONE.read_text(encoding="utf-8")
assert "raise ImportError(" in src, "tombstone does not raise ImportError"
def test_tombstone_contains_v2_install_hint() -> None:
src = TOMBSTONE.read_text(encoding="utf-8")
assert "pip install wifi-densepose==2.0.0" in src, (
"tombstone ImportError message must include the v2 pip install hint"
)
def test_tombstone_contains_migration_url() -> None:
src = TOMBSTONE.read_text(encoding="utf-8")
assert "docs/pip-migration.md" in src, (
"tombstone must point users at the migration guide"
)
def test_tombstone_is_minimal() -> None:
"""The whole point of the tombstone is that it's MINIMAL — no
imports, no helper functions, no class definitions. Lock that
down so a well-intentioned refactor doesn't accidentally bloat it
into a real module that loads partway before failing."""
src = TOMBSTONE.read_text(encoding="utf-8")
forbidden = ("def ", "class ", "import wifi_densepose", "import os", "import sys")
for f in forbidden:
assert f not in src, f"tombstone must not contain {f!r} — it should ONLY raise"

View file

@ -0,0 +1,105 @@
"""WiFi-DensePose — passive human sensing from WiFi CSI.
ADR-117 v2.0 is a PyO3-bound replacement for the legacy pure-Python
``wifi-densepose==1.1.0`` (released 2025-06-07). The compiled core is
the same Rust workspace published in `v2/crates/` of the
`ruvnet/RuView <https://github.com/ruvnet/RuView>`_ repository.
Quick start::
import wifi_densepose
print(wifi_densepose.__version__)
print(wifi_densepose.__rust_version__)
print(wifi_densepose.hello()) # → "ok"
P1 (this release): scaffold. Core types land in P2; vital signs +
signal DSP in P3; WebSocket/MQTT client in P4. See the
`ADR-117 modernization plan
<https://github.com/ruvnet/RuView/blob/main/docs/adr/ADR-117-pip-wifi-densepose-modernization.md>`_
for the full phase ledger.
Migrating from v1.x: the v1 line was pure-Python and had a different
API surface. v2 is a hard break (semver-justified). See the
``v1.99.0`` tombstone wheel for the migration URL.
"""
from __future__ import annotations
# Public Python version follows the wheel version, NOT the Rust core
# version. The Rust core version is surfaced separately as
# `__rust_version__` for diagnostics.
__version__ = "2.0.0a1"
# Re-export the compiled module's surface. The leading underscore on
# `_native` is intentional — it marks the binding module as internal.
# Users always import from `wifi_densepose` directly.
from wifi_densepose import _native
# ─── P2 — Core type re-exports ───────────────────────────────────────
# Bound types land in `wifi_densepose._native` and are re-exported here
# under their stable public names. Users always `from wifi_densepose
# import Keypoint, KeypointType` — never reach into `_native`.
Keypoint = _native.Keypoint
KeypointType = _native.KeypointType
BoundingBox = _native.BoundingBox
PersonPose = _native.PersonPose
PoseEstimate = _native.PoseEstimate
# ─── P3 — Vital sign extraction ──────────────────────────────────────
VitalStatus = _native.VitalStatus
VitalEstimate = _native.VitalEstimate
VitalReading = _native.VitalReading
BreathingExtractor = _native.BreathingExtractor
HeartRateExtractor = _native.HeartRateExtractor
# ─── P3.5 — BFLD (Beamforming Feedback Loop Data) ─────────────────────
BfldKind = _native.BfldKind
BfldFrame = _native.BfldFrame
BfldReport = _native.BfldReport
__rust_version__: str = _native.__rust_version__
"""Version of the bound Rust core. Useful for bug reports."""
__rust_build_tag__: str = _native.__rust_build_tag__
"""Build tag of the Rust core (P5 will swap this for the git SHA)."""
__build_features__: list[str] = list(_native.__build_features__)
"""Feature flags the wheel was compiled with."""
def hello() -> str:
"""Smoke test — confirms the compiled module loads and is callable.
Returns:
Always ``"ok"`` if the wheel built and loaded correctly.
Used by ``python/tests/test_smoke.py`` to assert the PyO3 round-trip
works end-to-end on every cibuildwheel target.
"""
return _native.hello()
__all__ = [
"__version__",
"__rust_version__",
"__rust_build_tag__",
"__build_features__",
"hello",
# P2 — core types
"Keypoint",
"KeypointType",
"BoundingBox",
"PersonPose",
"PoseEstimate",
# P3 — vital sign extraction
"VitalStatus",
"VitalEstimate",
"VitalReading",
"BreathingExtractor",
"HeartRateExtractor",
# P3.5 — BFLD (forward-compat surface for the future Rust crate)
"BfldKind",
"BfldFrame",
"BfldReport",
]

View file

@ -0,0 +1,93 @@
"""ADR-117 P4 — Pure-Python client layer.
This sub-package is the **client-facing** half of `wifi-densepose`:
end users who only want to *consume* live RuView telemetry (rather than
running DSP locally) get a tight, opt-in client extra:
```
pip install "wifi-densepose[client]"
```
The runtime install footprint stays small for users who only need the
compiled PyO3 surface: `websockets` and `paho-mqtt` are declared as the
`[client]` extra in `pyproject.toml` and are NOT pulled in by the
default install.
## Modules
- `ws` `SensingClient`: asyncio WebSocket client for the
sensing-server `/ws/sensing` endpoint (ADR-115 §1)
- `mqtt` `RuViewMqttClient`: paho-mqtt v2 wrapper for
`ruview/<node>/raw/+` + `homeassistant/+/wifi_densepose_<node>/+/+`
topics (ADR-115 §3)
- `primitives` `SemanticPrimitiveListener`: typed view over the
10 HA-MIND semantic primitives (ADR-115 §3.12)
- `ha` `HABlueprintHelper`: parses MQTT-discovery payloads, helps
users introspect what entities a node is publishing
No PyO3 here this module is pure Python so it loads without the
compiled extension (useful for users who only want the client surface
and not the DSP pipeline).
"""
from __future__ import annotations
# Re-export the user-facing types. Import errors are deferred to the
# moment the user actually instantiates one of these classes — that way
# `from wifi_densepose.client import HABlueprintHelper` still works
# even if the user hasn't installed `[client]` extras yet (HABlueprint
# is pure stdlib).
from wifi_densepose.client.ha import (
HaDiscoveryPayload,
HaEntity,
HABlueprintHelper,
)
from wifi_densepose.client.primitives import (
SemanticPrimitive,
SemanticPrimitiveEvent,
SemanticPrimitiveListener,
)
__all__ = [
# ws — re-exported lazily; see module docstring
"SensingClient",
"SensingMessage",
"EdgeVitalsMessage",
"PoseDataMessage",
"ConnectionEstablishedMessage",
# mqtt — re-exported lazily; see module docstring
"RuViewMqttClient",
# ha — pure stdlib
"HaDiscoveryPayload",
"HaEntity",
"HABlueprintHelper",
# primitives — pure stdlib
"SemanticPrimitive",
"SemanticPrimitiveEvent",
"SemanticPrimitiveListener",
]
def __getattr__(name: str):
"""Lazy re-exports for the modules that pull in optional extras.
`SensingClient` needs `websockets`; `RuViewMqttClient` needs
`paho-mqtt`. Importing those at package init would make
`wifi_densepose.client` unusable without the extras installed
defeating the point of an *optional* extra. We defer the import
until the attribute is actually looked up.
"""
if name in {
"SensingClient",
"SensingMessage",
"EdgeVitalsMessage",
"PoseDataMessage",
"ConnectionEstablishedMessage",
}:
from wifi_densepose.client import ws as _ws
return getattr(_ws, name)
if name == "RuViewMqttClient":
from wifi_densepose.client.mqtt import RuViewMqttClient as _R
return _R
raise AttributeError(f"module 'wifi_densepose.client' has no attribute {name!r}")

View file

@ -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

View file

@ -0,0 +1,257 @@
"""ADR-117 P4 — paho-mqtt v2 wrapper for RuView MQTT topics.
Subscribes to the topic namespaces defined in ADR-115:
- `ruview/<node>/raw/edge_vitals` opt-in firehose of the WS edge_vitals
- `ruview/<node>/raw/pose` opt-in firehose of pose data
- `ruview/<node>/raw/sensing_update` opt-in firehose of every sensing update
- `homeassistant/+/wifi_densepose_<node>/+/config` HA discovery payloads
- `homeassistant/+/wifi_densepose_<node>/+/state` HA state payloads
The client uses **paho-mqtt v2's `Client(CallbackAPIVersion.VERSION2)`**
API explicitly. v1's deprecated callback signatures will not work.
Example:
```python
from wifi_densepose.client import RuViewMqttClient
def on_edge_vitals(topic, payload):
print(topic, payload["breathing_rate_bpm"])
client = RuViewMqttClient(broker_host="localhost", broker_port=1883)
client.on_message("ruview/+/raw/edge_vitals", on_edge_vitals)
client.start()
# ... runs in a background thread; call client.stop() to disconnect
```
The constructor never connects; call `.start()` to enter the network
loop and `.stop()` to disconnect cleanly. Both are idempotent.
"""
from __future__ import annotations
import json
import logging
import threading
import uuid
from typing import Any, Callable, Optional
try:
import paho.mqtt.client as mqtt # type: ignore[import-not-found]
from paho.mqtt.enums import CallbackAPIVersion # type: ignore[import-not-found]
_PAHO_AVAILABLE = True
except ImportError: # pragma: no cover
_PAHO_AVAILABLE = False
log = logging.getLogger(__name__)
MessageHandler = Callable[[str, Any], None]
"""(topic, decoded_payload) → None. The payload is JSON-decoded if the
content is valid JSON, otherwise the raw bytes are passed through."""
class RuViewMqttClient:
"""Wrapper around paho-mqtt v2 with per-topic-pattern callbacks.
Per the rumqttc lesson [[feedback_mqtt_integration_test_patterns]]:
- Each instance gets a unique client_id (per-test isolation when
tests run in parallel against the same broker).
- Subscription wildcards (`+`, `#`) are supported by paho's
built-in matcher; we route by exact pattern match against the
registered handler.
"""
def __init__(
self,
*,
broker_host: str = "localhost",
broker_port: int = 1883,
client_id: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
keepalive: int = 60,
tls: bool = False,
) -> None:
if not _PAHO_AVAILABLE:
raise ImportError(
"RuViewMqttClient requires the `paho-mqtt` package. Install with "
"`pip install \"wifi-densepose[client]\"` to enable the client extras."
)
self.broker_host = broker_host
self.broker_port = broker_port
self.keepalive = keepalive
self._client_id = client_id or f"wifi-densepose-client-{uuid.uuid4().hex[:12]}"
self._handlers: dict[str, MessageHandler] = {}
self._handlers_lock = threading.Lock()
self._client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=self._client_id,
clean_session=True,
)
if username is not None:
self._client.username_pw_set(username, password)
if tls:
self._client.tls_set()
self._client.on_connect = self._on_connect
self._client.on_message = self._on_message
self._client.on_disconnect = self._on_disconnect
self._started = False
self._connected_event = threading.Event()
@property
def client_id(self) -> str:
return self._client_id
@property
def connected(self) -> bool:
return self._connected_event.is_set()
# ── handler registration ─────────────────────────────────────────
def on_message(self, topic_pattern: str, handler: MessageHandler) -> None:
"""Register a handler for a topic pattern. Replaces any
previous handler for the same pattern."""
with self._handlers_lock:
self._handlers[topic_pattern] = handler
def unsubscribe_handler(self, topic_pattern: str) -> None:
with self._handlers_lock:
self._handlers.pop(topic_pattern, None)
if self._started:
self._client.unsubscribe(topic_pattern)
# ── lifecycle ────────────────────────────────────────────────────
def start(self) -> None:
"""Connect to the broker and enter the network loop in a
background thread. Idempotent."""
if self._started:
return
self._client.connect(self.broker_host, self.broker_port, self.keepalive)
self._client.loop_start()
self._started = True
def wait_connected(self, timeout: float = 5.0) -> bool:
"""Block until CONNACK has been received. Returns True on
connect, False on timeout. Mirrors the rumqttc SubAck pump
pattern but for paho's connect step."""
return self._connected_event.wait(timeout=timeout)
def stop(self) -> None:
"""Disconnect and stop the network loop. Idempotent."""
if not self._started:
return
try:
self._client.disconnect()
except Exception as e: # pragma: no cover — best-effort
log.debug("ignored mqtt disconnect error: %r", e)
try:
self._client.loop_stop()
except Exception as e: # pragma: no cover
log.debug("ignored mqtt loop_stop error: %r", e)
self._started = False
self._connected_event.clear()
def publish(
self,
topic: str,
payload: Any,
*,
qos: int = 0,
retain: bool = False,
) -> None:
"""Publish a payload. Dicts/lists are JSON-encoded; bytes pass
through; strings are encoded UTF-8."""
if isinstance(payload, (dict, list)):
data: Any = json.dumps(payload, default=str)
else:
data = payload
info = self._client.publish(topic, data, qos=qos, retain=retain)
# paho v2 returns MQTTMessageInfo; rc != MQTT_ERR_SUCCESS is a
# broker-side error we should propagate so callers don't think
# the publish succeeded.
if info.rc != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError(f"mqtt publish failed: topic={topic} rc={info.rc}")
# ── paho callbacks (v2 signatures) ───────────────────────────────
def _on_connect(self, client: Any, _userdata: Any, _flags: Any, reason_code: Any, _properties: Any = None) -> None:
# paho v2 passes ReasonCode; success is 0 ("Success" / Granted_QoS_0)
rc = int(reason_code) if hasattr(reason_code, "__int__") else reason_code
if rc == 0:
self._connected_event.set()
# Re-subscribe to all known patterns. Important after a
# reconnect — paho doesn't auto-resubscribe with
# clean_session=True.
with self._handlers_lock:
patterns = list(self._handlers.keys())
for pattern in patterns:
client.subscribe(pattern)
log.debug("mqtt CONNACK ok; subscribed to %d pattern(s)", len(patterns))
else:
log.warning("mqtt CONNACK with non-success rc=%r", reason_code)
def _on_disconnect(self, _client: Any, _userdata: Any, _flags: Any = None, reason_code: Any = None, _properties: Any = None) -> None:
self._connected_event.clear()
log.debug("mqtt disconnected rc=%r", reason_code)
def _on_message(self, _client: Any, _userdata: Any, message: Any) -> None:
topic = message.topic
# Best-effort JSON decode — fall back to raw bytes if it's not JSON.
payload: Any
try:
payload = json.loads(message.payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
payload = message.payload
with self._handlers_lock:
handlers = list(self._handlers.items())
for pattern, handler in handlers:
if _topic_matches(pattern, topic):
try:
handler(topic, payload)
except Exception as e: # never let a user callback crash the loop
log.exception("handler for pattern %r raised: %r", pattern, e)
# ── re-subscribe on demand ──────────────────────────────────────
def subscribe_registered(self) -> None:
"""Explicitly issue SUBSCRIBE for every registered handler.
Useful when you registered handlers AFTER calling start().
"""
if not self._started:
return
with self._handlers_lock:
patterns = list(self._handlers.keys())
for pattern in patterns:
self._client.subscribe(pattern)
# ─── Topic-pattern matching ──────────────────────────────────────────
def _topic_matches(pattern: str, topic: str) -> bool:
"""MQTT topic wildcard matcher.
- `+` matches exactly one topic level
- `#` matches one or more remaining levels (must be the final segment)
"""
p_parts = pattern.split("/")
t_parts = topic.split("/")
i = 0
while i < len(p_parts):
if p_parts[i] == "#":
return i == len(p_parts) - 1 and len(t_parts) >= i
if i >= len(t_parts):
return False
if p_parts[i] == "+":
i += 1
continue
if p_parts[i] != t_parts[i]:
return False
i += 1
return len(p_parts) == len(t_parts)

View file

@ -0,0 +1,222 @@
"""ADR-117 P4 — Typed listener for HA-MIND semantic primitives.
ADR-115 §3.12 defines 10 fused inference outputs that the sensing-server
publishes under the HA-DISCO MQTT namespace. This module gives clients
a typed handle on them so they can write `if event.kind ==
SemanticPrimitive.SomeoneSleeping: ...` instead of pattern-matching
strings.
The 10 v1 primitives (ADR-115 §3.12.1):
| Enum value | Topic suffix | Output kind |
|---|---|---|
| `SomeoneSleeping` | `someone_sleeping` | binary_sensor |
| `PossibleDistress` | `possible_distress` | binary_sensor + event |
| `RoomActive` | `room_active` | binary_sensor |
| `ElderlyInactivityAnomaly` | `elderly_inactivity` | binary_sensor + event |
| `MeetingInProgress` | `meeting_in_progress` | binary_sensor |
| `BathroomOccupied` | `bathroom_occupied` | binary_sensor |
| `FallRiskElevated` | `fall_risk_elevated` | sensor (0100) + event |
| `BedExit` | `bed_exit` | event |
| `NoMovementSafety` | `no_movement_safety` | binary_sensor + event |
| `MultiRoomTransition` | `multi_room_transition` | event |
"""
from __future__ import annotations
import enum
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
# ─── Enum ────────────────────────────────────────────────────────────
class SemanticPrimitive(enum.Enum):
"""One of the 10 HA-MIND fused inference outputs."""
SomeoneSleeping = "someone_sleeping"
PossibleDistress = "possible_distress"
RoomActive = "room_active"
ElderlyInactivityAnomaly = "elderly_inactivity"
MeetingInProgress = "meeting_in_progress"
BathroomOccupied = "bathroom_occupied"
FallRiskElevated = "fall_risk_elevated"
BedExit = "bed_exit"
NoMovementSafety = "no_movement_safety"
MultiRoomTransition = "multi_room_transition"
@classmethod
def from_object_id(cls, object_id: str) -> Optional["SemanticPrimitive"]:
for v in cls:
if v.value == object_id:
return v
return None
# ─── Event payload ───────────────────────────────────────────────────
@dataclass(frozen=True)
class SemanticPrimitiveEvent:
"""A single fired event for one semantic primitive.
`state` semantics depend on the primitive kind:
- binary_sensor: "ON" / "OFF"
- sensor: numeric string (e.g. "73" for fall_risk_elevated 0100)
- event: "fired" or an event-class string like "bed_exit_detected"
"""
kind: SemanticPrimitive
node_id: str
state: str
confidence: float = 0.0
explanation: tuple[str, ...] = ()
timestamp: float = 0.0
raw: dict[str, Any] = field(default_factory=dict, hash=False, compare=False)
# ─── Listener ────────────────────────────────────────────────────────
Callback = Callable[[SemanticPrimitiveEvent], None]
class SemanticPrimitiveListener:
"""Routes raw MQTT state messages to per-primitive callbacks.
Designed to plug into RuViewMqttClient:
```python
from wifi_densepose.client import (
RuViewMqttClient, SemanticPrimitive, SemanticPrimitiveListener
)
listener = SemanticPrimitiveListener()
listener.on(SemanticPrimitive.SomeoneSleeping, lambda e: print(e))
client = RuViewMqttClient()
client.on_message(
"homeassistant/+/wifi_densepose_+/+/state",
listener.handle_mqtt_message,
)
client.start()
```
The listener itself never touches MQTT it's a pure router. You
feed it `(topic, payload)` pairs and it figures out which primitive
the topic refers to and decodes the payload.
"""
# Matches state topics for any of the 10 primitives.
# homeassistant/<kind>/wifi_densepose_<node>/<primitive_slug>/state
_SLUGS = {p.value for p in SemanticPrimitive}
def __init__(self) -> None:
self._handlers: dict[Optional[SemanticPrimitive], list[Callback]] = {}
def on(self, primitive: SemanticPrimitive, cb: Callback) -> None:
"""Register a callback for a specific primitive."""
self._handlers.setdefault(primitive, []).append(cb)
def on_any(self, cb: Callback) -> None:
"""Register a callback that fires for ALL primitives. Useful
for logging or dashboards."""
self._handlers.setdefault(None, []).append(cb)
def handle_mqtt_message(self, topic: str, payload: Any) -> Optional[SemanticPrimitiveEvent]:
"""Decode one MQTT message into a SemanticPrimitiveEvent and
fire the matching callbacks. Returns the event (or None if the
topic was not a semantic-primitive state topic)."""
parts = topic.split("/")
# Shape: homeassistant / <kind> / wifi_densepose_<node> / <slug> / state
if len(parts) != 5:
return None
if parts[0] != "homeassistant" or parts[4] != "state":
return None
node_prefix = parts[2]
if not node_prefix.startswith("wifi_densepose_"):
return None
slug = parts[3]
if slug not in self._SLUGS:
return None
primitive = SemanticPrimitive.from_object_id(slug)
if primitive is None: # pragma: no cover — guarded above
return None
node_id = node_prefix[len("wifi_densepose_"):]
event = _decode_event(primitive, node_id, payload)
# Dispatch — primitive-specific first, then "any" handlers.
for cb in self._handlers.get(primitive, ()):
cb(event)
for cb in self._handlers.get(None, ()):
cb(event)
return event
def _decode_event(
primitive: SemanticPrimitive,
node_id: str,
payload: Any,
) -> SemanticPrimitiveEvent:
"""Decode a raw state payload into a typed event.
HA state payloads come in two shapes:
1. Plain string ("ON", "OFF", "73") used by binary_sensor/sensor
with no json_attributes_topic.
2. JSON object with `state` + `confidence` + `explanation` fields
used by HA-MIND semantic primitives per ADR-115 §3.12.4.
Both are supported transparently.
"""
if isinstance(payload, bytes):
try:
payload = payload.decode("utf-8")
except UnicodeDecodeError:
return SemanticPrimitiveEvent(
kind=primitive, node_id=node_id, state="", raw={}
)
if isinstance(payload, dict):
body = payload
elif isinstance(payload, str):
# Try to JSON-decode; if it's not JSON, treat as a plain state string.
try:
decoded = json.loads(payload)
except json.JSONDecodeError:
return SemanticPrimitiveEvent(
kind=primitive,
node_id=node_id,
state=payload,
raw={"state": payload},
)
if isinstance(decoded, dict):
body = decoded
else:
return SemanticPrimitiveEvent(
kind=primitive,
node_id=node_id,
state=str(decoded),
raw={"state": decoded},
)
else:
return SemanticPrimitiveEvent(
kind=primitive, node_id=node_id, state=str(payload), raw={}
)
expl = body.get("explanation") or body.get("reason") or ()
if isinstance(expl, str):
expl_tuple: tuple[str, ...] = (expl,)
else:
expl_tuple = tuple(str(x) for x in expl)
return SemanticPrimitiveEvent(
kind=primitive,
node_id=node_id,
state=str(body.get("state", "")),
confidence=float(body.get("confidence", 0.0)),
explanation=expl_tuple,
timestamp=float(body.get("timestamp", 0.0)),
raw=body,
)

View file

@ -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)

View file