Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] edba71f470 chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 15:39:35 +00:00
42 changed files with 189 additions and 1095 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
run:
working-directory: v2
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Rust toolchain
run: rustup show && rustc --version
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
+3 -3
View File
@@ -41,7 +41,7 @@ jobs:
image_tag: ${{ steps.determine-tag.outputs.tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Determine deployment environment
id: determine-env
@@ -85,7 +85,7 @@ jobs:
url: https://staging.wifi-densepose.com
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up kubectl
uses: azure/setup-kubectl@v3
@@ -131,7 +131,7 @@ jobs:
url: https://wifi-densepose.com
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up kubectl
uses: azure/setup-kubectl@v3
+33 -43
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -81,7 +81,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
# `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`,
# `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the
@@ -108,18 +108,16 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
# Swatinem/rust-cache replaces a naive `actions/cache` of the whole
# `v2/target`. That manual cache of a 38-crate target dir (multi-GB) was an
# intermittent failure source — several CI runs this cycle died at the
# cache/setup step (after toolchain install, before "Run Rust tests"),
# needing a rerun. rust-cache is purpose-built for Rust: it caches the
# registry + git + a pruned target, evicts stale deps, and restores far more
# reliably (and faster) on large workspaces. `workspaces: v2` points it at
# the v2/ cargo workspace (keys on v2/Cargo.lock, caches v2/target).
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
- name: Cache cargo
uses: actions/cache@v4
with:
workspaces: v2
path: |
~/.cargo/registry
~/.cargo/git
v2/target
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Run Rust tests
working-directory: v2
@@ -190,7 +188,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
continue-on-error: true
@@ -255,7 +253,7 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
@@ -269,36 +267,28 @@ jobs:
pip install -r requirements.txt
pip install pytest # the perf suite is pytest, not locust
# 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: Start application
working-directory: archive/v1
env:
# No CSI hardware in CI — serve mock pose data so the pose endpoints
# respond 200 under load instead of erroring "requires real CSI data".
MOCK_POSE_DATA: "true"
run: |
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 &
sleep 10
- name: Run performance tests
working-directory: archive/v1
env:
MOCK_POSE_DATA: "true"
run: |
# 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
# The repo's performance suite is pytest (test_api_throughput.py,
# test_frame_budget.py, test_inference_speed.py) — there is no
# locustfile.py, so the old `locust -f tests/performance/locustfile.py`
# command always failed with "Could not find ...". Run the real suite.
# -o addopts="" drops the root pyproject's --cov/--cov-fail-under=100
# flags (pytest-cov isn't installed here and 100% cov is for unit tests).
pytest tests/performance/ -o addopts="" -v --junitxml=perf-junit.xml
- name: Upload performance results
if: always()
@@ -323,7 +313,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
continue-on-error: true
@@ -395,7 +385,7 @@ jobs:
contents: write # gh-pages deploy needs write (GITHUB_TOKEN is read-only by default -> 403)
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
snapshot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Fetch /traffic/clones + /traffic/views from GitHub
env:
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
name: Build x86_64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -77,7 +77,7 @@ jobs:
name: Build aarch64 (arm)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -144,7 +144,7 @@ jobs:
github.event_name == 'push' &&
vars.HAS_GCP_CREDENTIALS == 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Download x86_64 artifact
uses: actions/download-artifact@v4
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-unknown-unknown }
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Rust + wasm32 target
uses: dtolnay/rust-toolchain@stable
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
target: [aarch64-apple-darwin, x86_64-apple-darwin]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -82,7 +82,7 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -130,7 +130,7 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download all artifacts
uses: actions/download-artifact@v4
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
if: github.ref_type == 'tag'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Check firmware version.txt == tag
run: |
# Tag form: vX.Y.Z-esp32 → expect version.txt to contain X.Y.Z
@@ -70,7 +70,7 @@ jobs:
artifact_pt: partition-table-c6.bin
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Build firmware (${{ matrix.variant }})
working-directory: firmware/esp32-csi-node
+4 -4
View File
@@ -99,7 +99,7 @@ jobs:
- boundary-min
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Download QEMU artifact
uses: actions/download-artifact@v4
@@ -213,7 +213,7 @@ jobs:
name: Fuzz Testing (ADR-061 Layer 6)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install clang
run: |
@@ -262,7 +262,7 @@ jobs:
name: NVS Matrix Generation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install NVS generator
run: pip install esp-idf-nvs-partition-gen
@@ -316,7 +316,7 @@ jobs:
image: espressif/idf:v5.4
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Download QEMU artifact
uses: actions/download-artifact@v4
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
name: Verify fix markers
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
RUST_BACKTRACE: 1
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install mosquitto + clients and start with allow_anonymous
run: |
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v3
+3 -3
View File
@@ -75,7 +75,7 @@ jobs:
arch: AMD64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# Linux aarch64 needs QEMU for cross-build on x86_64 runners.
- name: Set up QEMU
@@ -120,7 +120,7 @@ jobs:
startsWith(github.ref, 'refs/tags/v2.')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install maturin
run: pip install maturin>=1.7
- name: Build sdist
@@ -143,7 +143,7 @@ jobs:
startsWith(github.ref, 'refs/tags/v1.99')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: '3.12'
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Stage viewer for Pages
run: |
+4 -4
View File
@@ -39,7 +39,7 @@ jobs:
- { label: 'ruflo+itar', flags: '--features ruflo,itar-unrestricted' }
- { label: 'full+train', flags: '--features full,train' }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
@@ -59,7 +59,7 @@ jobs:
name: clippy (-D warnings, --no-deps)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
# v2/rust-toolchain.toml pins channel "1.89" with profile "minimal" (no
# clippy). dtolnay@stable installs clippy on the floating "stable"
# toolchain, but the override makes cargo use the separate "1.89"
@@ -92,7 +92,7 @@ jobs:
name: build train_marl bin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
@@ -126,7 +126,7 @@ jobs:
name: ITAR / publish guard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: publish = false is present (no accidental crates.io publish)
run: |
CARGO=v2/crates/ruview-swarm/Cargo.toml
+23 -24
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -46,10 +46,7 @@ jobs:
- name: Run Bandit security scan
run: |
# The Python codebase lives under archive/v1/src (it moved there when
# the runtime was rewritten in Rust). Scanning `src/` matched nothing,
# so this SAST step was a silent no-op.
bandit -r archive/v1/src/ -f sarif -o bandit-results.sarif
bandit -r src/ -f sarif -o bandit-results.sarif
continue-on-error: true
- name: Upload Bandit results to GitHub Security
@@ -60,20 +57,22 @@ jobs:
sarif_file: bandit-results.sarif
category: bandit
# Removed the deprecated `returntocorp/semgrep-action@v1` step: it was
# redundant (the pip `semgrep --sarif` below is what feeds GitHub Security;
# the action only pushed to the Semgrep cloud app via SEMGREP_APP_TOKEN) and
# it pulled `returntocorp/semgrep-agent:v1` from Docker Hub on every run,
# which intermittently timed out and turned this check red. The pip semgrep
# (installed above) needs no Docker pull. The action's `p/docker` +
# `p/kubernetes` rulesets are folded into the command below so coverage is
# preserved.
- name: Run Semgrep + generate SARIF
- name: Run Semgrep security scan
continue-on-error: true
uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/secrets
p/python
p/docker
p/kubernetes
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
- name: Generate Semgrep SARIF
run: |
semgrep \
--config=p/security-audit --config=p/secrets --config=p/python \
--config=p/docker --config=p/kubernetes \
--sarif --output=semgrep.sarif archive/v1/src/
semgrep --config=p/security-audit --config=p/secrets --config=p/python --sarif --output=semgrep.sarif src/
continue-on-error: true
- name: Upload Semgrep results to GitHub Security
@@ -96,7 +95,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
continue-on-error: true
@@ -163,7 +162,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Docker Buildx
continue-on-error: true
@@ -244,7 +243,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Run Checkov IaC scan
continue-on-error: true
@@ -305,7 +304,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -340,7 +339,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
continue-on-error: true
@@ -377,7 +376,7 @@ jobs:
steps:
- name: Checkout code
continue-on-error: true
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Check security policy files
continue-on-error: true
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
name: build · push · smoke-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: recursive
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Stage demos for Pages
run: |
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true
fetch-depth: 0
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
+1 -6
View File
@@ -12,10 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
- **Person count no longer pinned to 1 — addresses #803.** The aggregate occupancy reported by the sensing server was derived from `smoothed_person_score`, an EMA-smoothed *activity* score (amplitude variance / motion / spectral energy). That score saturates near a single occupant — one moving person maxes it out — so it cannot discriminate occupancy *count* and stayed clamped at 1 across S3/C6 and the Python/Docker/Rust servers. Meanwhile the count-aware per-node estimates the ESP32 paths already compute (firmware `n_persons`, and the DynamicMinCut `corr_persons`) were stashed in `NodeState::prev_person_count` and then **discarded** by the aggregator (same dead-wiring class as #872). The aggregator now takes `max(activity_count, node_max)` via a unit-tested `aggregate_person_count` helper, so a node positively estimating 23 occupants is surfaced instead of overwritten. The fix can only ever *raise* the count when a node reports more people, so the single-occupant case is provably never inflated (regression-guarded by test). **Second half:** the pure-CSI per-node path itself clamped its own estimate — the DynamicMinCut occupancy (`estimate_persons_from_correlation`, 03) was mapped to a score via `corr_persons / 3.0`, putting 2 people at 0.667, *just under* the 0.70 up-threshold of `score_to_person_count`, so the per-node count never climbed past 1 (so `node_max` was also stuck at 1 for CSI-only nodes). Replaced it with a threshold-aligned `corr_persons_to_score` mapping (1→0.40, 2→0.74, 3→0.96) whose steady state round-trips back to the same count through the EMA + hysteresis, while still gating transient noise. A convergence test replays the exact EMA loop to prove min-cut=2 now reports 2 (and documents that the old `/3.0` mapping reported 1). Full multi-person accuracy still depends on the underlying estimator quality; this removes the two server-side clamps that masked it. 586 sensing-server tests pass.
- **MQTT publisher now actually runs (`--mqtt`) — closes #872.** The `--mqtt*` flags were defined only in `cli::Args` (dead code, referenced nowhere) while the binary parses a *separate* `main::Args` with no mqtt fields, and `main.rs` never started the `mqtt::` publisher — so MQTT/Home-Assistant integration was completely unwired (`--mqtt` errored as an unexpected argument, and even with the Docker image's `--features mqtt` build the publisher never ran). Earlier attempts chased a Docker *rebuild*; the real cause was disconnected *code*. Extracted the flags into a shared `cli::MqttArgs` (`#[command(flatten)]` into both structs), spawn the publisher on `--mqtt`, and bridge the JSON sensing broadcast into the typed `VitalsSnapshot` stream with a defensive `serde_json::Value` mapping. Verified end-to-end against `mosquitto`: 20 HA auto-discovery entities + live state (presence/person-count/…). 577 (default) / 580 (`--features mqtt`) tests pass.
- **Mass Casualty triage never reports a survivor with a heartbeat as Deceased (safety) — PR #926.** Both triage paths in `wifi-densepose-mat``TriageCalculator::calculate` (`combine_assessments(Absent, None) ⇒ Deceased`) and the detection path `EnsembleClassifier::determine_triage` (`!has_breathing && !has_movement ⇒ Deceased`) — ignored the `heartbeat` field. A survivor with a detectable **pulse** but no sensed breathing/movement (respiratory arrest — the most time-critical *savable* state, Immediate/Red) was therefore reported **Deceased (Black)** and deprioritized for rescue. The domain path was in fact only reachable *because* a heartbeat made `has_vitals()` true, so every "Deceased" was a live person. Both paths now escalate to **Immediate** when a heartbeat is present; total absence of breathing, movement *and* heartbeat is unchanged (domain → `Unknown`, ensemble → `Deceased`). 2 safety regression tests; full MAT suite (177) green.
- **Per-node Home-Assistant devices now report each node's *own* presence/motion — PR #918.** After the one-device-per-node fan-out landed, the MQTT bridge still applied the *room-level aggregate* `classification` to every node, so in a multi-node deployment a node watching an empty corner inherited another node's "present" (and `motion_level: "absent"` was mis-mapped to full motion). Each node in the broadcast `nodes[]` already carries its own `classification`; the bridge now reads it per node (extracted into a testable `vitals_snapshots_from_sensing_json`), keeping vitals + person count room-level. 4 unit tests.
- **`--model` gives an actionable diagnostic instead of a cryptic magic error — PR #919 (refs #894).** Passing a HuggingFace `ruvnet/wifi-densepose-pretrained` file (`model.safetensors` / `model-q4.bin` / `model.rvf.jsonl`) to `--model` produced `invalid magic at offset 0: … got 0x77455735`, then a silent fall back to heuristics. The load-failure path now detects the format (safetensors / quantized blob / JSONL manifest) and explains that those files are a different format **and** encoder architecture than the RVF binary container the progressive loader expects, pointing to #894. Pure `diagnose_model_load_error` + 4 tests.
- **`--export-rvf` no longer silently produces a placeholder model — PR #920.** The `--export-rvf` handler ran *before* `--train`/`--pretrain` and unconditionally wrote placeholder sine-wave weights, so the documented `--train … --export-rvf <path>` workflow short-circuited to a fake model and never trained (while printing "exported successfully"). It now emits the placeholder **container-format demo** only standalone (with a clear warning), and falls through to real training when `--train`/`--pretrain` is set; docs point to `--save-rvf` for the real model. 3 guard tests.
### Added
- **WiFi-CSI pose: efficiency frontier + per-room calibration service** (ADR-150 §3.23.6). Two beyond-SOTA results on the MM-Fi benchmark, plus the deployment mechanism that resolves real-world generalization:
@@ -37,7 +33,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
- **ESP32 OTA upload now fails closed when no PSK is provisioned** (#596 audit finding — critical, **breaking change for unprovisioned nodes**). `ota_check_auth()` previously returned `true` when `s_ota_psk[0] == '\0'`, so a freshly-flashed node would accept attacker-controlled firmware over plain HTTP on port 8032 from any host on the WiFi. No Secure Boot V2, no signed-image verification — a single LAN call could brick or backdoor a node. The fix rejects every OTA upload until a PSK is written to NVS (the OTA HTTP server still starts so operators can run `provision.py --ota-psk <hex>` over USB-CDC without reflashing). **Operators affected**: any deployment that relied on the unauthenticated OTA endpoint working out of the box now needs to provision a PSK before subsequent OTA pushes will succeed. Boot-time `ESP_LOGW` makes the new posture visible.
- **Bearer-token auth accepts the scheme case-insensitively (RFC 6750) — PR #929.** `require_bearer` parsed the `Authorization` header with a case-sensitive `strip_prefix("Bearer ")`, so a *correct* `RUVIEW_API_TOKEN` sent as `Authorization: bearer <token>` (or `BEARER`, or with extra whitespace) was rejected with a confusing 401 — needless friction when enabling auth. The scheme is now matched with `eq_ignore_ascii_case` (per RFC 6750 §2.1 / RFC 7235 §2.1); the token compare is unchanged — still exact and constant-time (`ct_eq`) — so a wrong token or a non-Bearer scheme (`Basic …`) still returns 401. Audited the surrounding code while here: `ct_eq` correctly rejects length mismatch (no prefix-auth bypass) and the middleware fails closed. New `accepts_case_insensitive_bearer_scheme` test.
- **Path-traversal vulnerabilities patched in five sensing-server endpoints** (closes #615 — critical). New `wifi_densepose_sensing_server::path_safety::safe_id()` enforces `[A-Za-z0-9._-]` only (no leading `.`, max 64 chars) before any user-controlled identifier reaches a `format!()` building a filesystem path. Applied at:
- `POST /api/v1/recording/start` (`recording.rs``session_name`)
- `GET /api/v1/recording/download/:id` (`recording.rs``id`)
@@ -435,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. *(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.)*
- Presence detection: 100% accuracy on 60,630 overnight samples.
- Inference: 0.008 ms per sample, 164K embeddings/sec.
- Contrastive self-supervised training: 51.6% improvement over baseline.
+2 -5
View File
@@ -24,13 +24,10 @@ services:
environment:
- RUST_LOG=info
# CSI_SOURCE controls the data source for the sensing server.
# Options: auto (default) — probe for ESP32 UDP then host WiFi; **fail
# hard with exit 78 if neither is detected**.
# Synthetic data is no longer a silent fallback
# (issue #937 fix) — operators must opt in.
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
# wifi — use host Wi-Fi RSSI/scan data (Windows netsh)
# simulated — explicitly generate synthetic CSI for demo mode
# simulated — generate synthetic CSI data (no hardware required)
- CSI_SOURCE=${CSI_SOURCE:-auto}
# MODELS_DIR controls where the server scans for .rvf model files.
# Mount a host directory and set this to make models visible:
+2 -57
View File
@@ -11,65 +11,10 @@
# docker run ruvnet/wifi-densepose:latest --model /app/models/my.rvf
#
# Environment variables:
# CSI_SOURCE — data source. Valid values:
# auto — try ESP32 then Windows WiFi, **fail-loud if no
# real hardware is detected** (issue #937 fix:
# the server no longer silently falls back to
# synthetic data — that's now opt-in only).
# esp32 — listen for UDP CSI on the configured port.
# wifi — Windows-native WiFi capture.
# simulated — explicit demo mode with synthetic CSI.
# Default is `auto`. Set CSI_SOURCE=simulated when you want
# fake data tagged as such; never set it implicitly.
# CSI_SOURCE — data source: auto (default), esp32, wifi, simulated
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
set -e
# ── Issue #864: fail-closed on default posture ───────────────────────────────
# The pre-fix default was: empty RUVIEW_API_TOKEN (auth off) + --bind-addr
# 0.0.0.0 + docker-compose publishing :3000/:3001/:5005 → an unauthenticated
# attacker on any reachable network segment could read /api/v1/sensing/latest
# and the /ws/sensing live stream. That posture is unsafe on guest WiFi,
# untrusted LANs, accidentally-port-forwarded hosts, or any reverse-proxied
# deployment. Refuse to start with this combination.
#
# Escape hatches (operator must opt in explicitly):
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
# only safe on an isolated trust boundary.
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
#
# This check runs only for the default sensing-server path (no args + flag-only
# args). The `cog-ha-matter` / `homecore` routes below are excluded because
# they own their own auth lifecycle.
case "${1:-}" in
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
*)
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
# If the operator hasn't overridden the bind, refuse outright on
# the default 0.0.0.0. If they've nailed it to loopback (or a
# specific private address they trust), let it run.
__bind_default="${RUVIEW_BIND_ADDR:-0.0.0.0}"
case "$__bind_default" in
127.*|localhost|::1)
: ;; # loopback bind is safe even without a token
*)
echo "[entrypoint] ERROR: refusing to start sensing-server with default" >&2
echo "[entrypoint] posture: RUVIEW_API_TOKEN is unset AND bind is" >&2
echo "[entrypoint] ${__bind_default}. /ws/sensing streams live sensing" >&2
echo "[entrypoint] frames; that data would be readable by anyone who" >&2
echo "[entrypoint] can reach this host. Pick one:" >&2
echo "[entrypoint] docker run -e RUVIEW_API_TOKEN=\$(openssl rand -hex 32) ..." >&2
echo "[entrypoint] docker run -e RUVIEW_BIND_ADDR=127.0.0.1 ..." >&2
echo "[entrypoint] docker run -e RUVIEW_ALLOW_UNAUTHENTICATED=1 ... # only on trusted network" >&2
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/864" >&2
exit 64
;;
esac
fi
;;
esac
# Route to cog-ha-matter (ADR-116) when invoked as:
# docker run <image> cog-ha-matter [--flags]
# or via the short alias `ha-matter`. Strips the keyword and execs the
@@ -103,7 +48,7 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
--ui-path /app/ui \
--http-port 3000 \
--ws-port 3001 \
--bind-addr "${RUVIEW_BIND_ADDR:-0.0.0.0}" \
--bind-addr 0.0.0.0 \
"$@"
fi
+3 -3
View File
@@ -122,7 +122,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm # benchmark
| What we measured | Result | Why it matters |
|-----------------|--------|---------------|
| **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)) |
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
| **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), 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 |
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 100% presence accuracy, 0 skeleton violations | 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 |
|-----------------|--------|---------------|
| **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)) |
| **Presence detection** | **100% accuracy** | Never misses a person, never false alarms |
| **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 |
+5 -5
View File
@@ -1048,7 +1048,7 @@ The Rust sensing server binary accepts the following flags:
| `--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 a **placeholder** RVF container-format demo and exit — **not a trained model**. For a real model use `--train` (+ `--save-rvf`) or download a pretrained encoder. |
| `--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 |
@@ -1119,7 +1119,7 @@ What it ships (and what it does not):
| Capability | Status |
|------------|--------|
| 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)) |
| 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 |
@@ -1359,7 +1359,7 @@ docker run --rm \
-v $(pwd)/output:/output \
--entrypoint /app/sensing-server \
ruvnet/wifi-densepose:latest \
--train --dataset /data --epochs 100 --save-rvf /output/model.rvf
--train --dataset /data --epochs 100 --export-rvf /output/model.rvf
```
The pipeline runs 10 phases:
@@ -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 (v2 encoder: 82.3% held-out triplet acc)
# presence-head.json — presence detection head (100% accuracy)
# 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** — 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))
- **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
@@ -65,15 +65,6 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC
d_m3LogOutput=0 # Disable WASM3 stdout logging (use ESP_LOG)
d_m3FixedHeap=0 # Use dynamic allocation (PSRAM-friendly)
WASM3_AVAILABLE=1 # Flag for conditional compilation
# Issue #946: GCC 15.2.0 for Xtensa (ESP-IDF v6.0.1) rejects wasm3's
# `M3_MUSTTAIL` aggressive tail-call attribute with
# "cannot tail-call: machine description does not have a sibcall_epilogue
# instruction pattern". wasm3 falls back to a regular call sequence when
# M3_NO_MUSTTAIL is defined — slightly slower per opcode but functionally
# identical. Forcing it off unconditionally on Xtensa is fine because the
# tail-call optimisation was never reliable on this target anyway. Older
# IDF/GCC builds also accept the define (it just becomes a no-op).
M3_NO_MUSTTAIL=1
)
# Suppress warnings from third-party code.
@@ -220,20 +220,11 @@ static void fast_loop_cb(TimerHandle_t t)
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
apply_decision(&dec);
/* ADR-081 Layer 4/5: emit compact feature state at 1 Hz (the spec's
* 110 Hz floor). Was previously emitted on every fast tick (~5 Hz at
* the default 200 ms fast period), which combined with CSI promiscuous
* RX saturated the WiFi TX airtime — measured live on COM8 (S3) and
* COM9 (C6): every adaptive cycle showed `sendto ENOMEM — backing off
* for 100 ms`, and bumping LWIP/WiFi buffer pools to 4× had no effect
* on the rate because the bottleneck was radio TX time, not pool size.
* Dropping to 1 Hz (5× less feature_state traffic) frees the TX queue
* for CSI sends and lands well within the spec. */
static uint8_t s_emit_divider = 0;
if (++s_emit_divider >= 5) {
s_emit_divider = 0;
emit_feature_state();
}
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
* (default 200 ms → 5 Hz, within the 110 Hz spec). Replaces raw
* ADR-018 CSI as the default upstream; raw remains available as a
* debug stream gated by the channel plan. */
emit_feature_state();
}
static void medium_loop_cb(TimerHandle_t t)
@@ -21,7 +21,6 @@
#include "esp_wifi.h"
#include "esp_mac.h"
#include "esp_timer.h"
#include "esp_idf_version.h"
#include "freertos/FreeRTOS.h"
#include "freertos/timers.h"
#include <string.h>
@@ -145,27 +144,11 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
}
}
/* Issue #944: ESP-IDF v6.0 changed `esp_now_send_cb_t` from
* void (*)(const uint8_t *mac, esp_now_send_status_t status)
* to
* void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
* Both signatures ignore the address-side argument here — we only inspect
* `status` to bump the TX-fail counter — so the body is identical; only the
* function-pointer type differs. ESP_IDF_VERSION_MAJOR is the canonical guard.
*/
#if ESP_IDF_VERSION_MAJOR >= 6
static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
{
(void)tx_info;
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
}
#else
static void on_send(const uint8_t *mac, esp_now_send_status_t status)
{
(void)mac;
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
}
#endif
static void beacon_timer_cb(TimerHandle_t t)
{
@@ -23,9 +23,6 @@
#include "esp_wifi.h"
#include "esp_timer.h"
#include "sdkconfig.h"
#include "esp_netif.h" /* #954: STA gateway lookup for self-ping CSI source */
#include "ping/ping_sock.h" /* #954: esp_ping gateway traffic generator */
#include "lwip/ip_addr.h" /* #954: ip_addr_t target for esp_ping */
/* ADR-060: Access the global NVS config for MAC filter and channel override. */
extern nvs_config_t g_nvs_config;
@@ -368,67 +365,6 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
(void)type;
}
/* ---- RuView#521/#954: connected-STA CSI traffic source (additive) ----
*
* The ESP32 CSI engine only produces CSI for received OFDM frames (L-LTF/HT-LTF).
* On a quiet network — or on a display-enabled build where the #893 MGMT->MGMT+DATA
* promiscuous upgrade is skipped (has_display=true) — the only CSI-eligible frames
* are sparse beacons (often non-OFDM DSSS), so wifi_csi_callback can starve to
* yield=0pps -> DEGRADED -> motion/presence=0 (#521, #954).
*
* This guarantees a ~50 Hz OFDM unicast floor by pinging the STA's own gateway:
* the router's ICMP echo replies are OFDM frames destined to this station, which
* drive the CSI engine regardless of promiscuous filter state or ambient traffic.
* It is ADDITIVE — promiscuous capture (#396/#893) is left fully intact so
* multistatic/multi-node sensing still hears other stations' frames. Mirrors
* Espressif's esp-csi csi_recv_router reference.
*/
static esp_ping_handle_t s_self_ping = NULL;
static void csi_ping_cb_noop(esp_ping_handle_t hdl, void *args) { (void)hdl; (void)args; }
static void csi_start_self_ping(void)
{
if (s_self_ping != NULL) {
return; /* already running */
}
esp_netif_t *sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
esp_netif_ip_info_t ip;
if (sta == NULL || esp_netif_get_ip_info(sta, &ip) != ESP_OK || ip.gw.addr == 0) {
ESP_LOGW(TAG, "self-ping: no gateway IP yet; CSI relies on ambient frames (#954)");
return;
}
char gw_str[16];
esp_ip4addr_ntoa(&ip.gw, gw_str, sizeof(gw_str));
ip_addr_t target;
memset(&target, 0, sizeof(target));
ipaddr_aton(gw_str, &target);
esp_ping_config_t cfg = ESP_PING_DEFAULT_CONFIG();
cfg.target_addr = target;
cfg.count = ESP_PING_COUNT_INFINITE;
cfg.interval_ms = 20; /* 50 Hz -> ~50 received OFDM replies/sec */
cfg.data_size = 1;
cfg.task_stack_size = 4096;
esp_ping_callbacks_t cbs = {
.cb_args = NULL,
.on_ping_success = csi_ping_cb_noop,
.on_ping_timeout = csi_ping_cb_noop,
.on_ping_end = csi_ping_cb_noop,
};
if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) {
esp_ping_start(s_self_ping);
ESP_LOGI(TAG, "self-ping started -> %s @50Hz (CSI OFDM source, fix #521/#954)", gw_str);
} else {
ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed");
s_self_ping = NULL;
}
}
void csi_collector_set_node_id(uint8_t node_id)
{
s_node_id = node_id;
@@ -590,11 +526,6 @@ void csi_collector_init(void)
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
(unsigned)s_node_id, (unsigned)csi_channel);
/* RuView#521/#954: start the connected-STA traffic source so the CSI engine
* receives a guaranteed OFDM unicast floor even when promiscuous capture is
* starved (display builds / quiet networks). Additive to #396/#893. */
csi_start_self_ping();
}
/* Accessor for other modules that need the authoritative runtime node_id. */
@@ -12,8 +12,7 @@
* 0xC5110003 — ADR-069 feature vector (edge_processing.h)
* 0xC5110004 — ADR-063 fused vitals (edge_processing.h)
* 0xC5110005 — ADR-039 compressed CSI (edge_processing.h)
* 0xC5110006 — ADR-081 feature state (this file)
* 0xC5110007 — ADR-040 WASM output (wasm_runtime.h, reassigned per issue #928)
* 0xC5110006 — ADR-081 feature state (this file) ← new
*/
#ifndef RV_FEATURE_STATE_H
+1 -10
View File
@@ -23,16 +23,7 @@
static const char *TAG = "swarm";
/* ---- Task parameters ---- */
/* Issue #949: 3 KB was sized for plain HTTP (~2.5 KB). The bug reporter
* configured `--seed-url https://…` which exercises TLS — mbedTLS handshake
* alone needs 4-6 KB on the stack (cipher suite + cert chain + ECDH), and on
* top of that esp_http_client adds another 1.5-2 KB. The task panicked with
* `0xa5a5a5a5` (FreeRTOS stack-fill sentinel) immediately after "bridge init
* OK". 8 KB comfortably fits TLS with margin for the cert chain + headers;
* confirmed against mbedTLS's stack analyser. Plain-HTTP deployments waste
* ~5 KB of headroom but that's <0.1 % of PSRAM, an acceptable cost for the
* bug class this prevents. */
#define SWARM_TASK_STACK 8192 /**< 8 KB stack — fits mbedTLS handshake. */
#define SWARM_TASK_STACK 3072 /**< 3 KB stack — HTTP client uses ~2.5 KB. */
#define SWARM_TASK_PRIO 3
#define SWARM_TASK_CORE 0
#define SWARM_HTTP_TIMEOUT 3000 /**< HTTP timeout in ms (Seed responds <100ms on LAN). */
+2 -11
View File
@@ -43,16 +43,7 @@
#define WASM_MAX_MODULE_SIZE (128 * 1024) /**< Max .wasm binary size (128 KB). */
#define WASM_STACK_SIZE (8 * 1024) /**< WASM execution stack (8 KB). */
/* Issue #928: WASM output was originally 0xC5110004, but that magic is
* canonically owned by ADR-063 fused vitals (edge_processing.h). Both packets
* were transmitted on the same magic, and the host parser only knew the WASM
* shape, so on the ESP32-C6 + MR60BHA2 mmWave config the 48-byte fused-vitals
* packet was being read as garbage WASM events. Reassigned to 0xC5110007 (next
* free slot in the registry — see rv_feature_state.h). Firmware older than
* this commit will silently lose its WASM event stream against an updated host
* — that's the deliberate "fail loud" choice over silent misparsing.
*/
#define WASM_OUTPUT_MAGIC 0xC5110007 /**< WASM output packet magic (post-#928). */
#define WASM_OUTPUT_MAGIC 0xC5110004 /**< WASM output packet magic. */
#define WASM_MAX_EVENTS 16 /**< Max events per output packet. */
/* ---- WASM Event (5 bytes: u8 type + f32 value) ---- */
@@ -63,7 +54,7 @@ typedef struct __attribute__((packed)) {
/* ---- WASM Output Packet ---- */
typedef struct __attribute__((packed)) {
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110007 (issue #928). */
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110004. */
uint8_t node_id; /**< ESP32 node identifier. */
uint8_t module_id; /**< Module slot index. */
uint16_t event_count; /**< Number of events in this packet. */
@@ -29,30 +29,6 @@ CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# LWIP: enable extended socket options for UDP multicast
CONFIG_LWIP_SO_RCVBUF=y
# Issue (sibling of #946/#949/#864 cluster): UDP `sendto` returned ENOMEM
# in a tight loop on both ESP32-S3 (COM8) and ESP32-C6 (COM9) at the v0.7.0
# CSI packet rate (CSI cb + status + sync + feature_state all sharing the
# LWIP/WiFi pools). stream_sender.c has a cooldown path so the device
# doesn't crash, but ~90 % of CSI frames were dropped before reaching the
# host — boot trace showed `sendto ENOMEM — backing off 100 ms` repeating
# every capture cycle. Stock IDF v5.4 defaults: UDP recv mbox=6, TCPIP
# mbox=32, WiFi dynamic TX buffers=32 — too small once CSI promiscuous
# mode is active. These bumps roughly quadruple the relevant pools at
# ~3 KB extra heap cost, measured live on both targets Jun 8 2026.
CONFIG_LWIP_UDP_RECVMBOX_SIZE=32
CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=64
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=64
# NOTE: Empirical 25 s measurements on the S3 at COM8 showed these bumps
# eliminate the csi_collector.sendto failure path (`fail #1..5` →
# `fail #0`) — real improvement — but do NOT eliminate the broader
# `feature_state emit` ENOMEM at ~10/s. That residual is the WiFi
# radio's TX airtime saturating under CSI promiscuous RX, and bigger
# buffers cap out at the 100 ms backoff window regardless of size
# (verified at WIFI_DYNAMIC_TX=128 + PBUF_POOL=32 — identical count).
# The proper fix is rate-limiting adaptive_controller.c's emit cadence
# from ~50 ms to the intended 1 Hz, which is a code refactor tracked
# in a separate follow-up issue.
# FreeRTOS: increase task stack for CSI processing
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
@@ -108,14 +108,8 @@ pub async fn start_server(
cmd.args(["--log-level", log_level]);
}
// Default to explicit "simulated" demo mode when the desktop user hasn't
// chosen a source — this is the *Tauri demo* app, not a production
// sensing endpoint, so the demo default is correct here. Critically, the
// value passed downstream is the **explicit** "simulated", not "auto",
// which means the sensing-server will tag the data as synthetic in its
// API responses rather than silently fall back (issue #937 fix in
// sensing-server's `auto` handler).
let source = config.source.as_deref().unwrap_or("simulated");
// Set data source (default to "simulate" if not specified for demo mode)
let source = config.source.as_deref().unwrap_or("simulate");
cmd.args(["--source", source]);
// Redirect stdout/stderr to pipes for monitoring
@@ -323,7 +317,7 @@ pub async fn restart_server(
log_level: None,
bind_address: None,
server_path: None,
source: None, // Falls through to explicit "simulated" — Tauri demo default.
source: None, // Use default (simulate)
}
};
@@ -172,14 +172,6 @@ impl EnsembleClassifier {
let has_movement = reading.movement.movement_type != MovementType::None;
if !has_breathing && !has_movement {
// SAFETY: a detectable heartbeat means the survivor is ALIVE. No
// sensed breathing/movement *with* a pulse is respiratory arrest —
// the most time-critical savable state (Immediate), never Deceased.
// Only the total absence of breathing, movement AND heartbeat is
// reported Deceased.
if reading.heartbeat.is_some() {
return TriageStatus::Immediate;
}
return TriageStatus::Deceased;
}
@@ -303,27 +295,6 @@ mod tests {
assert_eq!(result.recommended_triage, TriageStatus::Deceased);
}
/// SAFETY regression: heartbeat present but no sensed breathing/movement is
/// respiratory arrest — Immediate, never Deceased. Only the *total* absence
/// of breathing, movement AND heartbeat (the test above) is Deceased.
#[test]
fn test_heartbeat_with_no_breathing_or_movement_is_immediate() {
// breathing: None, heartbeat: Some(72 bpm), movement: None
let reading = make_reading(None, Some(72.0), MovementType::None);
let classifier = EnsembleClassifier::new(EnsembleConfig {
min_ensemble_confidence: 0.0,
..EnsembleConfig::default()
});
let result = classifier.classify(&reading);
assert_eq!(
result.recommended_triage,
TriageStatus::Immediate,
"a survivor with a pulse must never be triaged Deceased"
);
}
#[test]
fn test_ensemble_confidence_weighting() {
let classifier = EnsembleClassifier::new(EnsembleConfig {
@@ -104,20 +104,7 @@ impl TriageCalculator {
let movement_status = Self::assess_movement(vitals);
// Step 4: Combine assessments
let status = Self::combine_assessments(breathing_status, movement_status);
// Step 5: SAFETY OVERRIDE — a detectable heartbeat means the survivor is
// ALIVE. `combine_assessments` only sees breathing + movement, so a
// person with a pulse but no *sensed* breathing/movement (respiratory
// arrest, or breathing too shallow for CSI to pick up) would otherwise
// be reported Deceased and deprioritized for rescue. No breathing + a
// pulse is the most time-critical *savable* state, so escalate to
// Immediate rather than ever calling a survivor with a heartbeat dead.
if status == TriageStatus::Deceased && vitals.heartbeat.is_some() {
return TriageStatus::Immediate;
}
status
Self::combine_assessments(breathing_status, movement_status)
}
/// Assess breathing status
@@ -230,9 +217,7 @@ enum MovementAssessment {
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
};
use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile};
use chrono::Utc;
fn create_vitals(
@@ -248,29 +233,6 @@ mod tests {
}
}
/// SAFETY regression: a survivor with a detectable heartbeat but no sensed
/// breathing or movement is in respiratory arrest — Immediate (Red), and
/// must NEVER be reported Deceased. (Before the fix, `combine_assessments`
/// ignored heartbeat and returned Deceased; that path was in fact only
/// reachable *because* a heartbeat made `has_vitals()` true.)
#[test]
fn heartbeat_with_no_breathing_or_movement_is_immediate_not_deceased() {
let vitals = VitalSignsReading {
breathing: None,
heartbeat: Some(HeartbeatSignature {
rate_bpm: 72.0,
variability: 0.1,
strength: SignalStrength::Moderate,
}),
movement: MovementProfile::default(),
timestamp: Utc::now(),
confidence: ConfidenceScore::new(0.8),
};
let status = TriageCalculator::calculate(&vitals);
assert_eq!(status, TriageStatus::Immediate, "pulse present ⇒ alive");
assert_ne!(status, TriageStatus::Deceased);
}
#[test]
fn test_no_vitals_is_unknown() {
let vitals = create_vitals(None, MovementProfile::default());
@@ -100,17 +100,7 @@ pub async fn require_bearer(
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme ("Bearer") is
// case-insensitive. Match it as such (and tolerate extra leading
// whitespace before the token) so a correct token isn't rejected
// just because a client sent `bearer`/`BEARER`. The token compare
// below stays exact + constant-time.
.and_then(|s| {
let (scheme, token) = s.split_once(' ')?;
scheme
.eq_ignore_ascii_case("Bearer")
.then(|| token.trim_start())
});
.and_then(|s| s.strip_prefix("Bearer "));
let ok = supplied
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
.unwrap_or(false);
@@ -195,31 +185,6 @@ mod tests {
);
}
#[tokio::test]
async fn accepts_case_insensitive_bearer_scheme() {
// RFC 6750 §2.1 / RFC 7235 §2.1: the auth-scheme is case-insensitive.
// A correct token must authenticate regardless of scheme casing or
// extra whitespace; a wrong token must still be rejected.
async fn req_status(auth_value: &str) -> StatusCode {
let r = wrap(AuthState::from_token("s3cr3t"));
let mut req = Request::builder()
.method("GET")
.uri("/api/v1/info")
.body(Body::empty())
.unwrap();
req.headers_mut()
.insert(AUTHORIZATION, auth_value.parse().unwrap());
r.oneshot(req).await.unwrap().status()
}
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("bearer s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("BEARER s3cr3t").await, StatusCode::OK);
assert_eq!(req_status("Bearer s3cr3t").await, StatusCode::OK); // extra space
// Scheme leniency must NOT weaken the token check.
assert_eq!(req_status("bearer nope").await, StatusCode::UNAUTHORIZED);
assert_eq!(req_status("Basic s3cr3t").await, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn enabled_blocks_api_with_wrong_bearer() {
let r = wrap(AuthState::from_token("s3cr3t"));
@@ -45,14 +45,13 @@ pub fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
})
}
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
/// the original 0xC511_0004 collided with ADR-063 fused vitals).
/// Parse a WASM output packet (magic 0xC511_0004).
pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
if buf.len() < 8 {
return None;
}
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != 0xC511_0007 {
if magic != 0xC511_0004 {
return None;
}
@@ -1114,7 +1114,7 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
})
}
// ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ─────
// ── ADR-040: WASM Output Packet (magic 0xC511_0004) ───────────────────────────
/// Single WASM event (type + value).
#[derive(Debug, Clone, Serialize)]
@@ -1131,14 +1131,13 @@ struct WasmOutputPacket {
events: Vec<WasmEvent>,
}
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
/// the original 0xC511_0004 was a collision with ADR-063 fused vitals).
/// Parse a WASM output packet (magic 0xC511_0004).
fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
if buf.len() < 8 {
return None;
}
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != 0xC511_0007 {
if magic != 0xC511_0004 {
return None;
}
@@ -1170,187 +1169,6 @@ fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
})
}
// ── ADR-063: Edge Fused Vitals Packet (magic 0xC511_0004) ─────────────────────
//
// 48-byte packed struct emitted by the ESP32-C6 + MR60BHA2 mmWave config when
// `mmwave_sensor_get_state().detected` is true. Byte layout from
// `firmware/esp32-csi-node/main/edge_processing.h` line 129 — kept in lockstep
// with the firmware's `_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48)`.
// Issue #928 surfaced that this magic was being parsed as WASM output and the
// fused vitals were silently lost. Adding the proper parser here.
#[derive(Debug, Clone, Serialize)]
struct EdgeFusedVitalsPacket {
node_id: u8,
/// Bit0=presence, Bit1=fall, Bit2=motion, Bit3=mmwave_present.
flags: u8,
/// Fused breathing rate in BPM (firmware sends BPM*100; we scale here).
breathing_rate_bpm: f32,
/// Fused heartrate in BPM (firmware sends BPM*10000; we scale here).
heartrate_bpm: f32,
rssi: i8,
n_persons: u8,
/// `mmwave_type_t` enum value from firmware.
mmwave_type: u8,
/// 0-100 fusion quality score.
fusion_confidence: u8,
motion_energy: f32,
presence_score: f32,
timestamp_ms: u32,
/// Raw mmWave heart rate (BPM).
mmwave_hr_bpm: f32,
/// Raw mmWave breathing rate (BPM).
mmwave_br_bpm: f32,
/// Distance to nearest target (cm).
mmwave_distance_cm: f32,
/// Target count from mmWave.
mmwave_targets: u8,
/// mmWave signal quality 0-100.
mmwave_confidence: u8,
}
/// Parse an ADR-063 edge fused vitals packet (magic 0xC511_0004, 48 bytes).
fn parse_edge_fused_vitals(buf: &[u8]) -> Option<EdgeFusedVitalsPacket> {
if buf.len() < 48 {
return None;
}
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != 0xC511_0004 {
return None;
}
let node_id = buf[4];
let flags = buf[5];
let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]);
let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let rssi = buf[12] as i8;
let n_persons = buf[13];
let mmwave_type = buf[14];
let fusion_confidence = buf[15];
let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]);
let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]);
let mmwave_hr_bpm = f32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]);
let mmwave_br_bpm = f32::from_le_bytes([buf[32], buf[33], buf[34], buf[35]]);
let mmwave_distance_cm = f32::from_le_bytes([buf[36], buf[37], buf[38], buf[39]]);
let mmwave_targets = buf[40];
let mmwave_confidence = buf[41];
// buf[42..48] are firmware reserved fields (reserved3 u16 + reserved4 u32).
Some(EdgeFusedVitalsPacket {
node_id,
flags,
breathing_rate_bpm: breathing_raw as f32 / 100.0,
heartrate_bpm: heartrate_raw as f32 / 10000.0,
rssi,
n_persons,
mmwave_type,
fusion_confidence,
motion_energy,
presence_score,
timestamp_ms,
mmwave_hr_bpm,
mmwave_br_bpm,
mmwave_distance_cm,
mmwave_targets,
mmwave_confidence,
})
}
#[cfg(test)]
mod issue_928_magic_collision_tests {
//! Issue #928 — `0xC511_0004` was being parsed as WASM output, eating the
//! C6+mmWave fused-vitals packets. After this fix, `0xC511_0004` routes to
//! `parse_edge_fused_vitals` and WASM output owns the freshly-allocated
//! `0xC511_0007` slot. Tests guard both halves of the swap.
use super::*;
/// Build a 48-byte synthetic fused-vitals packet matching the firmware's
/// `edge_fused_vitals_pkt_t` layout from `edge_processing.h:129`.
fn build_fused_vitals_packet() -> Vec<u8> {
let mut buf = vec![0u8; 48];
buf[0..4].copy_from_slice(&0xC511_0004u32.to_le_bytes());
buf[4] = 9; // node_id
buf[5] = 0b0000_1001; // flags: presence | mmwave_present
buf[6..8].copy_from_slice(&1600u16.to_le_bytes()); // breathing 16.00 BPM
buf[8..12].copy_from_slice(&720_000u32.to_le_bytes()); // heartrate 72.0 BPM
buf[12] = (-55i8) as u8; // rssi
buf[13] = 1; // n_persons
buf[14] = 2; // mmwave_type
buf[15] = 85; // fusion_confidence
buf[16..20].copy_from_slice(&0.42f32.to_le_bytes()); // motion_energy
buf[20..24].copy_from_slice(&0.95f32.to_le_bytes()); // presence_score
buf[24..28].copy_from_slice(&1_234_567u32.to_le_bytes()); // timestamp_ms
buf[28..32].copy_from_slice(&71.5f32.to_le_bytes()); // mmwave_hr_bpm
buf[32..36].copy_from_slice(&15.8f32.to_le_bytes()); // mmwave_br_bpm
buf[36..40].copy_from_slice(&182.0f32.to_le_bytes()); // mmwave_distance_cm
buf[40] = 1; // mmwave_targets
buf[41] = 90; // mmwave_confidence
// bytes 42..48 — firmware reserved fields, left as zero
buf
}
#[test]
fn parse_edge_fused_vitals_extracts_fields_correctly() {
let buf = build_fused_vitals_packet();
let pkt = parse_edge_fused_vitals(&buf).expect("must parse a well-formed packet");
assert_eq!(pkt.node_id, 9);
assert_eq!(pkt.flags, 0b0000_1001);
assert!((pkt.breathing_rate_bpm - 16.0).abs() < 1e-3, "breathing scale 100");
assert!((pkt.heartrate_bpm - 72.0).abs() < 1e-3, "heartrate scale 10000");
assert_eq!(pkt.rssi, -55);
assert_eq!(pkt.n_persons, 1);
assert_eq!(pkt.mmwave_type, 2);
assert_eq!(pkt.fusion_confidence, 85);
assert!((pkt.motion_energy - 0.42).abs() < 1e-6);
assert!((pkt.presence_score - 0.95).abs() < 1e-6);
assert_eq!(pkt.timestamp_ms, 1_234_567);
assert!((pkt.mmwave_hr_bpm - 71.5).abs() < 1e-6);
assert!((pkt.mmwave_br_bpm - 15.8).abs() < 1e-3);
assert!((pkt.mmwave_distance_cm - 182.0).abs() < 1e-6);
assert_eq!(pkt.mmwave_targets, 1);
assert_eq!(pkt.mmwave_confidence, 90);
}
#[test]
fn parse_edge_fused_vitals_rejects_short_buffer() {
let buf = build_fused_vitals_packet();
// Truncate to 47 bytes — one short of the 48-byte minimum.
assert!(parse_edge_fused_vitals(&buf[..47]).is_none());
}
#[test]
fn parse_edge_fused_vitals_rejects_wrong_magic() {
let mut buf = build_fused_vitals_packet();
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes()); // WASM magic, not fused
assert!(parse_edge_fused_vitals(&buf).is_none());
}
#[test]
fn parse_wasm_output_rejects_legacy_0004_magic() {
// The old WASM magic collided with fused vitals — must no longer be
// accepted. A real fused-vitals packet starts with 0xC511_0004 and
// would have been misparsed before this fix.
let buf = build_fused_vitals_packet();
assert!(parse_wasm_output(&buf).is_none(),
"issue #928: WASM parser must NOT accept 0xC511_0004");
}
#[test]
fn parse_wasm_output_accepts_new_0007_magic() {
// Build a tiny well-formed WASM output packet on the new magic.
let mut buf = vec![0u8; 8];
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes());
buf[4] = 5; // node_id
buf[5] = 1; // module_id
buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // event_count = 0
let pkt = parse_wasm_output(&buf).expect("0xC511_0007 must parse");
assert_eq!(pkt.node_id, 5);
assert_eq!(pkt.module_id, 1);
assert!(pkt.events.is_empty());
}
}
// ── ESP32 UDP frame parser ───────────────────────────────────────────────────
fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
@@ -5161,45 +4979,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
}
}
// ADR-063: Try edge fused vitals packet (magic 0xC511_0004).
// Must come BEFORE the WASM parser — issue #928: these two
// packet types shared a magic and the WASM parser was eating
// fused-vitals frames on the C6+mmWave config. The reassign of
// WASM_OUTPUT_MAGIC → 0xC511_0007 (firmware side) plus this
// dedicated parser resolve the collision.
if let Some(fused) = parse_edge_fused_vitals(&buf[..len]) {
debug!(
"Edge fused vitals from {src}: node={} br={:.1} hr={:.1} \
mmwave_targets={} fusion_conf={}",
fused.node_id, fused.breathing_rate_bpm, fused.heartrate_bpm,
fused.mmwave_targets, fused.fusion_confidence,
);
let s = state.write().await;
if let Ok(json) = serde_json::to_string(&serde_json::json!({
"type": "edge_fused_vitals",
"node_id": fused.node_id,
"breathing_rate_bpm": fused.breathing_rate_bpm,
"heartrate_bpm": fused.heartrate_bpm,
"n_persons": fused.n_persons,
"fusion_confidence": fused.fusion_confidence,
"mmwave": {
"hr_bpm": fused.mmwave_hr_bpm,
"br_bpm": fused.mmwave_br_bpm,
"distance_cm": fused.mmwave_distance_cm,
"targets": fused.mmwave_targets,
"confidence": fused.mmwave_confidence,
"type": fused.mmwave_type,
},
"motion_energy": fused.motion_energy,
"presence_score": fused.presence_score,
"timestamp_ms": fused.timestamp_ms,
})) {
let _ = s.tx.send(json);
}
continue;
}
// ADR-040: Try WASM output packet (magic 0xC511_0007 post-#928).
// ADR-040: Try WASM output packet (magic 0xC511_0004).
if let Some(wasm_output) = parse_wasm_output(&buf[..len]) {
debug!(
"WASM output from {src}: node={} module={} events={}",
@@ -5696,159 +5476,6 @@ 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()
)
}
/// Whether `--export-rvf` should emit the placeholder container-format demo.
///
/// It must only do so **standalone**. Combined with `--train`/`--pretrain` the
/// real model is produced by the training pipeline, so short-circuiting here
/// would silently skip training and write placeholder weights — the #894 bug
/// where the documented `--train … --export-rvf` workflow produced a fake model.
fn export_emits_placeholder_demo(export_set: bool, train: bool, pretrain: bool) -> bool {
export_set && !train && !pretrain
}
// ── Main ─────────────────────────────────────────────────────────────────────
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
@@ -5892,24 +5519,9 @@ async fn main() {
return;
}
// Handle --export-rvf: writes a CONTAINER-FORMAT DEMO with placeholder
// weights — it is NOT a trained model. Only short-circuit when standalone:
// combined with --train/--pretrain the real model is exported by the
// training pipeline, and short-circuiting here would silently skip training
// and write placeholder weights (#894 — the documented `--train …
// --export-rvf` workflow produced a placeholder and never trained).
if export_emits_placeholder_demo(args.export_rvf.is_some(), args.train, args.pretrain) {
let rvf_path = args
.export_rvf
.as_ref()
.expect("export_emits_placeholder_demo implies export_rvf is set");
eprintln!(
"WARNING: --export-rvf writes a CONTAINER-FORMAT DEMO with placeholder \
weights it is NOT a trained model. Train one with \
`--train --dataset <DIR>` (which exports a calibrated .rvf to the \
models/ directory), or download a pretrained encoder. See issue #894."
);
eprintln!("Exporting RVF container package (placeholder weights)...");
// Handle --export-rvf mode: build an RVF container package and exit
if let Some(ref rvf_path) = args.export_rvf {
eprintln!("Exporting RVF container package...");
use rvf_pipeline::RvfModelBuilder;
let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0");
@@ -5958,13 +5570,6 @@ async fn main() {
}
}
return;
} else if args.export_rvf.is_some() {
// --export-rvf alongside --train/--pretrain: don't emit a placeholder.
// Fall through so training runs; it exports the real calibrated model.
eprintln!(
"Note: --export-rvf is ignored in training mode — the trained model \
is exported by the training pipeline to the models/ directory."
);
}
// Handle --pretrain mode: self-supervised contrastive pretraining (ADR-024)
@@ -6421,17 +6026,7 @@ async fn main() {
info!(" UI path: {}", args.ui_path.display());
info!(" Source: {}", args.source);
// Auto-detect data source.
//
// Issue #937 / sibling fix: previously `auto` silently fell back to the
// synthetic data source when no ESP32 or Windows WiFi was reachable, with
// only an `info!` log line as the signal. Downstream API consumers
// (`/api/v1/sensing/latest`, `/ws/sensing`) had no in-band way to know they
// were being served fake CSI tagged as production telemetry. That is the
// exact "where's the real data?" pattern external reviewers (#943, #934)
// cited as the most damaging evidence of the project misrepresenting its
// posture. Synthetic-data is now opt-in only — operators who want demo
// mode must explicitly set `--source simulated` or `CSI_SOURCE=simulated`.
// Auto-detect data source
let source = match args.source.as_str() {
"auto" => {
info!("Auto-detecting data source...");
@@ -6442,23 +6037,10 @@ async fn main() {
info!(" Windows WiFi detected");
"wifi"
} else {
error!(
"No real CSI source detected. Auto-detection refuses to silently \
fall back to synthetic data because that would expose downstream \
consumers (/api/v1/sensing/latest, /ws/sensing) to fake telemetry \
tagged as production. To run with synthetic data, set the source \
explicitly: --source simulated (or CSI_SOURCE=simulated in Docker). \
To use real hardware: provision an ESP32 to emit CSI on UDP :{} or \
install the Windows WiFi capture driver. See \
https://github.com/ruvnet/RuView/issues/937 for context.",
args.udp_port
);
std::process::exit(78); // EX_CONFIG
info!(" No hardware detected, using simulation");
"simulate"
}
}
// "simulate" is a synonym for "simulated" (back-compat alias kept so
// existing operators who already opted in don't get broken by this fix).
"simulate" => "simulated",
other => other,
};
@@ -6531,9 +6113,7 @@ async fn main() {
model_loaded = true;
progressive_loader = Some(loader);
}
Err(e) => {
error!("{}", diagnose_model_load_error(mp, &data, &e.to_string()))
}
Err(e) => error!("Progressive loader init failed: {e}"),
},
Err(e) => error!("Failed to read model file: {e}"),
}
@@ -6620,13 +6200,56 @@ async fn main() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) else {
continue;
};
// #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);
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(),
));
}
}
}
});
@@ -7445,169 +7068,3 @@ 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}");
}
}
#[cfg(test)]
mod export_rvf_mode_tests {
use super::export_emits_placeholder_demo;
#[test]
fn standalone_export_emits_placeholder() {
// --export-rvf alone → the container-format demo (placeholder weights).
assert!(export_emits_placeholder_demo(true, false, false));
}
#[test]
fn export_with_train_does_not_short_circuit() {
// #894: `--train --export-rvf` must NOT emit a placeholder + skip
// training — it must fall through to the real training pipeline.
assert!(!export_emits_placeholder_demo(true, true, false));
assert!(!export_emits_placeholder_demo(true, false, true));
assert!(!export_emits_placeholder_demo(true, true, true));
}
#[test]
fn no_export_flag_never_emits() {
assert!(!export_emits_placeholder_demo(false, false, false));
assert!(!export_emits_placeholder_demo(false, true, false));
}
}
@@ -276,13 +276,6 @@ pub struct FieldNormalMode {
pub geometry_hash: u64,
/// Baseline eigenvalue count above Marcenko-Pastur threshold (empty-room).
pub baseline_eigenvalue_count: usize,
/// Baseline noise variance estimate (median of bottom-half positive
/// eigenvalues from the calibration covariance). Persisted so that
/// `estimate_occupancy` can anchor its Marcenko-Pastur threshold to the
/// calibration noise floor instead of letting it drift with the
/// per-window sample size. Defaults to 0.0 in the diagonal-fallback path.
/// Issue #942.
pub baseline_noise_var: f64,
}
/// Body perturbation extracted from a CSI observation.
@@ -511,11 +504,7 @@ impl FieldModel {
let baseline: Vec<Vec<f64>> = self.link_stats.iter().map(|ls| ls.mean_vector()).collect();
// --- True eigenvalue decomposition (with diagonal fallback) ---
// Returns: (energies, modes, baseline_count, baseline_noise_var).
// The noise_var slot is 0.0 in the diagonal-fallback paths; the
// estimation hot path treats 0.0 as "no anchored noise floor" and
// falls back to per-window noise_var, preserving pre-#942 behavior.
let (mode_energies, environmental_modes, baseline_eig_count, baseline_noise_var) =
let (mode_energies, environmental_modes, baseline_eig_count) =
if let Some(ref cov_sum) = self.covariance_sum {
if self.covariance_count > 1 {
// Compute sample covariance from raw outer products:
@@ -599,28 +588,23 @@ impl FieldModel {
let baseline_count =
eigenvalues.iter().filter(|&&ev| ev > mp_threshold).count();
(energies, modes, baseline_count, noise_var)
(energies, modes, baseline_count)
}
Err(_) => {
// Fallback to diagonal approximation on SVD failure
let (e, m, b) =
diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
diagonal_fallback(&self.link_stats, n_sc, n_modes)
}
}
// When eigenvalue feature is disabled, use diagonal fallback
#[cfg(not(feature = "eigenvalue"))]
{
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
diagonal_fallback(&self.link_stats, n_sc, n_modes)
}
} else {
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
diagonal_fallback(&self.link_stats, n_sc, n_modes)
}
} else {
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
diagonal_fallback(&self.link_stats, n_sc, n_modes)
};
// Compute variance explained using the same centered covariance as modes.
@@ -664,7 +648,6 @@ impl FieldModel {
calibrated_at_us: timestamp_us,
geometry_hash,
baseline_eigenvalue_count: baseline_eig_count,
baseline_noise_var,
};
self.modes = Some(field_mode);
@@ -811,7 +794,7 @@ impl FieldModel {
// Marcenko-Pastur noise estimate: median of POSITIVE eigenvalues
// in the bottom half. Excludes zeros from rank-deficient matrices
// (common when n_subcarriers > n_frames, e.g. 56 subcarriers / 50 frames).
let local_noise_var = {
let noise_var = {
let mut positive: Vec<f64> =
eigenvalues.iter().copied().filter(|&e| e > 1e-10).collect();
positive.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
@@ -824,22 +807,6 @@ impl FieldModel {
return Ok(0); // All zero eigenvalues — can't estimate
}
};
// Issue #942: anchor the noise floor to the calibration's noise_var
// when it's available. Per-window noise_var drifts with sample size —
// a short estimation window can produce a small local_noise_var that
// inflates `significant` and breaks the test_estimate_occupancy_noise_only
// invariant. The max of (calibration noise, local noise) keeps the
// threshold from collapsing on small windows while still letting the
// per-window noise dominate when it's the larger estimate. Falls back
// to local_noise_var when baseline_noise_var == 0 (diagonal-fallback
// calibration path, or pre-#942 stored modes).
let noise_var = if modes.baseline_noise_var > 0.0 {
local_noise_var.max(modes.baseline_noise_var)
} else {
local_noise_var
};
let ratio = n as f64 / count as f64;
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);