mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
The Rust port lived two directories deep (rust-port/wifi-densepose-rs/) without any sibling under rust-port/ that warranted the extra level. Move the whole workspace up to v2/ to match v1/ (Python) at the same depth and shorten every cd / build command across the repo. git mv preserves history for all tracked files. 60 files updated for path references (CI workflows, ADRs, docs, scripts, READMEs, internal .claude-flow state). Two manual fixes for relative-cd paths in CLAUDE.md and ADR-043 that became wrong after the depth change (cd ../.. → cd ..). Validated: - cargo check --workspace --no-default-features → clean (after target/ nuke; the gitignored target/ was carried by the OS rename and had hard-coded old paths in build scripts) - cargo test --workspace --no-default-features → 1,539 passed, 0 failed, 8 ignored (same totals as pre-rename) - ESP32-S3 on COM7 → still streaming live CSI (cb #40300, RSSI -64 dBm) After-merge follow-up: contributors should `rm -rf v2/target` once and let cargo regenerate from the new path.
379 lines
12 KiB
Rust
379 lines
12 KiB
Rust
//! 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.
|
|
|
|
#[cfg(not(feature = "std"))]
|
|
use libm::{fabsf, sqrtf};
|
|
#[cfg(feature = "std")]
|
|
fn sqrtf(x: f32) -> f32 { x.sqrt() }
|
|
#[cfg(feature = "std")]
|
|
fn fabsf(x: f32) -> f32 { x.abs() }
|
|
|
|
/// 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);
|
|
}
|
|
}
|