//! WiFi-DensePose Sensing Server //! //! Lightweight Axum server that: //! - Receives ESP32 CSI frames via UDP (port 5005) //! - Processes signals using RuVector-powered wifi-densepose-signal crate //! - Broadcasts sensing updates via WebSocket (ws://localhost:8765/ws/sensing) //! - Serves the static UI files (port 8080) //! //! Replaces both ws_server.py and the Python HTTP server. #![allow(dead_code)] mod adaptive_classifier; pub mod cli; pub mod csi; mod field_bridge; mod multistatic_bridge; pub mod pose; mod rvf_container; mod rvf_pipeline; mod tracker_bridge; pub mod types; mod vital_signs; // Training pipeline modules (exposed via lib.rs) use wifi_densepose_sensing_server::{dataset, embedding, graph_transformer, trainer}; use ruvector_mincut::{DynamicMinCut, MinCutBuilder}; use std::collections::{HashMap, VecDeque}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, Path, Query, State, }, http::StatusCode, response::{Html, IntoResponse, Json}, routing::{delete, get, post}, Extension, Router, }; use clap::Parser; use axum::http::HeaderValue; use serde::{Deserialize, Serialize}; use tokio::net::UdpSocket; use tokio::sync::{broadcast, RwLock}; use tower_http::services::ServeDir; use tower_http::set_header::SetResponseHeaderLayer; use tracing::{debug, error, info, warn}; use rvf_container::{RvfBuilder, RvfContainerInfo, RvfReader, VitalSignConfig}; use rvf_pipeline::ProgressiveLoader; use vital_signs::{VitalSignDetector, VitalSigns}; // ADR-022 Phase 3: Multi-BSSID pipeline integration use wifi_densepose_wifiscan::parse_netsh_output as parse_netsh_bssid_output; use wifi_densepose_wifiscan::{BssidRegistry, WindowsWifiPipeline}; // Accuracy sprint: Kalman tracker, multistatic fusion, field model use wifi_densepose_signal::ruvsense::field_model::{CalibrationStatus, FieldModel}; use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, MultistaticFuser}; use wifi_densepose_signal::ruvsense::pose_tracker::PoseTracker; // ── CLI ────────────────────────────────────────────────────────────────────── #[derive(Parser, Debug)] #[command(name = "sensing-server", about = "WiFi-DensePose sensing server")] struct Args { /// HTTP port for UI and REST API #[arg(long, default_value = "8080")] http_port: u16, /// WebSocket port for sensing stream #[arg(long, default_value = "8765")] ws_port: u16, /// UDP port for ESP32 CSI frames #[arg(long, default_value = "5005")] udp_port: u16, /// Path to UI static files (repo `ui/`; from `v2/` use `../ui` or rely on auto-detect) #[arg(long, default_value = "../ui")] ui_path: PathBuf, /// Tick interval in milliseconds (default 100 ms = 10 fps for smooth pose animation) #[arg(long, default_value = "100")] tick_ms: u64, /// Bind address (default 127.0.0.1; set to 0.0.0.0 for network access) #[arg(long, default_value = "127.0.0.1", env = "SENSING_BIND_ADDR")] bind_addr: String, /// Additional hostname (with or without `:PORT`) to permit in the `Host` /// header — defends loopback-bound deployments against DNS rebinding. /// Loopback names (`localhost`, `127.0.0.1`, `[::1]`) are always permitted /// implicitly. Pass multiple times to add several entries. Comma-separated /// values are also accepted via the `SENSING_ALLOWED_HOSTS` env var. #[arg(long = "allowed-host", value_name = "HOST")] allowed_hosts: Vec, /// Disable Host-header validation entirely. Use only when the server sits /// behind a reverse proxy that already canonicalises `Host` (e.g. nginx /// `proxy_set_header Host`) — bare deployments stay vulnerable to DNS /// rebinding without it. #[arg(long)] disable_host_validation: bool, /// MQTT publisher (HA auto-discovery) + privacy-mode flags (ADR-115). /// Flattened so `--mqtt*` reach the binary's parser and the publisher /// in `mqtt::` is actually started (fixes #872). Uses the *lib* crate's /// `MqttArgs` type so it's compatible with `mqtt::config::from_args`. #[command(flatten)] mqtt_opts: wifi_densepose_sensing_server::cli::MqttArgs, /// Data source: auto, wifi, esp32, simulate #[arg(long, default_value = "auto")] source: String, /// Run vital sign detection benchmark (1000 frames) and exit #[arg(long)] benchmark: bool, /// Load model config from an RVF container at startup #[arg(long, value_name = "PATH")] load_rvf: Option, /// Save current model state as an RVF container on shutdown #[arg(long, value_name = "PATH")] save_rvf: Option, /// Load a trained .rvf model for inference #[arg(long, value_name = "PATH")] model: Option, /// Enable progressive loading (Layer A instant start) #[arg(long)] progressive: bool, /// Export an RVF container package and exit (no server) #[arg(long, value_name = "PATH")] export_rvf: Option, /// Run training mode (train a model and exit) #[arg(long)] train: bool, /// Path to dataset directory (MM-Fi or Wi-Pose) #[arg(long, value_name = "PATH")] dataset: Option, /// Dataset type: "mmfi" or "wipose" #[arg(long, value_name = "TYPE", default_value = "mmfi")] dataset_type: String, /// Number of training epochs #[arg(long, default_value = "100")] epochs: usize, /// Directory for training checkpoints #[arg(long, value_name = "DIR")] checkpoint_dir: Option, /// Run self-supervised contrastive pretraining (ADR-024) #[arg(long)] pretrain: bool, /// Number of pretraining epochs (default 50) #[arg(long, default_value = "50")] pretrain_epochs: usize, /// Extract embeddings mode: load model and extract CSI embeddings #[arg(long)] embed: bool, /// Build fingerprint index from embeddings (env|activity|temporal|person) #[arg(long, value_name = "TYPE")] build_index: Option, /// Node positions for multistatic fusion (format: "x,y,z;x,y,z;...") #[arg(long, env = "SENSING_NODE_POSITIONS")] node_positions: Option, /// Start field model calibration on boot (empty room required) #[arg(long)] calibrate: bool, // --------------------------------------------------------------- // ADR-102: Edge Module Registry — surface the canonical Cognitum // cog catalog via `GET /api/v1/edge/registry`. // --------------------------------------------------------------- /// Override the upstream URL for the edge module registry. Set to a /// mirror or local file://... URL for air-gapped deployments. Empty /// string or --no-edge-registry disables the endpoint entirely. #[arg( long, value_name = "URL", env = "RUVIEW_EDGE_REGISTRY_URL", default_value = "https://storage.googleapis.com/cognitum-apps/app-registry.json" )] edge_registry_url: String, /// Cache TTL for the edge module registry, in seconds. #[arg( long, value_name = "SECS", env = "RUVIEW_EDGE_REGISTRY_TTL_SECS", default_value = "3600" )] edge_registry_ttl_secs: u64, /// Disable the edge module registry endpoint entirely. Returns 404 on /// `GET /api/v1/edge/registry`. Use for air-gapped deployments. #[arg(long, env = "RUVIEW_NO_EDGE_REGISTRY")] no_edge_registry: bool, } // ── Data types ─────────────────────────────────────────────────────────────── /// ADR-018 ESP32 CSI binary frame header (20 bytes) #[derive(Debug, Clone)] #[allow(dead_code)] struct Esp32Frame { magic: u32, node_id: u8, n_antennas: u8, n_subcarriers: u8, freq_mhz: u16, sequence: u32, rssi: i8, noise_floor: i8, amplitudes: Vec, phases: Vec, } /// Sensing update broadcast to WebSocket clients #[derive(Debug, Clone, Serialize, Deserialize)] struct SensingUpdate { #[serde(rename = "type")] msg_type: String, timestamp: f64, source: String, tick: u64, nodes: Vec, features: FeatureInfo, classification: ClassificationInfo, signal_field: SignalField, /// Vital sign estimates (breathing rate, heart rate, confidence). #[serde(skip_serializing_if = "Option::is_none")] vital_signs: Option, // ── ADR-022 Phase 3: Enhanced multi-BSSID pipeline fields ── /// Enhanced motion estimate from multi-BSSID pipeline. #[serde(skip_serializing_if = "Option::is_none")] enhanced_motion: Option, /// Enhanced breathing estimate from multi-BSSID pipeline. #[serde(skip_serializing_if = "Option::is_none")] enhanced_breathing: Option, /// Posture classification from BSSID fingerprint matching. #[serde(skip_serializing_if = "Option::is_none")] posture: Option, /// Signal quality score from multi-BSSID quality gate [0.0, 1.0]. #[serde(skip_serializing_if = "Option::is_none")] signal_quality_score: Option, /// Quality gate verdict: "Permit", "Warn", or "Deny". #[serde(skip_serializing_if = "Option::is_none")] quality_verdict: Option, /// Number of BSSIDs used in the enhanced sensing cycle. #[serde(skip_serializing_if = "Option::is_none")] bssid_count: Option, // ── ADR-023 Phase 7-8: Model inference fields ── /// Pose keypoints when a trained model is loaded (x, y, z, confidence). #[serde(skip_serializing_if = "Option::is_none")] pose_keypoints: Option>, /// Model status when a trained model is loaded. #[serde(skip_serializing_if = "Option::is_none")] model_status: Option, // ── Multi-person detection (issue #97) ── /// Detected persons from WiFi sensing (multi-person support). #[serde(skip_serializing_if = "Option::is_none")] persons: Option>, /// Estimated person count from CSI feature heuristics (1-3 for single ESP32). #[serde(skip_serializing_if = "Option::is_none")] estimated_persons: Option, /// Per-node feature breakdown for multi-node deployments. #[serde(skip_serializing_if = "Option::is_none")] node_features: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] struct NodeInfo { node_id: u8, rssi_dbm: f64, position: [f64; 3], amplitude: Vec, subcarrier_count: usize, /// ADR-110 iter 23 — cross-board sync snapshot for this node. /// `None` when no fresh sync packet has been observed (no mesh peer /// reachable, or this node is a singleton). Populated from /// `NodeState::latest_sync` and the iter 18 fps EMA. #[serde(skip_serializing_if = "Option::is_none")] sync: Option, } /// ADR-110 iter 23 — per-node mesh-sync snapshot embedded in NodeInfo. /// Surfaces what was previously only visible in the debug log so UI clients /// can render leader / follower / offset / measured-fps live. #[derive(Debug, Clone, Serialize, Deserialize)] struct NodeSyncSnapshot { /// Smoothed local-vs-mesh offset in µs (negative when this node's clock /// is behind the leader's — see §A0.10's measured -1.16 s on the bench). offset_us: i64, /// True when this node is the elected mesh leader. is_leader: bool, /// True when this node has heard a fresh leader beacon within the /// firmware's VALID_WINDOW_MS gate (3 s). is_valid: bool, /// True once the EMA-smoothed offset has seeded (one full beacon round-trip). smoothed: bool, /// Sync packet's sequence high-water — used by the host to pair CSI /// frames against this snapshot for §A0.12 mesh-time recovery. sequence: u32, /// Per-node measured CSI frame rate (iter 18 EMA). 20.0 until the /// EMA has at least 5 samples; the actually-observed rate after that. csi_fps_ema: f64, /// How many CSI frames have contributed to `csi_fps_ema`. Clients can /// treat <5 as "not yet trustworthy" and fall back to 20 Hz. csi_fps_samples: u32, /// ADR-110 iter 34 — milliseconds since the host last received a sync /// packet from this node. Lets UI dashboards render sync-age decay /// (badge fades after 5 s, drops off after the 9 s mesh_aligned_us /// staleness gate). `None` only when the host never had Instant data /// for this node, which shouldn't happen in normal flow but is /// modeled defensively. #[serde(skip_serializing_if = "Option::is_none")] staleness_ms: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] struct FeatureInfo { mean_rssi: f64, variance: f64, motion_band_power: f64, breathing_band_power: f64, dominant_freq_hz: f64, change_points: usize, spectral_power: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] struct ClassificationInfo { motion_level: String, presence: bool, confidence: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] struct SignalField { grid_size: [usize; 3], values: Vec, } /// WiFi-derived pose keypoint (17 COCO keypoints) #[derive(Debug, Clone, Serialize, Deserialize)] struct PoseKeypoint { name: String, x: f64, y: f64, z: f64, confidence: f64, } /// Person detection from WiFi sensing #[derive(Debug, Clone, Serialize, Deserialize)] struct PersonDetection { id: u32, confidence: f64, keypoints: Vec, bbox: BoundingBox, zone: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct BoundingBox { x: f64, y: f64, width: f64, height: f64, } /// Per-node sensing state for multi-node deployments (issue #249). /// Each ESP32 node gets its own frame history, smoothing buffers, and vital /// sign detector so that data from different nodes is never mixed. struct NodeState { pub(crate) frame_history: VecDeque>, smoothed_person_score: f64, pub(crate) prev_person_count: usize, smoothed_motion: f64, current_motion_level: String, debounce_counter: u32, debounce_candidate: String, baseline_motion: f64, baseline_frames: u64, smoothed_hr: f64, smoothed_br: f64, smoothed_hr_conf: f64, smoothed_br_conf: f64, hr_buffer: VecDeque, br_buffer: VecDeque, rssi_history: VecDeque, vital_detector: VitalSignDetector, latest_vitals: VitalSigns, pub(crate) last_frame_time: Option, edge_vitals: Option, /// ADR-110 §A0.12: Latest sync packet received from this node. When a /// CSI frame arrives with byte 19 bit 4 set (`adr018_flags.ieee802154_sync_valid`), /// the host can recover a mesh-aligned timestamp via /// `latest_sync.epoch_us + (now_local - latest_sync.local_us)`. latest_sync: Option, /// Last time a sync packet from this node was received (for staleness). latest_sync_at: Option, /// ADR-110 iter 18: EMA-tracked CSI frame rate for this node. /// Replaces the hardcoded 20 Hz fallback in /// `mesh_aligned_us_for_csi_frame` once `csi_fps_samples ≥ 5`. csi_fps_ema: f64, /// Number of inter-frame deltas observed (need ≥5 before trusting EMA). csi_fps_samples: u32, /// Latest extracted features for cross-node fusion. latest_features: Option, // ── RuVector Phase 2: Temporal smoothing & coherence gating ── /// Previous frame's smoothed keypoint positions for EMA temporal smoothing. prev_keypoints: Option>, /// Rolling buffer of motion_energy values for coherence scoring (last 20 frames). motion_energy_history: VecDeque, /// Coherence score [0.0, 1.0]: low variance in motion_energy = high coherence. coherence_score: f64, /// ADR-084 Pass 3 cluster-Pi novelty sensor — per-node sketch bank of /// recent CSI feature vectors. Populated by `update_novelty` on each /// frame; left `None` to disable the sensor on a per-node basis. feature_history: Option, /// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank, /// 1 = no overlap). Consumed by the model-wake gate downstream. pub(crate) last_novelty_score: Option, } /// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2). /// Lower = smoother (more history, less jitter). 0.15 balances responsiveness /// with stability for WiFi CSI where per-frame noise is high. const TEMPORAL_EMA_ALPHA_DEFAULT: f64 = 0.15; /// Reduced EMA alpha when coherence is low (trust measurements less). const TEMPORAL_EMA_ALPHA_LOW_COHERENCE: f64 = 0.05; /// Coherence threshold below which we reduce EMA alpha. const COHERENCE_LOW_THRESHOLD: f64 = 0.3; /// Maximum allowed bone-length change ratio between frames (20%). const MAX_BONE_CHANGE_RATIO: f64 = 0.20; /// Number of motion_energy frames to track for coherence scoring. const COHERENCE_WINDOW: usize = 20; /// ADR-084 Pass 3 — per-node novelty sketch dimension (56 subcarriers, /// the dominant ESP32-S3 capture configuration). const NOVELTY_VECTOR_DIM: usize = 56; /// ADR-084 Pass 3 — number of past sketches retained per-node for /// novelty comparison. 64 frames ≈ 6.4 s at 10 Hz. const NOVELTY_HISTORY_CAPACITY: usize = 64; /// ADR-084 Pass 3 — feature-vector schema version. Bump on changes to /// subcarrier ordering / normalisation so banks reject stale data. const NOVELTY_SKETCH_VERSION: u16 = 1; /// ADR-110 iter 18 — EMA update for per-node CSI fps tracking. /// /// Returns the new EMA value, or `None` if the delta is implausible /// (≤ 0, or > 1 second — likely a connection gap, not a real frame /// rate sample). α = 1/8 fixed shift, ~8-sample effective window, /// matching the firmware-side ESP-NOW offset smoother in §A0.10. /// /// Free function for testability — every transformation that doesn't /// touch the rest of `NodeState` lives outside the `impl` block. pub(crate) fn update_csi_fps_ema(prev_fps: f64, dt_sec: f64) -> Option { if !(dt_sec > 0.0 && dt_sec < 1.0) { return None; } let instantaneous = 1.0 / dt_sec; // y[n] = y[n-1] + (x - y[n-1]) / 8 Some(prev_fps + (instantaneous - prev_fps) / 8.0) } #[cfg(test)] mod fps_ema_tests { use super::update_csi_fps_ema; #[test] fn steady_10hz_converges_toward_10() { let mut fps = 20.0; for _ in 0..40 { fps = update_csi_fps_ema(fps, 0.100).unwrap(); } assert!((fps - 10.0).abs() < 0.1, "expected ~10 Hz after 40 samples at 100 ms intervals, got {fps}"); } #[test] fn steady_20hz_stays_near_20() { let mut fps = 20.0; for _ in 0..20 { fps = update_csi_fps_ema(fps, 0.050).unwrap(); } assert!((fps - 20.0).abs() < 0.05, "expected ~20 Hz, got {fps}"); } #[test] fn nonpositive_dt_rejected() { assert!(update_csi_fps_ema(15.0, 0.0).is_none()); assert!(update_csi_fps_ema(15.0, -0.1).is_none()); } #[test] fn long_gap_rejected_as_implausible() { assert!(update_csi_fps_ema(20.0, 2.0).is_none()); } } impl NodeState { /// ADR-110 §A0.12 timestamp recovery: given a CSI frame's node-local /// `esp_timer_get_time()` snapshot, return the mesh-aligned epoch /// computed from this node's most recent sync packet — or `None` /// if no sync has been received yet, or the last one is too stale /// (older than 3 × VALID_WINDOW_MS = 9 s, matching the firmware's own /// staleness gate). pub(crate) fn mesh_aligned_us(&self, local_at_frame_us: u64) -> Option { let sync = self.latest_sync.as_ref()?; let seen_at = self.latest_sync_at?; // Drop stale syncs — firmware emits at ~0.5 Hz default, anything // older than 9 s likely means the mesh transport dropped. if seen_at.elapsed() > std::time::Duration::from_secs(9) { return None; } Some(sync.apply_to_local(local_at_frame_us)) } /// ADR-110 §A0.12 sequence-based mesh-time recovery for an in-flight /// ADR-018 CSI frame. The frame carries no `local_us` (the wire /// format has no slot), but it carries a sequence number that the /// sync packet's `sequence` high-water can be paired against. Uses /// 20 Hz as the default CSI rate (the firmware's /// `CSI_MIN_SEND_INTERVAL_US`-implied ceiling). Returns `None` if /// no fresh sync has been observed for this node. pub(crate) fn mesh_aligned_us_for_csi_frame(&self, frame_sequence: u32) -> Option { let sync = self.latest_sync.as_ref()?; let seen_at = self.latest_sync_at?; if seen_at.elapsed() > std::time::Duration::from_secs(9) { return None; } // Iter 18: use the measured per-node fps once we have ≥5 inter-frame // samples; until then fall back to the 20 Hz firmware ceiling. The // §A0.12 capture showed real bench fps ≈ 10, so the measured value // is significantly more accurate than the constant fallback. let fps = if self.csi_fps_samples >= 5 { self.csi_fps_ema } else { 20.0 }; Some(sync.mesh_aligned_us_for_sequence(frame_sequence, fps)) } /// ADR-110 iter 18 — update the per-node observed-fps EMA from a fresh /// CSI frame arrival. Call once per accepted CSI frame from /// `udp_receiver_task`. Uses `last_frame_time` as the previous-frame /// anchor; the first frame after init seeds the timer without producing /// a sample (no prior dt to measure). /// ADR-110 iter 32 — apply a freshly-decoded sync packet to this node. /// Overwrites `latest_sync` with the new packet and stamps /// `latest_sync_at` so the staleness gate in `mesh_aligned_us_for_csi_frame` /// can age it out after 9 s. Used by `udp_receiver_task` on every /// successful magic-dispatched sync datagram; extracted so the dispatch /// path is testable without spinning up the tokio UDP socket. pub(crate) fn apply_sync_packet( &mut self, pkt: wifi_densepose_hardware::SyncPacket, now: std::time::Instant, ) { self.latest_sync = Some(pkt); self.latest_sync_at = Some(now); } /// ADR-110 iter 30 — pure snapshot of this node's mesh-sync state. /// Returns `None` when no sync packet has been observed. Used by both /// the WebSocket broadcaster (iter 23) and the REST handlers (iter 29); /// extracted here so tests can build a `NodeState`, populate /// `latest_sync`, and assert the snapshot shape without spinning up /// the axum router. pub(crate) fn sync_snapshot(&self) -> Option { let sync = self.latest_sync.as_ref()?; Some(NodeSyncSnapshot { offset_us: sync.local_minus_epoch_us(), is_leader: sync.flags.is_leader, is_valid: sync.flags.is_valid, smoothed: sync.flags.smoothed_used, sequence: sync.sequence, csi_fps_ema: self.csi_fps_ema, csi_fps_samples: self.csi_fps_samples, staleness_ms: self.latest_sync_at.map(|t| t.elapsed().as_millis() as u64), }) } pub(crate) fn observe_csi_frame_arrival(&mut self, now: std::time::Instant) { if let Some(prev) = self.last_frame_time { let dt = now.duration_since(prev).as_secs_f64(); if let Some(new_ema) = update_csi_fps_ema(self.csi_fps_ema, dt) { self.csi_fps_ema = new_ema; self.csi_fps_samples = self.csi_fps_samples.saturating_add(1); } } self.last_frame_time = Some(now); } pub(crate) fn new() -> Self { Self { frame_history: VecDeque::new(), smoothed_person_score: 0.0, prev_person_count: 0, smoothed_motion: 0.0, current_motion_level: "absent".to_string(), debounce_counter: 0, debounce_candidate: "absent".to_string(), baseline_motion: 0.0, baseline_frames: 0, smoothed_hr: 0.0, smoothed_br: 0.0, smoothed_hr_conf: 0.0, smoothed_br_conf: 0.0, hr_buffer: VecDeque::with_capacity(8), br_buffer: VecDeque::with_capacity(8), rssi_history: VecDeque::new(), vital_detector: VitalSignDetector::new(10.0), latest_vitals: VitalSigns::default(), last_frame_time: None, edge_vitals: None, latest_sync: None, latest_sync_at: None, csi_fps_ema: 20.0, csi_fps_samples: 0, latest_features: None, prev_keypoints: None, motion_energy_history: VecDeque::with_capacity(COHERENCE_WINDOW), coherence_score: 1.0, // assume stable initially feature_history: Some( wifi_densepose_signal::ruvsense::longitudinal::EmbeddingHistory::with_sketch( NOVELTY_VECTOR_DIM, NOVELTY_HISTORY_CAPACITY, NOVELTY_SKETCH_VERSION, ), ), last_novelty_score: None, } } /// ADR-084 cluster-Pi novelty step. Truncates / zero-pads the /// incoming amplitude vector to `NOVELTY_VECTOR_DIM`, scores its /// novelty against the per-node bank, then inserts it. The novelty /// score is computed *before* the insert so a frame doesn't see /// itself in the bank. pub(crate) fn update_novelty(&mut self, amplitudes: &[f64]) { let history = match &mut self.feature_history { Some(h) => h, None => return, }; let mut feature: Vec = amplitudes .iter() .take(NOVELTY_VECTOR_DIM) .map(|&v| v as f32) .collect(); feature.resize(NOVELTY_VECTOR_DIM, 0.0); // Score before insert so a query doesn't see itself. self.last_novelty_score = history.novelty(&feature); let _ = history.push( wifi_densepose_signal::ruvsense::longitudinal::EmbeddingEntry { person_id: 0, day_us: 0, embedding: feature, }, ); } /// Update the coherence score from the latest motion_energy value. /// /// Coherence is computed as 1.0 / (1.0 + running_variance) so that /// low motion-energy variance maps to high coherence ([0, 1]). fn update_coherence(&mut self, motion_energy: f64) { if self.motion_energy_history.len() >= COHERENCE_WINDOW { self.motion_energy_history.pop_front(); } self.motion_energy_history.push_back(motion_energy); let n = self.motion_energy_history.len(); if n < 2 { self.coherence_score = 1.0; return; } let mean: f64 = self.motion_energy_history.iter().sum::() / n as f64; let variance: f64 = self .motion_energy_history .iter() .map(|v| (v - mean) * (v - mean)) .sum::() / (n - 1) as f64; // Map variance to [0, 1] coherence: higher variance = lower coherence. self.coherence_score = (1.0 / (1.0 + variance)).clamp(0.0, 1.0); } /// Choose the EMA alpha based on current coherence score. fn ema_alpha(&self) -> f64 { if self.coherence_score < COHERENCE_LOW_THRESHOLD { TEMPORAL_EMA_ALPHA_LOW_COHERENCE } else { TEMPORAL_EMA_ALPHA_DEFAULT } } } /// Per-node feature info for WebSocket broadcasts (multi-node support). #[derive(Debug, Clone, Serialize, Deserialize)] struct PerNodeFeatureInfo { node_id: u8, features: FeatureInfo, classification: ClassificationInfo, rssi_dbm: f64, last_seen_ms: u64, frame_rate_hz: f64, stale: bool, /// ADR-084 Pass 3 cluster-Pi novelty score in `[0.0, 1.0]`. /// `0.0` = exact-match-in-bank, `1.0` = no overlap with recent /// per-node frame history. `None` until the first /// `update_novelty()` call. Consumers (model-wake gate, anomaly /// emit, UI heatmap) read this to decide whether to escalate. #[serde(skip_serializing_if = "Option::is_none")] novelty_score: Option, } /// Build a per-node feature snapshot for the WebSocket envelope. /// /// ADR-084 Pass 3.6 — exposes `last_novelty_score` from each /// `NodeState` to the WebSocket consumer. Returns `None` when the /// node map is empty (no live ESP32 frames have been ingested yet), /// so the existing `node_features: None` semantics on cold-start are /// preserved. /// /// Stale flag uses 5-second threshold matching `ESP32_OFFLINE_TIMEOUT`. fn build_node_features( node_states: &std::collections::HashMap, now: std::time::Instant, ) -> Option> { if node_states.is_empty() { return None; } let entries: Vec = node_states .iter() .map(|(&node_id, ns)| { let last_seen_ms = ns .last_frame_time .map(|t| now.saturating_duration_since(t).as_millis() as u64) .unwrap_or(u64::MAX); let stale = ns .last_frame_time .map(|t| now.saturating_duration_since(t) > ESP32_OFFLINE_TIMEOUT) .unwrap_or(true); let features = ns.latest_features.clone().unwrap_or(FeatureInfo { mean_rssi: 0.0, variance: 0.0, motion_band_power: 0.0, breathing_band_power: 0.0, dominant_freq_hz: 0.0, change_points: 0, spectral_power: 0.0, }); PerNodeFeatureInfo { node_id, features, classification: ClassificationInfo { motion_level: ns.current_motion_level.clone(), presence: !matches!(ns.current_motion_level.as_str(), "absent"), confidence: ns.smoothed_person_score.clamp(0.0, 1.0), }, rssi_dbm: ns.rssi_history.back().copied().unwrap_or(0.0), last_seen_ms, frame_rate_hz: 0.0, // Computed elsewhere; not yet plumbed here. stale, novelty_score: ns.last_novelty_score, } }) .collect(); Some(entries) } // ── ADR-044 §5.2: Rolling P95 adaptive feature normalizer ──────────────────── /// Streaming P95 estimator over a fixed-size sliding window. /// /// Self-calibrates feature normalization to whatever distribution the deployment /// produces — no hardcoded scale values that can saturate in large rooms or /// degrade in high-interference environments. /// /// O(n log n) per query via sorted copy — acceptable at 20 Hz with window=600. /// Cold-start (len < min_samples) returns `None` so the caller uses the legacy /// fixed denominator, preserving day-0 behaviour. pub struct RollingP95 { buf: std::collections::VecDeque, window: usize, min_samples: usize, } impl RollingP95 { pub fn new(window: usize, min_samples: usize) -> Self { Self { buf: std::collections::VecDeque::with_capacity(window), window, min_samples, } } pub fn push(&mut self, v: f64) { if self.buf.len() == self.window { self.buf.pop_front(); } self.buf.push_back(v); } /// Returns `Some(p95)` once enough samples have accumulated, else `None`. pub fn current(&self) -> Option { if self.buf.len() < self.min_samples { return None; } let mut sorted: Vec = self.buf.iter().copied().collect(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let idx = ((sorted.len() as f64) * 0.95).ceil() as usize; Some(sorted[idx.saturating_sub(1).min(sorted.len() - 1)]) } #[allow(dead_code)] pub fn len(&self) -> usize { self.buf.len() } #[allow(dead_code)] pub fn is_empty(&self) -> bool { self.buf.is_empty() } } // ── ADR-044 §5.3: Runtime config persistence ───────────────────────────────── /// Runtime configuration that persists across server restarts via `data/config.json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct RuntimeConfig { /// Divisor for multi-node person-count deduplication (sum / factor). pub dedup_factor: f64, } impl Default for RuntimeConfig { fn default() -> Self { Self { dedup_factor: 3.0 } } } /// Load persisted runtime config from `/config.json`. /// Falls back to [`RuntimeConfig::default`] if the file is absent or malformed. pub(crate) fn load_runtime_config(data_dir: &std::path::Path) -> RuntimeConfig { let path = data_dir.join("config.json"); match std::fs::read_to_string(&path) { Ok(json) => serde_json::from_str(&json).unwrap_or_default(), Err(_) => RuntimeConfig::default(), } } /// Persist runtime config to `/config.json`. pub(crate) fn save_runtime_config(data_dir: &std::path::Path, config: &RuntimeConfig) { let path = data_dir.join("config.json"); if let Ok(json) = serde_json::to_string_pretty(config) { if let Err(e) = std::fs::write(&path, json) { warn!("Failed to save runtime config to {}: {e}", path.display()); } else { info!("Runtime config saved to {}", path.display()); } } } /// Shared application state struct AppStateInner { latest_update: Option, rssi_history: VecDeque, /// Circular buffer of recent CSI amplitude vectors for temporal analysis. /// Each entry is the full subcarrier amplitude vector for one frame. /// Capacity: FRAME_HISTORY_CAPACITY frames. frame_history: VecDeque>, tick: u64, source: String, /// Instant of the last ESP32 UDP frame received (for offline detection). last_esp32_frame: Option, tx: broadcast::Sender, // ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state + // a parallel broadcast topic (`/ws/introspection`) running alongside // (not replacing) the window-aggregated `tx` / `/ws/sensing` pipeline. intro: wifi_densepose_sensing_server::introspection::IntrospectionState, intro_tx: broadcast::Sender, total_detections: u64, start_time: std::time::Instant, /// Vital sign detector (processes CSI frames to estimate HR/RR). vital_detector: VitalSignDetector, /// Most recent vital sign reading for the REST endpoint. latest_vitals: VitalSigns, /// RVF container info if a model was loaded via `--load-rvf`. rvf_info: Option, /// Path to save RVF container on shutdown (set via `--save-rvf`). save_rvf_path: Option, /// Progressive loader for a trained model (set via `--model`). progressive_loader: Option, /// Active SONA profile name. active_sona_profile: Option, /// Whether a trained model is loaded. model_loaded: bool, /// Smoothed person count (EMA) for hysteresis — prevents frame-to-frame jumping. smoothed_person_score: f64, /// Previous person count for hysteresis (asymmetric up/down thresholds). prev_person_count: usize, // ── Motion smoothing & adaptive baseline (ADR-047 tuning) ──────────── /// EMA-smoothed motion score (alpha ~0.15 for ~10 FPS → ~1s time constant). smoothed_motion: f64, /// Current classification state for hysteresis debounce. current_motion_level: String, /// How many consecutive frames the *raw* classification has agreed with a /// *candidate* new level. State only changes after DEBOUNCE_FRAMES. debounce_counter: u32, /// The candidate motion level that the debounce counter is tracking. debounce_candidate: String, /// Adaptive baseline: EMA of motion score when room is "quiet" (low motion). /// Subtracted from raw score so slow environmental drift doesn't inflate readings. baseline_motion: f64, /// Number of frames processed so far (for baseline warm-up). baseline_frames: u64, // ── Vital signs smoothing ──────────────────────────────────────────── /// EMA-smoothed heart rate (BPM). smoothed_hr: f64, /// EMA-smoothed breathing rate (BPM). smoothed_br: f64, /// EMA-smoothed HR confidence. smoothed_hr_conf: f64, /// EMA-smoothed BR confidence. smoothed_br_conf: f64, /// Median filter buffer for HR (last N raw values for outlier rejection). hr_buffer: VecDeque, /// Median filter buffer for BR. br_buffer: VecDeque, /// ADR-039: Latest edge vitals packet from ESP32. edge_vitals: Option, /// ADR-040: Latest WASM output packet from ESP32. latest_wasm_events: Option, // ── Model management fields ───────────────────────────────────────────── /// Discovered RVF model files from `data/models/`. discovered_models: Vec, /// ID of the currently loaded model, if any. active_model_id: Option, // ── Recording fields ──────────────────────────────────────────────────── /// Metadata for recorded CSI data files. recordings: Vec, /// Whether CSI recording is currently in progress. recording_active: bool, /// When the current recording started. recording_start_time: Option, /// ID of the current recording (used for filename). recording_current_id: Option, /// Shutdown signal for the recording writer task. recording_stop_tx: Option>, // ── Training fields ───────────────────────────────────────────────────── /// Training status: "idle", "running", "completed", "failed". training_status: String, /// Training configuration, if any. training_config: Option, // ── Adaptive classifier (environment-tuned) ────────────────────────── /// Trained adaptive model (loaded from data/adaptive_model.json or trained at runtime). adaptive_model: Option, // ── Per-node state (issue #249) ───────────────────────────────────── /// Per-node sensing state for multi-node deployments. /// Keyed by `node_id` from the ESP32 frame header. node_states: HashMap, // ── Accuracy sprint: Kalman tracker, multistatic fusion, eigenvalue counting ── /// Global Kalman-based pose tracker for stable person IDs and smoothed keypoints. pose_tracker: PoseTracker, /// Instant of last tracker update (for computing dt). last_tracker_instant: Option, /// Attention-weighted multi-node CSI fusion engine. multistatic_fuser: MultistaticFuser, /// SVD-based room field model for eigenvalue person counting (None until calibration). field_model: Option, // ── ADR-044 §5.2: adaptive rolling-p95 normalization ───────────────────── /// Rolling P95 of `FeatureInfo.variance` over the last ~30 s (600 frames @ 20 Hz). pub(crate) p95_variance: RollingP95, /// Rolling P95 of `FeatureInfo.motion_band_power` over the last ~30 s. pub(crate) p95_motion_band_power: RollingP95, /// Rolling P95 of `FeatureInfo.spectral_power` over the last ~30 s. pub(crate) p95_spectral_power: RollingP95, // ── ADR-044 §5.3: runtime-configurable dedup factor ─────────────────────── /// Divisor for multi-node person-count deduplication (sum / factor). /// Default 3.0 (one body visible to ~3 nodes on average). /// Configurable at runtime via `POST /api/v1/config/dedup-factor` and /// `POST /api/v1/config/ground-truth`. Persisted across restarts. pub(crate) dedup_factor: f64, /// Data directory for persisting runtime config (parent of `firmware_dir`). pub(crate) data_dir: std::path::PathBuf, } /// If no ESP32 frame arrives within this duration, source reverts to offline. const ESP32_OFFLINE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); impl AppStateInner { /// Return the effective data source, accounting for ESP32 frame timeout. /// If the source is "esp32" but no frame has arrived in 5 seconds, returns /// "esp32:offline" so the UI can distinguish active vs stale connections. /// Person count: eigenvalue-based if field model is calibrated, else heuristic. /// Uses global frame_history if populated, otherwise the freshest per-node history. fn person_count(&self) -> usize { match self.field_model.as_ref() { Some(fm) => { // Prefer global frame_history (populated by wifi/simulate paths). // Fall back to freshest per-node history (populated by ESP32 paths). let history = if !self.frame_history.is_empty() { &self.frame_history } else { // Find the node with the most recent frame self.node_states .values() .filter(|ns| !ns.frame_history.is_empty()) .max_by_key(|ns| ns.last_frame_time) .map(|ns| &ns.frame_history) .unwrap_or(&self.frame_history) }; field_bridge::occupancy_or_fallback( fm, history, self.smoothed_person_score, self.prev_person_count, ) } None => score_to_person_count(self.smoothed_person_score, self.prev_person_count), } } fn effective_source(&self) -> String { if self.source == "esp32" { if let Some(last) = self.last_esp32_frame { if last.elapsed() > ESP32_OFFLINE_TIMEOUT { return "esp32:offline".to_string(); } } } self.source.clone() } } /// Number of frames retained in `frame_history` for temporal analysis. /// At 500 ms ticks this covers ~50 seconds; at 100 ms ticks ~10 seconds. const FRAME_HISTORY_CAPACITY: usize = 100; type SharedState = Arc>; // ── ESP32 Edge Vitals Packet (ADR-039, magic 0xC511_0002) ──────────────────── /// Decoded vitals packet from ESP32 edge processing pipeline. #[derive(Debug, Clone, Serialize)] struct Esp32VitalsPacket { node_id: u8, presence: bool, fall_detected: bool, motion: bool, breathing_rate_bpm: f64, heartrate_bpm: f64, rssi: i8, n_persons: u8, motion_energy: f32, presence_score: f32, timestamp_ms: u32, } /// Parse a 32-byte edge vitals packet (magic 0xC511_0002). fn parse_esp32_vitals(buf: &[u8]) -> Option { if buf.len() < 32 { return None; } let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); if magic != 0xC511_0002 { 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 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]]); Some(Esp32VitalsPacket { node_id, presence: (flags & 0x01) != 0, fall_detected: (flags & 0x02) != 0, motion: (flags & 0x04) != 0, breathing_rate_bpm: breathing_raw as f64 / 100.0, heartrate_bpm: heartrate_raw as f64 / 10000.0, rssi, n_persons, motion_energy, presence_score, timestamp_ms, }) } // ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ───── /// Single WASM event (type + value). #[derive(Debug, Clone, Serialize)] struct WasmEvent { event_type: u8, value: f32, } /// Decoded WASM output packet from ESP32 Tier 3 runtime. #[derive(Debug, Clone, Serialize)] struct WasmOutputPacket { node_id: u8, module_id: u8, events: Vec, } /// 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 { if buf.len() < 8 { return None; } let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); if magic != 0xC511_0007 { return None; } let node_id = buf[4]; let module_id = buf[5]; let event_count = u16::from_le_bytes([buf[6], buf[7]]) as usize; let mut events = Vec::with_capacity(event_count); let mut offset = 8; for _ in 0..event_count { if offset + 5 > buf.len() { break; } let event_type = buf[offset]; let value = f32::from_le_bytes([ buf[offset + 1], buf[offset + 2], buf[offset + 3], buf[offset + 4], ]); events.push(WasmEvent { event_type, value }); offset += 5; } Some(WasmOutputPacket { node_id, module_id, events, }) } // ── 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 { 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 { 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 { if buf.len() < 20 { return None; } let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); if magic != 0xC511_0001 { return None; } // Frame layout (must match firmware csi_collector.c): // [0..3] magic (u32 LE) // [4] node_id (u8) // [5] n_antennas (u8) // [6..7] n_subcarriers (u16 LE) // [8..11] freq_mhz (u32 LE) // [12..15] sequence (u32 LE) // [16] rssi (i8) // [17] noise_floor (i8) // [18..19] reserved // [20..] I/Q data let node_id = buf[4]; let n_antennas = buf[5]; let n_subcarriers = buf[6]; let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]); let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]); let rssi_raw = buf[14] as i8; // Fix RSSI sign: ensure it's always negative (dBm convention). let rssi = if rssi_raw > 0 { rssi_raw.saturating_neg() } else { rssi_raw }; let noise_floor = buf[15] as i8; let iq_start = 20; let n_pairs = n_antennas as usize * n_subcarriers as usize; let expected_len = iq_start + n_pairs * 2; if buf.len() < expected_len { return None; } let mut amplitudes = Vec::with_capacity(n_pairs); let mut phases = Vec::with_capacity(n_pairs); for k in 0..n_pairs { let i_val = buf[iq_start + k * 2] as i8 as f64; let q_val = buf[iq_start + k * 2 + 1] as i8 as f64; amplitudes.push((i_val * i_val + q_val * q_val).sqrt()); phases.push(q_val.atan2(i_val)); } Some(Esp32Frame { magic, node_id, n_antennas, n_subcarriers, freq_mhz, sequence, rssi, noise_floor, amplitudes, phases, }) } // ── Signal field generation ────────────────────────────────────────────────── /// Generate a signal field that reflects where motion and signal changes are occurring. /// /// Instead of a fixed-animation circle, this function uses the actual sensing data: /// - `subcarrier_variances`: per-subcarrier variance computed from the frame history. /// High-variance subcarriers indicate spatial directions where the signal is disrupted. /// - `motion_score`: overall motion intensity [0, 1]. /// - `breathing_rate_hz`: estimated breathing rate in Hz; if > 0, adds a breathing ring. /// - `signal_quality`: overall quality metric [0, 1] modulates field brightness. /// /// The field grid is 20×20 cells representing a top-down view of the room. /// Hotspots are derived from the subcarrier index (treated as an angular bin) so that /// subcarriers with the highest variance produce peaks at the corresponding directions. fn generate_signal_field( _mean_rssi: f64, motion_score: f64, breathing_rate_hz: f64, signal_quality: f64, subcarrier_variances: &[f64], ) -> SignalField { let grid = 20usize; let mut values = vec![0.0f64; grid * grid]; let center = (grid as f64 - 1.0) / 2.0; // Normalise subcarrier variances to [0, 1]. let max_var = subcarrier_variances.iter().cloned().fold(0.0f64, f64::max); let norm_factor = if max_var > 1e-9 { max_var } else { 1.0 }; // For each cell, accumulate contributions from all subcarriers. // Each subcarrier k is assigned an angular direction proportional to its index // so that different subcarriers illuminate different regions of the room. let n_sub = subcarrier_variances.len().max(1); for (k, &var) in subcarrier_variances.iter().enumerate() { let weight = (var / norm_factor) * motion_score; if weight < 1e-6 { continue; } // Map subcarrier index to an angle across the full 2π sweep. let angle = (k as f64 / n_sub as f64) * 2.0 * std::f64::consts::PI; // Place the hotspot at a distance proportional to the weight, capped at 40% of // the grid radius so it stays within the room model. let radius = center * 0.8 * weight.sqrt(); let hx = center + radius * angle.cos(); let hz = center + radius * angle.sin(); for z in 0..grid { for x in 0..grid { let dx = x as f64 - hx; let dz = z as f64 - hz; let dist2 = dx * dx + dz * dz; // Gaussian blob centred on the hotspot; spread scales with weight. let spread = (0.5 + weight * 2.0).max(0.5); values[z * grid + x] += weight * (-dist2 / (2.0 * spread * spread)).exp(); } } } // Base radial attenuation from the router assumed at grid centre. for z in 0..grid { for x in 0..grid { let dx = x as f64 - center; let dz = z as f64 - center; let dist = (dx * dx + dz * dz).sqrt(); let base = signal_quality * (-dist * 0.12).exp(); values[z * grid + x] += base * 0.3; } } // Breathing ring: if a breathing rate was estimated add a faint annular highlight // at a radius corresponding to typical chest-wall displacement range. if breathing_rate_hz > 0.05 { let ring_r = center * 0.55; let ring_width = 1.8f64; for z in 0..grid { for x in 0..grid { let dx = x as f64 - center; let dz = z as f64 - center; let dist = (dx * dx + dz * dz).sqrt(); let ring_val = 0.08 * (-(dist - ring_r).powi(2) / (2.0 * ring_width * ring_width)).exp(); values[z * grid + x] += ring_val; } } } // Clamp and normalise to [0, 1]. let field_max = values.iter().cloned().fold(0.0f64, f64::max); let scale = if field_max > 1e-9 { 1.0 / field_max } else { 1.0 }; for v in &mut values { *v = (*v * scale).clamp(0.0, 1.0); } SignalField { grid_size: [grid, 1, grid], values, } } // ── Feature extraction from ESP32 frame ────────────────────────────────────── /// Estimate breathing rate in Hz from the amplitude time series stored in `frame_history`. /// /// Approach: /// 1. Build a scalar time series by computing the mean amplitude of each historical frame. /// 2. Run a peak-detection pass: count rising-edge zero-crossings of the de-meaned signal. /// 3. Convert the crossing rate to Hz, clipped to the physiological range 0.1–0.5 Hz /// (12–30 breaths/min). /// /// For accuracy the function additionally applies a simple 3-tap Goertzel-style power /// estimate at evenly-spaced candidate frequencies in the breathing band and returns /// the candidate with the highest energy. fn estimate_breathing_rate_hz(frame_history: &VecDeque>, sample_rate_hz: f64) -> f64 { let n = frame_history.len(); if n < 6 { return 0.0; } // Build scalar time series: mean amplitude per frame. let series: Vec = frame_history .iter() .map(|amps| { if amps.is_empty() { 0.0 } else { amps.iter().sum::() / amps.len() as f64 } }) .collect(); let mean_s = series.iter().sum::() / n as f64; // De-mean. let detrended: Vec = series.iter().map(|x| x - mean_s).collect(); // Goertzel power at candidate frequencies in the breathing band [0.1, 0.5] Hz. // We evaluate 9 candidate frequencies uniformly spaced in that band. let n_candidates = 9usize; let f_low = 0.1f64; let f_high = 0.5f64; let mut best_freq = 0.0f64; let mut best_power = 0.0f64; for i in 0..n_candidates { let freq = f_low + (f_high - f_low) * i as f64 / (n_candidates - 1).max(1) as f64; let omega = 2.0 * std::f64::consts::PI * freq / sample_rate_hz; let coeff = 2.0 * omega.cos(); let mut s_prev2 = 0.0f64; let mut s_prev1 = 0.0f64; for &x in &detrended { let s = x + coeff * s_prev1 - s_prev2; s_prev2 = s_prev1; s_prev1 = s; } // Goertzel magnitude squared. let power = s_prev2 * s_prev2 + s_prev1 * s_prev1 - coeff * s_prev1 * s_prev2; if power > best_power { best_power = power; best_freq = freq; } } // Only report a breathing rate if the Goertzel energy is meaningfully above noise. // Threshold: power must exceed 10× the average power across all candidates. let avg_power = { let mut total = 0.0f64; for i in 0..n_candidates { let freq = f_low + (f_high - f_low) * i as f64 / (n_candidates - 1).max(1) as f64; let omega = 2.0 * std::f64::consts::PI * freq / sample_rate_hz; let coeff = 2.0 * omega.cos(); let mut s_prev2 = 0.0f64; let mut s_prev1 = 0.0f64; for &x in &detrended { let s = x + coeff * s_prev1 - s_prev2; s_prev2 = s_prev1; s_prev1 = s; } total += s_prev2 * s_prev2 + s_prev1 * s_prev1 - coeff * s_prev1 * s_prev2; } total / n_candidates as f64 }; if best_power > avg_power * 3.0 { best_freq.clamp(f_low, f_high) } else { 0.0 } } /// Compute per-subcarrier variance across the sliding window of `frame_history`. /// /// For each subcarrier index `k`, returns `Var[A_k]` over all stored frames. /// This captures spatial signal variation; subcarriers whose amplitude fluctuates /// heavily across time correspond to directions with motion. /// Compute per-subcarrier importance weights using a simple sensitivity split. /// /// Subcarriers whose sensitivity (amplitude magnitude) is above the median are /// considered "sensitive" and receive weight `1.0 + (sens / max_sens)` (range 1.0–2.0). /// The rest receive a baseline weight of 0.5. This mirrors the RuVector mincut /// partition logic without requiring the graph dependency. fn compute_subcarrier_importance_weights(sensitivity: &[f64]) -> Vec { let n = sensitivity.len(); if n == 0 { return vec![]; } let max_sens = sensitivity .iter() .cloned() .fold(f64::NEG_INFINITY, f64::max) .max(1e-9); // Compute median via a sorted copy. let mut sorted = sensitivity.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let median = if n % 2 == 0 { (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 } else { sorted[n / 2] }; sensitivity .iter() .map(|&s| { if s >= median { 1.0 + (s / max_sens).min(1.0) } else { 0.5 } }) .collect() } fn compute_subcarrier_variances(frame_history: &VecDeque>, n_sub: usize) -> Vec { if frame_history.is_empty() || n_sub == 0 { return vec![0.0; n_sub]; } let n_frames = frame_history.len() as f64; let mut means = vec![0.0f64; n_sub]; let mut sq_means = vec![0.0f64; n_sub]; for frame in frame_history.iter() { for k in 0..n_sub { let a = if k < frame.len() { frame[k] } else { 0.0 }; means[k] += a; sq_means[k] += a * a; } } (0..n_sub) .map(|k| { let mean = means[k] / n_frames; let sq_mean = sq_means[k] / n_frames; (sq_mean - mean * mean).max(0.0) }) .collect() } /// Extract features from the current ESP32 frame, enhanced with temporal context from /// `frame_history`. /// /// Improvements over the previous single-frame approach: /// /// - **Variance**: computed as the mean of per-subcarrier temporal variance across the /// sliding window, not just the intra-frame spatial variance. /// - **Motion detection**: uses frame-to-frame temporal difference (mean L2 change /// between the current frame and the previous frame) normalised by signal amplitude, /// so that actual changes are detected rather than just a threshold on the current frame. /// - **Breathing rate**: estimated via Goertzel filter bank on the 0.1–0.5 Hz band of /// the amplitude time series. /// - **Signal quality**: based on SNR estimate (RSSI – noise floor) and subcarrier /// variance stability. /// /// Returns (features, raw_classification, breathing_rate_hz, sub_variances, raw_motion_score). fn extract_features_from_frame( frame: &Esp32Frame, frame_history: &VecDeque>, sample_rate_hz: f64, ) -> (FeatureInfo, ClassificationInfo, f64, Vec, f64) { let n_sub = frame.amplitudes.len().max(1); let n = n_sub as f64; let mean_rssi = frame.rssi as f64; // ── RuVector Phase 1: subcarrier importance weighting ── // Compute per-subcarrier sensitivity from amplitude magnitude, then weight // sensitive subcarriers higher (>1.0) and insensitive ones lower (0.5). // This emphasises body-motion-correlated subcarriers in all downstream metrics. let sub_sensitivity: Vec = frame.amplitudes.iter().map(|a| a.abs()).collect(); let importance_weights = compute_subcarrier_importance_weights(&sub_sensitivity); let weight_sum: f64 = importance_weights.iter().sum::(); let mean_amp: f64 = if weight_sum > 0.0 { frame .amplitudes .iter() .zip(importance_weights.iter()) .map(|(a, w)| a * w) .sum::() / weight_sum } else { frame.amplitudes.iter().sum::() / n }; // ── Intra-frame subcarrier variance (weighted by importance) ── let intra_variance: f64 = if weight_sum > 0.0 { frame .amplitudes .iter() .zip(importance_weights.iter()) .map(|(a, w)| w * (a - mean_amp).powi(2)) .sum::() / weight_sum } else { frame .amplitudes .iter() .map(|a| (a - mean_amp).powi(2)) .sum::() / n }; // ── Temporal (sliding-window) per-subcarrier variance ── let sub_variances = compute_subcarrier_variances(frame_history, n_sub); let temporal_variance: f64 = if sub_variances.is_empty() { intra_variance } else { sub_variances.iter().sum::() / sub_variances.len() as f64 }; // Use the larger of intra-frame and temporal variance as the reported variance. let variance = intra_variance.max(temporal_variance); // ── Spectral power ── let spectral_power: f64 = frame.amplitudes.iter().map(|a| a * a).sum::() / n; // ── Motion band power (upper half of subcarriers, high spatial frequency) ── let half = frame.amplitudes.len() / 2; let motion_band_power = if half > 0 { frame.amplitudes[half..] .iter() .map(|a| (a - mean_amp).powi(2)) .sum::() / (frame.amplitudes.len() - half) as f64 } else { 0.0 }; // ── Breathing band power (lower half of subcarriers, low spatial frequency) ── let breathing_band_power = if half > 0 { frame.amplitudes[..half] .iter() .map(|a| (a - mean_amp).powi(2)) .sum::() / half as f64 } else { 0.0 }; // ── Dominant frequency via peak subcarrier index ── let peak_idx = frame .amplitudes .iter() .enumerate() .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) .map(|(i, _)| i) .unwrap_or(0); let dominant_freq_hz = peak_idx as f64 * 0.05; // ── Change point detection (threshold-crossing count in current frame) ── let threshold = mean_amp * 1.2; let change_points = frame .amplitudes .windows(2) .filter(|w| (w[0] < threshold) != (w[1] < threshold)) .count(); // ── Motion score: sliding-window temporal difference ── // Compare current frame against the most recent historical frame. // The difference is normalised by the mean amplitude to be scale-invariant. let temporal_motion_score = if let Some(prev_frame) = frame_history.back() { let n_cmp = n_sub.min(prev_frame.len()); if n_cmp > 0 { let diff_energy: f64 = (0..n_cmp) .map(|k| (frame.amplitudes[k] - prev_frame[k]).powi(2)) .sum::() / n_cmp as f64; // Normalise by mean squared amplitude to get a dimensionless ratio. let ref_energy = mean_amp * mean_amp + 1e-9; (diff_energy / ref_energy).sqrt().clamp(0.0, 1.0) } else { 0.0 } } else { // No history yet — fall back to intra-frame variance-based estimate. (intra_variance / (mean_amp * mean_amp + 1e-9)) .sqrt() .clamp(0.0, 1.0) }; // Blend temporal motion with variance-based motion for robustness. // Also factor in motion_band_power and change_points for ESP32 real-world sensitivity. let variance_motion = (temporal_variance / 10.0).clamp(0.0, 1.0); let mbp_motion = (motion_band_power / 25.0).clamp(0.0, 1.0); let cp_motion = (change_points as f64 / 15.0).clamp(0.0, 1.0); let motion_score = (temporal_motion_score * 0.4 + variance_motion * 0.2 + mbp_motion * 0.25 + cp_motion * 0.15) .clamp(0.0, 1.0); // ── Signal quality metric ── // Based on estimated SNR (RSSI relative to noise floor) and subcarrier consistency. let snr_db = (frame.rssi as f64 - frame.noise_floor as f64).max(0.0); let snr_quality = (snr_db / 40.0).clamp(0.0, 1.0); // 40 dB → quality = 1.0 // Penalise quality when temporal variance is very high (unstable signal). let stability = (1.0 - (temporal_variance / (mean_amp * mean_amp + 1e-9)).clamp(0.0, 1.0)).max(0.0); let signal_quality = (snr_quality * 0.6 + stability * 0.4).clamp(0.0, 1.0); // ── Breathing rate estimation ── let breathing_rate_hz = estimate_breathing_rate_hz(frame_history, sample_rate_hz); let features = FeatureInfo { mean_rssi, variance, motion_band_power, breathing_band_power, dominant_freq_hz, change_points, spectral_power, }; // Return raw motion_score and signal_quality — classification is done by // `smooth_and_classify()` which has access to EMA state and hysteresis. let raw_classification = ClassificationInfo { motion_level: raw_classify(motion_score), presence: motion_score > 0.04, confidence: (0.4 + signal_quality * 0.3 + motion_score * 0.3).clamp(0.0, 1.0), }; ( features, raw_classification, breathing_rate_hz, sub_variances, motion_score, ) } /// Simple threshold classification (no smoothing) — used as the "raw" input. fn raw_classify(score: f64) -> String { if score > 0.25 { "active".into() } else if score > 0.12 { "present_moving".into() } else if score > 0.04 { "present_still".into() } else { "absent".into() } } /// Debounce frames required before state transition (at ~10 FPS = ~0.4s). const DEBOUNCE_FRAMES: u32 = 4; /// EMA alpha for motion smoothing (~1s time constant at 10 FPS). const MOTION_EMA_ALPHA: f64 = 0.15; /// EMA alpha for slow-adapting baseline (~30s time constant at 10 FPS). const BASELINE_EMA_ALPHA: f64 = 0.003; /// Number of warm-up frames before baseline subtraction kicks in. const BASELINE_WARMUP: u64 = 50; /// Apply EMA smoothing, adaptive baseline subtraction, and hysteresis debounce /// to the raw classification. Mutates the smoothing state in `AppStateInner`. fn smooth_and_classify(state: &mut AppStateInner, raw: &mut ClassificationInfo, raw_motion: f64) { // 1. Adaptive baseline: slowly track the "quiet room" floor. // Only update baseline when raw score is below the current smoothed level // (i.e. during calm periods) so walking doesn't inflate the baseline. state.baseline_frames += 1; if state.baseline_frames < BASELINE_WARMUP { // During warm-up, aggressively learn the baseline. state.baseline_motion = state.baseline_motion * 0.9 + raw_motion * 0.1; } else if raw_motion < state.smoothed_motion + 0.05 { state.baseline_motion = state.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) + raw_motion * BASELINE_EMA_ALPHA; } // 2. Subtract baseline and clamp. let adjusted = (raw_motion - state.baseline_motion * 0.7).max(0.0); // 3. EMA smooth the adjusted score. state.smoothed_motion = state.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) + adjusted * MOTION_EMA_ALPHA; let sm = state.smoothed_motion; // 4. Classify from smoothed score. let candidate = raw_classify(sm); // 5. Hysteresis debounce: require N consecutive frames agreeing on a new state. if candidate == state.current_motion_level { // Already in this state — reset debounce. state.debounce_counter = 0; state.debounce_candidate = candidate; } else if candidate == state.debounce_candidate { state.debounce_counter += 1; if state.debounce_counter >= DEBOUNCE_FRAMES { // Transition accepted. state.current_motion_level = candidate; state.debounce_counter = 0; } } else { // New candidate — restart counter. state.debounce_candidate = candidate; state.debounce_counter = 1; } // 6. Write the smoothed result back into the classification. raw.motion_level = state.current_motion_level.clone(); raw.presence = sm > 0.03; raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0); } /// Per-node variant of `smooth_and_classify` that operates on a `NodeState` /// instead of `AppStateInner` (issue #249). fn smooth_and_classify_node(ns: &mut NodeState, raw: &mut ClassificationInfo, raw_motion: f64) { ns.baseline_frames += 1; if ns.baseline_frames < BASELINE_WARMUP { ns.baseline_motion = ns.baseline_motion * 0.9 + raw_motion * 0.1; } else if raw_motion < ns.smoothed_motion + 0.05 { ns.baseline_motion = ns.baseline_motion * (1.0 - BASELINE_EMA_ALPHA) + raw_motion * BASELINE_EMA_ALPHA; } let adjusted = (raw_motion - ns.baseline_motion * 0.7).max(0.0); ns.smoothed_motion = ns.smoothed_motion * (1.0 - MOTION_EMA_ALPHA) + adjusted * MOTION_EMA_ALPHA; let sm = ns.smoothed_motion; let candidate = raw_classify(sm); if candidate == ns.current_motion_level { ns.debounce_counter = 0; ns.debounce_candidate = candidate; } else if candidate == ns.debounce_candidate { ns.debounce_counter += 1; if ns.debounce_counter >= DEBOUNCE_FRAMES { ns.current_motion_level = candidate; ns.debounce_counter = 0; } } else { ns.debounce_candidate = candidate; ns.debounce_counter = 1; } raw.motion_level = ns.current_motion_level.clone(); raw.presence = sm > 0.03; raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0); } /// If an adaptive model is loaded, override the classification with the /// model's prediction. Uses the full 15-feature vector for higher accuracy. fn adaptive_override( state: &AppStateInner, features: &FeatureInfo, classification: &mut ClassificationInfo, ) { if let Some(ref model) = state.adaptive_model { // Get current frame amplitudes from the latest history entry. let amps = state .frame_history .back() .map(|v| v.as_slice()) .unwrap_or(&[]); let feat_arr = adaptive_classifier::features_from_runtime( &serde_json::json!({ "variance": features.variance, "motion_band_power": features.motion_band_power, "breathing_band_power": features.breathing_band_power, "spectral_power": features.spectral_power, "dominant_freq_hz": features.dominant_freq_hz, "change_points": features.change_points, "mean_rssi": features.mean_rssi, }), amps, ); let (label, conf) = model.classify(&feat_arr); classification.motion_level = label.to_string(); classification.presence = label != "absent"; // Blend model confidence with existing smoothed confidence. classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0); } } /// Size of the median filter window for vital signs outlier rejection. const VITAL_MEDIAN_WINDOW: usize = 21; /// EMA alpha for vital signs (~5s time constant at 10 FPS). const VITAL_EMA_ALPHA: f64 = 0.02; /// Maximum BPM jump per frame before a value is rejected as an outlier. const HR_MAX_JUMP: f64 = 8.0; const BR_MAX_JUMP: f64 = 2.0; /// Minimum change from current smoothed value before EMA updates (dead-band). /// Prevents micro-drift from creeping in. const HR_DEAD_BAND: f64 = 2.0; const BR_DEAD_BAND: f64 = 0.5; /// Smooth vital signs using median-filter outlier rejection + EMA. /// Mutates `state.smoothed_hr`, `state.smoothed_br`, etc. /// Returns the smoothed VitalSigns to broadcast. fn smooth_vitals(state: &mut AppStateInner, raw: &VitalSigns) -> VitalSigns { let raw_hr = raw.heart_rate_bpm.unwrap_or(0.0); let raw_br = raw.breathing_rate_bpm.unwrap_or(0.0); // -- Outlier rejection: skip values that jump too far from current EMA -- let hr_ok = state.smoothed_hr < 1.0 || (raw_hr - state.smoothed_hr).abs() < HR_MAX_JUMP; let br_ok = state.smoothed_br < 1.0 || (raw_br - state.smoothed_br).abs() < BR_MAX_JUMP; // Push into buffer (only non-outlier values) if hr_ok && raw_hr > 0.0 { state.hr_buffer.push_back(raw_hr); if state.hr_buffer.len() > VITAL_MEDIAN_WINDOW { state.hr_buffer.pop_front(); } } if br_ok && raw_br > 0.0 { state.br_buffer.push_back(raw_br); if state.br_buffer.len() > VITAL_MEDIAN_WINDOW { state.br_buffer.pop_front(); } } // Compute trimmed mean: drop top/bottom 25% then average the middle 50%. // This is more stable than pure median and less noisy than raw mean. let trimmed_hr = trimmed_mean(&state.hr_buffer); let trimmed_br = trimmed_mean(&state.br_buffer); // EMA smooth with dead-band: only update if the trimmed mean differs // from the current smoothed value by more than the dead-band. // This prevents the display from constantly creeping by tiny amounts. if trimmed_hr > 0.0 { if state.smoothed_hr < 1.0 { state.smoothed_hr = trimmed_hr; } else if (trimmed_hr - state.smoothed_hr).abs() > HR_DEAD_BAND { state.smoothed_hr = state.smoothed_hr * (1.0 - VITAL_EMA_ALPHA) + trimmed_hr * VITAL_EMA_ALPHA; } // else: within dead-band, hold current value } if trimmed_br > 0.0 { if state.smoothed_br < 1.0 { state.smoothed_br = trimmed_br; } else if (trimmed_br - state.smoothed_br).abs() > BR_DEAD_BAND { state.smoothed_br = state.smoothed_br * (1.0 - VITAL_EMA_ALPHA) + trimmed_br * VITAL_EMA_ALPHA; } } // Smooth confidence state.smoothed_hr_conf = state.smoothed_hr_conf * 0.92 + raw.heartbeat_confidence * 0.08; state.smoothed_br_conf = state.smoothed_br_conf * 0.92 + raw.breathing_confidence * 0.08; VitalSigns { breathing_rate_bpm: if state.smoothed_br > 1.0 { Some(state.smoothed_br) } else { None }, heart_rate_bpm: if state.smoothed_hr > 1.0 { Some(state.smoothed_hr) } else { None }, breathing_confidence: state.smoothed_br_conf, heartbeat_confidence: state.smoothed_hr_conf, signal_quality: raw.signal_quality, } } /// Per-node variant of `smooth_vitals` that operates on a `NodeState` (issue #249). fn smooth_vitals_node(ns: &mut NodeState, raw: &VitalSigns) -> VitalSigns { let raw_hr = raw.heart_rate_bpm.unwrap_or(0.0); let raw_br = raw.breathing_rate_bpm.unwrap_or(0.0); let hr_ok = ns.smoothed_hr < 1.0 || (raw_hr - ns.smoothed_hr).abs() < HR_MAX_JUMP; let br_ok = ns.smoothed_br < 1.0 || (raw_br - ns.smoothed_br).abs() < BR_MAX_JUMP; if hr_ok && raw_hr > 0.0 { ns.hr_buffer.push_back(raw_hr); if ns.hr_buffer.len() > VITAL_MEDIAN_WINDOW { ns.hr_buffer.pop_front(); } } if br_ok && raw_br > 0.0 { ns.br_buffer.push_back(raw_br); if ns.br_buffer.len() > VITAL_MEDIAN_WINDOW { ns.br_buffer.pop_front(); } } let trimmed_hr = trimmed_mean(&ns.hr_buffer); let trimmed_br = trimmed_mean(&ns.br_buffer); if trimmed_hr > 0.0 { if ns.smoothed_hr < 1.0 { ns.smoothed_hr = trimmed_hr; } else if (trimmed_hr - ns.smoothed_hr).abs() > HR_DEAD_BAND { ns.smoothed_hr = ns.smoothed_hr * (1.0 - VITAL_EMA_ALPHA) + trimmed_hr * VITAL_EMA_ALPHA; } } if trimmed_br > 0.0 { if ns.smoothed_br < 1.0 { ns.smoothed_br = trimmed_br; } else if (trimmed_br - ns.smoothed_br).abs() > BR_DEAD_BAND { ns.smoothed_br = ns.smoothed_br * (1.0 - VITAL_EMA_ALPHA) + trimmed_br * VITAL_EMA_ALPHA; } } ns.smoothed_hr_conf = ns.smoothed_hr_conf * 0.92 + raw.heartbeat_confidence * 0.08; ns.smoothed_br_conf = ns.smoothed_br_conf * 0.92 + raw.breathing_confidence * 0.08; VitalSigns { breathing_rate_bpm: if ns.smoothed_br > 1.0 { Some(ns.smoothed_br) } else { None }, heart_rate_bpm: if ns.smoothed_hr > 1.0 { Some(ns.smoothed_hr) } else { None }, breathing_confidence: ns.smoothed_br_conf, heartbeat_confidence: ns.smoothed_hr_conf, signal_quality: raw.signal_quality, } } /// Trimmed mean: sort, drop top/bottom 25%, average the middle 50%. /// More robust than median (uses more data) and less noisy than raw mean. fn trimmed_mean(buf: &VecDeque) -> f64 { if buf.is_empty() { return 0.0; } let mut sorted: Vec = buf.iter().copied().collect(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let n = sorted.len(); let trim = n / 4; // drop 25% from each end let middle = &sorted[trim..n - trim.max(0)]; if middle.is_empty() { sorted[n / 2] // fallback to median if too few samples } else { middle.iter().sum::() / middle.len() as f64 } } // ── Windows WiFi RSSI collector ────────────────────────────────────────────── /// Parse `netsh wlan show interfaces` output for RSSI and signal quality fn parse_netsh_interfaces_output(output: &str) -> Option<(f64, f64, String)> { let mut rssi = None; let mut signal = None; let mut ssid = None; for line in output.lines() { let line = line.trim(); if line.starts_with("Signal") { // "Signal : 89%" if let Some(pct) = line.split(':').nth(1) { let pct = pct.trim().trim_end_matches('%'); if let Ok(v) = pct.parse::() { signal = Some(v); // Convert signal% to approximate dBm: -100 + (signal% * 0.6) rssi = Some(-100.0 + v * 0.6); } } } if line.starts_with("SSID") && !line.starts_with("BSSID") { if let Some(s) = line.split(':').nth(1) { ssid = Some(s.trim().to_string()); } } } match (rssi, signal, ssid) { (Some(r), Some(_s), Some(name)) => Some((r, _s, name)), (Some(r), Some(_s), None) => Some((r, _s, "Unknown".into())), _ => None, } } async fn windows_wifi_task(state: SharedState, tick_ms: u64) { let mut interval = tokio::time::interval(Duration::from_millis(tick_ms)); let mut seq: u32 = 0; // ADR-022 Phase 3: Multi-BSSID pipeline state (kept across ticks) let mut registry = BssidRegistry::new(32, 30); let mut pipeline = WindowsWifiPipeline::new(); info!( "Windows WiFi multi-BSSID pipeline active (tick={}ms, max_bssids=32)", tick_ms ); loop { interval.tick().await; seq += 1; // ── Step 1: Run multi-BSSID scan via spawn_blocking ────────── // NetshBssidScanner is not Send, so we run `netsh` and parse // the output inside a blocking closure. let bssid_scan_result = tokio::task::spawn_blocking(|| { let output = std::process::Command::new("netsh") .args(["wlan", "show", "networks", "mode=bssid"]) .output() .map_err(|e| format!("netsh bssid scan failed: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( "netsh exited with {}: {}", output.status, stderr.trim() )); } let stdout = String::from_utf8_lossy(&output.stdout); parse_netsh_bssid_output(&stdout).map_err(|e| format!("parse error: {e}")) }) .await; // Unwrap the JoinHandle result, then the inner Result. let observations = match bssid_scan_result { Ok(Ok(obs)) if !obs.is_empty() => obs, Ok(Ok(_empty)) => { debug!("Multi-BSSID scan returned 0 observations, falling back"); windows_wifi_fallback_tick(&state, seq).await; continue; } Ok(Err(e)) => { warn!("Multi-BSSID scan error: {e}, falling back"); windows_wifi_fallback_tick(&state, seq).await; continue; } Err(join_err) => { error!("spawn_blocking panicked: {join_err}"); continue; } }; let obs_count = observations.len(); // Derive SSID from the first observation for the source label. let ssid = observations .first() .map(|o| o.ssid.clone()) .unwrap_or_else(|| "Unknown".into()); // ── Step 2: Feed observations into registry ────────────────── registry.update(&observations); let multi_ap_frame = registry.to_multi_ap_frame(); // ── Step 3: Run enhanced pipeline ──────────────────────────── let enhanced = pipeline.process(&multi_ap_frame); // ── Step 4: Build backward-compatible Esp32Frame ───────────── let first_rssi = observations.first().map(|o| o.rssi_dbm).unwrap_or(-80.0); let _first_signal_pct = observations.first().map(|o| o.signal_pct).unwrap_or(40.0); let frame = Esp32Frame { magic: 0xC511_0001, node_id: 0, n_antennas: 1, n_subcarriers: obs_count.min(255) as u8, freq_mhz: 2437, sequence: seq, rssi: first_rssi.clamp(-128.0, 127.0) as i8, noise_floor: -90, amplitudes: multi_ap_frame.amplitudes.clone(), phases: multi_ap_frame.phases.clone(), }; // ── Step 4b: Update frame history and extract features ─────── let mut s_write_pre = state.write().await; s_write_pre .frame_history .push_back(frame.amplitudes.clone()); if s_write_pre.frame_history.len() > FRAME_HISTORY_CAPACITY { s_write_pre.frame_history.pop_front(); } let sample_rate_hz = 1000.0 / tick_ms as f64; let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &s_write_pre.frame_history, sample_rate_hz); smooth_and_classify(&mut s_write_pre, &mut classification, raw_motion); adaptive_override(&s_write_pre, &features, &mut classification); drop(s_write_pre); // ── Step 5: Build enhanced fields from pipeline result ─────── let enhanced_motion = Some(serde_json::json!({ "score": enhanced.motion.score, "level": format!("{:?}", enhanced.motion.level), "contributing_bssids": enhanced.motion.contributing_bssids, })); let enhanced_breathing = enhanced.breathing.as_ref().map(|b| { serde_json::json!({ "rate_bpm": b.rate_bpm, "confidence": b.confidence, "bssid_count": b.bssid_count, }) }); let posture_str = enhanced.posture.map(|p| format!("{p:?}")); let sig_quality_score = Some(enhanced.signal_quality.score); let verdict_str = Some(format!("{:?}", enhanced.verdict)); let bssid_n = Some(enhanced.bssid_count); // ── Step 6: Update shared state ────────────────────────────── let mut s = state.write().await; s.source = format!("wifi:{ssid}"); s.rssi_history.push_back(first_rssi); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } s.tick += 1; let tick = s.tick; let motion_score = if classification.motion_level == "active" { 0.8 } else if classification.motion_level == "present_still" { 0.3 } else { 0.05 }; let raw_vitals = s .vital_detector .process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); let feat_variance = features.variance; // ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring. s.p95_variance.push(features.variance); s.p95_motion_band_power.push(features.motion_band_power); s.p95_spectral_power.push(features.spectral_power); // Multi-person estimation with temporal smoothing (EMA α=0.10). let raw_score = compute_person_score(&s, &features); s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10; let est_persons = if classification.presence { let count = s.person_count(); s.prev_person_count = count; count } else { s.prev_person_count = 0; 0 }; let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: format!("wifi:{ssid}"), tick, nodes: vec![NodeInfo { node_id: 0, rssi_dbm: first_rssi, position: [0.0, 0.0, 0.0], amplitude: multi_ap_frame.amplitudes, subcarrier_count: obs_count, sync: None, // multi-BSSID scan path — no mesh peer }], features, classification, signal_field: generate_signal_field( first_rssi, motion_score, breathing_rate_hz, feat_variance.min(1.0), &sub_variances, ), vital_signs: Some(vitals), enhanced_motion, enhanced_breathing, posture: posture_str, signal_quality_score: sig_quality_score, quality_verdict: verdict_str, bssid_count: bssid_n, pose_keypoints: None, model_status: None, persons: None, estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, node_features: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); debug!( "Multi-BSSID tick #{tick}: {obs_count} BSSIDs, quality={:.2}, verdict={:?}", enhanced.signal_quality.score, enhanced.verdict ); } } /// Fallback: single-RSSI collection via `netsh wlan show interfaces`. /// /// Used when the multi-BSSID scan fails or returns 0 observations. async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { let output = match tokio::process::Command::new("netsh") .args(["wlan", "show", "interfaces"]) .output() .await { Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(), Err(e) => { warn!("netsh interfaces fallback failed: {e}"); return; } }; let (rssi_dbm, signal_pct, ssid) = match parse_netsh_interfaces_output(&output) { Some(v) => v, None => { debug!("Fallback: no WiFi interface connected"); return; } }; let frame = Esp32Frame { magic: 0xC511_0001, node_id: 0, n_antennas: 1, n_subcarriers: 1, freq_mhz: 2437, sequence: seq, rssi: rssi_dbm as i8, noise_floor: -90, amplitudes: vec![signal_pct], phases: vec![0.0], }; let mut s = state.write().await; // Update frame history before extracting features. s.frame_history.push_back(frame.amplitudes.clone()); if s.frame_history.len() > FRAME_HISTORY_CAPACITY { s.frame_history.pop_front(); } let sample_rate_hz = 2.0_f64; // fallback tick ~ 500 ms => 2 Hz let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz); smooth_and_classify(&mut s, &mut classification, raw_motion); adaptive_override(&s, &features, &mut classification); s.source = format!("wifi:{ssid}"); s.rssi_history.push_back(rssi_dbm); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } s.tick += 1; let tick = s.tick; let motion_score = if classification.motion_level == "active" { 0.8 } else if classification.motion_level == "present_still" { 0.3 } else { 0.05 }; let raw_vitals = s .vital_detector .process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); let feat_variance = features.variance; // ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring. s.p95_variance.push(features.variance); s.p95_motion_band_power.push(features.motion_band_power); s.p95_spectral_power.push(features.spectral_power); // Multi-person estimation with temporal smoothing (EMA α=0.10). let raw_score = compute_person_score(&s, &features); s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10; let est_persons = if classification.presence { let count = s.person_count(); s.prev_person_count = count; count } else { s.prev_person_count = 0; 0 }; let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: format!("wifi:{ssid}"), tick, nodes: vec![NodeInfo { node_id: 0, rssi_dbm, position: [0.0, 0.0, 0.0], amplitude: vec![signal_pct], subcarrier_count: 1, sync: None, // synthetic-RSSI fallback path — no mesh peer }], features, classification, signal_field: generate_signal_field( rssi_dbm, motion_score, breathing_rate_hz, feat_variance.min(1.0), &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, enhanced_breathing: None, posture: None, signal_quality_score: None, quality_verdict: None, bssid_count: None, pose_keypoints: None, model_status: None, persons: None, estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, node_features: None, }; let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update(&mut s.pose_tracker, &mut last_tracker_instant, raw_persons); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); } /// Probe if Windows WiFi is connected async fn probe_windows_wifi() -> bool { match tokio::process::Command::new("netsh") .args(["wlan", "show", "interfaces"]) .output() .await { Ok(o) => { let out = String::from_utf8_lossy(&o.stdout); parse_netsh_interfaces_output(&out).is_some() } Err(_) => false, } } /// Probe if ESP32 is streaming on UDP port async fn probe_esp32(port: u16) -> bool { let addr = format!("0.0.0.0:{port}"); match UdpSocket::bind(&addr).await { Ok(sock) => { let mut buf = [0u8; 256]; match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await { Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(), _ => false, } } Err(_) => false, } } // ── Simulated data generator ───────────────────────────────────────────────── fn generate_simulated_frame(tick: u64) -> Esp32Frame { let t = tick as f64 * 0.1; let n_sub = 56usize; let mut amplitudes = Vec::with_capacity(n_sub); let mut phases = Vec::with_capacity(n_sub); for i in 0..n_sub { let base = 15.0 + 5.0 * (i as f64 * 0.1 + t * 0.3).sin(); let noise = (i as f64 * 7.3 + t * 13.7).sin() * 2.0; amplitudes.push((base + noise).max(0.1)); phases.push((i as f64 * 0.2 + t * 0.5).sin() * std::f64::consts::PI); } Esp32Frame { magic: 0xC511_0001, node_id: 1, n_antennas: 1, n_subcarriers: n_sub as u8, freq_mhz: 2437, sequence: tick as u32, rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8, noise_floor: -90, amplitudes, phases, } } // ── WebSocket handler ──────────────────────────────────────────────────────── async fn ws_sensing_handler( ws: WebSocketUpgrade, State(state): State, ) -> impl IntoResponse { ws.on_upgrade(|socket| handle_ws_client(socket, state)) } async fn handle_ws_client(mut socket: WebSocket, state: SharedState) { let mut rx = { let s = state.read().await; s.tx.subscribe() }; info!("WebSocket client connected (sensing)"); // ADR-044/045: ping/pong keepalive to prevent proxy idle timeouts. let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(30)); ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { tokio::select! { msg = rx.recv() => { match msg { Ok(json) => { if socket.send(Message::Text(json)).await.is_err() { break; } } // Lagged: client fell behind — skip missed frames, don't disconnect. Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { tracing::debug!("WS client lagged by {n} frames, skipping"); continue; } Err(_) => break, // channel closed } } _ = ping_interval.tick() => { if socket.send(Message::Ping(vec![])).await.is_err() { break; } } msg = socket.recv() => { match msg { Some(Ok(Message::Close(_))) | None => break, Some(Ok(Message::Pong(_))) => {} // keepalive response _ => {} // ignore other client messages } } } } info!("WebSocket client disconnected (sensing)"); } // ── ADR-099: real-time CSI introspection — WS topic + REST snapshot ────────── // // Parallel to the window-aggregated `/ws/sensing` topic. Subscribers see a // fresh `IntrospectionSnapshot` JSON frame on every accepted CSI frame // (regime / Lyapunov exponent / top-k DTW similarity), no window-close delay. async fn ws_introspection_handler( ws: WebSocketUpgrade, State(state): State, ) -> impl IntoResponse { ws.on_upgrade(|socket| handle_ws_introspection_client(socket, state)) } async fn handle_ws_introspection_client(mut socket: WebSocket, state: SharedState) { let mut rx = { let s = state.read().await; s.intro_tx.subscribe() }; info!("WebSocket client connected (introspection)"); loop { tokio::select! { msg = rx.recv() => { match msg { Ok(json) => { if socket.send(Message::Text(json)).await.is_err() { break; } } Err(_) => break, } } msg = socket.recv() => { match msg { Some(Ok(Message::Close(_))) | None => break, _ => {} // ignore client messages } } } } info!("WebSocket client disconnected (introspection)"); } /// `GET /api/v1/introspection/snapshot` — one-shot poll for the latest /// per-frame snapshot (regime, Lyapunov, top-k similarity). Mirrors the shape /// of `/api/v1/sensing/latest` for the dashboard one-shot path. async fn api_introspection_snapshot(State(state): State) -> impl IntoResponse { let s = state.read().await; Json(s.intro.snapshot().clone()) } // ── Pose WebSocket handler (sends pose_data messages for Live Demo) ────────── async fn ws_pose_handler( ws: WebSocketUpgrade, State(state): State, ) -> impl IntoResponse { ws.on_upgrade(|socket| handle_ws_pose_client(socket, state)) } async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) { let mut rx = { let s = state.read().await; s.tx.subscribe() }; info!("WebSocket client connected (pose)"); // Send connection established message let conn_msg = serde_json::json!({ "type": "connection_established", "payload": { "status": "connected", "backend": "rust+ruvector" } }); let _ = socket.send(Message::Text(conn_msg.to_string())).await; loop { tokio::select! { msg = rx.recv() => { match msg { Ok(json) => { // Parse the sensing update and convert to pose format if let Ok(sensing) = serde_json::from_str::(&json) { if sensing.msg_type == "sensing_update" { // Determine pose estimation mode for the UI indicator. // "model_inference" — a trained RVF model is loaded. // "signal_derived" — keypoints estimated from raw CSI features. let model_loaded = { let s = state.read().await; s.model_loaded }; let pose_source = if model_loaded { "model_inference" } else { "signal_derived" }; let persons = if model_loaded { // When a trained model is loaded, prefer its keypoints if present. sensing.pose_keypoints.as_ref().map(|kps| { let kp_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", ]; let keypoints: Vec = kps.iter() .enumerate() .map(|(i, kp)| PoseKeypoint { name: kp_names.get(i).unwrap_or(&"unknown").to_string(), x: kp[0], y: kp[1], z: kp[2], confidence: kp[3], }) .collect(); vec![PersonDetection { id: 1, confidence: sensing.classification.confidence, bbox: BoundingBox { x: 260.0, y: 150.0, width: 120.0, height: 220.0 }, keypoints, zone: "zone_1".into(), }] }).unwrap_or_else(|| { // Prefer tracked persons from broadcast if available sensing.persons.clone().unwrap_or_else(|| derive_pose_from_sensing(&sensing)) }) } else { // Prefer tracked persons from broadcast if available sensing.persons.clone().unwrap_or_else(|| derive_pose_from_sensing(&sensing)) }; let pose_msg = serde_json::json!({ "type": "pose_data", "zone_id": "zone_1", "timestamp": sensing.timestamp, "payload": { "pose": { "persons": persons, }, "confidence": if sensing.classification.presence { sensing.classification.confidence } else { 0.0 }, "activity": sensing.classification.motion_level, // pose_source tells the UI which estimation mode is active. "pose_source": pose_source, "metadata": { "frame_id": format!("rust_frame_{}", sensing.tick), "processing_time_ms": 1, "source": sensing.source, "tick": sensing.tick, "signal_strength": sensing.features.mean_rssi, "motion_band_power": sensing.features.motion_band_power, "breathing_band_power": sensing.features.breathing_band_power, "estimated_persons": persons.len(), } } }); if socket.send(Message::Text(pose_msg.to_string())).await.is_err() { break; } } } } // Lagged: skip missed frames, don't disconnect. Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { tracing::debug!("WS pose client lagged by {n} frames, skipping"); continue; } Err(_) => break, // channel closed } } msg = socket.recv() => { match msg { Some(Ok(Message::Text(text))) => { // Handle ping/pong if let Ok(v) = serde_json::from_str::(&text) { if v.get("type").and_then(|t| t.as_str()) == Some("ping") { let pong = serde_json::json!({"type": "pong"}); let _ = socket.send(Message::Text(pong.to_string())).await; } } } Some(Ok(Message::Close(_))) | None => break, Some(Ok(Message::Pong(_))) => {} // keepalive response _ => {} } } } } info!("WebSocket client disconnected (pose)"); } // ── REST endpoints ─────────────────────────────────────────────────────────── async fn health(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": "ok", "source": s.effective_source(), "tick": s.tick, "clients": s.tx.receiver_count(), })) } async fn latest(State(state): State) -> Json { let s = state.read().await; match &s.latest_update { Some(update) => Json(serde_json::to_value(update).unwrap_or_default()), None => Json(serde_json::json!({"status": "no data yet"})), } } /// Generate WiFi-derived pose keypoints from sensing data. /// /// Keypoint positions are modulated by real signal features rather than a pure /// time-based sine/cosine loop: /// /// - `motion_band_power` drives whole-body translation and limb splay /// - `variance` seeds per-frame noise so the skeleton never freezes /// - `breathing_band_power` expands/contracts torso keypoints (shoulders, hips) /// - `dominant_freq_hz` tilts the upper body laterally (lean direction) /// - `change_points` adds burst jitter to extremities (wrists, ankles) /// /// When `presence == false` no persons are returned (empty room). /// When walking is detected (`motion_score > 0.55`) the figure shifts laterally /// with a stride-swing pattern applied to arms and legs. // ── Multi-person estimation (issue #97) ────────────────────────────────────── /// Fuse features across all active nodes for higher SNR. /// /// When multiple ESP32 nodes observe the same room, their CSI features /// can be combined: /// - Variance: use max (most sensitive node dominates) /// - Motion/breathing/spectral power: weighted average by RSSI (closer node = higher weight) /// - Dominant frequency: weighted average /// - Change points: keep current node's value (not meaningful to average) /// - Mean RSSI: use max (best signal) fn fuse_multi_node_features( current_features: &FeatureInfo, node_states: &HashMap, ) -> FeatureInfo { let now = std::time::Instant::now(); let active: Vec<(&FeatureInfo, f64)> = node_states .values() .filter(|ns| { ns.last_frame_time .is_some_and(|t| now.duration_since(t).as_secs() < 10) }) .filter_map(|ns| { let feat = ns.latest_features.as_ref()?; let rssi = ns.rssi_history.back().copied().unwrap_or(-80.0); Some((feat, rssi)) }) .collect(); if active.len() <= 1 { return current_features.clone(); } // RSSI-based weights: higher RSSI = closer to person = more weight. // Map RSSI relative to best node into [0.1, 1.0]. let max_rssi = active .iter() .map(|(_, r)| *r) .fold(f64::NEG_INFINITY, f64::max); let weights: Vec = active .iter() .map(|(_, r)| (1.0 + (r - max_rssi + 20.0) / 20.0).clamp(0.1, 1.0)) .collect(); let w_sum: f64 = weights.iter().sum::().max(1e-9); FeatureInfo { // Weighted average variance (not max — max inflates person score // and causes count flips between 1↔2 persons). variance: active .iter() .zip(&weights) .map(|((f, _), w)| f.variance * w) .sum::() / w_sum, // Weighted average for motion/breathing/spectral motion_band_power: active .iter() .zip(&weights) .map(|((f, _), w)| f.motion_band_power * w) .sum::() / w_sum, breathing_band_power: active .iter() .zip(&weights) .map(|((f, _), w)| f.breathing_band_power * w) .sum::() / w_sum, spectral_power: active .iter() .zip(&weights) .map(|((f, _), w)| f.spectral_power * w) .sum::() / w_sum, dominant_freq_hz: active .iter() .zip(&weights) .map(|((f, _), w)| f.dominant_freq_hz * w) .sum::() / w_sum, change_points: current_features.change_points, // keep current node's value // Best RSSI across nodes mean_rssi: active .iter() .map(|(f, _)| f.mean_rssi) .fold(f64::NEG_INFINITY, f64::max), } } /// Estimate person count from CSI features using a weighted composite heuristic. /// /// Single ESP32 link limitations: variance-based detection can reliably detect /// 1-2 persons. 3+ is speculative and requires ≥3 nodes for spatial resolution. /// /// Returns a raw score (0.0..1.0) that the caller converts to person count /// after temporal smoothing. fn compute_person_score(state: &AppStateInner, feat: &FeatureInfo) -> f64 { // ADR-044 §5.2: adaptive rolling-P95 normalization. // Legacy fixed denominators (variance/300, motion/250, spectral/500) saturate // when live ESP32 values exceed those limits — zero dynamic range results. // Use the P95 of the last ~30 s of history instead, falling back to the legacy // denominators during cold-start (<60 samples) to preserve day-0 behaviour. let var_denom = state .p95_variance .current() .map(|p| p.max(50.0)) .unwrap_or(300.0); let motion_denom = state .p95_motion_band_power .current() .map(|p| p.max(50.0)) .unwrap_or(250.0); let sp_denom = state .p95_spectral_power .current() .map(|p| p.max(100.0)) .unwrap_or(500.0); let var_norm = (feat.variance / var_denom).clamp(0.0, 1.0); let cp_norm = (feat.change_points as f64 / 30.0).clamp(0.0, 1.0); let motion_norm = (feat.motion_band_power / motion_denom).clamp(0.0, 1.0); let sp_norm = (feat.spectral_power / sp_denom).clamp(0.0, 1.0); var_norm * 0.40 + cp_norm * 0.20 + motion_norm * 0.25 + sp_norm * 0.15 } /// Estimate person count via ruvector DynamicMinCut on the subcarrier /// temporal correlation graph. /// /// Builds a graph where: /// - Nodes = active subcarriers (variance > noise floor) /// - Edges = Pearson correlation between subcarrier time series /// (weight = correlation coefficient; high correlation = heavy edge) /// - Source = virtual node connected to the most active subcarrier /// - Sink = virtual node connected to the least correlated subcarrier /// /// The min-cut value indicates how many independent motion clusters exist: /// - High min-cut (relative to total edge weight) → one tightly coupled /// group → 1 person /// - Low min-cut → two loosely coupled groups → 2 persons /// /// Uses `ruvector_mincut::DynamicMinCut` for O(V²E) exact max-flow. fn estimate_persons_from_correlation(frame_history: &VecDeque>) -> usize { let n_frames = frame_history.len(); if n_frames < 10 { return 1; } let window: Vec<&Vec> = frame_history.iter().rev().take(20).collect(); let n_sub = window[0].len().min(56); if n_sub < 4 { return 1; } let k = window.len() as f64; // Per-subcarrier mean and variance let mut means = vec![0.0f64; n_sub]; let mut variances = vec![0.0f64; n_sub]; for frame in &window { for sc in 0..n_sub.min(frame.len()) { means[sc] += frame[sc] / k; } } for frame in &window { for sc in 0..n_sub.min(frame.len()) { variances[sc] += (frame[sc] - means[sc]).powi(2) / k; } } // Active subcarriers: variance above noise floor let noise_floor = 1.0; let active: Vec = (0..n_sub) .filter(|&sc| variances[sc] > noise_floor) .collect(); let m = active.len(); if m < 3 { return if m == 0 { 0 } else { 1 }; } // Build correlation graph edges between active subcarriers. // Edge weight = |Pearson correlation|. High correlation → same person. let mut edges: Vec<(u64, u64, f64)> = Vec::new(); let source = m as u64; let sink = (m + 1) as u64; // Precompute std devs let stds: Vec = active .iter() .map(|&sc| variances[sc].sqrt().max(1e-9)) .collect(); for i in 0..m { for j in (i + 1)..m { // Pearson correlation between subcarriers i and j let mut cov = 0.0f64; for frame in &window { let si = active[i]; let sj = active[j]; if si < frame.len() && sj < frame.len() { cov += (frame[si] - means[si]) * (frame[sj] - means[sj]) / k; } } let corr = (cov / (stds[i] * stds[j])).abs(); if corr > 0.1 { // Bidirectional edges for flow network let weight = corr * 10.0; // Scale up for integer-like flow edges.push((i as u64, j as u64, weight)); edges.push((j as u64, i as u64, weight)); } } } // Source → highest-variance subcarrier, Sink → lowest-variance. // partial_cmp returns None on NaN; the outer unwrap_or only catches an // empty iterator, not a comparator panic. Same NaN-panic class as #611 // — a single NaN variance frame would kill the sensing-server process. let (max_var_idx, _) = active .iter() .enumerate() .max_by(|(_, &a), (_, &b)| { variances[a] .partial_cmp(&variances[b]) .unwrap_or(std::cmp::Ordering::Equal) }) .unwrap_or((0, &0)); let (min_var_idx, _) = active .iter() .enumerate() .min_by(|(_, &a), (_, &b)| { variances[a] .partial_cmp(&variances[b]) .unwrap_or(std::cmp::Ordering::Equal) }) .unwrap_or((0, &0)); if max_var_idx == min_var_idx { return 1; } edges.push((source, max_var_idx as u64, 100.0)); edges.push((min_var_idx as u64, sink, 100.0)); // Run min-cut let mc: DynamicMinCut = match MinCutBuilder::new() .exact() .with_edges(edges.clone()) .build() { Ok(mc) => mc, Err(_) => return 1, }; let cut_value = mc.min_cut_value(); let total_edge_weight: f64 = edges .iter() .filter(|(s, t, _)| *s != source && *s != sink && *t != source && *t != sink) .map(|(_, _, w)| w) .sum::() / 2.0; // bidirectional → halve if total_edge_weight < 1e-9 { return 1; } // Normalized cut ratio: low = easy to split = multiple people let cut_ratio = cut_value / total_edge_weight; if cut_ratio > 0.4 { 1 // Tightly coupled — one person } else if cut_ratio > 0.15 { 2 // Moderately separable — two people } else { 3 // Highly separable — three+ people } } /// Map a DynamicMinCut occupancy estimate (`estimate_persons_from_correlation`, /// 0–3) onto a target score whose steady state round-trips back through /// `score_to_person_count` to the *same* count (issue #803). /// /// The CSI path EMA-smooths this target and re-discretises it via /// `score_to_person_count`. The previous `corr_persons / 3.0` mapping put a /// 2-person estimate at 0.667 — just under the 0.70 up-threshold — so the /// smoothed score could never climb past 1, pinning the per-node count to 1 /// even when the min-cut cleanly separated two people. These anchors sit /// inside the hysteresis bands so a *sustained* estimate converges to the /// matching count while transient noise stays gated by the EMA: /// 1 → 0.40 (below the 0.55 down-threshold) /// 2 → 0.74 (between the 0.70 up- and 0.78 down-thresholds → reachable /// both climbing from 1 and falling from 3) /// 3 → 0.96 (above the 0.92 up-threshold) fn corr_persons_to_score(corr_persons: usize) -> f64 { match corr_persons { 0 => 0.20, 1 => 0.40, 2 => 0.74, _ => 0.96, } } #[cfg(test)] mod corr_persons_round_trip_tests { //! Issue #803 — a sustained min-cut occupancy estimate must survive the //! CSI path's EMA + `score_to_person_count` re-discretisation instead of //! collapsing back to 1. use super::*; /// Replays the CSI-loop smoothing (`score = score*0.92 + target*0.08`) /// followed by `score_to_person_count`, exactly as the per-node path does, /// and returns the steady-state reported count. fn converge(corr_persons: usize) -> usize { let mut score = 0.0f64; let mut count = 1usize; for _ in 0..400 { let target = corr_persons_to_score(corr_persons); score = score * 0.92 + target * 0.08; count = score_to_person_count(score, count); } count } #[test] fn sustained_one_person_estimate_reports_one() { assert_eq!(converge(1), 1); } #[test] fn sustained_two_person_estimate_reports_two() { assert_eq!(converge(2), 2, "#803: min-cut=2 must round-trip to count 2"); } #[test] fn sustained_three_person_estimate_reports_three() { assert_eq!(converge(3), 3); } #[test] fn old_div3_mapping_would_pin_two_people_to_one() { // Regression-documents the bug: 2/3 = 0.667 never crosses the 0.70 // up-threshold, so the old mapping reported 1 for two people. let mut score = 0.0f64; let mut count = 1usize; for _ in 0..400 { score = score * 0.92 + (2.0 / 3.0) * 0.08; count = score_to_person_count(score, count); } assert_eq!(count, 1, "old corr_persons/3.0 mapping was the #803 bug"); } } /// Convert smoothed person score to discrete count with hysteresis. /// /// Uses asymmetric thresholds: higher threshold to *add* a person, lower to /// *drop* one. This prevents flickering when the score hovers near a boundary /// (the #1 user-reported issue — see #237, #249, #280, #292). fn score_to_person_count(smoothed_score: f64, prev_count: usize) -> usize { // Up-thresholds (must exceed to increase count): // 1→2: 0.80 (raised from 0.65 — single-person movement in multipath // rooms easily hits 0.65, causing false 2-person detection) // 2→3: 0.92 (raised from 0.85 — 3 persons needs very strong signal) // Down-thresholds (must drop below to decrease count): // 2→1: 0.55 (hysteresis gap of 0.25) // 3→2: 0.78 (hysteresis gap of 0.14) match prev_count { 0 | 1 => { if smoothed_score > 0.85 { 3 } else if smoothed_score > 0.70 { 2 } else { 1 } } 2 => { if smoothed_score > 0.92 { 3 } else if smoothed_score < 0.55 { 1 } else { 2 // hold — within hysteresis band } } _ => { // prev_count >= 3 if smoothed_score < 0.55 { 1 } else if smoothed_score < 0.78 { 2 } else { 3 // hold } } } } /// Combine the activity-score-derived aggregate count with the count-aware /// per-node estimates (issue #803). /// /// The aggregate `s.person_count()` is driven by `smoothed_person_score`, an /// EMA-smoothed *activity* score (amplitude variance / motion / spectral /// energy). That score saturates near a single occupant — one moving person /// can max it out — so it cannot discriminate occupancy *count*, leaving the /// reported value pinned at 1. Meanwhile the per-node paths already derive a /// genuinely count-aware estimate (ESP32 firmware `n_persons`, or the /// DynamicMinCut `corr_persons`) and stash it in `NodeState::prev_person_count` /// — but that value was being discarded by the aggregator. /// /// This takes the larger of the two. It can only ever *raise* the count when a /// node has positively estimated more occupants, so it never regresses the /// single-person case (a lone occupant yields `node_max == 1`). fn aggregate_person_count( activity_count: usize, node_states: &std::collections::HashMap, ) -> usize { let node_max = node_states .values() .map(|n| n.prev_person_count) .max() .unwrap_or(0); activity_count.max(node_max) } #[cfg(test)] mod aggregate_person_count_tests { //! Issue #803 — the saturating activity score must not clamp a //! count-aware per-node estimate back down to 1. use super::*; use std::collections::HashMap; fn node_with_count(c: usize) -> NodeState { let mut n = NodeState::new(); n.prev_person_count = c; n } #[test] fn empty_nodes_fall_back_to_activity_count() { let nodes: HashMap = HashMap::new(); assert_eq!(aggregate_person_count(1, &nodes), 1); assert_eq!(aggregate_person_count(0, &nodes), 0); } #[test] fn node_estimate_raises_a_saturated_activity_count() { // The activity score saturates at 1, but a node positively reports 2. let mut nodes = HashMap::new(); nodes.insert(1u8, node_with_count(2)); assert_eq!( aggregate_person_count(1, &nodes), 2, "a node reporting 2 must not be discarded by the activity count" ); } #[test] fn activity_count_wins_when_higher_than_nodes() { // Never *lower* a confident activity-derived count to a stale node value. let mut nodes = HashMap::new(); nodes.insert(1u8, node_with_count(1)); assert_eq!(aggregate_person_count(3, &nodes), 3); } #[test] fn takes_max_across_multiple_nodes() { let mut nodes = HashMap::new(); nodes.insert(1u8, node_with_count(1)); nodes.insert(2u8, node_with_count(3)); nodes.insert(3u8, node_with_count(2)); assert_eq!(aggregate_person_count(1, &nodes), 3); } #[test] fn single_occupant_is_never_inflated() { // Regression guard: a lone occupant (every node sees 1) stays 1. let mut nodes = HashMap::new(); nodes.insert(1u8, node_with_count(1)); nodes.insert(2u8, node_with_count(1)); assert_eq!(aggregate_person_count(1, &nodes), 1); } } /// Generate a single person's skeleton with per-person spatial offset and phase stagger. /// /// `person_idx`: 0-based index of this person. /// `total_persons`: total number of detected persons (for spacing calculation). fn derive_single_person_pose( update: &SensingUpdate, person_idx: usize, total_persons: usize, ) -> PersonDetection { let cls = &update.classification; let feat = &update.features; // Per-person phase offset: ~120 degrees apart so they don't move in sync. let phase_offset = person_idx as f64 * 2.094; // Spatial spread: persons distributed symmetrically around center. let half = (total_persons as f64 - 1.0) / 2.0; let person_x_offset = (person_idx as f64 - half) * 120.0; // 120px spacing // Confidence decays for additional persons (less certain about person 2, 3). let conf_decay = 1.0 - person_idx as f64 * 0.15; // ── Signal-derived scalars ──────────────────────────────────────────────── let motion_score = (feat.motion_band_power / 15.0).clamp(0.0, 1.0); let is_walking = motion_score > 0.55; let breath_amp = (feat.breathing_band_power * 4.0).clamp(0.0, 12.0); let breath_phase = if let Some(ref vs) = update.vital_signs { let bpm = vs.breathing_rate_bpm.unwrap_or(15.0); let freq = (bpm / 60.0).clamp(0.1, 0.5); // Slow tick rate (0.02) for gentle breathing, not jerky oscillation. (update.tick as f64 * freq * 0.02 * std::f64::consts::TAU + phase_offset).sin() } else { (update.tick as f64 * 0.02 + phase_offset).sin() }; let lean_x = (feat.dominant_freq_hz / 5.0 - 1.0).clamp(-1.0, 1.0) * 18.0; let stride_x = if is_walking { let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.06 + phase_offset).sin(); stride_phase * 20.0 * motion_score } else { 0.0 }; // Dampen burst and noise to reduce jitter. The original used // tick*17.3 which changed wildly every frame. Now use slow tick // rate and minimal burst scaling for a stable skeleton. let burst = (feat.change_points as f64 / 20.0).clamp(0.0, 0.3); let noise_seed = person_idx as f64 * 97.1; // stable per-person, no tick let noise_val = (noise_seed.sin() * 43758.545).fract(); let snr_factor = ((feat.variance - 0.5) / 10.0).clamp(0.0, 1.0); let base_confidence = cls.confidence * (0.6 + 0.4 * snr_factor) * conf_decay; // ── Skeleton base position ──────────────────────────────────────────────── let base_x = 320.0 + stride_x + lean_x * 0.5 + person_x_offset; let base_y = 240.0 - motion_score * 8.0; // ── COCO 17-keypoint offsets from hip-center ────────────────────────────── let kp_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", ]; let kp_offsets: [(f64, f64); 17] = [ (0.0, -80.0), // 0 nose (-8.0, -88.0), // 1 left_eye (8.0, -88.0), // 2 right_eye (-16.0, -82.0), // 3 left_ear (16.0, -82.0), // 4 right_ear (-30.0, -50.0), // 5 left_shoulder (30.0, -50.0), // 6 right_shoulder (-45.0, -15.0), // 7 left_elbow (45.0, -15.0), // 8 right_elbow (-50.0, 20.0), // 9 left_wrist (50.0, 20.0), // 10 right_wrist (-20.0, 20.0), // 11 left_hip (20.0, 20.0), // 12 right_hip (-22.0, 70.0), // 13 left_knee (22.0, 70.0), // 14 right_knee (-24.0, 120.0), // 15 left_ankle (24.0, 120.0), // 16 right_ankle ]; const TORSO_KP: [usize; 4] = [5, 6, 11, 12]; const EXTREMITY_KP: [usize; 4] = [9, 10, 15, 16]; let keypoints: Vec = kp_names .iter() .zip(kp_offsets.iter()) .enumerate() .map(|(i, (name, (dx, dy)))| { let breath_dx = if TORSO_KP.contains(&i) { let sign = if *dx < 0.0 { -1.0 } else { 1.0 }; sign * breath_amp * breath_phase * 0.5 } else { 0.0 }; let breath_dy = if TORSO_KP.contains(&i) { let sign = if *dy < 0.0 { -1.0 } else { 1.0 }; sign * breath_amp * breath_phase * 0.3 } else { 0.0 }; let extremity_jitter = if EXTREMITY_KP.contains(&i) { let phase = noise_seed + i as f64 * 2.399; // Dampened from 12/8 to 4/3 to reduce visual jumping. ( phase.sin() * burst * motion_score * 4.0, (phase * 1.31).cos() * burst * motion_score * 3.0, ) } else { (0.0, 0.0) }; let kp_noise_x = ((noise_seed + i as f64 * 1.618).sin() * 43758.545).fract() * feat.variance.sqrt().clamp(0.0, 3.0) * motion_score; let kp_noise_y = ((noise_seed + i as f64 * std::f64::consts::E).cos() * 31415.926) .fract() * feat.variance.sqrt().clamp(0.0, 3.0) * motion_score * 0.6; let swing_dy = if is_walking { let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.12 + phase_offset).sin(); match i { 7 | 9 => -stride_phase * 20.0 * motion_score, 8 | 10 => stride_phase * 20.0 * motion_score, 13 | 15 => stride_phase * 25.0 * motion_score, 14 | 16 => -stride_phase * 25.0 * motion_score, _ => 0.0, } } else { 0.0 }; let final_x = base_x + dx + breath_dx + extremity_jitter.0 + kp_noise_x; let final_y = base_y + dy + breath_dy + extremity_jitter.1 + kp_noise_y + swing_dy; let kp_conf = if EXTREMITY_KP.contains(&i) { base_confidence * (0.7 + 0.3 * snr_factor) * (0.85 + 0.15 * noise_val) } else { base_confidence * (0.88 + 0.12 * ((i as f64 * 0.7 + noise_seed).cos())) }; PoseKeypoint { name: name.to_string(), x: final_x, y: final_y, z: lean_x * 0.02, confidence: kp_conf.clamp(0.1, 1.0), } }) .collect(); let xs: Vec = keypoints.iter().map(|k| k.x).collect(); let ys: Vec = keypoints.iter().map(|k| k.y).collect(); let min_x = xs.iter().cloned().fold(f64::MAX, f64::min) - 10.0; let min_y = ys.iter().cloned().fold(f64::MAX, f64::min) - 10.0; let max_x = xs.iter().cloned().fold(f64::MIN, f64::max) + 10.0; let max_y = ys.iter().cloned().fold(f64::MIN, f64::max) + 10.0; PersonDetection { id: (person_idx + 1) as u32, confidence: cls.confidence * conf_decay, keypoints, bbox: BoundingBox { x: min_x, y: min_y, width: (max_x - min_x).max(80.0), height: (max_y - min_y).max(160.0), }, zone: format!("zone_{}", person_idx + 1), } } fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec { let cls = &update.classification; if !cls.presence { return vec![]; } // Use estimated_persons if set by the tick loop; otherwise default to 1. let person_count = update.estimated_persons.unwrap_or(1).max(1); (0..person_count) .map(|idx| derive_single_person_pose(update, idx, person_count)) .collect() } // ── RuVector Phase 2: Temporal EMA smoothing for keypoints ────────────────── /// Expected bone lengths in pixel-space for the COCO-17 skeleton as used by /// `derive_single_person_pose`. Pairs are (parent_idx, child_idx). const POSE_BONE_PAIRS: &[(usize, usize)] = &[ (5, 7), (7, 9), (6, 8), (8, 10), // arms (5, 11), (6, 12), // torso (11, 13), (13, 15), (12, 14), (14, 16), // legs (5, 6), (11, 12), // shoulders, hips ]; /// Apply temporal EMA smoothing and bone-length clamping to person detections. /// /// For the *first* person (index 0) this uses the per-node `prev_keypoints` /// state. Multi-person smoothing is left for a future phase. fn apply_temporal_smoothing(persons: &mut [PersonDetection], ns: &mut NodeState) { if persons.is_empty() { return; } let alpha = ns.ema_alpha(); let person = &mut persons[0]; // smooth primary person only let current_kps: Vec<[f64; 3]> = person .keypoints .iter() .map(|kp| [kp.x, kp.y, kp.z]) .collect(); let smoothed = if let Some(ref prev) = ns.prev_keypoints { let mut out = Vec::with_capacity(current_kps.len()); for (cur, prv) in current_kps.iter().zip(prev.iter()) { out.push([ alpha * cur[0] + (1.0 - alpha) * prv[0], alpha * cur[1] + (1.0 - alpha) * prv[1], alpha * cur[2] + (1.0 - alpha) * prv[2], ]); } // Clamp bone lengths to ±20% of previous frame. clamp_bone_lengths_f64(&mut out, prev); out } else { current_kps.clone() }; // Write smoothed keypoints back into the person detection. for (kp, s) in person.keypoints.iter_mut().zip(smoothed.iter()) { kp.x = s[0]; kp.y = s[1]; kp.z = s[2]; } ns.prev_keypoints = Some(smoothed); } /// Clamp bone lengths so no bone changes by more than MAX_BONE_CHANGE_RATIO /// compared to the previous frame. fn clamp_bone_lengths_f64(pose: &mut [[f64; 3]], prev: &[[f64; 3]]) { for &(p, c) in POSE_BONE_PAIRS { if p >= pose.len() || c >= pose.len() { continue; } let prev_len = dist_f64(&prev[p], &prev[c]); if prev_len < 1e-6 { continue; } let cur_len = dist_f64(&pose[p], &pose[c]); if cur_len < 1e-6 { continue; } let ratio = cur_len / prev_len; let lo = 1.0 - MAX_BONE_CHANGE_RATIO; let hi = 1.0 + MAX_BONE_CHANGE_RATIO; if ratio < lo || ratio > hi { let target = prev_len * ratio.clamp(lo, hi); let scale = target / cur_len; for dim in 0..3 { let diff = pose[c][dim] - pose[p][dim]; pose[c][dim] = pose[p][dim] + diff * scale; } } } } fn dist_f64(a: &[f64; 3], b: &[f64; 3]) -> f64 { let dx = b[0] - a[0]; let dy = b[1] - a[1]; let dz = b[2] - a[2]; (dx * dx + dy * dy + dz * dz).sqrt() } // ── DensePose-compatible REST endpoints ───────────────────────────────────── async fn health_live(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": "alive", "uptime": s.start_time.elapsed().as_secs(), })) } async fn health_ready(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": "ready", "source": s.effective_source(), })) } async fn health_system(State(state): State) -> Json { let s = state.read().await; let uptime = s.start_time.elapsed().as_secs(); Json(serde_json::json!({ "status": "healthy", "components": { "api": { "status": "healthy", "message": "Rust Axum server" }, "hardware": { "status": if s.effective_source().ends_with(":offline") { "degraded" } else { "healthy" }, "message": format!("Source: {}", s.effective_source()) }, "pose": { "status": "healthy", "message": "WiFi-derived pose estimation" }, "stream": { "status": if s.tx.receiver_count() > 0 { "healthy" } else { "idle" }, "message": format!("{} client(s)", s.tx.receiver_count()) }, }, "metrics": { "cpu_percent": 2.5, "memory_percent": 1.8, "disk_percent": 15.0, "uptime_seconds": uptime, } })) } async fn health_version() -> Json { Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "name": "wifi-densepose-sensing-server", "backend": "rust+axum+ruvector", })) } async fn health_metrics(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "system_metrics": { "cpu": { "percent": 2.5 }, "memory": { "percent": 1.8, "used_mb": 5 }, "disk": { "percent": 15.0 }, }, "tick": s.tick, })) } async fn api_info(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "environment": "production", "backend": "rust", "source": s.effective_source(), "features": { "wifi_sensing": true, "pose_estimation": true, "signal_processing": true, "ruvector": true, "streaming": true, } })) } async fn pose_current(State(state): State) -> Json { let s = state.read().await; let persons = match &s.latest_update { Some(update) => update .persons .clone() .unwrap_or_else(|| derive_pose_from_sensing(update)), None => vec![], }; Json(serde_json::json!({ "timestamp": chrono::Utc::now().timestamp_millis() as f64 / 1000.0, "persons": persons, "total_persons": persons.len(), "source": s.effective_source(), })) } async fn pose_stats(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "total_detections": s.total_detections, "average_confidence": 0.87, "frames_processed": s.tick, "source": s.effective_source(), })) } async fn pose_zones_summary(State(state): State) -> Json { let s = state.read().await; let presence = s .latest_update .as_ref() .map(|u| u.classification.presence) .unwrap_or(false); Json(serde_json::json!({ "zones": { "zone_1": { "person_count": if presence { 1 } else { 0 }, "status": "monitored" }, "zone_2": { "person_count": 0, "status": "clear" }, "zone_3": { "person_count": 0, "status": "clear" }, "zone_4": { "person_count": 0, "status": "clear" }, } })) } async fn stream_status(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "active": true, "clients": s.tx.receiver_count(), "fps": if s.tick > 1 { 10u64 } else { 0u64 }, "source": s.effective_source(), })) } // ── Model Management Endpoints ────────────────────────────────────────────── /// GET /api/v1/models — list discovered RVF model files. async fn list_models(State(state): State) -> Json { // Re-scan directory each call so newly-added files are visible. let models = scan_model_files(); let total = models.len(); { let mut s = state.write().await; s.discovered_models = models.clone(); } Json(serde_json::json!({ "models": models, "total": total })) } /// GET /api/v1/models/active — return currently loaded model or null. async fn get_active_model(State(state): State) -> Json { let s = state.read().await; match &s.active_model_id { Some(id) => { let model = s .discovered_models .iter() .find(|m| m.get("id").and_then(|v| v.as_str()) == Some(id.as_str())); Json(serde_json::json!({ "active": model.cloned().unwrap_or_else(|| serde_json::json!({ "id": id })), })) } None => Json(serde_json::json!({ "active": serde_json::Value::Null })), } } /// POST /api/v1/models/load — load a model by ID. async fn load_model( State(state): State, Json(body): Json, ) -> Json { let model_id = body .get("id") .or_else(|| body.get("model_id")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if model_id.is_empty() { return Json(serde_json::json!({ "error": "missing 'id' field", "success": false })); } let mut s = state.write().await; s.active_model_id = Some(model_id.clone()); s.model_loaded = true; info!("Model loaded: {model_id}"); Json(serde_json::json!({ "success": true, "model_id": model_id })) } /// POST /api/v1/models/unload — unload the current model. async fn unload_model(State(state): State) -> Json { let mut s = state.write().await; let prev = s.active_model_id.take(); s.model_loaded = false; info!("Model unloaded (was: {:?})", prev); Json(serde_json::json!({ "success": true, "previous": prev })) } /// DELETE /api/v1/models/:id — delete a model file. async fn delete_model( State(state): State, Path(id): Path, ) -> Json { // ADR-050: Sanitize path to prevent directory traversal let safe_id = std::path::Path::new(&id) .file_name() .and_then(|f| f.to_str()) .unwrap_or(""); if safe_id.is_empty() || safe_id != id { return Json(serde_json::json!({ "error": "invalid model id", "success": false })); } let path = effective_models_dir().join(format!("{}.rvf", safe_id)); if path.exists() { if let Err(e) = std::fs::remove_file(&path) { warn!("Failed to delete model file {:?}: {}", path, e); return Json( serde_json::json!({ "error": format!("delete failed: {e}"), "success": false }), ); } // If this was the active model, unload it let mut s = state.write().await; if s.active_model_id.as_deref() == Some(id.as_str()) { s.active_model_id = None; s.model_loaded = false; } s.discovered_models .retain(|m| m.get("id").and_then(|v| v.as_str()) != Some(id.as_str())); info!("Model deleted: {id}"); Json(serde_json::json!({ "success": true, "deleted": id })) } else { Json(serde_json::json!({ "error": "model not found", "success": false })) } } /// GET /api/v1/models/lora/profiles — list LoRA adapter profiles. async fn list_lora_profiles() -> Json { // LoRA profiles are discovered from data/models/*.lora.json let profiles = scan_lora_profiles(); Json(serde_json::json!({ "profiles": profiles })) } /// POST /api/v1/models/lora/activate — activate a LoRA adapter profile. async fn activate_lora_profile(Json(body): Json) -> Json { let profile = body .get("profile") .or_else(|| body.get("name")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if profile.is_empty() { return Json(serde_json::json!({ "error": "missing 'profile' field", "success": false })); } info!("LoRA profile activated: {profile}"); Json(serde_json::json!({ "success": true, "profile": profile })) } /// Return the effective models directory, respecting the `MODELS_DIR` /// environment variable. Defaults to `data/models`. fn effective_models_dir() -> PathBuf { PathBuf::from(std::env::var("MODELS_DIR").unwrap_or_else(|_| "data/models".to_string())) } /// Scan the models directory for `.rvf` files and return metadata. /// Respects the `MODELS_DIR` environment variable. fn scan_model_files() -> Vec { let dir = effective_models_dir(); let mut models = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("rvf") { let name = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let modified = entry .metadata() .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); models.push(serde_json::json!({ "id": name, "name": name, "path": path.display().to_string(), "size_bytes": size, "format": "rvf", "modified_epoch": modified, })); } } } models } /// Scan the models directory for `.lora.json` LoRA profile files. /// Respects the `MODELS_DIR` environment variable. fn scan_lora_profiles() -> Vec { let dir = effective_models_dir(); let mut profiles = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name.ends_with(".lora.json") { let profile_name = name.trim_end_matches(".lora.json").to_string(); // Try to read the profile JSON let config = std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .unwrap_or_else(|| serde_json::json!({})); profiles.push(serde_json::json!({ "name": profile_name, "path": path.display().to_string(), "config": config, })); } } } profiles } // ── Recording Endpoints ───────────────────────────────────────────────────── /// GET /api/v1/recording/list — list CSI recordings. async fn list_recordings() -> Json { let recordings = scan_recording_files(); Json(serde_json::json!({ "recordings": recordings })) } /// POST /api/v1/recording/start — start recording CSI data. async fn start_recording( State(state): State, Json(body): Json, ) -> Json { let mut s = state.write().await; if s.recording_active { return Json(serde_json::json!({ "error": "recording already in progress", "success": false, "recording_id": s.recording_current_id, })); } let id = body .get("id") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| format!("rec_{}", chrono_timestamp())); // Create the recording file let rec_path = PathBuf::from("data/recordings").join(format!("{}.jsonl", id)); let file = match std::fs::File::create(&rec_path) { Ok(f) => f, Err(e) => { warn!("Failed to create recording file {:?}: {}", rec_path, e); return Json(serde_json::json!({ "error": format!("cannot create file: {e}"), "success": false, })); } }; // Create a stop signal channel let (stop_tx, mut stop_rx) = tokio::sync::watch::channel(false); s.recording_active = true; s.recording_start_time = Some(std::time::Instant::now()); s.recording_current_id = Some(id.clone()); s.recording_stop_tx = Some(stop_tx); // Subscribe to the broadcast channel to capture CSI frames let mut rx = s.tx.subscribe(); // Add initial recording entry s.recordings.push(serde_json::json!({ "id": id, "path": rec_path.display().to_string(), "status": "recording", "started_at": chrono_timestamp(), "frames": 0, })); let rec_id = id.clone(); // Spawn writer task in background tokio::spawn(async move { use std::io::Write; let mut writer = std::io::BufWriter::new(file); let mut frame_count: u64 = 0; loop { tokio::select! { result = rx.recv() => { match result { Ok(frame_json) => { if writeln!(writer, "{}", frame_json).is_err() { warn!("Recording {rec_id}: write error, stopping"); break; } frame_count += 1; // Flush every 100 frames if frame_count % 100 == 0 { let _ = writer.flush(); } } Err(broadcast::error::RecvError::Lagged(n)) => { debug!("Recording {rec_id}: lagged {n} frames"); } Err(broadcast::error::RecvError::Closed) => { info!("Recording {rec_id}: broadcast closed, stopping"); break; } } } _ = stop_rx.changed() => { if *stop_rx.borrow() { info!("Recording {rec_id}: stop signal received ({frame_count} frames)"); break; } } } } let _ = writer.flush(); info!("Recording {rec_id} finished: {frame_count} frames written"); }); info!("Recording started: {id}"); Json(serde_json::json!({ "success": true, "recording_id": id })) } /// POST /api/v1/recording/stop — stop recording CSI data. async fn stop_recording(State(state): State) -> Json { let mut s = state.write().await; if !s.recording_active { return Json(serde_json::json!({ "error": "no recording in progress", "success": false, })); } // Signal the writer task to stop if let Some(tx) = s.recording_stop_tx.take() { let _ = tx.send(true); } let duration_secs = s .recording_start_time .map(|t| t.elapsed().as_secs()) .unwrap_or(0); let rec_id = s.recording_current_id.take().unwrap_or_default(); s.recording_active = false; s.recording_start_time = None; // Update the recording entry status for rec in s.recordings.iter_mut() { if rec.get("id").and_then(|v| v.as_str()) == Some(rec_id.as_str()) { rec["status"] = serde_json::json!("completed"); rec["duration_secs"] = serde_json::json!(duration_secs); } } info!("Recording stopped: {rec_id} ({duration_secs}s)"); Json(serde_json::json!({ "success": true, "recording_id": rec_id, "duration_secs": duration_secs, })) } /// DELETE /api/v1/recording/:id — delete a recording file. async fn delete_recording( State(state): State, Path(id): Path, ) -> Json { // ADR-050: Sanitize path to prevent directory traversal let safe_id = std::path::Path::new(&id) .file_name() .and_then(|f| f.to_str()) .unwrap_or(""); if safe_id.is_empty() || safe_id != id { return Json(serde_json::json!({ "error": "invalid recording id", "success": false })); } let path = PathBuf::from("data/recordings").join(format!("{}.jsonl", safe_id)); if path.exists() { if let Err(e) = std::fs::remove_file(&path) { warn!("Failed to delete recording {:?}: {}", path, e); return Json( serde_json::json!({ "error": format!("delete failed: {e}"), "success": false }), ); } let mut s = state.write().await; s.recordings .retain(|r| r.get("id").and_then(|v| v.as_str()) != Some(id.as_str())); info!("Recording deleted: {id}"); Json(serde_json::json!({ "success": true, "deleted": id })) } else { Json(serde_json::json!({ "error": "recording not found", "success": false })) } } /// Scan `data/recordings/` for `.jsonl` files and return metadata. fn scan_recording_files() -> Vec { let dir = PathBuf::from("data/recordings"); let mut recordings = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { let name = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let modified = entry .metadata() .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); // Count lines (frames) — approximate for large files let frame_count = std::fs::read_to_string(&path) .map(|s| s.lines().count()) .unwrap_or(0); recordings.push(serde_json::json!({ "id": name, "name": name, "path": path.display().to_string(), "size_bytes": size, "frames": frame_count, "modified_epoch": modified, "status": "completed", })); } } } recordings } // ── Training Endpoints ────────────────────────────────────────────────────── /// GET /api/v1/train/status — get training status. async fn train_status(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "status": s.training_status, "config": s.training_config, })) } /// POST /api/v1/train/start — start a training run. async fn train_start( State(state): State, Json(body): Json, ) -> Json { let mut s = state.write().await; if s.training_status == "running" { return Json(serde_json::json!({ "error": "training already running", "success": false, })); } s.training_status = "running".to_string(); s.training_config = Some(body.clone()); info!("Training started with config: {}", body); Json(serde_json::json!({ "success": true, "status": "running", "message": "Training pipeline started. Use GET /api/v1/train/status to monitor.", })) } /// POST /api/v1/train/stop — stop the current training run. async fn train_stop(State(state): State) -> Json { let mut s = state.write().await; if s.training_status != "running" { return Json(serde_json::json!({ "error": "no training in progress", "success": false, })); } s.training_status = "idle".to_string(); info!("Training stopped"); Json(serde_json::json!({ "success": true, "status": "idle", })) } // ── Adaptive classifier endpoints ──────────────────────────────────────────── /// POST /api/v1/adaptive/train — train the adaptive classifier from recordings. async fn adaptive_train(State(state): State) -> Json { let rec_dir = PathBuf::from("data/recordings"); eprintln!("=== Adaptive Classifier Training ==="); match adaptive_classifier::train_from_recordings(&rec_dir) { Ok(model) => { let accuracy = model.training_accuracy; let frames = model.trained_frames; let stats: Vec<_> = model .class_stats .iter() .map(|cs| { serde_json::json!({ "class": cs.label, "samples": cs.count, "feature_means": cs.mean, }) }) .collect(); // Save to disk. if let Err(e) = model.save(&adaptive_classifier::model_path()) { warn!("Failed to save adaptive model: {e}"); } else { info!( "Adaptive model saved to {}", adaptive_classifier::model_path().display() ); } // Load into runtime state. let mut s = state.write().await; s.adaptive_model = Some(model); Json(serde_json::json!({ "success": true, "trained_frames": frames, "accuracy": accuracy, "class_stats": stats, })) } Err(e) => Json(serde_json::json!({ "success": false, "error": e, })), } } /// GET /api/v1/adaptive/status — check adaptive model status. async fn adaptive_status(State(state): State) -> Json { let s = state.read().await; match &s.adaptive_model { Some(model) => Json(serde_json::json!({ "loaded": true, "trained_frames": model.trained_frames, "accuracy": model.training_accuracy, "version": model.version, "classes": model.class_names, "class_stats": model.class_stats, })), None => Json(serde_json::json!({ "loaded": false, "message": "No adaptive model. POST /api/v1/adaptive/train to train one.", })), } } /// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds). async fn adaptive_unload(State(state): State) -> Json { let mut s = state.write().await; s.adaptive_model = None; Json(serde_json::json!({ "success": true, "message": "Adaptive model unloaded." })) } // ── Field model calibration endpoints (eigenvalue person counting) ────────── async fn calibration_start(State(state): State) -> Json { let mut s = state.write().await; // Guard: don't discard an in-progress or fresh calibration if let Some(ref fm) = s.field_model { match fm.status() { CalibrationStatus::Collecting => { return Json(serde_json::json!({ "success": false, "error": "Calibration already in progress. Call /calibration/stop first.", "frame_count": fm.calibration_frame_count(), })); } CalibrationStatus::Fresh => { return Json(serde_json::json!({ "success": false, "error": "A fresh calibration already exists. Call /calibration/stop or wait for expiry.", })); } _ => {} // Stale/Expired/Uncalibrated — ok to recalibrate } } match FieldModel::new(field_bridge::single_link_config()) { Ok(fm) => { s.field_model = Some(fm); Json(serde_json::json!({ "success": true, "message": "Calibration started — keep room empty while frames accumulate.", })) } Err(e) => Json(serde_json::json!({ "success": false, "error": format!("{e}"), })), } } async fn calibration_stop(State(state): State) -> Json { let mut s = state.write().await; if let Some(ref mut fm) = s.field_model { let ts = chrono::Utc::now().timestamp_micros() as u64; match fm.finalize_calibration(ts, 0) { Ok(modes) => { let baseline = modes.baseline_eigenvalue_count; let variance_explained = modes.variance_explained; info!("Field model calibrated: baseline_eigenvalues={baseline}, variance_explained={variance_explained:.2}"); Json(serde_json::json!({ "success": true, "baseline_eigenvalue_count": baseline, "variance_explained": variance_explained, "frame_count": fm.calibration_frame_count(), })) } Err(e) => Json(serde_json::json!({ "success": false, "error": format!("{e}"), })), } } else { Json(serde_json::json!({ "success": false, "error": "No field model active — call /calibration/start first.", })) } } async fn calibration_status(State(state): State) -> Json { let s = state.read().await; match s.field_model.as_ref() { Some(fm) => Json(serde_json::json!({ "active": true, "status": format!("{:?}", fm.status()), "frame_count": fm.calibration_frame_count(), })), None => Json(serde_json::json!({ "active": false, "status": "none", })), } } /// Generate a simple timestamp string (epoch seconds) for recording IDs. fn chrono_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } async fn vital_signs_endpoint(State(state): State) -> Json { let s = state.read().await; let vs = &s.latest_vitals; let (br_len, br_cap, hb_len, hb_cap) = s.vital_detector.buffer_status(); Json(serde_json::json!({ "vital_signs": { "breathing_rate_bpm": vs.breathing_rate_bpm, "heart_rate_bpm": vs.heart_rate_bpm, "breathing_confidence": vs.breathing_confidence, "heartbeat_confidence": vs.heartbeat_confidence, "signal_quality": vs.signal_quality, }, "buffer_status": { "breathing_samples": br_len, "breathing_capacity": br_cap, "heartbeat_samples": hb_len, "heartbeat_capacity": hb_cap, }, "source": s.effective_source(), "tick": s.tick, })) } /// Query params for `GET /api/v1/edge/registry`. #[derive(Debug, Deserialize)] struct EdgeRegistryParams { /// `?refresh=1` bypasses the in-process cache. Logged at debug for /// abuse visibility. ADR-102 §"Cache semantics". #[serde(default)] refresh: Option, } /// GET /api/v1/edge/registry — surfaces the canonical Cognitum cog catalog. /// /// See ADR-102 (`docs/adr/ADR-102-edge-module-registry.md`) for the design /// + trust model + security review. async fn edge_registry_endpoint( Extension(reg): Extension< Option>, >, Query(params): Query, ) -> Result, (StatusCode, Json)> { let Some(reg) = reg else { // --no-edge-registry, or upstream URL empty. return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "edge_registry_disabled", "detail": "This sensing-server was started with --no-edge-registry." })), )); }; let force_refresh = matches!(params.refresh.as_deref(), Some("1") | Some("true")); if force_refresh { tracing::debug!( event = "edge_registry.refresh_requested", "?refresh=1 bypassed the cache; verify this isn't being abused" ); } match tokio::task::spawn_blocking(move || reg.get(force_refresh)).await { Ok(Ok(resp)) => Ok(Json( serde_json::to_value(resp).unwrap_or(serde_json::json!({})), )), Ok(Err(err)) => { tracing::warn!(error = %err, "edge_registry upstream fetch failed and no cache"); Err(( StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({ "error": "edge_registry_upstream_unavailable", "detail": err.to_string() })), )) } Err(join_err) => { tracing::error!(error = %join_err, "edge_registry spawn_blocking task panicked"); Err(( StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "edge_registry_internal_error", "detail": join_err.to_string() })), )) } } } /// GET /api/v1/edge-vitals — latest edge vitals from ESP32 (ADR-039). async fn edge_vitals_endpoint(State(state): State) -> Json { let s = state.read().await; match &s.edge_vitals { Some(v) => Json(serde_json::json!({ "status": "ok", "edge_vitals": v, })), None => Json(serde_json::json!({ "status": "no_data", "edge_vitals": null, "message": "No edge vitals packet received yet. Ensure ESP32 edge_tier >= 1.", })), } } /// GET /api/v1/wasm-events — latest WASM events from ESP32 (ADR-040). async fn wasm_events_endpoint(State(state): State) -> Json { let s = state.read().await; match &s.latest_wasm_events { Some(w) => Json(serde_json::json!({ "status": "ok", "wasm_events": w, })), None => Json(serde_json::json!({ "status": "no_data", "wasm_events": null, "message": "No WASM output packet received yet. Upload and start a .wasm module on the ESP32.", })), } } async fn model_info(State(state): State) -> Json { let s = state.read().await; match &s.rvf_info { Some(info) => Json(serde_json::json!({ "status": "loaded", "container": info, })), None => Json(serde_json::json!({ "status": "no_model", "message": "No RVF container loaded. Use --load-rvf to load one.", })), } } async fn model_layers(State(state): State) -> Json { let s = state.read().await; match &s.progressive_loader { Some(loader) => { let (a, b, c) = loader.layer_status(); Json(serde_json::json!({ "layer_a": a, "layer_b": b, "layer_c": c, "progress": loader.loading_progress(), })) } None => Json(serde_json::json!({ "layer_a": false, "layer_b": false, "layer_c": false, "progress": 0.0, "message": "No model loaded with progressive loading", })), } } async fn model_segments(State(state): State) -> Json { let s = state.read().await; match &s.progressive_loader { Some(loader) => Json(serde_json::json!({ "segments": loader.segment_list() })), None => Json(serde_json::json!({ "segments": [] })), } } async fn sona_profiles(State(state): State) -> Json { let s = state.read().await; let names = s .progressive_loader .as_ref() .map(|l| l.sona_profile_names()) .unwrap_or_default(); let active = s.active_sona_profile.clone().unwrap_or_default(); Json(serde_json::json!({ "profiles": names, "active": active })) } async fn sona_activate( State(state): State, Json(body): Json, ) -> Json { let profile = body .get("profile") .and_then(|p| p.as_str()) .unwrap_or("") .to_string(); let mut s = state.write().await; let available = s .progressive_loader .as_ref() .map(|l| l.sona_profile_names()) .unwrap_or_default(); if available.contains(&profile) { s.active_sona_profile = Some(profile.clone()); Json(serde_json::json!({ "status": "activated", "profile": profile })) } else { Json(serde_json::json!({ "status": "error", "message": format!("Profile '{}' not found. Available: {:?}", profile, available), })) } } /// GET /api/v1/nodes — per-node health and feature info. /// ADR-110 iter 29 — per-node mesh sync snapshot via HTTP. /// /// GET /api/v1/nodes/:id/sync /// 200 → Json(NodeSyncSnapshot) when latest_sync is present /// 404 → {"error": "no_sync", "node_id": N} otherwise /// /// Complements the WebSocket `sync` field (iter 23) for clients that /// can't hold a streaming connection (curl scripts, Home Assistant REST /// sensors, automation rule probes). async fn node_sync_endpoint( State(state): State, Path(id): Path, ) -> Result, (StatusCode, Json)> { let s = state.read().await; let ns = s.node_states.get(&id).ok_or_else(|| { (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "unknown_node", "node_id": id, }))) })?; ns.sync_snapshot().map(Json).ok_or_else(|| { (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "no_sync", "node_id": id, "hint": "node hasn't emitted a sync packet yet (no mesh peer or not v0.6.9+)", }))) }) } /// ADR-110 iter 29 — fleet-wide mesh state via HTTP. /// /// GET /api/v1/mesh /// 200 → { "nodes": { "": NodeSyncSnapshot, ... }, "total": N } /// Nodes without a recent sync are omitted from the map; an empty /// `nodes` object means no mesh peers reachable. /// ADR-110 iter 36 — Prometheus exposition format for mesh state. /// /// GET /api/v1/mesh/metrics → text/plain /// wifi_densepose_mesh_offset_us{node="N"} /// wifi_densepose_mesh_is_leader{node="N"} 0|1 /// wifi_densepose_mesh_is_valid{node="N"} 0|1 /// wifi_densepose_mesh_smoothed{node="N"} 0|1 /// wifi_densepose_mesh_sequence{node="N"} /// wifi_densepose_mesh_csi_fps{node="N"} /// wifi_densepose_mesh_csi_fps_samples{node="N"} /// wifi_densepose_mesh_staleness_ms{node="N"} /// /// Spec: . /// Each metric is a gauge labeled by node_id. Nodes without a fresh sync /// are simply absent from the output (Prometheus handles missing series /// natively — the scrape just reports them as stale after the configured /// staleness duration). async fn mesh_metrics_endpoint(State(state): State) -> impl IntoResponse { use std::fmt::Write; let s = state.read().await; let mut body = String::with_capacity(1024); // Each metric: HELP + TYPE header + one line per node that has a snapshot. let metrics: &[(&str, &str, &str)] = &[ ("wifi_densepose_mesh_offset_us", "Cross-board mesh-aligned offset, microseconds (signed)", "gauge"), ("wifi_densepose_mesh_is_leader", "1 if this node is the elected mesh leader, else 0", "gauge"), ("wifi_densepose_mesh_is_valid", "1 if this node has heard a fresh leader beacon, else 0", "gauge"), ("wifi_densepose_mesh_smoothed", "1 once the firmware-side EMA filter has seeded, else 0", "gauge"), ("wifi_densepose_mesh_sequence", "High-water CSI sequence at sync emit time", "gauge"), ("wifi_densepose_mesh_csi_fps", "Per-node measured CSI frame rate (Hz)", "gauge"), ("wifi_densepose_mesh_csi_fps_samples", "How many inter-frame deltas the fps EMA has seen", "gauge"), ("wifi_densepose_mesh_staleness_ms", "Milliseconds since the host last received this node's sync packet", "gauge"), ]; // Collect (id, snapshot) pairs once so each metric loop reads the same set. let snaps: Vec<(u8, NodeSyncSnapshot)> = s.node_states.iter() .filter_map(|(&id, ns)| ns.sync_snapshot().map(|snap| (id, snap))) .collect(); // Iter 37: fleet cardinality summary — Ops dashboards want the // "how many leaders / followers / no-sync" tally at a glance // without scraping every per-node series and counting. let (leaders, followers) = fleet_role_counts(&snaps); let no_sync = s.node_states.len().saturating_sub(snaps.len()) as u64; let _ = writeln!(body, "# HELP wifi_densepose_mesh_node_total Per-state node count across the fleet"); let _ = writeln!(body, "# TYPE wifi_densepose_mesh_node_total gauge"); let _ = writeln!(body, "wifi_densepose_mesh_node_total{{state=\"leader\"}} {leaders}"); let _ = writeln!(body, "wifi_densepose_mesh_node_total{{state=\"follower\"}} {followers}"); let _ = writeln!(body, "wifi_densepose_mesh_node_total{{state=\"no_sync\"}} {no_sync}"); for (name, help, kind) in metrics { let _ = writeln!(body, "# HELP {name} {help}"); let _ = writeln!(body, "# TYPE {name} {kind}"); for (id, snap) in &snaps { let value = match *name { "wifi_densepose_mesh_offset_us" => snap.offset_us.to_string(), "wifi_densepose_mesh_is_leader" => bool_metric(snap.is_leader), "wifi_densepose_mesh_is_valid" => bool_metric(snap.is_valid), "wifi_densepose_mesh_smoothed" => bool_metric(snap.smoothed), "wifi_densepose_mesh_sequence" => snap.sequence.to_string(), "wifi_densepose_mesh_csi_fps" => format!("{:.3}", snap.csi_fps_ema), "wifi_densepose_mesh_csi_fps_samples" => snap.csi_fps_samples.to_string(), "wifi_densepose_mesh_staleness_ms" => snap.staleness_ms.map(|n| n.to_string()).unwrap_or_else(|| "0".into()), _ => continue, }; let _ = writeln!(body, "{name}{{node=\"{id}\"}} {value}"); } } ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], body) } fn bool_metric(b: bool) -> String { (if b { 1 } else { 0 }).to_string() } /// ADR-110 iter 37 — count (leaders, followers) in a populated snapshot set. /// Free function for testability — same pattern as iter 18's `update_csi_fps_ema`. pub(crate) fn fleet_role_counts(snaps: &[(u8, NodeSyncSnapshot)]) -> (u64, u64) { let leaders = snaps.iter().filter(|(_, s)| s.is_leader).count() as u64; let followers = (snaps.len() as u64).saturating_sub(leaders); (leaders, followers) } async fn mesh_endpoint(State(state): State) -> Json { let s = state.read().await; let mut nodes = serde_json::Map::new(); for (&id, ns) in s.node_states.iter() { if let Some(snap) = ns.sync_snapshot() { nodes.insert(id.to_string(), serde_json::to_value(snap).unwrap()); } } let total = nodes.len(); Json(serde_json::json!({ "nodes": serde_json::Value::Object(nodes), "total": total, })) } async fn nodes_endpoint(State(state): State) -> Json { let s = state.read().await; let now = std::time::Instant::now(); let nodes: Vec = s .node_states .iter() .map(|(&id, ns)| { let elapsed_ms = ns .last_frame_time .map(|t| now.duration_since(t).as_millis() as u64) .unwrap_or(999999); let stale = elapsed_ms > 5000; let status = if stale { "stale" } else { "active" }; let rssi = ns.rssi_history.back().copied().unwrap_or(-90.0); serde_json::json!({ "node_id": id, "status": status, "last_seen_ms": elapsed_ms, "rssi_dbm": rssi, "motion_level": &ns.current_motion_level, "person_count": ns.prev_person_count, }) }) .collect(); Json(serde_json::json!({ "nodes": nodes, "total": nodes.len(), })) } async fn info_page() -> Html { Html( "\

