wifi-densepose/v2/crates/wifi-densepose-sensing-server/src/main.rs

7591 lines
298 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String>,
/// 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<PathBuf>,
/// Save current model state as an RVF container on shutdown
#[arg(long, value_name = "PATH")]
save_rvf: Option<PathBuf>,
/// Load a trained .rvf model for inference
#[arg(long, value_name = "PATH")]
model: Option<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<String>,
/// Node positions for multistatic fusion (format: "x,y,z;x,y,z;...")
#[arg(long, env = "SENSING_NODE_POSITIONS")]
node_positions: Option<String>,
/// 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<f64>,
phases: Vec<f64>,
}
/// 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<NodeInfo>,
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<VitalSigns>,
// ── 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<serde_json::Value>,
/// Enhanced breathing estimate from multi-BSSID pipeline.
#[serde(skip_serializing_if = "Option::is_none")]
enhanced_breathing: Option<serde_json::Value>,
/// Posture classification from BSSID fingerprint matching.
#[serde(skip_serializing_if = "Option::is_none")]
posture: Option<String>,
/// Signal quality score from multi-BSSID quality gate [0.0, 1.0].
#[serde(skip_serializing_if = "Option::is_none")]
signal_quality_score: Option<f64>,
/// Quality gate verdict: "Permit", "Warn", or "Deny".
#[serde(skip_serializing_if = "Option::is_none")]
quality_verdict: Option<String>,
/// Number of BSSIDs used in the enhanced sensing cycle.
#[serde(skip_serializing_if = "Option::is_none")]
bssid_count: Option<usize>,
// ── 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<Vec<[f64; 4]>>,
/// Model status when a trained model is loaded.
#[serde(skip_serializing_if = "Option::is_none")]
model_status: Option<serde_json::Value>,
// ── Multi-person detection (issue #97) ──
/// Detected persons from WiFi sensing (multi-person support).
#[serde(skip_serializing_if = "Option::is_none")]
persons: Option<Vec<PersonDetection>>,
/// Estimated person count from CSI feature heuristics (1-3 for single ESP32).
#[serde(skip_serializing_if = "Option::is_none")]
estimated_persons: Option<usize>,
/// Per-node feature breakdown for multi-node deployments.
#[serde(skip_serializing_if = "Option::is_none")]
node_features: Option<Vec<PerNodeFeatureInfo>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct NodeInfo {
node_id: u8,
rssi_dbm: f64,
position: [f64; 3],
amplitude: Vec<f64>,
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<NodeSyncSnapshot>,
}
/// 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<u64>,
}
#[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<f64>,
}
/// 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<PoseKeypoint>,
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<Vec<f64>>,
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<f64>,
br_buffer: VecDeque<f64>,
rssi_history: VecDeque<f64>,
vital_detector: VitalSignDetector,
latest_vitals: VitalSigns,
pub(crate) last_frame_time: Option<std::time::Instant>,
edge_vitals: Option<Esp32VitalsPacket>,
/// 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<wifi_densepose_hardware::SyncPacket>,
/// Last time a sync packet from this node was received (for staleness).
latest_sync_at: Option<std::time::Instant>,
/// 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<FeatureInfo>,
// ── RuVector Phase 2: Temporal smoothing & coherence gating ──
/// Previous frame's smoothed keypoint positions for EMA temporal smoothing.
prev_keypoints: Option<Vec<[f64; 3]>>,
/// Rolling buffer of motion_energy values for coherence scoring (last 20 frames).
motion_energy_history: VecDeque<f64>,
/// 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<wifi_densepose_signal::ruvsense::longitudinal::EmbeddingHistory>,
/// 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<f32>,
}
/// 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<f64> {
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<u64> {
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<u64> {
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<NodeSyncSnapshot> {
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<f32> = 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::<f64>() / n as f64;
let variance: f64 = self
.motion_energy_history
.iter()
.map(|v| (v - mean) * (v - mean))
.sum::<f64>()
/ (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<f32>,
}
/// 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<u8, NodeState>,
now: std::time::Instant,
) -> Option<Vec<PerNodeFeatureInfo>> {
if node_states.is_empty() {
return None;
}
let entries: Vec<PerNodeFeatureInfo> = 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<f64>,
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<f64> {
if self.buf.len() < self.min_samples {
return None;
}
let mut sorted: Vec<f64> = 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 `<data_dir>/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 `<data_dir>/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<SensingUpdate>,
rssi_history: VecDeque<f64>,
/// 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<Vec<f64>>,
tick: u64,
source: String,
/// Instant of the last ESP32 UDP frame received (for offline detection).
last_esp32_frame: Option<std::time::Instant>,
tx: broadcast::Sender<String>,
// 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<String>,
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<RvfContainerInfo>,
/// Path to save RVF container on shutdown (set via `--save-rvf`).
save_rvf_path: Option<PathBuf>,
/// Progressive loader for a trained model (set via `--model`).
progressive_loader: Option<ProgressiveLoader>,
/// Active SONA profile name.
active_sona_profile: Option<String>,
/// 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<f64>,
/// Median filter buffer for BR.
br_buffer: VecDeque<f64>,
/// ADR-039: Latest edge vitals packet from ESP32.
edge_vitals: Option<Esp32VitalsPacket>,
/// ADR-040: Latest WASM output packet from ESP32.
latest_wasm_events: Option<WasmOutputPacket>,
// ── Model management fields ─────────────────────────────────────────────
/// Discovered RVF model files from `data/models/`.
discovered_models: Vec<serde_json::Value>,
/// ID of the currently loaded model, if any.
active_model_id: Option<String>,
// ── Recording fields ────────────────────────────────────────────────────
/// Metadata for recorded CSI data files.
recordings: Vec<serde_json::Value>,
/// Whether CSI recording is currently in progress.
recording_active: bool,
/// When the current recording started.
recording_start_time: Option<std::time::Instant>,
/// ID of the current recording (used for filename).
recording_current_id: Option<String>,
/// Shutdown signal for the recording writer task.
recording_stop_tx: Option<tokio::sync::watch::Sender<bool>>,
// ── Training fields ─────────────────────────────────────────────────────
/// Training status: "idle", "running", "completed", "failed".
training_status: String,
/// Training configuration, if any.
training_config: Option<serde_json::Value>,
// ── Adaptive classifier (environment-tuned) ──────────────────────────
/// Trained adaptive model (loaded from data/adaptive_model.json or trained at runtime).
adaptive_model: Option<adaptive_classifier::AdaptiveModel>,
// ── 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<u8, NodeState>,
// ── 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<std::time::Instant>,
/// 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<FieldModel>,
// ── 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<RwLock<AppStateInner>>;
// ── 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<Esp32VitalsPacket> {
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<WasmEvent>,
}
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
/// the original 0xC511_0004 was a collision with ADR-063 fused vitals).
fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
if buf.len() < 8 {
return None;
}
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != 0xC511_0007 {
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<EdgeFusedVitalsPacket> {
if buf.len() < 48 {
return None;
}
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
if magic != 0xC511_0004 {
return None;
}
let node_id = buf[4];
let flags = buf[5];
let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]);
let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let rssi = buf[12] as i8;
let n_persons = buf[13];
let mmwave_type = buf[14];
let fusion_confidence = buf[15];
let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]);
let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]);
let mmwave_hr_bpm = f32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]);
let mmwave_br_bpm = f32::from_le_bytes([buf[32], buf[33], buf[34], buf[35]]);
let mmwave_distance_cm = f32::from_le_bytes([buf[36], buf[37], buf[38], buf[39]]);
let mmwave_targets = buf[40];
let mmwave_confidence = buf[41];
// buf[42..48] are firmware reserved fields (reserved3 u16 + reserved4 u32).
Some(EdgeFusedVitalsPacket {
node_id,
flags,
breathing_rate_bpm: breathing_raw as f32 / 100.0,
heartrate_bpm: heartrate_raw as f32 / 10000.0,
rssi,
n_persons,
mmwave_type,
fusion_confidence,
motion_energy,
presence_score,
timestamp_ms,
mmwave_hr_bpm,
mmwave_br_bpm,
mmwave_distance_cm,
mmwave_targets,
mmwave_confidence,
})
}
#[cfg(test)]
mod issue_928_magic_collision_tests {
//! Issue #928 — `0xC511_0004` was being parsed as WASM output, eating the
//! C6+mmWave fused-vitals packets. After this fix, `0xC511_0004` routes to
//! `parse_edge_fused_vitals` and WASM output owns the freshly-allocated
//! `0xC511_0007` slot. Tests guard both halves of the swap.
use super::*;
/// Build a 48-byte synthetic fused-vitals packet matching the firmware's
/// `edge_fused_vitals_pkt_t` layout from `edge_processing.h:129`.
fn build_fused_vitals_packet() -> Vec<u8> {
let mut buf = vec![0u8; 48];
buf[0..4].copy_from_slice(&0xC511_0004u32.to_le_bytes());
buf[4] = 9; // node_id
buf[5] = 0b0000_1001; // flags: presence | mmwave_present
buf[6..8].copy_from_slice(&1600u16.to_le_bytes()); // breathing 16.00 BPM
buf[8..12].copy_from_slice(&720_000u32.to_le_bytes()); // heartrate 72.0 BPM
buf[12] = (-55i8) as u8; // rssi
buf[13] = 1; // n_persons
buf[14] = 2; // mmwave_type
buf[15] = 85; // fusion_confidence
buf[16..20].copy_from_slice(&0.42f32.to_le_bytes()); // motion_energy
buf[20..24].copy_from_slice(&0.95f32.to_le_bytes()); // presence_score
buf[24..28].copy_from_slice(&1_234_567u32.to_le_bytes()); // timestamp_ms
buf[28..32].copy_from_slice(&71.5f32.to_le_bytes()); // mmwave_hr_bpm
buf[32..36].copy_from_slice(&15.8f32.to_le_bytes()); // mmwave_br_bpm
buf[36..40].copy_from_slice(&182.0f32.to_le_bytes()); // mmwave_distance_cm
buf[40] = 1; // mmwave_targets
buf[41] = 90; // mmwave_confidence
// bytes 42..48 — firmware reserved fields, left as zero
buf
}
#[test]
fn parse_edge_fused_vitals_extracts_fields_correctly() {
let buf = build_fused_vitals_packet();
let pkt = parse_edge_fused_vitals(&buf).expect("must parse a well-formed packet");
assert_eq!(pkt.node_id, 9);
assert_eq!(pkt.flags, 0b0000_1001);
assert!((pkt.breathing_rate_bpm - 16.0).abs() < 1e-3, "breathing scale 100");
assert!((pkt.heartrate_bpm - 72.0).abs() < 1e-3, "heartrate scale 10000");
assert_eq!(pkt.rssi, -55);
assert_eq!(pkt.n_persons, 1);
assert_eq!(pkt.mmwave_type, 2);
assert_eq!(pkt.fusion_confidence, 85);
assert!((pkt.motion_energy - 0.42).abs() < 1e-6);
assert!((pkt.presence_score - 0.95).abs() < 1e-6);
assert_eq!(pkt.timestamp_ms, 1_234_567);
assert!((pkt.mmwave_hr_bpm - 71.5).abs() < 1e-6);
assert!((pkt.mmwave_br_bpm - 15.8).abs() < 1e-3);
assert!((pkt.mmwave_distance_cm - 182.0).abs() < 1e-6);
assert_eq!(pkt.mmwave_targets, 1);
assert_eq!(pkt.mmwave_confidence, 90);
}
#[test]
fn parse_edge_fused_vitals_rejects_short_buffer() {
let buf = build_fused_vitals_packet();
// Truncate to 47 bytes — one short of the 48-byte minimum.
assert!(parse_edge_fused_vitals(&buf[..47]).is_none());
}
#[test]
fn parse_edge_fused_vitals_rejects_wrong_magic() {
let mut buf = build_fused_vitals_packet();
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes()); // WASM magic, not fused
assert!(parse_edge_fused_vitals(&buf).is_none());
}
#[test]
fn parse_wasm_output_rejects_legacy_0004_magic() {
// The old WASM magic collided with fused vitals — must no longer be
// accepted. A real fused-vitals packet starts with 0xC511_0004 and
// would have been misparsed before this fix.
let buf = build_fused_vitals_packet();
assert!(parse_wasm_output(&buf).is_none(),
"issue #928: WASM parser must NOT accept 0xC511_0004");
}
#[test]
fn parse_wasm_output_accepts_new_0007_magic() {
// Build a tiny well-formed WASM output packet on the new magic.
let mut buf = vec![0u8; 8];
buf[0..4].copy_from_slice(&0xC511_0007u32.to_le_bytes());
buf[4] = 5; // node_id
buf[5] = 1; // module_id
buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // event_count = 0
let pkt = parse_wasm_output(&buf).expect("0xC511_0007 must parse");
assert_eq!(pkt.node_id, 5);
assert_eq!(pkt.module_id, 1);
assert!(pkt.events.is_empty());
}
}
// ── ESP32 UDP frame parser ───────────────────────────────────────────────────
fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
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.10.5 Hz
/// (1230 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<Vec<f64>>, 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<f64> = frame_history
.iter()
.map(|amps| {
if amps.is_empty() {
0.0
} else {
amps.iter().sum::<f64>() / amps.len() as f64
}
})
.collect();
let mean_s = series.iter().sum::<f64>() / n as f64;
// De-mean.
let detrended: Vec<f64> = 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.02.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<f64> {
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<Vec<f64>>, n_sub: usize) -> Vec<f64> {
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.10.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<Vec<f64>>,
sample_rate_hz: f64,
) -> (FeatureInfo, ClassificationInfo, f64, Vec<f64>, 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<f64> = 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::<f64>();
let mean_amp: f64 = if weight_sum > 0.0 {
frame
.amplitudes
.iter()
.zip(importance_weights.iter())
.map(|(a, w)| a * w)
.sum::<f64>()
/ weight_sum
} else {
frame.amplitudes.iter().sum::<f64>() / 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::<f64>()
/ weight_sum
} else {
frame
.amplitudes
.iter()
.map(|a| (a - mean_amp).powi(2))
.sum::<f64>()
/ 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::<f64>() / 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::<f64>() / 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::<f64>()
/ (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::<f64>()
/ 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::<f64>()
/ 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>) -> f64 {
if buf.is_empty() {
return 0.0;
}
let mut sorted: Vec<f64> = 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::<f64>() / 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::<f64>() {
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<SharedState>,
) -> 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<SharedState>,
) -> 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<SharedState>) -> 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<SharedState>,
) -> 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::<SensingUpdate>(&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<PoseKeypoint> = 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::<serde_json::Value>(&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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<u8, NodeState>,
) -> 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<f64> = 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::<f64>().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::<f64>()
/ w_sum,
// Weighted average for motion/breathing/spectral
motion_band_power: active
.iter()
.zip(&weights)
.map(|((f, _), w)| f.motion_band_power * w)
.sum::<f64>()
/ w_sum,
breathing_band_power: active
.iter()
.zip(&weights)
.map(|((f, _), w)| f.breathing_band_power * w)
.sum::<f64>()
/ w_sum,
spectral_power: active
.iter()
.zip(&weights)
.map(|((f, _), w)| f.spectral_power * w)
.sum::<f64>()
/ w_sum,
dominant_freq_hz: active
.iter()
.zip(&weights)
.map(|((f, _), w)| f.dominant_freq_hz * w)
.sum::<f64>()
/ 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<Vec<f64>>) -> usize {
let n_frames = frame_history.len();
if n_frames < 10 {
return 1;
}
let window: Vec<&Vec<f64>> = 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<usize> = (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<f64> = 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::<f64>()
/ 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`,
/// 03) 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<u8, NodeState>,
) -> 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<u8, NodeState> = 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<PoseKeypoint> = 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<f64> = keypoints.iter().map(|k| k.x).collect();
let ys: Vec<f64> = 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<PersonDetection> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::json!({
"status": "ready",
"source": s.effective_source(),
}))
}
async fn health_system(State(state): State<SharedState>) -> Json<serde_json::Value> {
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<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
// 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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>,
Path(id): Path<String>,
) -> Json<serde_json::Value> {
// 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<serde_json::Value> {
// 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<serde_json::Value>) -> Json<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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::<serde_json::Value>(&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<serde_json::Value> {
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<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>,
Path(id): Path<String>,
) -> Json<serde_json::Value> {
// 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<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<String>,
}
/// 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<Arc<wifi_densepose_sensing_server::edge_registry::EdgeRegistry>>,
>,
Query(params): Query<EdgeRegistryParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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 <path> to load one.",
})),
}
}
async fn model_layers(State(state): State<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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<SharedState>,
Path(id): Path<u8>,
) -> Result<Json<NodeSyncSnapshot>, (StatusCode, Json<serde_json::Value>)> {
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": { "<id>": 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"} <signed-int>
/// 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"} <u32>
/// wifi_densepose_mesh_csi_fps{node="N"} <float>
/// wifi_densepose_mesh_csi_fps_samples{node="N"} <u32>
/// wifi_densepose_mesh_staleness_ms{node="N"} <u64>
///
/// Spec: <https://prometheus.io/docs/instrumenting/exposition_formats/>.
/// 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<SharedState>) -> 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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
let now = std::time::Instant::now();
let nodes: Vec<serde_json::Value> = 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<String> {
Html(
"<html><body>\
<h1>WiFi-DensePose Sensing Server</h1>\
<p>Rust + Axum + RuVector</p>\
<ul>\
<li><a href='/health'>/health</a> — Server health</li>\
<li><a href='/api/v1/sensing/latest'>/api/v1/sensing/latest</a> — Latest sensing data</li>\
<li><a href='/api/v1/vital-signs'>/api/v1/vital-signs</a> — Vital sign estimates (HR/RR)</li>\
<li><a href='/api/v1/model/info'>/api/v1/model/info</a> — RVF model container info</li>\
<li>ws://localhost:8765/ws/sensing — WebSocket stream</li>\
</ul>\
</body></html>"
.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<NodeInfo> = 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::<f64>()
/ 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<NodeInfo> = 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<wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot> {
use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot;
// motion_level string -> motion scalar. "absent"/"none"/"still"/"idle"/""
// are non-moving; anything else (walking, …) is motion. `fallback` is used
// when the field is absent so a partial per-node payload defers to the
// room aggregate rather than silently reading 0.
fn motion_of(level: Option<&str>, fallback: f64) -> f64 {
match level {
Some("none") | Some("still") | Some("idle") | Some("absent") | Some("") => 0.0,
Some(_) => 1.0,
None => fallback,
}
}
let ts = (v["timestamp"].as_f64().unwrap_or(0.0) * 1000.0) as i64;
let vit = &v["vital_signs"];
let breathing = vit["breathing_rate_bpm"].as_f64();
let hr = vit["heart_rate_bpm"].as_f64();
let n_persons = v["persons"]
.as_array()
.map(|a| a.len() as u32)
.or_else(|| v["estimated_persons"].as_u64().map(|x| x as u32))
.unwrap_or(0);
// Room-level aggregate: the no-nodes fallback, and the per-node default for
// any field a node omits.
let acls = &v["classification"];
let agg_presence = acls["presence"].as_bool().unwrap_or(false);
let agg_motion = motion_of(acls["motion_level"].as_str(), 0.0);
let agg_conf = acls["confidence"].as_f64().unwrap_or(0.0);
let mk = |node_id: String, presence: bool, motion: f64, conf: f64, rssi: Option<f64>| {
VitalsSnapshot {
node_id,
timestamp_ms: ts,
presence,
motion,
presence_score: if presence { conf.max(0.0) } else { 0.0 },
breathing_rate_bpm: breathing,
heartrate_bpm: hr,
n_persons,
rssi_dbm: rssi,
vital_confidence: conf,
..Default::default()
}
};
match v["nodes"].as_array() {
Some(arr) if !arr.is_empty() => arr
.iter()
.map(|node| {
let n = node["node_id"].as_u64().unwrap_or(0);
// Each node carries its OWN classification — use it, deferring to
// the room aggregate only for fields the node omits.
let ncls = &node["classification"];
let presence = ncls["presence"].as_bool().unwrap_or(agg_presence);
let motion = motion_of(ncls["motion_level"].as_str(), agg_motion);
let conf = ncls["confidence"].as_f64().unwrap_or(agg_conf);
mk(
format!("{base_id}-node{n}"),
presence,
motion,
conf,
node["rssi_dbm"].as_f64(),
)
})
.collect(),
_ => vec![mk(
base_id.to_string(),
agg_presence,
agg_motion,
agg_conf,
v["nodes"][0]["rssi_dbm"].as_f64(),
)],
}
}
/// Turn a `ProgressiveLoader::new` failure into an actionable diagnostic (#894).
///
/// The published HuggingFace `ruvnet/wifi-densepose-pretrained` files
/// (`model.safetensors`, `model-q{2,4,8}.bin`, `model.rvf.jsonl`) are a
/// different *format* — and a different encoder architecture — than the RVF
/// binary container the `--model` progressive loader expects (`RVFS` magic
/// `0x52564653`). Feeding one to `--model` produced a bare
/// "invalid magic at offset 0 …" that left users stuck. Detect the common
/// cases and explain plainly what's loadable instead.
fn diagnose_model_load_error(path: &std::path::Path, data: &[u8], err: &str) -> String {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// safetensors: 8-byte LE header length, then a JSON object starting with '{'.
let looks_safetensors = ext == "safetensors" || (data.len() > 9 && data[8] == b'{');
// JSONL manifest: starts with '{' (or the well-known suffix).
let looks_jsonl =
ext == "jsonl" || name.ends_with(".rvf.jsonl") || data.first() == Some(&b'{');
// Quantized weight blob shipped on HF (model-q2/q4/q8.bin).
let looks_quant_bin = ext == "bin" || name.contains("-q");
let kind = if looks_safetensors {
"a safetensors weight file"
} else if looks_jsonl {
"a JSONL manifest, not the binary container"
} else if looks_quant_bin {
"a quantized weight blob (e.g. HuggingFace model-q4.bin)"
} else {
"not an RVF binary container"
};
format!(
"model `{}` could not be loaded: it is {kind}. The --model flag expects an \
RVF binary container (`RVFS` magic 0x52564653) produced by the \
wifi-densepose-train pipeline. The HuggingFace ruvnet/wifi-densepose-pretrained \
files are a different format and encoder architecture, so they do not load \
here directly (issue #894). Continuing with signal heuristics. (loader: {err})",
path.display()
)
}
/// Whether `--export-rvf` should emit the placeholder container-format demo.
///
/// It must only do so **standalone**. Combined with `--train`/`--pretrain` the
/// real model is produced by the training pipeline, so short-circuiting here
/// would silently skip training and write placeholder weights — the #894 bug
/// where the documented `--train … --export-rvf` workflow produced a fake model.
fn export_emits_placeholder_demo(export_set: bool, train: bool, pretrain: bool) -> bool {
export_set && !train && !pretrain
}
// ── Main ─────────────────────────────────────────────────────────────────────
/// If `--ui-path` points nowhere (wrong cwd), try common repo layouts relative to cwd.
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 <DIR>` (which exports a calibrated .rvf to the \
models/ directory), or download a pretrained encoder. See issue #894."
);
eprintln!("Exporting RVF container package (placeholder weights)...");
use rvf_pipeline::RvfModelBuilder;
let mut builder = RvfModelBuilder::new("wifi-densepose", "1.0.0");
// 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<f32> = (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<f32> = 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<Vec<Vec<f32>>> {
(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<Vec<Vec<f32>>> = 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 <path> 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<Vec<Vec<f32>>> = (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::<f32>().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<Vec<Vec<f32>>> = (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<dataset::TrainingSample> {
(0..50)
.map(|i| {
let csi: Vec<Vec<f32>> = (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<trainer::TrainingSample> =
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<ProgressiveLoader> = 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<wifi_densepose_sensing_server::edge_registry::EdgeRegistry>,
> = 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::<String>(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::<String>(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::<mqtt::state::VitalsSnapshot>(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::<serde_json::Value>(&json) else {
continue;
};
// #898/#872: emit one snapshot per physical node so
// each surfaces as its own Home-Assistant device with
// its *own* presence/motion/RSSI (see
// vitals_snapshots_from_sensing_json). Falls back to a
// single aggregate snapshot for per-node-less sources.
for snap in vitals_snapshots_from_sensing_json(&v, &node_id) {
let _ = vtx.send(snap);
}
}
});
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 <token>`.
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=<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<Arc<EdgeRegistry>>)
// 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<f32> = 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<NodeSyncSnapshot>) -> 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<f64> = (0..NOVELTY_VECTOR_DIM).map(|i| (i as f64).sin()).collect();
ns.update_novelty(&amplitudes);
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(&amplitudes);
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<f64> = (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<SharedState>) -> Json<serde_json::Value> {
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.010.0."
}))
}
/// `POST /api/v1/config/dedup-factor` — set the dedup factor (clamped 1.010.0).
///
/// Body: `{ "value": <f64> }`
async fn config_set_dedup_factor(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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": <u64> }`
async fn config_set_ground_truth(
State(state): State<SharedState>,
Json(body): Json<serde_json::Value>,
) -> Json<serde_json::Value> {
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));
}
}