feat: complete vendor repos, add edge intelligence and WASM modules

- Add 154 missing vendor files (gitignore was filtering them)
  - vendor/midstream: 564 files (was 561)
  - vendor/sublinear-time-solver: 1190 files (was 1039)
- Add ESP32 edge processing (ADR-039): presence, vitals, fall detection
- Add WASM programmable sensing (ADR-040/041) with wasm3 runtime
- Add firmware CI workflow (.github/workflows/firmware-ci.yml)
- Add wifi-densepose-wasm-edge crate for edge WASM modules
- Update sensing server, provision.py, UI components

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-03-02 23:53:25 -05:00
parent 407b46b206
commit 4b1005524e
196 changed files with 52578 additions and 995 deletions
+6
View File
@@ -17,6 +17,12 @@ members = [
"crates/wifi-densepose-vitals",
"crates/wifi-densepose-ruvector",
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.
# Build separately: cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --release
exclude = [
"crates/wifi-densepose-wasm-edge",
]
[workspace.package]
version = "0.3.0"
@@ -11,9 +11,6 @@
mod rvf_container;
mod rvf_pipeline;
mod vital_signs;
mod recording;
mod model_manager;
mod training_api;
// Training pipeline modules (exposed via lib.rs)
use wifi_densepose_sensing_server::{graph_transformer, trainer, dataset, embedding};
@@ -202,6 +199,13 @@ struct SensingUpdate {
/// 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>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -275,9 +279,6 @@ struct AppStateInner {
frame_history: VecDeque<Vec<f64>>,
tick: u64,
source: String,
/// Timestamp of the last ESP32 UDP frame received.
/// Used by the hybrid auto-detect task to switch between esp32 and simulation.
last_esp32_frame: Option<std::time::Instant>,
tx: broadcast::Sender<String>,
total_detections: u64,
start_time: std::time::Instant,
@@ -295,14 +296,12 @@ struct AppStateInner {
active_sona_profile: Option<String>,
/// Whether a trained model is loaded.
model_loaded: bool,
/// CSI frame recording state (ADR-036).
recording_state: recording::RecordingState,
/// Currently loaded model via model_manager API (ADR-036).
loaded_model: Option<model_manager::LoadedModelState>,
/// Training pipeline state (ADR-036).
training_state: training_api::TrainingState,
/// Broadcast channel for training progress WebSocket (ADR-036).
training_progress_tx: tokio::sync::broadcast::Sender<String>,
/// Smoothed person count (EMA) for hysteresis — prevents frame-to-frame jumping.
smoothed_person_score: 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>,
}
/// Number of frames retained in `frame_history` for temporal analysis.
@@ -311,6 +310,111 @@ 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> {
@@ -904,17 +1008,16 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
let feat_variance = features.variance;
// ADR-036: Capture data for recording before values are moved.
let rec_amps = multi_ap_frame.amplitudes.clone();
let rec_rssi = first_rssi;
let rec_features = serde_json::json!({
"variance": feat_variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
// Multi-person estimation with temporal smoothing (EMA α=0.15).
let raw_score = compute_person_score(&features);
s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15;
let est_persons = if classification.presence {
score_to_person_count(s.smoothed_person_score)
} else {
0
};
let update = SensingUpdate {
let mut update = SensingUpdate {
msg_type: "sensing_update".to_string(),
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
source: format!("wifi:{ssid}"),
@@ -941,19 +1044,20 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
bssid_count: bssid_n,
pose_keypoints: None,
model_status: None,
persons: None,
estimated_persons: if est_persons > 0 { Some(est_persons) } else { None },
};
// Populate persons from the sensing update.
let persons = derive_pose_from_sensing(&update);
if !persons.is_empty() {
update.persons = Some(persons);
}
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi, -90.0, &rec_features,
).await;
debug!(
"Multi-BSSID tick #{tick}: {obs_count} BSSIDs, quality={:.2}, verdict={:?}",
@@ -1031,16 +1135,16 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
let feat_variance = features.variance;
// ADR-036: Capture data for recording before values are moved.
let rec_amps = vec![signal_pct];
let rec_features = serde_json::json!({
"variance": feat_variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
});
// Multi-person estimation with temporal smoothing.
let raw_score = compute_person_score(&features);
s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15;
let est_persons = if classification.presence {
score_to_person_count(s.smoothed_person_score)
} else {
0
};
let update = SensingUpdate {
let mut update = SensingUpdate {
msg_type: "sensing_update".to_string(),
timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
source: format!("wifi:{ssid}"),
@@ -1067,19 +1171,19 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
bssid_count: None,
pose_keypoints: None,
model_status: None,
persons: None,
estimated_persons: if est_persons > 0 { Some(est_persons) } else { None },
};
let persons = derive_pose_from_sensing(&update);
if !persons.is_empty() {
update.persons = Some(persons);
}
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
state, &rec_amps, rssi_dbm, -90.0, &rec_features,
).await;
}
/// Probe if Windows WiFi is connected
@@ -1275,6 +1379,7 @@ async fn handle_ws_pose_client(mut socket: WebSocket, state: SharedState) {
"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(),
}
}
});
@@ -1342,69 +1447,112 @@ async fn latest(State(state): State<SharedState>) -> Json<serde_json::Value> {
/// 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.
fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec<PersonDetection> {
let cls = &update.classification;
if !cls.presence {
return vec![];
}
// ── Multi-person estimation (issue #97) ──────────────────────────────────────
/// 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(feat: &FeatureInfo) -> f64 {
// Normalize each feature to [0, 1] using calibrated ranges:
//
// variance: intra-frame amp variance. 1-person ~2-15, 2-person ~15-60,
// real ESP32 can go higher. Use 30.0 as scaling midpoint.
let var_norm = (feat.variance / 30.0).clamp(0.0, 1.0);
// change_points: threshold crossings in 56 subcarriers. 1-person ~5-15,
// 2-person ~15-30. Scale by 30.0 (half of max 55).
let cp_norm = (feat.change_points as f64 / 30.0).clamp(0.0, 1.0);
// motion_band_power: upper-half subcarrier variance. 1-person ~1-8,
// 2-person ~8-25. Scale by 20.0.
let motion_norm = (feat.motion_band_power / 20.0).clamp(0.0, 1.0);
// spectral_power: mean squared amplitude. Highly variable (~100-1000+).
// Use relative change indicator: high spectral_power with high variance
// suggests multiple reflectors. Scale by 500.0.
let sp_norm = (feat.spectral_power / 500.0).clamp(0.0, 1.0);
// Weighted composite — variance and change_points carry the most signal.
var_norm * 0.35 + cp_norm * 0.30 + motion_norm * 0.20 + sp_norm * 0.15
}
/// Convert smoothed person score to discrete count with hysteresis.
///
/// Uses asymmetric thresholds: higher threshold to add a person, lower to remove.
/// This prevents flickering at the boundary.
fn score_to_person_count(smoothed_score: f64) -> usize {
// Thresholds chosen conservatively for single-ESP32 link:
// score > 0.50 → 2 persons (needs sustained high variance + change points)
// score > 0.80 → 3 persons (very high activity, rare with single link)
if smoothed_score > 0.80 {
3
} else if smoothed_score > 0.50 {
2
} else {
1
}
}
/// Generate a single person's skeleton with per-person spatial offset and phase stagger.
///
/// `person_idx`: 0-based index of this person.
/// `total_persons`: total number of detected persons (for spacing calculation).
fn derive_single_person_pose(
update: &SensingUpdate,
person_idx: usize,
total_persons: usize,
) -> PersonDetection {
let cls = &update.classification;
let feat = &update.features;
// Per-person phase offset: ~120 degrees apart so they don't move in sync.
let phase_offset = person_idx as f64 * 2.094;
// Spatial spread: persons distributed symmetrically around center.
let half = (total_persons as f64 - 1.0) / 2.0;
let person_x_offset = (person_idx as f64 - half) * 120.0; // 120px spacing
// Confidence decays for additional persons (less certain about person 2, 3).
let conf_decay = 1.0 - person_idx as f64 * 0.15;
// ── Signal-derived scalars ────────────────────────────────────────────────
// Continuous motion score from motion_band_power (0..1).
// motion_band_power is the high-frequency subcarrier variance — it is high
// when a body is actively moving through the RF field.
let motion_score = (feat.motion_band_power / 15.0).clamp(0.0, 1.0);
let is_walking = motion_score > 0.55;
// Breathing expansion: torso keypoints shift ±breath_amp pixels per cycle.
// breathing_band_power comes from low-frequency subcarrier variance.
let breath_amp = (feat.breathing_band_power * 4.0).clamp(0.0, 12.0);
// Breathing phase: use the vital-sign estimate if available, otherwise
// derive a proxy from breathing_band_power and the tick counter.
let breath_phase = if let Some(ref vs) = update.vital_signs {
// breathing_rate_bpm is Option<f64>; fall back to 15 BPM if not yet estimated.
// 15 BPM -> 0.25 Hz, which sits comfortably in the breathing band.
let bpm = vs.breathing_rate_bpm.unwrap_or(15.0);
let freq = (bpm / 60.0).clamp(0.1, 0.5);
(update.tick as f64 * freq * 0.1 * std::f64::consts::TAU).sin()
(update.tick as f64 * freq * 0.1 * std::f64::consts::TAU + phase_offset).sin()
} else {
(update.tick as f64 * 0.08 + feat.breathing_band_power).sin()
(update.tick as f64 * 0.08 + feat.breathing_band_power + phase_offset).sin()
};
// Lateral lean derived from dominant_freq_hz (peak subcarrier index -> Hz).
// Maps 0..10 Hz range to ±18 px horizontal shift of the torso center.
let lean_x = (feat.dominant_freq_hz / 5.0 - 1.0).clamp(-1.0, 1.0) * 18.0;
// Walking stride: lateral body displacement oscillating with motion_band_power.
// Amplitude is zero when the person is stationary.
let stride_x = if is_walking {
let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.12).sin();
let stride_phase = (feat.motion_band_power * 0.7 + update.tick as f64 * 0.12 + phase_offset).sin();
stride_phase * 45.0 * motion_score
} else {
0.0
};
// Burst jitter from change_points: rapid threshold crossings in the
// amplitude vector indicate fast movement or sudden signal disturbance.
let burst = (feat.change_points as f64 / 8.0).clamp(0.0, 1.0);
// Deterministic per-frame noise seeded by variance and tick.
// Uses the fractional part of a large sine to get a tick-dependent value
// in (-1, 1) without needing a PRNG.
let noise_seed = feat.variance * 31.7 + update.tick as f64 * 17.3;
let noise_seed = feat.variance * 31.7 + update.tick as f64 * 17.3 + person_idx as f64 * 97.1;
let noise_val = (noise_seed.sin() * 43758.545).fract();
// Scale base confidence by SNR proxy (high variance = better signal quality).
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);
let base_confidence = cls.confidence * (0.6 + 0.4 * snr_factor) * conf_decay;
// ── Skeleton base position ────────────────────────────────────────────────
// Center figure on a 640x480 canvas.
let base_x = 320.0 + stride_x + lean_x * 0.5;
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 ──────────────────────────────
@@ -1416,7 +1564,6 @@ fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec<PersonDetection> {
"left_knee", "right_knee", "left_ankle", "right_ankle",
];
// Nominal (dx, dy) offsets from hip-center in pixels.
let kp_offsets: [(f64, f64); 17] = [
( 0.0, -80.0), // 0 nose
( -8.0, -88.0), // 1 left_eye
@@ -1437,37 +1584,27 @@ fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec<PersonDetection> {
( 24.0, 120.0), // 16 right_ankle
];
// Torso keypoints: left_shoulder(5), right_shoulder(6), left_hip(11), right_hip(12).
// These respond to the breathing expansion signal.
const TORSO_KP: [usize; 4] = [5, 6, 11, 12];
// Extremity keypoints: left_wrist(9), right_wrist(10), left_ankle(15), right_ankle(16).
// These pick up burst jitter from high change_points counts.
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)))| {
// ── Breathing expansion (torso only) ─────────────────────────
let breath_dx = if TORSO_KP.contains(&i) {
// Shoulders spread outward; hips compress inward on inhale.
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) {
// Shoulders rise slightly; hips descend slightly on inhale.
let sign = if *dy < 0.0 { -1.0 } else { 1.0 };
sign * breath_amp * breath_phase * 0.3
} else {
0.0
};
// ── Extremity burst jitter ────────────────────────────────────
let extremity_jitter = if EXTREMITY_KP.contains(&i) {
// Each extremity gets an independent phase offset.
let phase = noise_seed + i as f64 * 2.399; // golden-angle spacing
let phase = noise_seed + i as f64 * 2.399;
(
phase.sin() * burst * motion_score * 12.0,
(phase * 1.31).cos() * burst * motion_score * 8.0,
@@ -1476,53 +1613,44 @@ fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec<PersonDetection> {
(0.0, 0.0)
};
// ── Per-joint motion noise (scales with signal variance) ──────
// Different seed per keypoint so every joint moves independently.
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 * 2.718).cos() * 31415.926).fract()
* feat.variance.sqrt().clamp(0.0, 3.0) * motion_score * 0.6;
// ── Walking arm/leg swing (contralateral gait pattern) ────────
let swing_dy = if is_walking {
let stride_phase =
(feat.motion_band_power * 0.7 + update.tick as f64 * 0.12).sin();
(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, // left elbow/wrist
8 | 10 => stride_phase * 20.0 * motion_score, // right elbow/wrist
13 | 15 => stride_phase * 25.0 * motion_score, // left knee/ankle
14 | 16 => -stride_phase * 25.0 * motion_score, // right knee/ankle
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
};
// ── Compose final position ────────────────────────────────────
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 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;
// Extremity confidence is lower when signal variance is low.
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()))
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, // slight Z depth from lean direction
z: lean_x * 0.02,
confidence: kp_conf.clamp(0.1, 1.0),
}
})
.collect();
// Bounding box derived from actual keypoint extents with padding.
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;
@@ -1530,9 +1658,9 @@ fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec<PersonDetection> {
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;
vec![PersonDetection {
id: 1,
confidence: cls.confidence,
PersonDetection {
id: (person_idx + 1) as u32,
confidence: cls.confidence * conf_decay,
keypoints,
bbox: BoundingBox {
x: min_x,
@@ -1540,8 +1668,22 @@ fn derive_pose_from_sensing(update: &SensingUpdate) -> Vec<PersonDetection> {
width: (max_x - min_x).max(80.0),
height: (max_y - min_y).max(160.0),
},
zone: "zone_1".into(),
}]
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()
}
// ── DensePose-compatible REST endpoints ─────────────────────────────────────
@@ -1691,6 +1833,38 @@ async fn vital_signs_endpoint(State(state): State<SharedState>) -> Json<serde_js
}))
}
/// 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 {
@@ -1809,13 +1983,57 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
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);
}
s.edge_vitals = Some(vitals);
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());
// Append current amplitudes to history before extracting features so
// that temporal analysis includes the most recent frame.
@@ -1847,7 +2065,16 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
);
s.latest_vitals = vitals.clone();
let update = SensingUpdate {
// Multi-person estimation with temporal smoothing.
let raw_score = compute_person_score(&features);
s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15;
let est_persons = if classification.presence {
score_to_person_count(s.smoothed_person_score)
} else {
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(),
@@ -1874,30 +2101,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
bssid_count: None,
pose_keypoints: None,
model_status: None,
persons: None,
estimated_persons: if est_persons > 0 { Some(est_persons) } else { None },
};
let persons = derive_pose_from_sensing(&update);
if !persons.is_empty() {
update.persons = Some(persons);
}
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
// Capture data for recording before storing.
let rec_amps = frame.amplitudes.iter().take(56).cloned().collect::<Vec<_>>();
let rec_rssi = features.mean_rssi;
let rec_features = 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,
});
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi,
frame.noise_floor as f64, &rec_features,
).await;
}
}
Err(e) => {
@@ -1910,9 +2126,6 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
// ── Simulated data task ──────────────────────────────────────────────────────
/// Duration without ESP32 frames before falling back to simulation.
const ESP32_TIMEOUT: Duration = Duration::from_secs(3);
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);
@@ -1920,23 +2133,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
loop {
interval.tick().await;
// If ESP32 sent a frame recently, skip simulation — real data is flowing.
{
let s = state.read().await;
if let Some(last) = s.last_esp32_frame {
if last.elapsed() < ESP32_TIMEOUT {
continue; // ESP32 is active, don't emit simulated frames
}
}
}
let mut s = state.write().await;
// If we just transitioned from esp32 → simulated, log once.
if s.source == "esp32" {
info!("ESP32 silent for {}s — switching to simulation", ESP32_TIMEOUT.as_secs());
}
s.source = "simulated".to_string();
s.tick += 1;
let tick = s.tick;
@@ -1970,7 +2167,16 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
let frame_amplitudes = frame.amplitudes.clone();
let frame_n_sub = frame.n_subcarriers;
let update = SensingUpdate {
// Multi-person estimation with temporal smoothing.
let raw_score = compute_person_score(&features);
s.smoothed_person_score = s.smoothed_person_score * 0.85 + raw_score * 0.15;
let est_persons = if classification.presence {
score_to_person_count(s.smoothed_person_score)
} else {
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(),
@@ -2007,32 +2213,23 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
} else {
None
},
persons: None,
estimated_persons: if est_persons > 0 { Some(est_persons) } else { None },
};
// Populate persons from the sensing update.
let persons = derive_pose_from_sensing(&update);
if !persons.is_empty() {
update.persons = Some(persons);
}
if update.classification.presence {
s.total_detections += 1;
}
if let Ok(json) = serde_json::to_string(&update) {
let _ = s.tx.send(json);
}
// Capture data for recording before storing.
let rec_amps = frame.amplitudes.clone();
let rec_rssi = features.mean_rssi;
let rec_features = 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,
});
s.latest_update = Some(update);
drop(s);
// ADR-036: Record frame if recording is active.
recording::maybe_record_frame(
&state, &rec_amps, rec_rssi, -90.0, &rec_features,
).await;
}
}
@@ -2500,7 +2697,6 @@ async fn main() {
info!(" Source: {}", args.source);
// Auto-detect data source
let is_auto_mode = args.source == "auto";
let source = match args.source.as_str() {
"auto" => {
info!("Auto-detecting data source...");
@@ -2511,7 +2707,7 @@ async fn main() {
info!(" Windows WiFi detected");
"wifi"
} else {
info!(" No hardware detected, starting with simulation (hot-plug enabled)");
info!(" No hardware detected, using simulation");
"simulate"
}
}
@@ -2593,14 +2789,12 @@ async fn main() {
}
let (tx, _) = broadcast::channel::<String>(256);
let (training_progress_tx, _) = broadcast::channel::<String>(512);
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: if source == "esp32" { Some(std::time::Instant::now()) } else { None },
tx,
total_detections: 0,
start_time: std::time::Instant::now(),
@@ -2611,39 +2805,22 @@ async fn main() {
progressive_loader,
active_sona_profile: None,
model_loaded,
recording_state: recording::RecordingState::default(),
loaded_model: None,
training_state: training_api::TrainingState::default(),
training_progress_tx,
smoothed_person_score: 0.0,
edge_vitals: None,
latest_wasm_events: None,
}));
// Ensure data directories exist (ADR-036).
for dir in &[recording::RECORDINGS_DIR, model_manager::MODELS_DIR] {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!("Failed to create directory {dir}: {e}");
// 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));
}
}
// Start background tasks based on source.
// In auto mode we always start BOTH the UDP listener (for ESP32 hot-plug)
// and the simulation task (which self-pauses when ESP32 packets arrive).
if is_auto_mode {
info!("Auto mode: UDP listener + simulation fallback both active (hot-plug enabled)");
tokio::spawn(udp_receiver_task(state.clone(), args.udp_port));
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
} else {
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));
}
"wifi" => {
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
}
_ => {
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
}
}
@@ -2682,6 +2859,8 @@ async fn main() {
.route("/api/v1/sensing/latest", get(latest))
// Vital sign endpoints
.route("/api/v1/vital-signs", get(vital_signs_endpoint))
.route("/api/v1/edge-vitals", get(edge_vitals_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)
@@ -2698,10 +2877,6 @@ async fn main() {
.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-036: Recording, model management, and training APIs
.merge(recording::routes())
.merge(model_manager::routes())
.merge(training_api::routes())
// Static UI files
.nest_service("/ui", ServeDir::new(&ui_path))
.layer(SetResponseHeaderLayer::overriding(
@@ -0,0 +1,100 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "libc"
version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wifi-densepose-wasm-edge"
version = "0.3.0"
dependencies = [
"libm",
"sha2",
]
@@ -0,0 +1,31 @@
[package]
name = "wifi-densepose-wasm-edge"
version = "0.3.0"
edition = "2021"
authors = ["rUv <ruv@ruv.net>", "WiFi-DensePose Contributors"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/wifi-densepose"
description = "WASM-compilable sensing algorithms for ESP32 edge deployment (ADR-040)"
keywords = ["wifi", "wasm", "sensing", "esp32", "dsp"]
categories = ["embedded", "wasm", "science"]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# no_std math
libm = "0.2"
# SHA-256 for RVF build hash (optional, used by builder)
sha2 = { version = "0.10", optional = true, default-features = false }
[features]
default = []
# Enable std for testing on host + RVF builder
std = ["sha2/std"]
[profile.release]
opt-level = "s" # Optimize for size
lto = true
codegen-units = 1
panic = "abort"
strip = true
@@ -0,0 +1,182 @@
//! Signal anomaly and adversarial detection — no_std port.
//!
//! Ported from `ruvsense/adversarial.rs` for WASM execution.
//! Detects physically impossible or inconsistent CSI signals that may indicate:
//! - Environmental interference (appliance noise, RF jamming)
//! - Sensor malfunction (antenna disconnection, firmware bug)
//! - Adversarial manipulation (replay attack, signal injection)
//!
//! Detection heuristics:
//! 1. **Phase jump**: Large instantaneous phase discontinuity across all subcarriers
//! 2. **Amplitude flatline**: All subcarriers report identical amplitude (stuck sensor)
//! 3. **Energy spike**: Total signal energy exceeds physical bounds
//! 4. **Consistency check**: Phase and amplitude should correlate within bounds
use libm::fabsf;
/// Maximum subcarriers tracked.
const MAX_SC: usize = 32;
/// Phase jump threshold (radians) — physically impossible for human motion.
const PHASE_JUMP_THRESHOLD: f32 = 2.5;
/// Minimum amplitude variance across subcarriers (zero = flatline/stuck).
const MIN_AMPLITUDE_VARIANCE: f32 = 0.001;
/// Maximum physically plausible energy ratio (current / baseline).
const MAX_ENERGY_RATIO: f32 = 50.0;
/// Number of frames for baseline estimation.
const BASELINE_FRAMES: u32 = 100;
/// Anomaly cooldown (frames) to avoid flooding events.
const ANOMALY_COOLDOWN: u16 = 20;
/// Anomaly detector state.
pub struct AnomalyDetector {
/// Previous phase per subcarrier.
prev_phases: [f32; MAX_SC],
/// Baseline mean amplitude per subcarrier.
baseline_amp: [f32; MAX_SC],
/// Baseline mean total energy.
baseline_energy: f32,
/// Frame counter for baseline accumulation.
baseline_count: u32,
/// Running sum for baseline computation.
baseline_sum: [f32; MAX_SC],
baseline_energy_sum: f32,
/// Whether baseline has been established.
calibrated: bool,
/// Whether phase has been initialized.
phase_initialized: bool,
/// Cooldown counter.
cooldown: u16,
/// Total anomalies detected.
anomaly_count: u32,
}
impl AnomalyDetector {
pub const fn new() -> Self {
Self {
prev_phases: [0.0; MAX_SC],
baseline_amp: [0.0; MAX_SC],
baseline_energy: 0.0,
baseline_count: 0,
baseline_sum: [0.0; MAX_SC],
baseline_energy_sum: 0.0,
calibrated: false,
phase_initialized: false,
cooldown: 0,
anomaly_count: 0,
}
}
/// Process one frame, returning true if an anomaly is detected.
pub fn process_frame(&mut self, phases: &[f32], amplitudes: &[f32]) -> bool {
let n_sc = phases.len().min(amplitudes.len()).min(MAX_SC);
if self.cooldown > 0 {
self.cooldown -= 1;
}
// ── Baseline accumulation ────────────────────────────────────────
if !self.calibrated {
let mut energy = 0.0f32;
for i in 0..n_sc {
self.baseline_sum[i] += amplitudes[i];
energy += amplitudes[i] * amplitudes[i];
}
self.baseline_energy_sum += energy;
self.baseline_count += 1;
if !self.phase_initialized {
for i in 0..n_sc {
self.prev_phases[i] = phases[i];
}
self.phase_initialized = true;
}
if self.baseline_count >= BASELINE_FRAMES {
let n = self.baseline_count as f32;
for i in 0..n_sc {
self.baseline_amp[i] = self.baseline_sum[i] / n;
}
self.baseline_energy = self.baseline_energy_sum / n;
self.calibrated = true;
}
return false;
}
let mut anomaly = false;
// ── Check 1: Phase jump across all subcarriers ───────────────────
if self.phase_initialized {
let mut jump_count = 0u32;
for i in 0..n_sc {
let delta = fabsf(phases[i] - self.prev_phases[i]);
if delta > PHASE_JUMP_THRESHOLD {
jump_count += 1;
}
}
// If >50% of subcarriers have large jumps, it's suspicious.
if n_sc > 0 && jump_count > (n_sc as u32) / 2 {
anomaly = true;
}
}
// ── Check 2: Amplitude flatline ──────────────────────────────────
if n_sc >= 4 {
let mut amp_mean = 0.0f32;
for i in 0..n_sc {
amp_mean += amplitudes[i];
}
amp_mean /= n_sc as f32;
let mut amp_var = 0.0f32;
for i in 0..n_sc {
let d = amplitudes[i] - amp_mean;
amp_var += d * d;
}
amp_var /= n_sc as f32;
if amp_var < MIN_AMPLITUDE_VARIANCE && amp_mean > 0.01 {
anomaly = true;
}
}
// ── Check 3: Energy spike ────────────────────────────────────────
{
let mut current_energy = 0.0f32;
for i in 0..n_sc {
current_energy += amplitudes[i] * amplitudes[i];
}
if self.baseline_energy > 0.0 {
let ratio = current_energy / self.baseline_energy;
if ratio > MAX_ENERGY_RATIO {
anomaly = true;
}
}
}
// Update previous phase.
for i in 0..n_sc {
self.prev_phases[i] = phases[i];
}
self.phase_initialized = true;
// Apply cooldown.
if anomaly && self.cooldown == 0 {
self.anomaly_count += 1;
self.cooldown = ANOMALY_COOLDOWN;
true
} else {
false
}
}
/// Total anomalies detected since initialization.
pub fn total_anomalies(&self) -> u32 {
self.anomaly_count
}
}
@@ -0,0 +1,150 @@
//! Phase phasor coherence monitor — no_std port.
//!
//! Ported from `ruvector/viewpoint/coherence.rs` for WASM execution.
//! Computes mean phasor coherence across subcarriers to detect signal quality
//! and environmental stability. Low coherence indicates multipath interference
//! or environmental changes that degrade sensing accuracy.
use libm::{cosf, sinf, sqrtf, atan2f};
/// Number of subcarriers to track for coherence.
const MAX_SC: usize = 32;
/// EMA smoothing factor for coherence score.
const ALPHA: f32 = 0.1;
/// Hysteresis thresholds for coherence gate decisions.
const HIGH_THRESHOLD: f32 = 0.7;
const LOW_THRESHOLD: f32 = 0.4;
/// Coherence gate state.
#[derive(Clone, Copy, PartialEq)]
pub enum GateState {
/// Signal is coherent — full sensing accuracy.
Accept,
/// Marginal coherence — predictions may be degraded.
Warn,
/// Incoherent — sensing unreliable, need recalibration.
Reject,
}
/// Phase phasor coherence monitor.
pub struct CoherenceMonitor {
/// Previous phase per subcarrier (for delta computation).
prev_phases: [f32; MAX_SC],
/// Running phasor sum (real component).
phasor_re: f32,
/// Running phasor sum (imaginary component).
phasor_im: f32,
/// EMA-smoothed coherence score [0, 1].
smoothed_coherence: f32,
/// Number of frames processed.
frame_count: u32,
/// Current gate state (with hysteresis).
gate: GateState,
/// Whether the monitor has been initialized.
initialized: bool,
}
impl CoherenceMonitor {
pub const fn new() -> Self {
Self {
prev_phases: [0.0; MAX_SC],
phasor_re: 0.0,
phasor_im: 0.0,
smoothed_coherence: 1.0,
frame_count: 0,
gate: GateState::Accept,
initialized: false,
}
}
/// Process one frame of phase data and return the coherence score [0, 1].
///
/// Coherence is computed as the magnitude of the mean phasor of inter-frame
/// phase differences across subcarriers. A score of 1.0 means all
/// subcarriers exhibit the same phase shift (perfectly coherent signal);
/// 0.0 means random phase changes (incoherent).
pub fn process_frame(&mut self, phases: &[f32]) -> f32 {
let n_sc = if phases.len() > MAX_SC { MAX_SC } else { phases.len() };
if !self.initialized {
for i in 0..n_sc {
self.prev_phases[i] = phases[i];
}
self.initialized = true;
return 1.0;
}
self.frame_count += 1;
// Compute mean phasor of phase deltas.
let mut sum_re = 0.0f32;
let mut sum_im = 0.0f32;
for i in 0..n_sc {
let delta = phases[i] - self.prev_phases[i];
// Phasor: e^{j*delta} = cos(delta) + j*sin(delta)
sum_re += cosf(delta);
sum_im += sinf(delta);
self.prev_phases[i] = phases[i];
}
// Mean phasor.
let n = n_sc as f32;
let mean_re = sum_re / n;
let mean_im = sum_im / n;
// Coherence = magnitude of mean phasor [0, 1].
let coherence = sqrtf(mean_re * mean_re + mean_im * mean_im);
// EMA smoothing.
self.smoothed_coherence = ALPHA * coherence + (1.0 - ALPHA) * self.smoothed_coherence;
// Hysteresis gate update.
self.gate = match self.gate {
GateState::Accept => {
if self.smoothed_coherence < LOW_THRESHOLD {
GateState::Reject
} else if self.smoothed_coherence < HIGH_THRESHOLD {
GateState::Warn
} else {
GateState::Accept
}
}
GateState::Warn => {
if self.smoothed_coherence >= HIGH_THRESHOLD {
GateState::Accept
} else if self.smoothed_coherence < LOW_THRESHOLD {
GateState::Reject
} else {
GateState::Warn
}
}
GateState::Reject => {
if self.smoothed_coherence >= HIGH_THRESHOLD {
GateState::Accept
} else {
GateState::Reject
}
}
};
self.smoothed_coherence
}
/// Get the current gate state.
pub fn gate_state(&self) -> GateState {
self.gate
}
/// Get the mean phasor angle (radians) — indicates dominant phase drift direction.
pub fn mean_phasor_angle(&self) -> f32 {
atan2f(self.phasor_im, self.phasor_re)
}
/// Get the EMA-smoothed coherence score.
pub fn coherence_score(&self) -> f32 {
self.smoothed_coherence
}
}
@@ -0,0 +1,235 @@
//! DTW (Dynamic Time Warping) gesture recognition — no_std port.
//!
//! Ported from `ruvsense/gesture.rs` for WASM execution on ESP32-S3.
//! Recognizes predefined gesture templates from CSI phase sequences
//! using constrained DTW with Sakoe-Chiba band.
use libm::fabsf;
/// Maximum gesture template length (samples).
const MAX_TEMPLATE_LEN: usize = 40;
/// Maximum observation window (samples).
const MAX_WINDOW_LEN: usize = 60;
/// Number of predefined gesture templates.
const NUM_TEMPLATES: usize = 4;
/// DTW distance threshold for a match.
const DTW_THRESHOLD: f32 = 2.5;
/// Sakoe-Chiba band width (constrains warping path).
const BAND_WIDTH: usize = 5;
/// Gesture template: a named sequence of phase-delta values.
struct GestureTemplate {
/// Template values (normalized phase deltas).
values: [f32; MAX_TEMPLATE_LEN],
/// Actual length of the template.
len: usize,
/// Gesture ID (emitted as event value).
id: u8,
}
/// DTW gesture detector state.
pub struct GestureDetector {
/// Sliding window of phase deltas.
window: [f32; MAX_WINDOW_LEN],
window_len: usize,
window_idx: usize,
/// Previous primary phase (for delta computation).
prev_phase: f32,
initialized: bool,
/// Cooldown counter (frames) to avoid duplicate detections.
cooldown: u16,
/// Predefined gesture templates.
templates: [GestureTemplate; NUM_TEMPLATES],
}
impl GestureDetector {
pub const fn new() -> Self {
Self {
window: [0.0; MAX_WINDOW_LEN],
window_len: 0,
window_idx: 0,
prev_phase: 0.0,
initialized: false,
cooldown: 0,
templates: [
// Template 1: Wave (oscillating phase)
GestureTemplate {
values: {
let mut v = [0.0f32; MAX_TEMPLATE_LEN];
// Manually define a wave pattern
v[0] = 0.5; v[1] = 0.8; v[2] = 0.3; v[3] = -0.3;
v[4] = -0.8; v[5] = -0.5; v[6] = 0.3; v[7] = 0.8;
v[8] = 0.5; v[9] = -0.3; v[10] = -0.8; v[11] = -0.5;
v
},
len: 12,
id: 1,
},
// Template 2: Push (steady positive phase shift)
GestureTemplate {
values: {
let mut v = [0.0f32; MAX_TEMPLATE_LEN];
v[0] = 0.1; v[1] = 0.3; v[2] = 0.5; v[3] = 0.7;
v[4] = 0.6; v[5] = 0.4; v[6] = 0.2; v[7] = 0.0;
v
},
len: 8,
id: 2,
},
// Template 3: Pull (steady negative phase shift)
GestureTemplate {
values: {
let mut v = [0.0f32; MAX_TEMPLATE_LEN];
v[0] = -0.1; v[1] = -0.3; v[2] = -0.5; v[3] = -0.7;
v[4] = -0.6; v[5] = -0.4; v[6] = -0.2; v[7] = 0.0;
v
},
len: 8,
id: 3,
},
// Template 4: Swipe (sharp directional change)
GestureTemplate {
values: {
let mut v = [0.0f32; MAX_TEMPLATE_LEN];
v[0] = 0.0; v[1] = 0.2; v[2] = 0.6; v[3] = 1.0;
v[4] = 0.8; v[5] = 0.2; v[6] = -0.2; v[7] = -0.4;
v[8] = -0.3; v[9] = -0.1;
v
},
len: 10,
id: 4,
},
],
}
}
/// Process one frame's phase data, returning a gesture ID if detected.
pub fn process_frame(&mut self, phases: &[f32]) -> Option<u8> {
if phases.is_empty() {
return None;
}
// Decrement cooldown.
if self.cooldown > 0 {
self.cooldown -= 1;
// Still need to update state even during cooldown.
}
// Use primary (first) subcarrier phase for gesture detection.
let primary_phase = phases[0];
if !self.initialized {
self.prev_phase = primary_phase;
self.initialized = true;
return None;
}
// Compute phase delta.
let delta = primary_phase - self.prev_phase;
self.prev_phase = primary_phase;
// Add to sliding window (ring buffer).
self.window[self.window_idx] = delta;
self.window_idx = (self.window_idx + 1) % MAX_WINDOW_LEN;
if self.window_len < MAX_WINDOW_LEN {
self.window_len += 1;
}
// Need minimum window before attempting matching.
if self.window_len < 8 || self.cooldown > 0 {
return None;
}
// Build contiguous observation from ring buffer.
let mut obs = [0.0f32; MAX_WINDOW_LEN];
for i in 0..self.window_len {
let ri = (self.window_idx + MAX_WINDOW_LEN - self.window_len + i) % MAX_WINDOW_LEN;
obs[i] = self.window[ri];
}
// Match against each template.
let mut best_id: Option<u8> = None;
let mut best_dist = DTW_THRESHOLD;
for tmpl in &self.templates {
if tmpl.len == 0 || self.window_len < tmpl.len {
continue;
}
// Use only the tail of the observation (matching template length + margin).
let obs_start = if self.window_len > tmpl.len + 10 {
self.window_len - tmpl.len - 10
} else {
0
};
let obs_slice = &obs[obs_start..self.window_len];
let dist = dtw_distance(obs_slice, &tmpl.values[..tmpl.len]);
if dist < best_dist {
best_dist = dist;
best_id = Some(tmpl.id);
}
}
if best_id.is_some() {
self.cooldown = 40; // ~2 seconds at 20 Hz.
}
best_id
}
}
/// Compute constrained DTW distance between two sequences.
/// Uses Sakoe-Chiba band to limit warping and reduce computation.
fn dtw_distance(a: &[f32], b: &[f32]) -> f32 {
let n = a.len();
let m = b.len();
if n == 0 || m == 0 {
return f32::MAX;
}
// Use a flat array on stack (max 60 × 40 = 2400 entries).
// For WASM, this uses linear memory which is fine.
const MAX_N: usize = MAX_WINDOW_LEN;
const MAX_M: usize = MAX_TEMPLATE_LEN;
let mut cost = [[f32::MAX; MAX_M]; MAX_N];
cost[0][0] = fabsf(a[0] - b[0]);
for i in 0..n {
for j in 0..m {
// Sakoe-Chiba band constraint.
let diff = if i > j { i - j } else { j - i };
if diff > BAND_WIDTH {
continue;
}
let c = fabsf(a[i] - b[j]);
if i == 0 && j == 0 {
cost[i][j] = c;
} else {
let mut min_prev = f32::MAX;
if i > 0 && cost[i - 1][j] < min_prev {
min_prev = cost[i - 1][j];
}
if j > 0 && cost[i][j - 1] < min_prev {
min_prev = cost[i][j - 1];
}
if i > 0 && j > 0 && cost[i - 1][j - 1] < min_prev {
min_prev = cost[i - 1][j - 1];
}
cost[i][j] = c + min_prev;
}
}
}
// Normalize by path length.
let path_len = (n + m) as f32;
cost[n - 1][m - 1] / path_len
}
@@ -0,0 +1,378 @@
//! Intrusion detection — ADR-041 Phase 1 module (Security category).
//!
//! Detects unauthorized entry by monitoring CSI phase disturbance patterns:
//! - Sudden amplitude changes in previously quiet zones
//! - Phase velocity exceeding normal movement bounds
//! - Transition from "empty" to "occupied" state
//! - Anomalous movement patterns (too fast for normal human motion)
//!
//! Security-grade: low false-negative rate at the cost of higher false-positive.
use libm::fabsf;
#[cfg(not(feature = "std"))]
use libm::sqrtf;
#[cfg(feature = "std")]
fn sqrtf(x: f32) -> f32 { x.sqrt() }
/// Maximum subcarriers.
const MAX_SC: usize = 32;
/// Phase velocity threshold for intrusion (rad/frame — very fast movement).
const INTRUSION_VELOCITY_THRESH: f32 = 1.5;
/// Amplitude change ratio threshold (vs baseline).
const AMPLITUDE_CHANGE_THRESH: f32 = 3.0;
/// Frames of quiet before arming (5 seconds at 20 Hz).
const ARM_FRAMES: u32 = 100;
/// Minimum consecutive detection frames before alert (debounce).
const DETECT_DEBOUNCE: u8 = 3;
/// Cooldown frames after alert (prevent flooding).
const ALERT_COOLDOWN: u16 = 100;
/// Baseline calibration frames.
const BASELINE_FRAMES: u32 = 200;
/// Event types (200-series: Security).
pub const EVENT_INTRUSION_ALERT: i32 = 200;
pub const EVENT_INTRUSION_ZONE: i32 = 201;
pub const EVENT_INTRUSION_ARMED: i32 = 202;
pub const EVENT_INTRUSION_DISARMED: i32 = 203;
/// Detector state.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DetectorState {
/// Calibrating baseline (learning ambient environment).
Calibrating,
/// Monitoring but not armed (waiting for environment to settle).
Monitoring,
/// Armed — will trigger on intrusion.
Armed,
/// Alert active — intrusion detected.
Alert,
}
/// Intrusion detector.
pub struct IntrusionDetector {
/// Per-subcarrier baseline amplitude.
baseline_amp: [f32; MAX_SC],
/// Per-subcarrier baseline variance.
baseline_var: [f32; MAX_SC],
/// Previous phase values.
prev_phases: [f32; MAX_SC],
/// Calibration accumulators.
calib_amp_sum: [f32; MAX_SC],
calib_amp_sq_sum: [f32; MAX_SC],
calib_count: u32,
/// Current state.
state: DetectorState,
/// Consecutive quiet frames (for arming).
quiet_frames: u32,
/// Consecutive detection frames (debounce).
detect_frames: u8,
/// Alert cooldown counter.
cooldown: u16,
/// Phase initialized flag.
phase_init: bool,
/// Total alerts fired.
alert_count: u32,
/// Frame counter.
frame_count: u32,
}
impl IntrusionDetector {
pub const fn new() -> Self {
Self {
baseline_amp: [0.0; MAX_SC],
baseline_var: [0.0; MAX_SC],
prev_phases: [0.0; MAX_SC],
calib_amp_sum: [0.0; MAX_SC],
calib_amp_sq_sum: [0.0; MAX_SC],
calib_count: 0,
state: DetectorState::Calibrating,
quiet_frames: 0,
detect_frames: 0,
cooldown: 0,
phase_init: false,
alert_count: 0,
frame_count: 0,
}
}
/// Process one frame. Returns events to emit.
pub fn process_frame(
&mut self,
phases: &[f32],
amplitudes: &[f32],
) -> &[(i32, f32)] {
let n_sc = phases.len().min(amplitudes.len()).min(MAX_SC);
if n_sc < 2 {
return &[];
}
self.frame_count += 1;
if self.cooldown > 0 {
self.cooldown -= 1;
}
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
let mut n_events = 0usize;
match self.state {
DetectorState::Calibrating => {
// Accumulate baseline statistics.
for i in 0..n_sc {
self.calib_amp_sum[i] += amplitudes[i];
self.calib_amp_sq_sum[i] += amplitudes[i] * amplitudes[i];
}
self.calib_count += 1;
if !self.phase_init {
for i in 0..n_sc {
self.prev_phases[i] = phases[i];
}
self.phase_init = true;
}
if self.calib_count >= BASELINE_FRAMES {
let n = self.calib_count as f32;
for i in 0..n_sc {
self.baseline_amp[i] = self.calib_amp_sum[i] / n;
let mean_sq = self.calib_amp_sq_sum[i] / n;
let mean = self.baseline_amp[i];
self.baseline_var[i] = mean_sq - mean * mean;
if self.baseline_var[i] < 0.001 {
self.baseline_var[i] = 0.001;
}
}
self.state = DetectorState::Monitoring;
}
}
DetectorState::Monitoring => {
// Wait for environment to be quiet before arming.
let disturbance = self.compute_disturbance(phases, amplitudes, n_sc);
if disturbance < 0.5 {
self.quiet_frames += 1;
} else {
self.quiet_frames = 0;
}
if self.quiet_frames >= ARM_FRAMES {
self.state = DetectorState::Armed;
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_INTRUSION_ARMED, 1.0);
}
n_events += 1;
}
}
// Update previous phases.
for i in 0..n_sc {
self.prev_phases[i] = phases[i];
}
}
DetectorState::Armed => {
let disturbance = self.compute_disturbance(phases, amplitudes, n_sc);
if disturbance >= 0.8 {
self.detect_frames = self.detect_frames.saturating_add(1);
if self.detect_frames >= DETECT_DEBOUNCE && self.cooldown == 0 {
self.state = DetectorState::Alert;
self.alert_count += 1;
self.cooldown = ALERT_COOLDOWN;
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_INTRUSION_ALERT, disturbance);
}
n_events += 1;
}
// Find the most disturbed zone.
let zone = self.find_disturbed_zone(amplitudes, n_sc);
if n_events < 4 {
unsafe {
EVENTS[n_events] = (EVENT_INTRUSION_ZONE, zone as f32);
}
n_events += 1;
}
}
} else {
self.detect_frames = 0;
}
for i in 0..n_sc {
self.prev_phases[i] = phases[i];
}
}
DetectorState::Alert => {
let disturbance = self.compute_disturbance(phases, amplitudes, n_sc);
// Return to armed once the disturbance subsides.
if disturbance < 0.3 {
self.quiet_frames += 1;
if self.quiet_frames >= ARM_FRAMES / 2 {
self.state = DetectorState::Armed;
self.detect_frames = 0;
self.quiet_frames = 0;
}
} else {
self.quiet_frames = 0;
}
for i in 0..n_sc {
self.prev_phases[i] = phases[i];
}
}
}
unsafe { &EVENTS[..n_events] }
}
/// Compute overall disturbance score.
fn compute_disturbance(&self, phases: &[f32], amplitudes: &[f32], n_sc: usize) -> f32 {
let mut phase_score = 0.0f32;
let mut amp_score = 0.0f32;
for i in 0..n_sc {
// Phase velocity.
let phase_vel = fabsf(phases[i] - self.prev_phases[i]);
if phase_vel > INTRUSION_VELOCITY_THRESH {
phase_score += 1.0;
}
// Amplitude deviation from baseline.
let amp_dev = fabsf(amplitudes[i] - self.baseline_amp[i]);
let sigma = sqrtf(self.baseline_var[i]);
if amp_dev > AMPLITUDE_CHANGE_THRESH * sigma {
amp_score += 1.0;
}
}
let n = n_sc as f32;
// Combined score: fraction of subcarriers showing disturbance.
(phase_score / n) * 0.6 + (amp_score / n) * 0.4
}
/// Find the zone with highest amplitude disturbance.
fn find_disturbed_zone(&self, amplitudes: &[f32], n_sc: usize) -> usize {
let zone_count = (n_sc / 4).max(1);
let subs_per_zone = n_sc / zone_count;
let mut max_dev = 0.0f32;
let mut max_zone = 0usize;
for z in 0..zone_count {
let start = z * subs_per_zone;
let end = if z == zone_count - 1 { n_sc } else { start + subs_per_zone };
let mut zone_dev = 0.0f32;
for i in start..end {
zone_dev += fabsf(amplitudes[i] - self.baseline_amp[i]);
}
if zone_dev > max_dev {
max_dev = zone_dev;
max_zone = z;
}
}
max_zone
}
/// Get current detector state.
pub fn state(&self) -> DetectorState {
self.state
}
/// Get total alerts fired.
pub fn total_alerts(&self) -> u32 {
self.alert_count
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_intrusion_init() {
let det = IntrusionDetector::new();
assert_eq!(det.state(), DetectorState::Calibrating);
assert_eq!(det.total_alerts(), 0);
}
#[test]
fn test_calibration_phase() {
let mut det = IntrusionDetector::new();
let phases = [0.0f32; 16];
let amps = [1.0f32; 16];
for _ in 0..BASELINE_FRAMES {
det.process_frame(&phases, &amps);
}
assert_eq!(det.state(), DetectorState::Monitoring);
}
#[test]
fn test_arm_after_quiet() {
let mut det = IntrusionDetector::new();
let phases = [0.0f32; 16];
let amps = [1.0f32; 16];
// Calibrate.
for _ in 0..BASELINE_FRAMES {
det.process_frame(&phases, &amps);
}
assert_eq!(det.state(), DetectorState::Monitoring);
// Feed quiet frames until armed.
for _ in 0..ARM_FRAMES + 1 {
det.process_frame(&phases, &amps);
}
assert_eq!(det.state(), DetectorState::Armed);
}
#[test]
fn test_intrusion_detection() {
let mut det = IntrusionDetector::new();
let phases = [0.0f32; 16];
let amps = [1.0f32; 16];
// Calibrate + arm.
for _ in 0..BASELINE_FRAMES {
det.process_frame(&phases, &amps);
}
for _ in 0..ARM_FRAMES + 1 {
det.process_frame(&phases, &amps);
}
assert_eq!(det.state(), DetectorState::Armed);
// Inject large disturbance with varying phases to maintain velocity.
let intrusion_amps = [10.0f32; 16];
let mut alert_detected = false;
for frame in 0..10 {
// Vary phase each frame so phase velocity stays high.
let phase_val = 3.0 + (frame as f32) * 2.0;
let intrusion_phases = [phase_val; 16];
let events = det.process_frame(&intrusion_phases, &intrusion_amps);
for &(et, _) in events {
if et == EVENT_INTRUSION_ALERT {
alert_detected = true;
}
}
}
assert!(alert_detected, "intrusion should be detected after large disturbance");
assert!(det.total_alerts() >= 1);
}
}
@@ -0,0 +1,228 @@
//! WiFi-DensePose WASM Edge — Hot-loadable sensing algorithms for ESP32-S3.
//!
//! ADR-040 Tier 3: Compiled to `wasm32-unknown-unknown`, these modules run
//! inside the WASM3 interpreter on the ESP32-S3 after Tier 2 DSP completes.
//!
//! # Host API (imported from "csi" namespace)
//!
//! The ESP32 firmware exposes CSI data through imported functions:
//! - `csi_get_phase(subcarrier) -> f32`
//! - `csi_get_amplitude(subcarrier) -> f32`
//! - `csi_get_variance(subcarrier) -> f32`
//! - `csi_get_bpm_breathing() -> f32`
//! - `csi_get_bpm_heartrate() -> f32`
//! - `csi_get_presence() -> i32`
//! - `csi_get_motion_energy() -> f32`
//! - `csi_get_n_persons() -> i32`
//! - `csi_get_timestamp() -> i32`
//! - `csi_emit_event(event_type: i32, value: f32)`
//! - `csi_log(ptr: i32, len: i32)`
//! - `csi_get_phase_history(buf_ptr: i32, max_len: i32) -> i32`
//!
//! # Module lifecycle (exported to host)
//!
//! - `on_init()` — called once when module is loaded
//! - `on_frame(n_subcarriers: i32)` — called per CSI frame (~20 Hz)
//! - `on_timer()` — called at configurable interval (default 1 s)
//!
//! # Build
//!
//! ```bash
//! cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --release
//! ```
#![cfg_attr(not(feature = "std"), no_std)]
#![allow(clippy::missing_safety_doc)]
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub mod gesture;
pub mod coherence;
pub mod adversarial;
pub mod rvf;
pub mod occupancy;
pub mod vital_trend;
pub mod intrusion;
// ── Host API FFI bindings ────────────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
extern "C" {
#[link_name = "csi_get_phase"]
pub fn host_get_phase(subcarrier: i32) -> f32;
#[link_name = "csi_get_amplitude"]
pub fn host_get_amplitude(subcarrier: i32) -> f32;
#[link_name = "csi_get_variance"]
pub fn host_get_variance(subcarrier: i32) -> f32;
#[link_name = "csi_get_bpm_breathing"]
pub fn host_get_bpm_breathing() -> f32;
#[link_name = "csi_get_bpm_heartrate"]
pub fn host_get_bpm_heartrate() -> f32;
#[link_name = "csi_get_presence"]
pub fn host_get_presence() -> i32;
#[link_name = "csi_get_motion_energy"]
pub fn host_get_motion_energy() -> f32;
#[link_name = "csi_get_n_persons"]
pub fn host_get_n_persons() -> i32;
#[link_name = "csi_get_timestamp"]
pub fn host_get_timestamp() -> i32;
#[link_name = "csi_emit_event"]
pub fn host_emit_event(event_type: i32, value: f32);
#[link_name = "csi_log"]
pub fn host_log(ptr: i32, len: i32);
#[link_name = "csi_get_phase_history"]
pub fn host_get_phase_history(buf_ptr: i32, max_len: i32) -> i32;
}
// ── Convenience wrappers ─────────────────────────────────────────────────────
/// Event type constants emitted via `csi_emit_event`.
///
/// Registry (ADR-041):
/// 0-99: Core (gesture, coherence, anomaly, custom)
/// 100-199: Medical (vital trends, apnea, brady/tachycardia)
/// 200-299: Security (intrusion, tamper, perimeter)
/// 300-399: Smart Building (occupancy zones, HVAC, lighting)
/// 400-499: Retail (foot traffic, dwell time)
/// 500-599: Industrial (vibration, proximity)
/// 600-699: Exotic (weather, wildlife, paranormal)
pub mod event_types {
// Core (0-99)
pub const GESTURE_DETECTED: i32 = 1;
pub const COHERENCE_SCORE: i32 = 2;
pub const ANOMALY_DETECTED: i32 = 3;
pub const CUSTOM_METRIC: i32 = 10;
// Medical (100-199) — see vital_trend module
pub const VITAL_TREND: i32 = 100;
pub const BRADYPNEA: i32 = 101;
pub const TACHYPNEA: i32 = 102;
pub const BRADYCARDIA: i32 = 103;
pub const TACHYCARDIA: i32 = 104;
pub const APNEA: i32 = 105;
// Security (200-299) — see intrusion module
pub const INTRUSION_ALERT: i32 = 200;
pub const INTRUSION_ZONE: i32 = 201;
// Smart Building (300-399) — see occupancy module
pub const ZONE_OCCUPIED: i32 = 300;
pub const ZONE_COUNT: i32 = 301;
pub const ZONE_TRANSITION: i32 = 302;
}
/// Log a message string to the ESP32 console (via host_log import).
#[cfg(target_arch = "wasm32")]
pub fn log_msg(msg: &str) {
unsafe {
host_log(msg.as_ptr() as i32, msg.len() as i32);
}
}
/// Emit a typed event to the host output packet.
#[cfg(target_arch = "wasm32")]
pub fn emit(event_type: i32, value: f32) {
unsafe {
host_emit_event(event_type, value);
}
}
// ── Panic handler (required for no_std WASM) ─────────────────────────────────
#[cfg(target_arch = "wasm32")]
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
// ── Default module entry points ──────────────────────────────────────────────
//
// Individual modules (gesture, coherence, adversarial) can define their own
// on_init/on_frame/on_timer. This default implementation demonstrates the
// combined pipeline: gesture detection + coherence monitoring + anomaly check.
#[cfg(target_arch = "wasm32")]
static mut STATE: CombinedState = CombinedState::new();
struct CombinedState {
gesture: gesture::GestureDetector,
coherence: coherence::CoherenceMonitor,
adversarial: adversarial::AnomalyDetector,
frame_count: u32,
}
impl CombinedState {
const fn new() -> Self {
Self {
gesture: gesture::GestureDetector::new(),
coherence: coherence::CoherenceMonitor::new(),
adversarial: adversarial::AnomalyDetector::new(),
frame_count: 0,
}
}
}
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn on_init() {
log_msg("wasm-edge: combined pipeline init");
}
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn on_frame(n_subcarriers: i32) {
let n_sc = n_subcarriers as usize;
let state = unsafe { &mut *core::ptr::addr_of_mut!(STATE) };
state.frame_count += 1;
// Collect phase/amplitude for top subcarriers (max 32).
let max_sc = if n_sc > 32 { 32 } else { n_sc };
let mut phases = [0.0f32; 32];
let mut amps = [0.0f32; 32];
for i in 0..max_sc {
unsafe {
phases[i] = host_get_phase(i as i32);
amps[i] = host_get_amplitude(i as i32);
}
}
// 1. Gesture detection (DTW template matching).
if let Some(gesture_id) = state.gesture.process_frame(&phases[..max_sc]) {
emit(event_types::GESTURE_DETECTED, gesture_id as f32);
}
// 2. Coherence monitoring (phase phasor).
let coh_score = state.coherence.process_frame(&phases[..max_sc]);
if state.frame_count % 20 == 0 {
emit(event_types::COHERENCE_SCORE, coh_score);
}
// 3. Anomaly detection (signal consistency check).
if state.adversarial.process_frame(&phases[..max_sc], &amps[..max_sc]) {
emit(event_types::ANOMALY_DETECTED, 1.0);
}
}
#[cfg(target_arch = "wasm32")]
#[no_mangle]
pub extern "C" fn on_timer() {
// Periodic summary.
let state = unsafe { &*core::ptr::addr_of!(STATE) };
let motion = unsafe { host_get_motion_energy() };
emit(event_types::CUSTOM_METRIC, motion);
if state.frame_count % 100 == 0 {
log_msg("wasm-edge: heartbeat");
}
}
@@ -0,0 +1,271 @@
//! Occupancy zone detection — ADR-041 Phase 1 module.
//!
//! Divides the sensing area into spatial zones and detects which zones
//! are occupied based on per-subcarrier amplitude/variance patterns.
//!
//! Each subcarrier group maps to a spatial zone (Fresnel zone geometry).
//! Occupied zones emit events with zone ID and confidence score.
use libm::fabsf;
/// Maximum number of zones (limited by subcarrier count).
const MAX_ZONES: usize = 8;
/// Maximum subcarriers to process.
const MAX_SC: usize = 32;
/// Minimum variance change to consider a zone occupied.
const ZONE_THRESHOLD: f32 = 0.02;
/// EMA smoothing factor for zone scores.
const ALPHA: f32 = 0.15;
/// Number of frames for baseline calibration.
const BASELINE_FRAMES: u32 = 200;
/// Event type for occupancy zone detection (300-series: Smart Building).
pub const EVENT_ZONE_OCCUPIED: i32 = 300;
pub const EVENT_ZONE_COUNT: i32 = 301;
pub const EVENT_ZONE_TRANSITION: i32 = 302;
/// Per-zone state.
struct ZoneState {
/// Baseline mean variance (calibrated from ambient).
baseline_var: f32,
/// Current EMA-smoothed zone score.
score: f32,
/// Whether this zone is currently occupied.
occupied: bool,
/// Previous occupied state (for transition detection).
prev_occupied: bool,
}
/// Occupancy zone detector.
pub struct OccupancyDetector {
zones: [ZoneState; MAX_ZONES],
n_zones: usize,
/// Calibration accumulators.
calib_sum: [f32; MAX_ZONES],
calib_count: u32,
calibrated: bool,
/// Frame counter.
frame_count: u32,
}
impl OccupancyDetector {
pub const fn new() -> Self {
const ZONE_INIT: ZoneState = ZoneState {
baseline_var: 0.0,
score: 0.0,
occupied: false,
prev_occupied: false,
};
Self {
zones: [ZONE_INIT; MAX_ZONES],
n_zones: 0,
calib_sum: [0.0; MAX_ZONES],
calib_count: 0,
calibrated: false,
frame_count: 0,
}
}
/// Process one frame of phase and amplitude data.
///
/// Returns a list of (event_type, value) pairs to emit.
/// Zone events encode zone_id in the integer part and confidence in the fraction.
pub fn process_frame(
&mut self,
phases: &[f32],
amplitudes: &[f32],
) -> &[(i32, f32)] {
let n_sc = phases.len().min(amplitudes.len()).min(MAX_SC);
if n_sc < 2 {
return &[];
}
self.frame_count += 1;
// Determine zone count: divide subcarriers into groups of 4.
let zone_count = (n_sc / 4).min(MAX_ZONES).max(1);
self.n_zones = zone_count;
let subs_per_zone = n_sc / zone_count;
// Compute per-zone variance of amplitudes.
let mut zone_vars = [0.0f32; MAX_ZONES];
for z in 0..zone_count {
let start = z * subs_per_zone;
let end = if z == zone_count - 1 { n_sc } else { start + subs_per_zone };
let count = (end - start) as f32;
let mut mean = 0.0f32;
for i in start..end {
mean += amplitudes[i];
}
mean /= count;
let mut var = 0.0f32;
for i in start..end {
let d = amplitudes[i] - mean;
var += d * d;
}
zone_vars[z] = var / count;
}
// Calibration phase.
if !self.calibrated {
for z in 0..zone_count {
self.calib_sum[z] += zone_vars[z];
}
self.calib_count += 1;
if self.calib_count >= BASELINE_FRAMES {
let n = self.calib_count as f32;
for z in 0..zone_count {
self.zones[z].baseline_var = self.calib_sum[z] / n;
}
self.calibrated = true;
}
return &[];
}
// Score each zone: deviation from baseline.
let mut total_occupied = 0u8;
for z in 0..zone_count {
let deviation = fabsf(zone_vars[z] - self.zones[z].baseline_var);
let raw_score = if self.zones[z].baseline_var > 0.001 {
deviation / self.zones[z].baseline_var
} else {
deviation * 100.0
};
// EMA smooth.
self.zones[z].score = ALPHA * raw_score + (1.0 - ALPHA) * self.zones[z].score;
// Threshold with hysteresis.
self.zones[z].prev_occupied = self.zones[z].occupied;
if self.zones[z].occupied {
// Higher threshold to leave occupied state.
self.zones[z].occupied = self.zones[z].score > ZONE_THRESHOLD * 0.5;
} else {
self.zones[z].occupied = self.zones[z].score > ZONE_THRESHOLD;
}
if self.zones[z].occupied {
total_occupied += 1;
}
}
// Build output events in a static buffer.
// We re-use a static to avoid allocation in no_std.
static mut EVENTS: [(i32, f32); 12] = [(0, 0.0); 12];
let mut n_events = 0usize;
// Emit per-zone occupancy (every 10 frames to limit bandwidth).
if self.frame_count % 10 == 0 {
for z in 0..zone_count {
if self.zones[z].occupied && n_events < 10 {
// Encode zone_id in integer part, confidence in fractional.
let val = z as f32 + self.zones[z].score.min(0.99);
unsafe {
EVENTS[n_events] = (EVENT_ZONE_OCCUPIED, val);
}
n_events += 1;
}
}
// Emit total occupied zone count.
if n_events < 11 {
unsafe {
EVENTS[n_events] = (EVENT_ZONE_COUNT, total_occupied as f32);
}
n_events += 1;
}
}
// Emit transitions immediately.
for z in 0..zone_count {
if self.zones[z].occupied != self.zones[z].prev_occupied && n_events < 12 {
let val = z as f32 + if self.zones[z].occupied { 0.5 } else { 0.0 };
unsafe {
EVENTS[n_events] = (EVENT_ZONE_TRANSITION, val);
}
n_events += 1;
}
}
unsafe { &EVENTS[..n_events] }
}
/// Get the number of currently occupied zones.
pub fn occupied_count(&self) -> u8 {
let mut count = 0u8;
for z in 0..self.n_zones {
if self.zones[z].occupied {
count += 1;
}
}
count
}
/// Check if a specific zone is occupied.
pub fn is_zone_occupied(&self, zone_id: usize) -> bool {
zone_id < self.n_zones && self.zones[zone_id].occupied
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_occupancy_detector_init() {
let det = OccupancyDetector::new();
assert_eq!(det.frame_count, 0);
assert!(!det.calibrated);
assert_eq!(det.occupied_count(), 0);
}
#[test]
fn test_occupancy_calibration() {
let mut det = OccupancyDetector::new();
let phases = [0.0f32; 16];
let amps = [1.0f32; 16];
// Feed baseline frames.
for _ in 0..BASELINE_FRAMES {
let events = det.process_frame(&phases, &amps);
assert!(events.is_empty());
}
assert!(det.calibrated);
}
#[test]
fn test_occupancy_detection() {
let mut det = OccupancyDetector::new();
let phases = [0.0f32; 16];
let uniform_amps = [1.0f32; 16];
// Calibrate with uniform amplitudes.
for _ in 0..BASELINE_FRAMES {
det.process_frame(&phases, &uniform_amps);
}
// Now inject a disturbance in zone 0 (first 4 subcarriers).
let mut disturbed = [1.0f32; 16];
disturbed[0] = 5.0;
disturbed[1] = 0.2;
disturbed[2] = 4.5;
disturbed[3] = 0.3;
// Process several frames with disturbance.
for _ in 0..50 {
det.process_frame(&phases, &disturbed);
}
// Zone 0 should be occupied.
assert!(det.is_zone_occupied(0));
assert!(det.occupied_count() >= 1);
}
}
@@ -0,0 +1,274 @@
//! RVF (RuVector Format) container for WASM sensing modules.
//!
//! Defines the binary format shared between the ESP32 C parser and the
//! Rust builder tool. The builder (behind `std` feature) packs a `.wasm`
//! binary with a manifest into an `.rvf` file.
//!
//! # Binary Layout
//!
//! ```text
//! [Header: 32 bytes][Manifest: 96 bytes][WASM: N bytes]
//! [Signature: 0|64 bytes][TestVectors: M bytes]
//! ```
/// RVF magic: `"RVF\x01"` as u32 LE = `0x01465652`.
pub const RVF_MAGIC: u32 = 0x0146_5652;
/// Current format version.
pub const RVF_FORMAT_VERSION: u16 = 1;
/// Header size in bytes.
pub const RVF_HEADER_SIZE: usize = 32;
/// Manifest size in bytes.
pub const RVF_MANIFEST_SIZE: usize = 96;
/// Ed25519 signature length.
pub const RVF_SIGNATURE_LEN: usize = 64;
/// Host API version supported by this crate.
pub const RVF_HOST_API_V1: u16 = 1;
// ── Capability flags ─────────────────────────────────────────────────────
pub const CAP_READ_PHASE: u32 = 1 << 0;
pub const CAP_READ_AMPLITUDE: u32 = 1 << 1;
pub const CAP_READ_VARIANCE: u32 = 1 << 2;
pub const CAP_READ_VITALS: u32 = 1 << 3;
pub const CAP_READ_HISTORY: u32 = 1 << 4;
pub const CAP_EMIT_EVENTS: u32 = 1 << 5;
pub const CAP_LOG: u32 = 1 << 6;
pub const CAP_ALL: u32 = 0x7F;
// ── Header flags ─────────────────────────────────────────────────────────
pub const FLAG_HAS_SIGNATURE: u16 = 1 << 0;
pub const FLAG_HAS_TEST_VECTORS: u16 = 1 << 1;
// ── Wire structs (must match C layout exactly) ───────────────────────────
/// RVF header (32 bytes, packed, little-endian).
#[repr(C, packed)]
#[derive(Clone, Copy)]
pub struct RvfHeader {
pub magic: u32,
pub format_version: u16,
pub flags: u16,
pub manifest_len: u32,
pub wasm_len: u32,
pub signature_len: u32,
pub test_vectors_len: u32,
pub total_len: u32,
pub reserved: u32,
}
/// RVF manifest (96 bytes, packed, little-endian).
#[repr(C, packed)]
#[derive(Clone, Copy)]
pub struct RvfManifest {
pub module_name: [u8; 32],
pub required_host_api: u16,
pub capabilities: u32,
pub max_frame_us: u32,
pub max_events_per_sec: u16,
pub memory_limit_kb: u16,
pub event_schema_version: u16,
pub build_hash: [u8; 32],
pub min_subcarriers: u16,
pub max_subcarriers: u16,
pub author: [u8; 10],
pub _reserved: [u8; 2],
}
// Compile-time size checks.
const _: () = assert!(core::mem::size_of::<RvfHeader>() == RVF_HEADER_SIZE);
const _: () = assert!(core::mem::size_of::<RvfManifest>() == RVF_MANIFEST_SIZE);
// ── Builder (std only) ──────────────────────────────────────────────────
#[cfg(feature = "std")]
pub mod builder {
use super::*;
use sha2::{Digest, Sha256};
use std::io::Write;
/// Copy a string into a fixed-size null-padded buffer.
fn copy_to_fixed<const N: usize>(src: &str) -> [u8; N] {
let mut buf = [0u8; N];
let len = src.len().min(N - 1); // leave room for null
buf[..len].copy_from_slice(&src.as_bytes()[..len]);
buf
}
/// Configuration for building an RVF file.
pub struct RvfConfig {
pub module_name: String,
pub author: String,
pub capabilities: u32,
pub max_frame_us: u32,
pub max_events_per_sec: u16,
pub memory_limit_kb: u16,
pub event_schema_version: u16,
pub min_subcarriers: u16,
pub max_subcarriers: u16,
}
impl Default for RvfConfig {
fn default() -> Self {
Self {
module_name: String::from("unnamed"),
author: String::from("unknown"),
capabilities: CAP_ALL,
max_frame_us: 10_000,
max_events_per_sec: 0,
memory_limit_kb: 0,
event_schema_version: 1,
min_subcarriers: 0,
max_subcarriers: 0,
}
}
}
/// Build an RVF container from WASM binary data and a config.
///
/// Returns the complete RVF as a byte vector.
/// The signature field is zeroed — sign externally and patch bytes
/// at the signature offset.
pub fn build_rvf(wasm_data: &[u8], config: &RvfConfig) -> Vec<u8> {
// Compute SHA-256 of WASM payload.
let mut hasher = Sha256::new();
hasher.update(wasm_data);
let hash: [u8; 32] = hasher.finalize().into();
// Build manifest.
let manifest = RvfManifest {
module_name: copy_to_fixed::<32>(&config.module_name),
required_host_api: RVF_HOST_API_V1,
capabilities: config.capabilities,
max_frame_us: config.max_frame_us,
max_events_per_sec: config.max_events_per_sec,
memory_limit_kb: config.memory_limit_kb,
event_schema_version: config.event_schema_version,
build_hash: hash,
min_subcarriers: config.min_subcarriers,
max_subcarriers: config.max_subcarriers,
author: copy_to_fixed::<10>(&config.author),
_reserved: [0; 2],
};
let signature_len = RVF_SIGNATURE_LEN as u32;
let total_len = (RVF_HEADER_SIZE + RVF_MANIFEST_SIZE) as u32
+ wasm_data.len() as u32
+ signature_len;
// Build header.
let header = RvfHeader {
magic: RVF_MAGIC,
format_version: RVF_FORMAT_VERSION,
flags: FLAG_HAS_SIGNATURE,
manifest_len: RVF_MANIFEST_SIZE as u32,
wasm_len: wasm_data.len() as u32,
signature_len,
test_vectors_len: 0,
total_len,
reserved: 0,
};
// Serialize.
let mut out = Vec::with_capacity(total_len as usize);
// SAFETY: header and manifest are packed repr(C) structs with no padding.
let header_bytes: &[u8] = unsafe {
core::slice::from_raw_parts(
&header as *const RvfHeader as *const u8,
RVF_HEADER_SIZE,
)
};
out.write_all(header_bytes).unwrap();
let manifest_bytes: &[u8] = unsafe {
core::slice::from_raw_parts(
&manifest as *const RvfManifest as *const u8,
RVF_MANIFEST_SIZE,
)
};
out.write_all(manifest_bytes).unwrap();
out.write_all(wasm_data).unwrap();
// Placeholder signature (zeroed — sign externally).
out.write_all(&[0u8; RVF_SIGNATURE_LEN]).unwrap();
out
}
/// Patch a signature into an existing RVF buffer.
///
/// The signature covers bytes 0 through (header + manifest + wasm - 1).
pub fn patch_signature(rvf: &mut [u8], signature: &[u8; RVF_SIGNATURE_LEN]) {
let sig_offset = RVF_HEADER_SIZE + RVF_MANIFEST_SIZE;
// Read wasm_len from header.
let wasm_len = u32::from_le_bytes([
rvf[12], rvf[13], rvf[14], rvf[15],
]) as usize;
let offset = sig_offset + wasm_len;
rvf[offset..offset + RVF_SIGNATURE_LEN].copy_from_slice(signature);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_rvf_roundtrip() {
// Minimal valid WASM: magic + version.
let wasm = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
let config = RvfConfig {
module_name: "test-module".into(),
author: "tester".into(),
capabilities: CAP_READ_PHASE | CAP_EMIT_EVENTS,
max_frame_us: 5000,
..Default::default()
};
let rvf = build_rvf(&wasm, &config);
// Check magic.
let magic = u32::from_le_bytes([rvf[0], rvf[1], rvf[2], rvf[3]]);
assert_eq!(magic, RVF_MAGIC);
// Check total length.
let expected_len = RVF_HEADER_SIZE + RVF_MANIFEST_SIZE + wasm.len()
+ RVF_SIGNATURE_LEN;
assert_eq!(rvf.len(), expected_len);
// Check WASM payload.
let wasm_offset = RVF_HEADER_SIZE + RVF_MANIFEST_SIZE;
assert_eq!(&rvf[wasm_offset..wasm_offset + wasm.len()], &wasm);
// Check module name in manifest.
let name_offset = RVF_HEADER_SIZE;
let name_bytes = &rvf[name_offset..name_offset + 11];
assert_eq!(&name_bytes[..11], b"test-module");
}
#[test]
fn test_build_hash_integrity() {
let wasm = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
let config = RvfConfig::default();
let rvf = build_rvf(&wasm, &config);
// Extract build_hash from manifest (offset 48 from manifest start).
let hash_offset = RVF_HEADER_SIZE + 32 + 2 + 4 + 4 + 2 + 2 + 2;
let stored_hash = &rvf[hash_offset..hash_offset + 32];
// Compute expected hash.
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(&wasm);
let expected: [u8; 32] = hasher.finalize().into();
assert_eq!(stored_hash, &expected);
}
}
}
@@ -0,0 +1,347 @@
//! Vital sign trend analysis — ADR-041 Phase 1 module.
//!
//! Monitors breathing rate and heart rate over time windows (1-min, 5-min, 15-min)
//! and detects clinically significant trends:
//! - Bradypnea (breathing < 12 BPM sustained)
//! - Tachypnea (breathing > 25 BPM sustained)
//! - Bradycardia (HR < 50 BPM sustained)
//! - Tachycardia (HR > 120 BPM sustained)
//! - Apnea (no breathing detected for > 20 seconds)
//! - Trend reversal (sudden direction change in vital trajectory)
// No libm imports needed — pure arithmetic.
/// Window sizes in samples (at 1 Hz timer rate).
const WINDOW_1M: usize = 60;
const WINDOW_5M: usize = 300;
/// Maximum history depth.
const MAX_HISTORY: usize = 300; // 5 minutes at 1 Hz.
/// Clinical thresholds (BPM).
const BRADYPNEA_THRESH: f32 = 12.0;
const TACHYPNEA_THRESH: f32 = 25.0;
const BRADYCARDIA_THRESH: f32 = 50.0;
const TACHYCARDIA_THRESH: f32 = 120.0;
const APNEA_SECONDS: u32 = 20;
/// Minimum consecutive alerts before emitting (debounce).
const ALERT_DEBOUNCE: u8 = 5;
/// Event types (100-series: Medical).
pub const EVENT_VITAL_TREND: i32 = 100;
pub const EVENT_BRADYPNEA: i32 = 101;
pub const EVENT_TACHYPNEA: i32 = 102;
pub const EVENT_BRADYCARDIA: i32 = 103;
pub const EVENT_TACHYCARDIA: i32 = 104;
pub const EVENT_APNEA: i32 = 105;
pub const EVENT_BREATHING_AVG: i32 = 110;
pub const EVENT_HEARTRATE_AVG: i32 = 111;
/// Ring buffer for vital sign history.
struct VitalHistory {
values: [f32; MAX_HISTORY],
len: usize,
idx: usize,
}
impl VitalHistory {
const fn new() -> Self {
Self {
values: [0.0; MAX_HISTORY],
len: 0,
idx: 0,
}
}
fn push(&mut self, val: f32) {
self.values[self.idx] = val;
self.idx = (self.idx + 1) % MAX_HISTORY;
if self.len < MAX_HISTORY {
self.len += 1;
}
}
/// Compute mean of the last N samples.
fn mean_last(&self, n: usize) -> f32 {
let count = n.min(self.len);
if count == 0 {
return 0.0;
}
let mut sum = 0.0f32;
for i in 0..count {
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
sum += self.values[ri];
}
sum / count as f32
}
/// Check if all of the last N samples are below threshold.
#[allow(dead_code)]
fn all_below(&self, n: usize, threshold: f32) -> bool {
let count = n.min(self.len);
if count < n {
return false;
}
for i in 0..count {
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
if self.values[ri] >= threshold {
return false;
}
}
true
}
/// Check if all of the last N samples are above threshold.
#[allow(dead_code)]
fn all_above(&self, n: usize, threshold: f32) -> bool {
let count = n.min(self.len);
if count < n {
return false;
}
for i in 0..count {
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
if self.values[ri] <= threshold {
return false;
}
}
true
}
/// Compute simple linear trend (positive = increasing).
fn trend(&self, n: usize) -> f32 {
let count = n.min(self.len);
if count < 4 {
return 0.0;
}
// Simple: (last_quarter_mean - first_quarter_mean) / window.
let quarter = count / 4;
let mut first_sum = 0.0f32;
let mut last_sum = 0.0f32;
for i in 0..quarter {
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
first_sum += self.values[ri];
}
for i in (count - quarter)..count {
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
last_sum += self.values[ri];
}
let first_mean = first_sum / quarter as f32;
let last_mean = last_sum / quarter as f32;
(last_mean - first_mean) / count as f32
}
}
/// Vital trend analyzer.
pub struct VitalTrendAnalyzer {
breathing: VitalHistory,
heartrate: VitalHistory,
/// Debounce counters for each alert type.
bradypnea_count: u8,
tachypnea_count: u8,
bradycardia_count: u8,
tachycardia_count: u8,
/// Consecutive samples with near-zero breathing.
apnea_counter: u32,
/// Timer call count.
timer_count: u32,
}
impl VitalTrendAnalyzer {
pub const fn new() -> Self {
Self {
breathing: VitalHistory::new(),
heartrate: VitalHistory::new(),
bradypnea_count: 0,
tachypnea_count: 0,
bradycardia_count: 0,
tachycardia_count: 0,
apnea_counter: 0,
timer_count: 0,
}
}
/// Called at ~1 Hz with current vital signs.
///
/// Returns events as (event_type, value) pairs.
pub fn on_timer(&mut self, breathing_bpm: f32, heartrate_bpm: f32) -> &[(i32, f32)] {
self.timer_count += 1;
self.breathing.push(breathing_bpm);
self.heartrate.push(heartrate_bpm);
static mut EVENTS: [(i32, f32); 8] = [(0, 0.0); 8];
let mut n = 0usize;
// ── Apnea detection (highest priority) ──────────────────────────
if breathing_bpm < 1.0 {
self.apnea_counter += 1;
if self.apnea_counter >= APNEA_SECONDS {
unsafe {
EVENTS[n] = (EVENT_APNEA, self.apnea_counter as f32);
}
n += 1;
}
} else {
self.apnea_counter = 0;
}
// ── Bradypnea (sustained low breathing) ────────────────────────
if breathing_bpm > 0.0 && breathing_bpm < BRADYPNEA_THRESH {
self.bradypnea_count = self.bradypnea_count.saturating_add(1);
if self.bradypnea_count >= ALERT_DEBOUNCE && n < 7 {
unsafe {
EVENTS[n] = (EVENT_BRADYPNEA, breathing_bpm);
}
n += 1;
}
} else {
self.bradypnea_count = 0;
}
// ── Tachypnea (sustained high breathing) ───────────────────────
if breathing_bpm > TACHYPNEA_THRESH {
self.tachypnea_count = self.tachypnea_count.saturating_add(1);
if self.tachypnea_count >= ALERT_DEBOUNCE && n < 7 {
unsafe {
EVENTS[n] = (EVENT_TACHYPNEA, breathing_bpm);
}
n += 1;
}
} else {
self.tachypnea_count = 0;
}
// ── Bradycardia ────────────────────────────────────────────────
if heartrate_bpm > 0.0 && heartrate_bpm < BRADYCARDIA_THRESH {
self.bradycardia_count = self.bradycardia_count.saturating_add(1);
if self.bradycardia_count >= ALERT_DEBOUNCE && n < 7 {
unsafe {
EVENTS[n] = (EVENT_BRADYCARDIA, heartrate_bpm);
}
n += 1;
}
} else {
self.bradycardia_count = 0;
}
// ── Tachycardia ────────────────────────────────────────────────
if heartrate_bpm > TACHYCARDIA_THRESH {
self.tachycardia_count = self.tachycardia_count.saturating_add(1);
if self.tachycardia_count >= ALERT_DEBOUNCE && n < 7 {
unsafe {
EVENTS[n] = (EVENT_TACHYCARDIA, heartrate_bpm);
}
n += 1;
}
} else {
self.tachycardia_count = 0;
}
// ── Periodic averages (every 60 seconds) ───────────────────────
if self.timer_count % 60 == 0 && self.breathing.len >= WINDOW_1M {
let br_avg = self.breathing.mean_last(WINDOW_1M);
let hr_avg = self.heartrate.mean_last(WINDOW_1M);
if n < 7 {
unsafe {
EVENTS[n] = (EVENT_BREATHING_AVG, br_avg);
}
n += 1;
}
if n < 8 {
unsafe {
EVENTS[n] = (EVENT_HEARTRATE_AVG, hr_avg);
}
n += 1;
}
}
unsafe { &EVENTS[..n] }
}
/// Get the 1-minute breathing average.
pub fn breathing_avg_1m(&self) -> f32 {
self.breathing.mean_last(WINDOW_1M)
}
/// Get the breathing trend (positive = increasing).
pub fn breathing_trend_5m(&self) -> f32 {
self.breathing.trend(WINDOW_5M)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vital_trend_init() {
let vt = VitalTrendAnalyzer::new();
assert_eq!(vt.timer_count, 0);
assert_eq!(vt.apnea_counter, 0);
}
#[test]
fn test_normal_vitals_no_alerts() {
let mut vt = VitalTrendAnalyzer::new();
// Normal breathing (16 BPM) and heart rate (72 BPM).
for _ in 0..60 {
let events = vt.on_timer(16.0, 72.0);
// Should not generate clinical alerts.
for &(et, _) in events {
assert!(
et != EVENT_BRADYPNEA && et != EVENT_TACHYPNEA
&& et != EVENT_BRADYCARDIA && et != EVENT_TACHYCARDIA
&& et != EVENT_APNEA,
"unexpected clinical alert with normal vitals"
);
}
}
}
#[test]
fn test_apnea_detection() {
let mut vt = VitalTrendAnalyzer::new();
let mut apnea_detected = false;
for _ in 0..30 {
let events = vt.on_timer(0.0, 72.0);
for &(et, _) in events {
if et == EVENT_APNEA {
apnea_detected = true;
}
}
}
assert!(apnea_detected, "apnea should be detected after 20+ seconds of zero breathing");
}
#[test]
fn test_tachycardia_detection() {
let mut vt = VitalTrendAnalyzer::new();
let mut tachy_detected = false;
for _ in 0..20 {
let events = vt.on_timer(16.0, 130.0);
for &(et, _) in events {
if et == EVENT_TACHYCARDIA {
tachy_detected = true;
}
}
}
assert!(tachy_detected, "tachycardia should be detected with sustained HR > 120");
}
#[test]
fn test_breathing_average() {
let mut vt = VitalTrendAnalyzer::new();
for _ in 0..60 {
vt.on_timer(16.0, 72.0);
}
let avg = vt.breathing_avg_1m();
assert!((avg - 16.0).abs() < 0.1, "1-min breathing average should be ~16.0");
}
}