mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
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:
@@ -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, &s);
|
||||
}
|
||||
|
||||
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, &s);
|
||||
}
|
||||
assert_eq!(det.state(), DetectorState::Monitoring);
|
||||
|
||||
// Feed quiet frames until armed.
|
||||
for _ in 0..ARM_FRAMES + 1 {
|
||||
det.process_frame(&phases, &s);
|
||||
}
|
||||
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, &s);
|
||||
}
|
||||
for _ in 0..ARM_FRAMES + 1 {
|
||||
det.process_frame(&phases, &s);
|
||||
}
|
||||
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], &s[..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, &s);
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user