Compare commits
5 Commits
2ba9646c69
...
9e64c1be78
| Author | SHA1 | Date |
|---|---|---|
|
|
9e64c1be78 | |
|
|
2c136aca74 | |
|
|
69e61e3437 | |
|
|
d9e87e13b4 | |
|
|
be48143f77 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 2–3 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`, 0–3) 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.2–3.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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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={}",
|
||||
|
|
|
|||
Loading…
Reference in New Issue