WiFi-DensePose Sensing Server

\

Rust + Axum + RuVector

\ \ " .to_string() ) } // ── UDP receiver task ──────────────────────────────────────────────────────── async fn udp_receiver_task(state: SharedState, udp_port: u16) { let addr = format!("0.0.0.0:{udp_port}"); let socket = match UdpSocket::bind(&addr).await { Ok(s) => { info!("UDP listening on {addr} for ESP32 CSI frames"); s } Err(e) => { error!("Failed to bind UDP {addr}: {e}"); return; } }; let mut buf = [0u8; 2048]; loop { match socket.recv_from(&mut buf).await { Ok((len, src)) => { // ADR-039: Try edge vitals packet first (magic 0xC511_0002). if let Some(vitals) = parse_esp32_vitals(&buf[..len]) { debug!( "ESP32 vitals from {src}: node={} br={:.1} hr={:.1} pres={}", vitals.node_id, vitals.breathing_rate_bpm, vitals.heartrate_bpm, vitals.presence ); let mut s = state.write().await; // Broadcast vitals via WebSocket. if let Ok(json) = serde_json::to_string(&serde_json::json!({ "type": "edge_vitals", "node_id": vitals.node_id, "presence": vitals.presence, "fall_detected": vitals.fall_detected, "motion": vitals.motion, "breathing_rate_bpm": vitals.breathing_rate_bpm, "heartrate_bpm": vitals.heartrate_bpm, "n_persons": vitals.n_persons, "motion_energy": vitals.motion_energy, "presence_score": vitals.presence_score, "rssi": vitals.rssi, })) { let _ = s.tx.send(json); } // Issue #323: Also emit a sensing_update so the UI renders // detections for ESP32 nodes running the edge DSP pipeline // (Tier 2+). Without this, vitals arrive but the UI shows // "no detection" because it only renders sensing_update msgs. s.source = "esp32".to_string(); s.last_esp32_frame = Some(std::time::Instant::now()); // ── Per-node state for edge vitals (issue #249) ────── let node_id = vitals.node_id; let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new); ns.last_frame_time = Some(std::time::Instant::now()); ns.edge_vitals = Some(vitals.clone()); ns.rssi_history.push_back(vitals.rssi as f64); if ns.rssi_history.len() > 60 { ns.rssi_history.pop_front(); } // Store per-node person count from edge vitals. let node_est = if vitals.presence { (vitals.n_persons as usize).max(1) } else { 0 }; ns.prev_person_count = node_est; s.tick += 1; let tick = s.tick; let motion_level = if vitals.motion { "present_moving" } else if vitals.presence { "present_still" } else { "absent" }; let motion_score = if vitals.motion { 0.8 } else if vitals.presence { 0.3 } else { 0.05 }; // Aggregate person count: gate on presence first (matching WiFi path). let now = std::time::Instant::now(); let total_persons = if vitals.presence { let dedup = s.dedup_factor; let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback( &s.multistatic_fuser, &s.node_states, dedup, ); match fused { Some(ref f) => { let score = multistatic_bridge::compute_person_score_from_amplitudes( &f.fused_amplitude, ); s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10; // #803: don't let the saturating activity score // discard count-aware per-node estimates. let count = aggregate_person_count(s.person_count(), &s.node_states); s.prev_person_count = count; count.max(1) // presence=true => at least 1 } None => { aggregate_person_count(fallback_count.unwrap_or(0), &s.node_states) .max(1) } } } else { s.prev_person_count = 0; 0 }; // Feed field model calibration if active (use per-node history for ESP32). if let Some(frame_history) = s .node_states .get(&node_id) .map(|ns| ns.frame_history.clone()) { if let Some(ref mut fm) = s.field_model { field_bridge::maybe_feed_calibration(fm, &frame_history); } } // Build nodes array with all active nodes. let active_nodes: Vec = s .node_states .iter() .filter(|(_, n)| { n.last_frame_time .is_some_and(|t| now.duration_since(t).as_secs() < 10) }) .map(|(&id, n)| NodeInfo { node_id: id, rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0), position: [2.0, 0.0, 1.5], amplitude: vec![], subcarrier_count: 0, // Vitals-only path; still expose the sync snapshot // if the node also speaks ESP-NOW. sync: n.sync_snapshot(), }) .collect(); let features = FeatureInfo { mean_rssi: vitals.rssi as f64, variance: vitals.motion_energy as f64, motion_band_power: vitals.motion_energy as f64, breathing_band_power: if vitals.presence { 0.5 } else { 0.0 }, dominant_freq_hz: vitals.breathing_rate_bpm / 60.0, change_points: 0, spectral_power: vitals.motion_energy as f64, }; // Store latest features on node for cross-node fusion. if let Some(ns) = s.node_states.get_mut(&node_id) { ns.latest_features = Some(features.clone()); } // Cross-node fusion: combine features from all active nodes. let fused_features = fuse_multi_node_features(&features, &s.node_states); let mut classification = ClassificationInfo { motion_level: motion_level.to_string(), presence: vitals.presence, confidence: vitals.presence_score as f64, }; // Boost classification confidence with multi-node coverage. let n_active = s .node_states .values() .filter(|ns| { ns.last_frame_time .is_some_and(|t| now.duration_since(t).as_secs() < 10) }) .count(); if n_active > 1 { classification.confidence = (classification.confidence * (1.0 + 0.15 * (n_active as f64 - 1.0))) .clamp(0.0, 1.0); } let signal_field = generate_signal_field( fused_features.mean_rssi, motion_score, vitals.breathing_rate_bpm / 60.0, (vitals.presence_score as f64).min(1.0), &[], ); let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: "esp32".to_string(), tick, nodes: active_nodes, features: fused_features.clone(), classification, signal_field, vital_signs: Some(VitalSigns { breathing_rate_bpm: if vitals.breathing_rate_bpm > 0.0 { Some(vitals.breathing_rate_bpm) } else { None }, heart_rate_bpm: if vitals.heartrate_bpm > 0.0 { Some(vitals.heartrate_bpm) } else { None }, breathing_confidence: if vitals.presence { 0.7 } else { 0.0 }, heartbeat_confidence: if vitals.presence { 0.7 } else { 0.0 }, signal_quality: vitals.presence_score as f64, }), enhanced_motion: None, enhanced_breathing: None, posture: None, signal_quality_score: None, quality_verdict: None, bssid_count: None, pose_keypoints: None, model_status: None, persons: None, estimated_persons: if total_persons > 0 { Some(total_persons) } else { None }, // ADR-084 Pass 3.6: surface per-node novelty_score // (and the rest of the per-node feature snapshot) // on the WebSocket envelope so cluster-Pi consumers // can implement model-wake gating without round- // tripping back to the server. node_features: build_node_features(&s.node_states, now), }; let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); s.edge_vitals = Some(vitals); continue; } // ADR-110 §A0.12: Try sync packet (magic 0xC511_A110). // A 32-byte UDP datagram carrying mesh-aligned epoch + sequence // high-water from the node's c6_sync_espnow EMA-smoothed offset. // Stored per-node so subsequent CSI frames with byte 19 bit 4 // set can have an aligned timestamp recovered downstream. if len >= wifi_densepose_hardware::SYNC_PACKET_SIZE { let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); if magic == wifi_densepose_hardware::SYNC_PACKET_MAGIC { match wifi_densepose_hardware::SyncPacket::from_bytes(&buf[..len]) { Ok(sync) => { debug!("ESP32 sync from {src}: node={} leader={} valid={} smoothed={} \ seq={} offset_us={}", sync.node_id, sync.flags.is_leader, sync.flags.is_valid, sync.flags.smoothed_used, sync.sequence, sync.local_minus_epoch_us()); let mut s = state.write().await; let node_id = sync.node_id; let ns = s.node_states.entry(node_id) .or_insert_with(NodeState::new); ns.apply_sync_packet(sync, std::time::Instant::now()); continue; } Err(e) => { debug!("Sync packet decode error from {src}: {e}"); // Fall through — magic matched but decode failed; not a CSI frame. continue; } } } } // 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={}", wasm_output.node_id, wasm_output.module_id, wasm_output.events.len() ); let mut s = state.write().await; // Broadcast WASM events via WebSocket. if let Ok(json) = serde_json::to_string(&serde_json::json!({ "type": "wasm_event", "node_id": wasm_output.node_id, "module_id": wasm_output.module_id, "events": wasm_output.events, })) { let _ = s.tx.send(json); } s.latest_wasm_events = Some(wasm_output); continue; } if let Some(frame) = parse_esp32_frame(&buf[..len]) { debug!( "ESP32 frame from {src}: node={}, subs={}, seq={}", frame.node_id, frame.n_subcarriers, frame.sequence ); let mut s = state.write().await; s.source = "esp32".to_string(); s.last_esp32_frame = Some(std::time::Instant::now()); // Also maintain global frame_history for backward compat // (simulation path, REST endpoints, etc.). s.frame_history.push_back(frame.amplitudes.clone()); if s.frame_history.len() > FRAME_HISTORY_CAPACITY { s.frame_history.pop_front(); } // ── ADR-099: real-time introspection tap ──────────────── // Per-frame update of the attractor / DTW pipeline running // parallel to the window-aggregated event path. Placed // BEFORE the per-node `&mut` borrow of `s.node_states` so // `s.intro` / `s.intro_tx` stay reachable. Never window- // blocked; `/ws/introspection` sees a fresh snapshot on // every accepted frame. { let intro_feature = if frame.amplitudes.is_empty() { 0.0 } else { frame.amplitudes.iter().copied().sum::() / frame.amplitudes.len() as f64 }; let intro_ts_ns = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); let _ = s.intro.update(intro_ts_ns, intro_feature); if let Ok(intro_json) = serde_json::to_string(s.intro.snapshot()) { let _ = s.intro_tx.send(intro_json); } } // ── Per-node processing (issue #249) ────────────────── // Process entirely within per-node state so different // ESP32 nodes never mix their smoothing/vitals buffers. // We scope the mutable borrow of node_states so we can // access other AppStateInner fields afterward. let node_id = frame.node_id; // Clone adaptive model before mutable borrow of node_states // to avoid unsafe raw pointer (review finding #2). let adaptive_model_clone = s.adaptive_model.clone(); let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new); // ADR-110 iter 19 — feed the per-node fps EMA from real // CSI arrivals. The helper sets `last_frame_time` as a // side effect, so the previous bare assignment is gone. ns.observe_csi_frame_arrival(std::time::Instant::now()); // ADR-084 Pass 3: cluster-Pi novelty sensor. // Score this frame's feature vector against the per-node // sketch bank *before* pushing it (so the score reflects // pre-insert state). Result lands in `ns.last_novelty_score` // for downstream model-wake gating. ns.update_novelty(&frame.amplitudes); ns.frame_history.push_back(frame.amplitudes.clone()); if ns.frame_history.len() > FRAME_HISTORY_CAPACITY { ns.frame_history.pop_front(); } let sample_rate_hz = 1000.0 / 500.0_f64; let ( features, mut classification, breathing_rate_hz, sub_variances, raw_motion, ) = extract_features_from_frame(&frame, &ns.frame_history, sample_rate_hz); smooth_and_classify_node(ns, &mut classification, raw_motion); // Adaptive override using cloned model (safe, no raw pointers). if let Some(ref model) = adaptive_model_clone { let amps = ns.frame_history.back().map(|v| v.as_slice()).unwrap_or(&[]); let feat_arr = adaptive_classifier::features_from_runtime( &serde_json::json!({ "variance": features.variance, "motion_band_power": features.motion_band_power, "breathing_band_power": features.breathing_band_power, "spectral_power": features.spectral_power, "dominant_freq_hz": features.dominant_freq_hz, "change_points": features.change_points, "mean_rssi": features.mean_rssi, }), amps, ); let (label, conf) = model.classify(&feat_arr); classification.motion_level = label.to_string(); classification.presence = label != "absent"; classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0); } ns.rssi_history.push_back(features.mean_rssi); if ns.rssi_history.len() > 60 { ns.rssi_history.pop_front(); } let raw_vitals = ns .vital_detector .process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals_node(ns, &raw_vitals); ns.latest_vitals = vitals.clone(); // DynamicMinCut person estimation from subcarrier correlation. let corr_persons = estimate_persons_from_correlation(&ns.frame_history); // #803: map the min-cut count onto a threshold-aligned score // so it round-trips back to the same count. The old // `corr_persons / 3.0` left 2 people at 0.667 — under the // 0.70 up-threshold — so the count was pinned at 1. let raw_score = corr_persons_to_score(corr_persons); ns.smoothed_person_score = ns.smoothed_person_score * 0.92 + raw_score * 0.08; if classification.presence { let count = score_to_person_count(ns.smoothed_person_score, ns.prev_person_count); ns.prev_person_count = count; } else { ns.prev_person_count = 0; } // Store latest features on node for cross-node fusion. ns.latest_features = Some(features.clone()); // Done with per-node mutable borrow; now read aggregated // state from all nodes (the borrow of `ns` ends here). // (We re-borrow node_states immutably via `s` below.) s.rssi_history.push_back(features.mean_rssi); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } s.latest_vitals = vitals.clone(); // Cross-node fusion: combine features from all active nodes. let fused_features = fuse_multi_node_features(&features, &s.node_states); s.tick += 1; let tick = s.tick; let motion_score = if classification.motion_level == "active" { 0.8 } else if classification.motion_level == "present_still" { 0.3 } else { 0.05 }; // Aggregate person count: gate on presence first (matching WiFi path). let now = std::time::Instant::now(); let total_persons = if classification.presence { let dedup = s.dedup_factor; let (fused, fallback_count) = multistatic_bridge::fuse_or_fallback( &s.multistatic_fuser, &s.node_states, dedup, ); match fused { Some(ref f) => { let score = multistatic_bridge::compute_person_score_from_amplitudes( &f.fused_amplitude, ); s.smoothed_person_score = s.smoothed_person_score * 0.90 + score * 0.10; // #803: don't let the saturating activity score // discard count-aware per-node estimates. let count = aggregate_person_count(s.person_count(), &s.node_states); s.prev_person_count = count; count.max(1) } None => { aggregate_person_count(fallback_count.unwrap_or(0), &s.node_states) .max(1) } } } else { s.prev_person_count = 0; 0 }; // Feed field model calibration if active (use per-node history for ESP32). if let Some(frame_history) = s .node_states .get(&node_id) .map(|ns| ns.frame_history.clone()) { if let Some(ref mut fm) = s.field_model { field_bridge::maybe_feed_calibration(fm, &frame_history); } } // Build nodes array with all active nodes. let active_nodes: Vec = s .node_states .iter() .filter(|(_, n)| { n.last_frame_time .is_some_and(|t| now.duration_since(t).as_secs() < 10) }) .map(|(&id, n)| NodeInfo { node_id: id, rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0), position: [2.0, 0.0, 1.5], amplitude: n .frame_history .back() .map(|a| a.iter().take(56).cloned().collect()) .unwrap_or_default(), subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()), // ADR-110 iter 23 / iter 30 — single source of truth. sync: n.sync_snapshot(), }) .collect(); let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: "esp32".to_string(), tick, nodes: active_nodes, features: fused_features.clone(), classification, signal_field: generate_signal_field( fused_features.mean_rssi, motion_score, breathing_rate_hz, fused_features.variance.min(1.0), &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, enhanced_breathing: None, posture: None, signal_quality_score: None, quality_verdict: None, bssid_count: None, pose_keypoints: None, model_status: None, persons: None, estimated_persons: if total_persons > 0 { Some(total_persons) } else { None }, // ADR-084 Pass 3.6: surface per-node novelty_score // (and the rest of the per-node feature snapshot) // on the WebSocket envelope so cluster-Pi consumers // can implement model-wake gating without round- // tripping back to the server. node_features: build_node_features(&s.node_states, now), }; let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); // Evict stale nodes every 100 ticks to prevent memory leak. if tick % 100 == 0 { let stale = Duration::from_secs(60); let before = s.node_states.len(); s.node_states.retain(|_id, ns| { ns.last_frame_time .is_some_and(|t| now.duration_since(t) < stale) }); let evicted = before - s.node_states.len(); if evicted > 0 { info!( "Evicted {} stale node(s), {} active", evicted, s.node_states.len() ); } } } } Err(e) => { warn!("UDP recv error: {e}"); tokio::time::sleep(Duration::from_millis(100)).await; } } } } // ── Simulated data task ────────────────────────────────────────────────────── async fn simulated_data_task(state: SharedState, tick_ms: u64) { let mut interval = tokio::time::interval(Duration::from_millis(tick_ms)); info!("Simulated data source active (tick={}ms)", tick_ms); loop { interval.tick().await; let mut s = state.write().await; s.tick += 1; let tick = s.tick; let frame = generate_simulated_frame(tick); // Append current amplitudes to history before feature extraction. s.frame_history.push_back(frame.amplitudes.clone()); if s.frame_history.len() > FRAME_HISTORY_CAPACITY { s.frame_history.pop_front(); } let sample_rate_hz = 1000.0 / tick_ms as f64; let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) = extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz); smooth_and_classify(&mut s, &mut classification, raw_motion); adaptive_override(&s, &features, &mut classification); s.rssi_history.push_back(features.mean_rssi); if s.rssi_history.len() > 60 { s.rssi_history.pop_front(); } let motion_score = if classification.motion_level == "active" { 0.8 } else if classification.motion_level == "present_still" { 0.3 } else { 0.05 }; let raw_vitals = s .vital_detector .process_frame(&frame.amplitudes, &frame.phases); let vitals = smooth_vitals(&mut s, &raw_vitals); s.latest_vitals = vitals.clone(); let frame_amplitudes = frame.amplitudes.clone(); let frame_n_sub = frame.n_subcarriers; // ADR-044 §5.2: feed raw features into rolling-P95 estimators before scoring. s.p95_variance.push(features.variance); s.p95_motion_band_power.push(features.motion_band_power); s.p95_spectral_power.push(features.spectral_power); // Multi-person estimation with temporal smoothing (EMA α=0.10). let raw_score = compute_person_score(&s, &features); s.smoothed_person_score = s.smoothed_person_score * 0.90 + raw_score * 0.10; let est_persons = if classification.presence { let count = s.person_count(); s.prev_person_count = count; count } else { s.prev_person_count = 0; 0 }; let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, source: "simulated".to_string(), tick, nodes: vec![NodeInfo { node_id: 1, rssi_dbm: features.mean_rssi, position: [2.0, 0.0, 1.5], amplitude: frame_amplitudes, subcarrier_count: frame_n_sub as usize, sync: None, // simulated frame path — no mesh peer }], features: features.clone(), classification, signal_field: generate_signal_field( features.mean_rssi, motion_score, breathing_rate_hz, features.variance.min(1.0), &sub_variances, ), vital_signs: Some(vitals), enhanced_motion: None, enhanced_breathing: None, posture: None, signal_quality_score: None, quality_verdict: None, bssid_count: None, pose_keypoints: None, model_status: if s.model_loaded { Some(serde_json::json!({ "loaded": true, "layers": s.progressive_loader.as_ref() .map(|l| { let (a,b,c) = l.layer_status(); a as u8 + b as u8 + c as u8 }) .unwrap_or(0), "sona_profile": s.active_sona_profile.as_deref().unwrap_or("default"), })) } else { None }, persons: None, estimated_persons: if est_persons > 0 { Some(est_persons) } else { None }, node_features: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). let raw_persons = derive_pose_from_sensing(&update); let mut last_tracker_instant = s.last_tracker_instant.take(); let tracked = tracker_bridge::tracker_update( &mut s.pose_tracker, &mut last_tracker_instant, raw_persons, ); s.last_tracker_instant = last_tracker_instant; if !tracked.is_empty() { update.persons = Some(tracked); } if update.classification.presence { s.total_detections += 1; } if let Ok(json) = serde_json::to_string(&update) { let _ = s.tx.send(json); } s.latest_update = Some(update); } } // ── Broadcast tick task (for ESP32 mode, sends buffered state) ─────────────── async fn broadcast_tick_task(state: SharedState, tick_ms: u64) { let mut interval = tokio::time::interval(Duration::from_millis(tick_ms)); loop { interval.tick().await; let s = state.read().await; if let Some(ref update) = s.latest_update { if s.tx.receiver_count() > 0 { // Re-broadcast the latest sensing_update so pose WS clients // always get data even when ESP32 pauses between frames. // // Issue #618: overwrite `source` with `effective_source()` // before each broadcast so a stale latest_update (frozen // payload from a now-offline ESP32) is emitted with // `source: "esp32:offline"` instead of `source: "esp32"`. // The REST `/health` endpoint already does this; before // this fix the WS path was the only consumer that didn't, // so the UI's "LIVE — ESP32 HARDWARE Connected" banner // stayed green long after the hardware went away. let mut tagged = update.clone(); tagged.source = s.effective_source(); if let Ok(json) = serde_json::to_string(&tagged) { let _ = s.tx.send(json); } } } } } /// Map one sensing-broadcast JSON document into the `VitalsSnapshot`(s) to /// publish over MQTT (issues #872/#898). /// /// Multi-node sources carry a `nodes` array where **each node has its own /// `classification`** (`motion_level`, `presence`, `confidence`) and RSSI — so /// each node must surface its *own* presence/motion, not the room-level /// aggregate. Previously the bridge applied the aggregate `classification` to /// every per-node Home-Assistant device, so a node in an empty corner inherited /// another node's "present" (and `motion_level: "absent"` was mis-mapped to full /// motion). Vitals (breathing / heart rate) and the person count are room-level /// and shared across the per-node devices. Falls back to a single aggregate /// snapshot when there is no per-node data (e.g. wifi / simulate sources). #[cfg(feature = "mqtt")] fn vitals_snapshots_from_sensing_json( v: &serde_json::Value, base_id: &str, ) -> Vec { use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot; // motion_level string -> motion scalar. "absent"/"none"/"still"/"idle"/"" // are non-moving; anything else (walking, …) is motion. `fallback` is used // when the field is absent so a partial per-node payload defers to the // room aggregate rather than silently reading 0. fn motion_of(level: Option<&str>, fallback: f64) -> f64 { match level { Some("none") | Some("still") | Some("idle") | Some("absent") | Some("") => 0.0, Some(_) => 1.0, None => fallback, } } let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64; let vit = &v["vital_signs"]; let breathing = vit["breathing_rate_bpm"].as_f64(); let hr = vit["heart_rate_bpm"].as_f64(); let n_persons = v["persons"] .as_array() .map(|a| a.len() as u32) .or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32)) .unwrap_or(0); // Room-level aggregate: the no-nodes fallback, and the per-node default for // any field a node omits. let acls = &v["classification"]; let agg_presence = acls["presence"].as_bool().unwrap_or(false); let agg_motion = motion_of(acls["motion_level"].as_str(), 0.0); let agg_conf = acls["confidence"].as_f64().unwrap_or(0.0); let mk = |node_id: String, presence: bool, motion: f64, conf: f64, rssi: Option| { VitalsSnapshot { node_id, timestamp_ms: ts, presence, motion, presence_score: if presence { conf.max(0.0) } else { 0.0 }, breathing_rate_bpm: breathing, heartrate_bpm: hr, n_persons, rssi_dbm: rssi, vital_confidence: conf, ..Default::default() } }; match v["nodes"].as_array() { Some(arr) if !arr.is_empty() => arr .iter() .map(|node| { let n = node["node_id"].as_u64().unwrap_or(0); // Each node carries its OWN classification — use it, deferring to // the room aggregate only for fields the node omits. let ncls = &node["classification"]; let presence = ncls["presence"].as_bool().unwrap_or(agg_presence); let motion = motion_of(ncls["motion_level"].as_str(), agg_motion); let conf = ncls["confidence"].as_f64().unwrap_or(agg_conf); mk( format!("{base_id}-node{n}"), presence, motion, conf, node["rssi_dbm"].as_f64(), ) }) .collect(), _ => vec![mk( base_id.to_string(), agg_presence, agg_motion, agg_conf, v["nodes"][0]["rssi_dbm"].as_f64(), )], } } /// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894). /// /// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files /// (`model.safetensors`, `model-q{2,4,8}.bin`, `model.rvf.jsonl`) are a /// different *format* — and a different encoder architecture — than the RVF /// binary container the `--model` progressive loader expects (`RVFS` magic /// `0x52564653`). Feeding one to `--model` produced a bare /// "invalid magic at offset 0 …" that left users stuck. Detect the common /// cases and explain plainly what's loadable instead. fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String { let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("") .to_ascii_lowercase(); let ext = path .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_ascii_lowercase(); // safetensors: 8-byte LE header length, then a JSON object starting with '{'. let looks_safetensors = ext == "safetensors" || (data.len() > 9 && data[8] == b'{'); // JSONL manifest: starts with '{' (or the well-known suffix). let looks_jsonl = ext == "jsonl" || name.ends_with(".rvf.jsonl") || data.first() == Some(&b'{'); // Quantized weight blob shipped on HF (model-q2/q4/q8.bin). let looks_quant_bin = ext == "bin" || name.contains("-q"); let kind = if looks_safetensors { "a safetensors weight file" } else if looks_jsonl { "a JSONL manifest, not the binary container" } else if looks_quant_bin { "a quantized weight blob (e.g. HuggingFace model-q4.bin)" } else { "not an RVF binary container" }; format!( "model `{}` could not be loaded: it is {kind}. The --model flag expects an \ RVF binary container (`RVFS` magic 0x52564653) produced by the \ wifi-densepose-train pipeline. The HuggingFace ruvnet/wifi-densepose-pretrained \ files are a different format and encoder architecture, so they do not load \ here directly (issue #894). Continuing with signal heuristics. (loader: {err})", path.display() ) } /// Whether `--export-rvf` should emit the placeholder container-format demo. /// /// It must only do so **standalone**. Combined with `--train`/`--pretrain` the /// real model is produced by the training pipeline, so short-circuiting here /// would silently skip training and write placeholder weights — the #894 bug /// where the documented `--train … --export-rvf` workflow produced a fake model. fn export_emits_placeholder_demo(export_set: bool, train: bool, pretrain: bool) -> bool { export_set && !train && !pretrain } // ── Main ───────────────────────────────────────────────────────────────────── /// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd. fn coalesce_ui_path(initial: std::path::PathBuf) -> std::path::PathBuf { if initial.is_dir() { return initial; } for rel in &["../ui", "./ui", "../../ui"] { let p = std::path::PathBuf::from(rel); if p.is_dir() { warn!( "UI path {} not found; using {} (set --ui-path explicitly if wrong)", initial.display(), p.display() ); return p; } } initial } #[tokio::main] async fn main() { // Initialize tracing tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "info,tower_http=debug".into()), ) .init(); let mut args = Args::parse(); args.ui_path = coalesce_ui_path(args.ui_path); // Handle --benchmark mode: run vital sign benchmark and exit if args.benchmark { eprintln!("Running vital sign detection benchmark (1000 frames)..."); let (total, per_frame) = vital_signs::run_benchmark(1000); eprintln!(); eprintln!("Summary: {total:?} total, {per_frame:?} per frame"); return; } // Handle --export-rvf: writes a CONTAINER-FORMAT DEMO with placeholder // weights — it is NOT a trained model. Only short-circuit when standalone: // combined with --train/--pretrain the real model is exported by the // training pipeline, and short-circuiting here would silently skip training // and write placeholder weights (#894 — the documented `--train … // --export-rvf` workflow produced a placeholder and never trained). if export_emits_placeholder_demo(args.export_rvf.is_some(), args.train, args.pretrain) { let rvf_path = args .export_rvf .as_ref() .expect("export_emits_placeholder_demo implies export_rvf is set"); eprintln!( "WARNING: --export-rvf writes a CONTAINER-FORMAT DEMO with placeholder \ weights — it is NOT a trained model. Train one with \ `--train --dataset ` (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"); // Vital sign config (default breathing 0.1-0.5 Hz, heartbeat 0.8-2.0 Hz) builder.set_vital_config(0.1, 0.5, 0.8, 2.0); // Model profile (input/output spec) builder.set_model_profile( "56-subcarrier CSI amplitude/phase @ 10-100 Hz", "17 COCO keypoints + body part UV + vital signs", "ESP32-S3 or Windows WiFi RSSI, Rust 1.85+", ); // Placeholder weights (17 keypoints × 56 subcarriers × 3 dims = 2856 params) let placeholder_weights: Vec = (0..2856).map(|i| (i as f32 * 0.001).sin()).collect(); builder.set_weights(&placeholder_weights); // Training provenance builder.set_training_proof( "wifi-densepose-rs-v1.0.0", serde_json::json!({ "pipeline": "ADR-023 8-phase", "test_count": 229, "benchmark_fps": 9520, "framework": "wifi-densepose-rs", }), ); // SONA default environment profile let default_lora: Vec = vec![0.0; 64]; builder.add_sona_profile("default", &default_lora, &default_lora); match builder.build() { Ok(rvf_bytes) => { if let Err(e) = std::fs::write(rvf_path, &rvf_bytes) { eprintln!("Error writing RVF: {e}"); std::process::exit(1); } eprintln!("Wrote {} bytes to {}", rvf_bytes.len(), rvf_path.display()); eprintln!("RVF container exported successfully."); } Err(e) => { eprintln!("Error building RVF: {e}"); std::process::exit(1); } } 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) if args.pretrain { eprintln!("=== WiFi-DensePose Contrastive Pretraining (ADR-024) ==="); let ds_path = args .dataset .clone() .unwrap_or_else(|| PathBuf::from("data")); let source = match args.dataset_type.as_str() { "wipose" => dataset::DataSource::WiPose(ds_path.clone()), _ => dataset::DataSource::MmFi(ds_path.clone()), }; let pipeline = dataset::DataPipeline::new(dataset::DataConfig { source, ..Default::default() }); // Generate synthetic or load real CSI windows let generate_synthetic_windows = || -> Vec>> { (0..50) .map(|i| { (0..4) .map(|a| { (0..56) .map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5) .collect() }) .collect() }) .collect() }; let csi_windows: Vec>> = match pipeline.load() { Ok(s) if !s.is_empty() => { eprintln!("Loaded {} samples from {}", s.len(), ds_path.display()); s.into_iter().map(|s| s.csi_window).collect() } _ => { eprintln!("Using synthetic data for pretraining."); generate_synthetic_windows() } }; let n_subcarriers = csi_windows .first() .and_then(|w| w.first()) .map(|f| f.len()) .unwrap_or(56); let tf_config = graph_transformer::TransformerConfig { n_subcarriers, n_keypoints: 17, d_model: 64, n_heads: 4, n_gnn_layers: 2, }; let transformer = graph_transformer::CsiToPoseTransformer::new(tf_config); eprintln!("Transformer params: {}", transformer.param_count()); let trainer_config = trainer::TrainerConfig { epochs: args.pretrain_epochs, batch_size: 8, lr: 0.001, warmup_epochs: 2, min_lr: 1e-6, early_stop_patience: args.pretrain_epochs + 1, pretrain_temperature: 0.07, ..Default::default() }; let mut t = trainer::Trainer::with_transformer(trainer_config, transformer); let e_config = embedding::EmbeddingConfig { d_model: 64, d_proj: 128, temperature: 0.07, normalize: true, }; let mut projection = embedding::ProjectionHead::new(e_config.clone()); let augmenter = embedding::CsiAugmenter::new(); eprintln!( "Starting contrastive pretraining for {} epochs...", args.pretrain_epochs ); let start = std::time::Instant::now(); for epoch in 0..args.pretrain_epochs { let loss = t.pretrain_epoch(&csi_windows, &augmenter, &mut projection, 0.07, epoch); if epoch % 10 == 0 || epoch == args.pretrain_epochs - 1 { eprintln!(" Epoch {epoch}: contrastive loss = {loss:.4}"); } } let elapsed = start.elapsed().as_secs_f64(); eprintln!("Pretraining complete in {elapsed:.1}s"); // Save pretrained model as RVF with embedding segment if let Some(ref save_path) = args.save_rvf { eprintln!("Saving pretrained model to RVF: {}", save_path.display()); t.sync_transformer_weights(); let weights = t.params().to_vec(); let mut proj_weights = Vec::new(); projection.flatten_into(&mut proj_weights); let mut builder = RvfBuilder::new(); builder.add_manifest( "wifi-densepose-pretrained", env!("CARGO_PKG_VERSION"), "WiFi DensePose contrastive pretrained model (ADR-024)", ); builder.add_weights(&weights); builder.add_embedding( &serde_json::json!({ "d_model": e_config.d_model, "d_proj": e_config.d_proj, "temperature": e_config.temperature, "normalize": e_config.normalize, "pretrain_epochs": args.pretrain_epochs, }), &proj_weights, ); match builder.write_to_file(save_path) { Ok(()) => eprintln!( "RVF saved ({} transformer + {} projection params)", weights.len(), proj_weights.len() ), Err(e) => eprintln!("Failed to save RVF: {e}"), } } return; } // Handle --embed mode: extract embeddings from CSI data if args.embed { eprintln!("=== WiFi-DensePose Embedding Extraction (ADR-024) ==="); let model_path = match &args.model { Some(p) => p.clone(), None => { eprintln!("Error: --embed requires --model to a pretrained .rvf file"); std::process::exit(1); } }; let reader = match RvfReader::from_file(&model_path) { Ok(r) => r, Err(e) => { eprintln!("Failed to load model: {e}"); std::process::exit(1); } }; let weights = reader.weights().unwrap_or_default(); let (embed_config_json, proj_weights) = reader.embedding().unwrap_or_else(|| { eprintln!("Warning: no embedding segment in RVF, using defaults"); ( serde_json::json!({"d_model":64,"d_proj":128,"temperature":0.07,"normalize":true}), Vec::new(), ) }); let d_model = embed_config_json["d_model"].as_u64().unwrap_or(64) as usize; let d_proj = embed_config_json["d_proj"].as_u64().unwrap_or(128) as usize; let tf_config = graph_transformer::TransformerConfig { n_subcarriers: 56, n_keypoints: 17, d_model, n_heads: 4, n_gnn_layers: 2, }; let e_config = embedding::EmbeddingConfig { d_model, d_proj, temperature: 0.07, normalize: true, }; let mut extractor = embedding::EmbeddingExtractor::new(tf_config, e_config.clone()); // Load transformer weights if !weights.is_empty() { if let Err(e) = extractor.transformer.unflatten_weights(&weights) { eprintln!("Warning: failed to load transformer weights: {e}"); } } // Load projection weights if !proj_weights.is_empty() { let (proj, _) = embedding::ProjectionHead::unflatten_from(&proj_weights, &e_config); extractor.projection = proj; } // Load dataset and extract embeddings let _ds_path = args .dataset .clone() .unwrap_or_else(|| PathBuf::from("data")); let csi_windows: Vec>> = (0..10) .map(|i| { (0..4) .map(|a| { (0..56) .map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5) .collect() }) .collect() }) .collect(); eprintln!( "Extracting embeddings from {} CSI windows...", csi_windows.len() ); let embeddings = extractor.extract_batch(&csi_windows); for (i, emb) in embeddings.iter().enumerate() { let norm: f32 = emb.iter().map(|x| x * x).sum::().sqrt(); eprintln!(" Window {i}: {d_proj}-dim embedding, ||e|| = {norm:.4}"); } eprintln!( "Extracted {} embeddings of dimension {d_proj}", embeddings.len() ); return; } // Handle --build-index mode: build a fingerprint index from embeddings if let Some(ref index_type_str) = args.build_index { eprintln!("=== WiFi-DensePose Fingerprint Index Builder (ADR-024) ==="); let index_type = match index_type_str.as_str() { "env" | "environment" => embedding::IndexType::EnvironmentFingerprint, "activity" => embedding::IndexType::ActivityPattern, "temporal" => embedding::IndexType::TemporalBaseline, "person" => embedding::IndexType::PersonTrack, _ => { eprintln!( "Unknown index type '{}'. Use: env, activity, temporal, person", index_type_str ); std::process::exit(1); } }; let tf_config = graph_transformer::TransformerConfig::default(); let e_config = embedding::EmbeddingConfig::default(); let mut extractor = embedding::EmbeddingExtractor::new(tf_config, e_config); // Generate synthetic CSI windows for demo let csi_windows: Vec>> = (0..20) .map(|i| { (0..4) .map(|a| { (0..56) .map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5) .collect() }) .collect() }) .collect(); let mut index = embedding::FingerprintIndex::new(index_type); for (i, window) in csi_windows.iter().enumerate() { let emb = extractor.extract(window); index.insert(emb, format!("window_{i}"), i as u64 * 100); } eprintln!("Built {:?} index with {} entries", index_type, index.len()); // Test a query let query_emb = extractor.extract(&csi_windows[0]); let results = index.search(&query_emb, 5); eprintln!("Top-5 nearest to window_0:"); for r in &results { eprintln!( " entry={}, distance={:.4}, metadata={}", r.entry, r.distance, r.metadata ); } return; } // Handle --train mode: train a model and exit if args.train { eprintln!("=== WiFi-DensePose Training Mode ==="); // Build data pipeline let ds_path = args .dataset .clone() .unwrap_or_else(|| PathBuf::from("data")); let source = match args.dataset_type.as_str() { "wipose" => dataset::DataSource::WiPose(ds_path.clone()), _ => dataset::DataSource::MmFi(ds_path.clone()), }; let pipeline = dataset::DataPipeline::new(dataset::DataConfig { source, ..Default::default() }); // Generate synthetic training data (50 samples with deterministic CSI + keypoints) let generate_synthetic = || -> Vec { (0..50) .map(|i| { let csi: Vec> = (0..4) .map(|a| { (0..56) .map(|s| ((i * 7 + a * 13 + s) as f32 * 0.31).sin() * 0.5) .collect() }) .collect(); let mut kps = [(0.0f32, 0.0f32, 1.0f32); 17]; for (k, kp) in kps.iter_mut().enumerate() { kp.0 = (k as f32 * 0.1 + i as f32 * 0.02).sin() * 100.0 + 320.0; kp.1 = (k as f32 * 0.15 + i as f32 * 0.03).cos() * 80.0 + 240.0; } dataset::TrainingSample { csi_window: csi, pose_label: dataset::PoseLabel { keypoints: kps, body_parts: Vec::new(), confidence: 1.0, }, source: "synthetic", } }) .collect() }; // Load samples (fall back to synthetic if dataset missing/empty) let samples = match pipeline.load() { Ok(s) if !s.is_empty() => { eprintln!("Loaded {} samples from {}", s.len(), ds_path.display()); s } Ok(_) => { eprintln!( "No samples found at {}. Using synthetic data.", ds_path.display() ); generate_synthetic() } Err(e) => { eprintln!("Failed to load dataset: {e}. Using synthetic data."); generate_synthetic() } }; // Convert dataset samples to trainer format let trainer_samples: Vec = samples.iter().map(trainer::from_dataset_sample).collect(); // Split 80/20 train/val let split = (trainer_samples.len() * 4) / 5; let (train_data, val_data) = trainer_samples.split_at(split.max(1)); eprintln!( "Train: {} samples, Val: {} samples", train_data.len(), val_data.len() ); // Create transformer + trainer let n_subcarriers = train_data .first() .and_then(|s| s.csi_features.first()) .map(|f| f.len()) .unwrap_or(56); let tf_config = graph_transformer::TransformerConfig { n_subcarriers, n_keypoints: 17, d_model: 64, n_heads: 4, n_gnn_layers: 2, }; let transformer = graph_transformer::CsiToPoseTransformer::new(tf_config); eprintln!("Transformer params: {}", transformer.param_count()); let trainer_config = trainer::TrainerConfig { epochs: args.epochs, batch_size: 8, lr: 0.001, warmup_epochs: 5, min_lr: 1e-6, early_stop_patience: 20, checkpoint_every: 10, ..Default::default() }; let mut t = trainer::Trainer::with_transformer(trainer_config, transformer); // Run training eprintln!("Starting training for {} epochs...", args.epochs); let result = t.run_training(train_data, val_data); eprintln!("Training complete in {:.1}s", result.total_time_secs); eprintln!( " Best epoch: {}, PCK@0.2: {:.4}, OKS mAP: {:.4}", result.best_epoch, result.best_pck, result.best_oks ); // Save checkpoint if let Some(ref ckpt_dir) = args.checkpoint_dir { let _ = std::fs::create_dir_all(ckpt_dir); let ckpt_path = ckpt_dir.join("best_checkpoint.json"); let ckpt = t.checkpoint(); match ckpt.save_to_file(&ckpt_path) { Ok(()) => eprintln!("Checkpoint saved to {}", ckpt_path.display()), Err(e) => eprintln!("Failed to save checkpoint: {e}"), } } // Sync weights back to transformer and save as RVF t.sync_transformer_weights(); if let Some(ref save_path) = args.save_rvf { eprintln!("Saving trained model to RVF: {}", save_path.display()); let weights = t.params().to_vec(); let mut builder = RvfBuilder::new(); builder.add_manifest( "wifi-densepose-trained", env!("CARGO_PKG_VERSION"), "WiFi DensePose trained model weights", ); builder.add_metadata(&serde_json::json!({ "training": { "epochs": args.epochs, "best_epoch": result.best_epoch, "best_pck": result.best_pck, "best_oks": result.best_oks, "n_train_samples": train_data.len(), "n_val_samples": val_data.len(), "n_subcarriers": n_subcarriers, "param_count": weights.len(), }, })); builder.add_vital_config(&VitalSignConfig::default()); builder.add_weights(&weights); match builder.write_to_file(save_path) { Ok(()) => eprintln!( "RVF saved ({} params, {} bytes)", weights.len(), weights.len() * 4 ), Err(e) => eprintln!("Failed to save RVF: {e}"), } } return; } info!("WiFi-DensePose Sensing Server (Rust + Axum + RuVector)"); info!(" HTTP: http://localhost:{}", args.http_port); info!(" WebSocket: ws://localhost:{}/ws/sensing", args.ws_port); info!(" UDP: 0.0.0.0:{} (ESP32 CSI)", args.udp_port); info!(" UI path: {}", args.ui_path.display()); info!(" Source: {}", args.source); // Auto-detect data source let source = match args.source.as_str() { "auto" => { info!("Auto-detecting data source..."); if probe_esp32(args.udp_port).await { info!(" ESP32 CSI detected on UDP :{}", args.udp_port); "esp32" } else if probe_windows_wifi().await { info!(" Windows WiFi detected"); "wifi" } else { info!(" No hardware detected, using simulation"); "simulate" } } other => other, }; info!("Data source: {source}"); // Shared state // Vital sign sample rate derives from tick interval (e.g. 500ms tick => 2 Hz) let vital_sample_rate = 1000.0 / args.tick_ms as f64; info!("Vital sign detector sample rate: {vital_sample_rate:.1} Hz"); // Load RVF container if --load-rvf was specified let rvf_info = if let Some(ref rvf_path) = args.load_rvf { info!("Loading RVF container from {}", rvf_path.display()); match RvfReader::from_file(rvf_path) { Ok(reader) => { let info = reader.info(); info!( " RVF loaded: {} segments, {} bytes", info.segment_count, info.total_size ); if let Some(ref manifest) = info.manifest { if let Some(model_id) = manifest.get("model_id") { info!(" Model ID: {model_id}"); } if let Some(version) = manifest.get("version") { info!(" Version: {version}"); } } if info.has_weights { if let Some(w) = reader.weights() { info!(" Weights: {} parameters", w.len()); } } if info.has_vital_config { info!(" Vital sign config: present"); } if info.has_quant_info { info!(" Quantization info: present"); } if info.has_witness { info!(" Witness/proof: present"); } Some(info) } Err(e) => { error!("Failed to load RVF container: {e}"); None } } } else { None }; // Load trained model via --model (uses progressive loading if --progressive set) let model_path = args.model.as_ref().or(args.load_rvf.as_ref()); let mut progressive_loader: Option = None; let mut model_loaded = false; if let Some(mp) = model_path { if args.progressive || args.model.is_some() { info!("Loading trained model (progressive) from {}", mp.display()); match std::fs::read(mp) { Ok(data) => match ProgressiveLoader::new(&data) { Ok(mut loader) => { if let Ok(la) = loader.load_layer_a() { info!( " Layer A ready: model={} v{} ({} segments)", la.model_name, la.version, la.n_segments ); } model_loaded = true; progressive_loader = Some(loader); } Err(e) => { error!("{}", diagnose_model_load_error(mp, &data, &e.to_string())) } }, Err(e) => error!("Failed to read model file: {e}"), } } } // Ensure data directories exist for models and recordings let models_dir = effective_models_dir(); let _ = std::fs::create_dir_all(&models_dir); let _ = std::fs::create_dir_all("data/recordings"); // Discover model and recording files on startup let initial_models = scan_model_files(); let initial_recordings = scan_recording_files(); info!( "Discovered {} model files, {} recording files", initial_models.len(), initial_recordings.len() ); // ADR-044 §5.3: load persisted runtime config from the data directory. let data_dir = std::path::PathBuf::from("data"); let runtime_config = load_runtime_config(&data_dir); info!( "Loaded runtime config: dedup_factor={:.2}", runtime_config.dedup_factor ); // ADR-102: optional Edge Module Registry. None when --no-edge-registry // is set (or when the URL is empty); otherwise we construct one with // the configured TTL. The fetch happens lazily on first request. let edge_registry: Option< std::sync::Arc, > = if args.no_edge_registry || args.edge_registry_url.is_empty() { info!("Edge module registry: DISABLED (--no-edge-registry or empty URL)"); None } else { info!( "Edge module registry: enabled — upstream={} ttl={}s", args.edge_registry_url, args.edge_registry_ttl_secs ); Some(std::sync::Arc::new( wifi_densepose_sensing_server::edge_registry::EdgeRegistry::new( args.edge_registry_url.clone(), std::time::Duration::from_secs(args.edge_registry_ttl_secs), ), )) }; let (tx, _) = broadcast::channel::(256); // ADR-099: parallel broadcast for the per-frame introspection snapshot stream // consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow // clients drop oldest, identical backpressure shape. let (intro_tx, _) = broadcast::channel::(256); // #872: actually start the MQTT publisher when `--mqtt` is set. The publisher // (mqtt::) consumes a typed VitalsSnapshot stream; we bridge the existing JSON // sensing broadcast into it with a defensive serde_json::Value mapping (absent // fields default — never publish wrong values). Gated on the `mqtt` feature // (the Docker image is built `--features mqtt`); without it `--mqtt` WARNs and // no-ops, matching the documented contract. if args.mqtt_opts.mqtt { #[cfg(feature = "mqtt")] { use wifi_densepose_sensing_server::mqtt; let mcfg = std::sync::Arc::new(mqtt::config::MqttConfig::from_args(&args.mqtt_opts)); match mcfg.validate() { Ok(()) => { let node_id = mcfg.client_id.clone(); let builder = mqtt::publisher::OwnedDiscoveryBuilder { discovery_prefix: mcfg.discovery_prefix.clone(), node_id: node_id.clone(), node_friendly_name: Some("RuView".to_string()), sw_version: env!("CARGO_PKG_VERSION").to_string(), model: "RuView WiFi Sensing".to_string(), via_device: None, }; let (vtx, vrx) = broadcast::channel::(64); let (host, port) = (mcfg.host.clone(), mcfg.port); mqtt::publisher::spawn(mcfg, builder, vrx); let mut jrx = tx.subscribe(); tokio::spawn(async move { while let Ok(json) = jrx.recv().await { let Ok(v) = serde_json::from_str::(&json) else { continue; }; // #898/#872: emit one snapshot per physical node so // each surfaces as its own Home-Assistant device with // its *own* presence/motion/RSSI (see // vitals_snapshots_from_sensing_json). Falls back to a // single aggregate snapshot for per-node-less sources. for snap in vitals_snapshots_from_sensing_json(&v, &node_id) { let _ = vtx.send(snap); } } }); tracing::info!("MQTT publisher started -> {host}:{port}"); } Err(e) => tracing::error!("MQTT config invalid: {e}; publisher not started"), } } #[cfg(not(feature = "mqtt"))] tracing::warn!( "--mqtt set but this binary was built without the `mqtt` feature; the publisher is a \ no-op. Use the official Docker image (built `--features mqtt`) or rebuild with \ `cargo build -p wifi-densepose-sensing-server --features mqtt`." ); } let state: SharedState = Arc::new(RwLock::new(AppStateInner { latest_update: None, rssi_history: VecDeque::new(), frame_history: VecDeque::new(), tick: 0, source: source.into(), last_esp32_frame: None, tx, intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(), intro_tx, total_detections: 0, start_time: std::time::Instant::now(), vital_detector: VitalSignDetector::new(vital_sample_rate), latest_vitals: VitalSigns::default(), rvf_info, save_rvf_path: args.save_rvf.clone(), progressive_loader, active_sona_profile: None, model_loaded, smoothed_person_score: 0.0, prev_person_count: 0, smoothed_motion: 0.0, current_motion_level: "absent".to_string(), debounce_counter: 0, debounce_candidate: "absent".to_string(), baseline_motion: 0.0, baseline_frames: 0, smoothed_hr: 0.0, smoothed_br: 0.0, smoothed_hr_conf: 0.0, smoothed_br_conf: 0.0, hr_buffer: VecDeque::with_capacity(8), br_buffer: VecDeque::with_capacity(8), edge_vitals: None, latest_wasm_events: None, // Model management discovered_models: initial_models, active_model_id: None, // Recording recordings: initial_recordings, recording_active: false, recording_start_time: None, recording_current_id: None, recording_stop_tx: None, // Training training_status: "idle".to_string(), training_config: None, adaptive_model: adaptive_classifier::AdaptiveModel::load(&adaptive_classifier::model_path()) .ok() .inspect(|m| { info!( "Loaded adaptive classifier: {} frames, {:.1}% accuracy", m.trained_frames, m.training_accuracy * 100.0 ); }), node_states: HashMap::new(), // Accuracy sprint pose_tracker: PoseTracker::new(), last_tracker_instant: None, multistatic_fuser: { let mut fuser = MultistaticFuser::with_config(MultistaticConfig { min_nodes: 1, // single-node passthrough ..Default::default() }); if let Some(ref pos_str) = args.node_positions { let positions = field_bridge::parse_node_positions(pos_str); if !positions.is_empty() { info!( "Configured {} node positions for multistatic fusion", positions.len() ); fuser.set_node_positions(positions); } } fuser }, field_model: if args.calibrate { info!("Field model calibration enabled — room should be empty during startup"); FieldModel::new(field_bridge::single_link_config()).ok() } else { None }, // ADR-044 §5.2: rolling-P95 over ~30 s at 20 Hz; warm-up after 60 samples. p95_variance: RollingP95::new(600, 60), p95_motion_band_power: RollingP95::new(600, 60), p95_spectral_power: RollingP95::new(600, 60), // ADR-044 §5.3: runtime-configurable dedup factor (persisted). dedup_factor: runtime_config.dedup_factor, data_dir: data_dir.clone(), })); // Start background tasks based on source match source { "esp32" => { tokio::spawn(udp_receiver_task(state.clone(), args.udp_port)); tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms)); } "wifi" => { tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms)); } _ => { tokio::spawn(simulated_data_task(state.clone(), args.tick_ms)); } } // ADR-050: Parse bind address once, use for all listeners let bind_ip: std::net::IpAddr = args .bind_addr .parse() .expect("Invalid --bind-addr (use 127.0.0.1 or 0.0.0.0)"); // #443: optional bearer-token auth on `/api/v1/*`. `RUVIEW_API_TOKEN` // unset/empty ⇒ middleware is a no-op (LAN-mode default preserved); set ⇒ // every `/api/v1/*` request must carry `Authorization: Bearer `. let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env(); if bearer_auth_state.is_enabled() { info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)"); if bind_ip.is_unspecified() { warn!( "API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments", bind_ip ); } } else { info!( "API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN= to enforce bearer auth." ); } // DNS-rebinding defense: validate the `Host` header against an allowlist // before any handler runs. Default is loopback-only (`localhost`, // `127.0.0.1`, `[::1]`, each with or without a port). Operators extend // the set via `--allowed-host` flags or the `SENSING_ALLOWED_HOSTS` env // var; `--disable-host-validation` opts out entirely for reverse-proxy // setups that already canonicalise `Host`. let host_allowlist = if args.disable_host_validation { warn!( "Host-header validation DISABLED — server is reachable via any Host. \ Only use this behind a reverse proxy that pins Host." ); wifi_densepose_sensing_server::host_validation::HostAllowlist::disabled() } else { let allowlist = wifi_densepose_sensing_server::host_validation::HostAllowlist::from_cli_and_env( args.allowed_hosts.iter().cloned(), ); info!( "Host-header validation ON ({} entries; loopback names always included)", allowlist.entries_for_test().len() ); allowlist }; // WebSocket server on dedicated port (8765) let ws_state = state.clone(); let ws_app = Router::new() .route("/ws/sensing", get(ws_sensing_handler)) .route("/health", get(health)) .layer(axum::middleware::from_fn_with_state( host_allowlist.clone(), wifi_densepose_sensing_server::host_validation::require_allowed_host, )) .with_state(ws_state); let ws_addr = SocketAddr::from((bind_ip, args.ws_port)); let ws_listener = tokio::net::TcpListener::bind(ws_addr) .await .expect("Failed to bind WebSocket port"); info!("WebSocket server listening on {ws_addr}"); tokio::spawn(async move { axum::serve(ws_listener, ws_app).await.unwrap(); }); // HTTP server (serves UI + full DensePose-compatible REST API) let ui_path = args.ui_path.clone(); let http_app = Router::new() .route("/", get(info_page)) // Health endpoints (DensePose-compatible) .route("/health", get(health)) .route("/health/health", get(health_system)) .route("/health/live", get(health_live)) .route("/health/ready", get(health_ready)) .route("/health/version", get(health_version)) .route("/health/metrics", get(health_metrics)) // API info .route("/api/v1/info", get(api_info)) .route("/api/v1/status", get(health_ready)) .route("/api/v1/metrics", get(health_metrics)) // Sensing endpoints .route("/api/v1/sensing/latest", get(latest)) // Per-node health endpoint .route("/api/v1/nodes", get(nodes_endpoint)) // ADR-110 iter 29 — per-node mesh sync state for HTTP clients. .route("/api/v1/nodes/:id/sync", get(node_sync_endpoint)) .route("/api/v1/mesh", get(mesh_endpoint)) .route("/api/v1/mesh/metrics", get(mesh_metrics_endpoint)) // Vital sign endpoints .route("/api/v1/vital-signs", get(vital_signs_endpoint)) .route("/api/v1/edge-vitals", get(edge_vitals_endpoint)) // ADR-102: Edge Module Registry — surfaces the canonical Cognitum cog // catalog (`https://storage.googleapis.com/cognitum-apps/app-registry.json`) // with in-process TTL cache + stale-on-error fallback. Disabled when // --no-edge-registry is set (returns 404). .route("/api/v1/edge/registry", get(edge_registry_endpoint)) .route("/api/v1/wasm-events", get(wasm_events_endpoint)) // RVF model container info .route("/api/v1/model/info", get(model_info)) // Progressive loading & SONA endpoints (Phase 7-8) .route("/api/v1/model/layers", get(model_layers)) .route("/api/v1/model/segments", get(model_segments)) .route("/api/v1/model/sona/profiles", get(sona_profiles)) .route("/api/v1/model/sona/activate", post(sona_activate)) // Pose endpoints (WiFi-derived) .route("/api/v1/pose/current", get(pose_current)) .route("/api/v1/pose/stats", get(pose_stats)) .route("/api/v1/pose/zones/summary", get(pose_zones_summary)) // Stream endpoints .route("/api/v1/stream/status", get(stream_status)) .route("/api/v1/stream/pose", get(ws_pose_handler)) // Sensing WebSocket on the HTTP port so the UI can reach it without a second port .route("/ws/sensing", get(ws_sensing_handler)) // ADR-099: real-time introspection — per-frame attractor + DTW snapshot. .route("/ws/introspection", get(ws_introspection_handler)) .route( "/api/v1/introspection/snapshot", get(api_introspection_snapshot), ) // Model management endpoints (UI compatibility) .route("/api/v1/models", get(list_models)) .route("/api/v1/models/active", get(get_active_model)) .route("/api/v1/models/load", post(load_model)) .route("/api/v1/models/unload", post(unload_model)) .route("/api/v1/models/{id}", delete(delete_model)) .route("/api/v1/models/lora/profiles", get(list_lora_profiles)) .route("/api/v1/models/lora/activate", post(activate_lora_profile)) // Recording endpoints .route("/api/v1/recording/list", get(list_recordings)) .route("/api/v1/recording/start", post(start_recording)) .route("/api/v1/recording/stop", post(stop_recording)) .route("/api/v1/recording/{id}", delete(delete_recording)) // Training endpoints .route("/api/v1/train/status", get(train_status)) .route("/api/v1/train/start", post(train_start)) .route("/api/v1/train/stop", post(train_stop)) // Adaptive classifier endpoints .route("/api/v1/adaptive/train", post(adaptive_train)) .route("/api/v1/adaptive/status", get(adaptive_status)) .route("/api/v1/adaptive/unload", post(adaptive_unload)) // Field model calibration (eigenvalue-based person counting) .route("/api/v1/calibration/start", post(calibration_start)) .route("/api/v1/calibration/stop", post(calibration_stop)) .route("/api/v1/calibration/status", get(calibration_status)) // ADR-044 §5.3: runtime-configurable dedup factor .route( "/api/v1/config/dedup-factor", get(config_get_dedup_factor).post(config_set_dedup_factor), ) .route("/api/v1/config/ground-truth", post(config_set_ground_truth)) // Static UI files .nest_service("/ui", ServeDir::new(&ui_path)) // ADR-102: make the edge registry handle (Option>) // available to the /api/v1/edge/registry handler. None when disabled. .layer(Extension(edge_registry.clone())) .layer(SetResponseHeaderLayer::overriding( axum::http::header::CACHE_CONTROL, HeaderValue::from_static("no-cache, no-store, must-revalidate"), )) // Opt-in bearer-token auth on `/api/v1/*` (#443). When `RUVIEW_API_TOKEN` // is unset/empty the middleware is a no-op — the default stays // LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never // gated (orchestrator probes + local browsers). .layer(axum::middleware::from_fn_with_state( bearer_auth_state.clone(), wifi_densepose_sensing_server::bearer_auth::require_bearer, )) // DNS-rebinding defense: applied last so it runs first on the request // path (axum layers run outermost-in). Rejects requests whose `Host` // header is not in the allowlist before any handler — including // `/health` and `/ws/*` — observes the body. .layer(axum::middleware::from_fn_with_state( host_allowlist.clone(), wifi_densepose_sensing_server::host_validation::require_allowed_host, )) .with_state(state.clone()); let http_addr = SocketAddr::from((bind_ip, args.http_port)); let http_listener = tokio::net::TcpListener::bind(http_addr) .await .expect("Failed to bind HTTP port"); info!("HTTP server listening on {http_addr}"); info!( "Open http://localhost:{}/ui/index.html in your browser", args.http_port ); // Run the HTTP server with graceful shutdown support let shutdown_state = state.clone(); let server = axum::serve(http_listener, http_app).with_graceful_shutdown(async { tokio::signal::ctrl_c() .await .expect("failed to install CTRL+C handler"); info!("Shutdown signal received"); }); server.await.unwrap(); // Save RVF container on shutdown if --save-rvf was specified let s = shutdown_state.read().await; if let Some(ref save_path) = s.save_rvf_path { info!("Saving RVF container to {}", save_path.display()); let mut builder = RvfBuilder::new(); builder.add_manifest( "wifi-densepose-sensing", env!("CARGO_PKG_VERSION"), "WiFi DensePose sensing model state", ); builder.add_metadata(&serde_json::json!({ "source": s.effective_source(), "total_ticks": s.tick, "total_detections": s.total_detections, "uptime_secs": s.start_time.elapsed().as_secs(), })); builder.add_vital_config(&VitalSignConfig::default()); // Save transformer weights if a model is loaded, otherwise empty let weights: Vec = if s.model_loaded { // If we loaded via --model, the progressive loader has the weights // For now, save runtime state placeholder let tf = graph_transformer::CsiToPoseTransformer::new(Default::default()); tf.flatten_weights() } else { Vec::new() }; builder.add_weights(&weights); match builder.write_to_file(save_path) { Ok(()) => info!(" RVF saved ({} weight params)", weights.len()), Err(e) => error!(" Failed to save RVF: {e}"), } } info!("Server shut down cleanly"); } #[cfg(test)] mod node_sync_snapshot_serialization_tests { //! ADR-110 iter 24 — JSON public-API contract for the iter 23 //! NodeSyncSnapshot field. Any future rename / removal here must be //! intentional and update both Rust + UI/automation consumers. use super::*; fn sample_sync() -> NodeSyncSnapshot { NodeSyncSnapshot { offset_us: 1_163_565, is_leader: false, is_valid: true, smoothed: true, sequence: 20, csi_fps_ema: 10.0, csi_fps_samples: 47, staleness_ms: Some(120), } } fn sample_node(sync: Option) -> NodeInfo { NodeInfo { node_id: 9, rssi_dbm: -38.0, position: [2.0, 0.0, 1.5], amplitude: vec![], subcarrier_count: 0, sync, } } #[test] fn sync_present_serializes_all_seven_fields() { let v = serde_json::to_value(sample_node(Some(sample_sync()))).unwrap(); let s = v.get("sync").expect("sync key must be present"); // All eight contract fields named exactly as iter 23/34 documented. for key in ["offset_us", "is_leader", "is_valid", "smoothed", "sequence", "csi_fps_ema", "csi_fps_samples", "staleness_ms"] { assert!(s.get(key).is_some(), "sync object missing field `{}` — UI contract broken", key); } // Spot-check values round-trip. assert_eq!(s["offset_us"], 1_163_565); assert_eq!(s["is_leader"], false); assert_eq!(s["sequence"], 20); assert_eq!(s["csi_fps_samples"], 47); } #[test] fn sync_absent_omits_the_key_entirely() { // skip_serializing_if = "Option::is_none" must drop the key, not // emit `"sync": null`. The non-mesh paths rely on this for // backwards compatibility with pre-iter-23 UI clients. let v = serde_json::to_value(sample_node(None)).unwrap(); assert!(v.get("sync").is_none(), "expected `sync` key omitted when None, got {:?}", v.get("sync")); // The base NodeInfo fields are still there. assert_eq!(v["node_id"], 9); assert_eq!(v["rssi_dbm"], -38.0); } #[test] fn sync_round_trips_through_serde() { let original = sample_node(Some(sample_sync())); let json = serde_json::to_string(&original).unwrap(); let parsed: NodeInfo = serde_json::from_str(&json).unwrap(); // Field-level equality on the sync sub-object. let s_orig = original.sync.unwrap(); let s_parsed = parsed.sync.expect("sync should survive round-trip"); assert_eq!(s_parsed.offset_us, s_orig.offset_us); assert_eq!(s_parsed.is_leader, s_orig.is_leader); assert_eq!(s_parsed.is_valid, s_orig.is_valid); assert_eq!(s_parsed.smoothed, s_orig.smoothed); assert_eq!(s_parsed.sequence, s_orig.sequence); assert!((s_parsed.csi_fps_ema - s_orig.csi_fps_ema).abs() < 1e-9); assert_eq!(s_parsed.csi_fps_samples, s_orig.csi_fps_samples); } } #[cfg(test)] mod sync_snapshot_helper_tests { //! ADR-110 iter 30 — covers the pure helper that backs both //! `/api/v1/nodes/:id/sync` and `/api/v1/mesh` REST endpoints and //! the WebSocket sensing_update broadcast. Tests at this layer keep //! the public-API contract honest without spinning up the axum //! router or constructing a full AppStateInner. use super::*; use wifi_densepose_hardware::{SyncPacket, SyncPacketFlags}; fn populated_sync(node_id: u8) -> SyncPacket { SyncPacket { node_id, proto_ver: 1, flags: SyncPacketFlags { is_leader: false, is_valid: true, smoothed_used: true }, local_us: 28_798_450, epoch_us: 27_634_885, sequence: 20, } } #[test] fn fresh_node_with_no_sync_returns_none() { // Mirrors the REST 404 "no_sync" branch. let ns = NodeState::new(); assert!(ns.sync_snapshot().is_none()); } #[test] fn node_with_latest_sync_produces_correct_snapshot() { // Mirrors the REST 200 OK branch + the WebSocket sync field. let mut ns = NodeState::new(); ns.latest_sync = Some(populated_sync(9)); ns.latest_sync_at = Some(std::time::Instant::now()); // Pretend the fps EMA has settled (iter 18 5-sample warmup). ns.csi_fps_ema = 10.5; ns.csi_fps_samples = 42; let snap = ns.sync_snapshot().expect("populated state must produce a snapshot"); assert_eq!(snap.offset_us, 1_163_565); // §A0.10 measured boot delta assert!(!snap.is_leader); assert!(snap.is_valid); assert!(snap.smoothed); assert_eq!(snap.sequence, 20); assert!((snap.csi_fps_ema - 10.5).abs() < 1e-9); assert_eq!(snap.csi_fps_samples, 42); } #[test] fn apply_sync_packet_populates_a_fresh_node() { // Mirrors what udp_receiver_task does on the very first sync // packet from a previously-unseen node. let mut ns = NodeState::new(); assert!(ns.latest_sync.is_none()); assert!(ns.latest_sync_at.is_none()); let now = std::time::Instant::now(); ns.apply_sync_packet(populated_sync(9), now); let sync = ns.latest_sync.as_ref().expect("must be populated"); assert_eq!(sync.node_id, 9); assert_eq!(sync.sequence, 20); // latest_sync_at must be exactly the Instant we passed (no clock skew). assert_eq!(ns.latest_sync_at, Some(now)); // sync_snapshot now produces a value (REST 200 OK path). assert!(ns.sync_snapshot().is_some()); } #[test] fn apply_sync_packet_overwrites_older_data() { // Subsequent packets must replace, not accumulate. Otherwise the // §A0.10-smoothed offset would lag the latest beacon. let mut ns = NodeState::new(); let t0 = std::time::Instant::now(); ns.apply_sync_packet(populated_sync(9), t0); // Second packet: same node, advanced sequence + offset. let mut second = populated_sync(9); second.sequence = 40; second.local_us = 30_000_000; second.epoch_us = 28_834_900; let t1 = t0 + std::time::Duration::from_secs(2); ns.apply_sync_packet(second, t1); let cur = ns.latest_sync.as_ref().unwrap(); assert_eq!(cur.sequence, 40); // newer sequence persisted assert_eq!(cur.local_us, 30_000_000); // newer local persisted assert_eq!(ns.latest_sync_at, Some(t1)); // staleness clock reset } #[test] fn snapshot_staleness_ms_tracks_apply_time() { // Iter 34: staleness_ms = (Instant::now() - latest_sync_at).as_millis(). // We can't pass a synthetic "now" through sync_snapshot, but we can // pin latest_sync_at to a past instant and assert the value lands // in a plausible window. let mut ns = NodeState::new(); ns.latest_sync = Some(populated_sync(9)); ns.latest_sync_at = std::time::Instant::now() .checked_sub(std::time::Duration::from_millis(750)); let snap = ns.sync_snapshot().unwrap(); let st = snap.staleness_ms.expect("staleness_ms must be present"); // Should be approximately 750 ms — give a generous ±500 ms tolerance // for any test-runner scheduling delay between checked_sub() and // elapsed() within sync_snapshot. assert!(st >= 740 && st < 1250, "expected ~750 ms staleness, got {} ms", st); } #[test] fn fleet_role_counts_classifies_correctly() { // Iter 37 — verify the leader/follower split that drives the // Prometheus `wifi_densepose_mesh_node_total{state=...}` gauge. // Local fixture rather than reaching across test modules. fn snap(is_leader: bool) -> NodeSyncSnapshot { NodeSyncSnapshot { offset_us: 0, is_leader, is_valid: true, smoothed: true, sequence: 0, csi_fps_ema: 10.0, csi_fps_samples: 10, staleness_ms: Some(0), } } assert_eq!(super::fleet_role_counts(&[]), (0, 0)); let snaps = vec![(12u8, snap(true)), (9, snap(false)), (3, snap(false))]; assert_eq!(super::fleet_role_counts(&snaps), (1, 2)); // Edge: all leaders (election would prevent this but gauge math must hold). assert_eq!(super::fleet_role_counts(&[(1u8, snap(true)), (2, snap(true))]), (2, 0)); } #[test] fn bool_metric_returns_zero_or_one_as_text() { // Locks the Prometheus exposition convention: gauges holding a // boolean state MUST emit literal "0" or "1", never "false"/"true". // If anyone changes the helper to format!("{}", b), Prometheus will // 400-reject the scrape — catch it here instead of in production. assert_eq!(super::bool_metric(true), "1"); assert_eq!(super::bool_metric(false), "0"); } #[test] fn mesh_aligned_us_honors_9s_staleness_gate() { // The receive helper stores latest_sync_at = Instant::now() each // beacon. mesh_aligned_us_for_csi_frame returns None once that // Instant is older than 9 s (3 × VALID_WINDOW_MS). Verify both // sides of that boundary without sleeping — set latest_sync_at // to past instants directly. let mut ns = NodeState::new(); let now = std::time::Instant::now(); ns.latest_sync = Some(populated_sync(9)); // Fresh: 1 s old → should return Some. ns.latest_sync_at = now.checked_sub(std::time::Duration::from_secs(1)); assert!(ns.mesh_aligned_us_for_csi_frame(20).is_some(), "1 s old sync must produce a mesh-aligned timestamp"); // Just inside the gate: 8 s old → should still return Some. ns.latest_sync_at = now.checked_sub(std::time::Duration::from_secs(8)); assert!(ns.mesh_aligned_us_for_csi_frame(20).is_some(), "8 s old sync must still be inside the 9 s gate"); // Just outside the gate: 10 s old → must return None. ns.latest_sync_at = now.checked_sub(std::time::Duration::from_secs(10)); assert!(ns.mesh_aligned_us_for_csi_frame(20).is_none(), "10 s old sync must trigger the 9 s staleness gate"); } #[test] fn snapshot_reflects_leader_state() { // Same data shape that /api/v1/mesh emits for a leader node. let mut ns = NodeState::new(); let mut s = populated_sync(12); s.flags = SyncPacketFlags { is_leader: true, is_valid: true, smoothed_used: false }; s.local_us = 28_864_932; s.epoch_us = 28_864_939; // -7 µs delta on the leader ns.latest_sync = Some(s); ns.latest_sync_at = Some(std::time::Instant::now()); let snap = ns.sync_snapshot().unwrap(); assert!(snap.is_leader); assert_eq!(snap.offset_us, -7); // call-stack µs only assert!(!snap.smoothed); } } #[cfg(test)] mod novelty_tests { use super::*; /// First call to `update_novelty` must produce *some* score /// (`Some(_)` not `None`) — proves the per-node sketch bank is /// initialised by `NodeState::new()` and the novelty path is /// actually being exercised. With an empty bank the score is 1.0 /// (max novelty). #[test] fn first_frame_yields_max_novelty_then_zero_on_repeat() { let mut ns = NodeState::new(); let amplitudes: Vec = (0..NOVELTY_VECTOR_DIM).map(|i| (i as f64).sin()).collect(); ns.update_novelty(&litudes); let first = ns.last_novelty_score.expect("sketch bank initialised"); assert!( (first - 1.0).abs() < 1e-6, "empty bank → max novelty 1.0, got {first}" ); // Repeat the exact same frame — bank now contains it, so the // novelty score must be 0.0 (the score is computed before the // second insert, against the post-first-insert bank). ns.update_novelty(&litudes); let second = ns.last_novelty_score.expect("score stays Some"); assert_eq!(second, 0.0, "exact-repeat frame → novelty 0.0"); } /// `update_novelty` must tolerate amplitude vectors of unexpected /// length — short ones zero-padded, long ones truncated — without /// panicking. ESP32-S3 boards report 56 subcarriers but other /// hardware variants ship 52 or 64; the schema-locked sketch bank /// requires exactly NOVELTY_VECTOR_DIM. #[test] fn handles_short_and_long_amplitude_vectors() { let mut ns = NodeState::new(); ns.update_novelty(&[1.0, 2.0]); // way short assert!(ns.last_novelty_score.is_some()); let too_long: Vec = (0..NOVELTY_VECTOR_DIM * 2).map(|i| i as f64).collect(); ns.update_novelty(&too_long); // way long assert!(ns.last_novelty_score.is_some()); } } // ── ADR-044 §5.3: dedup_factor runtime configuration endpoints ──────────────── /// `GET /api/v1/config/dedup-factor` — read the current dedup factor. async fn config_get_dedup_factor(State(state): State) -> Json { let s = state.read().await; Json(serde_json::json!({ "dedup_factor": s.dedup_factor, "description": "Divisor for multi-node person count deduplication (sum / factor). Range: 1.0–10.0." })) } /// `POST /api/v1/config/dedup-factor` — set the dedup factor (clamped 1.0–10.0). /// /// Body: `{ "value": }` async fn config_set_dedup_factor( State(state): State, Json(body): Json, ) -> Json { let value = body.get("value").and_then(|v| v.as_f64()).unwrap_or(3.0); let clamped = value.clamp(1.0, 10.0); let mut s = state.write().await; s.dedup_factor = clamped; let data_dir = s.data_dir.clone(); drop(s); save_runtime_config( &data_dir, &RuntimeConfig { dedup_factor: clamped, }, ); Json(serde_json::json!({ "status": "ok", "dedup_factor": clamped, })) } /// `POST /api/v1/config/ground-truth` — auto-tune dedup factor from a known person count. /// /// Derives `dedup_factor = raw_node_sum / ground_truth_count` from the current /// per-node person counts, clamped to [1.0, 10.0]. Persisted immediately. /// /// Body: `{ "count": }` async fn config_set_ground_truth( State(state): State, Json(body): Json, ) -> Json { let ground_truth = match body.get("count").and_then(|v| v.as_u64()) { Some(n) if n > 0 => n as usize, _ => return Json(serde_json::json!({"error": "count must be a positive integer"})), }; let mut s = state.write().await; let raw_sum: usize = s .node_states .values() .filter(|ns| { ns.last_frame_time .map(|t| t.elapsed() < std::time::Duration::from_secs(10)) .unwrap_or(false) }) .map(|ns| ns.prev_person_count) .sum(); let optimal = if raw_sum > 0 { (raw_sum as f64) / (ground_truth as f64) } else { 3.0 }; let clamped = optimal.clamp(1.0, 10.0); s.dedup_factor = clamped; let data_dir = s.data_dir.clone(); drop(s); save_runtime_config( &data_dir, &RuntimeConfig { dedup_factor: clamped, }, ); Json(serde_json::json!({ "status": "ok", "ground_truth": ground_truth, "raw_sum": raw_sum, "computed_dedup_factor": clamped, })) } // ── Unit tests: RollingP95 ───────────────────────────────────────────────────── #[cfg(test)] mod rolling_p95_tests { use super::RollingP95; #[test] fn cold_start_returns_none() { let p = RollingP95::new(100, 10); assert!(p.current().is_none(), "empty buffer must return None"); } #[test] fn below_min_samples_returns_none() { let mut p = RollingP95::new(100, 10); for i in 1..=9 { p.push(i as f64); } assert!( p.current().is_none(), "fewer than min_samples must return None" ); } #[test] fn p95_of_ramp_is_near_95() { let mut p = RollingP95::new(100, 10); for i in 1..=100 { p.push(i as f64); } let p95 = p.current().expect("should have value after 100 samples"); assert!( (94.0..=96.0).contains(&p95), "P95 of 1..=100 should be ~95, got {p95}" ); } #[test] fn window_slides_evicts_oldest() { let mut p = RollingP95::new(5, 3); // Push 1..=5, then 100 — oldest (1) is evicted. for i in 1..=5 { p.push(i as f64); } p.push(100.0); // evicts 1; buf = [2, 3, 4, 5, 100] let p95 = p.current().expect("6 pushes, window=5 → 5 samples"); // P95 of [2,3,4,5,100]: idx = ceil(5*0.95)=5 → sorted[4]=100 assert_eq!( p95, 100.0, "largest value should dominate p95 after eviction" ); } #[test] fn len_reports_buffer_size() { let mut p = RollingP95::new(10, 5); assert_eq!(p.len(), 0); p.push(1.0); assert_eq!(p.len(), 1); } } #[cfg(all(test, feature = "mqtt"))] mod mqtt_bridge_tests { use super::vitals_snapshots_from_sensing_json; use serde_json::json; /// Regression for the per-node presence bug (#872/#898): each node must /// surface its OWN classification, not the room-level aggregate. Node 1 is /// present+moving; node 2 is absent — node 2 must NOT inherit node 1's /// "present". #[test] fn per_node_presence_uses_each_nodes_own_classification() { let v = json!({ "timestamp": 1.0, "classification": { "presence": true, "motion_level": "walking", "confidence": 0.9 }, "vital_signs": { "breathing_rate_bpm": 14.0, "heart_rate_bpm": 60.0 }, "persons": [{}, {}], "nodes": [ { "node_id": 1, "rssi_dbm": -40.0, "classification": { "presence": true, "motion_level": "walking", "confidence": 0.8 } }, { "node_id": 2, "rssi_dbm": -70.0, "classification": { "presence": false, "motion_level": "absent", "confidence": 0.1 } } ] }); let snaps = vitals_snapshots_from_sensing_json(&v, "ruview"); assert_eq!(snaps.len(), 2, "one snapshot per node"); let n1 = snaps.iter().find(|s| s.node_id == "ruview-node1").unwrap(); let n2 = snaps.iter().find(|s| s.node_id == "ruview-node2").unwrap(); assert!(n1.presence && n1.motion > 0.0, "node1 present + moving"); assert!( !n2.presence && n2.motion == 0.0, "node2 must be absent — not inherit the room aggregate" ); // Per-node RSSI preserved. assert_eq!(n1.rssi_dbm, Some(-40.0)); assert_eq!(n2.rssi_dbm, Some(-70.0)); // Vitals + person count are room-level, shared across node devices. assert_eq!(n1.n_persons, 2); assert_eq!(n2.n_persons, 2); assert_eq!(n1.breathing_rate_bpm, Some(14.0)); assert_eq!(n2.heartrate_bpm, Some(60.0)); // presence_score is gated on presence. assert!(n1.presence_score > 0.0); assert_eq!(n2.presence_score, 0.0); } /// A node that omits a classification field defers to the room aggregate /// rather than silently reading false/0. #[test] fn per_node_missing_fields_fall_back_to_aggregate() { let v = json!({ "timestamp": 1.0, "classification": { "presence": true, "motion_level": "still", "confidence": 0.7 }, "vital_signs": {}, "nodes": [ { "node_id": 3, "rssi_dbm": -55.0 } ] // no per-node classification }); let snaps = vitals_snapshots_from_sensing_json(&v, "n"); assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].node_id, "n-node3"); assert!(snaps[0].presence, "defers to aggregate presence"); assert_eq!(snaps[0].motion, 0.0, "aggregate 'still' => no motion"); } /// No `nodes` array (wifi / simulate sources): single aggregate snapshot /// keyed by the base id. #[test] fn falls_back_to_single_aggregate_when_no_nodes() { let v = json!({ "timestamp": 2.0, "classification": { "presence": true, "motion_level": "idle", "confidence": 0.6 }, "vital_signs": { "breathing_rate_bpm": 12.0 }, "persons": [{}] }); let snaps = vitals_snapshots_from_sensing_json(&v, "ruview"); assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].node_id, "ruview"); assert!(snaps[0].presence); assert_eq!(snaps[0].motion, 0.0, "idle => no motion"); assert_eq!(snaps[0].n_persons, 1); } /// `motion_level: "absent"` must map to zero motion (the old aggregate /// match fell through to `Some(_) => 1.0`, treating absent as full motion). #[test] fn absent_motion_level_is_zero_motion() { let v = json!({ "timestamp": 0.0, "classification": { "presence": false, "motion_level": "absent", "confidence": 0.0 }, "vital_signs": {} }); let snaps = vitals_snapshots_from_sensing_json(&v, "x"); assert_eq!(snaps[0].motion, 0.0); assert!(!snaps[0].presence); } } #[cfg(test)] mod model_load_diagnostic_tests { use super::diagnose_model_load_error; use std::path::Path; #[test] fn safetensors_is_named_and_points_at_894() { // 8-byte LE header length then '{' — the safetensors signature. let data = [0x10, 0, 0, 0, 0, 0, 0, 0, b'{', b'"']; let msg = diagnose_model_load_error( Path::new("models/wifi-densepose-pretrained/model.safetensors"), &data, "invalid magic at offset 0", ); assert!(msg.contains("safetensors"), "{msg}"); assert!(msg.contains("#894"), "{msg}"); assert!(msg.contains("signal heuristics"), "{msg}"); } #[test] fn quantized_bin_is_identified() { let data = [0x35, 0x57, 0x45, 0x77]; // the 0x77455735 the loader reports let msg = diagnose_model_load_error(Path::new("model-q4.bin"), &data, "bad magic"); assert!(msg.contains("quantized weight blob"), "{msg}"); assert!(msg.contains("RVFS") || msg.contains("0x52564653"), "{msg}"); } #[test] fn jsonl_manifest_is_identified() { let data = *b"{\"seg\":0}"; let msg = diagnose_model_load_error(Path::new("model.rvf.jsonl"), &data, "x"); assert!(msg.contains("JSONL manifest"), "{msg}"); } #[test] fn unknown_format_still_gives_guidance() { let data = [0u8, 1, 2, 3]; let msg = diagnose_model_load_error(Path::new("weird.dat"), &data, "x"); assert!(msg.contains("RVF binary container"), "{msg}"); assert!(msg.contains("wifi-densepose-train"), "{msg}"); } } #[cfg(test)] mod export_rvf_mode_tests { use super::export_emits_placeholder_demo; #[test] fn standalone_export_emits_placeholder() { // --export-rvf alone → the container-format demo (placeholder weights). assert!(export_emits_placeholder_demo(true, false, false)); } #[test] fn export_with_train_does_not_short_circuit() { // #894: `--train --export-rvf` must NOT emit a placeholder + skip // training — it must fall through to the real training pipeline. assert!(!export_emits_placeholder_demo(true, true, false)); assert!(!export_emits_placeholder_demo(true, false, true)); assert!(!export_emits_placeholder_demo(true, true, true)); } #[test] fn no_export_flag_never_emits() { assert!(!export_emits_placeholder_demo(false, false, false)); assert!(!export_emits_placeholder_demo(false, true, false)); } }