Compare commits

...

18 Commits

Author SHA1 Message Date
Lixin2026 6169d9999a
Merge 9fa8eab610 into f5d0e1e69e 2026-06-03 00:40:11 -06:00
rUv f5d0e1e69e
fix(#894): actionable diagnostic when --model gets a non-RVF file (#919)
Users who downloaded ruvnet/wifi-densepose-pretrained and passed
model.safetensors / model-q4.bin / model.rvf.jsonl to --model hit a bare
"Progressive loader init failed: invalid magic at offset 0: expected
0x52564653, got 0x77455735" and were stuck — the server then silently fell
back to signal heuristics (which over-count, feeding "is it fake" reports).

The HF files are a different *format* and encoder architecture than the RVF
binary container the progressive loader expects, so they can't load directly.
Now the load-failure path detects the common cases (safetensors header,
JSONL manifest, quantized .bin blob) and emits a plain explanation naming the
format, what --model actually expects (RVF `RVFS` container from
wifi-densepose-train), and that it's continuing with heuristics — with a
pointer to #894.

Pure, testable `diagnose_model_load_error()` + 4 unit tests (run under the
default `--no-default-features` CI). Full crate unit suite: 429 + 114 passed,
0 failed.
2026-06-02 20:05:30 +02:00
rUv b12662a54d
fix(mqtt): per-node HA devices use each node's own presence/motion (#872) (#918)
The MQTT bridge fanned out one Home-Assistant device per node (#898) but
applied the *room-level aggregate* classification to every node — so in a
multi-node setup a node in an empty corner inherited another node's
"present", and `motion_level: "absent"` was mis-mapped to full motion
(the aggregate match fell through `Some(_) => 1.0`).

Each node in the sensing broadcast's `nodes` array already carries its own
`classification` (`motion_level`/`presence`/`confidence`, see
PerNodeFeatureInfo) and RSSI. Now each per-node snapshot reads that node's
own classification, deferring to the room aggregate only for fields a node
omits. Vitals (breathing/heart rate) and person count stay room-level.

Extracted the JSON→VitalsSnapshot mapping into a pure, testable function
(`vitals_snapshots_from_sensing_json`) and added 4 unit tests covering
per-node divergence, partial-field fallback, the no-nodes aggregate path,
and the absent→zero-motion fix.

Supersedes #899, which targeted the right bug but read non-existent fields
(`node["motion_level"]` / `node["status"]` instead of the nested
`node["classification"]` + `stale`).

Verified: builds with `--features mqtt`; new tests pass; full crate unit
suite 432 + 114 passed, 0 failed.
2026-06-02 19:26:01 +02:00
rUv 573b00fd98
perf(ci): drop dead uvicorn start from perf job (#917)
Since #915 the perf job gates only on test_frame_budget.py, which drives
the CSIProcessor pipeline in-process and makes no HTTP calls. The
"Start application" step (uvicorn + `sleep 10`) was therefore dead weight:
it existed only for the now-excluded api_throughput/inference_speed tests,
wasted ~10-15 s per main-push run, and dumped ~50 misleading
"router requires hardware setup" ERROR lines into every CI log for a
server no test touched. MOCK_POSE_DATA is server-only, unused here.

Removed the step and the vestigial env. The gated test is unchanged and
passes (verified locally, 3/3).
2026-06-02 19:01:08 +02:00
rUv 91b0e625bd
docs(#882): complete the "100% presence" retraction across all docs (#916)
The v1 "100% presence accuracy" headline was already retracted in the
README / user-guide intro / proof-of-capabilities — but 6 secondary
spots still flatly claimed "100% accuracy, never false alarms", which
made proof-of-capabilities.md's "replaced everywhere" assertion untrue.

Completed the retraction in-place with the honest label-free metric
(82.3% held-out temporal-triplet; v1 was a single-class recording where
a constant "yes" scores ~99.98%):

- docs/readme-details.md — 2 benchmark tables + the pre-trained-model row
- docs/user-guide.md — capability table, model-file comment, applications list
- CHANGELOG.md — annotated the historical entry in-place (kept as public
  record per built-in-public ethos, not rewritten)

Verified: no remaining flat "100% presence/accuracy" claim lacks a
retraction marker; proof-of-capabilities.md "replaced everywhere" is now
accurate.
2026-06-02 18:50:39 +02:00
rUv 88b835dd89
fix(ci): perf job gates on the real frame-budget guard, not TDD stubs (#915)
After #914 fixed collection, the perf job actually ran the suite and
exposed that test_api_throughput.py / test_inference_speed.py are TDD
red-phase stubs (every test suffixed `_should_fail_initially`) that time
a *mock that sleeps* — not a real perf signal. They carry machine-
dependent wall-clock asserts (actual_rps >= 40, batch_time < individual_time)
that are inherently flaky on shared CI runners, plus a cross-class
fixture-scope bug (`fixture 'standard_model' not found`). Result: 3 failed,
10 errored — by design, not a regression.

Forcing those green would manufacture a false signal. Instead, gate only
on test_frame_budget.py, which times the *real* CSIProcessor pipeline
against the ADR 50 ms per-frame budget (single-frame, p95/100-frames,
+Doppler) — a genuine regression guard. Verified locally: 3 passed.

The stub files remain in-repo for local TDD; they re-enter CI when their
features are implemented and the mock-timing asserts are made deterministic.
2026-06-02 18:31:55 +02:00
rUv f8f08076eb
fix(ci): perf tests — use `python -m pytest` so `src` import resolves (#914)
The Performance Tests job collected 26 items then aborted with
`ModuleNotFoundError: No module named 'src'` on test_frame_budget.py,
which does `from src.core.csi_processor import CSIProcessor`. The bare
`pytest` console script does not put the cwd (archive/v1) on sys.path;
`python -m pytest` does. pytest aborts the whole session on a collection
error, so this one import masked the entire (otherwise mock-based,
self-contained) perf suite.

Verified locally: bare-script path reproduces the exact error; `-m`
resolves it and test_frame_budget.py passes 3/3. The other two files
(test_api_throughput.py mock server, test_inference_speed.py MockPoseModel
+psutil) are fully self-contained — no test hits the running server.

Closes the last red job in the v1-API CI chain (#910/#911/#913).
2026-06-02 18:12:00 +02:00
rUv 55f6a74e1e
Merge pull request #913 from ruvnet/fix/ci-v1-api-perms-locust
ci(v1-api): fix gh-pages 403 + run real pytest perf suite
2026-06-02 17:36:43 +02:00
ruv b5a91c5635 ci(v1-api): install pytest, drop root --cov addopts for perf suite, ascii comment 2026-06-02 17:29:04 +02:00
ruv 308d2fc89d ci(v1-api): fix gh-pages 403 + run real perf suite — green main CI
Two more latent v1-API CI bugs surfaced once #910/#911 let the jobs reach
their later steps:

- API Documentation: openapi generation now succeeds (psutil fix), but the
  gh-pages deploy failed with HTTP 403 — the job had no `permissions` block
  and GITHUB_TOKEN is read-only by default. Add `permissions: contents:
  write`, and make the deploy `continue-on-error` (the openapi generation is
  the real validation; Pages may be disabled).
- Performance Tests: ran `locust -f tests/performance/locustfile.py`, but
  there is no locustfile — the suite is pytest (test_api_throughput.py,
  test_frame_budget.py, test_inference_speed.py). Run pytest instead, with
  working-directory: archive/v1 and MOCK_POSE_DATA=true.

ci.yml validated as well-formed YAML.
2026-06-02 17:26:39 +02:00
rUv 5038e3c8e1
Merge pull request #911 from ruvnet/fix/ci-v1-api-mock-mode
ci(v1-api): MOCK_POSE_DATA + declare psutil — green Performance Tests & API Docs
2026-06-02 06:20:21 -04:00
ruv e239af3636 fix(deps): declare psutil in requirements.txt — green API Documentation CI
The API Documentation job (and any env without locust) failed with
`ModuleNotFoundError: No module named 'psutil'` when importing the app:
psutil is imported by src/api/routers/health.py, services/metrics.py,
commands/status.py, and tasks/monitoring.py, but was never declared as a
dependency — it only happened to be present where locust (Performance
Tests) pulled it in transitively. Declare it explicitly (psutil>=5.9.0).

Verified locally: `from src.api.main import app; app.openapi()` (the exact
docs-job operation) now succeeds.
2026-06-02 12:11:55 +02:00
ruv 4856afbd0c ci(v1-api): run Performance Tests + API Docs with MOCK_POSE_DATA=true
After the DensePoseHead startup fix (#910), the v1 API starts, but the
Performance Tests load-hit the pose endpoints which error "requires real
CSI data" (no hardware in CI, mock_pose_data defaults False), and the
API-docs job imports the app the same way. Set MOCK_POSE_DATA=true on both
jobs so they exercise the mock path. Verified: the env var maps to
settings.mock_pose_data=True (pydantic, no env_prefix).

(Note: Performance Tests is continue-on-error so this is cleanup, not a
run-blocker; the run-level red on main has been transient Docker Hub pull
timeouts on Tests/docker-build, which are infra flakes that pass on re-run.)
2026-06-02 12:04:58 +02:00
rUv 4d205a05c4
Merge pull request #910 from ruvnet/fix/v1-pose-service-densepose-config
fix(v1-api): pass required config to DensePoseHead — green main CI
2026-06-02 05:50:25 -04:00
ruv bc42ae7903 fix(v1-api): pass required config to DensePoseHead — green main CI
The "Continuous Integration" workflow (Performance Tests + API
Documentation jobs) has failed on every main commit since the API start
path was exercised: pose_service._initialize_models() called
`DensePoseHead()` with no args, but DensePoseHead.__init__ requires a
config dict → "TypeError: DensePoseHead.__init__() missing 1 required
positional argument: 'config'" → uvicorn "Application startup failed".

Pass a config: input_channels=256 (matches the modality translator's
output), num_body_parts=24 (DensePose standard), num_uv_coordinates=2.
Both call sites (with/without pose_model_path) fixed.

Verified locally: DensePoseHead(config) + ModalityTranslationNetwork(config)
both construct + eval, clearing the startup TypeError.
2026-06-02 11:42:52 +02:00
rUv b7b8c1109b
Merge pull request #908 from ruvnet/fix/893-release-bins-refresh
release(firmware): refresh release_bins with the #893 CSI fix → v0.6.7
2026-06-02 05:35:34 -04:00
ruv 786e834dae release(firmware): refresh release_bins with the #893 CSI fix → v0.6.7
The pre-built binaries in release_bins/ were v0.6.6 (May 21) and shipped
the MGMT-only promiscuous filter, so display-less boards flashed from them
got yield=0pps (#893/#866/#897 — the root cause of the "can't reproduce /
it's fake" reports). Rebuilt every flashable variant from main (which has
the #893 display-gated DATA-frame fix) and refreshed the binaries:

- top-level ESP32-S3 8MB (sdkconfig.defaults) — esp32-csi-node.bin +
  bootloader (partition-table/ota_data unchanged — code-only fix)
- esp32-csi-node-4mb.bin (ESP32-S3 4MB, sdkconfig.defaults.4mb)
- c6-adr110/ (ESP32-C6, sdkconfig.defaults.esp32c6) — the exact firmware
  hardware-verified on COM6 (CSI yield 0→27 pps, presence/motion alive,
  no #396 crash)
- s3-adr110/ (same production S3 8MB config)

Left untouched: s3-fair-adr110/ (a non-production size-comparison build,
features stripped — not a board anyone flashes for sensing).

version.txt → 0.6.7; SHA256SUMS regenerated for the changed variant dirs.
Display boards keep MGMT-only (preserves the #396 crash protection);
display-less boards now capture DATA frames and stream CSI.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-02 11:18:03 +02:00
yinlixin 9fa8eab610 docs: clarify medical-use boundaries 2026-05-31 09:10:25 +08:00
19 changed files with 359 additions and 76 deletions

View File

@ -265,23 +265,45 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install locust
pip install pytest # the perf suite is pytest, not locust
- name: Start application
working-directory: archive/v1
run: |
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
sleep 10
# No "Start application" step: the gated test (test_frame_budget.py) drives
# the CSIProcessor pipeline in-process and makes no HTTP calls, so the old
# uvicorn server + `sleep 10` were dead weight — they only existed for the
# now-excluded api_throughput/inference_speed tests, and on every run dumped
# ~50 misleading "router requires hardware setup" ERROR lines for a server
# no test touched. MOCK_POSE_DATA is server-only and unused here.
- name: Run performance tests
working-directory: archive/v1
run: |
locust -f tests/performance/locustfile.py --headless --users 50 --spawn-rate 5 --run-time 60s --host http://localhost:8000
# Gate only on the genuine, deterministic perf guard:
# test_frame_budget.py times the *real* CSIProcessor pipeline against
# the ADR 50 ms per-frame budget (single-frame, p95 over 100 frames,
# +Doppler) — a true regression signal.
#
# test_api_throughput.py / test_inference_speed.py are excluded: every
# test there is a TDD red-phase stub (suffix `_should_fail_initially`)
# that times a *mock that sleeps* — meaningless as a perf signal, with
# machine-dependent wall-clock asserts (e.g. `actual_rps >= 40`,
# `batch_time < individual_time`) that are inherently flaky on shared
# CI runners, plus a cross-class fixture-scope bug. Forcing them green
# would be manufacturing a false signal; they stay in-repo for local
# TDD but do not gate CI until the underlying features are implemented.
#
# `python -m pytest` (not the bare `pytest` script) puts the cwd
# (archive/v1) on sys.path so `from src.core...` resolves — the bare
# script omits cwd and raises ModuleNotFoundError: No module named 'src'.
# -o addopts="" drops the root pyproject's --cov/--cov-fail-under=100.
python -m pytest tests/performance/test_frame_budget.py \
-o addopts="" -v --junitxml=perf-junit.xml
- name: Upload performance results
if: always()
uses: actions/upload-artifact@v4
with:
name: performance-results
path: locust_report.html
path: archive/v1/perf-junit.xml
# Docker Build and Test
# NOTE: the canonical Docker build for the sensing-server is now
@ -367,6 +389,8 @@ jobs:
runs-on: ubuntu-latest
needs: [docker-build]
if: github.ref == 'refs/heads/main'
permissions:
contents: write # gh-pages deploy needs write (GITHUB_TOKEN is read-only by default -> 403)
steps:
- name: Checkout code
uses: actions/checkout@v4
@ -384,6 +408,8 @@ jobs:
- name: Generate OpenAPI spec
working-directory: archive/v1
env:
MOCK_POSE_DATA: "true" # no CSI hardware in CI
run: |
python -c "
from src.api.main import app
@ -394,6 +420,7 @@ jobs:
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
continue-on-error: true # openapi generation above is the real validation; deploy is best-effort (Pages may be disabled)
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs

View File

@ -430,7 +430,7 @@ Model release (no new firmware binary). Firmware remains at v0.6.0-esp32.
- Security fix merged via PR #310.
### Performance
- Presence detection: 100% accuracy on 60,630 overnight samples.
- Presence detection: 100% accuracy on 60,630 overnight samples. *(Retracted — that recording was single-class (one sleeping person, 6,062/6,063 frames "present"), so a constant "yes" scores ~99.98%. Superseded by the honest 82.3% held-out temporal-triplet metric; see [#882](https://github.com/ruvnet/RuView/issues/882). Kept here as the in-place public record.)*
- Inference: 0.008 ms per sample, 164K embeddings/sec.
- Contrastive self-supervised training: 51.6% improvement over baseline.

View File

@ -34,6 +34,9 @@ Every WiFi router already fills your space with radio waves. When people move, b
Built on [RuVector](https://github.com/ruvnet/ruvector/) and [Cognitum Seed](https://cognitum.one), RuView runs entirely on edge hardware — an ESP32 mesh (as low as $9 per node) paired with a Cognitum Seed for persistent memory, cryptographic attestation, and AI integration. No cloud, no cameras, no internet required.
> [!IMPORTANT]
> **Medical-use boundary.** RuView is a research and ambient-sensing project, not a medical device or clinical monitoring system. Vital-sign, fall-risk, apnea, distress, and triage outputs should be treated as experimental signals that require validation against regulated clinical equipment and clinician judgment before any health, anesthesia, emergency, ward, elder-care, or patient-care decision. Do not use RuView as the sole basis for diagnosis, treatment, alarm escalation, or continuous patient monitoring.
The system learns each environment locally using spiking neural networks that adapt in under 30 seconds, with multi-frequency mesh scanning across 6 WiFi channels that uses your neighbors' routers as free radar illuminators. Every measurement is cryptographically attested via an Ed25519 witness chain.
RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the radio reflections off the people in a room, and a small pretrained model — published on Hugging Face at [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) — tells you who's there, how they're breathing, and how their heart rate is trending. The model fits in 8 KB (4-bit quantized) and runs in microseconds on a Raspberry Pi. (The [v2 encoder](https://huggingface.co/ruvnet/wifi-densepose-pretrained) reports an honest, label-free held-out **temporal-triplet accuracy of 82.3%** — up from 66.4% raw; the older "100% presence" figure was measured on a single-class recording and has been retracted in favor of this.) No cameras, no wearables, no app on the user's phone.

View File

@ -107,16 +107,25 @@ class PoseService:
async def _initialize_models(self):
"""Initialize neural network models."""
try:
# Initialize DensePose model
# Initialize DensePose model. DensePoseHead requires a config
# dict — input_channels matches the modality translator's output
# (256), with the standard DensePose 24 body parts and 2 (U,V)
# coordinates. (Previously called with no args → TypeError at
# startup, which broke the API service.)
densepose_config = {
'input_channels': 256,
'num_body_parts': 24,
'num_uv_coordinates': 2,
}
if self.settings.pose_model_path:
self.densepose_model = DensePoseHead()
self.densepose_model = DensePoseHead(densepose_config)
# Load model weights if path is provided
# model_state = torch.load(self.settings.pose_model_path)
# self.densepose_model.load_state_dict(model_state)
self.logger.info("DensePose model loaded")
else:
self.logger.warning("No pose model path provided, using default model")
self.densepose_model = DensePoseHead()
self.densepose_model = DensePoseHead(densepose_config)
# Initialize modality translation
config = {

View File

@ -122,7 +122,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm # benchmark
| What we measured | Result | Why it matters |
|-----------------|--------|---------------|
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric on the last 20% by time (v1's "100% presence" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| **Inference speed** | **0.008 ms** per embedding | 125,000x faster than real-time |
| **Throughput** | **164,183 embeddings/sec** | One Mac Mini handles 1,600+ ESP32 nodes |
| **Contrastive learning** | **51.6% improvement** | Strong pattern learning from real overnight data |
@ -233,7 +233,7 @@ python firmware/esp32-csi-node/provision.py --port COM9 --hop-channels "1,6,11"
| **kNN similarity search** | "Find the 10 most similar states to right now" — anomaly detection, fingerprinting | Cognitum Seed |
| **Witness chain** | SHA-256 tamper-evident audit trail for every measurement (1,747 entries validated) | Cognitum Seed |
| **Camera-free pose training** | 17 COCO keypoints from 10 sensor signals — PIR, RSSI triangulation, subcarrier asymmetry, vibration, BME280 | 2x ESP32 + Seed |
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 100% presence accuracy, 0 skeleton violations | Download from release |
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 82.3% held-out temporal-triplet accuracy (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) | Download from release |
| **Sub-ms inference** | 0.012 ms latency, 171,472 embeddings/sec on M4 Pro | Any machine with Node.js |
| **SONA adaptation** | Adapts to new rooms in <1ms without retraining | ruvllm runtime |
| **LoRA room adapters** | Per-node fine-tuning with 2,048 parameters per adapter | Automatic |
@ -262,7 +262,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
| What we measured | Result | Why it matters |
|-----------------|--------|---------------|
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
| **CSI embedding quality** | **82.3% held-out temporal-triplet** | Honest label-free metric (v1's "100% presence" was single-class — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| **Person counting** | **24/24 correct** (MinCut) | Fixed the #1 user-reported issue |
| **Inference speed** | **0.012 ms** per embedding | 83,000x faster than real-time |
| **Throughput** | **171,472 embeddings/sec** | One Mac Mini handles 1,700+ ESP32 nodes |

View File

@ -1119,7 +1119,7 @@ What it ships (and what it does not):
| Capability | Status |
|------------|--------|
| Presence detection (occupied / empty) | ✅ Trained head — 100% accuracy on validation |
| Presence detection (occupied / empty) | ✅ Trained head — v2 encoder reports 82.3% held-out temporal-triplet acc (v1's "100% on validation" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882)) |
| 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 |
@ -1824,7 +1824,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
# 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)
# presence-head.json — presence detection head (v2 encoder: 82.3% held-out triplet acc)
# node-1.json — LoRA adapter for room 1
# node-2.json — LoRA adapter for room 2
```
@ -1833,7 +1833,7 @@ huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/pre
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
- **Presence detection**v2 encoder: 82.3% held-out temporal-triplet accuracy (v1's "100%" was a single-class recording — retracted, [#882](https://github.com/ruvnet/RuView/issues/882))
- **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

View File

@ -1,4 +1,4 @@
889715e9d698ad78f9978ad8b93b6af24a726b0494247201c8f0d920d9fc80ca *firmware/esp32-csi-node/release_bins/c6-adr110/bootloader.bin
d8539e47c6f10a3344679118619e3fe01cfd66eb560ea8883268ca7c9a12efa4 *firmware/esp32-csi-node/release_bins/c6-adr110/esp32-csi-node.bin
b0fb1f217a39c80bc95b5eb8208a0b8572ae64efa0f6d580b76caff4affe0f4d *firmware/esp32-csi-node/release_bins/c6-adr110/bootloader.bin
4764c5b20a353895f70122816adc98f861ec20e9a8ea9b344dc0648b6341073c *firmware/esp32-csi-node/release_bins/c6-adr110/esp32-csi-node.bin
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f *firmware/esp32-csi-node/release_bins/c6-adr110/ota_data_initial.bin
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 *firmware/esp32-csi-node/release_bins/c6-adr110/partition-table.bin

View File

@ -1,3 +1,3 @@
3c4905dd202ccabf4230cbabcc9320f250a60b1a7254eff7424780201bcb2072 *firmware/esp32-csi-node/release_bins/s3-adr110/bootloader.bin
7a8bf9582c9031fed32f1ada44f5c41dd99bd07fadff8e5c86e07aa0f343e847 *firmware/esp32-csi-node/release_bins/s3-adr110/esp32-csi-node.bin
b973d7eda65affb746adcfa63ceb18f779f206d240b76f01b8c9ae7485455660 *firmware/esp32-csi-node/release_bins/s3-adr110/bootloader.bin
e21ef94aba779d534dc048c1b9da731c81e5dbe09d0645cfd70a05ad3642d3e9 *firmware/esp32-csi-node/release_bins/s3-adr110/esp32-csi-node.bin
67222c257c0477501fd4002275638dc4262b34eb68235b8289fb1337054d322b *firmware/esp32-csi-node/release_bins/s3-adr110/partition-table.bin

View File

@ -1,3 +1,4 @@
0.6.6
git-sha: cbcb389cb (pre-commit)
built: 2026-05-21
0.6.7
git-sha: 8703ade9b
built: 2026-06-02
note: RuView#893 — display-less boards capture DATA frames (CSI yield 0pps fix); hardware-verified on ESP32-C6 (0->27 pps)

View File

@ -36,3 +36,4 @@ scikit-learn>=1.2.0
# Monitoring dependencies
prometheus-client>=0.16.0
psutil>=5.9.0 # system metrics — imported by health.py / metrics.py / status.py / monitoring.py

View File

@ -5476,6 +5476,149 @@ async fn broadcast_tick_task(state: SharedState, tick_ms: u64) {
}
}
/// Map one sensing-broadcast JSON document into the `VitalsSnapshot`(s) to
/// publish over MQTT (issues #872/#898).
///
/// Multi-node sources carry a `nodes` array where **each node has its own
/// `classification`** (`motion_level`, `presence`, `confidence`) and RSSI — so
/// each node must surface its *own* presence/motion, not the room-level
/// aggregate. Previously the bridge applied the aggregate `classification` to
/// every per-node Home-Assistant device, so a node in an empty corner inherited
/// another node's "present" (and `motion_level: "absent"` was mis-mapped to full
/// motion). Vitals (breathing / heart rate) and the person count are room-level
/// and shared across the per-node devices. Falls back to a single aggregate
/// snapshot when there is no per-node data (e.g. wifi / simulate sources).
#[cfg(feature = "mqtt")]
fn vitals_snapshots_from_sensing_json(
v: &serde_json::Value,
base_id: &str,
) -> Vec<wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot> {
use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot;
// motion_level string -> motion scalar. "absent"/"none"/"still"/"idle"/""
// are non-moving; anything else (walking, …) is motion. `fallback` is used
// when the field is absent so a partial per-node payload defers to the
// room aggregate rather than silently reading 0.
fn motion_of(level: Option<&str>, fallback: f64) -> f64 {
match level {
Some("none") | Some("still") | Some("idle") | Some("absent") | Some("") => 0.0,
Some(_) => 1.0,
None => fallback,
}
}
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
let vit = &v["vital_signs"];
let breathing = vit["breathing_rate_bpm"].as_f64();
let hr = vit["heart_rate_bpm"].as_f64();
let n_persons = v["persons"]
.as_array()
.map(|a| a.len() as u32)
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
.unwrap_or(0);
// Room-level aggregate: the no-nodes fallback, and the per-node default for
// any field a node omits.
let acls = &v["classification"];
let agg_presence = acls["presence"].as_bool().unwrap_or(false);
let agg_motion = motion_of(acls["motion_level"].as_str(), 0.0);
let agg_conf = acls["confidence"].as_f64().unwrap_or(0.0);
let mk = |node_id: String, presence: bool, motion: f64, conf: f64, rssi: Option<f64>| {
VitalsSnapshot {
node_id,
timestamp_ms: ts,
presence,
motion,
presence_score: if presence { conf.max(0.0) } else { 0.0 },
breathing_rate_bpm: breathing,
heartrate_bpm: hr,
n_persons,
rssi_dbm: rssi,
vital_confidence: conf,
..Default::default()
}
};
match v["nodes"].as_array() {
Some(arr) if !arr.is_empty() => arr
.iter()
.map(|node| {
let n = node["node_id"].as_u64().unwrap_or(0);
// Each node carries its OWN classification — use it, deferring to
// the room aggregate only for fields the node omits.
let ncls = &node["classification"];
let presence = ncls["presence"].as_bool().unwrap_or(agg_presence);
let motion = motion_of(ncls["motion_level"].as_str(), agg_motion);
let conf = ncls["confidence"].as_f64().unwrap_or(agg_conf);
mk(
format!("{base_id}-node{n}"),
presence,
motion,
conf,
node["rssi_dbm"].as_f64(),
)
})
.collect(),
_ => vec![mk(
base_id.to_string(),
agg_presence,
agg_motion,
agg_conf,
v["nodes"][0]["rssi_dbm"].as_f64(),
)],
}
}
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
///
/// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files
/// (`model.safetensors`, `model-q{2,4,8}.bin`, `model.rvf.jsonl`) are a
/// different *format* — and a different encoder architecture — than the RVF
/// binary container the `--model` progressive loader expects (`RVFS` magic
/// `0x52564653`). Feeding one to `--model` produced a bare
/// "invalid magic at offset 0 …" that left users stuck. Detect the common
/// cases and explain plainly what's loadable instead.
fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// safetensors: 8-byte LE header length, then a JSON object starting with '{'.
let looks_safetensors = ext == "safetensors" || (data.len() > 9 && data[8] == b'{');
// JSONL manifest: starts with '{' (or the well-known suffix).
let looks_jsonl =
ext == "jsonl" || name.ends_with(".rvf.jsonl") || data.first() == Some(&b'{');
// Quantized weight blob shipped on HF (model-q2/q4/q8.bin).
let looks_quant_bin = ext == "bin" || name.contains("-q");
let kind = if looks_safetensors {
"a safetensors weight file"
} else if looks_jsonl {
"a JSONL manifest, not the binary container"
} else if looks_quant_bin {
"a quantized weight blob (e.g. HuggingFace model-q4.bin)"
} else {
"not an RVF binary container"
};
format!(
"model `{}` could not be loaded: it is {kind}. The --model flag expects an \
RVF binary container (`RVFS` magic 0x52564653) produced by the \
wifi-densepose-train pipeline. The HuggingFace ruvnet/wifi-densepose-pretrained \
files are a different format and encoder architecture, so they do not load \
here directly (issue #894). Continuing with signal heuristics. (loader: {err})",
path.display()
)
}
// ── Main ─────────────────────────────────────────────────────────────────────
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
@ -6113,7 +6256,9 @@ async fn main() {
model_loaded = true;
progressive_loader = Some(loader);
}
Err(e) => error!("Progressive loader init failed: {e}"),
Err(e) => {
error!("{}", diagnose_model_load_error(mp, &data, &e.to_string()))
}
},
Err(e) => error!("Failed to read model file: {e}"),
}
@ -6200,56 +6345,13 @@ async fn main() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) else {
continue;
};
let cls = &v["classification"];
let vit = &v["vital_signs"];
let presence = cls["presence"].as_bool().unwrap_or(false);
let n_persons = v["persons"]
.as_array()
.map(|a| a.len() as u32)
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
.unwrap_or(0);
let motion = match cls["motion_level"].as_str() {
Some("none") | Some("still") | Some("idle") | Some("") => 0.0,
Some(_) => 1.0,
None => 0.0,
};
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
let conf = cls["confidence"].as_f64().unwrap_or(0.0);
let presence_score = if presence { conf.max(0.0) } else { 0.0 };
let breathing = vit["breathing_rate_bpm"].as_f64();
let hr = vit["heart_rate_bpm"].as_f64();
// #898: emit one snapshot per physical node so each
// surfaces as its own Home-Assistant device (with
// its own RSSI + availability). Falls back to a
// single aggregate snapshot when there is no
// per-node data (e.g. wifi / simulate sources).
let mk = |nid: String, rssi: Option<f64>| mqtt::state::VitalsSnapshot {
node_id: nid,
timestamp_ms: ts,
presence,
motion,
presence_score,
breathing_rate_bpm: breathing,
heartrate_bpm: hr,
n_persons,
rssi_dbm: rssi,
vital_confidence: conf,
..Default::default()
};
match v["nodes"].as_array() {
Some(arr) if !arr.is_empty() => {
for node in arr {
let n = node["node_id"].as_u64().unwrap_or(0);
let nid = format!("{node_id}-node{n}");
let _ = vtx.send(mk(nid, node["rssi_dbm"].as_f64()));
}
}
_ => {
let _ = vtx.send(mk(
node_id.clone(),
v["nodes"][0]["rssi_dbm"].as_f64(),
));
}
// #898/#872: emit one snapshot per physical node so
// each surfaces as its own Home-Assistant device with
// its *own* presence/motion/RSSI (see
// vitals_snapshots_from_sensing_json). Falls back to a
// single aggregate snapshot for per-node-less sources.
for snap in vitals_snapshots_from_sensing_json(&v, &node_id) {
let _ = vtx.send(snap);
}
}
});
@ -7068,3 +7170,143 @@ mod rolling_p95_tests {
assert_eq!(p.len(), 1);
}
}
#[cfg(all(test, feature = "mqtt"))]
mod mqtt_bridge_tests {
use super::vitals_snapshots_from_sensing_json;
use serde_json::json;
/// Regression for the per-node presence bug (#872/#898): each node must
/// surface its OWN classification, not the room-level aggregate. Node 1 is
/// present+moving; node 2 is absent — node 2 must NOT inherit node 1's
/// "present".
#[test]
fn per_node_presence_uses_each_nodes_own_classification() {
let v = json!({
"timestamp": 1.0,
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.9 },
"vital_signs": { "breathing_rate_bpm": 14.0, "heart_rate_bpm": 60.0 },
"persons": [{}, {}],
"nodes": [
{ "node_id": 1, "rssi_dbm": -40.0,
"classification": { "presence": true, "motion_level": "walking", "confidence": 0.8 } },
{ "node_id": 2, "rssi_dbm": -70.0,
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.1 } }
]
});
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
assert_eq!(snaps.len(), 2, "one snapshot per node");
let n1 = snaps.iter().find(|s| s.node_id == "ruview-node1").unwrap();
let n2 = snaps.iter().find(|s| s.node_id == "ruview-node2").unwrap();
assert!(n1.presence && n1.motion > 0.0, "node1 present + moving");
assert!(
!n2.presence && n2.motion == 0.0,
"node2 must be absent — not inherit the room aggregate"
);
// Per-node RSSI preserved.
assert_eq!(n1.rssi_dbm, Some(-40.0));
assert_eq!(n2.rssi_dbm, Some(-70.0));
// Vitals + person count are room-level, shared across node devices.
assert_eq!(n1.n_persons, 2);
assert_eq!(n2.n_persons, 2);
assert_eq!(n1.breathing_rate_bpm, Some(14.0));
assert_eq!(n2.heartrate_bpm, Some(60.0));
// presence_score is gated on presence.
assert!(n1.presence_score > 0.0);
assert_eq!(n2.presence_score, 0.0);
}
/// A node that omits a classification field defers to the room aggregate
/// rather than silently reading false/0.
#[test]
fn per_node_missing_fields_fall_back_to_aggregate() {
let v = json!({
"timestamp": 1.0,
"classification": { "presence": true, "motion_level": "still", "confidence": 0.7 },
"vital_signs": {},
"nodes": [ { "node_id": 3, "rssi_dbm": -55.0 } ] // no per-node classification
});
let snaps = vitals_snapshots_from_sensing_json(&v, "n");
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].node_id, "n-node3");
assert!(snaps[0].presence, "defers to aggregate presence");
assert_eq!(snaps[0].motion, 0.0, "aggregate 'still' => no motion");
}
/// No `nodes` array (wifi / simulate sources): single aggregate snapshot
/// keyed by the base id.
#[test]
fn falls_back_to_single_aggregate_when_no_nodes() {
let v = json!({
"timestamp": 2.0,
"classification": { "presence": true, "motion_level": "idle", "confidence": 0.6 },
"vital_signs": { "breathing_rate_bpm": 12.0 },
"persons": [{}]
});
let snaps = vitals_snapshots_from_sensing_json(&v, "ruview");
assert_eq!(snaps.len(), 1);
assert_eq!(snaps[0].node_id, "ruview");
assert!(snaps[0].presence);
assert_eq!(snaps[0].motion, 0.0, "idle => no motion");
assert_eq!(snaps[0].n_persons, 1);
}
/// `motion_level: "absent"` must map to zero motion (the old aggregate
/// match fell through to `Some(_) => 1.0`, treating absent as full motion).
#[test]
fn absent_motion_level_is_zero_motion() {
let v = json!({
"timestamp": 0.0,
"classification": { "presence": false, "motion_level": "absent", "confidence": 0.0 },
"vital_signs": {}
});
let snaps = vitals_snapshots_from_sensing_json(&v, "x");
assert_eq!(snaps[0].motion, 0.0);
assert!(!snaps[0].presence);
}
}
#[cfg(test)]
mod model_load_diagnostic_tests {
use super::diagnose_model_load_error;
use std::path::Path;
#[test]
fn safetensors_is_named_and_points_at_894() {
// 8-byte LE header length then '{' — the safetensors signature.
let data = [0x10, 0, 0, 0, 0, 0, 0, 0, b'{', b'"'];
let msg = diagnose_model_load_error(
Path::new("models/wifi-densepose-pretrained/model.safetensors"),
&data,
"invalid magic at offset 0",
);
assert!(msg.contains("safetensors"), "{msg}");
assert!(msg.contains("#894"), "{msg}");
assert!(msg.contains("signal heuristics"), "{msg}");
}
#[test]
fn quantized_bin_is_identified() {
let data = [0x35, 0x57, 0x45, 0x77]; // the 0x77455735 the loader reports
let msg = diagnose_model_load_error(Path::new("model-q4.bin"), &data, "bad magic");
assert!(msg.contains("quantized weight blob"), "{msg}");
assert!(msg.contains("RVFS") || msg.contains("0x52564653"), "{msg}");
}
#[test]
fn jsonl_manifest_is_identified() {
let data = *b"{\"seg\":0}";
let msg = diagnose_model_load_error(Path::new("model.rvf.jsonl"), &data, "x");
assert!(msg.contains("JSONL manifest"), "{msg}");
}
#[test]
fn unknown_format_still_gives_guidance() {
let data = [0u8, 1, 2, 3];
let msg = diagnose_model_load_error(Path::new("weird.dat"), &data, "x");
assert!(msg.contains("RVF binary container"), "{msg}");
assert!(msg.contains("wifi-densepose-train"), "{msg}");
}
}