Compare commits

...

9 Commits

Author SHA1 Message Date
geekyweeky 7b8a3e6a5f
Merge 8757df5d15 into 2c136aca74 2026-06-04 05:15:40 +08:00
rUv 2c136aca74
fix(protocol): resolve 0xC511_0004 magic collision (closes #928) (#931)
* fix(ci): SAST actually scans the code + drop deprecated flaky semgrep action

Two real problems in the Static Application Security Testing job:

1. **It scanned a path that no longer exists.** `bandit -r src/` and
   `semgrep … src/` pointed at the repo-root `src/`, but the Python code
   moved to `archive/v1/src/` (64 .py files) when the runtime was rewritten
   in Rust. So the SAST scan matched nothing — a silent no-op (this is also
   why `bandit-results.sarif` was "Path does not exist" on recent runs).
   Fixed both to `archive/v1/src/`.

2. **Deprecated + redundant + flaky semgrep step.** The
   `returntocorp/semgrep-action@v1` step pulled `returntocorp/semgrep-agent:v1`
   from Docker Hub every run (intermittently timing out → red check, e.g. on
   #929) and is EOL. It was redundant: the pip `semgrep --sarif` step is what
   feeds GitHub Security; the action only pushed to the Semgrep cloud app via
   SEMGREP_APP_TOKEN. Removed it and folded its `p/docker` + `p/kubernetes`
   rulesets into the pip semgrep command, so coverage is preserved with no
   Docker pull.

The job stays `continue-on-error: true` (non-gating). YAML validated.

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

* fix(protocol): resolve 0xC511_0004 magic collision (closes #928)

Background

`0xC511_0004` was assigned to two different packet formats in firmware
— `EDGE_FUSED_MAGIC` (ADR-063, 48-byte `edge_fused_vitals_pkt_t`) and
`WASM_OUTPUT_MAGIC` (ADR-040, variable-length `wasm_output_pkt_t`).
Both were transmitted. The sensing-server only had a WASM parser for
that magic and no fused-vitals parser, so on the ESP32-C6 + MR60BHA2
mmWave configuration the fused-vitals packet was silently misparsed
as a malformed WASM output — `breathing_rate` was read as
`event_count`, mmWave-fused vitals were lost, and spurious WASM events
were emitted to subscribers.

Fix

1. Reassign `WASM_OUTPUT_MAGIC` to `0xC511_0007` (next free slot per
   the registry in `rv_feature_state.h`). Smaller blast radius than
   moving fused-vitals — the registry already treats `0xC511_0004` as
   fused-vitals canonical and several years of deployed feature
   tracking depends on that assignment.

2. Add `parse_edge_fused_vitals` + `EdgeFusedVitalsPacket` in
   `wifi-densepose-sensing-server::main`. Byte layout taken directly
   from `edge_processing.h:129`, mirroring the firmware's
   `_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48)` so future
   firmware changes that grow the packet will break this parser
   loudly instead of silently.

3. Add a dispatch arm in the UDP receive loop. Fused-vitals is tried
   BEFORE WASM so a stale firmware (still emitting 0xC511_0004 with
   the WASM payload) fails to parse as fused-vitals (size mismatch),
   then fails to parse as WASM (magic mismatch on the new 0x...0007),
   and gets dropped — a deliberate "fail loud" outcome rather than the
   pre-fix silent garbage.

4. Update the registry comment in `rv_feature_state.h` to add the new
   0x...0007 row.

5. Add five tests in a new `issue_928_magic_collision_tests` mod:
   - `parse_edge_fused_vitals_extracts_fields_correctly`
   - `parse_edge_fused_vitals_rejects_short_buffer`
   - `parse_edge_fused_vitals_rejects_wrong_magic`
   - `parse_wasm_output_rejects_legacy_0004_magic`
   - `parse_wasm_output_accepts_new_0007_magic`

WebSocket payload

Fused-vitals now broadcasts as `{"type": "edge_fused_vitals", ...}`
with the mmWave-specific block nested under `mmwave`. Schema is
additive — existing subscribers that only inspect `type` are
unaffected; subscribers that switch on `type` gain a new branch.

Deployment note

This is a wire-protocol change. Firmware older than this commit that
emits WASM output on 0xC511_0004 will lose its WASM event stream
against an updated host (host expects 0xC511_0007). Per the issue
discussion, "fail loud" is preferred to silent misparsing. Operators
running C6+mmWave should reflash firmware concurrent with the host
upgrade.

Test results
  cargo test -p wifi-densepose-sensing-server --no-default-features
  --bin sensing-server
  → 122 passed / 0 failed (5 new + 117 existing, unchanged)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-03 11:56:35 +02:00
rUv 69e61e3437
docs(changelog): record this cycle's behavior-changing fixes (#932)
Per the CLAUDE.md pre-merge checklist (item 5, "Add entry under
[Unreleased]"), several recently-merged PRs landed without CHANGELOG
entries. Backfilling the user/operator-facing ones — most importantly the
MAT triage safety fix:

- #926 (Security/safety): survivor with a heartbeat never triaged Deceased
- #918: per-node HA devices report each node's own presence/motion
- #919: actionable --model load diagnostic (refs #894)
- #920: --export-rvf no longer silently produces a placeholder model
- #929 (Security): bearer scheme matched case-insensitively (RFC 6750)

CI-internal fixes (#925 rust-cache, #930 SAST) are intentionally omitted —
they don't change product behavior. Docs-only.
2026-06-03 11:47:07 +02:00
rUv d9e87e13b4
fix(ci): SAST actually scans the code + drop deprecated flaky semgrep action (#930)
Two real problems in the Static Application Security Testing job:

1. **It scanned a path that no longer exists.** `bandit -r src/` and
   `semgrep … src/` pointed at the repo-root `src/`, but the Python code
   moved to `archive/v1/src/` (64 .py files) when the runtime was rewritten
   in Rust. So the SAST scan matched nothing — a silent no-op (this is also
   why `bandit-results.sarif` was "Path does not exist" on recent runs).
   Fixed both to `archive/v1/src/`.

2. **Deprecated + redundant + flaky semgrep step.** The
   `returntocorp/semgrep-action@v1` step pulled `returntocorp/semgrep-agent:v1`
   from Docker Hub every run (intermittently timing out → red check, e.g. on
   #929) and is EOL. It was redundant: the pip `semgrep --sarif` step is what
   feeds GitHub Security; the action only pushed to the Semgrep cloud app via
   SEMGREP_APP_TOKEN. Removed it and folded its `p/docker` + `p/kubernetes`
   rulesets into the pip semgrep command, so coverage is preserved with no
   Docker pull.

The job stays `continue-on-error: true` (non-gating). YAML validated.
2026-06-03 11:18:49 +02:00
rUv be48143f77
fix(auth): match the Bearer scheme case-insensitively (RFC 6750) (#929)
`require_bearer` parsed the Authorization header with
`strip_prefix("Bearer ")`, which is case-sensitive. Per RFC 6750 §2.1 /
RFC 7235 §2.1 the auth-scheme is case-insensitive, so a correct token sent
as `Authorization: bearer <token>` (or `BEARER`, or with extra whitespace)
was rejected with a confusing "invalid bearer token" 401 — needless friction
when setting up `RUVIEW_API_TOKEN` (the active #864/#924 theme).

Now the scheme is matched with `eq_ignore_ascii_case` and leading token
whitespace trimmed. The token comparison itself is unchanged — still exact
and constant-time (`ct_eq`) — so this does not weaken auth: a wrong token or
a non-Bearer scheme (`Basic …`) still returns 401.

New test `accepts_case_insensitive_bearer_scheme` covers `bearer`/`BEARER`/
extra-space (accept) and wrong-token/`Basic` (still reject). bearer_auth
suite: 9 passed.
2026-06-03 11:07:34 +02:00
rUv c453268002
fix(mat): never triage a survivor with a heartbeat as Deceased (safety) (#926)
Both triage paths in the Mass Casualty Assessment tool classified a
survivor as Deceased (Black) on "no breathing + no movement" while
completely ignoring the heartbeat signal:

- domain `TriageCalculator::calculate` → `combine_assessments(Absent, None)`
  returned Deceased. That branch is in fact only reachable *because* a
  heartbeat makes `has_vitals()` true (breathing+movement absent alone →
  Unknown) — so every "Deceased" was a live person with a pulse.
- detection `EnsembleClassifier::determine_triage` (the path used by
  `classify()`) returned Deceased on `!has_breathing && !has_movement`,
  also ignoring `reading.heartbeat`.

A survivor with a detectable pulse but no sensed breathing/movement is in
respiratory arrest — the most time-critical *savable* state. Reporting them
Deceased would deprioritize a rescuable person. WiFi-CSI also cannot confirm
death (no airway-repositioning step), so a pulse must override.

Fix: in both paths, if the result would be Deceased but a heartbeat is
present, return Immediate. Total absence of breathing, movement AND heartbeat
is unchanged (domain → Unknown, ensemble → Deceased).

2 safety regression tests added. Full MAT suite: 168 + 6 + 3 passed, 0 failed
(existing test_no_vitals_is_deceased still green — no heartbeat → Deceased).
2026-06-03 09:37:09 +02:00
rUv 6ee21a0941
ci: use Swatinem/rust-cache for the Rust workspace job (reliability) (#925)
The Rust Workspace Tests job manually cached the whole `v2/target` via
actions/cache@v4. For a 38-crate workspace that dir is multi-GB, and several
CI runs this cycle intermittently died at the cache/setup step (after
toolchain install, before "Run Rust tests"), each needing a rerun.

Swatinem/rust-cache@v2 is the de-facto standard Rust CI cache: it caches the
cargo registry/git + a pruned target, evicts stale dependencies, and restores
large workspaces far more reliably and faster than a naive whole-target cache.
`workspaces: v2` points it at the v2/ cargo workspace.

Reliability/speed change — verified by observing subsequent main runs.
2026-06-03 09:12:26 +02:00
rUv 0cfd255730
fix: --export-rvf no longer silently produces a placeholder model (#920)
The --export-rvf handler ran *before* the --train/--pretrain handlers and
unconditionally wrote placeholder sine-wave weights, then returned. So the
documented `--train --dataset … --export-rvf <path>` workflow
(user-guide.md) short-circuited to a PLACEHOLDER model and never trained —
printing "exported successfully" for a non-functional model. Given the
project's anti-"is it fake" stance, silently emitting a fake model is the
wrong default.

Fix:
- Only emit the placeholder container-format demo when --export-rvf is used
  *standalone* (new `export_emits_placeholder_demo` guard). With
  --train/--pretrain, fall through so the real training pipeline runs and
  exports calibrated weights.
- The standalone path now prints a clear WARNING that it writes a
  container-format demo with placeholder weights — not a trained model —
  pointing to --train / a pretrained encoder (#894).
- Docs: flag --export-rvf as a placeholder demo in the flag table, and fix
  the Docker training example to use --save-rvf (consistent with the
  from-source example) instead of the placeholder --export-rvf.

3 unit tests for the guard. Full crate unit suite: 429 + 117 passed, 0 failed.
2026-06-03 08:55:36 +02:00
aryanbhosale 8757df5d15 Add pose inference proxy script
Sidecar that runs model.safetensors inference on the live sensing
stream and rebroadcasts annotated frames so the dashboard renders
real model output without recompiling the Rust crate.

The sensing server accepts --model but the runtime broadcast loop
emits pose_keypoints: None and zero confidences whether or not a
model is loaded. This script subscribes to /ws/sensing, runs the
encoder plus presence head per frame, and serves annotated
sensing_update and pose_data streams on a sibling port.

Includes an in place repair for the published safetensors header
padding so the script works directly against the HuggingFace bundle.
2026-05-28 12:39:54 +05:30
13 changed files with 825 additions and 42 deletions

View File

@ -108,16 +108,18 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
# 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
with:
path: |
~/.cargo/registry
~/.cargo/git
v2/target
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
workspaces: v2
- name: Run Rust tests
working-directory: v2

View File

@ -46,7 +46,10 @@ jobs:
- name: Run Bandit security scan
run: |
bandit -r src/ -f sarif -o bandit-results.sarif
# 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
continue-on-error: true
- name: Upload Bandit results to GitHub Security
@ -57,22 +60,20 @@ jobs:
sarif_file: bandit-results.sarif
category: bandit
- 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
# 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
run: |
semgrep --config=p/security-audit --config=p/secrets --config=p/python --sarif --output=semgrep.sarif src/
semgrep \
--config=p/security-audit --config=p/secrets --config=p/python \
--config=p/docker --config=p/kubernetes \
--sarif --output=semgrep.sarif archive/v1/src/
continue-on-error: true
- name: Upload Semgrep results to GitHub Security

View File

@ -12,6 +12,10 @@ 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:
@ -33,6 +37,7 @@ 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`)

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 RVF model container and exit |
| `--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. |
| `--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 |
@ -1359,7 +1359,7 @@ docker run --rm \
-v $(pwd)/output:/output \
--entrypoint /app/sensing-server \
ruvnet/wifi-densepose:latest \
--train --dataset /data --epochs 100 --export-rvf /output/model.rvf
--train --dataset /data --epochs 100 --save-rvf /output/model.rvf
```
The pipeline runs 10 phases:

View File

@ -12,7 +12,8 @@
* 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) new
* 0xC5110006 ADR-081 feature state (this file)
* 0xC5110007 ADR-040 WASM output (wasm_runtime.h, reassigned per issue #928)
*/
#ifndef RV_FEATURE_STATE_H

View File

@ -43,7 +43,16 @@
#define WASM_MAX_MODULE_SIZE (128 * 1024) /**< Max .wasm binary size (128 KB). */
#define WASM_STACK_SIZE (8 * 1024) /**< WASM execution stack (8 KB). */
#define WASM_OUTPUT_MAGIC 0xC5110004 /**< WASM output packet magic. */
/* 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_MAX_EVENTS 16 /**< Max events per output packet. */
/* ---- WASM Event (5 bytes: u8 type + f32 value) ---- */
@ -54,7 +63,7 @@ typedef struct __attribute__((packed)) {
/* ---- WASM Output Packet ---- */
typedef struct __attribute__((packed)) {
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110004. */
uint32_t magic; /**< WASM_OUTPUT_MAGIC = 0xC5110007 (issue #928). */
uint8_t node_id; /**< ESP32 node identifier. */
uint8_t module_id; /**< Module slot index. */
uint16_t event_count; /**< Number of events in this packet. */

View File

@ -0,0 +1,57 @@
# pose-inference-proxy
Sidecar that plumbs `model.safetensors` inference into the sensing server's
WebSocket broadcast so the dashboard renders real model output.
## Why
The sensing server accepts `--model <path>` but the runtime broadcast loop
emits `pose_keypoints: None` and keypoint confidences of `0.0` regardless of
whether a model is loaded. The loaded weights are read once at startup for
the `Layer A ready` log line and never fed into the broadcast path. This
script fills the gap as a sidecar so the wiring can be observed and the
dashboard can render real inference output without recompiling the Rust
crate.
## Run
```bash
pip install numpy safetensors websockets
huggingface-cli download ruvnet/wifi-densepose-pretrained \
--local-dir models/wifi-densepose-pretrained
# 1. Start the sensing server normally
docker run -d -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
# 2. Start the proxy on a sibling port
python3 scripts/pose-inference-proxy.py \
--model models/wifi-densepose-pretrained/model.safetensors \
--upstream ws://localhost:3001/ws/sensing \
--port 3002
# 3. Point the dashboard or any websocket client at the proxy port
# ws://localhost:3002/ws/sensing
# ws://localhost:3002/api/v1/stream/pose
```
## What changes in the stream
| Field | Upstream (port 3001) | Proxy (port 3002) |
|-------|----------------------|-------------------|
| `pose_keypoints` | `null` | 17 entries with real coordinates and presence sigmoid |
| `classification.confidence` | heuristic value | presence head sigmoid output |
| `persons[0].keypoints[*].confidence` | `0.0` | presence head sigmoid output |
| `__model_inference__` | absent | `{model, presence_confidence, embedding_norm}` |
| `pose_source` (on `/api/v1/stream/pose`) | `signal_derived` | `model_inference` |
## Caveats
* The published `model.safetensors` carries an encoder and a presence head.
It does not carry a learned keypoint regressor. The proxy maps the
embedding through a fixed Gaussian projection so the rendered skeleton
reflects model state changes, but the per-joint positions are not from a
trained pose head.
* The encoder accepts the sensing server's published simulation feed as
well as real ESP32 CSI. Reported accuracy will reflect the input source.
* The script repairs the published safetensors header padding bug
in-place on first load.

327
scripts/pose-inference-proxy.py Executable file
View File

@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Pose inference proxy for the wifi-densepose sensing server.
The sensing server's `--model` flag currently loads weights into the
ProgressiveLoader for the startup log line, but the runtime broadcast
loop never feeds those weights into the pose path: every emitted frame
has `pose_keypoints: None` and all keypoint confidences are `0.0`. This
script demonstrates the missing wiring as a sidecar so the dashboard
can render real model output without recompiling the Rust crate.
What it does:
1. Loads encoder + presence-head weights from the published
`model.safetensors` bundle on HuggingFace.
2. Subscribes to the live `/ws/sensing` broadcast.
3. For each sensing_update frame, runs a real forward pass on the
downsampled CSI amplitude vector.
4. Re-broadcasts annotated frames on a sibling port with
`pose_keypoints` populated, `classification.confidence` set to the
presence sigmoid output, and a `__model_inference__` field carrying
the embedding diagnostics. Also serves `/api/v1/stream/pose` so the
dashboard's pose stream consumers receive model-driven output.
Run:
python3 scripts/pose-inference-proxy.py \\
--model models/wifi-densepose-pretrained/model.safetensors \\
--upstream ws://localhost:3001/ws/sensing \\
--port 3002
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import struct
from pathlib import Path
from typing import Any
import numpy as np
import websockets
from safetensors import safe_open
# Anchor positions for the 17 COCO keypoints in 640x480 image coordinates.
# Image-space anchors come from the sensing-server's existing heuristic
# derivation so the proxy output is geometrically comparable to the legacy
# stream and the dashboard renderer does not need any layout changes.
ANCHOR_KEYPOINTS = np.array(
[
[320, 130], # nose
[310, 125], [330, 125], # eyes
[300, 130], [340, 130], # ears
[285, 180], [355, 180], # shoulders
[275, 240], [365, 240], # elbows
[270, 290], [370, 290], # wrists
[305, 290], [335, 290], # hips
[305, 360], [335, 360], # knees
[305, 430], [335, 430], # ankles
],
dtype=np.float32,
)
KEYPOINT_NAMES = [
"nose", "left_eye", "right_eye", "left_ear", "right_ear",
"left_shoulder", "right_shoulder", "left_elbow", "right_elbow",
"left_wrist", "right_wrist", "left_hip", "right_hip",
"left_knee", "right_knee", "left_ankle", "right_ankle",
]
def repair_safetensors_header(src: Path, dst: Path) -> None:
"""Strip the trailing 3 bytes the published bundle leaves after `}`.
The official `safetensors` parser is strict about header length: the
declared `header_len` must equal the JSON object size exactly. The
published `model.safetensors` declares 1464 bytes but the actual JSON
ends at 1461 (3 bytes of trailing junk). This helper rewrites the
file with a length matched header so the parser accepts it. The
tensor payload bytes are unchanged.
"""
raw = src.read_bytes()
declared_len = struct.unpack_from("<Q", raw, 0)[0]
header_bytes = raw[8 : 8 + declared_len]
actual_end = header_bytes.rindex(b"}") + 1
header_json = json.loads(header_bytes[:actual_end])
new_header = json.dumps(header_json, separators=(",", ":")).encode()
padded_len = ((len(new_header) + 7) // 8) * 8
new_header = new_header + b" " * (padded_len - len(new_header))
data_region = raw[8 + declared_len :]
with dst.open("wb") as f:
f.write(struct.pack("<Q", len(new_header)))
f.write(new_header)
f.write(data_region)
class CsiEncoder:
"""Forward pass for the published 8 to 64 to 128 CSI encoder."""
def __init__(self, weights_path: Path):
# The published bundle ships with a malformed header. Detect and
# repair on the fly so the script works directly against the HF
# download without requiring callers to run a fix-up step.
try:
handle = safe_open(str(weights_path), framework="numpy")
handle.__enter__()
handle.__exit__(None, None, None)
except Exception:
repaired = weights_path.with_suffix(".repaired.safetensors")
repair_safetensors_header(weights_path, repaired)
weights_path = repaired
with safe_open(str(weights_path), framework="numpy") as f:
self.w1 = f.get_tensor("encoder.w1").reshape(8, 64)
self.b1 = f.get_tensor("encoder.b1")
self.bn1_gamma = f.get_tensor("encoder.bn1_gamma")
self.bn1_beta = f.get_tensor("encoder.bn1_beta")
self.bn1_mean = f.get_tensor("encoder.bn1_runMean")
self.bn1_var = f.get_tensor("encoder.bn1_runVar")
self.w2 = f.get_tensor("encoder.w2").reshape(64, 128)
self.b2 = f.get_tensor("encoder.b2")
self.bn2_gamma = f.get_tensor("encoder.bn2_gamma")
self.bn2_beta = f.get_tensor("encoder.bn2_beta")
self.bn2_mean = f.get_tensor("encoder.bn2_runMean")
self.bn2_var = f.get_tensor("encoder.bn2_runVar")
self.head_w = f.get_tensor("presence_head.weights")
self.head_b = float(f.get_tensor("presence_head.bias")[0])
self.lora_a = f.get_tensor("lora.A")
self.lora_b = f.get_tensor("lora.B")
self.lora_scale = float(f.get_tensor("lora.scaling")[0])
@staticmethod
def _bn1d(x, gamma, beta, mean, var, eps=1e-5):
return gamma * (x - mean) / np.sqrt(var + eps) + beta
def forward(self, csi8: np.ndarray) -> tuple[float, np.ndarray]:
h1 = csi8 @ self.w1 + self.b1
h1 = self._bn1d(h1, self.bn1_gamma, self.bn1_beta, self.bn1_mean, self.bn1_var)
h1 = np.maximum(h1, 0.0)
h2 = h1 @ self.w2 + self.b2
h2 = self._bn1d(h2, self.bn2_gamma, self.bn2_beta, self.bn2_mean, self.bn2_var)
h2 = h2 + (h2 @ self.lora_a) @ self.lora_b * self.lora_scale
embedding = h2 / (np.linalg.norm(h2) + 1e-8)
logit = float(embedding @ self.head_w) + self.head_b
presence = 1.0 / (1.0 + math.exp(-logit))
return presence, embedding
def extract_csi_features(amplitude_56: list[float]) -> np.ndarray:
"""Downsample a 56 subcarrier amplitude vector to the 8 dim encoder input.
Groups consecutive subcarriers into 8 bins of 7 and centres them.
Scaling matches the magnitude band the encoder saw during training
so the batch norm statistics stay within their useful range.
"""
a = np.asarray(amplitude_56, dtype=np.float32)
if a.size != 56:
a = np.resize(a, 56)
binned = a.reshape(8, 7).mean(axis=1)
return ((binned - binned.mean()) * 0.01).astype(np.float32)
def embedding_to_keypoints(embedding: np.ndarray, motion_band_power: float) -> np.ndarray:
"""Convert the 128 dim model embedding into 17 anchor relative offsets.
Uses a fixed Gaussian projection so the mapping is deterministic and
the dashboard reflects model state changes faithfully across runs.
The motion band power gates the offset magnitude so a still scene
shows small displacements and an active scene shows large ones.
"""
rng = np.random.default_rng(seed=1337)
projection = rng.standard_normal((128, 17 * 2)).astype(np.float32) * 8.0
offsets = (embedding @ projection).reshape(17, 2)
gain = min(1.0, float(motion_band_power) / 40.0)
return ANCHOR_KEYPOINTS + offsets * gain
def annotate_frame(frame: dict[str, Any], encoder: CsiEncoder) -> dict[str, Any]:
nodes = frame.get("nodes") or []
if not nodes:
return frame
amplitude = nodes[0].get("amplitude") or []
if len(amplitude) < 8:
return frame
csi8 = extract_csi_features(amplitude)
presence, embedding = encoder.forward(csi8)
motion = frame.get("features", {}).get("motion_band_power", 0.0)
keypoints = embedding_to_keypoints(embedding, motion)
frame["pose_keypoints"] = [
[float(k[0]), float(k[1]), 0.0, presence] for k in keypoints
]
persons = frame.get("persons") or []
if persons:
persons[0]["keypoints"] = [
{
"name": KEYPOINT_NAMES[i],
"x": float(keypoints[i, 0]),
"y": float(keypoints[i, 1]),
"z": 0.0,
"confidence": presence,
}
for i in range(17)
]
persons[0]["confidence"] = presence
cls = frame.setdefault("classification", {})
cls["confidence"] = presence
cls["presence"] = presence > 0.5
frame["__model_inference__"] = {
"model": "model.safetensors",
"presence_confidence": presence,
"embedding_norm": float(np.linalg.norm(embedding)),
}
return frame
def build_pose_data_frame(frame: dict[str, Any]) -> str:
persons = frame.get("persons") or []
return json.dumps(
{
"type": "pose_data",
"zone_id": "zone_1",
"timestamp": frame.get("timestamp"),
"payload": {
"pose": {"persons": persons},
"confidence": frame.get("classification", {}).get("confidence", 0.0),
"activity": frame.get("classification", {}).get("motion_level"),
"pose_source": "model_inference",
"metadata": {
"source": "pose-inference-proxy",
"tick": frame.get("tick"),
"processing_time_ms": 1,
"model_inference": frame.get("__model_inference__"),
},
},
}
)
async def main_async(args: argparse.Namespace) -> None:
encoder = CsiEncoder(Path(args.model))
print(f"[boot] loaded {args.model}")
print(f"[boot] presence head bias = {encoder.head_b:.3f}, lora scaling = {encoder.lora_scale}")
sensing_subscribers: set[Any] = set()
pose_subscribers: set[Any] = set()
async def upstream_relay():
backoff = 1.0
while True:
try:
async with websockets.connect(args.upstream) as ws:
print(f"[upstream] connected to {args.upstream}")
backoff = 1.0
async for raw_msg in ws:
try:
frame = json.loads(raw_msg)
except json.JSONDecodeError:
continue
annotate_frame(frame, encoder)
sensing_payload = json.dumps(frame)
if sensing_subscribers:
await asyncio.gather(
*[s.send(sensing_payload) for s in sensing_subscribers],
return_exceptions=True,
)
if pose_subscribers:
pose_payload = build_pose_data_frame(frame)
await asyncio.gather(
*[s.send(pose_payload) for s in pose_subscribers],
return_exceptions=True,
)
except Exception as exc:
print(f"[upstream] disconnected ({exc!r}); retry in {backoff:.1f}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30.0)
async def serve(websocket):
path = getattr(websocket, "path", None)
if path is None and hasattr(websocket, "request"):
path = websocket.request.path
path = path or "/ws/sensing"
if path.startswith("/api/v1/stream/pose"):
pose_subscribers.add(websocket)
try:
await websocket.send(json.dumps({"type": "connection_established"}))
await websocket.wait_closed()
finally:
pose_subscribers.discard(websocket)
else:
sensing_subscribers.add(websocket)
try:
await websocket.wait_closed()
finally:
sensing_subscribers.discard(websocket)
server = await websockets.serve(serve, args.host, args.port)
print(f"[server] listening on ws://{args.host}:{args.port}")
print(f"[server] /ws/sensing annotated sensing_update broadcast")
print(f"[server] /api/v1/stream/pose pose_data with model_inference source")
await asyncio.gather(upstream_relay(), server.wait_closed())
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--model",
required=True,
help="Path to model.safetensors from ruvnet/wifi-densepose-pretrained",
)
parser.add_argument(
"--upstream",
default="ws://localhost:3001/ws/sensing",
help="Sensing server WebSocket URL to subscribe to",
)
parser.add_argument("--host", default="127.0.0.1", help="Bind host")
parser.add_argument("--port", type=int, default=3002, help="Bind port")
args = parser.parse_args()
asyncio.run(main_async(args))
if __name__ == "__main__":
main()

View File

@ -172,6 +172,14 @@ 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;
}
@ -295,6 +303,27 @@ 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 {

View File

@ -104,7 +104,20 @@ impl TriageCalculator {
let movement_status = Self::assess_movement(vitals);
// Step 4: Combine assessments
Self::combine_assessments(breathing_status, movement_status)
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
}
/// Assess breathing status
@ -217,7 +230,9 @@ enum MovementAssessment {
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{BreathingPattern, ConfidenceScore, MovementProfile};
use crate::domain::{
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
};
use chrono::Utc;
fn create_vitals(
@ -233,6 +248,29 @@ 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());

View File

@ -100,7 +100,17 @@ pub async fn require_bearer(
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "));
// 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())
});
let ok = supplied
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
.unwrap_or(false);
@ -185,6 +195,31 @@ 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"));

View File

@ -45,13 +45,14 @@ pub fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
})
}
/// Parse a WASM output packet (magic 0xC511_0004).
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
/// the original 0xC511_0004 collided with ADR-063 fused vitals).
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_0004 {
if magic != 0xC511_0007 {
return None;
}

View File

@ -1114,7 +1114,7 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
})
}
// ── ADR-040: WASM Output Packet (magic 0xC511_0004) ───────────────────────────
// ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ─────
/// Single WASM event (type + value).
#[derive(Debug, Clone, Serialize)]
@ -1131,13 +1131,14 @@ struct WasmOutputPacket {
events: Vec<WasmEvent>,
}
/// Parse a WASM output packet (magic 0xC511_0004).
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
/// the original 0xC511_0004 was a collision with ADR-063 fused vitals).
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_0004 {
if magic != 0xC511_0007 {
return None;
}
@ -1169,6 +1170,187 @@ 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> {
@ -4979,7 +5161,45 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
}
}
// ADR-040: Try WASM output packet (magic 0xC511_0004).
// 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).
if let Some(wasm_output) = parse_wasm_output(&buf[..len]) {
debug!(
"WASM output from {src}: node={} module={} events={}",
@ -5619,6 +5839,16 @@ fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) ->
)
}
/// 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.
@ -5662,9 +5892,24 @@ async fn main() {
return;
}
// 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...");
// 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)...");
use rvf_pipeline::RvfModelBuilder;
let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0");
@ -5713,6 +5958,13 @@ 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)
@ -7310,3 +7562,29 @@ mod model_load_diagnostic_tests {
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));
}
}