* docs(adr-117): seed branch — ADR-117 pip-modernization spec + soul-signature research bundle
Two artifacts landing together on this new branch as the prerequisite
documentation for the v2.0.0 Python wheel modernization work:
1. **docs/adr/ADR-117-pip-wifi-densepose-modernization.md** (644 lines)
— Plan to bring the 2025-published `wifi-densepose` PyPI package
(last release v1.1.0, 2025-06-07, 11.5 months out of sync) up to
the current Rust v2/ workspace SOTA. Recommends PyO3 + maturin
with abi3-py310 (one binary covers Python 3.10–3.13 per OS/arch),
first-wheel scope = core + vitals + signal crates (~5 MB), v1.99.0
tombstone + 90-day un-yank window for v1.1.0, v2.0.0 hard break.
Open questions catalogued; phases P1–P6+ laid out with concrete
acceptance criteria.
2. **docs/research/soul/** (5 files, ~1,450 lines) — Soul Signature
research spec: 7-channel electromagnetic biometric fingerprint
(AETHER 128-dim + cardiac HR/HRV + cardiac waveform morphology +
respiratory pattern + gait timing + skeletal proportions +
subcarrier reflection profile), fused into one RVF graph file.
Includes 60s scanning protocol, 5-layer security model,
threat-model + mitigations, references to existing ADRs (014,
021, 024, 027, 030, 039, 079, 106, 108, 109, 110, 115). Marked
"Research Specification (Pre-Implementation)". Explicit "what
this is NOT" disclaimers preempt pseudoscience drift; every
discriminative-power claim either cites a measurement or is
marked "open research; baseline TBD".
Branch off main at HEAD; ready for /loop 10m implementation
iterations.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-117/p1): scaffold python/ workspace — PyO3 + maturin + smoke tests (refs #785)
ADR-117 P1 — the python/ directory is now a working maturin-buildable
crate that produces the v2.x replacement for the legacy pure-Python
wifi-densepose==1.1.0 PyPI wheel.
## What lands
- `python/Cargo.toml` — PyO3 0.22 with `extension-module` + `abi3-py310`
(one binary covers Python 3.10–3.13 per OS/arch — keeps the
cibuildwheel matrix to 5 wheels per release, not 20). Depends on
`wifi-densepose-core` from the existing v2/ workspace via relative
path.
- `python/pyproject.toml` — maturin>=1.7 build backend with
`python-source = "python"` and `module-name = "wifi_densepose._native"`
so the compiled module loads as an internal underscore-private
submodule of the user-facing `wifi_densepose` package. PEP 621
metadata + classifiers + project URLs. Optional-deps:
`wifi-densepose[client]` for the P4 WS/MQTT pure-Python layer,
`wifi-densepose[dev]` for the test toolchain (pytest, ruff, mypy).
- `python/src/lib.rs` — minimal `#[pymodule] wifi_densepose_native`
exporting `__rust_version__`, `__rust_build_tag__`,
`__build_features__`, and a `hello()` smoke function. P2 will land
the core type bindings here.
- `python/wifi_densepose/__init__.py` — pure-Python facade re-exporting
the compiled module's symbols under their stable user-facing names.
Docstring teaches the v1→v2 migration story up-front.
- `python/wifi_densepose/py.typed` — PEP 561 marker so `mypy --strict`
in user code treats the wheel as fully typed (real stubs land in P2).
- `python/tests/test_smoke.py` — 6 P1 acceptance tests:
1. package imports without error
2. version string is PEP 440-compliant
3. `__rust_version__` is reachable from Python (the diagnostic
surface ADR-117 §5.2 promised)
4. `__build_features__` lists `p1-scaffold` marker
5. `wifi_densepose.hello()` returns "ok" (FFI round-trip)
6. `wifi_densepose._native` is reachable but the leading underscore
conveys "private; users should import the parent package"
- `python/README.md` — phase ledger, local build instructions
(`maturin develop`), layout diagram.
## What's deferred to P2+
- Core type bindings (`CsiFrame`, `Keypoint`, `PoseEstimate`) — P2
- Vitals + signal DSP bindings + witness v2 — P3
- Pure-Python WS/MQTT client layer (`wifi_densepose[client]`) — P4
- cibuildwheel + PyPI publish — P5
- v1.99.0 tombstone — concurrent with P5
The new `python/` crate is intentionally OUTSIDE the v2/ Cargo
workspace — it has its own Cargo.toml with `[package]` not
`[workspace.package]` inheritance — to keep maturin's `python-source`
+ `module-name` config self-contained and to avoid forcing every
`cargo test --workspace` invocation in v2/ to compile pyo3.
Refs ADR-117 §5 (Detailed design) and §6 (Phased migration).
Refs #785 (tracking issue).
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(adr-117/p1): standalone Cargo.toml + python-source=. + #[pyo3(name=_native)] (P1 GREEN)
Three fixes to make maturin develop actually work locally:
1. `python/Cargo.toml` removed `*.workspace = true` inheritance —
the python/ crate is intentionally outside the v2/ workspace
(ADR-117 §5.2) so it needs every `[package]` field local.
2. `python/pyproject.toml` `python-source = "python"` was wrong
because pyproject.toml lives at python/ — maturin was looking for
python/python/. Changed to `python-source = "."` so the
`wifi_densepose/` package directory sibling-to-pyproject is found.
3. `python/src/lib.rs` `#[pymodule] fn wifi_densepose_native` →
`#[pymodule] #[pyo3(name = "_native")] fn wifi_densepose_native`.
PyO3 generates `PyInit__native` from the pyo3-name attribute, which
must match the `module-name` in pyproject.toml's [tool.maturin]
block ("wifi_densepose._native"). Without this attribute the wheel
builds but `import wifi_densepose._native` fails with
ModuleNotFoundError.
## Local validation (P1 acceptance gate)
```
$ python -m venv .venv && .venv/Scripts/python -m pip install maturin pytest
$ VIRTUAL_ENV=… maturin develop --release
…
Finished `release` profile [optimized] target(s)
📦 Built wheel for abi3 Python ≥ 3.10
🛠 Installed wifi-densepose-2.0.0a1
$ .venv/Scripts/python -c 'import wifi_densepose; print(wifi_densepose.__version__, wifi_densepose.__rust_version__, wifi_densepose.hello())'
2.0.0a1 2.0.0-alpha.1 ok
$ .venv/Scripts/python -m pytest tests/ -v
tests/test_smoke.py::test_package_imports PASSED
tests/test_smoke.py::test_version_string_well_formed PASSED
tests/test_smoke.py::test_rust_version_surfaced PASSED
tests/test_smoke.py::test_build_features_listed PASSED
tests/test_smoke.py::test_hello_returns_ok PASSED
tests/test_smoke.py::test_native_module_private PASSED
======================== 6 passed in 0.05s =========================
```
P1 closed. Moving to P2 (core type bindings).
Refs #785, ADR-117 §6.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-117/p2): Keypoint + KeypointType bindings — 23 new tests (29/29 GREEN)
Lands the first chunk of P2: PyO3 bindings for `Keypoint` and
`KeypointType` from `wifi_densepose_core`. Bound types surface to
Python as `wifi_densepose.Keypoint` / `wifi_densepose.KeypointType`.
## Design choices that affect the API surface
1. **`Confidence` is NOT bound as a separate class.** Users hate
wrapping a float in a constructor. Python-side, confidence is just
a `float in [0.0, 1.0]`; the binding validates on construction
(`ValueError` for out-of-range, matching the Rust core error).
2. **`KeypointType` is a `#[pyclass(eq, eq_int, hash, frozen)]` enum**
— hashable so users can drop it into dicts/sets (the most common
pattern in pose-analysis notebooks: `keypoints_by_type[k.type] = k`).
3. **`Keypoint.__init__` keyword-only `z`** so 2D users don't have to
write `None` and 3D users get a clear named arg:
`Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)`.
4. **`Keypoint` is `#[pyclass(frozen)]`** — no in-place mutation. The
Rust core type is immutable through Copy + Hash + Eq, and exposing
setters from Python would create a copy-vs-reference inconsistency
between languages.
## Files
- `python/src/bindings/keypoint.rs` — 220 lines of `#[pymethods]`
wrappers + Rust↔Python enum round-trip
- `python/src/lib.rs` — `mod bindings { pub mod keypoint; }` +
`bindings::keypoint::register(m)?` call from `#[pymodule]`
- `python/wifi_densepose/__init__.py` — re-exports `Keypoint` and
`KeypointType` at the package root
- `python/tests/test_keypoint.py` — 23 tests covering:
- 17-element COCO ordering of `KeypointType.all()`
- index→type mapping for every variant
- snake_name matches COCO spec
- `is_face()` / `is_upper_body()` predicates
- hashability (the bug I caught when I added the set-based face
test — fixed by adding `hash` to the `#[pyclass]` attribute)
- 2D + 3D constructor variants
- position_2d / position_3d tuples
- is_visible threshold
- confidence validation (Err on out-of-range)
- distance_to (2D Euclidean, 3D Euclidean, fallback when one is 2D
and the other is 3D)
- __repr__ + __eq__
- the new `p2-keypoint-bindings` feature marker landed
## Local validation
\`\`\`
$ cd python && .venv/Scripts/python -m pytest tests/ -v
tests/test_smoke.py::test_package_imports PASSED
tests/test_smoke.py::test_version_string_well_formed PASSED
tests/test_smoke.py::test_rust_version_surfaced PASSED
tests/test_smoke.py::test_build_features_listed PASSED
tests/test_smoke.py::test_hello_returns_ok PASSED
tests/test_smoke.py::test_native_module_private PASSED
tests/test_keypoint.py::test_keypoint_type_all_returns_17 PASSED
…
======================== 29 passed in 0.06s =========================
\`\`\`
Wheel size after both bindings: still well under the 5 MB ADR §5.4
budget (release build with --strip on Windows: ~340 KB).
Also adds `python/.gitignore` to prevent the `.venv/` + `target/` +
`_native.abi3.pyd` artifacts from getting committed.
## What's left in P2
CsiFrame + PoseEstimate bindings land in the next iteration. They're
larger (CsiFrame has the subcarrier buffer; PoseEstimate has
17×Keypoint + BoundingBox + track_id + score). Pattern is now proven
so they go faster.
Refs #785, ADR-117 §6.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-117/p2): BoundingBox + PersonPose + PoseEstimate — P2 COMPLETE (57/57 tests GREEN)
Lands the second + third chunks of P2: PyO3 bindings for `BoundingBox`,
`PersonPose`, `PoseEstimate` from `wifi_densepose_core`. Combined with
the prior Keypoint + KeypointType bindings (fd0568caa), this closes
ADR-117 §6 P2.
## Coverage
| Type | Bound | Tests | Mutability |
|---|---|---|---|
| Confidence | exposed as `float` with validation | (covered in keypoint tests) | n/a |
| KeypointType | `#[pyclass(eq, eq_int, hash, frozen)]` | 7 tests | immutable |
| Keypoint | `#[pyclass(frozen)]` | 16 tests | immutable |
| BoundingBox | `#[pyclass(frozen)]` | 8 tests | immutable |
| PersonPose | `#[pyclass]` (mutable, builder-style) | 12 tests | mutable |
| PoseEstimate | `#[pyclass(frozen)]` | 8 tests | immutable |
Smoke (P1) + new tests: **57/57 PASS** locally on Windows.
## What's deferred to P3
CsiFrame intentionally NOT bound in P2 because it uses
`Array2<Complex64>` (ndarray) — the natural Python surface is via the
`numpy` pyo3 bridge, which lands in P3 alongside the vitals + signal
DSP bindings. Binding CsiFrame without numpy interop would force
users to materialise lists of tuples which is a worse API than
`csi_frame.amplitude_array()` returning an ndarray.
## Design choices that affect the API surface
1. **PersonPose.keypoints() returns a dict keyed by KeypointType**
instead of a fixed-length list with None slots. Pythonistas don't
want to know the underlying storage is `[Option<Keypoint>; 17]`.
2. **PoseEstimate.id and .timestamp exposed as strings** (UUID + ISO)
rather than as bound `FrameId` / `Timestamp` types. Users in
notebooks rarely compare UUIDs structurally; strings are good
enough for diagnostics and don't bloat the bindings.
3. **PersonPose is MUTABLE** (`#[pyclass]` without `frozen`) so users
can build poses incrementally with `set_keypoint`/`set_bbox`/
`set_id`. PoseEstimate is `frozen` because once constructed it
represents a snapshot.
## Three PyO3 0.22 gotchas surfaced this iteration
1. `#[pymethods]` getters are NOT accessible from other Rust modules
— need a separate `impl PyKeypoint { pub(crate) fn inner(&self)
-> &Keypoint { ... } }` block for cross-module use.
2. `PyDict::new(py)` was removed in PyO3 0.21 → 0.22 in favour of
`PyDict::new_bound(py)`. (Confusing because `Bound<'py, PyDict>`
is the return type either way.)
3. `dict.set_item(K, V)` requires both K and V to impl
`ToPyObject`. `#[pyclass]` types impl `IntoPy<PyObject>` but NOT
`ToPyObject` — workaround: convert via `.into_py(py)` first, then
`set_item(py_object_k, py_object_v)`.
Saved as PyO3 0.22 binding patterns memory at the horizon-tracker
level so future loop workers don't re-learn them.
## Local validation
\`\`\`
$ cd python && .venv/Scripts/python -m pytest tests/ -v
…
======================== 57 passed in 0.24s =========================
\`\`\`
Wheel size: still ~340 KB on Windows release build.
Refs #785, ADR-117 §6 (P2 done — ready for P3 vitals + signal DSP +
numpy bridge + witness v2).
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr-117): add BFLD support (§5.7a + P3.5 phase + §11.11/12 open questions)
Per maintainer feedback during P3 implementation, expand ADR-117 to
include Beamforming Feedback Loop Data (BFLD) as a first-class binding
target alongside CSI. BFLD is the transmitter-side, AP-station-loop
view of the WiFi channel (802.11ac/ax/be compressed beamforming feedback
frames) — complementary to receiver-side CSI, with three properties
that make it strategically important for the pip wheel:
1. **Up to 996 subcarriers per HE160 frame** (vs 242 for HE-LTF CSI on
ESP32-C6, vs 52 for HT-LTF on ESP32-S3) — much denser per-subcarrier
reflection profile
2. **Works on stock 802.11ac+ hardware** — no Nexmon patch, no ESP32
monitor mode, no firmware drift. Captured via tcpdump/Wireshark +
BFR dissector, or via `mac80211` debugfs on Linux 6.10+
3. **Direct input for the soul-signature spec** (`docs/research/soul/`)
— the seven-channel biometric needs dense subcarrier reflection;
BFLD provides it without specialized hardware
## Three additions to ADR-117
### §5.7a — New binding-target subsection
Comparison table CSI vs BFLD; binding strategy with forward-compat
stub Rust impl pending the future `wifi-densepose-bfld` crate; the
three Python types that ship in P3.5:
- `BfldFrame` (frozen) — one compressed feedback matrix snapshot
- `BfldReport` (frozen) — aggregator over a 60-s scan window
- `BfldKind` enum — `CompressedHE20/40/80/160`, `UncompressedHT20/40`
### §6 P3.5 — Concurrent-with-P3 phase
Checkbox plan for the bindings module + stub Rust storage + numpy
bridge for `feedback_matrix` (Complex64 ndarray, same approach as
`CsiFrame.amplitude` from P3). Lands in the same wheel as P3, no
schedule cushion needed.
### §11.11/12 — Two new open questions
- **§11.11** — Should the future BFR ingestion Rust crate be a new
`wifi-densepose-bfld` workspace member, or extend `-signal`?
*Tentative: new dedicated crate. Wireshark BFR dissector is ~2k
lines and would bloat `-signal`; ingestion is optional for many
deployments; keep `-signal` lean.*
- **§11.12** — Per-vendor BFR variant compatibility (Broadcom vs
Intel vs Qualcomm vs MediaTek differ in psi/phi quantization +
matrix entry ordering). How much normalisation in the Python
binding vs. the future Rust crate? *Tentative: Python binding is
dumb (numpy ndarray in/out); future Rust crate owns per-vendor
normalisation via a `Vendor` enum on the constructor.*
### §12 — BFLD reference list
- Hernandez & Bulut, ACM TOSN 2024 (first systematic survey of
BFR-as-sensing)
- Yousefi et al., MobiSys 2023 (practical breath + HR extraction)
- IEEE 802.11ax-2021 §27.3.10 (frame format)
- Wireshark `packet-ieee80211.c` dissector
- AX210 Linux mac80211 debugfs path (kernel 6.10+)
ADR line count: 644 → 807 (+163). Refs #785 (tracking issue).
The implementation work for P3.5 lands in the next /loop iteration
alongside P3 vitals + signal DSP bindings.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-117/p3+p3.5): vitals + BFLD bindings
P3 — Vital sign extraction bindings (wifi-densepose-vitals):
- VitalStatus enum (eq, eq_int, hash, frozen) — Valid/Degraded/Unreliable/Unavailable
- VitalEstimate (frozen) — value_bpm + confidence + status
- VitalReading (frozen) — HR + BR + signal quality composite
- BreathingExtractor — 0.1–0.5 Hz bandpass + zero-crossing
- HeartRateExtractor — 0.8–2.0 Hz bandpass + autocorrelation
- py.allow_threads on extract() hot loops (Q5 audit confirmed
core/vitals/signal are pure-sync — zero tokio deps, safe to release
GIL with no embedded runtime needed)
- 17 tests covering construction, getters, frozen immutability,
esp32_default + explicit ctors, synthetic-signal end-to-end
P3.5 — BFLD bindings (forward-compat surface, stub Rust):
- BfldKind enum — CompressedHE20/40/80/160 + UncompressedHT20/40
with n_subcarriers, bandwidth_mhz, is_he metadata getters
- BfldFrame (frozen) — from_compressed_feedback() accepts numpy
Complex64 ndarray [Nr x Nc x Nsc], validates dims against kind,
feedback_matrix() returns lossless roundtrip ndarray
- BfldReport — aggregates frames, rejects mismatched kinds,
computes inverse-CV coherence score
- 19 tests covering all 6 PHY variants + numpy roundtrip +
dim-mismatch error + aggregation
- Real Rust ingestion (wifi-densepose-bfld crate) lands post-v2.0
per ADR-117 §11.11/12 — Python API will not change
Total Python test count: 93 (was 57, +36 P3+P3.5). All passing.
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-117/p4): pure-Python WS/MQTT client layer
New sub-package `wifi_densepose.client` (no PyO3, no Rust deps):
- ws.SensingClient — asyncio websockets>=12 wrapper for the Rust
sensing-server /ws/sensing endpoint. Yields typed dataclasses
(ConnectionEstablishedMessage, EdgeVitalsMessage, PoseDataMessage)
with raw-payload fallback for forward-compat with unknown types.
Malformed frames log+drop without breaking the stream.
- mqtt.RuViewMqttClient — paho-mqtt v2 wrapper using the explicit
CallbackAPIVersion.VERSION2 API. Per-instance unique client_id by
default (rumqttc memory lesson). MQTT v5-spec-correct topic
wildcard matcher: + as whole-level wildcard, # matches the prefix
itself plus all sub-levels. Auto-resubscribes on reconnect.
Handler exceptions are caught and logged so a misbehaving callback
can't crash the network loop.
- primitives.SemanticPrimitiveListener — typed router for the 10
HA-MIND fused inference outputs from ADR-115 §3.12
(SomeoneSleeping, PossibleDistress, RoomActive, ElderlyInactivity-
Anomaly, MeetingInProgress, BathroomOccupied, FallRiskElevated,
BedExit, NoMovementSafety, MultiRoomTransition). Decodes both
JSON payloads with confidence+explanation AND plain HA state
strings ("ON"/"OFF"/numeric). Pluggable into RuViewMqttClient.
- ha.HABlueprintHelper — read-only parser for the
homeassistant/<kind>/wifi_densepose_<node>/<id>/config payload
family. Aggregator queries: entities_for_node, by_device_class,
nodes. Useful for blueprint authors + dashboard introspection.
Test coverage (63 new tests, 156 total in Python suite):
- test_client_ha — 18 tests (topic+payload parsing, aggregator)
- test_client_primitives — 13 tests (enum coverage, listener routing)
- test_client_mqtt — 17 tests (matcher parametrize, dispatch path,
on_connect, exception isolation) — no broker needed
- test_client_ws — 6 tests including end-to-end against an in-process
websockets.serve() fixture exercising all 4 message types plus a
malformed-frame survival check
Post-bridge wheel size: 238 KB (well under ADR §5.4 5 MB budget).
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md §5.6
Refs: docs/adr/ADR-115-home-assistant-integration.md §3.12
Refs: #785
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-117/p5+p-tomb): pip-release workflow + v1.99.0 tombstone wheel
P5 — `.github/workflows/pip-release.yml`:
- cibuildwheel matrix per ADR §5.4: manylinux x86_64 + aarch64,
macos x86_64 + arm64, win amd64 (5 wheels via abi3-py310 stable
ABI — one binary per OS/arch covers Python 3.10–3.13)
- Linux aarch64 cross-builds via QEMU; rustup 1.82 pinned in
CIBW_BEFORE_ALL_LINUX for reproducibility
- Per-wheel smoke test: import wifi_densepose, assert hello()=="ok"
- sdist via `maturin sdist`
- Trigger: workflow_dispatch + push to `v*-pip` tags ONLY (never
on regular commits — won't accidentally publish)
- TestPyPI dry-run gate via `repository-url: https://test.pypi.org/legacy/`
- Production PyPI publish via Trusted Publisher OIDC (no API tokens
in GH secrets per ADR §9). Requires one-time PyPI Trusted Publisher
registration before the first publish can fire.
- Q3 (witness hash v2 — ADR-117 §11.3) flagged in workflow comments
as a hard gate before the first tag.
P-tomb — `python/tombstone/`:
- Separate `wifi-densepose==1.99.0` sdist+wheel using setuptools
backend (NOT maturin — tombstone is pure Python, no Rust).
- `src/wifi_densepose/__init__.py` raises ImportError with the
migration URL on import. Verified locally: 2.7 KB wheel,
`pip install` then `import wifi_densepose` raises ImportError
with `pip install wifi-densepose==2.0.0` hint + repo URL.
- 5 unit tests (`tests/test_tombstone.py`) lock the file content
down: must `raise ImportError`, must contain v2 install hint
and migration URL, must NOT contain any `def`/`class`/`import`
beyond the bare `raise` — so a well-intentioned refactor can't
accidentally bloat the tombstone into a real module that loads
partway before failing.
Both wheels are published by the same pip-release.yml workflow:
- `v1.99.0-pip` tag → publishes tombstone (or via workflow_dispatch
with `target: v1-99-tombstone`)
- `v2.X.Y-pip` tag → publishes the v2 wheel matrix
Per ADR-117 §7.3: tag and publish 1.99.0-pip FIRST so the tombstone
claims the "current" slot in pip's resolver, THEN publish 2.0.0-pip.
Test count unchanged in main python/ suite (156/156). Tombstone
sub-suite: 5 passing.
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md §5.4, §7
Refs: #785
Co-Authored-By: claude-flow <ruv@ruv.net>
* hardening(adr-117): benchmarks + security/robustness test suite
Benchmarks (`python/bench/`, pytest-benchmark — opt-in via --benchmark-only):
| Hot path | Mean | Ops/sec | % of 100 Hz budget |
|---|---|---|---|
| BfldFrame HT20 1×1×52 | 800 ns | 1.25 Mops | 0.008% |
| BfldFrame HE20 2×1×242 | 1.3 μs | 750 kops | 0.013% |
| BfldFrame HE80 2×1×996 | 4.2 μs | 236 kops | 0.042% |
| BfldFrame HE160 2×2×1992 | 14 μs | 71 kops | 0.14% |
| BfldFrame.feedback_matrix() | 2.8 μs | 352 kops | — |
| WS edge_vitals decode | 7.4 μs | 134 kops | 0.074% |
| WS pose_data decode (3 persons) | 23 μs | 42 kops | 0.24% |
| BreathingExtractor.extract() 56sc | 28 μs | 35 kops | 0.28% |
| BreathingExtractor.extract() 114sc | 44 μs | 23 kops | 0.44% |
| BreathingExtractor.extract() 242sc | 79 μs | 13 kops | 0.79% |
| HeartRateExtractor.extract() 56sc | 105 μs | 9.5 kops | 1.05% |
All hot paths well under the 100 Hz ESP32 frame budget (10 ms).
Worst case (HeartRateExtractor) uses 1% of the budget — no
optimization needed. Scaling on n_subcarriers is sub-quadratic
(56→242 = 4.3× input, 2.8× time) — catches future O(n²)
regressions.
Security & robustness tests (`tests/test_security.py`, +27 tests):
- WS decoder: rejects non-object roots cleanly, survives 1 MB string
values, handles non-ASCII node IDs, survives deeply-nested JSON
(Python's json.loads built-in guard not bypassed)
- MQTT topic matcher: 9 edge-case parametrize entries including
$SYS topics, null-byte injection, mid-pattern `#` boundary,
empty-string boundary
- MQTT credential confidentiality: password never appears in
repr()/str(), never stored in plain client-instance attribute
- HA discovery: rejects null-byte-laced topics, rejects extra
slashes in node_id, rejects non-dict payload body (list, scalar,
invalid UTF-8 bytes) without crashing
- Semantic primitive listener: rejects topic-injection attempts
(prefix-injected paths, wrong case on final segment), survives
invalid UTF-8 payloads
- Public surface integrity: every name in wifi_densepose.__all__
AND wifi_densepose.client.__all__ resolves — catches accidental
re-export breakage between phases
- Multi-handler MQTT exception isolation: a crashing handler in
the middle of the registered list doesn't stop later handlers
from firing
Test count: 156 → 183 (+27). All passing.
Bench results steady-state confirm no Rust-binding-layer
optimization is needed before the v2.0.0 publish.
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(adr-117/p5): switch publish workflow to PYPI_API_TOKEN + user-facing README
- Workflow rewired from OIDC Trusted Publisher to token-based publish
via the `PYPI_API_TOKEN` GitHub Actions secret. Both publish jobs
(v2 wheels + tombstone) pass `password: ${{ secrets.PYPI_API_TOKEN }}`
to `pypa/gh-action-pypi-publish@release/v1`. Workflow comments now
document the GCP → GH secret-refresh command.
- Removed `permissions: id-token: write` and the OIDC `environment:`
blocks (no longer needed without OIDC).
- Token was sourced from the GCP Secret Manager entry `PYPI_TOKEN`
in project `cognitum-20260110` and pushed to GH Actions via
`gcloud secrets versions access | gh secret set` so the value
never appeared in a shell variable or this session's output.
- Rewrote `python/README.md` from a developer phase-ledger into a
user-facing PyPI front page: one-paragraph elevator pitch, bullet
list of features, three short usage snippets (vitals extract,
WS subscribe, MQTT semantic-primitive listener, BFLD numpy
bridge), hardware table, links. The README is the FIRST thing
pip users see at https://pypi.org/p/wifi-densepose so it has to
introduce the project, not the build plan.
Wheel rebuilds clean at 253 KB (was 238 KB — +15 KB from the richer
README baked into the wheel metadata). Test suite unchanged at 183/183.
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(adr-117): point root README + user-guide at the v2 pip wheel
- Root README — add Option 4 alongside the existing Docker / ESP32 /
Cognitum Seed installs: `pip install "wifi-densepose[client]"` with
a two-line import preview.
- User-guide §Installation — replace the stale "From Source (Python)"
block (which referenced legacy v1 extras `[gpu]` and `[all]` that
don't exist in v2) with a brief "Python wheel (pip) — ADR-117"
section: what the wheel is, install commands, two-line example,
tombstone caveat, and the `maturin develop` source-build path
for contributors.
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(adr-117/p5): pin Python 3.12 + isolated venv for tombstone smoke-test
First v1.99.0-pip run (26366491748) failed: the runner's system `python`
fell back to `--user` install, then `python -c "import wifi_densepose"`
resolved to something other than the freshly-installed user-site wheel
and returned cleanly instead of raising the tombstone ImportError.
Fixes:
- `actions/setup-python@v5` with explicit 3.12 — owns its own site-
packages so pip won't fall back to --user.
- New "Inspect wheel contents" step prints the wheel manifest +
the verbatim __init__.py inside it. If a future regression ships
an empty __init__.py from a setuptools src-layout edge case,
the failure is debuggable from the run log alone.
- Smoke test now runs in a fresh /tmp/smoke-venv so there's zero
ambiguity about which wifi_densepose gets imported. Also uses
importlib.util.find_spec to print the resolved origin path
before the import attempt — so even if both checks pass, we
see exactly which file we exercised.
No code changes to the tombstone source itself.
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(adr-117/p5): smoke-test must cd out of repo root before importing
Root cause from run 26366579422 diagnostics: the wheel built correctly
(872 bytes, valid ImportError) but `import wifi_densepose` resolved to
the legacy `./wifi_densepose/__init__.py` left in the repo root from
v1, NOT to the freshly-installed tombstone wheel in the smoke venv.
Python places the cwd at sys.path[0] for `python -c "..."`, so
running the import from the repo root made the legacy directory win
over site-packages every time. The "isolated venv" was not the
problem — the cwd was.
Fix: copy the wheel to /tmp, cd /tmp before the import. Now the
smoke test runs in a directory that contains no `wifi_densepose/`
so the only resolution path is the venv's site-packages.
The repo-root `./wifi_densepose/__init__.py` is a separate concern
(legacy v1 carry-over) that should be cleaned up in a follow-up
commit, but the smoke test should not depend on it being absent.
Co-Authored-By: claude-flow <ruv@ruv.net>
* feat(adr-117): publish wifi-densepose 2.0.0a1 + ruview 2.0.0a1 to PyPI
Three PyPI artifacts now live (published from .env-sourced PYPI_TOKEN
via twine from the maintainer box — direct upload bypassed the GH
Actions workflow auth churn):
1. wifi-densepose==1.99.0 — tombstone (raises ImportError with migration URL)
https://pypi.org/project/wifi-densepose/1.99.0/
2. wifi-densepose==2.0.0a1 — PyO3 wheel (win_amd64 cp310-abi3) + sdist
https://pypi.org/project/wifi-densepose/2.0.0a1/
3. ruview==2.0.0a1 — meta-package re-exporting wifi_densepose
https://pypi.org/project/ruview/2.0.0a1/
New `python/ruview-meta/` subdirectory:
- pyproject.toml — name="ruview", version="2.0.0a1", setuptools backend,
dependencies = ["wifi-densepose==2.0.0a1"]
- src/ruview/__init__.py — re-exports every name from
`wifi_densepose.__all__` so `from ruview import BreathingExtractor`
is equivalent to `from wifi_densepose import BreathingExtractor`.
Also re-exports `__version__`, `__rust_version__`,
`__rust_build_tag__`, `__build_features__`. Aliases the `client`
sub-package transparently when wifi-densepose[client] extras are
installed.
- README.md — explains why two PyPI names ship the same code (brand
vs technical name) and shows install commands for both.
End-to-end verified: fresh venv, `pip install ruview`,
`import ruview` + `import wifi_densepose` both succeed,
`ruview.BreathingExtractor is wifi_densepose.BreathingExtractor` → True.
Multi-platform wheels (manylinux x86_64+aarch64, macos x86_64+arm64)
still pending — the cibuildwheel workflow path remains for that.
Linux/macOS users today install via the sdist (requires rustup +
maturin locally).
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #785
Co-Authored-By: claude-flow <ruv@ruv.net>
* ci(adr-117): kics-compatible workflow comments + fix-marker guards
- KICS error fix (.github/workflows/pip-release.yml:20): the inline
`gcloud secrets versions access --secret=PYPI_TOKEN ...` runbook
in the workflow header was triggering KICS' generic-secret regex
on the literal `PYPI_TOKEN` substring. Moved the refresh runbook
to docs/integrations/pypi-release.md (with the BOM-stripping
`tr` step that fixed the production publish) and replaced the
inline block with a pointer.
- Three new fix-marker guards in scripts/fix-markers.json so the
next person to touch this code can't silently regress what
PR #786 just shipped:
* RuView#786-tombstone-import — the tombstone __init__.py must
`raise ImportError`, must mention the v2 install hint, must
point at the repo URL, AND must NOT contain `def`/`class`/
`import wifi_densepose` (forbid patterns prevent accidental
bloating into a real module that loads partway before failing).
* RuView#786-tombstone-smoke-cwd — pip-release.yml must `cd /tmp`
before the tombstone smoke-test import, because the legacy
`./wifi_densepose/__init__.py` at repo root would otherwise
shadow the venv install. This was the root cause of run
26366648768; locking it in.
* RuView#786-pypi-token-auth — the workflow must use
`password: ${{ secrets.PYPI_API_TOKEN }}` and must NOT carry
`id-token: write`. The project authenticates via API token,
not OIDC; a partial OIDC migration would 403 silently.
Local check: all 25 markers pass.
Refs: docs/adr/ADR-117-pip-wifi-densepose-modernization.md
Refs: #786
Co-Authored-By: claude-flow <ruv@ruv.net>
90 KiB
WiFi DensePose User Guide
WiFi DensePose turns commodity WiFi signals into real-time human pose estimation, vital sign monitoring, and presence detection. This guide walks you through installation, first run, API usage, hardware setup, and model training.
Table of Contents
- Prerequisites
- Installation
- Quick Start
- Data Sources
- REST API Reference
- WebSocket Streaming
- Web UI
- Vital Sign Detection
- CLI Reference
- Observatory Visualization
- Loading the Pretrained Model from Hugging Face
- Adaptive Classifier
- Training a Model
- RVF Model Containers
- Hardware Setup
- Camera-Free Pose Training
- ruvllm Training Pipeline
- Docker Compose (Multi-Service)
- Testing Firmware Without Hardware (QEMU)
- Troubleshooting
- FAQ
Prerequisites
| Requirement | Minimum | Recommended |
|---|---|---|
| OS | Windows 10/11, macOS 10.15, Ubuntu 18.04 | Latest stable |
| RAM | 4 GB | 8 GB+ |
| Disk | 2 GB free | 5 GB free |
| Docker (for Docker path) | Docker 20+ | Docker 24+ |
| Rust (for source build) | 1.70+ | 1.85+ |
| Python (for legacy v1) | 3.10+ | 3.13+ |
Hardware for live sensing (optional):
| Option | Cost | Capabilities |
|---|---|---|
| ESP32-S3 mesh (3-6 boards) | ~$54 | Full CSI: pose, breathing, heartbeat, presence |
| Intel 5300 / Atheros AR9580 | $50-100 | Full CSI with 3x3 MIMO (Linux only) |
| Any WiFi laptop | $0 | RSSI-only: coarse presence and motion detection |
No hardware? The system runs in simulated mode with synthetic CSI data.
Installation
Docker (Recommended)
The fastest path. No toolchain installation needed.
docker pull ruvnet/wifi-densepose:latest
Multi-architecture image (amd64 + arm64). Works on Intel/AMD and Apple Silicon Macs. Contains the Rust sensing server, Three.js UI, and all signal processing.
Data source selection: Use the CSI_SOURCE environment variable to select the sensing mode:
| Value | Description |
|---|---|
auto |
(default) Probe for ESP32 on UDP 5005, fall back to simulation |
esp32 |
Receive real CSI frames from ESP32 devices over UDP |
simulated |
Generate synthetic CSI frames (no hardware required) |
wifi |
Host Wi-Fi RSSI (not available inside containers) |
Example: docker run -e CSI_SOURCE=esp32 -p 3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest
From Source (Rust)
On Debian/Ubuntu-based Linux systems, install the native desktop prerequisites before the first Rust release build:
sudo apt update
sudo apt install -y \
build-essential pkg-config \
libglib2.0-dev libgtk-3-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libwebkit2gtk-4.1-dev
This prepares the native GTK/WebKit dependencies used by the desktop/Tauri crates in this workspace.
git clone https://github.com/ruvnet/RuView.git
cd RuView/v2
# Build
cargo build --release
# Verify (runs 1,400+ tests)
cargo test --workspace --no-default-features
The compiled binary is at target/release/sensing-server.
From crates.io (Individual Crates)
All 16 crates are published to crates.io at v0.3.0. Add individual crates to your own Rust project:
# Core types and traits
cargo add wifi-densepose-core
# Signal processing (includes RuvSense multistatic sensing)
cargo add wifi-densepose-signal
# Neural network inference
cargo add wifi-densepose-nn
# Mass Casualty Assessment Tool
cargo add wifi-densepose-mat
# ESP32 hardware + TDM protocol + QUIC transport
cargo add wifi-densepose-hardware
# RuVector integration (add --features crv for CRV signal-line protocol)
cargo add wifi-densepose-ruvector --features crv
# WebAssembly bindings
cargo add wifi-densepose-wasm
# WASM edge runtime (lightweight, for embedded/IoT)
cargo add wifi-densepose-wasm-edge
See the full crate list and dependency order in CLAUDE.md.
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.
pip install wifi-densepose # core DSP only
pip install "wifi-densepose[client]" # + websockets + paho-mqtt
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):
git clone https://github.com/ruvnet/RuView.git
cd RuView/python
pip install maturin>=1.7
maturin develop --release
Guided Installer
An interactive installer that detects your hardware and recommends a profile:
git clone https://github.com/ruvnet/RuView.git
cd RuView
./install.sh
Available profiles: verify, python, rust, browser, iot, docker, field, full.
Non-interactive:
./install.sh --profile rust --yes
Quick Start
30-Second Demo (Docker)
# Pull and run
docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
# Open the UI in your browser
# http://localhost:3000
You will see a Three.js visualization with:
- 3D body skeleton (17 COCO keypoints)
- Signal amplitude heatmap
- Phase plot
- Vital signs panel (breathing + heartbeat)
Verify the System Works
Open a second terminal and test the API:
# Health check
curl http://localhost:3000/health
# Expected: {"status":"ok","source":"simulated","clients":0}
# Latest sensing frame
curl http://localhost:3000/api/v1/sensing/latest
# Vital signs
curl http://localhost:3000/api/v1/vital-signs
# Pose estimation (17 COCO keypoints)
curl http://localhost:3000/api/v1/pose/current
# Server build info
curl http://localhost:3000/api/v1/info
All endpoints return JSON. In simulated mode, data is generated from a deterministic reference signal.
Data Sources
The --source flag controls where CSI data comes from.
Simulated Mode (No Hardware)
Default in Docker. Generates synthetic CSI data exercising the full pipeline.
# Docker
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
# (--source auto is the default; falls back to simulate when no hardware detected)
# From source
./target/release/sensing-server --source simulate --http-port 3000 --ws-port 3001
Windows WiFi (RSSI Only)
Uses netsh wlan to capture RSSI from nearby access points. No special hardware needed. Supports presence detection, motion classification, and coarse breathing rate estimation. No pose estimation (requires CSI).
# From source (Windows only)
./target/release/sensing-server --source wifi --http-port 3000 --ws-port 3001 --tick-ms 500
# Docker (requires --network host on Windows)
docker run --network host ruvnet/wifi-densepose:latest --source wifi --tick-ms 500
Community verified: Tested on Windows 10 (10.0.26200) with Intel Wi-Fi 6 AX201 160MHz, Python 3.14, StormFiber 5 GHz network. All 7 tutorial steps passed with stable RSSI readings at -48 dBm. See Tutorial #36 for the full walkthrough and test results.
Vital signs from RSSI: The sensing server now supports breathing rate estimation from RSSI variance patterns (requires stationary subject near AP) and motion classification with confidence scoring. RSSI-based vital sign detection has lower fidelity than ESP32 CSI — it is best for presence detection and coarse motion classification.
macOS WiFi (RSSI Only)
Uses CoreWLAN via a Swift helper binary. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works.
# Compile the Swift helper (once)
swiftc -O archive/v1/src/sensing/mac_wifi.swift -o mac_wifi
# Run natively
./target/release/sensing-server --source macos --http-port 3000 --ws-port 3001 --tick-ms 500
See ADR-025 for details.
Linux WiFi (RSSI Only)
Uses iw dev <iface> scan to capture RSSI. Requires CAP_NET_ADMIN (root) for active scans; use scan dump for cached results without root.
# Run natively (requires root for active scanning)
sudo ./target/release/sensing-server --source linux --http-port 3000 --ws-port 3001 --tick-ms 500
ESP32-S3 (Full CSI)
Real Channel State Information at 20 Hz with 56-192 subcarriers. Required for pose estimation, vital signs, and through-wall sensing.
# From source
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
# Docker (use CSI_SOURCE environment variable)
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
The ESP32 nodes stream binary CSI frames over UDP to port 5005. See Hardware Setup for flashing instructions.
ESP32 Multistatic Mesh (Advanced)
For higher accuracy with through-wall tracking, deploy 3-6 ESP32-S3 nodes in a multistatic mesh configuration. Each node acts as both transmitter and receiver, creating multiple sensing paths through the environment.
# Start the aggregator with multistatic mode
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
The mesh uses a Time-Division Multiplexing (TDM) protocol so nodes take turns transmitting, avoiding self-interference. Key features:
| Feature | Description |
|---|---|
| TDM coordination | Nodes cycle through TX/RX slots (configurable guard intervals) |
| Channel hopping | Automatic 2.4/5 GHz band cycling for multiband fusion |
| QUIC transport | TLS 1.3-encrypted streams on aggregator nodes (ADR-032a) |
| Manual crypto fallback | HMAC-SHA256 beacon auth on constrained ESP32-S3 nodes |
| Attention-weighted fusion | Cross-viewpoint attention with geometric diversity bias |
See ADR-029 and ADR-032 for the full design.
Connect Mesh Data to the Dashboard and Observatory
If a standalone aggregator command prints live packets, the ESP32 fleet is already reaching that host. To visualize the same data, stop the standalone aggregator and run sensing-server on that same host and UDP port. The sensing server is the aggregator used by the REST API, WebSocket stream, dashboard, and Observatory.
# From a source build
cd v2
cargo run -p wifi-densepose-sensing-server -- \
--source esp32 \
--udp-port 5005 \
--http-port 3000 \
--ws-port 3001 \
--ui-path ../../ui
# Docker
docker run --rm \
-e CSI_SOURCE=esp32 \
-p 3000:3000 \
-p 3001:3001 \
-p 5005:5005/udp \
ruvnet/wifi-densepose:latest
Open the UI from the sensing server, not from a local file:
| View | URL |
|---|---|
| Dashboard | http://localhost:3000/ui/index.html |
| Observatory | http://localhost:3000/ui/observatory.html |
Use these checks before debugging the browser:
curl http://localhost:3000/health
curl http://localhost:3000/api/v1/nodes
curl http://localhost:3000/api/v1/sensing/latest
If the ESP32 nodes are provisioned with --target-ip <AGGREGATOR_HOST>, that IP must be the machine running sensing-server. Only one process can receive UDP :5005 at a time, so leave the standalone hardware aggregator off while the dashboard or Observatory is live.
Cognitum Seed Integration (ADR-069)
Connect an ESP32-S3 to a Cognitum Seed (Pi Zero 2 W, ~$15) for persistent vector storage, kNN similarity search, cryptographic witness chain, and AI-accessible sensing via MCP proxy.
What the Seed adds:
- RVF vector store — Persistent 8-dim feature vectors with content-addressed IDs and kNN search (cosine, L2, dot product)
- Witness chain — SHA-256 tamper-evident audit trail for every ingest operation
- Ed25519 custody — Device-bound keypair for cryptographic attestation of sensing data
- Sensor fusion — BME280 (temp/humidity/pressure), PIR motion, reed switch, 4-ch ADC provide environmental ground truth
- MCP proxy — 114 tools via JSON-RPC 2.0 so AI assistants (Claude, GPT) can query sensing state directly
- Reflex rules — Automatic alarm triggers based on fragility, drift, and anomaly thresholds
Setup:
# 1. Plug in the Cognitum Seed via USB — appears as a network adapter at 169.254.42.1
# 2. Pair your client (opens a 30-second window, USB-only for security)
curl -sk -X POST https://169.254.42.1:8443/api/v1/pair/window
curl -sk -X POST https://169.254.42.1:8443/api/v1/pair \
-H 'Content-Type: application/json' -d '{"client_name":"my-laptop"}'
# Save the returned token — it is shown only once
# 3. Provision ESP32 to send features to your laptop (where the bridge runs)
python firmware/esp32-csi-node/provision.py --port COM9 \
--ssid "YourWiFi" --password "secret" \
--target-ip 192.168.1.20 --target-port 5006 --node-id 1
# 4. Run the bridge (receives ESP32 UDP, ingests into Seed via HTTPS)
export SEED_TOKEN="your-pairing-token"
python scripts/seed_csi_bridge.py \
--seed-url https://169.254.42.1:8443 --token "$SEED_TOKEN" \
--udp-port 5006 --batch-size 10 --validate
# 5. Check Seed status
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --stats
# 6. Trigger compaction (reclaim disk space from deleted vectors)
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --compact
Feature vector dimensions (magic 0xC5110003, 48 bytes, 1 Hz):
| Dim | Feature | Range | Source |
|---|---|---|---|
| 0 | Presence score | 0.0–1.0 | s_presence_score / 10.0 |
| 1 | Motion energy | 0.0–1.0 | s_motion_energy / 10.0 |
| 2 | Breathing rate | 0.0–1.0 | s_breathing_bpm / 30.0 |
| 3 | Heart rate | 0.0–1.0 | s_heartrate_bpm / 120.0 |
| 4 | Phase variance | 0.0–1.0 | Mean Welford variance of top-K subcarriers |
| 5 | Person count | 0.0–1.0 | Active persons / 4 |
| 6 | Fall detected | 0.0 or 1.0 | Binary fall flag |
| 7 | RSSI | 0.0–1.0 | (rssi + 100) / 100 |
Architecture:
ESP32-S3 ($9) ──UDP:5006──> Host (bridge) ──HTTPS──> Cognitum Seed ($15)
CSI @ 100 Hz seed_csi_bridge.py RVF vector store
Features @ 1 Hz Batches, validates kNN graph + boundary
Vitals @ 1 Hz NaN rejection Witness chain
Source IP filtering 114-tool MCP proxy
See ADR-069 for the complete design, validation results, and security analysis.
REST API Reference
Base URL: http://localhost:3000 (Docker) or http://localhost:8080 (binary default).
| Method | Endpoint | Description | Example Response |
|---|---|---|---|
GET |
/health |
Server health check | {"status":"ok","source":"simulated","clients":0} |
GET |
/api/v1/sensing/latest |
Latest CSI sensing frame (amplitude, phase, motion) | JSON with subcarrier arrays |
GET |
/api/v1/vital-signs |
Breathing rate + heart rate + confidence | {"breathing_bpm":16.2,"heart_bpm":72.1,"confidence":0.87} |
GET |
/api/v1/pose/current |
17 COCO keypoints (x, y, z, confidence) | Array of 17 joint positions |
GET |
/api/v1/info |
Server version, build info, uptime | JSON metadata |
GET |
/api/v1/bssid |
Multi-BSSID WiFi registry | List of detected access points |
GET |
/api/v1/model/layers |
Progressive model loading status | Layer A/B/C load state |
GET |
/api/v1/model/sona/profiles |
SONA adaptation profiles | List of environment profiles |
POST |
/api/v1/model/sona/activate |
Activate a SONA profile for a specific room | {"profile":"kitchen"} |
GET |
/api/v1/models |
List available RVF model files | {"models":[...],"count":0} |
GET |
/api/v1/models/active |
Currently loaded model (or null) | {"model":null} |
POST |
/api/v1/models/load |
Load a model by ID | {"status":"loaded","model_id":"..."} |
POST |
/api/v1/models/unload |
Unload the active model | {"status":"unloaded"} |
DELETE |
/api/v1/models/:id |
Delete a model file from disk | {"status":"deleted"} |
GET |
/api/v1/models/lora/profiles |
List LoRA adapter profiles | {"profiles":[]} |
POST |
/api/v1/models/lora/activate |
Activate a LoRA profile | {"status":"activated"} |
GET |
/api/v1/recording/list |
List CSI recording sessions | {"recordings":[...],"count":0} |
POST |
/api/v1/recording/start |
Start recording CSI frames to JSONL | {"status":"recording","session_id":"..."} |
POST |
/api/v1/recording/stop |
Stop the active recording | {"status":"stopped","duration_secs":...} |
DELETE |
/api/v1/recording/:id |
Delete a recording file | {"status":"deleted"} |
GET |
/api/v1/train/status |
Training run status | {"phase":"idle"} |
POST |
/api/v1/train/start |
Start a training run | {"status":"started"} |
POST |
/api/v1/train/stop |
Stop the active training run | {"status":"stopped"} |
POST |
/api/v1/adaptive/train |
Train adaptive classifier from recordings | {"success":true,"accuracy":0.85} |
GET |
/api/v1/adaptive/status |
Adaptive model status and accuracy | {"loaded":true,"accuracy":0.85} |
POST |
/api/v1/adaptive/unload |
Unload adaptive model | {"success":true} |
GET |
/api/v1/mesh |
ADR-110 fleet-wide mesh sync map (iter 29) | {"nodes":{"9":{...},"12":{...}},"total":2} |
GET |
/api/v1/nodes/:id/sync |
Single-node mesh sync snapshot (or 404) | {"offset_us":1163565,"is_leader":false,...} |
GET |
/api/v1/mesh/metrics |
ADR-110 mesh state in Prometheus exposition format (iter 36) | wifi_densepose_mesh_offset_us{node="9"} 1163565\n… |
Example: Get fleet mesh state (ADR-110)
curl -s http://localhost:3000/api/v1/mesh | python -m json.tool
{
"nodes": {
"9": {
"offset_us": 1163565,
"is_leader": false,
"is_valid": true,
"smoothed": true,
"sequence": 20,
"csi_fps_ema": 10.0,
"csi_fps_samples": 47
},
"12": {
"offset_us": -7,
"is_leader": true,
"is_valid": true,
"smoothed": false,
"sequence": 20,
"csi_fps_ema": 10.0,
"csi_fps_samples": 51
}
},
"total": 2
}
Empty {"nodes": {}, "total": 0} means no mesh peers reachable.
Nodes that haven't emitted a sync packet yet are omitted from the map.
Example: Get one node's sync state
curl -s http://localhost:3000/api/v1/nodes/9/sync | python -m json.tool
200 → same NodeSyncSnapshot shape as inside /api/v1/mesh or the
WebSocket sync field. Field meanings are documented under
Per-node mesh sync (ADR-110).
404 (unknown node):
{"error": "unknown_node", "node_id": 99}
404 (node exists but hasn't synced yet):
{
"error": "no_sync",
"node_id": 9,
"hint": "node hasn't emitted a sync packet yet (no mesh peer or not v0.6.9+)"
}
Useful for Home Assistant REST sensors, Prometheus exporters, automation rule probes, and curl debugging — anywhere you want one-shot mesh state without holding a WebSocket connection.
Example: Get Vital Signs
curl -s http://localhost:3000/api/v1/vital-signs | python -m json.tool
{
"breathing_bpm": 16.2,
"heart_bpm": 72.1,
"breathing_confidence": 0.87,
"heart_confidence": 0.63,
"motion_level": 0.12,
"timestamp_ms": 1709312400000
}
Example: Get Pose
curl -s http://localhost:3000/api/v1/pose/current | python -m json.tool
{
"persons": [
{
"id": 0,
"keypoints": [
{"name": "nose", "x": 0.52, "y": 0.31, "z": 0.0, "confidence": 0.91},
{"name": "left_eye", "x": 0.54, "y": 0.29, "z": 0.0, "confidence": 0.88}
]
}
],
"frame_id": 1024,
"timestamp_ms": 1709312400000
}
WebSocket Streaming
Real-time sensing data is available via WebSocket.
URL: ws://localhost:3000/ws/sensing (same port as HTTP — recommended) or ws://localhost:3001/ws/sensing (dedicated WS port).
Note: The
/ws/sensingWebSocket endpoint is available on both the HTTP port (3000) and the dedicated WebSocket port (3001/8765). The web UI uses the HTTP port so only one port needs to be exposed. The dedicated WS port remains available for backward compatibility.
Python Example
import asyncio
import websockets
import json
async def stream():
uri = "ws://localhost:3001/ws/sensing"
async with websockets.connect(uri) as ws:
async for message in ws:
data = json.loads(message)
persons = data.get("persons", [])
vitals = data.get("vital_signs", {})
print(f"Persons: {len(persons)}, "
f"Breathing: {vitals.get('breathing_bpm', 'N/A')} BPM")
asyncio.run(stream())
JavaScript Example
const ws = new WebSocket("ws://localhost:3001/ws/sensing");
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Persons:", data.persons?.length ?? 0);
console.log("Breathing:", data.vital_signs?.breathing_bpm, "BPM");
};
ws.onerror = (err) => console.error("WebSocket error:", err);
curl (single frame)
# Requires wscat (npm install -g wscat)
wscat -c ws://localhost:3001/ws/sensing
Per-node mesh sync (ADR-110)
Since firmware v0.7.0-esp32 + sensing-server iter 23, every
sensing_update whose nodes participate in the ADR-110
ESP-NOW mesh carries an optional sync object per node:
{
"type": "sensing_update",
"nodes": [
{
"node_id": 9,
"rssi_dbm": -38.0,
"amplitude": [...],
"subcarrier_count": 64,
"sync": {
"offset_us": 1163565,
"is_leader": false,
"is_valid": true,
"smoothed": true,
"sequence": 20,
"csi_fps_ema": 10.0,
"csi_fps_samples": 47
}
}
]
}
Field meanings:
| Field | Type | Meaning |
|---|---|---|
offset_us |
i64 | Smoothed local-vs-mesh clock offset in microseconds. Negative when this node is behind the leader. §A0.10 on the bench measured ~1.16 s boot delta between two C6 boards. |
is_leader |
bool | True when this node is the elected mesh leader (lowest EUI-64 in the cohort). |
is_valid |
bool | True when this node has heard a fresh leader beacon within the firmware's VALID_WINDOW_MS = 3 s freshness gate. |
smoothed |
bool | True once the firmware-side EMA filter has seeded (after ~8 beacons ≈ 0.8 s of follower mode). |
sequence |
u32 | High-water CSI sequence number stamped when this sync packet was emitted. Pair with the per-frame sequence field on incoming CSI to interpolate a mesh-aligned timestamp for any frame. |
csi_fps_ema |
f64 | Per-node EMA of the observed CSI frame rate. Bench typical ≈ 10 Hz. |
csi_fps_samples |
u32 | How many inter-frame deltas the EMA has seen. Treat values < 5 as "not yet trustworthy" and fall back to 20 Hz. |
staleness_ms |
u64 (optional) | Milliseconds since the host last received a sync packet from this node (iter 34). Fade UI badges after 5 000 ms; treat ≥ 9 000 ms as the same condition that the firmware's c6_sync_espnow_is_valid() reports as false. |
When sync is omitted entirely: the node isn't on the mesh (or
hasn't heard a peer yet). Non-ESP32 paths — multi-BSSID router scan,
synthetic-RSSI fallback, simulation — also omit sync. Existing
pre-iter-23 UI clients ignore the new field naturally because they
don't read it.
How to render this in a UI:
is_leader === true→ badge the node "Leader"is_valid === false→ grey out / "Sync lost"csi_fps_samples < 5→ label as "Calibrating" until ≥5 frames|offset_us|trend → render a jitter histogram to show the §A0.10 EMA suppression working live
How to recover a mesh-aligned timestamp for any CSI frame from this
node: take the frame's own sequence u32, subtract sync.sequence,
divide by sync.csi_fps_ema (or 20.0 if csi_fps_samples < 5),
multiply by 1 000 000 µs — that's the mesh delta from the sync emit
time. Use it to align multistatic frames from sibling boards.
Home Assistant + Matter integration
Full design + operator guide: docs/integrations/home-assistant.md (ADR-115).
30-second Mosquitto-add-on flow
-
Inside Home Assistant, install the Mosquitto broker add-on from the Add-on Store and start it.
-
In HA, Settings → Devices & Services → Add Integration → MQTT, point at the broker.
-
Start the sensing-server with MQTT:
docker run --rm --net=host ruvnet/wifi-densepose:0.7.0 \ --source esp32 --mqtt --mqtt-host <ha-host-ip> -
Within ~5 seconds HA auto-creates one device per RuView node with 21 entities: 11 raw signals (presence, person count, HR, BR, motion, fall, RSSI, zones, pose, …) plus 10 semantic primitives (someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room-transition).
Privacy mode for healthcare / AAL
sensing-server --mqtt --mqtt-host <broker> --mqtt-tls --privacy-mode
--privacy-mode strips heart rate, breathing rate, and pose keypoints from MQTT and Matter — they never reach the wire. Semantic primitives stay published because they're inferred states server-side, not biometric values. This is the architectural win that makes ADR-115 healthcare- and enterprise-deployable.
Matter Bridge (Apple Home / Google Home / Alexa / SmartThings)
sensing-server --matter --matter-setup-file /var/run/ruview-matter.txt
Open /var/run/ruview-matter.txt for the Matter pairing QR / 11-digit setup code. Scan it from Apple Home / Google Home / your HA Matter integration. RuView appears as a Bridged Device with one occupancy endpoint per node + per zone, plus a momentary switch for fall events.
Detailed entity reference, blueprint catalog, troubleshooting recipe matrix: see docs/integrations/home-assistant.md.
Web UI
The built-in Three.js UI is served at http://localhost:3000/ui/ (Docker) or the configured HTTP port.
Two visualization modes:
| Page | URL | Purpose |
|---|---|---|
| Dashboard | /ui/index.html |
Tabbed monitoring dashboard with body model, signal heatmap, phase plot, vital signs |
| Observatory | /ui/observatory.html |
Immersive 3D room visualization with cinematic lighting and wireframe figures |
Dashboard panels:
| Panel | Description |
|---|---|
| 3D Body View | Rotatable wireframe skeleton with 17 COCO keypoints |
| Signal Heatmap | 56 subcarriers color-coded by amplitude |
| Phase Plot | Per-subcarrier phase values over time |
| Doppler Bars | Motion band power indicators |
| Vital Signs | Live breathing rate (BPM) and heart rate (BPM) |
| Dashboard | System stats, throughput, connected WebSocket clients |
Both UIs update in real-time via WebSocket and auto-detect the sensing server on the same origin.
Dense Point Cloud (Camera + WiFi CSI Fusion)
RuView can generate real-time 3D point clouds by fusing camera depth estimation with WiFi CSI spatial sensing. This creates a spatial model of the environment that updates in real-time.
Setup
# Build the pointcloud binary
cd v2
cargo build --release -p wifi-densepose-pointcloud
# Start the server (auto-detects camera + CSI). Loopback-only by default.
./target/release/ruview-pointcloud serve --bind 127.0.0.1:9880
Open http://localhost:9880 for the interactive Three.js 3D viewer.
Security note. The server exposes live camera, skeleton, vitals, and occupancy over HTTP. The
--bindflag defaults to127.0.0.1:9880(loopback-only). Exposing on0.0.0.0or a LAN IP is opt-in — the server logs a warning when it does, but there is no auth/TLS layer. Put a reverse proxy in front if you need remote access.
Brain URL. Observations are POSTed to
http://127.0.0.1:9876by default. Override via theRUVIEW_BRAIN_URLenvironment variable or the--brain <url>flag onserve/train.
Sensors
| Sensor | Auto-detected | Data |
|---|---|---|
Camera (/dev/video0) |
Yes (Linux UVC) | RGB frames → MiDaS depth → 3D points |
| ESP32 CSI (UDP:3333) | Yes (if provisioned) | ADR-018 binary → occupancy + pose + vitals |
| MiDaS depth server (port 9885) | Optional | GPU-accelerated neural depth estimation |
Commands
| Command | Description |
|---|---|
ruview-pointcloud serve --bind 127.0.0.1:9880 |
Start HTTP server + Three.js viewer (loopback-only by default) |
ruview-pointcloud demo |
Generate synthetic point cloud (no hardware needed) |
ruview-pointcloud capture --output room.ply |
Capture single frame to PLY file |
ruview-pointcloud cameras |
List available cameras |
ruview-pointcloud train --data-dir ./data [--brain URL] |
Depth calibration + occupancy training (writes under canonicalized data-dir; refuses .. traversal) |
ruview-pointcloud csi-test --count 100 |
Send test CSI frames (no ESP32 needed) |
ruview-pointcloud fingerprint <name> [--seconds 5] |
Record a named CSI room fingerprint for later matching |
Pipeline Components
- ADR-018 Parser — Decodes ESP32 CSI binary frames from UDP (magic
0xC5110001raw CSI and0xC5110006feature state), extracts I/Q subcarrier amplitudes and phases. Lives inparser.rs; unit-tested against hand-rolled test vectors. - Pose (stub) — 17 COCO keypoint layout generated by
heuristic_pose_from_amplitudefrom CSI amplitude energy. This is not the trained WiFlow model — it is a placeholder so the viewer has a skeleton to render. Wiring to real Candle/ONNX inference from thewifi-densepose-nncrate is a planned follow-up. - Vital Signs — Breathing rate from CSI phase analysis (peak counting on stable subcarrier)
- Motion Detection — CSI amplitude variance over 20 frames, triggers adaptive capture
- RF Tomography — Backprojection from per-node RSSI to 8×8×4 occupancy grid
- Camera Depth — MiDaS monocular depth (GPU) with luminance+edge fallback
- Sensor Fusion — Voxel-grid merging of camera depth + CSI occupancy
- Brain Bridge — Stores spatial observations in the ruOS brain every 60 seconds
API Endpoints
| Endpoint | Method | Returns |
|---|---|---|
/health |
GET | {"status": "ok"} |
/api/status |
GET | Camera, CSI, pipeline state, vitals, motion |
/api/cloud |
GET | Point cloud (up to 1000 points) + pipeline data |
/api/splats |
GET | Gaussian splats for Three.js rendering |
/ |
GET | Interactive Three.js 3D viewer |
Training
The training pipeline calibrates depth estimation and occupancy detection:
ruview-pointcloud train --data-dir ~/.local/share/ruview/training --brain http://127.0.0.1:9876
This captures frames, runs depth calibration (grid search over scale/offset/gamma), trains occupancy thresholds, exports DPO preference pairs, and submits results to the ruOS brain.
Output Formats
- PLY — Standard 3D point cloud (ASCII, with RGB color)
- Gaussian Splats — JSON format for Three.js rendering
- Brain Memories — Spatial observations stored as
spatial-observation,spatial-motion,spatial-vitals
Deep Room Scan
Capture a high-quality 3D model of the room:
# Stop the live server first (frees the camera)
# Then capture 20 frames and process with MiDaS
ruview-pointcloud capture --frames 20 --output room_model.ply
Result: 40,000+ voxels at 5cm resolution, 12,000+ Gaussian splats.
ESP32 Provisioning for CSI
To send CSI data to the pointcloud server:
python3 firmware/esp32-csi-node/provision.py \
--port /dev/ttyACM0 \
--ssid "YourWiFi" --password "YourPassword" \
--target-ip 192.168.1.123 --target-port 3333 \
--node-id 1
Vital Sign Detection
The system extracts breathing rate and heart rate from CSI signal fluctuations using FFT peak detection.
| Sign | Frequency Band | Range | Method |
|---|---|---|---|
| Breathing | 0.1-0.5 Hz | 6-30 BPM | Bandpass filter + FFT peak |
| Heart rate | 0.8-2.0 Hz | 40-120 BPM | Bandpass filter + FFT peak |
Requirements:
- CSI-capable hardware (ESP32-S3 or research NIC) for accurate readings
- Subject within ~3-5 meters of an access point (up to ~8 m with multistatic mesh)
- Relatively stationary subject (large movements mask vital sign oscillations)
Signal smoothing: Vital sign estimates pass through a three-stage smoothing pipeline (ADR-048): outlier rejection (±8 BPM HR, ±2 BPM BR per frame), 21-frame trimmed mean, and EMA with α=0.02. This produces stable readings that hold steady for 5-10+ seconds instead of jumping every frame. See Adaptive Classifier for details.
Simulated mode produces synthetic vital sign data for testing.
CLI Reference
The Rust sensing server binary accepts the following flags:
| Flag | Default | Description |
|---|---|---|
--source |
auto |
Data source: auto, simulate, wifi, esp32 |
--http-port |
8080 |
HTTP port for REST API and UI |
--ws-port |
8765 |
WebSocket port |
--udp-port |
5005 |
UDP port for ESP32 CSI frames |
--ui-path |
(none) | Path to UI static files directory |
--tick-ms |
50 |
Simulated frame interval (milliseconds) |
--benchmark |
off | Run vital sign benchmark (1000 frames) and exit |
--train |
off | Train a model from dataset |
--dataset |
(none) | Path to dataset directory (MM-Fi or Wi-Pose) |
--dataset-type |
mmfi |
Dataset format: mmfi or wipose |
--epochs |
100 |
Training epochs |
--export-rvf |
(none) | Export RVF model container and exit |
--save-rvf |
(none) | Save model state to RVF on shutdown |
--model |
(none) | Load a trained .rvf model for inference |
--load-rvf |
(none) | Load model config from RVF container |
--progressive |
off | Enable progressive 3-layer model loading |
Common Invocations
# Simulated mode with UI (development)
./target/release/sensing-server --source simulate --http-port 3000 --ws-port 3001 --ui-path ../../ui
# ESP32 hardware mode
./target/release/sensing-server --source esp32 --udp-port 5005
# Windows WiFi RSSI
./target/release/sensing-server --source wifi --tick-ms 500
# Run benchmark
./target/release/sensing-server --benchmark
# Train and export model
./target/release/sensing-server --train --dataset data/ --epochs 100 --save-rvf model.rvf
# Load trained model with progressive loading
./target/release/sensing-server --model model.rvf --progressive
Observatory Visualization
The Observatory is an immersive Three.js visualization that renders WiFi sensing data as a cinematic 3D experience. It features room-scale props, wireframe human figures, WiFi signal animations, and a live data HUD.
URL: http://localhost:3000/ui/observatory.html
Features:
| Feature | Description |
|---|---|
| Room scene | Furniture, walls, floor with emissive materials and 6-point lighting |
| Wireframe figures | Up to 4 human skeletons with joint pulsation synced to breathing |
| Signal field | Volumetric WiFi wave visualization |
| Live HUD | Heart rate, breathing rate, confidence, RSSI, motion level |
| Auto-detect | Automatically connects to live ESP32 data when sensing server is running |
| Scenario cycling | 6 preset scenarios with smooth transitions (demo mode) |
Keyboard shortcuts:
| Key | Action |
|---|---|
1-6 |
Switch scenario |
A |
Toggle auto-cycle |
P |
Pause/resume |
S |
Open settings |
R |
Reset camera |
Live data auto-detect: When served by the sensing server, the Observatory probes /health on the same origin and automatically connects via WebSocket. The HUD badge switches from DEMO to LIVE. No configuration needed.
Loading the Pretrained Model from Hugging Face
A pretrained CSI encoder + presence-detection head is published on Hugging Face at ruvnet/wifi-densepose-pretrained. It was trained on 60,630 frames / 610,615 contrastive triplets (12.2M steps, final loss 0.065) and reports 100% presence accuracy and ~164k embeddings/sec on an Apple M4 Pro.
What it ships (and what it does not):
| Capability | Status |
|---|---|
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
| 128-dim CSI embeddings (re-ID, similarity, downstream training) | ✅ Trained encoder |
| Single-person breathing / heart-rate | ⚠️ Server still uses heuristic DSP — model does not replace this yet |
| 17-keypoint full-body pose | 🔬 No keypoint weights shipped yet — pose pipeline runs but without a learned head |
Download
pip install huggingface_hub
huggingface-cli download ruvnet/wifi-densepose-pretrained \
--local-dir models/wifi-densepose-pretrained
The download yields a small set of files (the .rvf.jsonl is the canonical container the sensing server reads):
models/wifi-densepose-pretrained/
model.rvf.jsonl # RVF container (encoder + presence head + lora)
model.safetensors # 48 KB — same encoder weights, safetensors format
model-q4.bin # 8 KB — recommended quantization for edge
presence-head.json # presence classifier head
config.json # sona-lora rank=8 alpha=16, target encoder + task_heads
Using the weights
The HF artifact is in JSONL RVF format (one JSON object per line: metadata, encoder, lora). What you can do with it today:
| Consumer | Format it reads | Status |
|---|---|---|
| Python / PyTorch training pipeline | model.safetensors |
✅ Works — load with safetensors.torch.load_file |
| RVF JSONL inspection / re-export | model.rvf.jsonl |
✅ Works — plain JSONL, parse line-by-line |
Sensing-server --model <PATH> flag |
binary RVF (RVFS magic) |
⚠️ Does not accept the JSONL file yet — see gap below |
Known gap (tracked): v2/crates/wifi-densepose-sensing-server/src/rvf_container.rs only parses the binary RVF segment format (magic 0x52564653). Pointing --model at model.rvf.jsonl causes the progressive loader to error with invalid magic at offset 0: expected 0x52564653, got 0x7974227B (0x7974227B is the ASCII bytes {"ty… from the JSONL header), and the live pipeline degrades to null output rather than falling back to heuristic mode. Until a JSONL adapter lands (or the model is re-published as binary RVF), run the sensing-server without --model and consume the HF weights from Python or the training pipeline.
# Works today — Python side (training, evaluation, embedding extraction):
python -c "
from safetensors.torch import load_file
state = load_file('models/wifi-densepose-pretrained/model.safetensors')
print({k: tuple(v.shape) for k, v in state.items()})
"
# Sensing server — run heuristic for now:
cargo run -p wifi-densepose-sensing-server --release -- \
--source esp32 --udp-port 5005 --http-port 3000
See RVF Model Containers for the binary format the loader expects, and Training a Model for using the encoder as a starting point for environment-specific fine-tuning.
Adaptive Classifier
The adaptive classifier (ADR-048) learns your environment's specific WiFi signal patterns from labeled recordings. It replaces static threshold-based classification with a trained logistic regression model that uses 15 features (7 server-computed + 8 subcarrier-derived statistics).
Signal Smoothing Pipeline
All CSI-derived metrics pass through a three-stage pipeline before reaching the UI:
| Stage | What It Does | Key Parameters |
|---|---|---|
| Adaptive baseline | Learns quiet-room noise floor, subtracts drift | α=0.003, 50-frame warm-up |
| EMA + median filter | Smooths motion score and vital signs | Motion α=0.15; Vitals: 21-frame trimmed mean, α=0.02 |
| Hysteresis debounce | Prevents rapid state flickering | 4 frames (~0.4s) required for state transition |
Vital signs use additional stabilization:
| Parameter | Value | Effect |
|---|---|---|
| HR dead-band | ±2 BPM | Prevents micro-drift |
| BR dead-band | ±0.5 BPM | Prevents micro-drift |
| HR max jump | 8 BPM/frame | Rejects noise spikes |
| BR max jump | 2 BPM/frame | Rejects noise spikes |
Recording Training Data
Record labeled CSI sessions while performing distinct activities. Each recording captures full sensing frames (features + raw subcarrier amplitudes) at ~10-25 FPS.
# 1. Record empty room (leave the room for 30 seconds)
curl -X POST http://localhost:3000/api/v1/recording/start \
-H "Content-Type: application/json" -d '{"id":"train_empty_room"}'
# ... wait 30 seconds ...
curl -X POST http://localhost:3000/api/v1/recording/stop
# 2. Record sitting still (sit near ESP32 for 30 seconds)
curl -X POST http://localhost:3000/api/v1/recording/start \
-H "Content-Type: application/json" -d '{"id":"train_sitting_still"}'
# ... wait 30 seconds ...
curl -X POST http://localhost:3000/api/v1/recording/stop
# 3. Record walking (walk around the room for 30 seconds)
curl -X POST http://localhost:3000/api/v1/recording/start \
-H "Content-Type: application/json" -d '{"id":"train_walking"}'
# ... wait 30 seconds ...
curl -X POST http://localhost:3000/api/v1/recording/stop
# 4. Record active movement (jumping jacks, arm waving for 30 seconds)
curl -X POST http://localhost:3000/api/v1/recording/start \
-H "Content-Type: application/json" -d '{"id":"train_active"}'
# ... wait 30 seconds ...
curl -X POST http://localhost:3000/api/v1/recording/stop
Recordings are saved as JSONL files in data/recordings/. Filenames must start with train_ and contain a class keyword:
| Filename pattern | Class |
|---|---|
*empty* or *absent* |
absent |
*still* or *sitting* |
present_still |
*walking* or *moving* |
present_moving |
*active* or *exercise* |
active |
Training the Model
Train the adaptive classifier from your labeled recordings:
curl -X POST http://localhost:3000/api/v1/adaptive/train
The server trains a multiclass logistic regression on 15 features using mini-batch SGD (200 epochs). Training completes in under 1 second for typical recording sets. The trained model is saved to data/adaptive_model.json and automatically loaded on server restart.
Check model status:
curl http://localhost:3000/api/v1/adaptive/status
Unload the model (revert to threshold-based classification):
curl -X POST http://localhost:3000/api/v1/adaptive/unload
Using the Trained Model
Once trained, the adaptive model runs automatically:
- Each CSI frame is classified using the learned weights instead of static thresholds
- Model confidence is blended with smoothed threshold confidence (70/30 split)
- The model persists across server restarts (loaded from
data/adaptive_model.json)
Tips for better accuracy:
- Record with clearly distinct activities (actually leave the room for "empty")
- Record 30-60 seconds per activity (more data = better model)
- Re-record and retrain if you move the ESP32 or rearrange the room
- The model is environment-specific — retrain when the physical setup changes
Adaptive Classifier API
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/adaptive/train |
Train from train_* recordings |
GET |
/api/v1/adaptive/status |
Model status, accuracy, class stats |
POST |
/api/v1/adaptive/unload |
Unload model, revert to thresholds |
POST |
/api/v1/recording/start |
Start recording CSI frames |
POST |
/api/v1/recording/stop |
Stop recording |
GET |
/api/v1/recording/list |
List recordings |
Training a Model
The training pipeline is implemented in pure Rust (7,832 lines, zero external ML dependencies).
Step 1: Obtain a Dataset
The system supports two public WiFi CSI datasets:
| Dataset | Source | Format | Subjects | Environments | Download |
|---|---|---|---|---|---|
| MM-Fi | NeurIPS 2023 | .npy |
40 | 4 rooms | GitHub repo (Google Drive / Baidu links inside) |
| Wi-Pose | Entropy 2023 | .mat |
12 | 1 room | GitHub repo (Google Drive / Baidu links inside) |
Download the dataset files and place them in a data/ directory.
Step 2: Train
# From source
./target/release/sensing-server --train --dataset data/ --dataset-type mmfi --epochs 100 --save-rvf model.rvf
# Via Docker (mount your data directory)
# Note: Training mode requires overriding the default entrypoint
docker run --rm \
-v $(pwd)/data:/data \
-v $(pwd)/output:/output \
--entrypoint /app/sensing-server \
ruvnet/wifi-densepose:latest \
--train --dataset /data --epochs 100 --export-rvf /output/model.rvf
The pipeline runs 10 phases:
- Dataset loading (MM-Fi
.npyor Wi-Pose.mat) - Hardware normalization (Intel 5300 / Atheros / ESP32 -> canonical 56 subcarriers)
- Subcarrier resampling (114->56 or 30->56 via Catmull-Rom interpolation)
- Graph transformer construction (17 COCO keypoints, 16 bone edges)
- Cross-attention training (CSI features -> body pose)
- Domain-adversarial training (MERIDIAN: gradient reversal + virtual domain augmentation)
- Composite loss optimization (MSE + CE + UV + temporal + bone + symmetry)
- SONA adaptation (micro-LoRA + EWC++)
- Sparse inference optimization (hot/cold neuron partitioning)
- RVF model packaging
Step 3: Use the Trained Model
./target/release/sensing-server --model model.rvf --progressive --source esp32
Progressive loading enables instant startup (Layer A loads in <5ms with basic inference), with full model loading in the background.
Cross-Environment Adaptation (MERIDIAN)
Models trained in one room typically lose 40-70% accuracy in a new room due to different WiFi multipath patterns. The MERIDIAN system (ADR-027) solves this with a 10-second automatic calibration:
- Deploy the trained model in a new room
- Collect ~200 unlabeled CSI frames (10 seconds at 20 Hz)
- The system automatically generates environment-specific LoRA weights via contrastive test-time training
- No labels, no retraining, no user intervention
MERIDIAN components (all pure Rust, +12K parameters):
| Component | What it does |
|---|---|
| Hardware Normalizer | Resamples any WiFi chipset to canonical 56 subcarriers |
| Domain Factorizer | Separates pose-relevant from room-specific features |
| Geometry Encoder | Encodes AP positions (FiLM conditioning with DeepSets) |
| Virtual Augmentor | Generates synthetic environments for robust training |
| Rapid Adaptation | 10-second unsupervised calibration via contrastive TTT |
See ADR-027 for the full design.
CRV Signal-Line Protocol
The CRV (Coordinate Remote Viewing) signal-line protocol (ADR-033) maps a 6-stage cognitive sensing methodology onto WiFi CSI processing. This enables structured anomaly classification and multi-person disambiguation.
| Stage | CRV Term | WiFi Mapping |
|---|---|---|
| I | Gestalt | Detrended autocorrelation → periodicity / chaos / transient classification |
| II | Sensory | 6-modality CSI feature encoding (texture, temperature, luminosity, etc.) |
| III | Topology | AP mesh topology graph with link quality weights |
| IV | Coherence | Phase phasor coherence gate (Accept/PredictOnly/Reject/Recalibrate) |
| V | Interrogation | Person-specific signal extraction with targeted subcarrier selection |
| VI | Partition | Multi-person partition with cross-room convergence scoring |
# Enable CRV in your Cargo.toml
cargo add wifi-densepose-ruvector --features crv
See ADR-033 for the full design.
RVF Model Containers
The RuVector Format (RVF) packages a trained model into a single self-contained binary file.
Export
./target/release/sensing-server --export-rvf model.rvf
Load
./target/release/sensing-server --model model.rvf --progressive
Contents
An RVF file contains: model weights, HNSW vector index, quantization codebooks, SONA adaptation profiles, Ed25519 training proof, and vital sign filter parameters.
Deployment Targets
| Target | Quantization | Size | Load Time |
|---|---|---|---|
| ESP32 / IoT | int4 | ~0.7 MB | <5ms |
| Mobile / WASM | int8 | ~6-10 MB | ~200-500ms |
| Field (WiFi-Mat) | fp16 | ~62 MB | ~2s |
| Server / Cloud | f32 | ~50+ MB | ~3s |
Hardware Setup
Supported targets
| Target | Use case | Source target flag | Notes |
|---|---|---|---|
| ESP32-S3 (default) | Production CSI mesh, 17-keypoint pose | idf.py set-target esp32s3 |
Dual-core 240 MHz, PSRAM, native USB-OTG, DVP camera path |
| ESP32-C6 (ADR-110) | Wi-Fi 6 / 802.15.4 research, battery seed nodes | idf.py set-target esp32c6 |
Single-core 160 MHz, no PSRAM, 802.11ax HE PHY, 802.15.4 (Thread/Zigbee), LP-core hibernation ~5 µA |
The same firmware/esp32-csi-node source tree builds for both. ESP-IDF picks up sdkconfig.defaults.esp32c6 automatically when the target is set to esp32c6; otherwise it uses sdkconfig.defaults (S3). All C6-only modules are #ifdef-gated, so the S3 build is byte-identical to today.
ESP32-S3 Mesh
A 3-6 node ESP32-S3 mesh provides full CSI at 20 Hz. Total cost: ~$54 for a 3-node setup.
What you need:
- 3-6x ESP32-S3 development boards (~$8 each)
- A WiFi router (the CSI source)
- A computer running the sensing server (aggregator)
Flashing firmware:
Pre-built binaries are available at Releases:
| Release | What It Includes | Tag |
|---|---|---|
| v0.7.0 | Latest — ADR-110 firmware-side substrate closed. Adds ESP-NOW mesh substrate with quantified ≤100 µs alignment (104.1 µs smoothed stdev, 3.95× suppression, 99.56 % cross-board match measured live), 32-byte sync-packet UDP emission with operator-tunable cadence, ADR-018 byte 19 bit 4 wire-fix sourced from working ESP-NOW path, Python SyncPacketParser stub for host wiring (WITNESS-LOG-110 §A0.7-§A0.13) | v0.7.0-esp32 |
| v0.6.9 | Sync-packet UDP emission, CONFIG_C6_SYNC_EVERY_N_FRAMES tunable cadence |
v0.6.9-esp32 |
| v0.6.8 | ESP-NOW EMA-smoothed cross-board offset (3.95× suppression, 104 µs stdev) | v0.6.8-esp32 |
| v0.6.7 | Real LP-core motion-gate RISC-V program (B4 code path complete) + Wi-Fi 6 soft-AP with TWT Responder for two-board iTWT benches (B1/B2 unblock) | v0.6.7-esp32 |
| v0.5.0 | Stable (S3 mesh, recommended) — mmWave sensor fusion (MR60BHA2/LD2410 auto-detect), 48-byte fused vitals, all v0.4.3.1 fixes | v0.5.0-esp32 |
| v0.4.3.1 | Fall detection fix (#263), 4MB flash (#265), watchdog fix (#266) | v0.4.3.1-esp32 |
| v0.4.1 | CSI build fix, compile guard, AMOLED display, edge intelligence (ADR-057) | v0.4.1-esp32 |
| v0.3.0-alpha | Alpha — adds on-device edge intelligence (ADR-039) | v0.3.0-alpha-esp32 |
| v0.2.0 | Raw CSI streaming, TDM, channel hopping, QUIC mesh | v0.2.0-esp32 |
Important: Always use v0.4.3.1 or later. Earlier versions have false fall detection alerts (v0.4.2 and below) and CSI disabled in the build config (pre-v0.4.1).
# Flash an ESP32-S3 with 8MB flash (most boards)
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write-flash --flash-mode dio --flash-size 8MB --flash-freq 80m \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node.bin
4MB flash boards (e.g. ESP32-S3 SuperMini 4MB): download esp32-csi-node-s3-4mb.bin + partition-table-s3-4mb.bin from the v0.6.7 release (882 KB binary, 52 % partition slack) and use --flash-size 4MB:
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write-flash --flash-mode dio --flash-size 4MB --flash-freq 80m \
0x0 bootloader.bin 0x8000 partition-table-4mb.bin \
0xF000 ota_data_initial.bin 0x20000 esp32-csi-node-4mb.bin
Provisioning:
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourWiFi" --password "YourPassword" --target-ip 192.168.1.20
Replace 192.168.1.20 with the IP of the machine running the sensing server.
Mesh key provisioning (secure mode):
For multistatic mesh deployments with authenticated beacons (ADR-032), provision a shared mesh key:
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourWiFi" --password "YourPassword" --target-ip 192.168.1.20 \
--mesh-key "$(openssl rand -hex 32)"
All nodes in a mesh must share the same 256-bit mesh key for HMAC-SHA256 beacon authentication. The key is stored in ESP32 NVS flash and zeroed on firmware erase.
ESP32-C6 (Wi-Fi 6 + 802.15.4 research target — ADR-110)
The C6 build adds four capabilities to the existing csi-node firmware, all opt-in via idf.py menuconfig → ESP32-C6 capabilities (ADR-110):
| Capability | Kconfig | What it does |
|---|---|---|
| Wi-Fi 6 HE-LTF tagging | CSI_FRAME_HE_TAGGING (default on) |
Each ADR-018 frame's previously-reserved bytes 18-19 now carry PPDU type (HT / HE-SU / HE-MU / HE-TB) + bandwidth flags. Magic stays 0xC5110001 — old aggregators see zeros and ignore. |
| 802.15.4 mesh time-sync | C6_TIMESYNC_ENABLE (default on, channel 15) |
Beacon-based cross-node clock alignment over the 802.15.4 radio. Frees the WiFi channel from coordination traffic — solves the ADR-029/030 multistatic clock-sync problem. |
| TWT (Target Wake Time) | C6_TWT_ENABLE (default on, 10 ms wake interval) |
After WiFi connect, negotiates an individual TWT agreement with the AP for deterministic CSI cadence. Graceful NACK fallback if the AP doesn't support 11ax TWT. |
| LP-core wake-on-motion hibernation | C6_LP_CORE_ENABLE (default off) |
Always-on motion gate on the LP RISC-V core; HP core stays in deep sleep until the configured GPIO wakes it. Targets ~5 µA for battery-powered Cognitum Seed nodes. |
Build + flash:
cd firmware/esp32-csi-node
idf.py set-target esp32c6
idf.py build # ~1.0 MB binary, 46% partition slack on 4 MB flash
idf.py -p COM6 flash
# Then provision the same way as S3 (provision.py works for both targets):
python provision.py --port COM6 --ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
Verifying the C6 modules came up — idf.py -p COM6 monitor should show:
I (353) main: ESP32-C6 CSI Node (ADR-018 / ADR-110) — v0.6.7 — Node ID: 1
I (413) c6_ts: init done: channel=15 EUI=<your-EUI64> leader=yes(candidate)
I (463) wifi: mac_version:HAL_MAC_ESP32AX_761 ← 802.11ax MAC firmware loaded
The c6_ts: init done line confirms the 802.15.4 stack is up; if TWT succeeds you'll also see an iTWT setup event received from AP line after the WiFi connect completes.
Multi-room time-aligned multistatic capture (preview):
Flash two or more C6 boards, leave them on the same 802.15.4 channel (default 15). One will elect itself leader (lowest EUI-64) and broadcast TS_BEACON frames every 100 ms; the others compute and apply offsets. Each CSI frame from a follower carries a c6_timesync_get_epoch_us() wall-clock estimate aligned to within ±100 µs of the leader's monotonic time. Target use case: ADR-029/030 multistatic fusion without burning WiFi airtime on coordination.
Battery seed-node mode (v0.6.7 — real LP-core program):
# Enable LP-core hibernation in menuconfig:
# ESP32-C6 capabilities (ADR-110) → Enable LP-core wake-on-motion hibernation
# → LP-core wake GPIO (default 4 — connect a PIR or accelerometer INT line here)
# → LP-core poll period (default 10 ms)
# → LP-core debounce sample count (default 3 consecutive matches)
idf.py menuconfig
idf.py build flash
When enabled, the C6 LP RISC-V coprocessor runs a real polling program
(firmware/esp32-csi-node/main/lp_core/main.c) that polls the wake GPIO at
the configured cadence, debounces N consecutive matching reads, and wakes the
HP core via ulp_lp_core_wakeup_main_processor(). esp_sleep_get_wakeup_cause()
returns ESP_SLEEP_WAKEUP_ULP, and c6_lp_core_motion_count() /
c6_lp_core_poll_count() expose the LP-side counters for the witness harness.
Target standby current ~5 µA (datasheet; pending INA measurement).
Two-board iTWT bench (v0.6.7 — soft-AP HE/TWT, no router required):
Pair two C6 boards — one acts as the iTWT-capable AP, the other as the STA that negotiates and benchmarks the TWT agreement.
# Board #1 (AP role): append to sdkconfig.defaults.esp32c6:
CONFIG_C6_SOFTAP_HE_ENABLE=y
CONFIG_C6_SOFTAP_HE_SSID="ruview-c6-twt"
CONFIG_C6_SOFTAP_HE_PSK="ruviewtwt"
CONFIG_C6_SOFTAP_HE_CHANNEL=6
idf.py set-target esp32c6 && idf.py build && idf.py -p COM6 flash
Board #1 boots in WIFI_MODE_APSTA, advertising HE capabilities and TWT
Responder=1 on channel 6. Board #2 provisions to associate with that SSID:
python firmware/esp32-csi-node/provision.py --port COM9 \
--ssid "ruview-c6-twt" --password "ruviewtwt" --target-ip 192.168.1.20
Board #2 runs the existing c6_twt_setup_default() on connect and now
negotiates a real iTWT agreement against the cooperative AP — the
iTWT setup queued: wake_interval=10000 µs log line should be followed by an
iTWT setup event received from AP instead of the INVALID_ARG graceful
fallback that fired against the bench's 11n-only ruv.net AP.
NVS overrides for AP role (namespace ruview): softap_ssid, softap_psk,
softap_chan — provision once and the values survive firmware updates.
What's NOT on the C6 build (vs S3 production): no AMOLED display (ADR-045 needs 8 MB + LCD touch driver), no WASM3 (ADR-040 needs PSRAM), no Seeed mmWave fusion (separate board). The C6 is a research/seed target, not a drop-in replacement for the S3 production node.
TDM slot assignment:
Each node in a multistatic mesh needs a unique TDM slot ID (0-based):
# Node 0 (slot 0) — first transmitter
python firmware/esp32-csi-node/provision.py --port COM7 --tdm-slot 0 --tdm-total 3
# Node 1 (slot 1)
python firmware/esp32-csi-node/provision.py --port COM8 --tdm-slot 1 --tdm-total 3
# Node 2 (slot 2)
python firmware/esp32-csi-node/provision.py --port COM9 --tdm-slot 2 --tdm-total 3
Edge Intelligence (v0.3.0-alpha, ADR-039):
The v0.3.0-alpha firmware adds on-device signal processing that runs directly on the ESP32-S3 — no host PC needed for basic presence and vital signs. Edge processing is disabled by default for full backward compatibility.
| Tier | What It Does | Extra RAM |
|---|---|---|
| 0 | Disabled (default) — streams raw CSI to the aggregator | 0 KB |
| 1 | Phase unwrapping, running statistics, top-K subcarrier selection, delta compression | ~30 KB |
| 2 | Everything in Tier 1, plus presence detection, breathing/heart rate, motion scoring, fall detection | ~33 KB |
Enable via NVS (no reflash needed):
# Enable Tier 2 (full vitals) on an already-flashed node
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourWiFi" --password "YourPassword" --target-ip 192.168.1.20 \
--edge-tier 2
Key NVS settings for edge processing:
| NVS Key | Default | What It Controls |
|---|---|---|
edge_tier |
0 | Processing tier (0=off, 1=stats, 2=vitals) |
pres_thresh |
50 | Sensitivity for presence detection (lower = more sensitive) |
fall_thresh |
15000 | Fall detection threshold in milli-units (15000 = 15.0 rad/s²). Normal walking is 2-5, real falls are 20+. Raise to reduce false positives. |
vital_win |
300 | How many frames of phase history to keep for breathing/HR extraction |
vital_int |
1000 | How often to send a vitals packet, in milliseconds |
subk_count |
32 | Number of best subcarriers to keep (out of 56) |
When Tier 2 is active, the node sends a 32-byte vitals packet at 1 Hz (configurable) containing presence state, motion score, breathing BPM, heart rate BPM, confidence values, fall flag, and occupancy estimate. The packet uses magic 0xC5110002 and is sent to the same aggregator IP and port as raw CSI frames.
Binary size: 990 KB (8MB flash, 52% free) or 773 KB (4MB flash). v0.5.0 adds mmWave sensor fusion (~12 KB larger).
Alpha notice: Vital sign estimation uses heuristic BPM extraction. Accuracy is best with stationary subjects in controlled environments. Not for medical use.
Start the aggregator:
# From source
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
# Docker (use CSI_SOURCE environment variable)
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
See ADR-018, ADR-029, and Tutorial #34.
Intel 5300 / Atheros NIC
These research NICs provide full CSI on Linux with firmware/driver modifications.
| NIC | Driver | Platform | Setup |
|---|---|---|---|
| Intel 5300 | iwl-csi |
Linux | Custom firmware, ~$15 used |
| Atheros AR9580 | ath9k patch |
Linux | Kernel patch, ~$20 used |
These are advanced setups. See the respective driver documentation for installation.
Camera-Free Pose Training
RuView can train a 17-keypoint COCO pose model without any camera by fusing 10 sensor signals from the ESP32 nodes and Cognitum Seed:
| Signal | Source | What it provides |
|---|---|---|
| PIR sensor | Seed GPIO 6 | Binary presence ground truth |
| BME280 temperature | Seed I2C | Occupancy proxy (temp rises with people) |
| BME280 humidity | Seed I2C | Breathing confirmation |
| Cross-node RSSI | 2x ESP32 | Rough XY position (triangulation) |
| Vitals stability | ESP32 DSP | Activity level (stable HR = stationary) |
| Temporal CSI patterns | ESP32 DSP | Walk (periodic), sit (stable), empty (flat) |
| kNN clusters | Seed vector store | Natural state groupings |
| Boundary fragility | Seed graph analysis | Regime changes (enter/exit) |
| Reed switch | Seed GPIO 5 | Door open/close events |
| Vibration sensor | Seed GPIO 13 | Footstep detection |
How It Works
The pipeline generates weak labels from sensor fusion, then trains in 5 phases:
- Multi-modal collection — Syncs CSI frames with Seed sensor events
- Weak label generation — RSSI triangulation for head position, subcarrier asymmetry for hands, vibration for feet
- 5-keypoint pose proxy — Trains head/hands/feet positions from fused signals
- 17-keypoint interpolation — Derives full COCO skeleton using bone length constraints
- Self-refinement — Bootstraps from confident predictions (3 rounds)
# With Cognitum Seed connected (all 10 signals):
node scripts/train-camera-free.js \
--data data/recordings/pretrain-*.csi.jsonl \
--seed-url https://169.254.42.1:8443 \
--seed-token "$SEED_TOKEN"
# Without Seed (CSI-only, 3 signals — still works):
node scripts/train-camera-free.js \
--data data/recordings/pretrain-*.csi.jsonl --no-seed
Output: 82.8 KB model (8 KB at 4-bit) with 17-keypoint predictions, 0 skeleton violations, LoRA per-node adapters, and EWC protection against forgetting.
See ADR-071 and the pretraining tutorial for the full walkthrough.
Camera-Supervised Pose Training (v0.7.0)
For significantly higher accuracy, use a webcam as a temporary teacher during training. The camera captures real 17-keypoint poses via MediaPipe, paired with simultaneous ESP32 CSI data. After training, the camera is no longer needed — the model runs on CSI only.
Result: 92.9% PCK@20 from a 5-minute collection session.
Requirements
- Python 3.9+ with
mediapipeandopencv-python(pip install mediapipe opencv-python) - ESP32-S3 node streaming CSI over UDP (port 5005)
- A webcam (laptop, USB, or Mac camera via Tailscale)
Step 1: Capture Camera + CSI Simultaneously
Run both scripts at the same time (in separate terminals):
# Terminal 1: Record ESP32 CSI
python scripts/record-csi-udp.py --duration 300
# Terminal 2: Capture camera keypoints
python scripts/collect-ground-truth.py --duration 300 --preview
Move around naturally in front of the camera for 5 minutes. The --preview flag shows a live skeleton overlay.
Step 2: Align and Train
# Align camera keypoints with CSI windows
node scripts/align-ground-truth.js \
--gt data/ground-truth/*.jsonl \
--csi data/recordings/csi-*.csi.jsonl
# Train (start with lite, scale up as you collect more data)
node scripts/train-wiflow-supervised.js \
--data data/paired/*.jsonl \
--scale lite \
--epochs 50
# Evaluate
node scripts/eval-wiflow.js \
--model models/wiflow-supervised/wiflow-v1.json \
--data data/paired/*.jsonl
Scale Presets
| Preset | Params | Training Time | Best For |
|---|---|---|---|
--scale lite |
189K | ~19 min | < 1,000 samples (5 min capture) |
--scale small |
474K | ~1 hr | 1K-10K samples |
--scale medium |
800K | ~2 hrs | 10K-50K samples |
--scale full |
7.7M | ~8 hrs | 50K+ samples (GPU recommended) |
See ADR-079 for the full design and optimization details.
Pre-Trained Models (No Training Required)
Pre-trained models are available on HuggingFace: https://huggingface.co/ruvnet/wifi-densepose-pretrained
Download and start sensing immediately — no datasets, no GPU, no training needed.
Quick Start with Pre-Trained Models
# Install huggingface CLI
pip install huggingface_hub
# Download all models
huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pretrained
# The models include:
# model.safetensors — 48 KB contrastive encoder
# model-q4.bin — 8 KB quantized (recommended)
# model-q2.bin — 4 KB ultra-compact (ESP32 edge)
# presence-head.json — presence detection head (100% accuracy)
# node-1.json — LoRA adapter for room 1
# node-2.json — LoRA adapter for room 2
What the Models Do
The pre-trained encoder converts 8-dim CSI feature vectors into 128-dim embeddings. These embeddings power all 17 sensing applications:
- Presence detection — 100% accuracy, never misses, never false alarms
- Environment fingerprinting — kNN search finds "states like this one"
- Anomaly detection — embeddings that don't match known clusters = anomaly
- Activity classification — different activities cluster in embedding space
- Room adaptation — swap LoRA adapters for different rooms without retraining
Retraining on Your Own Data
If you want to improve accuracy for your specific environment:
# Collect 2+ minutes of CSI from your ESP32
python scripts/collect-training-data.py --port 5006 --duration 120
# Retrain (uses ruvllm, no PyTorch needed)
node scripts/train-ruvllm.js --data data/recordings/*.csi.jsonl
# Benchmark your retrained model
node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
Health & Wellness Applications
WiFi sensing can monitor health metrics without any wearable or camera:
# Sleep quality monitoring (run overnight)
node scripts/sleep-monitor.js --port 5006 --bind 192.168.1.20
# Breathing disorder pre-screening
node scripts/apnea-detector.js --port 5006 --bind 192.168.1.20
# Stress detection via heart rate variability
node scripts/stress-monitor.js --port 5006 --bind 192.168.1.20
# Walking analysis + tremor detection
node scripts/gait-analyzer.js --port 5006 --bind 192.168.1.20
# Replay on recorded data (no live hardware needed)
node scripts/sleep-monitor.js --replay data/recordings/*.csi.jsonl
Note: These are pre-screening tools, not medical devices. Consult a healthcare professional for diagnosis.
ruvllm Training Pipeline
All training uses ruvllm — a Rust-native ML runtime. No Python, no PyTorch, no GPU drivers required. Runs on any machine with Node.js.
5-Phase Training
| Phase | What | Duration (M4 Pro) |
|---|---|---|
| Contrastive pretraining | Triplet + InfoNCE loss on CSI embeddings | ~5s |
| Task head training | Presence, activity, vitals classifiers | ~10s |
| LoRA refinement | Per-node room adaptation (rank-4) | ~4s |
| TurboQuant quantization | 2/4/8-bit with <0.5% quality loss | <1s |
| EWC consolidation | Prevent catastrophic forgetting | <1s |
# Basic training
node scripts/train-ruvllm.js --data data/recordings/pretrain-*.csi.jsonl
# Benchmark
node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
Quantization Options
| Bits | Size | Compression | Quality Loss | Use Case |
|---|---|---|---|---|
| fp32 | 48 KB | 1x | 0% | Development |
| 8-bit | 16 KB | 4x | <0.01% | Cognitum Seed inference |
| 4-bit | 8 KB | 8x | <0.1% | Recommended for deployment |
| 2-bit | 4 KB | 16x | <1% | ESP32-S3 SRAM (edge inference) |
Key Features
- SONA adaptation — Adapts to new rooms in <1ms without retraining
- LoRA adapters — 2,048 parameters per room, hot-swappable
- EWC protection — Learns new rooms without forgetting previous ones
- Deterministic — Same seed always produces same model (reproducible)
- 10x data augmentation — Temporal interpolation, noise injection, cross-node blending
Docker Compose (Multi-Service)
For production deployments with both Rust and Python services:
cd docker
docker compose up
This starts:
- Rust sensing server on ports 3000 (HTTP), 3001 (WS), 5005 (UDP)
- Python legacy server on ports 8080 (HTTP), 8765 (WS)
Testing Firmware Without Hardware (QEMU)
You can test the ESP32-S3 firmware on your computer without any physical hardware. The project uses QEMU — an emulator that pretends to be an ESP32-S3 chip, running the real firmware code inside a virtual machine on your PC.
This is useful when:
- You don't have an ESP32-S3 board yet
- You want to test firmware changes before flashing to real hardware
- You're running automated tests in CI/CD
- You want to simulate multiple ESP32 nodes talking to each other
What You Need
Required:
- Python 3.8+ (you probably already have this)
- QEMU with ESP32-S3 support (Espressif's fork)
Install QEMU (one-time setup):
# Easiest: use the automated installer (installs QEMU + Python tools)
bash scripts/install-qemu.sh
# Or check what's already installed:
bash scripts/install-qemu.sh --check
The installer detects your OS (Ubuntu, Fedora, macOS, etc.), installs build dependencies, clones Espressif's QEMU fork, builds it, and adds it to your PATH. It also installs the Python tools (esptool, pyyaml, esp-idf-nvs-partition-gen).
Manual installation (if you prefer)
# Build from source
git clone https://github.com/espressif/qemu.git
cd qemu
./configure --target-list=xtensa-softmmu --enable-slirp
make -j$(nproc)
export QEMU_PATH=$(pwd)/build/qemu-system-xtensa
# Install Python tools
pip install esptool pyyaml esp-idf-nvs-partition-gen
For multi-node testing (optional):
# Linux only — needed for virtual network bridges
sudo apt install socat bridge-utils iproute2
The qemu-cli.sh Command
All QEMU testing is available through a single command:
bash scripts/qemu-cli.sh <command>
| Command | What it does |
|---|---|
install |
Install QEMU (runs the installer above) |
test |
Run single-node firmware test |
swarm --preset smoke |
Quick 2-node swarm test |
swarm --preset standard |
Standard 3-node test |
mesh 3 |
Multi-node mesh test |
chaos |
Fault injection resilience test |
fuzz --duration 60 |
Run fuzz testing |
status |
Show what's installed and ready |
help |
Show all commands |
Your First Test Run
The simplest way to test the firmware:
# Using the CLI:
bash scripts/qemu-cli.sh test
# Or directly:
bash scripts/qemu-esp32s3-test.sh
What happens behind the scenes:
- The firmware is compiled with a "mock CSI" mode — instead of reading real WiFi signals, it generates synthetic test data that mimics real people walking, falling, or breathing
- The compiled firmware is loaded into QEMU, which boots it like a real ESP32-S3
- The emulator's serial output (what you'd see on a USB cable) is captured
- A validation script checks the output for expected behavior and errors
If you already built the firmware and want to skip rebuilding:
SKIP_BUILD=1 bash scripts/qemu-esp32s3-test.sh
To give it more time (useful on slower machines):
QEMU_TIMEOUT=120 bash scripts/qemu-esp32s3-test.sh
Understanding the Test Output
The test runs 16 checks on the firmware's output. Here's what a successful run looks like:
=== QEMU ESP32-S3 Firmware Test (ADR-061) ===
[PASS] Boot: Firmware booted successfully
[PASS] NVS config: Configuration loaded from flash
[PASS] Mock CSI: Synthetic WiFi data generator started
[PASS] Edge processing: Signal analysis pipeline running
[PASS] Frame serialization: Data packets formatted correctly
[PASS] No crashes: No error conditions detected
...
16/16 checks passed
=== Test Complete (exit code: 0) ===
Exit codes explained:
| Code | Meaning | What to do |
|---|---|---|
| 0 | PASS — everything works | Nothing, you're good! |
| 1 | WARN — minor issues | Review the output; usually safe to continue |
| 2 | FAIL — something broke | Check the [FAIL] lines for what went wrong |
| 3 | FATAL — can't even start | Usually a missing tool or build failure; check error messages |
Testing Multiple Nodes at Once (Swarm)
Real deployments use 3-8 ESP32 nodes. The swarm configurator lets you simulate multiple nodes on your computer, each with a different role:
- Sensor nodes — generate WiFi signal data (like ESP32s placed around a room)
- Coordinator node — collects data from all sensors and runs analysis
- Gateway node — bridges data to your computer
# Quick 2-node smoke test (15 seconds)
python3 scripts/qemu_swarm.py --preset smoke
# Standard 3-node test: 2 sensors + 1 coordinator (60 seconds)
python3 scripts/qemu_swarm.py --preset standard
# See what's available
python3 scripts/qemu_swarm.py --list-presets
# Preview what would run (without actually running)
python3 scripts/qemu_swarm.py --preset standard --dry-run
Note: Multi-node testing with virtual bridges requires Linux and sudo. On other systems, nodes use a simpler networking mode where each node can reach the coordinator but not each other.
Swarm Presets
| Preset | Nodes | Duration | Best for |
|---|---|---|---|
smoke |
2 | 15s | Quick check that things work |
standard |
3 | 60s | Normal development testing |
ci_matrix |
3 | 30s | CI/CD pipelines |
large_mesh |
6 | 90s | Testing at scale |
line_relay |
4 | 60s | Multi-hop relay testing |
ring_fault |
4 | 75s | Fault tolerance testing |
heterogeneous |
5 | 90s | Mixed scenario testing |
Writing Your Own Swarm Config
Create a YAML file describing your test scenario:
# my_test.yaml
swarm:
name: my-custom-test
duration_s: 45
topology: star # star, mesh, line, or ring
aggregator_port: 5005
nodes:
- role: coordinator
node_id: 0
scenario: 0 # 0=empty room (baseline)
channel: 6
edge_tier: 2
- role: sensor
node_id: 1
scenario: 2 # 2=walking person
channel: 6
tdm_slot: 1
- role: sensor
node_id: 2
scenario: 3 # 3=fall event
channel: 6
tdm_slot: 2
assertions:
- all_nodes_boot # Did every node start up?
- no_crashes # Any error/panic?
- all_nodes_produce_frames # Is each sensor generating data?
- fall_detected_by_node_2 # Did node 2 detect the fall?
Available scenarios (what kind of fake WiFi data to generate):
| # | Scenario | Description |
|---|---|---|
| 0 | Empty room | Baseline with just noise |
| 1 | Static person | Someone standing still |
| 2 | Walking | Someone walking across the room |
| 3 | Fall | Someone falling down |
| 4 | Multiple people | Two people in the room |
| 5 | Channel sweep | Cycling through WiFi channels |
| 6 | MAC filter | Testing device filtering |
| 7 | Ring overflow | Stress test with burst of data |
| 8 | RSSI sweep | Signal strength from weak to strong |
| 9 | Zero-length | Edge case: empty data packet |
Topology options:
| Topology | Shape | When to use |
|---|---|---|
star |
All sensors connect to one coordinator | Most common setup |
mesh |
Every node can talk to every other | Testing fully connected networks |
line |
Nodes in a chain (A → B → C → D) | Testing relay/forwarding |
ring |
Chain with ends connected | Testing circular routing |
Run your custom config:
python3 scripts/qemu_swarm.py --config my_test.yaml
Debugging Firmware in QEMU
If something goes wrong, you can attach a debugger to the emulated ESP32:
# Terminal 1: Start QEMU with debug support (paused at boot)
qemu-system-xtensa -machine esp32s3 -nographic \
-drive file=firmware/esp32-csi-node/build/qemu_flash.bin,if=mtd,format=raw \
-s -S
# Terminal 2: Connect the debugger
xtensa-esp-elf-gdb firmware/esp32-csi-node/build/esp32-csi-node.elf \
-ex "target remote :1234" \
-ex "break app_main" \
-ex "continue"
Or use VS Code: open the project, press F5, and select "QEMU ESP32-S3 Debug".
Running the Full Test Suite
For thorough validation before submitting a pull request:
# 1. Single-node test (2 minutes)
bash scripts/qemu-esp32s3-test.sh
# 2. Multi-node swarm test (1 minute)
python3 scripts/qemu_swarm.py --preset standard
# 3. Fuzz testing — finds edge-case crashes (1-5 minutes)
cd firmware/esp32-csi-node/test
make all CC=clang
make run_serialize FUZZ_DURATION=60
make run_edge FUZZ_DURATION=60
make run_nvs FUZZ_DURATION=60
# 4. NVS configuration matrix — tests 14 config combinations
python3 scripts/generate_nvs_matrix.py --output-dir build/nvs_matrix
# 5. Chaos testing — injects faults to test resilience (2 minutes)
bash scripts/qemu-chaos-test.sh
All of these also run automatically in CI when you push changes to firmware/.
Troubleshooting
Docker: "no matching manifest for linux/arm64" on macOS
The latest tag supports both amd64 and arm64. Pull the latest image:
docker pull ruvnet/wifi-densepose:latest
If you still see this error, your local Docker may have a stale cached manifest. Try:
docker pull --platform linux/arm64 ruvnet/wifi-densepose:latest
Docker: "Connection refused" on localhost:3000
Make sure you're mapping the ports correctly:
docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
The -p 3000:3000 maps host port 3000 to container port 3000.
Docker: No WebSocket data in UI
Add the WebSocket port mapping:
docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
ESP32: "CSI not enabled in menuconfig"
Firmware versions prior to v0.4.1 had CONFIG_ESP_WIFI_CSI_ENABLED disabled in the build config. Upgrade to v0.4.1 or later. If building from source, ensure sdkconfig.defaults exists (not just sdkconfig.defaults.template). See ADR-057.
ESP32: No data arriving
- Verify firmware is v0.4.1+ (older versions had CSI disabled — see above)
- Verify the ESP32 is connected to the same WiFi network
- Check the target IP matches the sensing server machine:
python firmware/esp32-csi-node/provision.py --port COM7 --target-ip <YOUR_IP> - Verify UDP port 5005 is not blocked by firewall
- Test with:
nc -lu 5005(Linux) or similar UDP listener
Build: Rust compilation errors
Ensure Rust 1.75+ is installed (1.85+ recommended):
rustup update stable
rustc --version
Build: Linux native desktop prerequisites
If you are compiling the Rust workspace on a Debian/Ubuntu-based Linux system, install the native desktop development packages first:
sudo apt update
sudo apt install -y \
build-essential pkg-config \
libglib2.0-dev libgtk-3-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libwebkit2gtk-4.1-dev
Then rerun:
cargo build --release
This is the same Linux pre-step referenced in the Rust source build section and covers the common GTK/WebKit pkg-config requirements used by the desktop build.
Windows: RSSI mode shows no data
Run the terminal as Administrator (required for netsh wlan access). Verified working on Windows 10 and 11 with Intel AX201 and Intel BE201 adapters.
Vital signs show 0 BPM
- Vital sign detection requires CSI-capable hardware (ESP32 or research NIC)
- RSSI-only mode (Windows WiFi) does not have sufficient resolution for vital signs
- In simulated mode, synthetic vital signs are generated after a few seconds of warm-up
- With real ESP32 data, vital signs take ~5 seconds to stabilize (smoothing pipeline warm-up)
Vital signs jumping around
The server applies a 3-stage smoothing pipeline (ADR-048). If readings are still unstable:
- Ensure the subject is relatively still (large movements mask vital sign oscillations)
- Train the adaptive classifier for your specific environment:
curl -X POST http://localhost:3000/api/v1/adaptive/train - Check signal quality:
curl http://localhost:3000/api/v1/sensing/latest— look forsignal_quality > 0.4
Observatory shows DEMO instead of LIVE
- Verify the sensing server is running:
curl http://localhost:3000/health - Access Observatory via the server URL:
http://localhost:3000/ui/observatory.html(not a file:// URL) - If a standalone
aggregatorcommand is already listening on UDP:5005, stop it and runsensing-server --source esp32 --udp-port 5005instead; the Observatory reads the server WebSocket, not the standalone aggregator output - Verify the ESP32 nodes are provisioned to the IP address of the machine running
sensing-server - Hard refresh with Ctrl+Shift+R to clear cached settings
- The auto-detect probes
/healthon the same origin — cross-origin won't work
QEMU: "qemu-system-xtensa: command not found"
QEMU for ESP32-S3 must be built from Espressif's fork — it is not in standard package managers:
git clone https://github.com/espressif/qemu.git
cd qemu && ./configure --target-list=xtensa-softmmu && make -j$(nproc)
export QEMU_PATH=$(pwd)/build/qemu-system-xtensa
Or point to an existing build: QEMU_PATH=/path/to/qemu-system-xtensa bash scripts/qemu-esp32s3-test.sh
QEMU: Test times out with no output
The emulator is slower than real hardware. Increase the timeout:
QEMU_TIMEOUT=120 bash scripts/qemu-esp32s3-test.sh
If there's truly no output at all, the firmware build may have failed. Rebuild without SKIP_BUILD:
bash scripts/qemu-esp32s3-test.sh # without SKIP_BUILD
QEMU: "esptool not found"
Install it with pip: pip install esptool
QEMU Swarm: "Must be run as root"
Multi-node swarm tests with virtual network bridges require root on Linux. Two options:
- Run with sudo:
sudo python3 scripts/qemu_swarm.py --preset standard - Skip bridges (nodes use simpler networking): the tool automatically falls back on non-root systems, but nodes can't communicate with each other (only with the aggregator)
QEMU Swarm: "yaml module not found"
Install PyYAML: pip install pyyaml
FAQ
Q: Do I need special hardware to try this?
No. Run docker run -p 3000:3000 ruvnet/wifi-densepose:latest and open http://localhost:3000. Simulated mode exercises the full pipeline with synthetic data.
Q: Can consumer WiFi laptops do pose estimation? No. Consumer WiFi exposes only RSSI (one number per access point), not CSI (56+ complex subcarrier values per frame). RSSI supports coarse presence and motion detection. Full pose estimation requires CSI-capable hardware like an ESP32-S3 ($8) or a research NIC.
Q: How accurate is the pose estimation? Accuracy depends on hardware and environment. With a 3-node ESP32 mesh in a single room, the system tracks 17 COCO keypoints. The core algorithm follows the CMU "DensePose From WiFi" paper (arXiv:2301.00250). The MERIDIAN domain generalization system (ADR-027) reduces cross-environment accuracy loss from 40-70% to under 15% via 10-second automatic calibration.
Q: Does it work through walls? Yes. WiFi signals penetrate non-metallic materials (drywall, wood, concrete up to ~30cm). Metal walls/doors significantly attenuate the signal. With a single AP the effective through-wall range is approximately 5 meters. With a 3-6 node multistatic mesh (ADR-029), attention-weighted cross-viewpoint fusion extends the effective range to ~8 meters through standard residential walls.
Q: How many people can it track? Each access point can distinguish ~3-5 people with 56 subcarriers. Multi-AP deployments multiply linearly (e.g., 4 APs cover ~15-20 people). There is no hard software limit; the practical ceiling is signal physics.
Q: Is this privacy-preserving? The system uses WiFi radio signals, not cameras. No images or video are captured or stored. However, it does track human position, movement, and vital signs, which is personal data subject to applicable privacy regulations.
Q: What's the Python vs Rust difference? The Rust implementation (v2) is 810x faster than Python (v1) for the full CSI pipeline. The Docker image is 132 MB vs 569 MB. Rust is the primary and recommended runtime. Python v1 remains available for legacy workflows.
Q: Can I use an ESP8266 instead of ESP32-S3? No. The ESP8266 does not expose WiFi Channel State Information (CSI) through its SDK, has insufficient RAM (~80 KB vs 512 KB), and runs a single-core 80 MHz CPU that cannot handle the signal processing pipeline. The ESP32-S3 is the minimum supported CSI capture device. See Issue #138 for alternatives including using cheap Android TV boxes as aggregation hubs.
Q: Does the Windows WiFi tutorial work on Windows 10? Yes. Community-tested on Windows 10 (build 26200) with an Intel Wi-Fi 6 AX201 160MHz adapter on a 5 GHz network. All 7 tutorial steps passed with Python 3.14. See Issue #36 for full test results.
Q: Can I run the sensing server on an ARM device (Raspberry Pi, TV box)?
ARM64 deployment is planned (ADR-046) but not yet available as a pre-built binary. You can cross-compile from source using cross build --release --target aarch64-unknown-linux-gnu -p wifi-densepose-sensing-server if you have the Rust cross-compilation toolchain set up.
Further Reading
- Architecture Decision Records - 48 ADRs covering all design decisions
- WiFi-Mat Disaster Response Guide - Search & rescue module
- Build Guide - Detailed build instructions
- RuVector - Signal intelligence crate ecosystem
- CMU DensePose From WiFi - The foundational research paper