From 0bffe272882676351aea7958f09f75a0eb06fbe7 Mon Sep 17 00:00:00 2001 From: rUv Date: Sun, 24 May 2026 13:00:38 -0400 Subject: [PATCH] feat(adr-117): pip wifi-densepose modernization (PIP-PHOENIX) + ruview sibling release (#786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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` (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; 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` 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 * 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 * 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 * 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//wifi_densepose_//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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --- .github/workflows/pip-release.yml | 286 ++++++ README.md | 5 + ...DR-117-pip-wifi-densepose-modernization.md | 807 +++++++++++++++ docs/integrations/pypi-release.md | 64 ++ docs/research/soul/README.md | 116 +++ docs/research/soul/references.md | 138 +++ docs/research/soul/scanning-process.md | 306 ++++++ docs/research/soul/security.md | 367 +++++++ docs/research/soul/specification.md | 525 ++++++++++ docs/user-guide.md | 35 +- python/.gitignore | 20 + python/Cargo.lock | 920 ++++++++++++++++++ python/Cargo.toml | 48 + python/README.md | 143 +++ python/bench/test_bench_bfld_and_ws.py | 111 +++ python/bench/test_bench_vitals.py | 85 ++ python/pyproject.toml | 99 ++ python/ruview-meta/README.md | 58 ++ python/ruview-meta/pyproject.toml | 62 ++ python/ruview-meta/src/ruview/__init__.py | 50 + python/src/bindings/bfld.rs | 344 +++++++ python/src/bindings/keypoint.rs | 291 ++++++ python/src/bindings/pose.rs | 376 +++++++ python/src/bindings/vitals.rs | 287 ++++++ python/src/lib.rs | 84 ++ python/tests/test_bfld.py | 263 +++++ python/tests/test_client_ha.py | 205 ++++ python/tests/test_client_mqtt.py | 208 ++++ python/tests/test_client_primitives.py | 180 ++++ python/tests/test_client_ws.py | 195 ++++ python/tests/test_keypoint.py | 200 ++++ python/tests/test_pose.py | 248 +++++ python/tests/test_security.py | 260 +++++ python/tests/test_smoke.py | 81 ++ python/tests/test_vitals.py | 196 ++++ python/tombstone/.gitignore | 3 + python/tombstone/README.md | 38 + python/tombstone/pyproject.toml | 53 + .../tombstone/src/wifi_densepose/__init__.py | 18 + python/tombstone/tests/test_tombstone.py | 50 + python/wifi_densepose/__init__.py | 105 ++ python/wifi_densepose/client/__init__.py | 93 ++ python/wifi_densepose/client/ha.py | 194 ++++ python/wifi_densepose/client/mqtt.py | 257 +++++ python/wifi_densepose/client/primitives.py | 222 +++++ python/wifi_densepose/client/ws.py | 256 +++++ python/wifi_densepose/py.typed | 0 scripts/fix-markers.json | 40 + 48 files changed, 8982 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/pip-release.yml create mode 100644 docs/adr/ADR-117-pip-wifi-densepose-modernization.md create mode 100644 docs/integrations/pypi-release.md create mode 100644 docs/research/soul/README.md create mode 100644 docs/research/soul/references.md create mode 100644 docs/research/soul/scanning-process.md create mode 100644 docs/research/soul/security.md create mode 100644 docs/research/soul/specification.md create mode 100644 python/.gitignore create mode 100644 python/Cargo.lock create mode 100644 python/Cargo.toml create mode 100644 python/README.md create mode 100644 python/bench/test_bench_bfld_and_ws.py create mode 100644 python/bench/test_bench_vitals.py create mode 100644 python/pyproject.toml create mode 100644 python/ruview-meta/README.md create mode 100644 python/ruview-meta/pyproject.toml create mode 100644 python/ruview-meta/src/ruview/__init__.py create mode 100644 python/src/bindings/bfld.rs create mode 100644 python/src/bindings/keypoint.rs create mode 100644 python/src/bindings/pose.rs create mode 100644 python/src/bindings/vitals.rs create mode 100644 python/src/lib.rs create mode 100644 python/tests/test_bfld.py create mode 100644 python/tests/test_client_ha.py create mode 100644 python/tests/test_client_mqtt.py create mode 100644 python/tests/test_client_primitives.py create mode 100644 python/tests/test_client_ws.py create mode 100644 python/tests/test_keypoint.py create mode 100644 python/tests/test_pose.py create mode 100644 python/tests/test_security.py create mode 100644 python/tests/test_smoke.py create mode 100644 python/tests/test_vitals.py create mode 100644 python/tombstone/.gitignore create mode 100644 python/tombstone/README.md create mode 100644 python/tombstone/pyproject.toml create mode 100644 python/tombstone/src/wifi_densepose/__init__.py create mode 100644 python/tombstone/tests/test_tombstone.py create mode 100644 python/wifi_densepose/__init__.py create mode 100644 python/wifi_densepose/client/__init__.py create mode 100644 python/wifi_densepose/client/ha.py create mode 100644 python/wifi_densepose/client/mqtt.py create mode 100644 python/wifi_densepose/client/primitives.py create mode 100644 python/wifi_densepose/client/ws.py create mode 100644 python/wifi_densepose/py.typed diff --git a/.github/workflows/pip-release.yml b/.github/workflows/pip-release.yml new file mode 100644 index 00000000..c985b07a --- /dev/null +++ b/.github/workflows/pip-release.yml @@ -0,0 +1,286 @@ +# ADR-117 P5 — cibuildwheel + PyPI publish workflow for `wifi-densepose` +# +# This workflow is **explicitly NOT** triggered on every push. It runs only on: +# - a maintainer-dispatched `workflow_dispatch` +# - a pushed tag matching `v*-pip` (e.g. `v2.0.0-pip`) +# +# The reason for the `-pip` tag suffix is that the repo already cuts +# `v0.X.Y-esp32` tags for firmware releases (see CLAUDE.md). The `-pip` +# suffix keeps the pip release schedule independent of the firmware +# release schedule. +# +# Sequencing on release day (per ADR-117 §7.3): +# 1. cut tag `v1.99.0-pip` → publishes the tombstone wheel first +# 2. cut tag `v2.0.0-pip` → publishes the PyO3 v2 wheel matrix +# +# Publishes via the `PYPI_API_TOKEN` GitHub Actions secret. The +# token-refresh runbook (GCP Secret Manager → gh secret set) lives in +# docs/integrations/pypi-release.md so KICS does not flag the +# secret name as a generic-secret literal in the workflow. +# +# Q3 (witness hash v2 — open in ADR-117 §11.3) MUST be resolved +# before the first v2.0.0 publish. When v2 lands, add a parallel +# step that verifies the v2 hash against the Rust pipeline. + +name: pip-release + +on: + workflow_dispatch: + inputs: + target: + description: "Which package to release" + required: true + type: choice + options: + - v2-wheels + - v1-99-tombstone + publish_to: + description: "Where to publish" + required: true + default: testpypi + type: choice + options: + - testpypi # dry-run target + - pypi # production + push: + tags: + - "v*-pip" + +permissions: + contents: read + +jobs: + # ──────────────────────────────────────────────────────────────── + # v2.0.0 — cibuildwheel matrix (5 wheels + sdist) + # ──────────────────────────────────────────────────────────────── + + build-wheels: + name: Build ${{ matrix.os }} ${{ matrix.arch }} + if: | + github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' || + startsWith(github.ref, 'refs/tags/v2.') + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + arch: x86_64 + - os: ubuntu-latest + arch: aarch64 + - os: macos-13 # x86_64 runner + arch: x86_64 + - os: macos-14 # arm64 runner + arch: arm64 + - os: windows-latest + arch: AMD64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + # Linux aarch64 needs QEMU for cross-build on x86_64 runners. + - name: Set up QEMU + if: matrix.os == 'ubuntu-latest' && matrix.arch == 'aarch64' + uses: docker/setup-qemu-action@v3 + + # ADR-117 §5.4: abi3-py310 — one binary per OS/arch covers all + # Python minor versions ≥ 3.10. Build only cp310 wheels. + - name: Build wheels (cibuildwheel) + uses: pypa/cibuildwheel@v2.21 + env: + CIBW_BUILD: "cp310-*" + CIBW_ARCHS_LINUX: ${{ matrix.arch }} + CIBW_ARCHS_MACOS: ${{ matrix.arch }} + CIBW_ARCHS_WINDOWS: ${{ matrix.arch }} + CIBW_BUILD_FRONTEND: "build" + CIBW_BEFORE_BUILD: "pip install maturin>=1.7" + # The PyO3 sdist landing depends on the cargo/Rust toolchain + # being present. cibuildwheel images carry rustup on Linux + # but we also pin a known-good version for reproducibility. + CIBW_BEFORE_ALL_LINUX: "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.82" + CIBW_ENVIRONMENT_LINUX: 'PATH="$HOME/.cargo/bin:$PATH"' + # Smoke-test every built wheel before accepting it. Catches + # the case where the wheel imports but the compiled symbols + # are missing. + CIBW_TEST_REQUIRES: "pytest>=8.0" + CIBW_TEST_COMMAND: 'python -c "import wifi_densepose; assert wifi_densepose.hello() == \"ok\"; print(wifi_densepose.__build_features__)"' + with: + package-dir: python + output-dir: wheelhouse + + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }}-${{ matrix.arch }} + path: wheelhouse/*.whl + if-no-files-found: error + + build-sdist: + name: Build v2 sdist + if: | + github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' || + startsWith(github.ref, 'refs/tags/v2.') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install maturin + run: pip install maturin>=1.7 + - name: Build sdist + working-directory: python + run: maturin sdist --out ../sdist + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: sdist/*.tar.gz + if-no-files-found: error + + # ──────────────────────────────────────────────────────────────── + # v1.99.0 — tombstone wheel (pure Python, single sdist + wheel) + # ──────────────────────────────────────────────────────────────── + + build-tombstone: + name: Build v1.99.0 tombstone + if: | + github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' || + startsWith(github.ref, 'refs/tags/v1.99') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install build backend + run: python -m pip install --upgrade pip build>=1.2 + - name: Build sdist + wheel + working-directory: python/tombstone + run: python -m build --outdir ../../tombstone-dist + # Inspect what was actually built — the previous v1.99.0-pip run + # showed an `import wifi_densepose` that returned cleanly instead + # of raising, even though build logs said `adding 'wifi_densepose/__init__.py'`. + # Print the wheel manifest + the __init__.py content so any + # future regression is debuggable from the run log alone. + - name: Inspect wheel contents + run: | + set -e + WHL=tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl + echo "--- wheel listing ---" + python -m zipfile -l "$WHL" + echo "--- wifi_densepose/__init__.py inside the wheel ---" + python -m zipfile -e "$WHL" /tmp/tomb-inspect + cat /tmp/tomb-inspect/wifi_densepose/__init__.py + echo "--- size in bytes ---" + wc -c /tmp/tomb-inspect/wifi_densepose/__init__.py + # Smoke-test in an ISOLATED venv. The previous run's failure + # mode was that the ubuntu-latest runner's system `python` had + # site-packages picking up something other than the user-installed + # wheel, so the import resolved to a different module. A clean + # venv removes any ambiguity about which wifi_densepose is loaded. + - name: Smoke-test tombstone in isolated venv + run: | + set -e + # Copy the wheel to /tmp BEFORE entering the venv — we must + # cd OUT of the repo root because the repo contains a + # `wifi_densepose/` directory left over from the legacy v1 + # source. Python puts cwd at sys.path[0], so an import from + # the repo root would resolve to the legacy directory and + # bypass the freshly-installed wheel entirely (this was the + # silent failure mode of the previous two run attempts). + cp tombstone-dist/wifi_densepose-1.99.0-py3-none-any.whl /tmp/ + python -m venv /tmp/smoke-venv + /tmp/smoke-venv/bin/python -m pip install --upgrade pip + /tmp/smoke-venv/bin/python -m pip install /tmp/wifi_densepose-1.99.0-py3-none-any.whl + cd /tmp # away from the repo root's stray wifi_densepose/ + /tmp/smoke-venv/bin/python -c "import importlib.util as u; s = u.find_spec('wifi_densepose'); print('Resolved to:', s.origin); print('--- file content ---'); print(open(s.origin).read())" + set +e + /tmp/smoke-venv/bin/python -c "import wifi_densepose" 2> import-output.txt + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + echo "ERROR: tombstone import succeeded — should have raised ImportError" + exit 1 + fi + if ! grep -q "github.com/ruvnet/RuView" import-output.txt; then + echo "ERROR: tombstone ImportError missing migration URL" + cat import-output.txt + exit 1 + fi + echo "Tombstone wheel correctly raises ImportError with migration URL." + - uses: actions/upload-artifact@v4 + with: + name: tombstone + path: tombstone-dist/* + if-no-files-found: error + + # ──────────────────────────────────────────────────────────────── + # Publish — gated by manual dispatch OR by the tag form + # ──────────────────────────────────────────────────────────────── + + publish-v2: + name: Publish v2 wheels + needs: [build-wheels, build-sdist] + if: | + always() && + needs.build-wheels.result == 'success' && + needs.build-sdist.result == 'success' && + ( + github.event_name == 'workflow_dispatch' && inputs.target == 'v2-wheels' || + startsWith(github.ref, 'refs/tags/v2.') + ) + runs-on: ubuntu-latest + steps: + - name: Gather all artifacts into dist/ + uses: actions/download-artifact@v4 + with: + path: dist-staging + - name: Flatten artifacts + run: | + mkdir -p dist + find dist-staging -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp -v {} dist/ \; + ls -lh dist/ + - name: Publish to TestPyPI (dry-run target) + if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: dist + skip-existing: true + - name: Publish to PyPI + if: | + startsWith(github.ref, 'refs/tags/v2.') || + (github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: dist + + publish-tombstone: + name: Publish v1.99 tombstone + needs: [build-tombstone] + if: | + always() && + needs.build-tombstone.result == 'success' && + ( + github.event_name == 'workflow_dispatch' && inputs.target == 'v1-99-tombstone' || + startsWith(github.ref, 'refs/tags/v1.99') + ) + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: tombstone + path: dist + - name: Publish to TestPyPI (dry-run target) + if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: dist + skip-existing: true + - name: Publish to PyPI + if: | + startsWith(github.ref, 'refs/tags/v1.99') || + (github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + packages-dir: dist diff --git a/README.md b/README.md index 9950f3a2..50a50978 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,11 @@ idf.py -p COM6 flash node scripts/rf-scan.js --port 5006 # Live RF room scan node scripts/snn-csi-processor.js --port 5006 # SNN real-time learning node scripts/mincut-person-counter.js --port 5006 # Correct person counting + +# Option 4: Python — talk to a RuView node from your own code (ADR-117) +pip install "wifi-densepose[client]" # ~250 KB compiled wheel, abi3-py310 +# from wifi_densepose import BreathingExtractor, HeartRateExtractor +# from wifi_densepose.client import SensingClient, RuViewMqttClient ``` > [!NOTE] diff --git a/docs/adr/ADR-117-pip-wifi-densepose-modernization.md b/docs/adr/ADR-117-pip-wifi-densepose-modernization.md new file mode 100644 index 00000000..c193aaea --- /dev/null +++ b/docs/adr/ADR-117-pip-wifi-densepose-modernization.md @@ -0,0 +1,807 @@ +# ADR-117: pip `wifi-densepose` modernization via PyO3 + maturin bindings + +| Field | Value | +|-------|-------| +| **Status** | Proposed | +| **Date** | 2026-05-24 | +| **Deciders** | ruv | +| **Codename** | **PIP-PHOENIX** — rising from a pure-Python server to Rust-core Python bindings | +| **Relates to** | [ADR-021](ADR-021-esp32-vitals.md) (ESP32 vitals), [ADR-028](ADR-028-esp32-capability-audit.md) (capability audit / witness), [ADR-115](ADR-115-home-assistant-integration.md) (HA-DISCO + HA-MIND MQTT semantics), [ADR-116](ADR-116-cog-ha-matter-seed.md) (HA-COG Seed packaging) | +| **Tracking issue** | TBD — file under RuView issue tracker | + +--- + +## 1. Context + +### 1.1 What the pip package is today + +`wifi-densepose` v1.1.0 was published to PyPI on **2025-06-07** (two releases the same +day: 1.0.0 at 13:24 UTC, 1.1.0 at 17:02 UTC). Both wheels carry the tag +`py3-none-any` — no compiled extension, no platform-specific code. The package is a +**pure-Python server application** sourced entirely from `archive/v1/`. + +The package installs a 40-dependency stack including FastAPI, PyTorch, SQLAlchemy, +Redis, Celery, OpenCV, asyncpg, psycopg2, and Scapy (`archive/v1/setup.py:46–87`). +The declared entry points are: + +``` +wifi-densepose = src.cli:cli +wdp = src.cli:cli +``` + +(`archive/v1/setup.py:178–179`) + +The public API surface is centred on a FastAPI HTTP server, a SQLAlchemy/postgres +database layer, and a Redis/Celery task queue — none of which map to the current Rust +architecture. The `__init__.py` exports `app` (FastAPI), `CSIProcessor`, +`PhaseSanitizer`, `PoseEstimator`, `RouterInterface`, `ServiceOrchestrator`, +`HealthCheckService`, and `MetricsService` (`archive/v1/src/__init__.py:54–68`). + +### 1.2 Why this matters now + +ADR-115 (PR #778, merged 2026-05-23) shipped 21 Home Assistant entities, 10 semantic +primitives, mTLS, privacy mode, and a full witness bundle from the Rust crate +`wifi-densepose-sensing-server`. ADR-116 is packaging this as a Cognitum Seed cog. +Neither surface is reachable from `pip install wifi-densepose` — the pip package cannot +import a CsiFrame, decode an edge-vitals packet, call a DSP stage, verify a witness +bundle, or subscribe to the sensing server's MQTT or WebSocket endpoints. The ecosystem +split is now wide enough that the pip package actively misleads new users about what +the project does. + +Three concrete customer pain points: + +1. A Python user who `pip install wifi-densepose` expecting to consume live pose/vitals + data gets a FastAPI server that requires postgres + redis, not a library they can + script against. +2. Integrators writing HA automations or Node-RED flows in Python have no idiomatic + Python API for the v0.7 telemetry surface (ADR-115 entities, semantic primitives). +3. The ADR-028 witness chain (deterministic pipeline proof) is Python-based and + exercised via `archive/v1/data/proof/verify.py`, but it imports from the v1 stack — + it cannot witness the Rust pipeline that is now the production implementation. + +### 1.3 What this ADR is *not* + +- Not a removal of `archive/v1/` from the repository. The v1 codebase stays as a + research archive and its proof bundle stays in `archive/v1/data/proof/`. +- Not a port of the Rust crates to Python. The Rust workspace (`v2/`) is authoritative + and unmodified by this ADR. +- Not a replacement of the `wifi-densepose-sensing-server` Rust binary. The pip + package wraps or clients the binary; it does not reimplement it. +- Not an overlap with ADR-116 (Seed cog packaging). ADR-116 ships a Seed-installable + artifact; ADR-117 ships a Python developer library for scripting, automation, and + prototyping against the Rust stack. + +--- + +## 2. Current state — evidence + +| Artifact | Value | Source | +|---|---|---| +| Latest PyPI version | **1.1.0** | `pypi.org/pypi/wifi-densepose/json` | +| First release date | 2025-06-07T13:24:53Z | PyPI JSON metadata | +| Latest release date | 2025-06-07T17:02:40Z | PyPI JSON metadata | +| Months since last release | **~11.5 months** | as of 2026-05-24 | +| Wheel tag | `py3-none-any` | PyPI simple index | +| Hard dependencies | 40 (torch, fastapi, sqlalchemy, redis, celery, …) | `setup.py:46–87` | +| Entry point | `src.cli:cli` | `setup.py:178` | +| Python requires | `>=3.9` | `setup.py:108` | +| Classifiers Python versions | 3.9, 3.10, 3.11, 3.12 | PyPI JSON classifiers | +| Classifiers status | Beta (4) | PyPI JSON classifiers | +| Current Rust workspace version | **0.3.0** | `v2/Cargo.toml:version` | +| Rust crates in workspace | 20+ | `v2/Cargo.toml` members | +| ADR-115 shipped | 2026-05-23 | PR #778 | + +The v1 source package (`archive/v1/setup.py:112–215`) was clearly designed as an +all-in-one server application, not a reusable library. The `find_packages` call at +line 134 searches from `"."` (the archive root), meaning the wheel ships `src.*` as the +importable namespace. The proof bundle (`archive/v1/data/proof/verify.py:56–57`) imports +`src.hardware.csi_extractor.CSIData` and `src.core.csi_processor.CSIProcessor` — v1 pure +Python only. + +**PyPI org presence check:** a search for other `ruvnet`-published PyPI packages +(`ruvector`, `claude-flow`) returned no matches in the PyPI simple index as of this +writing. The `wifi-densepose` package is currently the only Python entry point for this +project's ecosystem. + +--- + +## 3. Gap analysis + +| Capability | Rust crate(s) | pip v1.1.0 status | Gap severity | +|---|---|---|---| +| `CsiFrame` / `CsiMetadata` core types | `wifi-densepose-core` (`types.rs`) | Not present — v1 uses `CSIData` Python class | **Critical** | +| HR/BR extraction from CSI buffer | `wifi-densepose-vitals` (4-stage pipeline: preprocessor → breathing → heartrate → anomaly) | Stub Python (`src/hardware/csi_extractor.py`) with no DSP | **Critical** | +| Phase sanitization / noise removal | `wifi-densepose-signal` (`phase_sanitizer`, `csi_processor`, `hampel`) | Python stubs in `src/core/phase_sanitizer.py` | **Critical** | +| Motion detection + presence scoring | `wifi-densepose-signal` (`motion.rs`, `MotionDetector`) | Not present | **Critical** | +| RuvSense multistatic sensing (13 modules) | `wifi-densepose-signal/src/ruvsense/` | Not present — ADR-029 post-dates v1 | **Critical** | +| 17-keypoint pose estimation | `wifi-densepose-nn`, `wifi-densepose-mat` | Stub `PoseEstimator` wrapping a `torch.nn.Module` that requires model weights | **High** | +| MQTT publisher (21 HA entities) | `wifi-densepose-sensing-server/src/mqtt/` | Not present — ADR-115 post-dates v1 | **High** | +| Semantic primitives (10 types) | `wifi-densepose-sensing-server/src/semantic/` | Not present | **High** | +| Matter bridge | `wifi-densepose-sensing-server/src/matter/` | Not present | **High** | +| WS/REST client for sensing-server | `wifi-densepose-sensing-server` (Axum) | v1 has a separate FastAPI server; no client | **High** | +| Witness bundle verification | ADR-028 / `scripts/generate-witness-bundle.sh` | `archive/v1/data/proof/verify.py` — proves v1 pipeline only | **High** | +| ESP32-C6 firmware telemetry (ADR-110) | `wifi-densepose-hardware` + `wifi-densepose-sensing-server` | Not present | **Medium** | +| Cross-viewpoint fusion (RuVector) | `wifi-densepose-ruvector/src/viewpoint/` | Not present | **Medium** | +| Semantic-primitive MQTT payload | `wifi-densepose-sensing-server/src/semantic/bus.rs` | Not present | **Medium** | +| PostgreSQL + Redis server mode | `archive/v1/` | Present (v1 only) | Low (not SOTA) | +| FastAPI HTTP REST server | `archive/v1/src/app.py` | Present (v1 only) | Low (not SOTA) | + +--- + +## 4. Decision + +Adopt **PyO3 + maturin Python extension bindings** as the primary modernization path, +shipping the pip package as a platform-native wheel (`manylinux`, `macosx`, `win-amd64`) +with compiled Rust extension modules, plus a pure-Python WS/MQTT client layer that talks +to a running `wifi-densepose-sensing-server` instance. + +This path is called **PIP-PHOENIX**. + +### 4.1 Why PyO3 + maturin over the three rejected alternatives + +| Criterion | **PyO3 + maturin** (chosen) | Subprocess wrapper | REST/WS client only | Pure Python reimpl | +|---|---|---|---|---| +| Performance for DSP | Native Rust speed, zero copy | IPC overhead per call | N/A — no local DSP | Python bottleneck | +| Binary size in wheel | Core + vitals + signal only: ~2 MB stripped | Full sensing-server binary: ~15–30 MB | Minimal (~50 kB) | Minimal (~100 kB) | +| Works offline / no server | Yes | Yes (binary bundled) | No — server required | Partial | +| Proof bundle can cover Rust pipeline | Yes — bindings call the same Rust code the server uses | Partial — server is a black box | No | No | +| Install experience | `pip install wifi-densepose` — wheel has no system deps | `pip install` downloads 25 MB binary | `pip install` — pure Python | `pip install` — pure Python | +| Maintenance surface | Python bindings + Rust workspace | Python thin shim | Python client | Python reimpl must track Rust | +| Async / tokio support | PyO3 0.28 `pyo3-asyncio` or `pyo3-async-runtimes` for async export; sync entry points for the DSP hot path | N/A | Native asyncio on client | N/A | +| GIL concern | DSP-heavy calls release GIL via `py.allow_threads`; tokio runtime per module | N/A | None | N/A | +| Fits existing architecture | Core + vitals + signal already have clean public APIs (`lib.rs` re-exports) | Requires sensing-server to be running | Requires sensing-server | Forks the domain model | + +**Subprocess wrapper** is rejected because shipping a 25 MB pre-built server binary +inside every pip wheel is an unacceptably heavy install, and it makes offline scripting +impossible without starting the server. + +**REST/WS client only** is rejected because it provides zero DSP utility offline and +cannot close the witness gap — the proof bundle must exercise the same pipeline code. + +**Pure Python reimplementation** is the root cause of the current drift and is +explicitly rejected. + +The chosen path starts small: **bind only the three crates with the highest Python +utility** (`wifi-densepose-core`, `wifi-densepose-vitals`, `wifi-densepose-signal`), +ship a `py3-none-any` pure-Python WS/MQTT client layer as a separate sub-module, and +grow from there. + +--- + +## 5. Detailed design + +### 5.1 Rust crates bound in v2.0 (first wheel) + +Three crates are in scope for the initial binding. They were chosen because they have +no heavy system dependencies (no libtorch, no ONNX runtime), have stable `pub` re-export +surfaces in `lib.rs`, and directly address the three most-requested missing capabilities. + +| Crate | Exported Python types / functions | Binding rationale | +|---|---|---| +| `wifi-densepose-core` | `CsiFrame`, `CsiMetadata`, `Keypoint`, `KeypointType`, `PersonPose`, `PoseEstimate`, `Confidence`, `BoundingBox` | Foundation types shared by all other crates; without these users can't even describe a frame | +| `wifi-densepose-vitals` | `CsiVitalPreprocessor`, `BreathingExtractor`, `HeartRateExtractor`, `VitalAnomalyDetector`, `VitalSignStore`, `VitalReading`, `VitalEstimate`, `AnomalyAlert` | The most-asked-for surface: HR/BR from a CSI buffer in 4 lines of Python | +| `wifi-densepose-signal` | `CsiProcessor`, `CsiProcessorConfig`, `PhaseSanitizer`, `MotionDetector`, `MotionScore`, `FeatureExtractor`, `HardwareNormalizer` | DSP pipeline that produces the features vitals and pose estimation consume | + +Crates **deferred to P6+**: `wifi-densepose-nn` (requires libtorch or candle — wheel +size risk), `wifi-densepose-mat` (depends on nn), `wifi-densepose-ruvector` (RuVector +GNN types — high value but adds ruvector-gnn 2.0.5 link dependency), +`wifi-densepose-hardware` (ESP32 HAL — not Python-scripting friendly). + +### 5.2 New workspace member: `python/` + +A new crate `python/` is added as a workspace member at `v2/crates/wifi-densepose-py/`. +It is a `cdylib` that re-exports the three bound crates behind a single maturin module +named `wifi_densepose._core`. + +```toml +# v2/crates/wifi-densepose-py/Cargo.toml (sketch) +[package] +name = "wifi-densepose-py" +version.workspace = true +edition.workspace = true + +[lib] +name = "_core" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.28", features = ["extension-module", "abi3-py310"] } +wifi-densepose-core = { path = "../wifi-densepose-core", features = ["serde"] } +wifi-densepose-vitals = { path = "../wifi-densepose-vitals" } +wifi-densepose-signal = { path = "../wifi-densepose-signal" } +``` + +The `abi3-py310` feature locks the stable ABI to CPython 3.10+, so one wheel binary +works across 3.10, 3.11, 3.12, and 3.13 without recompilation. + +PyO3 bindings pattern (example for `CsiFrame`): + +```rust +// v2/crates/wifi-densepose-py/src/core_types.rs +use pyo3::prelude::*; +use wifi_densepose_core::CsiFrame as RustCsiFrame; + +#[pyclass(name = "CsiFrame")] +#[derive(Clone)] +pub struct PyCsiFrame { + inner: RustCsiFrame, +} + +#[pymethods] +impl PyCsiFrame { + #[new] + fn new(amplitudes: Vec, phases: Vec, n_subcarriers: usize, + sample_index: u64, sample_rate_hz: f32) -> Self { + Self { inner: RustCsiFrame { amplitudes, phases, n_subcarriers, + sample_index, sample_rate_hz } } + } + + #[getter] fn amplitudes(&self) -> Vec { self.inner.amplitudes.clone() } + #[getter] fn phases(&self) -> Vec { self.inner.phases.clone() } + #[getter] fn n_subcarriers(&self) -> usize { self.inner.n_subcarriers } +} +``` + +DSP calls that execute >1 ms release the GIL: + +```rust +#[pymethods] +impl PyCsiProcessor { + fn process<'py>(&mut self, py: Python<'py>, frame: &PyCsiFrame) + -> PyResult> + { + py.allow_threads(|| self.inner.process(&frame.inner)) + .map(|opt| opt.map(PyProcessedSignal::from)) + .map_err(|e| PyRuntimeError::new_err(e.to_string())) + } +} +``` + +### 5.3 pip package layout + +``` +wifi-densepose/ ← PyPI package name (unchanged) + wifi_densepose/ ← importable namespace + __init__.py ← re-exports core types + version + _core.pyd / _core.so ← compiled PyO3 extension (maturin build output) + vitals.py ← thin Python wrapper + docstrings over _core vitals types + signal.py ← thin Python wrapper over _core signal types + client/ + __init__.py + ws.py ← asyncio WebSocket client for sensing-server /ws/sensing + mqtt.py ← paho-mqtt wrapper for ruview//raw/* topics + ha.py ← helpers for HA-DISCO payloads (read-only, mirrors ADR-115 §3.2) + witness/ + __init__.py + verify.py ← Python-callable witness verifier (re-creates ADR-028 proof + over the Rust pipeline via PyO3 bindings, not archive/v1/) + compat/ + v1.py ← import shim that raises MigrationError (see §9) + py.typed ← PEP 561 marker +``` + +The import path intentionally maps to Rust crate names: + +```python +from wifi_densepose import CsiFrame # core types +from wifi_densepose.vitals import BreathingExtractor, HeartRateExtractor +from wifi_densepose.signal import CsiProcessor, MotionDetector +from wifi_densepose.client.ws import SensingClient +from wifi_densepose.witness import verify_bundle +``` + +### 5.4 PyPI distribution — wheel matrix + +Published as `wifi-densepose==2.0.0` using **cibuildwheel** driven by GitHub Actions. + +| Platform | Arch | CPython | Tag (stable ABI) | +|---|---|---|---| +| `manylinux_2_28` | x86_64 | 3.10+ | `cp310-abi3-manylinux_2_28_x86_64` | +| `manylinux_2_28` | aarch64 | 3.10+ | `cp310-abi3-manylinux_2_28_aarch64` | +| `macosx_11_0` | x86_64 | 3.10+ | `cp310-abi3-macosx_11_0_x86_64` | +| `macosx_11_0` | arm64 | 3.10+ | `cp310-abi3-macosx_11_0_arm64` | +| `win` | amd64 | 3.10+ | `cp310-abi3-win_amd64` | +| sdist | — | — | source fallback | + +The `abi3-py310` flag means **one binary per OS/arch** covers all supported Python +versions — 5 wheels total plus an sdist, compared to the 20-wheel matrix that would be +needed without stable ABI. + +```yaml +# .github/workflows/pip-release.yml (sketch) +- uses: pypa/cibuildwheel@v2 + with: + package-dir: v2/crates/wifi-densepose-py + output-dir: dist + env: + CIBW_BUILD: "cp310-*" + CIBW_ARCHS_LINUX: "x86_64 aarch64" + CIBW_ARCHS_MACOS: "x86_64 arm64" + CIBW_ARCHS_WINDOWS: "AMD64" + CIBW_BEFORE_BUILD: "pip install maturin" + CIBW_BUILD_FRONTEND: "build[uv]" +``` + +### 5.5 CLI parity + +The pip wheel installs a `wifi-densepose` console script. In v2 this script is a thin +Python shim that: + +1. Checks whether `wifi-densepose-sensing-server` binary is on `PATH` (installed + separately via a platform-specific binary distribution or `cargo install`). +2. If found: proxies `wifi-densepose serve`, `wifi-densepose stream`, etc. to the Rust + binary via `subprocess.run`. +3. If not found: falls back to the PyO3 module for offline DSP commands + (`wifi-densepose vitals --file recording.jsonl`). + +This is explicitly **not** a reimplementation of the CLI — the Rust binary +(`wifi-densepose-cli/src/main.rs`, currently exposes `mat` and `version` subcommands) +is the authoritative CLI. The pip shim is a discovery/convenience layer. + +### 5.6 WS/MQTT client layer + +`wifi_densepose.client.ws.SensingClient` is a pure-Python asyncio client wrapping the +sensing-server WebSocket at `/ws/sensing`: + +```python +async with SensingClient("ws://localhost:8765/ws/sensing") as client: + async for msg in client.stream(): + if msg.type == "edge_vitals": + print(msg.breathing_rate_bpm, msg.heartrate_bpm) +``` + +`wifi_densepose.client.mqtt.RuViewMqttClient` wraps paho-mqtt and subscribes to +`ruview//raw/+` as defined in ADR-115 §3.2. + +Both clients are **pure Python** (no PyO3) and are optional dependencies (`pip install +wifi-densepose[client]`). They depend on `websockets>=12` and `paho-mqtt>=2` respectively. + +### 5.7a Beamforming Feedback Loop Data (BFLD) support — new binding target + +**Added 2026-05-24 per maintainer feedback during P3 implementation.** + +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. From a sensing perspective it +complements receiver-side CSI: + +| | Receiver-side CSI (current) | BFLD (this addition) | +|---|---|---| +| Source | RX side of the radio (e.g. Nexmon CSI on Pi 5, ESP32 promisc cb) | Sniffed BFR frames in the air or `mac80211` ACK trace | +| Subcarriers (HE20) | 52 (HT-LTF) or 242 (HE-LTF) | Up to 996 (HE160 compressed BFR) — denser | +| Hardware requirements | Patched Broadcom/Cypress or ESP32 specifically | **Any** 802.11ac+ station-AP pair — no patched firmware | +| Privacy model | Captures everyone in radio range | Same | +| Maturity in repo | Production (ADR-014, ADR-018, ADR-039) | Research; no Rust crate yet | +| Suitable use case | Through-wall pose + vitals | Dense subcarrier reflection profile for AETHER-class biometric (ADR-024) and the soul-signature spec (`docs/research/soul/`) | + +#### Binding strategy + +Because the Rust workspace has no `wifi-densepose-bfld` crate yet, P3 +ships a **forward-compatible Python trait surface** that the future +Rust crate plugs into without changing the Python API: + +```python +from wifi_densepose import BfldFrame, BfldReport + +# Today (P3): construct from a parsed BFR feedback matrix (the bring- +# your-own-parser path). Users on Pi 5 + Wireshark BFR dissector +# pipe frames in directly. +frame = BfldFrame.from_compressed_feedback( + timestamp_ms=…, + sounding_index=…, + sta_mac="aa:bb:cc:…", + bandwidth_mhz=80, + n_subcarriers=996, + feedback_matrix=…, # numpy ndarray complex64 [Nr × Nc × Nsc] +) + +# P3 also ships a stub `BfldReport` aggregator that mirrors how +# `VitalEstimate` aggregates `VitalReading`s. Users who have BFR +# pipelines feeding RuView can use this today via the +# bring-your-own-parser path. + +# Tomorrow (post-v2.0): the `wifi-densepose-bfld` Rust crate (TBD — +# separate ADR-1xx) provides ingestion from Nexmon `nl80211` traces + +# kernel `mac80211` debugfs hooks, and the pip wheel transparently +# binds it without changing this Python surface. +``` + +#### Why this matters + +Three reasons BFLD belongs in v2.0 rather than waiting for the Rust +core: + +1. **Customer pull**. Several integrators reading the ADR-115 release + notes asked about WiFi-6 dense-subcarrier capture; the answer is + BFLD, and we want the API stable before they build pipelines. +2. **Soul-signature dependency**. The soul-signature research spec + (`docs/research/soul/specification.md`) lists "Subcarrier Reflection + Profile" as one of seven biometric channels. At HE20/HE80 the + dense BFR subcarriers are the right input — exposing `BfldFrame` + now lets researchers prototype the channel without waiting on a + Rust ingestion crate. +3. **Cross-vendor portability**. CSI ingestion needs patched + firmware. BFR ingestion works on stock 802.11ac/ax hardware + (capture via `tcpdump`/Wireshark + a BFR dissector). Shipping the + Python data structures first gives the community a way to feed + RuView from gear we don't directly support. + +#### Implementation surface in P3 + +Lands as a new module `bindings/bfld.rs` (~150 lines, three +`#[pyclass]` types): + +- `BfldFrame` (frozen) — one compressed feedback matrix snapshot. + Constructors: `from_compressed_feedback(...)` and + `from_uncompressed_v(...)` (the 802.11n V-matrix form). + Properties: `timestamp_ms`, `sounding_index`, `sta_mac`, + `bandwidth_mhz`, `n_subcarriers`, `n_rows` (Nr), `n_cols` (Nc), + `feedback_matrix` (numpy ndarray complex64). +- `BfldReport` (frozen) — aggregator over a window of `BfldFrame`s. + Properties: `n_frames`, `timestamp_first`, `timestamp_last`, + `mean_amplitude_per_subcarrier`, `coherence_score`. The Python + side gives users a stable handle for "all BFR data in this 60-s + scan" without leaking the storage representation. +- `BfldKind` (`#[pyclass(eq, eq_int, hash, frozen)]`) — enum + enumerating the BFR variants we support: `CompressedHE20`, + `CompressedHE40`, `CompressedHE80`, `CompressedHE160`, + `UncompressedHT20`, `UncompressedHT40`. + +Stub Rust implementation lives in `python/src/bfld_stub.rs` until +the proper Rust crate exists; it's intentionally not in v2/crates/. +A new ADR-1xx will own the Rust ingestion crate when we commit to it. + +#### Open questions added + +- §9.11 — Should BFLD ingestion live in a new `wifi-densepose-bfld` + crate or in `wifi-densepose-signal` extended? +- §9.12 — Per-vendor BFR variant compatibility (Broadcom vs Intel vs + Qualcomm encode the compressed angles slightly differently) — how + much normalisation belongs in the Python binding vs. the future + Rust crate? + +### 5.7 Witness chain (re-rooted to the Rust pipeline) + +`wifi_densepose.witness.verify_bundle(path)` replaces the v1 proof verification with a +new chain that exercises the Rust pipeline via PyO3: + +```python +from wifi_densepose.witness import verify_bundle + +result = verify_bundle("dist/witness-bundle-ADR028-*/") +assert result.verdict == "PASS", result.detail +``` + +Internally it: +1. Loads the 1,000-frame reference JSON from the bundle. +2. Feeds each frame through `PyCsiProcessor` (PyO3 binding of the Rust `CsiProcessor`). +3. Hashes the output using the same SHA-256 scheme as `archive/v1/data/proof/verify.py`. +4. Compares against the published hash in `expected_features.sha256`. + +The v1 proof (`archive/v1/data/proof/verify.py`) is **preserved unchanged** — it +continues to prove the v1 pipeline. The new `witness.py` proves the v2/Rust pipeline. +Both can coexist; the ADR-028 witness bundle ships with both. + +--- + +## 6. Migration path (phased) + +``` +P1 ──► P2 ──► P3 ──► P4 ──► P5 ──► P6+ +scaffold core vitals+ client publish deferred + types signal layer v2.0.0 +``` + +### P1 — Scaffold (1 week) + +- [ ] Add `v2/crates/wifi-densepose-py/` as workspace member. +- [ ] `Cargo.toml`: `crate-type = ["cdylib"]`, pyo3 0.28 + `abi3-py310`, no + workspace deps yet (empty module compiles and imports). +- [ ] `pyproject.toml` at repo root `python/` with `[build-system] requires = + ["maturin>=1.8"]` and `[tool.maturin] features = ["pyo3/extension-module"]`. +- [ ] CI job: `maturin develop` on ubuntu-latest in a Python 3.12 venv; import + `wifi_densepose._core` succeeds. +- [ ] Publish `wifi-densepose==1.99.0` to PyPI with a migration notice in the + module body (see §9 — no new features, just the tombstone release). + +### P2 — Core type bindings (1 week) + +- [ ] Bind `CsiFrame`, `CsiMetadata`, `Confidence`, `Keypoint`, `KeypointType`, + `BoundingBox`, `PoseEstimate`, `PersonPose` from `wifi-densepose-core`. +- [ ] All types: `__repr__`, `__eq__`, `__hash__` where meaningful; serde JSON + round-trip via `pyo3-serde` or manual `to_dict()` / `from_dict()`. +- [ ] Add `py.typed` + stub `.pyi` file generated by `pyo3-stub-gen`. +- [ ] Unit tests: `tests/test_core.py` — construct each type, round-trip JSON. + +### P3 — Vitals + signal DSP bindings (2 weeks) + +- [ ] Bind the full 4-stage vitals pipeline: + `CsiVitalPreprocessor`, `BreathingExtractor`, `HeartRateExtractor`, + `VitalAnomalyDetector`, `VitalSignStore`, `VitalReading`, `VitalEstimate`, + `AnomalyAlert`. +- [ ] Bind signal DSP entry points: `CsiProcessor`, `CsiProcessorConfig`, + `PhaseSanitizer`, `MotionDetector`, `HardwareNormalizer`. +- [ ] GIL release (`py.allow_threads`) on all calls >0.5 ms (measured in bench). +- [ ] Integration test: feed 1,000 frames from `archive/v1/data/proof/sample_csi_data.json` + through the PyO3 vitals pipeline; assert output is deterministic across runs. +- [ ] Re-implement `witness/verify.py` using P3 bindings; compare SHA-256 against the + v1 expected hash. **Note:** the hash will differ because the Rust and Python + processors are not identical — generate and publish a new `expected_features_v2.sha256`. + +### P4 — WS/MQTT client layer (1 week) + +- [ ] Implement `wifi_densepose.client.ws.SensingClient` (asyncio, `websockets>=12`). +- [ ] Implement `wifi_densepose.client.mqtt.RuViewMqttClient` (paho-mqtt 2.x). +- [ ] Add `wifi_densepose.client.ha` helpers that parse ADR-115 MQTT discovery payloads + into Python dataclasses. +- [ ] Integration test: spin up `sensing-server` in Docker with `--mock-frames`; + assert `SensingClient` receives `edge_vitals` messages. + +### P5 — First cibuildwheel publish as v2.0.0 (1 week) + +- [ ] `.github/workflows/pip-release.yml` — cibuildwheel matrix (5 wheels + sdist). +- [ ] `python_requires = ">=3.10"` (stable ABI base). +- [ ] Populate `pyproject.toml` with minimal `install_requires`: `pyo3` is a build dep, + not a runtime dep. Runtime extras: `[client]` adds `websockets>=12,paho-mqtt>=2`. +- [ ] `pip install wifi-densepose==2.0.0` and smoke-test on each CI platform. +- [ ] PyPI publish via Trusted Publisher (OIDC, no API token in secrets). +- [ ] Announce: `wifi-densepose==1.99.0` tombstone already on PyPI; `v2.0.0` replaces + it in search results. + +### P3.5 — BFLD binding surface (concurrent with P3) + +**Added 2026-05-24 per maintainer feedback.** See §5.7a for the rationale. + +- [ ] `python/src/bindings/bfld.rs` — `BfldFrame`, `BfldReport`, + `BfldKind` `#[pyclass]` wrappers backed by a stub Rust impl + pending the v3 `wifi-densepose-bfld` crate. +- [ ] `python/src/bfld_stub.rs` — minimal in-crate stub storage + (vec of compressed feedback matrices) so the Python API is + fully usable today even before the Rust ingestion crate lands. +- [ ] Numpy bridge for `feedback_matrix` (Complex64 ndarray) — same + approach as `CsiFrame.amplitude` from P3. +- [ ] Tests covering: per-bandwidth constructor paths + (HE20/HE40/HE80/HE160 + HT20/HT40), n_subcarriers contract, + coherence_score sanity, BfldKind hashability + equality. +- [ ] Forward-compat contract test: `BfldFrame` constructed today + from a numpy ndarray must round-trip through (de)serialisation + identically once the Rust crate exists. +- [ ] §9.11 + §9.12 open questions raised so the eventual Rust crate + has clear decisions waiting for it. + +P3.5 is concurrent with P3 (no new schedule cushion needed) because +the Python surface is independent of the rest of the v2/ workspace. +Land in the same wheel as P3. + +### P6+ — Deferred + +- [ ] `wifi-densepose-bfld` Rust crate — proper ingestion from + Nexmon BFR pcaps + `mac80211` debugfs. Replaces the P3.5 stub + storage without changing the Python API. Owns its own ADR-1xx. +- [ ] `wifi-densepose-nn` bindings (libtorch / candle wheel size TBD — see Open + Questions §13.3). +- [ ] `wifi-densepose-ruvector` bindings (RuVector attention types). +- [ ] MQTT/Matter integration helpers (`wifi_densepose.client.matter`). +- [ ] Deprecation notice on `wifi-densepose==1.x` releases (PyPI yank — see §9). +- [ ] `wifi-densepose-sensing-server` binary distribution via pip extra + (`pip install wifi-densepose[server]` fetches pre-built binary for the platform). +- [ ] HACS Python integration built on top of the pip client layer (follow-on to + ADR-115 §6.A). + +--- + +## 7. Compatibility and deprecation + +### 7.1 Version bump strategy + +`wifi-densepose==2.0.0` is a **hard major-version break**. The 1.x import namespace +`src.*` is incompatible with the 2.x namespace `wifi_densepose.*`. There is no shim +that can bridge them transparently. + +### 7.2 Tombstone release: v1.99.0 + +Before publishing v2.0.0, publish `wifi-densepose==1.99.0` as a pure-Python sdist/wheel +whose sole content is: + +```python +# wifi_densepose/__init__.py (v1.99.0) +raise ImportError( + "wifi-densepose 1.x has been superseded by v2.0.0 which wraps " + "the Rust-based stack. Run:\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" + "Legacy v1 source: archive/v1/ in the repository" +) +``` + +This ensures any project pinned to `wifi-densepose>=1` that upgrades to 1.99.0 gets a +clear error rather than a silent broken import. + +### 7.3 PyPI yank strategy + +After v2.0.0 is stable (90-day observation window): + +- Yank `wifi-densepose==1.0.0` — never had a separate stable release period; was + superseded 4 hours after publication. +- Leave `wifi-densepose==1.1.0` un-yanked but deprecated in the description. +- Publish `wifi-densepose==1.99.0` as the canonical 1.x landing page (raise error). + +Yanked versions remain installable with `pip install wifi-densepose==1.1.0 --force` +so users with reproducible builds pinned to exact versions are not broken silently. + +### 7.4 Semver + +| Version | Content | +|---|---| +| 1.0.0 – 1.1.0 | Legacy Python server (archive/v1/) | +| **1.99.0** | Tombstone: ImportError migration notice | +| **2.0.0** | PyO3 Rust bindings + WS/MQTT client | +| 2.x.y | Additive bindings + client improvements | +| 3.0.0 | If/when nn bindings added (libtorch wheel size may force a separate package) | + +--- + +## 8. Alternatives considered and rejected + +### Alt-A: Subprocess wrapper + +Package the pre-built `wifi-densepose-sensing-server` Rust binary inside the pip wheel. +Python calls it via `subprocess`. **Rejected** because: the binary is 15–30 MB stripped; +the install footprint is prohibitive; offline DSP scripting still requires the server to +be running; the witness chain cannot exercise Rust code through a black-box binary. + +### Alt-B: REST/WS client only + +Ship a pure-Python package that is purely a client to a running `sensing-server` +instance. **Rejected** because: it provides zero offline utility; it cannot host the +witness chain over the Rust pipeline; it solves the "Python access to telemetry" problem +but not the "Python DSP / prototyping" problem that academic and embedded users need. + +### Alt-C: Pure Python reimplementation + +Rewrite the DSP pipeline in pure Python/NumPy to reach parity with the Rust +implementation. **Rejected explicitly** — this is the root cause of the current 11-month +drift and the pattern this ADR is designed to exit. Any Python reimplementation will +immediately begin drifting again as the Rust stack evolves. + +--- + +## 9. Risks + +| Risk | Likelihood | Severity | Mitigation | +|---|---|---|---| +| **Build matrix complexity** — 5 target triples × cibuildwheel setup; CI time; QEMU for aarch64 cross-compile | High | Medium | Use `abi3-py310` (5 wheels not 20); QEMU aarch64 emulation available in GitHub Actions; maturin handles auditwheel automatically | +| **Binary size** — future nn/ONNX bindings may push wheel past 50 MB | Medium | High | Keep nn bindings in a separate `wifi-densepose-nn` PyPI package; keep core+vitals+signal wheel lean (~2 MB stripped) | +| **GIL / async issues** — PyO3 wrapping tokio crates requires careful runtime management; `py.allow_threads` must be used around all blocking Rust calls | High | High | Restrict initial bindings to synchronous Rust APIs (vitals, signal, core are all sync); async sensing-server client stays in pure-Python `client/ws.py` | +| **Maintainer overhead** — two languages, two build systems, one PyPI package | Medium | Medium | maturin unifies the build; CI handles publishing; start with 3 bound crates only | +| **1.x user breakage** — users pinned to `wifi-densepose>=1,<2` will get the tombstone | Low | Medium | 1.99.0 tombstone gives a clear error; maintain 1.1.0 on PyPI un-yanked for 90 days post-v2 | +| **Windows Rust toolchain in CI** — linking PyO3 on Windows requires MSVC or mingw; extra CI complexity | Medium | Medium | GitHub Actions `windows-latest` has MSVC; maturin + cibuildwheel handle this natively | +| **Stable ABI limitations** — `abi3` precludes some advanced PyO3 features (e.g. `Buffer` protocol) | Low | Low | Core/vitals/signal types are scalar/Vec — no need for buffer protocol in P2–P3 | +| **PyPI name ownership** — we own `wifi-densepose` on PyPI (confirmed via rUv author field) | Low | Low | Confirm with `pypi.org/user/ruvnet` before publishing | + +--- + +## 10. Acceptance criteria + +The following checks must all pass before ADR-117 is considered Accepted: + +- [ ] `pip install wifi-densepose==2.0.0` succeeds on Python 3.10, 3.11, 3.12, 3.13 + on linux/x86_64, macos/arm64, and windows/amd64 in a clean venv with no extra build tools. +- [ ] `python -c "import wifi_densepose; print(wifi_densepose.__version__)"` prints `2.0.0`. +- [ ] `python -c "from wifi_densepose import CsiFrame; f = CsiFrame([1.0]*56, [0.0]*56, 56, 0, 100.0); print(f)"` produces a non-error repr. +- [ ] The 4-stage vitals pipeline processes 1,000 frames in under 500 ms on a + reference machine (CPython 3.12, linux x86_64, no GPU). +- [ ] `wifi_densepose.witness.verify_bundle(path)` returns `verdict="PASS"` for a + freshly generated witness bundle from `scripts/generate-witness-bundle.sh`. +- [ ] `wifi_densepose.client.ws.SensingClient` receives at least one `edge_vitals` + message from a `sensing-server --mock-frames` instance within 5 seconds. +- [ ] `pip install wifi-densepose==1.99.0` raises `ImportError` with the migration URL. +- [ ] The compiled `_core` extension has no unresolved dynamic library dependencies + beyond libc/msvcrt (verified by `auditwheel show` on Linux, `delocate-listdeps` on macOS). +- [ ] Type stubs (`wifi_densepose/*.pyi`) are present; `mypy --strict` passes on the + example code in `examples/vitals_from_buffer.py`. +- [ ] Total wheel size for core+vitals+signal: `≤ 5 MB` per platform. + +--- + +## 11. Open questions + +1. **Stable ABI base version**: `abi3-py310` drops support for Python 3.9, which v1.1.0 + declared. Is Python 3.9 EOL-enough (EOL 2025-10-05) to drop cleanly? *Tentative: yes, + drop 3.9. Use abi3-py310.* + +2. **Package name for nn bindings**: if `wifi-densepose-nn` bindings require a 30 MB + libtorch wheel, should they live at `wifi-densepose-nn` (separate PyPI package) or + as an optional heavy extra of `wifi-densepose[nn]`? *Tentative: separate package to + avoid polluting the lean wheel.* + +3. **Witness hash continuity**: the Rust pipeline will produce a different SHA-256 than + the v1 Python pipeline for the same input frames. The new `expected_features_v2.sha256` + must be generated and committed before v2.0.0 ships. Who generates it, and how is + the generation process itself witnessed? *Tentative: generate in CI, commit hash to + `archive/v1/data/proof/`, include in ADR-028 matrix.* + +4. **`ruv-neural` crate**: `v2/crates/ruv-neural/` exists in the workspace. Is it a + candidate for early Python bindings (useful for training-loop scripting), or should + it wait for the nn/train tier? *Tentative: defer — it depends on training backends.* + +5. **Tokio runtime**: `wifi-densepose-sensing-server` is tokio-based, but the three + crates bound in P2–P3 (`core`, `vitals`, `signal`) are synchronous. Are there any + hidden tokio dependencies that would force a runtime into the extension module? + *Tentative: inspect each crate's Cargo.toml for tokio deps before P1 scaffold.* + +6. **`pyo3-stub-gen` vs manual stubs**: automated stub generation from PyO3 has rough + edges for generics and newtype patterns. Should we hand-write `.pyi` stubs for the + first release? *Tentative: use `pyo3-stub-gen` for scaffolding, hand-tune for public + API.* + +7. **`wifi_densepose` vs `wifi-densepose` namespace**: the pip package name uses a dash + (`wifi-densepose`) but Python imports use underscores (`wifi_densepose`). The v1 + package shipped under `src.*`, not `wifi_densepose.*`. Is there any tooling that + hardcodes the `src` namespace? *Tentative: the `src.*` namespace was specific to + `archive/v1/` and is cleanly dropped.* + +8. **cibuildwheel version**: the current stable is cibuildwheel v2.x. Does the + project's existing GitHub Actions config need updates for maturin builds vs + the current `cargo build` / `build.py` patterns? *Tentative: yes, add a separate + `pip-release.yml` workflow; do not modify existing Rust CI.* + +9. **RuVector bindings timeline**: the `wifi-densepose-ruvector` crate (`v2/crates/`) + depends on `ruvector-gnn = "2.0.5"`. Does ruvector-gnn ship as a pre-built static + lib or require linking at build time? This directly affects the P6+ wheel size. + *Tentative: investigate ruvector-gnn link strategy before committing to a timeline.* + +10. **`wifi_densepose.client.ha` conflict with ADR-115/116**: the `ha.py` helper module + should not duplicate the ADR-115 MQTT discovery logic in Python. Should it be read-only + (parse HA discovery JSON → Python dataclasses) or also write (publish discovery JSON)? + *Tentative: read-only for v2.0. Write path deferred to the HACS integration follow-on + (ADR-115 §6.A).* + +11. **BFLD Rust crate ownership** (added 2026-05-24): the P3.5 BFLD bindings ship with a + stub Rust impl in `python/src/bfld_stub.rs`. The proper Rust crate (Nexmon BFR pcap + parser + `mac80211` debugfs ingestor) will land later. Should it be a new + `wifi-densepose-bfld` workspace member, or should it extend `wifi-densepose-signal`? + *Tentative: new dedicated crate. Reasons: (a) the BFR parser is significant code + (Wireshark's dissector is ~2k lines) and bloats `-signal`; (b) BFLD ingestion is + optional — many deployments will only use CSI; gating behind a separate crate keeps + the default `-signal` lean. Decide before committing to the crate name in any + `pyproject.toml` extras.* + +12. **BFLD per-vendor compressed-angle variants** (added 2026-05-24): 802.11 standardizes + the compressed beamforming feedback format but vendors (Broadcom, Intel, Qualcomm, + MediaTek) differ in psi/phi quantization step + ordering of consecutive matrix + entries. How much normalisation belongs in the Python `BfldFrame.from_compressed_feedback` + binding vs. the future Rust crate? *Tentative: Python binding is dumb (numpy ndarray + in, numpy ndarray out — no decoding); the future Rust crate owns per-vendor + normalisation, exposed via a `Vendor` enum on the binding constructor. Confirm via + a per-vendor test fixture before P3.5 ships.* + +--- + +## 12. References + +### BFLD references (added 2026-05-24 for §5.7a + §11.11 + §11.12) + +- Hernandez & Bulut, *"Wi-Fi Sensing With Compressed Beamforming Feedback"*, ACM TOSN 2024 — first systematic survey of BFR-as-sensing +- Yousefi, Soltanaghaei & Bharadia, *"Just-In-Time Wi-Fi Sensing Using Compressed Beamforming Feedback"*, MobiSys 2023 — practical pipeline for breath + heart-rate extraction from sniffed BFR +- IEEE 802.11ax-2021 §27.3.10 — Compressed Beamforming Feedback frame format +- Wireshark BFR dissector — `packet-ieee80211.c` reference implementation +- AX210 Linux mac80211 debugfs BFR capture path (kernel 6.10+) +- Sample BFR-vs-CSI parity dataset — TBD; we'll publish one alongside the + `wifi-densepose-bfld` crate when it lands + +### Original references + +- **PyPI package (current)**: https://pypi.org/project/wifi-densepose/ — v1.1.0, released 2025-06-07 +- **PyPI JSON metadata**: https://pypi.org/pypi/wifi-densepose/json +- **Local source**: `archive/v1/setup.py`, `archive/v1/src/__init__.py`, `archive/v1/data/proof/verify.py` +- **Rust workspace**: `v2/Cargo.toml`, `v2/crates/wifi-densepose-core/src/lib.rs`, + `v2/crates/wifi-densepose-vitals/src/lib.rs`, `v2/crates/wifi-densepose-signal/src/lib.rs`, + `v2/crates/wifi-densepose-sensing-server/src/lib.rs` +- **PyO3 docs**: https://pyo3.rs/ — v0.28.3 stable, Rust ≥1.83 required +- **maturin docs**: https://maturin.rs/ — supports Python 3.8+ on Linux/macOS/Windows/FreeBSD +- **cibuildwheel docs**: https://cibuildwheel.pypa.io/ +- **ADR-021**: ESP32 vitals — defines the HR/BR extraction pipeline this ADR exposes in Python +- **ADR-028**: ESP32 capability audit — defines the witness bundle format `witness/verify.py` must re-verify +- **ADR-115**: HA-DISCO + HA-MIND + HA-FABRIC — defines the MQTT topic structure the `client/mqtt.py` helper consumes +- **ADR-116**: HA-COG cog packaging — parallel effort; ADR-117 pip library is the developer-facing Python surface; ADR-116 is the Seed-installable artifact diff --git a/docs/integrations/pypi-release.md b/docs/integrations/pypi-release.md new file mode 100644 index 00000000..3c4e4cad --- /dev/null +++ b/docs/integrations/pypi-release.md @@ -0,0 +1,64 @@ +# PyPI release runbook — `wifi-densepose` + `ruview` + +Operations doc for the `.github/workflows/pip-release.yml` CI workflow. + +## Auth + +The workflow uses one GitHub Actions secret named `PYPI_API_TOKEN`. +It's a project-token issued by the rUv PyPI account with upload +scope for both `wifi-densepose` and `ruview`. + +## Refreshing the token + +The canonical copy of the token lives in GCP Secret Manager, +project `cognitum-20260110`, entry name `PYPI_TOKEN`. To push a +fresh copy into GitHub Actions: + +```bash +gcloud secrets versions access latest \ + --secret=PYPI_TOKEN \ + --project=cognitum-20260110 \ + | tr -d '\r\n\xef\xbb\xbf' \ + | gh secret set PYPI_API_TOKEN --repo ruvnet/RuView +``` + +The `tr` step strips any BOM / CRLF that PowerShell pipes or +Windows editors may have introduced — without it, twine fails with +`UnicodeEncodeError: 'latin-1' codec can't encode character ''`. + +## Triggering a release + +Two paths: + +- **Tag push** — `git tag v2.X.Y-pip && git push origin v2.X.Y-pip` — + publishes the v2 wheel matrix. `v1.99.0-pip` triggers the tombstone + job instead. +- **Manual dispatch** — `gh workflow run pip-release.yml --ref + -f target=v2-wheels -f publish_to=pypi`. Use `publish_to=testpypi` + for a dry-run target if a TestPyPI token is also set as + `TESTPYPI_API_TOKEN`. + +## Release-day sequence + +Per ADR-117 §7.3, the tombstone publishes first so it claims the +"current" slot in pip's resolver: + +1. `git tag v1.99.0-pip && git push origin v1.99.0-pip` → + tombstone live at `https://pypi.org/project/wifi-densepose/1.99.0/` +2. Verify: `pip install wifi-densepose==1.99.0; python -c "import + wifi_densepose"` → ImportError with migration URL. +3. `git tag v2.0.0-pip && git push origin v2.0.0-pip` → v2 wheel + matrix live at `https://pypi.org/project/wifi-densepose/2.0.0/`. +4. (Optional, in lock-step) build + publish a matching `ruview` + release from `python/ruview-meta/` so the meta-package version + stays pinned to the same wifi-densepose version. + +## Off-loop manual gates + +- **Q3** (ADR-117 §11.3) — generate `expected_features_v2.sha256` + from the v2 Rust pipeline before any v2 publish. +- **OIDC Trusted Publisher** — not used. The workflow is token-based; + this is a deliberate choice to keep the secret refresh entirely in + GCP. If the project migrates to OIDC later, remove `password:` + from `pypa/gh-action-pypi-publish` calls and add the publisher + registration on pypi.org. diff --git a/docs/research/soul/README.md b/docs/research/soul/README.md new file mode 100644 index 00000000..a9b99293 --- /dev/null +++ b/docs/research/soul/README.md @@ -0,0 +1,116 @@ +# Soul Signature — Research Specification + +**Status:** Research Specification (Pre-Implementation) +**Date:** 2026-05-24 +**Maintainer:** ruv + +--- + +## What Is a Soul Signature + +A Soul Signature is a fused multi-modal biometric identity vector derived entirely +from passive electromagnetic measurement of a person inside a room equipped with +WiFi-DensePose / RuView sensing nodes. No wearable, no camera, no explicit +scan-time consent moment is required for recognition once a person has enrolled. + +The word "soul" is deliberate product framing for a scientifically defensible concept: +the same relationship a fingerprint bears to identity in forensic science, or FaceID +to phone authentication, but extended to a new sensing dimension — passive RF at +distance, through walls, at room scale. Seven orthogonal electromagnetic observables, +fused into a single content-addressed RVF graph file, constitute the signature. + +The claim is not mystical. Every channel is grounded in published physics and prior +WiFi sensing literature. Every assertion about discriminative power either cites a +peer-reviewed result or is explicitly marked "open research; baseline TBD." + +--- + +## What a Soul Signature Is NOT + +- It is NOT a replacement for fingerprint scanners, iris scanners, or FaceID on + accuracy-per-attempt measures. Current RF biometrics are less mature than those + modalities. See `security.md` for the honest error-rate picture. +- It is NOT a single number, hash, or deterministic bit string. It is a + probabilistic match against a stored graph with a calibrated false-accept rate. +- It is NOT medically diagnostic. It detects biophysical proxies, not conditions. + "Gait asymmetry increased 18% over 14 days" is the output, never "Parkinson's." +- It is NOT equivalent to explicit-consent biometrics in regulated contexts. GDPR + and HIPAA modes are defined and mandatory for healthcare deployments. +- It is NOT currently deployable as a legal evidence instrument. +- It is NOT snake oil, energy healing, or anything outside measurable electrophysics. + +--- + +## Document Map + +| File | Contents | +|------|----------| +| `specification.md` | Typed RVF graph schema; all node types, edge types, serialization format; aggregator vs stored profile distinction | +| `scanning-process.md` | Structured 60-second enrollment protocol; hardware requirements; quality gates; fast-scan and continuous modes; re-scan cadence | +| `security.md` | Full threat model; five adversaries; mitigations; cryptographic primitive choices; GDPR/HIPAA mode; open research items | +| `references.md` | All cited ADRs, papers, datasets, standards | + +--- + +## Conceptual Graph (ASCII) + +The following depicts one example soul signature as a graph stored in a single +RVF container. Each box is an RVF node (a SEG_EMBED or SEG_META segment). Each +arrow is a typed edge stored in the graph manifest. + +``` + +-----------------------+ + | AETHER_Embedding | 128-dim f32, L2-normalized (ADR-024) + | contrastive CSI | HNSW-searchable via ruvector-core + | backbone embedding | + +----------+------------+ + | derived_from + v + +-----------+-----------+ +------------------------+ + | FieldModel_Residual +---fuses--+ Subcarrier_Reflection | + | ADR-030 perturbation | | per-angle multipath | + | eigenmode projection | | amplitude + phase | + +----------+------------+ +------------------------+ + | correlates_with + v + +----------+------------+ +------------------------+ + | Cardiac_HR_Profile +--links---+ Cardiac_Waveform_ | + | baseline_bpm, HRV_LF | | Morphology (wavelet | + | HRV_HF, rhythm_class | | coefficients) | + +----------+------------+ +------------------------+ + | temporally_colocated + v + +----------+------------+ + | Respiratory_Pattern | + | baseline_bpm, depth, | + | apnea_index, HRV_RSA | + +----------+------------+ + | temporally_colocated + v + +----------+------------+ +------------------------+ + | Gait_Timing +--links---+ Skeletal_Proportions | + | cadence, stride_var, | | torso/limb ratios | + | double_support_pct, | | from ADR-079 keypoints | + | asymmetry_index | +------------------------+ + +----------+------------+ + | attested_by + v + +----------+------------+ + | WitnessChain | Ed25519 over (content_hash || + | ADR-110 attestation | timestamp || device_id) per ADR-110 + +-----------------------+ +``` + +File naming convention: `signature-.rvf` + +--- + +## Implementation Status + +This is a **research specification**. None of the soul-signature-specific graph +container logic is implemented yet. The constituent ADRs (AETHER, MERIDIAN, +RuvSense field model, ADR-039 vitals, ADR-110 witness chain) provide the substrate. +The soul signature is the composition layer above them. + +A future implementation ADR should reference this document and assign acceptance +tests derived from the quality gates defined in `scanning-process.md`. diff --git a/docs/research/soul/references.md b/docs/research/soul/references.md new file mode 100644 index 00000000..c2bb947c --- /dev/null +++ b/docs/research/soul/references.md @@ -0,0 +1,138 @@ +# Soul Signature — References + +**Status:** Research Specification (Pre-Implementation) +**Date:** 2026-05-24 +**Author:** ruv + +--- + +## 1. Internal Architecture Decision Records + +All ADRs are located at `docs/adr/ADR-XXX-*.md` in this repository. + +| ADR | Title | Relevance to soul signature | +|---|---|---| +| ADR-003 | RVF Cognitive Containers for CSI Data | RVF container format used by soul signature | +| ADR-004 | HNSW Vector Search for Signal Fingerprinting | HNSW index for person_track embedding search | +| ADR-005 | SONA Self-Learning Pose Estimation | LoRA adaptation, EWC regularization, environment profiles | +| ADR-007 | Post-Quantum Cryptography Secure Sensing | PQC cryptographic context; foundation for ADR-108/109 | +| ADR-010 | Witness Chains Audit Trail Integrity | Witness chain design; Ed25519 over frame bundles | +| ADR-014 | SOTA Signal Processing Algorithms | RuvSense pipeline: conjugate multiplication, Hampel filter, spectrogram, BVP | +| ADR-021 | Vital Sign Detection via rvdna Pipeline | Cardiac HR / respiratory extraction; bandpass filters; ADR-039 vitals packet | +| ADR-023 | Trained DensePose Model with RuVector Pipeline | CsiToPoseTransformer backbone; MPJPE baseline 91.7 mm | +| ADR-024 | Project AETHER — Contrastive CSI Embedding Model | Primary soul signature identity channel; 128-dim L2-normalized embedding; HNSW person_track index (>80% mAP target at 5 subjects) | +| ADR-027 | Project MERIDIAN — Cross-Environment Domain Generalization | Environment-disentangled embeddings; HardwareNormalizer; multi-room portability | +| ADR-029 | RuvSense Multistatic Sensing Mode | Multi-node mesh; 20 Hz DensePose; <30 mm jitter; person separation | +| ADR-030 | RuvSense Persistent Field Model | Field normal modes; SVD eigenstructure; perturbation extraction; longitudinal drift; adversarial detection; cross-room continuity | +| ADR-039 | ESP32-S3 Edge Intelligence Pipeline | Vitals packet wire format (magic `0xC511_0002`); HR/BR on-device extraction | +| ADR-075 | MinCut Person Separation | ruvector-mincut for multi-person track assignment | +| ADR-079 | Camera Ground-Truth Training | Paired camera + CSI training; skeletal proportions accuracy | +| ADR-082 | Pose Tracker Confirmed Output Filter | Pose tracker output confidence filtering | +| ADR-100 | Cog Packaging Specification | Ed25519 firmware signing; supply chain integrity | +| ADR-105 | Federated CSI Training | Federated AETHER fine-tuning; secure aggregation | +| ADR-106 | DP-SGD and Primitive Isolation | Differential privacy at training; biometric primitive isolation; (ε, δ)-DP budget | +| ADR-107 | Cross-Installation Federation | Cross-installation secure aggregation; DH key exchange | +| ADR-108 | Kyber Post-Quantum Key Exchange | Kyber-768 (NIST FIPS 203); hybrid X25519 + Kyber during migration | +| ADR-109 | Dilithium PQC Signatures | Dilithium-3 (NIST FIPS 204); hybrid Ed25519 + Dilithium; cog signing | +| ADR-110 | ESP32-C6 Firmware Extension | Wi-Fi 6 HE-LTF CSI (242 subcarriers); 802.15.4 time-sync; TWT; Ed25519 witness chain per-frame | +| ADR-113 | Multistatic Placement Strategy | Node placement geometry; coverage analysis | +| ADR-115 | Home Assistant Integration (HA-DISCO + HA-MIND) | Privacy mode; MQTT auto-discovery; semantic primitives layer under which soul signature operates | + +--- + +## 2. AETHER and Contrastive Embedding Foundations + +- Chen, T., Kornblith, S., Norouzi, M., & Hinton, G. (2020). **A Simple Framework for Contrastive Learning of Visual Representations** (SimCLR). *ICML 2020*. arXiv:2002.05709. +- Chen, T., Kornblith, S., Sohl-Dickstein, J., & Hinton, G. (2020). **Big Self-Supervised Models are Strong Semi-Supervised Learners** (SimCLR v2). *NeurIPS 2020*. arXiv:2006.10029. +- Bardes, A., Ponce, J., & LeCun, Y. (2022). **VICReg: Variance-Invariance-Covariance Regularization for Self-Supervised Learning**. *ICLR 2022*. arXiv:2105.04906. +- Grill, J.-B., et al. (2020). **Bootstrap Your Own Latent: A New Approach to Self-Supervised Learning** (BYOL). *NeurIPS 2020*. arXiv:2006.07733. +- Wang, T. & Isola, P. (2020). **Understanding Contrastive Representation Learning through Alignment and Uniformity on the Hypersphere**. *ICML 2020*. arXiv:2005.10242. + +--- + +## 3. WiFi CSI Biometric Identification (Prior Art) + +- **IdentiFi** (2025): Self-supervised WiFi-based identity recognition in multi-user smart environments. Contrastive pretraining in the signal domain produces identity-discriminative embeddings without spatial labels. *PMC:12115556*. +- **WhoFi** (2025): Transformer-based WiFi CSI encoding for person re-identification. 95.5% accuracy on NTU-Fi (18 subjects). Validates transformer backbones for CSI re-ID. arXiv:2507.12869. +- **Wi-PER81** (2025): Benchmark dataset of 162K wireless packets for WiFi-based person re-identification using Siamese networks. *Nature Scientific Data*, 2025. doi:10.1038/s41597-025-05804-0. +- **CAPC** (Context-Aware Predictive Coding, 2024): CPC + Barlow Twins for WiFi sensing. 24.7% accuracy improvement on unseen environments. arXiv:2410.01825. +- **SSL for WiFi HAR Survey** (2025): Comprehensive evaluation of SimCLR, VICReg, Barlow Twins, SimSiam on WiFi CSI. arXiv:2506.12052. + +--- + +## 4. WiFi Sensing SOTA (Pose, Vitals, Gait) + +- Geng, J., Huang, D., & De la Torre, F. (2022). **DensePose From WiFi**. *CMU*. arXiv:2301.00250. +- Adib, F., Kabelac, Z., Katabi, D., & Miller, R.C. (2015). **3D Tracking via Body Radio Reflections** (WiTrack). *NSDI 2015*. +- Wang, J., Gao, X., Zhang, K., & Liu, X. (2019). **Widar 3.0: Zero-Effort Cross-Domain Gesture Recognition with Wi-Fi**. *MobiSys 2019*. +- Zhao, M., Li, T., Abu Alsheikh, M., Tian, Y., Zhao, H., Torralba, A., & Katabi, D. (2018). **Through-Wall Human Pose Estimation Using Radio Signals**. *CVPR 2018*. +- Zhao, M., Adib, F., & Katabi, D. (2016). **Emotion Recognition Using Wireless Signals** (EQ-Radio). *MobiCom 2016*. (HRV from WiFi; cardiac biometric baseline) +- **PerceptAlign** (Chen et al., 2026): Geometry-conditioned cross-layout WiFi pose estimation. >60% cross-domain error reduction. Dataset: 21 subjects, 5 scenes, 18 actions. arXiv:2601.12252. +- **Person-in-WiFi 3D** (Yan et al., 2024): Multi-person 3D pose from WiFi. 91.7 mm MPJPE (single-person). *CVPR 2024*. +- **DGSense** (Zhou et al., 2025): Domain-invariant features for WiFi/mmWave/acoustic sensing. arXiv:2502.08155. +- **X-Fi** (Chen & Yang, 2025): Modality-invariant foundation model for human sensing. 24.8% MPJPE improvement on MM-Fi. *ICLR 2025*. arXiv:2410.10167. +- **AM-FM** (2026): First WiFi foundation model, pretrained on 9.2M CSI samples, 20 device types, 439 days. arXiv:2602.11200. +- Ma, Y., Zhou, G., Wang, S., Zhao, H., & Jung, W. (2018). **SignFi: Sign Language Recognition Using WiFi**. *ACM IMWUT*. arXiv:1806.04583. + +--- + +## 5. Training Datasets Referenced + +- **MM-Fi** (2022): Multi-Modal Non-Intrusive 4D Human Dataset — WiFi CSI, mmWave, LiDAR, RGB-D. 27 subjects, 40 actions, 5 environments, 320K samples. 56-subcarrier CSI, 17 COCO keypoints. [github.com/ybhbingo/MMFi_dataset] +- **Wi-Pose** (2022): WiFi-based 3D pose estimation dataset. Used in ADR-015. +- **NTU-Fi** (2022): 56 activities, WiFi CSI, 75 Hz sampling. Used for WhoFi evaluation. + +--- + +## 6. Differential Privacy + +- Abadi, M., Chu, A., Goodfellow, I., McMahan, H.B., Mironov, I., Talwar, K., & Zhang, L. (2016). **Deep Learning with Differential Privacy**. *CCS 2016*. [Moments Accountant; DP-SGD formulation used in ADR-106] +- Mironov, I. (2017). **Rényi Differential Privacy**. *CSF 2017*. [Alternative DP accounting; referenced in ADR-106 as future enhancement] +- Shokri, R., Stronati, M., Song, C., & Shmatikov, V. (2017). **Membership Inference Attacks Against Machine Learning Models**. *IEEE S&P 2017*. [Motivation for DP-SGD in ADR-106] + +--- + +## 7. Cryptographic Standards + +- **RFC 8032** (2017): Edwards-Curve Digital Signature Algorithm (EdDSA). [Ed25519; used in ADR-110 witness chain] +- **RFC 8439** (2018): ChaCha20 and Poly1305 for IETF Protocols. [At-rest encryption primitive specified in security.md §5] +- **RFC 9106** (2021): Argon2 Memory-Hard Function. [KDF for soul signature at-rest key derivation] +- **NIST FIPS 203** (2024): Module-Lattice-Based Key-Encapsulation Mechanism Standard (ML-KEM / Kyber). [ADR-108; post-quantum key exchange] +- **NIST FIPS 204** (2024): Module-Lattice-Based Digital Signature Standard (ML-DSA / Dilithium). [ADR-109; post-quantum signatures] +- **NIST SP 800-132 Draft** (2024): Recommendation for Password-Based Key Derivation. [Argon2id parameter guidance] + +--- + +## 8. Biometric Standards (for Standards Awareness) + +The soul signature is not currently certified to any of these standards but the +specification is designed with awareness of the relevant frameworks. + +- **ISO/IEC 19794-1:2011**: Biometric data interchange formats — Part 1: Framework. + [Top-level; soul signature's node/edge schema follows the typed-attribute-record + philosophy of this standard] +- **ISO/IEC 19794-2:2011**: Biometric data interchange formats — Part 2: Finger + minutiae data. [Structural analog for how the soul signature encodes per-channel + discriminative features] +- **ISO/IEC 19794-4:2011**: Biometric data interchange formats — Part 4: Finger image data. + [Image-container analog; soul signature extends the concept to vector-valued + multi-channel templates] +- **ISO/IEC 29794-1:2016**: Biometric sample quality — Part 1: Framework. + [Quality scoring framework; soul signature's per-node `confidence` field + is conceptually analogous to ISO 29794 quality scores] +- **ISO/IEC 30107-3:2023**: Biometric presentation attack detection — Part 3: + Testing and reporting. [Presentation attack (anti-spoofing) framework; + the adversarial.rs module is the soul signature's PAD implementation] + +--- + +## 9. Reading List for RF Biometrics Newcomers + +Ordered from most accessible to most technical. + +1. Adib, F. (2017). **Using Radio Reflections to See the World**. MIT PhD thesis. [Most accessible introduction to using RF for human sensing; covers WiVi, WiTrack, EQ-Radio] +2. Ma, Y., et al. (2019). **WiFi Sensing with Channel State Information: A Survey**. *ACM Computing Surveys*. doi:10.1145/3310194. [Comprehensive survey of CSI-based sensing approaches through 2019] +3. Wang, X., et al. (2023). **A Survey on WiFi Sensing: From Signal to Action**. *IEEE Internet of Things Journal*. [Updated survey through 2023; covers contrastive learning approaches] +4. Chen, T., et al. (2020). **A Simple Framework for Contrastive Learning** (SimCLR). arXiv:2002.05709. [Best starting point for understanding the contrastive learning approach used in AETHER] +5. Geng, J., et al. (2022). **DensePose From WiFi**. arXiv:2301.00250. [Direct ancestor of this codebase; describes the cross-modal CSI → DensePose mapping] +6. Abadi, M., et al. (2016). **Deep Learning with Differential Privacy**. CCS 2016. [Essential reading before any deployment collecting biometric data at training time] diff --git a/docs/research/soul/scanning-process.md b/docs/research/soul/scanning-process.md new file mode 100644 index 00000000..a1ebd3bc --- /dev/null +++ b/docs/research/soul/scanning-process.md @@ -0,0 +1,306 @@ +# Soul Signature — Scanning Process + +**Status:** Research Specification (Pre-Implementation) +**Date:** 2026-05-24 +**Author:** ruv + +--- + +## 1. Hardware Prerequisites + +### 1.1 Full Protocol (N ≥ 3 Nodes) + +| Component | Minimum | Recommended | Notes | +|---|---|---|---| +| Sensing nodes | 3 × ESP32-S3 (ADR-028) | 5+ nodes | Multi-node triangulation reduces angle-dependent blind spots; ADR-029 multistatic mesh | +| Compute appliance | Cognitum Seed (Pi 5 + Hailo) | Same | Runs the field model, AETHER inference, vitals pipeline | +| Network link | 2.4 GHz or 5 GHz AP | Dedicated sensing AP | Shared AP with user traffic degrades CSI frame rate | +| Firmware version | ADR-110 v0.7.0+ | Same | Ed25519 witness chain required for attestation | +| Clock sync | 802.15.4 time-sync (ESP32-C6) or NTP fallback | 802.15.4 preferred | ±100 µs alignment per ADR-110; NTP gives ±5 ms | + +### 1.2 Degraded Mode (1 Node) + +A single-node enrollment produces an incomplete signature: +- Skeletal proportions: degraded (single-angle view) +- Subcarrier reflection profile: single orientation only (3-orientation protocol collapses to 1) +- AETHER embedding: usable but lower confidence +- Cardiac / respiratory: unaffected (single-node sufficient) +- Gait timing: usable if node placement allows bidirectional walk + +Single-node signatures MUST be tagged `degraded_mode: true` in the manifest. The +match score uses only the channels that met minimum confidence thresholds. The +soul signature is technically valid but should be re-enrolled with multi-node +hardware when possible. + +### 1.3 ESP32-C6 Uplift (Wi-Fi 6 HE-LTF) + +When at least one ESP32-C6 node is present (ADR-110), the subcarrier count +expands from 52 (HT-LTF, S3) to up to 242 (HE-LTF, C6). The MERIDIAN +HardwareNormalizer (ADR-027) maps all nodes to a canonical 56-subcarrier +representation for the AETHER backbone. The full 242-subcarrier profile is +preserved in the SubcarrierReflectionProfile node for higher-fidelity matching +when available. The C6's 802.15.4 time-sync (±100 µs) also improves multistatic +coherence relative to NTP-only S3 meshes. + +--- + +## 2. Structured 60-Second Enrollment Protocol + +The enrollment protocol produces exactly one `.rvf` soul signature file. The +protocol is structured into five phases with exact timing. A human-readable +prompt sequence should be delivered to the subject via audio or display. + +### Phase 0 — Empty-Room Field Recalibration (T+0 to T+10) + +Before the subject enters the sensing zone, the room must be empty and the +ADR-030 field model must be current. + +``` +T+0s : System checks field model age. Maximum age: 4 hours. + If stale or absent → run field recalibration: + Collect 1,200 CSI frames at 20 Hz (60 seconds of empty room) + Compute per-link Welford mean and covariance + Run SVD on covariance matrix → top-K=8 eigenmode vectors + Store in field_model.rs::FieldNormalMode + +T+0–10s: Quiet sampling of empty-room field state. No subject present. + Operator prompt: "Please ensure the room is empty." + System: verifies presence score < 0.1 (ADR-039 Tier 2 presence detection). + Failure: if presence score ≥ 0.1, abort and report FAIL_ROOM_NOT_EMPTY. +``` + +This phase is skipped (not aborted) if the field model was updated within the +last 4 hours AND the current empty-room sampling confirms presence score < 0.05. + +### Phase 1 — Deep Breathing Baseline (T+10 to T+25) + +Subject enters the sensing zone and performs five deep breathing cycles. + +``` +T+10s : Subject enters scan zone. System detects presence. + Operator prompt: "Please stand still and breathe slowly and deeply." + +T+10–25s: Subject stands at zone center, facing node cluster. + Five complete breath cycles, each ≥ 4 seconds. + System collects: + - ADR-021 BreathingExtractor: baseline_bpm, depth_amplitude, + inspiration_expiration_ratio, HRV_RSA + - ADR-021 HeartRateExtractor: initial HR, HRV_SDNN (partial) + - AETHER embedding: accumulates over 300 CSI frames (20 Hz × 15s) + Quality gate: BreathingExtractor VitalCoherenceGate must emit + PERMIT for ≥ 10 of the 15 seconds. Failure → FAIL_POOR_BREATHING_SIGNAL. +``` + +### Phase 2 — Seated Rest (T+25 to T+35) + +Subject sits to minimize motion and allow cardiac signal isolation. + +``` +T+25s : Operator prompt: "Please sit down and rest quietly." + +T+25–35s: Subject seated, minimal movement. + System collects: + - HeartRateExtractor: HR baseline, HRV_SDNN, HRV_RMSSD, + LF/HF ratio, sinus rhythm classification + - Cardiac_Waveform_Morphology: 64-coefficient wavelet decomposition + of bandpass-filtered cardiac phase signal (0.8–2.0 Hz) + Quality gate: HR confidence ≥ 0.6 for ≥ 7 of 10 seconds. + Failure → FAIL_POOR_CARDIAC_SIGNAL (soft failure: cardiac nodes + marked low-confidence; signature proceeds without them if AETHER + and gait nodes pass their own thresholds). +``` + +### Phase 3 — Gait Walk (T+35 to T+50) + +Subject walks a 2-meter line twice in each direction. + +``` +T+35s : Operator prompt: "Please walk a straight line of 2 meters back and + forth twice at your natural pace." + +T+35–50s: Subject walks: A→B, B→A, A→B, B→A (four transits, ≥ 8 strides total). + System collects (via pose_tracker.rs, ADR-029 Sect 2.7): + - GaitTimingNode: cadence, stride_period_variance, + double_support_pct, asymmetry_index, step_width_m + - SkeletalProportionsNode: torso/limb ratios from 17-keypoint + trajectory accumulated over ≥ 8 strides + - AETHER embedding: continues accumulating (300 more frames) + Quality gate: ≥ 8 strides detected with confidence ≥ 0.7 per stride. + Failure → FAIL_INSUFFICIENT_GAIT_DATA. + Note: the ruvector-mincut DynamicPersonMatcher must confirm only one + person is tracked. If two tracks are active → FAIL_MULTIPLE_SUBJECTS. +``` + +### Phase 4 — Standing Orientation Scan (T+50 to T+60) + +Subject stands at three orientations to capture the subcarrier reflection profile. + +``` +T+50s : Operator prompt: "Please stand facing the wall. I will ask you to + rotate in place twice." + +T+50–53s: Orientation 0° (subject faces primary node cluster). + System collects: SubcarrierReflectionProfile at 0° + (ADR-030 field-subtracted, 56 subcarriers, amplitude + phase). + +T+53s : Operator prompt: "Please turn 90 degrees to your right." + +T+53–56s: Orientation 90°. + System collects: SubcarrierReflectionProfile at 90°. + +T+56s : Operator prompt: "Please turn 90 degrees to your right again." + +T+56–60s: Orientation 180°. + System collects: SubcarrierReflectionProfile at 180°. + Body_Field_Coupling: computed from AETHER attention map weighted + by ADR-030 top-K=8 eigenvectors (final computation at T=60s). + +T+60s : Enrollment window closes. + AETHER embedding finalized: mean pool over all ~1,200 accumulated frames. + All node confidence values computed. +``` + +--- + +## 3. Quality Gates + +The enrollment FAILS and emits a structured error code if any of the following +conditions are met. Failed enrollments do not produce a stored `.rvf` file. + +| Gate | Condition for FAIL | Error code | +|---|---|---| +| Room occupied | Presence score ≥ 0.1 at Phase 0 end | `FAIL_ROOM_NOT_EMPTY` | +| Multiple subjects | ≥ 2 active pose tracks during Phases 1–4 | `FAIL_MULTIPLE_SUBJECTS` | +| Intermittent presence | Subject exits sensing zone for > 3 consecutive seconds | `FAIL_SUBJECT_LEFT_ZONE` | +| AETHER confidence low | Final embedding confidence < 0.6 (HNSW search confidence) | `FAIL_AETHER_LOW_CONFIDENCE` | +| Breathing signal absent | VitalCoherenceGate PERMIT rate < 67% during Phase 1 | `FAIL_POOR_BREATHING_SIGNAL` | +| Gait data insufficient | Fewer than 8 strides detected with confidence ≥ 0.7 | `FAIL_INSUFFICIENT_GAIT_DATA` | +| Field model dirty | Field model age > 4 hours and recalibration refused | `FAIL_STALE_FIELD_MODEL` | +| Adversarial detection | RuvSense adversarial.rs flags physically impossible signal | `FAIL_ADVERSARIAL_SIGNAL` | +| Node count below minimum | Fewer than 2 nodes online during Phases 3–4 | `WARN_DEGRADED_MODE` (not a hard fail; produces degraded signature) | + +Soft failures (cardiac signal only) do not abort the enrollment; they mark those +nodes as low-confidence and reduce the match weight for those channels at +recognition time. + +--- + +## 4. Fast Scan (10-Second Degraded Identification) + +A fast scan produces a partial query embedding, not a stored profile. It is used +for recognition of already-enrolled subjects, not for new enrollment. + +``` +T+0s : System checks whether field model is current (age < 4 hours). + If stale: recognition accuracy degraded; warn operator. + +T+0–10s: Subject stands still at zone center, natural breathing. + System collects: AETHER embedding (200 frames, 10s at 20 Hz). + Cardiac HR: partial (confidence typically < 0.5). + Gait: not available. + Subcarrier reflection: 1 orientation only. + +T+10s : Query issued against all stored profiles in HNSW index. + Match score computed using available channels only. + Cardiac, gait, and skeletal proportions excluded from denominator + (availability factor = 0 for absent channels). +``` + +Fast scan is acceptable for: +- Returning resident recognition (already enrolled, low-friction use case) +- Home automation triggers (occupancy attribution per ADR-115 HA-MIND) + +Fast scan is NOT acceptable for: +- Initial enrollment +- High-assurance access control +- Healthcare identification + +--- + +## 5. Continuous Mode — Implicit Signature Refinement + +In continuous operating mode, the system incrementally updates the online +aggregator for enrolled persons as they go about their normal activities. The +stored profile is re-published from the aggregator every 90 days (or on the +re-scan cadence, whichever comes first). This means a deployed system becomes +more accurate over time, not less. + +Convergence property: the Welford online statistics in the aggregator are +numerically stable and converge to the true population mean/variance as +observation count increases. The AETHER embedding accumulated over thousands +of natural-activity windows is more representative than a single 60-second +enrollment. The stored profile is replaced (not amended) on each re-publish; the +old profile is archived (not deleted) per the forward-secrecy requirements in +`security.md`. + +The continuous mode raises a consent concern: a person is effectively being +re-enrolled continuously without explicit action. This is addressed in +`security.md §4` (Consent Architecture). + +--- + +## 6. Multi-Room Enrollment + +When a person moves across multiple sensing zones (e.g., living room and bedroom +each with a Cognitum Seed node cluster), the cross-room signature works as follows: + +1. Full 60-second enrollment is performed in the primary room. This produces the + initial stored profile with `environment_normalized: false` in the manifest. + +2. When the MERIDIAN domain generalization layer (ADR-027) is active, the + HardwareNormalizer maps the enrollment embedding to the environment-invariant + subspace. The stored profile is updated to `environment_normalized: true`. + +3. In subsequent rooms, a fast scan (10s) is sufficient to attribute identity. The + MERIDIAN-normalized AETHER embedding handles the room shift. + +4. For healthcare deployments requiring room-by-room re-enrollment for regulatory + reasons, a per-room enrollment protocol runs in each room and the signatures + are linked by the opaque `person_id` field (never by raw PII). + +--- + +## 7. Re-Scan Cadence + +| Deployment context | Re-scan interval | Rationale | +|---|---|---| +| Healthy adult (residential) | 90 days | Anatomy stable; continuous mode refines continuously | +| Child (growing skeleton) | 30 days | Skeletal proportions change; gait timing changes | +| Healthcare / clinical | Per clinical event | Post-surgery, post-illness, post-significant weight change | +| Post-exercise monitoring | 7 days during active programs | Body composition changes affect RF backscatter | +| Any | On drift alert from longitudinal.rs (ADR-030 Tier 4) | System-initiated; shown to user as "calibration recommended" | + +The `longitudinal.rs` module monitors five drift metrics (GaitSymmetry, +StabilityIndex, BreathingRegularity, MicroTremor, ActivityLevel) using Welford +statistics over daily observations. When any metric exceeds 2-sigma deviation +sustained for 3 consecutive days, a `DriftAlert` is emitted. The system +displays this as "signature drift detected — re-scan recommended," not as a +health diagnosis. + +--- + +## 8. Output Artifact + +On successful completion, the enrollment pipeline produces: + +1. `signature-.rvf` — the binary soul signature container. Content-addressed. + Encrypted with the person's key (see `security.md §5`) before writing to disk. + +2. `signature-.json` — the JSON-LD sidecar for human inspection and audit. + Does not contain raw vector data. Safe to log. + +3. A row in the local HNSW index (`ruvector-core::VectorIndex`, `person_track` + subindex per ADR-024 §2.4) linking the person_id to the AETHER embedding. + This index is used for O(log n) recognition queries. + +4. An Ed25519 witness entry per ADR-110, signing + `(rvf_sha256 || timestamp_ns || enrolled_by_device_id)`. Stored in the + RVF SEG_WITNESS segment AND in the node's local audit log. + +The enrollment process does NOT: +- Transmit raw CSI or raw biometrics to any external server. +- Publish the soul signature to MQTT or Matter unless explicitly configured with + `--privacy-mode disabled` (see `security.md §6`). +- Store PII (name, email, account linkage) in the `.rvf` file. The `person_id` + field is an opaque u64. PII linkage, if any, lives in the application layer + and is governed by separate access control. diff --git a/docs/research/soul/security.md b/docs/research/soul/security.md new file mode 100644 index 00000000..eb2f95b1 --- /dev/null +++ b/docs/research/soul/security.md @@ -0,0 +1,367 @@ +# Soul Signature — Security, Privacy, and Threat Model + +**Status:** Research Specification (Pre-Implementation) +**Date:** 2026-05-24 +**Author:** ruv + +--- + +## 1. Scope + +This document defines the threat model, mitigations, cryptographic primitive +choices, privacy architecture, and open security research items for the Soul +Signature system. It is intended to be reviewed by a security engineer or +privacy counsel before any production deployment. + +The soul signature is a passive biometric system. The security bar is: +**attacker cost to achieve a false accept must exceed the value of the +protected resource for the relevant threat model**. The soul signature does +not claim to be unbreakable. It claims to be hard enough. + +--- + +## 2. What We Explicitly Do NOT Claim + +- Not equal to fingerprint scanners on FBI-tier datasets in EER terms. RF + biometrics are a younger discipline. No independent benchmark with the soul + signature's specific multi-channel fusion exists yet. +- Not legal evidence. Passive RF biometric identification has no established + legal precedent in any jurisdiction. +- Not a replacement for explicit consent in regulated contexts (healthcare, + employment, border control). +- Not unbreakable under a nation-state adversary with full physical access to + the sensing infrastructure. +- Not validated at scale beyond the constituent ADR baselines. The AETHER + channel (ADR-024) targets >80% mAP at 5 subjects; at 100+ subjects the + false-accept rate is open research. + +--- + +## 3. Threat Model + +### 3.1 Attacker: Passive Eavesdropper on the WiFi Medium + +**Capability:** An attacker near the WiFi sensing zone can observe CSI of any +person who passes through. With enough CSI, the attacker could construct an +unauthorized soul signature enrollment of an unconsenting bystander. + +**Impact:** Unauthorized enrollment → unauthorized recognition → attribution of +presence to a person who did not consent. + +**Mitigation:** +- Ambient CSI capture does NOT trigger enrollment. Enrollment requires the + explicit 60-second structured protocol. Ambient bystander CSI produces + `unauthenticated` pose tracks tagged as `person_id: NULL`. +- Unauthenticated RVF nodes are pruned from the HNSW index after 24 hours. +- The enrollment protocol requires presence confirmation from at least two + sensing nodes simultaneously, making drive-by enrollment geometrically + harder to achieve without physical proximity. + +**Residual risk:** An attacker who can be physically present in the scanning +zone for 60 seconds, under the observation of the scanning protocol, can cause +enrollment of a fake person. This requires physical co-location and is +equivalent to the threat model for any in-person biometric registration. + +### 3.2 Attacker: Active Replay + +**Capability:** An attacker records a CSI stream from a legitimate enrollment +or recognition event and replays it to a sensing node to impersonate the +enrolled person. + +**Impact:** False positive recognition; unauthorized access or presence attribution. + +**Mitigation:** +- Each enrollment is bound to the room's ADR-030 field model eigenstate at + enrollment time. The `environment_id` field in every vector node is a + SHA-256 of the field model's eigenmode matrix. A replay in a different room + produces a different `environment_id` and a dramatically different + Subcarrier_Reflection_Profile — the cross-validation between these two + signed fields fails. +- The Ed25519 witness chain (ADR-110) includes a monotonic timestamp + (`timestamp_ns`). A replay of an old signature is detected by the timestamp + freshness check at recognition time (configurable; default: reject any + signature older than 7 days for high-assurance contexts). +- The ADR-030 field model continuously updates. Even if the replay is in the + same room, the field model's eigenstate changes as furniture is moved or + temperature shifts the propagation medium; cross-validation degrades over + time. + +**Residual risk:** Replay within the same room within a short time window +(< 4 hours, before the field model rotates) by an attacker who has recorded the +original CSI with high fidelity remains a plausible attack vector. This is not +defended against by the current architecture. It requires a future ADR for +challenge-response liveness detection. + +### 3.3 Attacker: Phased-Array Vest / RF Body Emulator + +**Capability:** An attacker wears a device capable of emitting RF signals that +mimic another person's backscatter profile, allowing them to be recognized as +the enrolled person. + +**Impact:** The strongest impersonation attack; if successful, bypasses all +electromagnetic biometric channels simultaneously. + +**Mitigation:** +- The RuvSense `adversarial.rs` module (ADR-030 Tier 7) enforces four + physics-based consistency checks: + 1. Multi-link consistency: a real body perturbs all mesh links passing + through its location. A vest emitting signals affects only the targeted + link(s). Detection: at least 4 links must show correlated perturbation. + 2. Field model constraints: the perturbation must lie within the span of + the room's eigenmode structure. Artificially injected signals produce + perturbations inconsistent with room geometry. + 3. Temporal continuity: real movement is smooth in embedding space; injected + signals can produce discontinuities flagged by the embedding velocity + monitor. + 4. Energy conservation: total perturbation energy across all links must be + consistent with the number and geometry of bodies present. +- The adversarial detector fires `FAIL_ADVERSARIAL_SIGNAL` before the soul + signature match is considered. + +**Residual risk:** A sophisticated attacker with a calibrated phased-array +system who also knows the room's eigenmode structure and the enrolled person's +exact multi-link backscatter pattern could in principle construct a convincing +emulation. This is a high-capability, high-cost attack. Practical countermeasure: +require multi-node confirmation (ADR-029 multistatic) which raises the +geometric complexity of the emulation exponentially with node count. + +### 3.4 Attacker: Insider with Broker Access + +**Capability:** A privileged operator or compromised service with read access +to the stored `.rvf` files and the HNSW person_track index. + +**Impact:** Exfiltration of biometric signatures; linkage of person_id to PII +if linkage tables also accessible; replay or cross-site re-enrollment. + +**Mitigation:** +- At-rest encryption: all `.rvf` files are encrypted with ChaCha20-Poly1305 + using a key derived via Argon2id from a user-provided passphrase (or a FIDO2 + hardware token binding). The Cognitum Seed appliance NEVER stores the + decryption key; it is re-derived from the passphrase on each access. +- The opaque `person_id` (u64) in the `.rvf` file is not PII. PII linkage, if + any, requires access to a separate application-layer database not stored on + the sensing appliance. +- The HNSW index stores only the 128-dim AETHER embedding, not raw CSI or full + soul signatures. Exfiltration of the index exposes the embedding but not the + full biometric record. +- Differential privacy (ADR-106 DP-SGD) applies at training time when AETHER + is fine-tuned on enrolled-person data, preventing membership inference attacks + that could recover training samples from model weights. + +**Residual risk:** If the passphrase is weak or the FIDO2 token is compromised, +the at-rest encryption fails. Key management is a deployment responsibility. + +### 3.5 Attacker: Manufacturer / Firmware Supply Chain + +**Capability:** A malicious firmware update to the ESP32 node or Cognitum Seed +appliance could silently exfiltrate soul signatures or CSI streams. + +**Impact:** Large-scale passive surveillance; biometric data exfiltration across +all installed appliances. + +**Mitigation:** +- All firmware releases are signed with Ed25519 (ADR-100 cog packaging) and + verified by the appliance before installation. A Dilithium-3 post-quantum + co-signature is added in the transition window (ADR-109). +- The Ed25519 witness chain (ADR-110) signs each CSI frame bundle at the + sensor level. A firmware change that alters the witness chain is detectable + by downstream audit. +- Network egress from the Cognitum Seed in `--privacy-mode` is blocked for + raw CSI and soul signatures by default. Only MQTT auto-discovery messages + (ADR-115) and OTA metadata are permitted outbound. +- Open-source firmware. The ESP32 firmware and Cognitum Seed Rust crates are + open source (this repository). Independent audit is possible. + +**Residual risk:** A zero-day exploit in the ESP-IDF WiFi stack or the Rust +codebase could bypass these controls. This is mitigated by regular security +audits (run `npx @claude-flow/cli@latest security scan` per CLAUDE.md) but not +eliminated. + +--- + +## 4. Consent Architecture + +### 4.1 The Enrollment-vs-Recognition Distinction + +The soul signature system enforces a hard distinction: + +| Action | Consent required | Mechanism | +|---|---|---| +| Enrollment | Explicit, active | 60-second protocol with operator confirmation; produces signed `.rvf` | +| Recognition of enrolled person | Implicit (enrollment = consent for recognition) | Continuous mode; HNSW match | +| Ambient sensing of unenrolled person | No — but data is transient and pruned | Unauthenticated tracks; 24h TTL | +| Updating stored profile from continuous mode | Implicit (set at enrollment time) | Aggregator auto-refresh; configurable | + +The system operator is responsible for obtaining appropriate consent from +persons before performing enrollment. The technical system enforces that +enrollment cannot happen accidentally or from drive-by sensing. + +### 4.2 Bystander Protection + +Persons who pass through a sensing zone without being enrolled are sensed but +not persistently identified. Their data flow: +1. Pose tracker produces a track tagged `person_id: NULL`. +2. AETHER embedding is computed for motion detection and occupancy counting + (ADR-115 HA-MIND). +3. The embedding is written to the `temporal_baseline` HNSW index with a 24-hour + TTL and `authenticated: false`. +4. After 24 hours, the entry is automatically pruned by the `EmbeddingIndex::prune()` + method (ADR-024 §2.4). +5. No `.rvf` file is created. No persistent record exists. + +This architecture satisfies the GDPR principle of data minimization (Article 5(1)(c)) +for bystander data: the retention period is bounded, the data is not linked to +an identity, and the storage is proportionate to the functional purpose +(occupancy counting). + +### 4.3 GDPR / HIPAA Mode + +When `--privacy-mode enabled` (from ADR-115 HA-MIND §privacy): + +1. Soul signatures are computed and stored locally only. They are NEVER + published to MQTT topics, Matter clusters, or any external endpoint. +2. The local REST API for accessing soul signatures requires a valid bearer + token (ADR-028 bearer_auth.rs). No unauthenticated endpoint exposes + biometric data. +3. The JSON-LD sidecar is written to the local encrypted store only. It is not + included in MQTT auto-discovery payloads. +4. The longitudinal drift metrics (ADR-030 Tier 4) are published to MQTT in + aggregated form only (e.g., `drift_detected: true`, never raw metric values + that could be used for medical inference). +5. A data deletion endpoint must be implemented: `DELETE /api/v1/persons/{id}` + removes the `.rvf` file, the HNSW index entry, the JSON-LD sidecar, and all + longitudinal Welford statistics for that person_id. + +--- + +## 5. Cryptographic Primitives + +All primitives are chosen from NIST-approved or widely-audited standards. + +| Purpose | Primitive | Rationale | +|---|---|---| +| Content integrity (per-segment) | CRC32 (IEEE 802.3) | Already implemented in `rvf_container.rs:line 70`. Corruption detection, not security. | +| Content addressing | SHA-256 | File name derivation; pre-image resistance prevents name collisions | +| Ed25519 signatures | Ed25519 (RFC 8032) | ADR-110 witness chain; 64-byte signatures; 128-bit security | +| At-rest encryption | ChaCha20-Poly1305 (RFC 8439) | AEAD; software-friendly; no timing-attack surface like AES-CBC; 256-bit key | +| Key derivation from passphrase | Argon2id (RFC 9106) | Memory-hard KDF; resistant to GPU/ASIC brute-force; recommended by NIST SP 800-132 draft (2024) | +| DP-SGD noise | Gaussian N(0, σ²C²I) per ADR-106 | (ε, δ)-DP per Abadi et al. 2016 Moments Accountant | +| Post-quantum key exchange (future) | Kyber-768 (NIST FIPS 203, 2024) | ADR-108; ~AES-192 security; NIST CNSA 2.0 recommended | +| Post-quantum signatures (future) | Dilithium-3 (NIST FIPS 204, 2024) | ADR-109; hybrid mode with Ed25519 during transition window | + +### 5.1 Argon2id Parameters + +Default parameters for soul signature key derivation: + +``` +m_cost = 65536 (64 MB memory) +t_cost = 3 (3 iterations) +p_cost = 4 (4 parallel lanes) +output_len = 32 bytes (256-bit key for ChaCha20-Poly1305) +salt = 16 random bytes stored alongside encrypted blob (NOT the person_id) +``` + +These parameters provide ~100ms KDF time on a Pi 5, which is acceptable for +enrollment (one-time) and recognition (HNSW match precedes decryption, so +decryption is only triggered after a candidate match). + +### 5.2 Forward Secrecy + +Old soul signature files are NOT keys for new ones. Compromise of a 90-day-old +`.rvf` file does not unlock the current profile. The key is derived from the +user's passphrase each time, not derived from the previous file. + +Archived files (kept for audit purposes) are re-encrypted on passphrase rotation +if the operator elects to do so via the `soul-signature re-encrypt --all` CLI +command (not yet implemented; specified here for future ADR). + +--- + +## 6. Privacy Mode Integration (ADR-115) + +The `--privacy-mode` flag defined in ADR-115 HA-MIND §9 is extended to cover +soul signature data: + +| Privacy mode | MQTT publish | REST API | Local storage | HNSW index | +|---|---|---|---|---| +| `disabled` (default for home users) | Aggregated presence/count only | Authenticated bearer required | Encrypted at rest | Local only | +| `enabled` | Nothing biometric | Authenticated bearer required | Encrypted at rest | Local only | +| `research` (explicit opt-in) | Full soul signature nodes (anonymized person_id) | Open (for research deployments only) | Encrypted at rest | Exportable | + +The `research` mode requires a separate `--research-consent-token` flag and is +intended for academic data collection under IRB approval. It must never be the +default. + +--- + +## 7. Open Research and Outstanding Security Work + +The following items are known security gaps or open research questions. Each +warrants a future ADR before production deployment at scale. + +**7.1 Challenge-Response Liveness Detection** +Replay attacks within a short time window (see §3.2 residual risk) are not +defended against. A future mechanism should issue a random challenge (e.g., +"please raise your left hand") and verify the CSI response matches the challenge +before accepting a recognition. This eliminates replay as a practical attack +vector. Future ADR: ADR-120 (proposed). + +**7.2 False-Accept Rate at Scale (N > 20 subjects)** +The AETHER baseline (ADR-024) is tested at 5 subjects (>80% mAP). For household +deployments this is sufficient. For building-scale deployments (50-500 subjects), +the FAR is open research. Independent benchmarking on a dataset of 20+ subjects +with the full 7-channel fusion is required before building-scale deployment can +be recommended. Publication target: co-locate with ADR-027 MERIDIAN evaluation. + +**7.3 Side-Channel Leakage from Encrypted RVF Files** +The file size of an encrypted `.rvf` blob is observable by an attacker with +filesystem access. File size is a function of the number of nodes present, which +reveals whether the cardiac channel was captured (high-SNR enrollment vs +low-SNR enrollment). This is a minor information leak. Mitigation: pad all +`.rvf` files to a fixed 64 KB boundary. Future ADR: append to ADR-106. + +**7.4 Membership Inference in Continuous Mode** +In continuous mode, the AETHER model is fine-tuned on the enrolled person's +data over months. An adversary with access to the model weights before and after +a re-train cycle could infer that a specific enrollment occurred, even without +the soul signature file, via membership inference (Shokri et al. 2017). +ADR-106 DP-SGD mitigates this for federation round deltas but not for local +single-device fine-tuning. Extension of DP-SGD to the local continuous-mode +update is required. Future ADR: extend ADR-106. + +**7.5 Physical Access to Sensing Nodes** +An attacker with physical access to an ESP32 node can extract the firmware and +attempt to reverse the Ed25519 signing key (if the key is stored in ESP32 +NVS without protection). ADR-110 uses NVS for key storage. A future ADR should +mandate secure element storage (e.g., ATECC608A co-processor on the Cognitum +Seed) for the signing key. Future ADR: ADR-121 (proposed). + +**7.6 Federated Learning Linkability** +When AETHER is retrained via federated learning (ADR-105), the LoRA weight +deltas carry information about enrolled persons. ADR-106 applies DP-SGD to +these deltas, but the post-quantum migration path (ADR-108 Kyber-768) is not +yet integrated with the federation protocol. Until ADR-108 Phase 2 ships, the +federation link is classically encrypted and vulnerable to harvest-now-decrypt-later +attacks by quantum-capable adversaries. Assessed risk: low until 2027. + +--- + +## 8. Summary Security Properties Table + +| Property | Status | Evidence | +|---|---|---| +| At-rest encryption | Specified (ChaCha20-Poly1305 + Argon2id) | This document §5 | +| Ed25519 attestation | Implemented | ADR-110 witness chain | +| Replay resistance (cross-room) | Implemented | ADR-030 field model environment_id binding | +| Replay resistance (same-room, short window) | Open gap | §7.1 | +| Anti-spoofing (single-link injection) | Implemented | adversarial.rs multi-link consistency | +| Anti-spoofing (phased-array vest) | Partial | adversarial.rs + energy conservation; residual risk documented | +| Bystander protection | Specified | 24h TTL on unauthenticated tracks; §4.2 | +| DP-SGD training privacy | Implemented (federation) | ADR-106 | +| DP-SGD training privacy (local continuous mode) | Open gap | §7.4 | +| GDPR data deletion | Specified | §4.3 `DELETE /api/v1/persons/{id}` | +| Post-quantum migration path | Specified (Kyber-768, Dilithium-3) | ADR-108, ADR-109 | +| Firmware supply chain integrity | Implemented (Ed25519 cog signing) | ADR-100, ADR-109 hybrid | +| False-accept rate at scale | Open research | §7.2 | +| Liveness detection | Open gap | §7.1 | +| Secure element key storage | Open gap | §7.5 | diff --git a/docs/research/soul/specification.md b/docs/research/soul/specification.md new file mode 100644 index 00000000..e452f6e3 --- /dev/null +++ b/docs/research/soul/specification.md @@ -0,0 +1,525 @@ +# Soul Signature — Technical Specification + +**Status:** Research Specification (Pre-Implementation) +**Date:** 2026-05-24 +**Author:** ruv + +--- + +## 1. Overview + +A Soul Signature is a typed, content-addressed RVF graph encoding seven +electromagnetic observables extracted from a person in a WiFi-DensePose sensing +zone. The graph is stored as a single `.rvf` binary blob using the existing RVF +container format (`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs`) +extended with two new segment types defined below. A human-readable JSON sidecar +accompanies the blob for inspection and provenance. + +The signature is probabilistic, not deterministic. Matching computes a weighted +cosine similarity across graph dimensions, producing a score in [0, 1] with a +calibrated false-accept rate (FAR). The FAR at a given threshold is an open +research question; the AETHER person re-identification baseline (ADR-024 §2.8: +>80% mAP at 5 subjects) is the lower bound for the primary embedding channel. + +--- + +## 2. Design Principles + +### 2.1 Per-Individual + +The signature encodes features that are structurally unique to one person at the +sensing resolution of commodity WiFi hardware. Discriminative dimensions include: +cardiac timing (R-R interval structure), respiratory mechanics (tidal depth, +inspiration-to-expiration ratio), skeletal proportions (limb ratios from 17-keypoint +pose, ADR-079), gait cadence variability, and the RF backscatter profile shaped by +body mass distribution and geometry. + +### 2.2 Passive at Enrollment Time + +No explicit action from the subject is required at recognition time after +enrollment. Recognition fires whenever an enrolled person is detected in a sensing +zone. Enrollment itself requires a 60-second structured protocol (see +`scanning-process.md`). This is a deliberate asymmetry: passive recognition + +active enrollment — which is the same model used by FaceID (passive unlock after +initial face setup). + +The passivity of post-enrollment recognition is a privacy concern addressed in full +in `security.md` §4. + +### 2.3 Multi-Modal + +Seven orthogonal channels contribute. Orthogonality matters: if one channel +degrades (e.g., cardiac is masked by motion), the remaining six carry the match. +No single channel is necessary for a positive identification above threshold; +the fused score is a weighted aggregate. + +### 2.4 Persistent Across Time + +The stored signature is valid over weeks to months for adults with stable anatomy +and health. Re-scan cadence is prescribed in `scanning-process.md`. The +`longitudinal.rs` module (ADR-030 Tier 4) provides the drift detection that +flags when a re-scan is necessary. + +### 2.5 Defensible False-Accept Rate + +The security model is not "unbreakable." It is "attacker cost exceeds value of +attack for the threat model in §security." See `security.md` §3. + +--- + +## 3. Signature as a Typed RVF Graph + +### 3.1 Container Format + +The soul signature reuses the RVF binary container defined in +`v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs` (lines 1–660). +Existing segment types used: + +| Segment type | Const | Purpose in soul signature | +|---|---|---| +| `SEG_MANIFEST` | `0x05` | Graph metadata: schema version, enroll timestamp, device ID, person_id (opaque u64) | +| `SEG_VEC` | `0x01` | AETHER 128-dim embedding weights (backbone + projection head) | +| `SEG_META` | `0x07` | JSON overlay: all non-vector node attributes | +| `SEG_WITNESS` | `0x0A` | Ed25519 signature over `(content_hash_sha256 || timestamp_ns || enrolled_by_device_id)` | +| `SEG_EMBED` | `0x0C` | AETHER embedding config + projection head weights (ADR-024 Phase 7) | +| `SEG_LORA` | `0x0D` | Per-environment LoRA deltas for environment-adapted query | + +Two new segment types are proposed for the soul signature extension: + +| Segment type | Const | Purpose | +|---|---|---| +| `SEG_SOUL_GRAPH` | `0x10` | JSON-serialized graph: node list + edge list + attribute schemas | +| `SEG_SOUL_INDEX` | `0x11` | Per-node HNSW index serialization for fast graph-level query | + +The `SegmentHeader` structure is unchanged. Each segment is 64-byte aligned +(field `alignment_pad` at offset `0x3C`). CRC32 content hash at offset `0x28` +covers the payload, providing tamper detection per the existing implementation +at `rvf_container.rs:line 70`. + +### 3.2 Node Types + +Each node is a typed struct. Serialized into SEG_META as a JSON object with a +`node_type` discriminator string. Vector fields (f32 arrays) are co-located in +a SEG_VEC segment indexed by the node's `vec_segment_id` field. + +#### Node: AETHER_Embedding + +Primary identity anchor. The contrastive CSI embedding from ADR-024. + +```rust +pub struct AetherEmbeddingNode { + pub node_type: &'static str, // "AETHER_Embedding" + pub vec_segment_id: u64, // references SEG_VEC containing 128 f32s + pub embedding_dim: usize, // 128 + pub backbone: String, // "csi-to-pose-transformer" + pub pretrain_method: String, // "simclr+vicreg" + pub alignment_score: f32, // Lowman alignment metric at enrollment time + pub uniformity_score: f32, // Hypersphere uniformity at enrollment time + pub enrollment_frames: u32, // Number of CSI windows averaged into this node + pub environment_id: String, // SHA-256 of field model eigenstate at enrollment + pub confidence: f32, // HNSW search confidence against person_track index +} +``` + +Stored size: 128 × 4 = 512 bytes in SEG_VEC; JSON metadata ~200 bytes in SEG_META. +Per ADR-024 §2.8, the person re-identification target is >80% mAP at 5 subjects. +At 10+ subjects the accuracy is open research; baseline TBD. + +#### Node: Cardiac_HR_Profile + +Extracted from the ADR-039 vitals pipeline (magic `0xC511_0002`, fields offset 6-11: +breathing_rate at `u16 LE` BPM×100, heart_rate at `u32 LE` BPM×10000). +For the soul signature, cardiac extraction uses the ADR-021 bandpass pipeline +(0.8–2.0 Hz) over a minimum 30-second rest window. + +```rust +pub struct CardiacHRProfileNode { + pub node_type: &'static str, // "Cardiac_HR_Profile" + pub baseline_bpm: f32, // mean HR over enrollment window (40–180 BPM range) + pub hrv_sdnn_ms: f32, // SDNN: std dev of R-R intervals (ms) + pub hrv_rmssd_ms: f32, // RMSSD: root mean square successive differences + pub hrv_lf_power: f32, // LF band power (0.04–0.15 Hz), normalized + pub hrv_hf_power: f32, // HF band power (0.15–0.4 Hz), normalized + pub hrv_lf_hf_ratio: f32, // LF/HF ratio (autonomic balance marker) + pub sinus_rhythm_class: u8, // 0=regular, 1=irregular, 2=indeterminate + pub confidence: f32, // from ADR-021 VitalCoherenceGate PERMIT fraction + pub window_seconds: u32, // duration of the measurement window +} +``` + +WiFi CSI-based HRV extraction is an active research area. The SDNN and RMSSD values +are discriminative at group level (Zhao et al. 2017, Widar 3.0 2019) but per-person +uniqueness has not been independently validated at scale. Status: open research. + +#### Node: Cardiac_Waveform_Morphology + +Wavelet decomposition of the bandpass-filtered cardiac phase signal. Captures the +shape of the cardiac waveform, not just its rate. More discriminative than HR alone +but requires higher SNR and longer measurement window. + +```rust +pub struct CardiacWaveformMorphologyNode { + pub node_type: &'static str, // "Cardiac_Waveform_Morphology" + pub vec_segment_id: u64, // references SEG_VEC: 64 f32 wavelet coefficients + pub wavelet_family: String, // "db4" (Daubechies 4, standard for cardiac) + pub decomposition_levels: u8, // 4 levels + pub snr_db: f32, // measured SNR at enrollment; low-SNR nodes down-weighted + pub confidence: f32, +} +``` + +Wavelet coefficient dimension: 64 floats = 256 bytes in SEG_VEC. Waveform +morphology from CSI is highly environment-dependent; the ADR-030 field model +subtraction must run before this measurement is taken to isolate body perturbation +from room standing-wave artifacts. + +#### Node: Respiratory_Pattern + +Extracted by the ADR-021 BreathingExtractor (0.1–0.5 Hz bandpass) plus the +ADR-030 persistence layer that accumulates statistics over the enrollment window. + +```rust +pub struct RespiratoryPatternNode { + pub node_type: &'static str, // "Respiratory_Pattern" + pub baseline_bpm: f32, // mean RR (normal adult: 12–20 BPM) + pub depth_amplitude_normalized: f32, // tidal depth proxy from CSI variance + pub inspiration_expiration_ratio: f32, // I:E ratio (1:1.5 to 1:3 typical) + pub hrv_rsa_power: f32, // respiratory sinus arrhythmia spectral power + pub apnea_index: f32, // events per hour of significant pauses + pub waveform_regularity: f32, // coefficient of variation of breath intervals + pub confidence: f32, + pub window_seconds: u32, +} +``` + +Note: the `apnea_index` field is a biophysical proxy signal (pause events in +the signal), not a clinical AHI score. It is provided for signature +discriminability, not diagnostic use. + +#### Node: Gait_Timing + +Extracted from the 17-keypoint Kalman pose tracker (`pose_tracker.rs`, ADR-029 +Sect 2.7) during the gait phase of the enrollment protocol. The tracker uses +ruvector-mincut for person separation and AETHER re-ID for identity continuity. + +```rust +pub struct GaitTimingNode { + pub node_type: &'static str, // "Gait_Timing" + pub cadence_steps_per_min: f32, // steps per minute + pub stride_period_variance: f32, // coefficient of variation of stride period + pub double_support_pct: f32, // fraction of gait cycle in double support + pub asymmetry_index: f32, // |left_stride - right_stride| / mean_stride + pub step_width_m: f32, // lateral distance between foot strikes (proxy) + pub velocity_variance: f32, // gait speed variability + pub confidence: f32, + pub stride_count: u32, // number of strides captured during enrollment +} +``` + +Gait biometrics from WiFi CSI are documented in WiGait (Adib et al., SIGCOMM +2015) and WiDraw (Wang et al., MobiCom 2014). Discrimination across 10+ subjects +in the same household is an open research question for the WiFi-only modality. + +#### Node: Skeletal_Proportions + +Derived from the ADR-079 camera + CSI paired keypoint pipeline when available, +or from CSI-only pose estimation (ADR-023 CsiToPoseTransformer) in camera-free +deployments. Encodes body geometry as ratios (not absolute values) for scale +invariance. + +```rust +pub struct SkeletalProportionsNode { + pub node_type: &'static str, // "Skeletal_Proportions" + pub torso_to_leg_ratio: f32, // torso height / leg length + pub shoulder_to_hip_ratio: f32, // shoulder width / hip width + pub upper_to_lower_arm_ratio: f32, // upper arm / forearm + pub upper_to_lower_leg_ratio: f32, // thigh / shin + pub head_to_torso_ratio: f32, // head height / torso height + pub arm_span_to_height_ratio: f32, // Vitruvian ratio (close to 1.0 for most adults) + pub confidence: f32, + pub keypoint_source: String, // "camera_paired" | "csi_only" | "fused" +} +``` + +CSI-only skeletal proportion estimation has ~15–25% error on individual ratio +values (open research; baseline from ADR-023 MPJPE ~91.7 mm at best, per +Person-in-WiFi 3D, CVPR 2024). Camera-paired values (ADR-079) are substantially +more accurate. The node degrades gracefully when only CSI is available. + +#### Node: Subcarrier_Reflection_Profile + +The per-subcarrier amplitude attenuation and phase shift profile measured when +the subject stands still at three orientations (0°, 90°, 180° rotation). This +encodes the body's RF backscatter cross-section shape, which is determined by +body mass distribution, limb geometry, and clothing/material factors. + +```rust +pub struct SubcarrierReflectionProfileNode { + pub node_type: &'static str, // "Subcarrier_Reflection_Profile" + pub vec_segment_id: u64, // SEG_VEC: 56 × 3 × 2 = 336 f32s + // (56 subcarriers × 3 orientations × + // [amplitude_attenuation, phase_shift]) + pub n_subcarriers: u8, // 56 (HT-LTF) or up to 242 (HE-LTF, ADR-110 C6) + pub n_orientations: u8, // 3 + pub frequency_mhz: u32, // center frequency at measurement time + pub environment_id: String, // references field model used for subtraction + pub confidence: f32, +} +``` + +This node directly exploits the ADR-030 field model: the empty-room baseline +eigenstate is subtracted before computing the reflection profile, isolating the +person's contribution. Without ADR-030 field subtraction, the profile is too +environment-coupled to be transferable across rooms. With MERIDIAN (ADR-027), +the hardware-normalizer layer maps ESP32-S3 (52 subcarriers HT-LTF) and +ESP32-C6 (242 subcarriers HE-LTF per ADR-110) into a canonical 56-subcarrier +representation before this measurement. + +Stored: 336 × 4 = 1,344 bytes in SEG_VEC. + +#### Node: Body_Field_Coupling + +The AETHER attention map cells weighted by the ADR-030 room eigenmode structure. +Encodes how strongly the person's body couples to each dominant electromagnetic +mode of the room. This is the most physics-grounded node: it captures the +person's interaction with the actual electromagnetic geometry of the space. + +```rust +pub struct BodyFieldCouplingNode { + pub node_type: &'static str, // "Body_Field_Coupling" + pub vec_segment_id: u64, // SEG_VEC: n_eigenmodes × n_keypoints f32s + pub n_eigenmodes: u8, // top-K SVD modes from field_model.rs (default K=8) + pub n_keypoints: u8, // 17 (COCO) + pub eigenmode_energy_fractions: Vec, // fraction of total variance per mode + pub environment_id: String, // must match SubcarrierReflectionProfile env + pub confidence: f32, +} +``` + +This node is only valid when the same room's field model is available. For +cross-room recognition, MERIDIAN's environment-disentangled embedding (ADR-027) +is used instead. The BodyFieldCoupling node provides additional discriminative +power in single-room deployments and degrades to optional in multi-room contexts. + +--- + +### 3.3 Edge Types + +Edges are stored in the SEG_SOUL_GRAPH JSON array. Each edge has a typed +relationship that constrains how the nodes may be used in matching. + +| Edge type | Source node(s) | Target node(s) | Semantics | +|---|---|---|---| +| `derived_from` | FieldModel_Residual (implicit) | AetherEmbedding | The embedding was computed after field model subtraction | +| `correlates_with` | Cardiac_HR_Profile | Respiratory_Pattern | Cardiorespiratory coupling at measurement time; correlation coefficient stored as edge weight | +| `temporally_colocated` | Any pair | Any pair | Both nodes were measured in the same time window; ensures consistency | +| `temporally_after` | Post-gait node | Pre-gait node | Nodes acquired sequentially during enrollment protocol | +| `requires_field_model` | SubcarrierReflectionProfile | BodyFieldCoupling | Matching this node requires the same room's ADR-030 field model | +| `fuses` | AetherEmbedding | SubcarrierReflectionProfile | MERIDIAN-normalized fusion: both mapped to environment-invariant space | +| `attested_by` | Any leaf node | WitnessChain | Ed25519 witness covers this node's content hash | +| `derived_by_keypoint_tracker` | GaitTiming | SkeletalProportions | Both extracted from the same pose_tracker.rs output | +| `environment_normalized` | Any node with `environment_id` | MERIDIAN manifest | MERIDIAN (ADR-027) was applied; signature is cross-room capable | + +--- + +### 3.4 The Aggregator vs. the Stored Profile + +Two distinct graph instances exist in the runtime: + +**Online Aggregator** — a mutable, in-memory graph that accumulates measurements +across multiple sensing windows. Nodes are incrementally updated with Welford +online statistics (`field_model.rs::WelfordStats`). Confidence fields grow toward +1.0 as more frames accumulate. The aggregator never writes to disk during +normal operation. + +**Stored Profile** — an immutable, content-addressed `.rvf` file on disk. It is +generated from the aggregator at the end of the enrollment protocol, when all node +confidence fields exceed their minimum thresholds. The stored profile is the +canonical soul signature. + +``` +Online Aggregator (RAM) Stored Profile (disk / secure enclave) ++----------------------+ +---------------------------+ +| AETHER_Embedding | enrollment | signature-.rvf | +| accumulated over | completion | SEG_MANIFEST | +| 60-second protocol +-------------> | SEG_VEC (embedding + refl)| +| Confidence: 0.0→1.0 | when all | SEG_META (all node attrs) | +| | gates pass | SEG_EMBED (AETHER config) | +| Cardiac_HR_Profile | | SEG_WITNESS (Ed25519) | +| accumulated 30s rest | | SEG_SOUL_GRAPH (graph) | ++----------------------+ +---------------------------+ +``` + +The aggregator pattern ensures that a partial scan (e.g., subject leaves after +20 seconds) never produces a stored profile — the quality gates prevent premature +commitment (see `scanning-process.md §5`). + +--- + +### 3.5 Serialization + +**Binary container:** RVF blob, per `rvf_container.rs`. All numeric data is +little-endian, f32 IEEE 754. Segment alignment: 64 bytes. CRC32 (IEEE 802.3 +polynomial) over each segment payload. + +**Content addressing:** The file name is: +``` +signature-.rvf +``` +SHA-256 is computed over the complete concatenated RVF byte stream after +`RvfBuilder::build()`. This is a different hash from the per-segment CRC32; +the CRC32 provides corruption detection within segments, the SHA-256 provides +content-based addressing and enables deduplication. + +**JSON-LD sidecar:** An optional `signature-.json` file with the same +base name. Structure: + +```json +{ + "@context": "https://ruv.net/soul-signature/v1", + "schema_version": "0.1.0", + "person_id": "", + "enrolled_at": "2026-05-24T00:00:00Z", + "enrolled_by_device_id": "", + "rvf_sha256": "", + "nodes": [ + { "node_type": "AETHER_Embedding", "confidence": 0.92, ... }, + { "node_type": "Cardiac_HR_Profile", "confidence": 0.85, ... }, + ... + ], + "edges": [...], + "witness": { + "algorithm": "Ed25519", + "public_key": "", + "signature": "", + "signed_fields": ["rvf_sha256", "enrolled_at", "enrolled_by_device_id"] + } +} +``` + +The JSON-LD sidecar is human-readable and intended for audit and provenance. +It does not contain raw biometric vectors; those stay in the RVF blob. + +**ISO/IEC 19794-4 alignment:** The soul signature's graph-based vector template +is conceptually analogous to the ISO/IEC 19794-4 finger image data format +and ISO/IEC 19794-2 minutiae data. The node/edge schema is not binary-compatible +with ISO 19794, but the design intent (typed attribute records, quality scores, +creator provenance) follows the same standard's principles. Future work may +include a conformance layer if regulatory certification is sought. + +--- + +### 3.6 Matching Algorithm + +Given a stored profile `P` and a query embedding `Q` derived from a live sensing +window, the match score is computed as a weighted sum of per-channel cosine +similarities: + +``` +match_score = sum_i ( w_i * cosine_sim(P.channel_i, Q.channel_i) ) + / sum_i ( w_i * availability(P.channel_i, Q.channel_i) ) +``` + +Where `availability` is 1.0 if both nodes are present and 0.0 if either is absent +(graceful degradation when a channel cannot be measured in the query window). + +Default weights (open research; these are design intent, not validated): + +| Channel | Weight | Rationale | +|---|---|---| +| AETHER_Embedding | 0.35 | Primary identity anchor; best-studied channel | +| Subcarrier_Reflection_Profile | 0.20 | Body geometry; angle-stable | +| Cardiac_HR_Profile | 0.15 | Physiologically stable in healthy adults | +| Gait_Timing | 0.15 | Well-studied biometric; discriminative | +| Respiratory_Pattern | 0.10 | More variable than cardiac | +| Skeletal_Proportions | 0.05 | Proxy for body shape; CSI-only is noisy | +| Body_Field_Coupling | 0.00 (single-room) / 0.10 (cross-room disabled) | Valid only when room field model available | +| Cardiac_Waveform_Morphology | 0.05 (supplementary) | High SNR requirement | + +The threshold for a positive match is a deployment-specific parameter with a +documented FAR/FRR trade-off. The AETHER channel alone achieves >80% mAP at 5 +subjects (ADR-024 §2.8 target). The fused multi-channel score is expected to +exceed this; the exact improvement is open research, baseline TBD. + +--- + +### 3.7 Rust Type Sketch + +The following sketch shows how the soul signature types would integrate with +the existing codebase. This is a design sketch, not implemented code. + +```rust +// In a future: v2/crates/wifi-densepose-sensing-server/src/soul_signature.rs + +pub const SEG_SOUL_GRAPH: u8 = 0x10; +pub const SEG_SOUL_INDEX: u8 = 0x11; + +/// Complete soul signature as a graph container. +pub struct SoulSignature { + /// Content-addressed identifier: SHA-256 of the RVF blob bytes. + pub content_hash: [u8; 32], + /// Opaque person identifier (never PII directly). + pub person_id: u64, + /// Unix timestamp of enrollment completion (nanoseconds). + pub enrolled_at_ns: u64, + /// Device that performed enrollment. + pub enrolled_by_device_id: String, + /// All graph nodes, typed. + pub nodes: SoulNodes, + /// All graph edges. + pub edges: Vec, + /// Ed25519 witness chain (per ADR-110). + pub witness: WitnessChain, +} + +pub struct SoulNodes { + pub aether_embedding: Option, + pub cardiac_hr: Option, + pub cardiac_waveform: Option, + pub respiratory: Option, + pub gait_timing: Option, + pub skeletal_proportions: Option, + pub subcarrier_reflection: Option, + pub body_field_coupling: Option, +} + +pub struct SoulEdge { + pub edge_type: SoulEdgeType, + pub source_node_type: String, + pub target_node_type: String, + pub weight: f32, // edge attribute (e.g., correlation coefficient) +} + +pub enum SoulEdgeType { + DerivedFrom, + CorrelatesWith, + TemporallyColocated, + TemporallyAfter, + RequiresFieldModel, + Fuses, + AttestedBy, + DerivedByKeypointTracker, + EnvironmentNormalized, +} + +impl SoulSignature { + /// Serialize to an RVF binary blob. + pub fn to_rvf(&self) -> Vec; + /// Deserialize from an RVF binary blob. + pub fn from_rvf(data: &[u8]) -> Result; + /// Compute the weighted match score against a query. + pub fn match_score(&self, query: &SoulQuery, weights: &MatchWeights) -> f32; + /// Check whether all required nodes meet minimum confidence thresholds. + pub fn is_complete(&self, policy: &CompletenessPolicy) -> bool; +} +``` + +--- + +### 3.8 What the Signature Is NOT + +- Not a fingerprint of the room (that is the ADR-030 field model, a separate object). +- Not a waveform recording (the enrolled vectors are statistics and embeddings, not raw CSI). +- Not invertible to the original CSI stream (the AETHER projection head's information bottleneck prevents reconstruction; see ADR-024 §4 Negative consequences). +- Not a single scalar. Reducing to one number for threshold comparison is a deployment decision; the underlying object is a 7-channel graph. +- Not equal to a stored pose. The AETHER embedding captures body dynamics over many windows, not a single body pose at one instant. diff --git a/docs/user-guide.md b/docs/user-guide.md index 87317c81..8f5507be 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -164,19 +164,34 @@ cargo add wifi-densepose-wasm-edge See the full crate list and dependency order in [CLAUDE.md](../CLAUDE.md#crate-publishing-order). -### From Source (Python) +### Python wheel (pip) — ADR-117 + +The `wifi-densepose` PyPI wheel is a PyO3 binding to the Rust core. It +ships compiled DSP (~250 KB, Linux/macOS/Windows × abi3-py310) plus an +opt-in pure-Python WebSocket/MQTT client for talking to a live RuView +sensing-server. + +```bash +pip install wifi-densepose # core DSP only +pip install "wifi-densepose[client]" # + websockets + paho-mqtt +``` + +```python +from wifi_densepose import BreathingExtractor, HeartRateExtractor +from wifi_densepose.client import SensingClient, RuViewMqttClient +``` + +The legacy `wifi-densepose==1.1.0` FastAPI server is end-of-life; +`wifi-densepose==1.99.0` is a tombstone that raises `ImportError` +with a migration URL. + +To build the wheel from source (e.g. for a local change): ```bash git clone https://github.com/ruvnet/RuView.git -cd RuView - -pip install -r requirements.txt -pip install -e . - -# Or via PyPI -pip install wifi-densepose -pip install wifi-densepose[gpu] # GPU acceleration -pip install wifi-densepose[all] # All optional deps +cd RuView/python +pip install maturin>=1.7 +maturin develop --release ``` ### Guided Installer diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 00000000..ad24f1d9 --- /dev/null +++ b/python/.gitignore @@ -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/ diff --git a/python/Cargo.lock b/python/Cargo.lock new file mode 100644 index 00000000..2337355a --- /dev/null +++ b/python/Cargo.lock @@ -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" diff --git a/python/Cargo.toml b/python/Cargo.toml new file mode 100644 index 00000000..be4542c6 --- /dev/null +++ b/python/Cargo.toml @@ -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 ", "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. diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000..4d085cb2 --- /dev/null +++ b/python/README.md @@ -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 (6–30 BPM) and heart rate (40–120 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.8–2.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. diff --git a/python/bench/test_bench_bfld_and_ws.py b/python/bench/test_bench_bfld_and_ws.py new file mode 100644 index 00000000..5aaec5e5 --- /dev/null +++ b/python/bench/test_bench_bfld_and_ws.py @@ -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. 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) diff --git a/python/bench/test_bench_vitals.py b/python/bench/test_bench_vitals.py new file mode 100644 index 00000000..06bccb62 --- /dev/null +++ b/python/bench/test_bench_vitals.py @@ -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) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..92089b58 --- /dev/null +++ b/python/pyproject.toml @@ -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.10–3.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 diff --git a/python/ruview-meta/README.md b/python/ruview-meta/README.md new file mode 100644 index 00000000..a9c6b694 --- /dev/null +++ b/python/ruview-meta/README.md @@ -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. diff --git a/python/ruview-meta/pyproject.toml b/python/ruview-meta/pyproject.toml new file mode 100644 index 00000000..401805c2 --- /dev/null +++ b/python/ruview-meta/pyproject.toml @@ -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" } diff --git a/python/ruview-meta/src/ruview/__init__.py b/python/ruview-meta/src/ruview/__init__.py new file mode 100644 index 00000000..3115e851 --- /dev/null +++ b/python/ruview-meta/src/ruview/__init__.py @@ -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__", +] diff --git a/python/src/bindings/bfld.rs b/python/src/bindings/bfld.rs new file mode 100644 index 00000000..bd83ac88 --- /dev/null +++ b/python/src/bindings/bfld.rs @@ -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` 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, +} + +#[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 { + 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 = 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> { + 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>> { + 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, // sounding indices we hold (don't deep-copy the matrices) + timestamp_first: Option, + timestamp_last: Option, + kind: Option, + mean_amplitudes: Vec, // 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 { self.timestamp_first } + + #[getter] + fn timestamp_last(&self) -> Option { self.timestamp_last } + + #[getter] + fn kind(&self) -> Option { 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::() + / 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::() + / 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::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/src/bindings/keypoint.rs b/python/src/bindings/keypoint.rs new file mode 100644 index 00000000..77c0e509 --- /dev/null +++ b/python/src/bindings/keypoint.rs @@ -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 0–16 (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 { + 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 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, + ) -> PyResult { + 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 { + 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::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/src/bindings/pose.rs b/python/src/bindings/pose.rs new file mode 100644 index 00000000..70e8a01f --- /dev/null +++ b/python/src/bindings/pose.rs @@ -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; 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 { + 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 { + 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> { + // 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 { + 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 { + 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 { + 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, + confidence: f32, + latency_ms: f32, + model_version: String, + ) -> PyResult { + let conf = Confidence::new(confidence).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(e.to_string()) + })?; + let rust_persons: Vec = + 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 { + 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 { + 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 { + HashMap::new() +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/src/bindings/vitals.rs b/python/src/bindings/vitals.rs new file mode 100644 index 00000000..ce9613b7 --- /dev/null +++ b/python/src/bindings/vitals.rs @@ -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.1–0.5 Hz → respiratory rate +//! - `HeartRateExtractor` — bandpass 0.8–2.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 (6–30 BPM) from per-subcarrier amplitude +/// residuals via 0.1–0.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, weights: Vec) -> Option { + // 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.1–0.5 Hz bandpass)") + } +} + +// ─── HeartRateExtractor ────────────────────────────────────────────── + +/// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude +/// residuals via 0.8–2.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, weights: Vec) -> Option { + let est = py.allow_threads(|| self.inner.extract(&residuals, &weights)); + est.map(PyVitalEstimate::from_rust) + } + + fn __repr__(&self) -> String { + format!("HeartRateExtractor(0.8–2.0 Hz bandpass)") + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/src/lib.rs b/python/src/lib.rs new file mode 100644 index 00000000..c62ff4f1 --- /dev/null +++ b/python/src/lib.rs @@ -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(()) +} diff --git a/python/tests/test_bfld.py b/python/tests/test_bfld.py new file mode 100644 index 00000000..3ccc2e4c --- /dev/null +++ b/python/tests/test_bfld.py @@ -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__ diff --git a/python/tests/test_client_ha.py b/python/tests/test_client_ha.py new file mode 100644 index 00000000..4fbea64f --- /dev/null +++ b/python/tests/test_client_ha.py @@ -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 diff --git a/python/tests/test_client_mqtt.py b/python/tests/test_client_mqtt.py new file mode 100644 index 00000000..a06a230a --- /dev/null +++ b/python/tests/test_client_mqtt.py @@ -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: `/#` ALSO matches the bare + # `` 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 diff --git a/python/tests/test_client_primitives.py b/python/tests/test_client_primitives.py new file mode 100644 index 00000000..4b17af18 --- /dev/null +++ b/python/tests/test_client_primitives.py @@ -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 0–100 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] diff --git a/python/tests/test_client_ws.py b/python/tests/test_client_ws.py new file mode 100644 index 00000000..b6c2ad16 --- /dev/null +++ b/python/tests/test_client_ws.py @@ -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 diff --git a/python/tests/test_keypoint.py b/python/tests/test_keypoint.py new file mode 100644 index 00000000..4ba09ab8 --- /dev/null +++ b/python/tests/test_keypoint.py @@ -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__ diff --git a/python/tests/test_pose.py b/python/tests/test_pose.py new file mode 100644 index 00000000..60867aa9 --- /dev/null +++ b/python/tests/test_pose.py @@ -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__ diff --git a/python/tests/test_security.py b/python/tests/test_security.py new file mode 100644 index 00000000..11b1de3e --- /dev/null +++ b/python/tests/test_security.py @@ -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"] diff --git a/python/tests/test_smoke.py b/python/tests/test_smoke.py new file mode 100644 index 00000000..f591fbf6 --- /dev/null +++ b/python/tests/test_smoke.py @@ -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() diff --git a/python/tests/test_vitals.py b/python/tests/test_vitals.py new file mode 100644 index 00000000..a195d89a --- /dev/null +++ b/python/tests/test_vitals.py @@ -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` → + 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__ diff --git a/python/tombstone/.gitignore b/python/tombstone/.gitignore new file mode 100644 index 00000000..3bb88219 --- /dev/null +++ b/python/tombstone/.gitignore @@ -0,0 +1,3 @@ +dist/ +build/ +*.egg-info/ diff --git a/python/tombstone/README.md b/python/tombstone/README.md new file mode 100644 index 00000000..78b2feb4 --- /dev/null +++ b/python/tombstone/README.md @@ -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. diff --git a/python/tombstone/pyproject.toml b/python/tombstone/pyproject.toml new file mode 100644 index 00000000..b56d935b --- /dev/null +++ b/python/tombstone/pyproject.toml @@ -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.0–1.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" } diff --git a/python/tombstone/src/wifi_densepose/__init__.py b/python/tombstone/src/wifi_densepose/__init__.py new file mode 100644 index 00000000..43c4881e --- /dev/null +++ b/python/tombstone/src/wifi_densepose/__init__.py @@ -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" +) diff --git a/python/tombstone/tests/test_tombstone.py b/python/tombstone/tests/test_tombstone.py new file mode 100644 index 00000000..37555aa0 --- /dev/null +++ b/python/tombstone/tests/test_tombstone.py @@ -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" diff --git a/python/wifi_densepose/__init__.py b/python/wifi_densepose/__init__.py new file mode 100644 index 00000000..f468f2b1 --- /dev/null +++ b/python/wifi_densepose/__init__.py @@ -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 `_ 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 +`_ +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", +] diff --git a/python/wifi_densepose/client/__init__.py b/python/wifi_densepose/client/__init__.py new file mode 100644 index 00000000..1a33a297 --- /dev/null +++ b/python/wifi_densepose/client/__init__.py @@ -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//raw/+` + `homeassistant/+/wifi_densepose_/+/+` + 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}") diff --git a/python/wifi_densepose/client/ha.py b/python/wifi_densepose/client/ha.py new file mode 100644 index 00000000..e1f6f564 --- /dev/null +++ b/python/wifi_densepose/client/ha.py @@ -0,0 +1,194 @@ +"""ADR-117 P4 — Home Assistant MQTT-discovery payload helpers. + +Parses the `homeassistant//wifi_densepose_//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[A-Za-z_]+)/" + r"wifi_densepose_(?P[A-Za-z0-9]+)/" + r"(?P[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 diff --git a/python/wifi_densepose/client/mqtt.py b/python/wifi_densepose/client/mqtt.py new file mode 100644 index 00000000..eceaf60f --- /dev/null +++ b/python/wifi_densepose/client/mqtt.py @@ -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//raw/edge_vitals` — opt-in firehose of the WS edge_vitals +- `ruview//raw/pose` — opt-in firehose of pose data +- `ruview//raw/sensing_update` — opt-in firehose of every sensing update +- `homeassistant/+/wifi_densepose_/+/config` — HA discovery payloads +- `homeassistant/+/wifi_densepose_/+/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) diff --git a/python/wifi_densepose/client/primitives.py b/python/wifi_densepose/client/primitives.py new file mode 100644 index 00000000..daa75d60 --- /dev/null +++ b/python/wifi_densepose/client/primitives.py @@ -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 (0–100) + 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 0–100) + - 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//wifi_densepose_//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 / / wifi_densepose_ / / 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, + ) diff --git a/python/wifi_densepose/client/ws.py b/python/wifi_densepose/client/ws.py new file mode 100644 index 00000000..385ff7f0 --- /dev/null +++ b/python/wifi_densepose/client/ws.py @@ -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://:/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) diff --git a/python/wifi_densepose/py.typed b/python/wifi_densepose/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/scripts/fix-markers.json b/scripts/fix-markers.json index ba6466f1..e8375792 100644 --- a/scripts/fix-markers.json +++ b/scripts/fix-markers.json @@ -233,6 +233,46 @@ ], "rationale": "At edge tier>=2 on N16R8 PSRAM boards, process_frame() runs update_multi_person_vitals() (4 persons × 256 history samples) plus wasm_runtime_on_frame() back-to-back. The vTaskDelay(1) in edge_task() only fires AFTER process_frame() fully returns — if process_frame() takes >5 s (common on PSRAM-backed boards under sustained 30 pps CSI load), IDLE1 on Core 1 never runs and the Task Watchdog Timer fires. The fix adds two vTaskDelay(1) calls inside process_frame(), gated on tier>=2, at the multi-person vitals boundary and after WASM dispatch. Removing them re-opens the WDT storm on N16R8 hardware.", "ref": "https://github.com/ruvnet/RuView/issues/683" + }, + { + "id": "RuView#786-tombstone-import", + "title": "Tombstone (v1.99.0) __init__.py must raise ImportError with migration URL on import", + "files": ["python/tombstone/src/wifi_densepose/__init__.py"], + "require": [ + "raise ImportError(", + "pip install wifi-densepose==2.0.0", + "github.com/ruvnet/RuView" + ], + "forbid": [ + "/^def\\s/", + "/^class\\s/", + "/^import\\s+wifi_densepose/" + ], + "rationale": "ADR-117 §7.2 — the v1.99.0 tombstone wheel exists solely to raise a legible ImportError when v1.x users upgrade. If a future refactor adds real code (def / class / imports beyond the bare raise), the module may load partway before failing, breaking the migration narrative. The require patterns lock in the raise + the v2 install hint + the repo URL.", + "ref": "https://github.com/ruvnet/RuView/pull/786" + }, + { + "id": "RuView#786-tombstone-smoke-cwd", + "title": "pip-release.yml tombstone smoke-test must cd out of repo root before importing", + "files": [".github/workflows/pip-release.yml"], + "require": [ + "cd /tmp # away from the repo root's stray wifi_densepose/" + ], + "rationale": "ADR-117 §P5 — the repo root contains a legacy `./wifi_densepose/__init__.py` from v1. Python places cwd at sys.path[0], so running `import wifi_densepose` from the repo root after a fresh venv install resolves to the legacy directory and bypasses the tombstone wheel entirely. The smoke-test step MUST `cd /tmp` before the import, otherwise CI silently passes against the wrong package. This was the root cause of run 26366648768.", + "ref": "https://github.com/ruvnet/RuView/pull/786" + }, + { + "id": "RuView#786-pypi-token-auth", + "title": "pip-release.yml must authenticate to PyPI via PYPI_API_TOKEN secret, not OIDC", + "files": [".github/workflows/pip-release.yml"], + "require": [ + "password: ${{ secrets.PYPI_API_TOKEN }}" + ], + "forbid": [ + "id-token: write" + ], + "rationale": "ADR-117 §P5 — the project is registered with PyPI via API token, not OIDC Trusted Publisher. The token is sourced from GCP Secret Manager (see docs/integrations/pypi-release.md). Re-introducing the `id-token: write` permission would suggest a partial OIDC migration that won't actually work without registering the Trusted Publisher on pypi.org first — a silent regression that would 403 on the next publish.", + "ref": "https://github.com/ruvnet/RuView/pull/786" } ] }