323 lines
9.3 KiB
Rust
323 lines
9.3 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 (ADR-018 format).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CsiMetadata {
|
|
/// Timestamp when frame was received
|
|
pub timestamp: DateTime<Utc>,
|
|
/// Node identifier (0-255)
|
|
pub node_id: u8,
|
|
/// Number of antennas
|
|
pub n_antennas: u8,
|
|
/// Number of subcarriers
|
|
pub n_subcarriers: u16,
|
|
/// Channel center frequency in MHz
|
|
pub channel_freq_mhz: u32,
|
|
/// RSSI in dBm (signed byte, typically -100 to 0)
|
|
pub rssi_dbm: i8,
|
|
/// Noise floor in dBm (signed byte)
|
|
pub noise_floor_dbm: i8,
|
|
/// Channel bandwidth (derived from n_subcarriers)
|
|
pub bandwidth: Bandwidth,
|
|
/// Antenna configuration (populated from n_antennas)
|
|
pub antenna_config: AntennaConfig,
|
|
/// Sequence number for ordering
|
|
pub sequence: u32,
|
|
/// ADR-110: PPDU type from ADR-018 byte 18. None on pre-ADR-110 firmware
|
|
/// (or when CONFIG_CSI_FRAME_HE_TAGGING is disabled — byte stays zero
|
|
/// and pre-ADR-110 readers see the same zero, full backwards compat).
|
|
/// Byte 18 = 0 reads as PpduType::HtLegacy (the wire encoding for the
|
|
/// HT/legacy bucket); 0xFF reads as PpduType::Unknown.
|
|
pub ppdu_type: PpduType,
|
|
/// ADR-110: flags from ADR-018 byte 19 — bandwidth bits, STBC, LDPC,
|
|
/// 802.15.4-time-sync-valid bit. See [`Adr018Flags`].
|
|
pub adr018_flags: Adr018Flags,
|
|
}
|
|
|
|
/// PPDU type encoded in ADR-018 byte 18 (ADR-110 extension).
|
|
///
|
|
/// Wire encoding (matches firmware `csi_collector.c`):
|
|
/// 0 = HT / legacy bucket (11b/g/HT/VHT all collapse here)
|
|
/// 1 = HE-SU (802.11ax single-user)
|
|
/// 2 = HE-MU (802.11ax multi-user)
|
|
/// 3 = HE-TB (802.11ax trigger-based)
|
|
/// 0xFF = Unknown
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum PpduType {
|
|
HtLegacy,
|
|
HeSu,
|
|
HeMu,
|
|
HeTb,
|
|
Unknown,
|
|
}
|
|
|
|
impl PpduType {
|
|
pub fn from_byte(b: u8) -> Self {
|
|
match b {
|
|
0 => Self::HtLegacy,
|
|
1 => Self::HeSu,
|
|
2 => Self::HeMu,
|
|
3 => Self::HeTb,
|
|
_ => Self::Unknown,
|
|
}
|
|
}
|
|
pub fn to_byte(self) -> u8 {
|
|
match self {
|
|
Self::HtLegacy => 0,
|
|
Self::HeSu => 1,
|
|
Self::HeMu => 2,
|
|
Self::HeTb => 3,
|
|
Self::Unknown => 0xFF,
|
|
}
|
|
}
|
|
pub fn is_he(self) -> bool {
|
|
matches!(self, Self::HeSu | Self::HeMu | Self::HeTb)
|
|
}
|
|
}
|
|
|
|
/// Flags encoded in ADR-018 byte 19 (ADR-110 extension).
|
|
///
|
|
/// Wire encoding:
|
|
/// bit 0 : bandwidth wide (set = 40 MHz, clear = 20 MHz)
|
|
/// bit 1 : (reserved for 80/160 future)
|
|
/// bit 2 : STBC
|
|
/// bit 3 : LDPC (reserved — not yet populated by firmware)
|
|
/// bit 4 : 802.15.4 time-sync valid (C6 only)
|
|
/// bit 5-7 : reserved
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Adr018Flags {
|
|
pub bw40: bool,
|
|
pub stbc: bool,
|
|
pub ldpc: bool,
|
|
pub ieee802154_sync_valid: bool,
|
|
}
|
|
|
|
impl Adr018Flags {
|
|
pub fn from_byte(b: u8) -> Self {
|
|
Self {
|
|
bw40: (b & 0x01) != 0,
|
|
stbc: (b & 0x04) != 0,
|
|
ldpc: (b & 0x08) != 0,
|
|
ieee802154_sync_valid: (b & 0x10) != 0,
|
|
}
|
|
}
|
|
pub fn to_byte(self) -> u8 {
|
|
let mut b = 0u8;
|
|
if self.bw40 { b |= 0x01; }
|
|
if self.stbc { b |= 0x04; }
|
|
if self.ldpc { b |= 0x08; }
|
|
if self.ieee802154_sync_valid { b |= 0x10; }
|
|
b
|
|
}
|
|
}
|
|
|
|
impl Default for Adr018Flags {
|
|
fn default() -> Self {
|
|
Self { bw40: false, stbc: false, ldpc: false, ieee802154_sync_valid: false }
|
|
}
|
|
}
|
|
|
|
/// 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(),
|
|
node_id: 1,
|
|
n_antennas: 1,
|
|
n_subcarriers: 3,
|
|
channel_freq_mhz: 2437,
|
|
rssi_dbm: -50,
|
|
noise_floor_dbm: -95,
|
|
bandwidth: Bandwidth::Bw20,
|
|
antenna_config: AntennaConfig::default(),
|
|
sequence: 1,
|
|
ppdu_type: PpduType::HtLegacy,
|
|
adr018_flags: Adr018Flags::default(),
|
|
},
|
|
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);
|
|
}
|
|
}
|