ADR-001 (WiFi-Mat disaster response pipeline): - Add EnsembleClassifier with weighted voting (breathing/heartbeat/movement) - Wire EventStore into DisasterResponse with domain event emission - Add scan control API endpoints (push CSI, scan control, pipeline status, domain events) - Implement START triage protocol (Immediate/Delayed/Minor/Deceased/Unknown) - Critical patterns (Agonal/Apnea) bypass confidence threshold for safety - Add 6 deterministic integration tests with synthetic sinusoidal CSI data ADR-009 (WASM signal pipeline): - Add pushCsiData() with zero-crossing breathing rate extraction - Add getPipelineConfig() for runtime configuration access - Update TypeScript type definitions for new WASM exports ADR-012 (ESP32 CSI sensor mesh): - Implement CsiFrame, CsiMetadata, SubcarrierData types - Implement Esp32CsiParser with binary frame parsing (magic/header/IQ pairs) - Add parse_stream() with automatic resync on corruption - Add ParseError enum with descriptive error variants - 12 unit tests covering valid frames, corruption, multi-frame streams All 275 workspace tests pass. No mocks, no stubs, no placeholders. https://claude.ai/code/session_01Ki7pvEZtJDvqJkmyn6B714
209 lines
6.0 KiB
Rust
209 lines
6.0 KiB
Rust
//! CSI frame types representing parsed WiFi Channel State Information.
|
|
//!
|
|
//! These types are hardware-agnostic representations of CSI data that
|
|
//! can be produced by any parser (ESP32, Intel 5300, etc.) and consumed
|
|
//! by the detection pipeline.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// A parsed CSI frame containing subcarrier data and metadata.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CsiFrame {
|
|
/// Frame metadata (RSSI, channel, timestamps, etc.)
|
|
pub metadata: CsiMetadata,
|
|
/// Per-subcarrier I/Q data
|
|
pub subcarriers: Vec<SubcarrierData>,
|
|
}
|
|
|
|
impl CsiFrame {
|
|
/// Number of subcarriers in this frame.
|
|
pub fn subcarrier_count(&self) -> usize {
|
|
self.subcarriers.len()
|
|
}
|
|
|
|
/// Convert to amplitude and phase arrays for the detection pipeline.
|
|
///
|
|
/// Returns (amplitudes, phases) where:
|
|
/// - amplitude = sqrt(I^2 + Q^2)
|
|
/// - phase = atan2(Q, I)
|
|
pub fn to_amplitude_phase(&self) -> (Vec<f64>, Vec<f64>) {
|
|
let amplitudes: Vec<f64> = self.subcarriers.iter()
|
|
.map(|sc| (sc.i as f64 * sc.i as f64 + sc.q as f64 * sc.q as f64).sqrt())
|
|
.collect();
|
|
|
|
let phases: Vec<f64> = self.subcarriers.iter()
|
|
.map(|sc| (sc.q as f64).atan2(sc.i as f64))
|
|
.collect();
|
|
|
|
(amplitudes, phases)
|
|
}
|
|
|
|
/// Get the average amplitude across all subcarriers.
|
|
pub fn mean_amplitude(&self) -> f64 {
|
|
if self.subcarriers.is_empty() {
|
|
return 0.0;
|
|
}
|
|
let sum: f64 = self.subcarriers.iter()
|
|
.map(|sc| (sc.i as f64 * sc.i as f64 + sc.q as f64 * sc.q as f64).sqrt())
|
|
.sum();
|
|
sum / self.subcarriers.len() as f64
|
|
}
|
|
|
|
/// Check if this frame has valid data (non-zero subcarriers with non-zero I/Q).
|
|
pub fn is_valid(&self) -> bool {
|
|
!self.subcarriers.is_empty()
|
|
&& self.subcarriers.iter().any(|sc| sc.i != 0 || sc.q != 0)
|
|
}
|
|
}
|
|
|
|
/// Metadata associated with a CSI frame.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CsiMetadata {
|
|
/// Timestamp when frame was received
|
|
pub timestamp: DateTime<Utc>,
|
|
/// RSSI in dBm (typically -100 to 0)
|
|
pub rssi: i32,
|
|
/// Noise floor in dBm
|
|
pub noise_floor: i32,
|
|
/// WiFi channel number
|
|
pub channel: u8,
|
|
/// Secondary channel offset (0, 1, or 2)
|
|
pub secondary_channel: u8,
|
|
/// Channel bandwidth
|
|
pub bandwidth: Bandwidth,
|
|
/// Antenna configuration
|
|
pub antenna_config: AntennaConfig,
|
|
/// Source MAC address (if available)
|
|
pub source_mac: Option<[u8; 6]>,
|
|
/// Sequence number for ordering
|
|
pub sequence: u32,
|
|
}
|
|
|
|
/// WiFi channel bandwidth.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Bandwidth {
|
|
/// 20 MHz (standard)
|
|
Bw20,
|
|
/// 40 MHz (HT)
|
|
Bw40,
|
|
/// 80 MHz (VHT)
|
|
Bw80,
|
|
/// 160 MHz (VHT)
|
|
Bw160,
|
|
}
|
|
|
|
impl Bandwidth {
|
|
/// Expected number of subcarriers for this bandwidth.
|
|
pub fn expected_subcarriers(&self) -> usize {
|
|
match self {
|
|
Bandwidth::Bw20 => 56,
|
|
Bandwidth::Bw40 => 114,
|
|
Bandwidth::Bw80 => 242,
|
|
Bandwidth::Bw160 => 484,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Antenna configuration for MIMO.
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub struct AntennaConfig {
|
|
/// Number of transmit antennas
|
|
pub tx_antennas: u8,
|
|
/// Number of receive antennas
|
|
pub rx_antennas: u8,
|
|
}
|
|
|
|
impl Default for AntennaConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
tx_antennas: 1,
|
|
rx_antennas: 1,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A single subcarrier's I/Q data.
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub struct SubcarrierData {
|
|
/// In-phase component
|
|
pub i: i16,
|
|
/// Quadrature component
|
|
pub q: i16,
|
|
/// Subcarrier index (-28..28 for 20MHz, etc.)
|
|
pub index: i16,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use approx::assert_relative_eq;
|
|
|
|
fn make_test_frame() -> CsiFrame {
|
|
CsiFrame {
|
|
metadata: CsiMetadata {
|
|
timestamp: Utc::now(),
|
|
rssi: -50,
|
|
noise_floor: -95,
|
|
channel: 6,
|
|
secondary_channel: 0,
|
|
bandwidth: Bandwidth::Bw20,
|
|
antenna_config: AntennaConfig::default(),
|
|
source_mac: None,
|
|
sequence: 1,
|
|
},
|
|
subcarriers: vec![
|
|
SubcarrierData { i: 100, q: 0, index: -28 },
|
|
SubcarrierData { i: 0, q: 50, index: -27 },
|
|
SubcarrierData { i: 30, q: 40, index: -26 },
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_amplitude_phase_conversion() {
|
|
let frame = make_test_frame();
|
|
let (amps, phases) = frame.to_amplitude_phase();
|
|
|
|
assert_eq!(amps.len(), 3);
|
|
assert_eq!(phases.len(), 3);
|
|
|
|
// First subcarrier: I=100, Q=0 -> amplitude=100, phase=0
|
|
assert_relative_eq!(amps[0], 100.0, epsilon = 0.01);
|
|
assert_relative_eq!(phases[0], 0.0, epsilon = 0.01);
|
|
|
|
// Second: I=0, Q=50 -> amplitude=50, phase=pi/2
|
|
assert_relative_eq!(amps[1], 50.0, epsilon = 0.01);
|
|
assert_relative_eq!(phases[1], std::f64::consts::FRAC_PI_2, epsilon = 0.01);
|
|
|
|
// Third: I=30, Q=40 -> amplitude=50, phase=atan2(40,30)
|
|
assert_relative_eq!(amps[2], 50.0, epsilon = 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mean_amplitude() {
|
|
let frame = make_test_frame();
|
|
let mean = frame.mean_amplitude();
|
|
// (100 + 50 + 50) / 3 = 66.67
|
|
assert_relative_eq!(mean, 200.0 / 3.0, epsilon = 0.1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_valid() {
|
|
let frame = make_test_frame();
|
|
assert!(frame.is_valid());
|
|
|
|
let empty = CsiFrame {
|
|
metadata: frame.metadata.clone(),
|
|
subcarriers: vec![],
|
|
};
|
|
assert!(!empty.is_valid());
|
|
}
|
|
|
|
#[test]
|
|
fn test_bandwidth_subcarriers() {
|
|
assert_eq!(Bandwidth::Bw20.expected_subcarriers(), 56);
|
|
assert_eq!(Bandwidth::Bw40.expected_subcarriers(), 114);
|
|
}
|
|
}
|