From 0a282d0870cb132090b2c414a6bc6801c8f3ef9d Mon Sep 17 00:00:00 2001 From: erichkusuki Date: Sat, 11 Jul 2026 13:32:01 +0200 Subject: [PATCH 1/5] fix(firmware): add display-less DevKitC-1 build overlay; update stale IDF v5.2 refs to v5.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR-045 display probe false-positives on display-less ESP32-S3-DevKitC-1 boards (SH8601 init 'succeeds' against floating pins), so main.c skips the RuView#893 MGMT+DATA promiscuous upgrade and CSI yield collapses to 0 pps. sdkconfig.defaults.devkitc compiles display support out, making has_display constant-false so the #893 upgrade always applies. Hardware-verified on 2x DevKitC-1-N16R8: 0 pps -> steady 40-45 pps. Also updates the Quick Start build commands and badge from espressif/idf:v5.2 to v5.4 — current source uses esp_driver_uart (IDF v5.3+) and no longer builds under v5.2; CI already uses v5.4. Addresses #1308 Co-Authored-By: claude-flow --- firmware/esp32-csi-node/README.md | 16 ++++++++++++---- .../esp32-csi-node/sdkconfig.defaults.devkitc | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 firmware/esp32-csi-node/sdkconfig.defaults.devkitc diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index f75d053c..9be2509d 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -4,7 +4,7 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project. -[![ESP-IDF v5.2](https://img.shields.io/badge/ESP--IDF-v5.2-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.2/) +[![ESP-IDF v5.4](https://img.shields.io/badge/ESP--IDF-v5.4-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.4/) [![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3) [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE) [![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget) @@ -48,10 +48,18 @@ with `--flash_size 4MB`. # From the repository root: MSYS_NO_PATHCONV=1 docker run --rm \ -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ - espressif/idf:v5.2 bash -c \ + espressif/idf:v5.4 bash -c \ "rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build" ``` +> **Display-less boards (ESP32-S3-DevKitC-1 and similar):** build with the +> `sdkconfig.defaults.devkitc` overlay instead — the default build compiles +> display support in, and the runtime panel probe false-positives on boards +> with no panel, which disables the RuView#893 MGMT+DATA CSI upgrade and +> collapses CSI yield to 0 pps. See the header of +> [`sdkconfig.defaults.devkitc`](sdkconfig.defaults.devkitc) for the exact +> build command. + ### 2. Flash Offsets must match `partitions_display.csv` (8 MB) or `partitions_4mb.csv` (4 MB): @@ -250,7 +258,7 @@ Offset Size Field # From the repository root: MSYS_NO_PATHCONV=1 docker run --rm \ -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ - espressif/idf:v5.2 bash -c \ + espressif/idf:v5.4 bash -c \ "rm -rf build sdkconfig && idf.py set-target esp32s3 && idf.py build" ``` @@ -268,7 +276,7 @@ To change Kconfig settings before building: ```bash MSYS_NO_PATHCONV=1 docker run --rm -it \ -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ - espressif/idf:v5.2 bash -c \ + espressif/idf:v5.4 bash -c \ "idf.py set-target esp32s3 && idf.py menuconfig" ``` diff --git a/firmware/esp32-csi-node/sdkconfig.defaults.devkitc b/firmware/esp32-csi-node/sdkconfig.defaults.devkitc new file mode 100644 index 00000000..8cb1962a --- /dev/null +++ b/firmware/esp32-csi-node/sdkconfig.defaults.devkitc @@ -0,0 +1,16 @@ +# DevKitC-1 (display-less) production overlay. +# +# The stock ESP32-S3-DevKitC-1 has no AMOLED panel, but the ADR-045 runtime +# probe false-positives on it: with no TCA9554 and floating QSPI pins, the +# SH8601 init sequence reports success, display_is_active() returns true, and +# main.c skips the RuView#893 MGMT+DATA promiscuous upgrade — CSI yield +# collapses to 0 pps (the exact symptom #893 fixed). Compiling display support +# out makes has_display constant-false so the upgrade always applies. +# +# Build (from repo root, per README "Docker — the only reliable method"): +# MSYS_NO_PATHCONV=1 docker run --rm \ +# -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ +# espressif/idf:v5.2 bash -c \ +# "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build" + +# CONFIG_DISPLAY_ENABLE is not set From b5ce60081bd7818b641f26e5ebebfb709f15ecc4 Mon Sep 17 00:00:00 2001 From: erichkusuki Date: Sat, 11 Jul 2026 13:32:01 +0200 Subject: [PATCH 2/5] fix(sensing-server): wire WDP_GUARD_INTERVAL_US/WDP_TDM_* into EngineBridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EngineBridge::new built its own StreamingEngine, whose internal MultistaticFuser was hardcoded to MultistaticConfig::default() (60 ms guard) — the #1031/#1049 env overrides only reached the sibling multistatic_fuser field on AppState, so governed trust cycles failed against the default guard no matter what the deployment configured. - wifi-densepose-engine: add StreamingEngine::set_multistatic_config() - engine_bridge: EngineBridge::new takes Option - main.rs: thread the same env-derived config into both fusion paths Verified on a 2-node ESP32-S3 deployment: the failure message now reflects the configured guard, and with healthy nodes governed cycles ran 90 s with zero fusion errors. Fixes #1309 Co-Authored-By: claude-flow --- v2/crates/wifi-densepose-engine/src/lib.rs | 11 ++++++ .../src/engine_bridge.rs | 39 +++++++++++++------ .../wifi-densepose-sensing-server/src/main.rs | 12 +++++- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/v2/crates/wifi-densepose-engine/src/lib.rs b/v2/crates/wifi-densepose-engine/src/lib.rs index d80b0031..f9b02c6b 100644 --- a/v2/crates/wifi-densepose-engine/src/lib.rs +++ b/v2/crates/wifi-densepose-engine/src/lib.rs @@ -230,6 +230,17 @@ impl StreamingEngine { /// cycle on, the adapter id is part of provenance `model_version` — and /// therefore of the witness — so the exact weights shaping inference are /// pinned in the trust chain. Pass the result of hashing the adapter file. + /// Override the multistatic fuser's timestamp guard interval (#1049/#1057). + /// Without this, `StreamingEngine::new` always builds + /// `MultistaticFuser::with_config(MultistaticConfig::default())` — a + /// hardcoded 60 ms hard guard that ignores whatever schedule/override the + /// caller derived from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US`, so + /// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed + /// trust cycles even after widening the guard elsewhere. + pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) { + self.fuser = MultistaticFuser::with_config(cfg); + } + pub fn set_room_adapter(&mut self, info: AdapterInfo) { self.adapter = Some(info); } diff --git a/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs index c03ec0e9..5941a655 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs @@ -38,6 +38,7 @@ use wifi_densepose_bfld::{PrivacyClass, PrivacyMode}; use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput}; use wifi_densepose_geo::types::GeoRegistration; use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId; +use wifi_densepose_signal::ruvsense::multistatic::MultistaticConfig; use wifi_densepose_worldgraph::WorldId; use super::multistatic_bridge::node_frames_from_states; @@ -79,8 +80,24 @@ pub struct EngineBridge { impl EngineBridge { /// Build a bridge for one installation. `room_area_id`/`room_name` name the /// observation scope; `mode` is the starting privacy mode. - pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self { + /// + /// `multistatic_cfg` is `None` on the pure default (60 ms/20 ms guard); + /// callers that derive a config from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US` + /// (see `main.rs::multistatic_guard_config_from_env`) should pass it here + /// too — otherwise the governed trust cycle silently keeps using the + /// hardcoded default even though the sibling `multistatic_fuser` field on + /// `AppState` picked up the override (#1049/#1057). + pub fn new( + mode: PrivacyMode, + model_version: u16, + room_area_id: &str, + room_name: &str, + multistatic_cfg: Option, + ) -> Self { let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default()); + if let Some(cfg) = multistatic_cfg { + engine.set_multistatic_config(cfg); + } let room = engine.add_room(room_area_id, room_name); Self { engine, @@ -267,7 +284,7 @@ mod tests { #[test] fn empty_states_produce_no_belief() { - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None); let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000); assert!(out.is_none()); // No belief published, no sensor wired. @@ -276,7 +293,7 @@ mod tests { #[test] fn live_cycle_produces_witnessed_belief_with_provenance() { - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room", None); let states = two_node_states(); let out = bridge .process_cycle_from_states(&states, 10_000) @@ -299,7 +316,7 @@ mod tests { fn live_path_is_deterministic() { let states = two_node_states_fixed(); let run = || { - let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None); b.process_cycle_from_states(&states, 5_000).unwrap().unwrap() }; let a = run(); @@ -325,7 +342,7 @@ mod tests { #[test] fn nodes_registered_once_across_cycles() { - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None); let states = two_node_states(); bridge.process_cycle_from_states(&states, 1_000); bridge.process_cycle_from_states(&states, 2_000); @@ -336,7 +353,7 @@ mod tests { #[test] fn retention_bounds_world_graph_growth() { - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None); bridge.set_semantic_retention(5); let states = two_node_states(); for i in 0..20i64 { @@ -349,7 +366,7 @@ mod tests { #[test] fn adapter_identity_flows_into_live_witness() { let states = two_node_states_fixed(); - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None); let base = bridge .process_cycle_from_states(&states, 1_000) .unwrap() @@ -381,7 +398,7 @@ mod tests { /// the status endpoint, with a zero error count on the happy path. #[test] fn observe_cycle_records_trust_state() { - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None); assert!(bridge.last_trust_witness().is_none()); assert_eq!(bridge.effective_class(), None); @@ -430,7 +447,7 @@ mod tests { m } - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None); let mismatched = mismatched_states(); assert!(bridge.observe_cycle(&mismatched, 1_000).is_none()); @@ -461,7 +478,7 @@ mod tests { /// mapping bfld's privacy gate applies at `Restricted`. #[test] fn restricted_class_suppresses_raw_outputs() { - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None); bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted bridge .observe_cycle(&two_node_states(), 1_000) @@ -472,7 +489,7 @@ mod tests { #[test] fn identity_strict_mode_is_carried_into_provenance() { - let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R", None); bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); let out = bridge .process_cycle_from_states(&two_node_states(), 7_000) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 38a543d3..120eaf5d 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -7514,6 +7514,11 @@ async fn main() { let field_surface: rufield_surface::FieldState = Arc::new(RwLock::new(rufield_surface::FieldSurface::from_env())); + // Populated inside the `multistatic_fuser` field initializer below, then + // threaded into `engine_bridge` so both fusion paths honor the same + // WDP_TDM_SLOTS/WDP_GUARD_INTERVAL_US-derived guard (#1049/#1057). + let mut engine_bridge_multistatic_cfg: Option = None; + let state: SharedState = Arc::new(RwLock::new(AppStateInner { latest_update: None, rssi_history: VecDeque::new(), @@ -7588,7 +7593,7 @@ async fn main() { ); let mut fuser = MultistaticFuser::with_config(MultistaticConfig { min_nodes: 1, // single-node passthrough - ..cfg + ..cfg.clone() }); if let Some(ref pos_str) = args.node_positions { let positions = field_bridge::parse_node_positions(pos_str); @@ -7600,6 +7605,10 @@ async fn main() { fuser.set_node_positions(positions); } } + engine_bridge_multistatic_cfg = Some(MultistaticConfig { + min_nodes: 1, + ..cfg + }); fuser }, engine_bridge: engine_bridge::EngineBridge::new( @@ -7607,6 +7616,7 @@ async fn main() { 1, "default", "Default Room", + engine_bridge_multistatic_cfg, ), field_model: if args.calibrate { info!("Field model calibration enabled — room should be empty during startup"); From 2ddb6a7b027eb9c263c66e256c3bc6b8ab40041e Mon Sep 17 00:00:00 2001 From: erichkusuki Date: Sat, 11 Jul 2026 13:32:02 +0200 Subject: [PATCH 3/5] fix(sensing-server): exempt /api/v1/stream/pose WS from bearer auth; add UI token field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers cannot attach an Authorization header to a WebSocket upgrade, so with RUVIEW_API_TOKEN set the Live Demo pose stream at /api/v1/stream/pose always failed with 401 — the same reason /ws/sensing is already exempted (see bearer_auth module docs). Adds a narrow EXEMPT_PATHS list plus a regression test that the exemption does not leak to other /api/v1/* paths. Query-string tokens remain rejected (CWE-598 test untouched). Also adds an 'API Access' bearer-token field to the QuickSettings panel: ui/services/api.service.js had setAuthToken() but nothing ever called it, so enabling RUVIEW_API_TOKEN broke every /api/v1/* call from the bundled dashboard. The token is stored in localStorage and applied before the first request. Fixes #1310 Co-Authored-By: claude-flow --- ui/style.css | 14 +++++ ui/utils/quick-settings.js | 55 +++++++++++++++++++ .../src/bearer_auth.rs | 33 ++++++++++- 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/ui/style.css b/ui/style.css index 6c19ba6d..55c1905e 100644 --- a/ui/style.css +++ b/ui/style.css @@ -4088,6 +4088,20 @@ a:focus-visible, color: #fff; } +.qs-text-input { + padding: var(--space-6) var(--space-8); + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + background: var(--color-secondary); + color: var(--color-text); + font-size: var(--font-size-sm); +} + +.qs-text-input:focus { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + /* --- Screenshot Flash --- */ .screenshot-flash { diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js index 358592b8..5112ee9a 100644 --- a/ui/utils/quick-settings.js +++ b/ui/utils/quick-settings.js @@ -1,6 +1,10 @@ // Quick Settings Panel - Centralized configuration for all UI features // Accessible via gear icon in header +import { apiService } from '../services/api.service.js'; + +const API_TOKEN_STORAGE_KEY = 'ruview-api-token'; + export class QuickSettings { constructor(app) { this.app = app; @@ -10,10 +14,21 @@ export class QuickSettings { } init() { + this.applyStoredApiToken(); this.createButton(); this.createPanel(); } + // Apply a previously-saved bearer token to apiService as early as + // possible, before any tab's REST calls fire. The server only ever + // checks the `Authorization: Bearer` header (see bearer_auth.rs) — this + // intentionally never puts the token in a URL query string. + applyStoredApiToken() { + let token = null; + try { token = localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ } + if (token) apiService.setAuthToken(token); + } + createButton() { this.button = document.createElement('button'); this.button.className = 'settings-gear'; @@ -70,6 +85,18 @@ export class QuickSettings { +
+
API Access
+
+ Bearer token (set only if the server enforces RUVIEW_API_TOKEN) + +
+ + +
+ +
+
Data
@@ -112,6 +139,30 @@ export class QuickSettings { } }); + this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => { + const input = this.panel.querySelector('#qs-api-token'); + const status = this.panel.querySelector('#qs-api-token-status'); + const token = input.value.trim(); + if (!token) { + status.textContent = 'Enter a token first, or use Clear to remove one.'; + return; + } + try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ } + apiService.setAuthToken(token); + status.textContent = 'Token saved and applied. Reloading...'; + setTimeout(() => window.location.reload(), 600); + }); + + this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => { + const input = this.panel.querySelector('#qs-api-token'); + const status = this.panel.querySelector('#qs-api-token-status'); + try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ } + apiService.setAuthToken(null); + input.value = ''; + status.textContent = 'Token cleared. Reloading...'; + setTimeout(() => window.location.reload(), 600); + }); + this.panel.querySelector('#qs-clear-data').addEventListener('click', () => { try { localStorage.clear(); @@ -154,6 +205,10 @@ export class QuickSettings { if (this.getSetting('compact')) { document.body.classList.add('compact-mode'); } + const status = this.panel.querySelector('#qs-api-token-status'); + let hasToken = false; + try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ } + if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).'; } prefersReducedMotion() { diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index d929e857..011343ac 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -34,6 +34,15 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN"; /// Path prefix the middleware protects when auth is enabled. pub const PROTECTED_PREFIX: &str = "/api/v1/"; +/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from +/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor +/// cannot attach an `Authorization` header to the handshake request, so this +/// path can never carry a bearer token from a stock browser client — the +/// same reasoning that already exempts `/ws/sensing` (see module docs). +/// Exempted here rather than moved out of `/api/v1/*` to avoid an API +/// surface change for existing clients. +const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"]; + /// Cheap, cloneable handle to the configured token (or `None`). #[derive(Debug, Clone, Default)] pub struct AuthState { @@ -93,7 +102,8 @@ pub async fn require_bearer( let Some(expected) = auth.token.clone() else { return next.run(request).await; }; - if !request.uri().path().starts_with(PROTECTED_PREFIX) { + let path = request.uri().path(); + if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) { return next.run(request).await; } let supplied = request @@ -141,6 +151,7 @@ mod tests { .route("/health", get(|| async { "ok" })) .route("/api/v1/info", get(|| async { "ok" })) .route("/api/v1/sensitive", axum::routing::post(|| async { "ok" })) + .route("/api/v1/stream/pose", get(|| async { "ok" })) .route("/ui/index.html", get(|| async { "" })) } @@ -361,6 +372,26 @@ mod tests { ); } + /// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket` + /// constructor drives directly — it cannot attach an `Authorization` + /// header, so this path must stay reachable even with auth ON (mirrors + /// the existing `/ws/sensing` exemption, just inside the `/api/v1/*` + /// prefix this time). + #[tokio::test] + async fn enabled_exempts_pose_stream_websocket() { + let r = wrap(AuthState::from_token("s3cr3t")); + assert_eq!( + status(r.clone(), "GET", "/api/v1/stream/pose", None).await, + StatusCode::OK, + "pose stream WS must stay reachable without a bearer token" + ); + // The exemption is narrow: it must not leak to other /api/v1/* paths. + assert_eq!( + status(r, "GET", "/api/v1/info", None).await, + StatusCode::UNAUTHORIZED + ); + } + #[test] fn ct_eq_basics() { assert!(ct_eq(b"abc", b"abc")); From 83f549d3080ba1c53aed0a47b911839d815fdec2 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 14 Jul 2026 11:08:37 -0400 Subject: [PATCH 4/5] fix: post-review fixes for PRs #1311/#1312/#1313 + CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sdkconfig.defaults.devkitc: header build command idf v5.2 -> v5.4 (source needs esp_driver_uart, IDF >=5.3 — same reason the PR fixed the README refs) - engine/lib.rs: separate doc comments — set_room_adapter's ADR-150 adapter/witness doc had merged into set_multistatic_config's doc - ui: apply stored bearer token at api.service.js module load instead of QuickSettings.init — services + dashboard tab dispatch their first /api/v1/* requests before initializeEnhancements() runs, so the first requests on every load went out without the Authorization header - CHANGELOG: Unreleased entries for #1308/#1309/#1310 Co-Authored-By: claude-flow --- CHANGELOG.md | 3 +++ .../esp32-csi-node/sdkconfig.defaults.devkitc | 2 +- ui/services/api.service.js | 15 ++++++++++++++- ui/utils/quick-settings.js | 17 +++-------------- v2/crates/wifi-densepose-engine/src/lib.rs | 10 ++++++---- vendor/rvcsi | 2 +- vendor/sublinear-time-solver | 2 +- 7 files changed, 29 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a02bd8e5..9fd3bffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (−22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies. ### Fixed +- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10–150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed). +- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it. +- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 40–45 pps). - **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1–O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn` — `ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1–O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `/.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1–D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20. - **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed. - **`csi_fps_ema` reported the CSI frame rate 40–840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`. diff --git a/firmware/esp32-csi-node/sdkconfig.defaults.devkitc b/firmware/esp32-csi-node/sdkconfig.defaults.devkitc index 8cb1962a..aa1d8dee 100644 --- a/firmware/esp32-csi-node/sdkconfig.defaults.devkitc +++ b/firmware/esp32-csi-node/sdkconfig.defaults.devkitc @@ -10,7 +10,7 @@ # Build (from repo root, per README "Docker — the only reliable method"): # MSYS_NO_PATHCONV=1 docker run --rm \ # -v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \ -# espressif/idf:v5.2 bash -c \ +# espressif/idf:v5.4 bash -c \ # "rm -rf build sdkconfig && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' set-target esp32s3 && idf.py -DSDKCONFIG_DEFAULTS='sdkconfig.defaults;sdkconfig.defaults.devkitc' build" # CONFIG_DISPLAY_ENABLE is not set diff --git a/ui/services/api.service.js b/ui/services/api.service.js index 79b94432..4e5daee7 100644 --- a/ui/services/api.service.js +++ b/ui/services/api.service.js @@ -147,4 +147,17 @@ export class ApiService { } // Create singleton instance -export const apiService = new ApiService(); \ No newline at end of file +export const apiService = new ApiService(); + +// Storage key shared with the QuickSettings "API Access" panel. +export const API_TOKEN_STORAGE_KEY = 'ruview-api-token'; + +// Apply a previously-saved bearer token at module load — before app init +// dispatches its first request — so a configured RUVIEW_API_TOKEN works from +// the very first /api/v1/* call. The server only ever checks the +// `Authorization: Bearer` header (see bearer_auth.rs) — this intentionally +// never puts the token in a URL query string. +try { + const storedToken = localStorage.getItem(API_TOKEN_STORAGE_KEY); + if (storedToken) apiService.setAuthToken(storedToken); +} catch { /* storage unavailable (private browsing etc.) */ } \ No newline at end of file diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js index 5112ee9a..afae95eb 100644 --- a/ui/utils/quick-settings.js +++ b/ui/utils/quick-settings.js @@ -1,9 +1,7 @@ // Quick Settings Panel - Centralized configuration for all UI features // Accessible via gear icon in header -import { apiService } from '../services/api.service.js'; - -const API_TOKEN_STORAGE_KEY = 'ruview-api-token'; +import { apiService, API_TOKEN_STORAGE_KEY } from '../services/api.service.js'; export class QuickSettings { constructor(app) { @@ -13,22 +11,13 @@ export class QuickSettings { this.isOpen = false; } + // A stored token is applied at api.service.js module load (before any + // request fires) — this panel only saves/clears it. init() { - this.applyStoredApiToken(); this.createButton(); this.createPanel(); } - // Apply a previously-saved bearer token to apiService as early as - // possible, before any tab's REST calls fire. The server only ever - // checks the `Authorization: Bearer` header (see bearer_auth.rs) — this - // intentionally never puts the token in a URL query string. - applyStoredApiToken() { - let token = null; - try { token = localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ } - if (token) apiService.setAuthToken(token); - } - createButton() { this.button = document.createElement('button'); this.button.className = 'settings-gear'; diff --git a/v2/crates/wifi-densepose-engine/src/lib.rs b/v2/crates/wifi-densepose-engine/src/lib.rs index f9b02c6b..ec21ca1d 100644 --- a/v2/crates/wifi-densepose-engine/src/lib.rs +++ b/v2/crates/wifi-densepose-engine/src/lib.rs @@ -226,10 +226,6 @@ impl StreamingEngine { } } - /// Activate a per-room calibration adapter (ADR-150 §3.4). From the next - /// cycle on, the adapter id is part of provenance `model_version` — and - /// therefore of the witness — so the exact weights shaping inference are - /// pinned in the trust chain. Pass the result of hashing the adapter file. /// Override the multistatic fuser's timestamp guard interval (#1049/#1057). /// Without this, `StreamingEngine::new` always builds /// `MultistaticFuser::with_config(MultistaticConfig::default())` — a @@ -237,10 +233,16 @@ impl StreamingEngine { /// caller derived from `WDP_TDM_SLOTS`/`WDP_GUARD_INTERVAL_US`, so /// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed /// trust cycles even after widening the guard elsewhere. + /// + /// Rebuilds the fuser, so call before any frames are processed. pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) { self.fuser = MultistaticFuser::with_config(cfg); } + /// Activate a per-room calibration adapter (ADR-150 §3.4). From the next + /// cycle on, the adapter id is part of provenance `model_version` — and + /// therefore of the witness — so the exact weights shaping inference are + /// pinned in the trust chain. Pass the result of hashing the adapter file. pub fn set_room_adapter(&mut self, info: AdapterInfo) { self.adapter = Some(info); } diff --git a/vendor/rvcsi b/vendor/rvcsi index 72891d74..77c8b6e0 160000 --- a/vendor/rvcsi +++ b/vendor/rvcsi @@ -1 +1 @@ -Subproject commit 72891d740f92903c78a8208a9069f6c82a4d1cc2 +Subproject commit 77c8b6e051fe8a2a3370715d4c3139311b93c364 diff --git a/vendor/sublinear-time-solver b/vendor/sublinear-time-solver index c25dddf1..47804fc5 160000 --- a/vendor/sublinear-time-solver +++ b/vendor/sublinear-time-solver @@ -1 +1 @@ -Subproject commit c25dddf163d8c413628ecdc6e979583d39270f22 +Subproject commit 47804fc5ca495d4fd608866fdb1e0960b34bffbf From 8c1d3d772ab00dad99aec3b89619793750484059 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 14 Jul 2026 12:08:20 -0400 Subject: [PATCH 5/5] chore: bump wifi-densepose-engine 0.3.1, wifi-densepose-sensing-server 0.3.4 engine 0.3.1: additive StreamingEngine::set_multistatic_config (#1312) sensing-server 0.3.4: bearer-auth pose-WS exemption + EngineBridge guard config threading (#1312, #1313); no public lib API change (engine_bridge is binary-internal) Co-Authored-By: claude-flow --- v2/Cargo.lock | 4 ++-- v2/crates/wifi-densepose-engine/Cargo.toml | 2 +- v2/crates/wifi-densepose-sensing-server/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 8c094cf6..5e8f34f7 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -10961,7 +10961,7 @@ dependencies = [ [[package]] name = "wifi-densepose-engine" -version = "0.3.0" +version = "0.3.1" dependencies = [ "blake3", "criterion", @@ -11126,7 +11126,7 @@ dependencies = [ [[package]] name = "wifi-densepose-sensing-server" -version = "0.3.3" +version = "0.3.4" dependencies = [ "axum", "chrono", diff --git a/v2/crates/wifi-densepose-engine/Cargo.toml b/v2/crates/wifi-densepose-engine/Cargo.toml index 08ec54f5..35daac93 100644 --- a/v2/crates/wifi-densepose-engine/Cargo.toml +++ b/v2/crates/wifi-densepose-engine/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "wifi-densepose-engine" description = "RuView streaming-engine integration layer — composes the ADR-135..146 building blocks into one trust-traceable pipeline cycle" -version = "0.3.0" +version = "0.3.1" edition.workspace = true authors.workspace = true license.workspace = true diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 20925f15..712c3b7f 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wifi-densepose-sensing-server" -version = "0.3.3" +version = "0.3.4" edition.workspace = true description = "Lightweight Axum server for WiFi sensing UI with RuVector signal processing" license.workspace = true