6936 lines
270 KiB
Rust
6936 lines
270 KiB
Rust
//! 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,
|
||
|
||
/// 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-115 §3.8 — MQTT publisher (HA-DISCO) ──────────────────────────
|
||
#[arg(long, env = "RUVIEW_MQTT")]
|
||
mqtt: bool,
|
||
#[arg(long, env = "RUVIEW_MQTT_HOST", default_value = "localhost")]
|
||
mqtt_host: String,
|
||
#[arg(long, env = "RUVIEW_MQTT_PORT")]
|
||
mqtt_port: Option<u16>,
|
||
#[arg(long, env = "RUVIEW_MQTT_USERNAME")]
|
||
mqtt_username: Option<String>,
|
||
#[arg(long, default_value = "MQTT_PASSWORD")]
|
||
mqtt_password_env: String,
|
||
#[arg(long, env = "RUVIEW_MQTT_CLIENT_ID")]
|
||
mqtt_client_id: Option<String>,
|
||
#[arg(long, env = "RUVIEW_MQTT_PREFIX", default_value = "homeassistant")]
|
||
mqtt_prefix: String,
|
||
#[arg(long, env = "RUVIEW_MQTT_TLS")]
|
||
mqtt_tls: bool,
|
||
#[arg(long, value_name = "PATH")]
|
||
mqtt_ca_file: Option<PathBuf>,
|
||
#[arg(long, value_name = "PATH")]
|
||
mqtt_client_cert: Option<PathBuf>,
|
||
#[arg(long, value_name = "PATH")]
|
||
mqtt_client_key: Option<PathBuf>,
|
||
#[arg(long, default_value = "600")]
|
||
mqtt_refresh_secs: u64,
|
||
#[arg(long, default_value = "0.2")]
|
||
mqtt_rate_vitals: f64,
|
||
#[arg(long, default_value = "1.0")]
|
||
mqtt_rate_motion: f64,
|
||
#[arg(long, default_value = "1.0")]
|
||
mqtt_rate_count: f64,
|
||
#[arg(long, default_value = "0.1")]
|
||
mqtt_rate_rssi: f64,
|
||
#[arg(long)]
|
||
mqtt_publish_pose: bool,
|
||
#[arg(long, default_value = "1.0")]
|
||
mqtt_rate_pose: f64,
|
||
#[arg(long, env = "RUVIEW_PRIVACY_MODE")]
|
||
privacy_mode: 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>,
|
||
#[cfg(feature = "mqtt")]
|
||
vitals_tx: Option<broadcast::Sender<wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot>>,
|
||
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_0004) ───────────────────────────
|
||
|
||
/// 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_0004).
|
||
fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||
if buf.len() < 8 {
|
||
return None;
|
||
}
|
||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||
if magic != 0xC511_0004 {
|
||
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,
|
||
})
|
||
}
|
||
|
||
// ── 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.1–0.5 Hz
|
||
/// (12–30 breaths/min).
|
||
///
|
||
/// For accuracy the function additionally applies a simple 3-tap Goertzel-style power
|
||
/// estimate at evenly-spaced candidate frequencies in the breathing band and returns
|
||
/// the candidate with the highest energy.
|
||
fn estimate_breathing_rate_hz(frame_history: &VecDeque<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.0–2.0).
|
||
/// The rest receive a baseline weight of 0.5. This mirrors the RuVector mincut
|
||
/// partition logic without requiring the graph dependency.
|
||
fn compute_subcarrier_importance_weights(sensitivity: &[f64]) -> Vec<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.1–0.5 Hz band of
|
||
/// the amplitude time series.
|
||
/// - **Signal quality**: based on SNR estimate (RSSI – noise floor) and subcarrier
|
||
/// variance stability.
|
||
///
|
||
/// Returns (features, raw_classification, breathing_rate_hz, sub_variances, raw_motion_score).
|
||
fn extract_features_from_frame(
|
||
frame: &Esp32Frame,
|
||
frame_history: &VecDeque<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
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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;
|
||
let count = s.person_count();
|
||
s.prev_person_count = count;
|
||
count.max(1) // presence=true => at least 1
|
||
}
|
||
None => fallback_count.unwrap_or(0).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-040: Try WASM output packet (magic 0xC511_0004).
|
||
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);
|
||
let raw_score = corr_persons as f64 / 3.0;
|
||
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;
|
||
let count = s.person_count();
|
||
s.prev_person_count = count;
|
||
count.max(1)
|
||
}
|
||
None => fallback_count.unwrap_or(0).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);
|
||
}
|
||
}
|
||
// ADR-115: feed the MQTT publisher (compiled in only when
|
||
// --features mqtt is active; the field and this block are
|
||
// stripped entirely in the default feature set).
|
||
#[cfg(feature = "mqtt")]
|
||
if let Some(ref vtx) = s.vitals_tx {
|
||
if vtx.receiver_count() > 0 {
|
||
use wifi_densepose_sensing_server::mqtt::state::VitalsSnapshot;
|
||
let vs = &s.latest_vitals;
|
||
let snap = VitalsSnapshot {
|
||
node_id: "ruview".to_string(),
|
||
timestamp_ms: std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_millis() as i64,
|
||
presence: update.classification.presence,
|
||
fall_detected: false,
|
||
motion: s.smoothed_motion,
|
||
motion_energy: update.features.motion_band_power,
|
||
presence_score: s.smoothed_person_score,
|
||
breathing_rate_bpm: vs.breathing_rate_bpm,
|
||
heartrate_bpm: vs.heart_rate_bpm,
|
||
n_persons: s.person_count() as u32,
|
||
rssi_dbm: update.nodes.first().map(|n| n.rssi_dbm),
|
||
vital_confidence: (vs.breathing_confidence + vs.heartbeat_confidence) / 2.0,
|
||
};
|
||
let _ = vtx.send(snap);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 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 mode: build an RVF container package and exit
|
||
if let Some(ref rvf_path) = args.export_rvf {
|
||
eprintln!("Exporting RVF container package...");
|
||
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;
|
||
}
|
||
|
||
// 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!("Progressive loader init failed: {e}"),
|
||
},
|
||
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);
|
||
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,
|
||
#[cfg(feature = "mqtt")]
|
||
vitals_tx: None,
|
||
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-115: spawn the MQTT publisher when --mqtt / RUVIEW_MQTT=true is set.
|
||
// The publisher task owns connection lifecycle, HA discovery, and per-entity
|
||
// state publication. Errors during connect are retried internally by rumqttc.
|
||
#[cfg(feature = "mqtt")]
|
||
if args.mqtt {
|
||
use wifi_densepose_sensing_server::mqtt::{
|
||
config::{MqttConfig, PublishRates, TlsConfig},
|
||
publisher::{spawn as mqtt_spawn, OwnedDiscoveryBuilder},
|
||
state::VitalsSnapshot,
|
||
};
|
||
let password = std::env::var(&args.mqtt_password_env).ok();
|
||
let port = args.mqtt_port.unwrap_or(if args.mqtt_tls { 8883 } else { 1883 });
|
||
let tls = if !args.mqtt_tls {
|
||
TlsConfig::Off
|
||
} else {
|
||
match (args.mqtt_ca_file.as_ref(), args.mqtt_client_cert.as_ref(), args.mqtt_client_key.as_ref()) {
|
||
(Some(ca), Some(cert), Some(key)) => TlsConfig::MutualTls {
|
||
ca_file: ca.clone(), client_cert: cert.clone(), client_key: key.clone(),
|
||
},
|
||
(Some(ca), None, None) => TlsConfig::PinnedCa { ca_file: ca.clone() },
|
||
_ => TlsConfig::SystemTrust,
|
||
}
|
||
};
|
||
let client_id = args.mqtt_client_id.clone()
|
||
.unwrap_or_else(|| format!("wifi-densepose-{}", std::process::id()));
|
||
let mqtt_cfg = MqttConfig {
|
||
host: args.mqtt_host.clone(),
|
||
port,
|
||
username: args.mqtt_username.clone(),
|
||
password,
|
||
client_id: client_id.clone(),
|
||
discovery_prefix: args.mqtt_prefix.clone(),
|
||
tls,
|
||
refresh_secs: args.mqtt_refresh_secs,
|
||
rates: PublishRates {
|
||
vitals_hz: args.mqtt_rate_vitals,
|
||
motion_hz: args.mqtt_rate_motion,
|
||
count_hz: args.mqtt_rate_count,
|
||
rssi_hz: args.mqtt_rate_rssi,
|
||
pose_hz: args.mqtt_rate_pose,
|
||
},
|
||
publish_pose: args.mqtt_publish_pose,
|
||
privacy_mode: args.privacy_mode,
|
||
};
|
||
let can_start = match mqtt_cfg.validate() {
|
||
Ok(()) => true,
|
||
Err(ref e) if !e.is_fatal() => {
|
||
warn!("[mqtt] {e}");
|
||
true
|
||
}
|
||
Err(e) => {
|
||
error!("[mqtt] invalid config — MQTT disabled: {e}");
|
||
false
|
||
}
|
||
};
|
||
if can_start {
|
||
let (vtx, vrx) = broadcast::channel::<VitalsSnapshot>(16);
|
||
{
|
||
let mut s = state.write().await;
|
||
s.vitals_tx = Some(vtx);
|
||
}
|
||
let builder = OwnedDiscoveryBuilder {
|
||
discovery_prefix: mqtt_cfg.discovery_prefix.clone(),
|
||
node_id: mqtt_cfg.client_id.clone(),
|
||
node_friendly_name: None,
|
||
sw_version: env!("CARGO_PKG_VERSION").to_string(),
|
||
model: "WiFi-DensePose".to_string(),
|
||
via_device: None,
|
||
};
|
||
mqtt_spawn(Arc::new(mqtt_cfg), builder, vrx);
|
||
info!("[mqtt] publisher task spawned — connecting to broker");
|
||
}
|
||
}
|
||
|
||
// 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(&litudes);
|
||
let first = ns.last_novelty_score.expect("sketch bank initialised");
|
||
assert!(
|
||
(first - 1.0).abs() < 1e-6,
|
||
"empty bank → max novelty 1.0, got {first}"
|
||
);
|
||
|
||
// Repeat the exact same frame — bank now contains it, so the
|
||
// novelty score must be 0.0 (the score is computed before the
|
||
// second insert, against the post-first-insert bank).
|
||
ns.update_novelty(&litudes);
|
||
let second = ns.last_novelty_score.expect("score stays Some");
|
||
assert_eq!(second, 0.0, "exact-repeat frame → novelty 0.0");
|
||
}
|
||
|
||
/// `update_novelty` must tolerate amplitude vectors of unexpected
|
||
/// length — short ones zero-padded, long ones truncated — without
|
||
/// panicking. ESP32-S3 boards report 56 subcarriers but other
|
||
/// hardware variants ship 52 or 64; the schema-locked sketch bank
|
||
/// requires exactly NOVELTY_VECTOR_DIM.
|
||
#[test]
|
||
fn handles_short_and_long_amplitude_vectors() {
|
||
let mut ns = NodeState::new();
|
||
ns.update_novelty(&[1.0, 2.0]); // way short
|
||
assert!(ns.last_novelty_score.is_some());
|
||
|
||
let too_long: Vec<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.0–10.0."
|
||
}))
|
||
}
|
||
|
||
/// `POST /api/v1/config/dedup-factor` — set the dedup factor (clamped 1.0–10.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);
|
||
}
|
||
}
|