feat(adr-018): ESP32-S3 firmware, Rust aggregator, and live CSI pipeline
Complete end-to-end WiFi CSI capture pipeline verified on real hardware: - ESP32-S3 firmware: WiFi STA + promiscuous mode CSI collection, ADR-018 binary serialization, UDP streaming at ~20 Hz - Rust aggregator CLI binary (clap): receives UDP frames, parses with Esp32CsiParser, prints per-frame summary (node, seq, rssi, amp) - UDP aggregator module with per-node sequence tracking and drop detection - CsiFrame bridge to detection pipeline (amplitude/phase/SNR conversion) - Python ESP32 binary parser with UDP reader - Presence detection confirmed: motion score 10/10 from live CSI variance Hardware verified: ESP32-S3-DevKitC-1 (CP2102, MAC 3C:0F:02:EC:C2:28), Docker ESP-IDF v5.2 build, esptool 5.1.0 flash, 20 Rust + 6 Python tests pass. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
1
rust-port/wifi-densepose-rs/Cargo.lock
generated
1
rust-port/wifi-densepose-rs/Cargo.lock
generated
@@ -3966,6 +3966,7 @@ dependencies = [
|
||||
"approx",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
|
||||
@@ -17,6 +17,8 @@ intel5300 = []
|
||||
linux-wifi = []
|
||||
|
||||
[dependencies]
|
||||
# CLI argument parsing (for bin/aggregator)
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
# Byte parsing
|
||||
byteorder = "1.5"
|
||||
# Time
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! UDP aggregator for ESP32 CSI nodes (ADR-018 Layer 2).
|
||||
//!
|
||||
//! Receives ADR-018 binary frames over UDP from multiple ESP32 nodes,
|
||||
//! parses them, tracks per-node state (sequence gaps, drop counting),
|
||||
//! and forwards parsed `CsiFrame`s to the processing pipeline via an
|
||||
//! `mpsc` channel.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::sync::mpsc::{self, SyncSender, Receiver};
|
||||
|
||||
use crate::csi_frame::CsiFrame;
|
||||
use crate::esp32_parser::Esp32CsiParser;
|
||||
|
||||
/// Configuration for the UDP aggregator.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AggregatorConfig {
|
||||
/// Address to bind the UDP socket to.
|
||||
pub bind_addr: String,
|
||||
/// Port to listen on.
|
||||
pub port: u16,
|
||||
/// Channel capacity for the frame sender (0 = unbounded-like behavior via sync).
|
||||
pub channel_capacity: usize,
|
||||
}
|
||||
|
||||
impl Default for AggregatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind_addr: "0.0.0.0".to_string(),
|
||||
port: 5005,
|
||||
channel_capacity: 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-node tracking state.
|
||||
#[derive(Debug)]
|
||||
struct NodeState {
|
||||
/// Last seen sequence number.
|
||||
last_sequence: u32,
|
||||
/// Total frames received from this node.
|
||||
frames_received: u64,
|
||||
/// Total dropped frames detected (sequence gaps).
|
||||
frames_dropped: u64,
|
||||
}
|
||||
|
||||
impl NodeState {
|
||||
fn new(initial_sequence: u32) -> Self {
|
||||
Self {
|
||||
last_sequence: initial_sequence,
|
||||
frames_received: 1,
|
||||
frames_dropped: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update state with a new sequence number. Returns the gap size (0 if contiguous).
|
||||
fn update(&mut self, sequence: u32) -> u32 {
|
||||
self.frames_received += 1;
|
||||
let expected = self.last_sequence.wrapping_add(1);
|
||||
let gap = if sequence > expected {
|
||||
sequence - expected
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.frames_dropped += gap as u64;
|
||||
self.last_sequence = sequence;
|
||||
gap
|
||||
}
|
||||
}
|
||||
|
||||
/// UDP aggregator that receives CSI frames from ESP32 nodes.
|
||||
pub struct Esp32Aggregator {
|
||||
socket: UdpSocket,
|
||||
nodes: HashMap<u8, NodeState>,
|
||||
tx: SyncSender<CsiFrame>,
|
||||
}
|
||||
|
||||
impl Esp32Aggregator {
|
||||
/// Create a new aggregator bound to the configured address.
|
||||
pub fn new(config: &AggregatorConfig) -> io::Result<(Self, Receiver<CsiFrame>)> {
|
||||
let addr: SocketAddr = format!("{}:{}", config.bind_addr, config.port)
|
||||
.parse()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
|
||||
let socket = UdpSocket::bind(addr)?;
|
||||
let (tx, rx) = mpsc::sync_channel(config.channel_capacity);
|
||||
|
||||
Ok((
|
||||
Self {
|
||||
socket,
|
||||
nodes: HashMap::new(),
|
||||
tx,
|
||||
},
|
||||
rx,
|
||||
))
|
||||
}
|
||||
|
||||
/// Create an aggregator from an existing socket (for testing).
|
||||
pub fn from_socket(socket: UdpSocket, tx: SyncSender<CsiFrame>) -> Self {
|
||||
Self {
|
||||
socket,
|
||||
nodes: HashMap::new(),
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the blocking receive loop. Call from a dedicated thread.
|
||||
pub fn run(&mut self) -> io::Result<()> {
|
||||
let mut buf = [0u8; 2048];
|
||||
loop {
|
||||
let (n, _src) = self.socket.recv_from(&mut buf)?;
|
||||
self.handle_packet(&buf[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single UDP packet. Public for unit testing.
|
||||
pub fn handle_packet(&mut self, data: &[u8]) {
|
||||
match Esp32CsiParser::parse_frame(data) {
|
||||
Ok((frame, _consumed)) => {
|
||||
let node_id = frame.metadata.node_id;
|
||||
let seq = frame.metadata.sequence;
|
||||
|
||||
// Track node state
|
||||
match self.nodes.get_mut(&node_id) {
|
||||
Some(state) => {
|
||||
state.update(seq);
|
||||
}
|
||||
None => {
|
||||
self.nodes.insert(node_id, NodeState::new(seq));
|
||||
}
|
||||
}
|
||||
|
||||
// Send to channel (ignore send errors — receiver may have dropped)
|
||||
let _ = self.tx.try_send(frame);
|
||||
}
|
||||
Err(_) => {
|
||||
// Bad packet — silently drop (per ADR-018: aggregator is tolerant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the number of dropped frames for a specific node.
|
||||
pub fn drops_for_node(&self, node_id: u8) -> u64 {
|
||||
self.nodes.get(&node_id).map_or(0, |s| s.frames_dropped)
|
||||
}
|
||||
|
||||
/// Get the number of tracked nodes.
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// Helper: build an ADR-018 frame packet for testing.
|
||||
fn build_test_packet(node_id: u8, sequence: u32, n_subcarriers: usize) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// Magic
|
||||
buf.extend_from_slice(&0xC5110001u32.to_le_bytes());
|
||||
// Node ID
|
||||
buf.push(node_id);
|
||||
// Antennas
|
||||
buf.push(1);
|
||||
// Subcarriers (LE u16)
|
||||
buf.extend_from_slice(&(n_subcarriers as u16).to_le_bytes());
|
||||
// Frequency MHz (LE u32)
|
||||
buf.extend_from_slice(&2437u32.to_le_bytes());
|
||||
// Sequence (LE u32)
|
||||
buf.extend_from_slice(&sequence.to_le_bytes());
|
||||
// RSSI (i8)
|
||||
buf.push((-50i8) as u8);
|
||||
// Noise floor (i8)
|
||||
buf.push((-90i8) as u8);
|
||||
// Reserved
|
||||
buf.extend_from_slice(&[0u8; 2]);
|
||||
// I/Q data
|
||||
for i in 0..n_subcarriers {
|
||||
buf.push((i % 127) as u8); // I
|
||||
buf.push(((i * 2) % 127) as u8); // Q
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregator_receives_valid_frame() {
|
||||
let (tx, rx) = mpsc::sync_channel(16);
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let mut agg = Esp32Aggregator::from_socket(socket, tx);
|
||||
|
||||
let pkt = build_test_packet(1, 0, 4);
|
||||
agg.handle_packet(&pkt);
|
||||
|
||||
let frame = rx.try_recv().unwrap();
|
||||
assert_eq!(frame.metadata.node_id, 1);
|
||||
assert_eq!(frame.metadata.sequence, 0);
|
||||
assert_eq!(frame.subcarrier_count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregator_tracks_sequence_gaps() {
|
||||
let (tx, _rx) = mpsc::sync_channel(16);
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let mut agg = Esp32Aggregator::from_socket(socket, tx);
|
||||
|
||||
// Send seq 0
|
||||
agg.handle_packet(&build_test_packet(1, 0, 4));
|
||||
// Send seq 5 (gap of 4)
|
||||
agg.handle_packet(&build_test_packet(1, 5, 4));
|
||||
|
||||
assert_eq!(agg.drops_for_node(1), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregator_handles_bad_packet() {
|
||||
let (tx, rx) = mpsc::sync_channel(16);
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let mut agg = Esp32Aggregator::from_socket(socket, tx);
|
||||
|
||||
// Garbage bytes — should not panic or produce a frame
|
||||
agg.handle_packet(&[0xFF, 0xFE, 0xFD, 0xFC, 0x00]);
|
||||
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert_eq!(agg.node_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregator_multi_node() {
|
||||
let (tx, rx) = mpsc::sync_channel(16);
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let mut agg = Esp32Aggregator::from_socket(socket, tx);
|
||||
|
||||
agg.handle_packet(&build_test_packet(1, 0, 4));
|
||||
agg.handle_packet(&build_test_packet(2, 0, 4));
|
||||
|
||||
assert_eq!(agg.node_count(), 2);
|
||||
|
||||
let f1 = rx.try_recv().unwrap();
|
||||
let f2 = rx.try_recv().unwrap();
|
||||
assert_eq!(f1.metadata.node_id, 1);
|
||||
assert_eq!(f2.metadata.node_id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregator_loopback_udp() {
|
||||
// Full UDP roundtrip via loopback
|
||||
let recv_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let recv_addr = recv_socket.local_addr().unwrap();
|
||||
recv_socket.set_nonblocking(true).unwrap();
|
||||
|
||||
let send_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::sync_channel(16);
|
||||
let mut agg = Esp32Aggregator::from_socket(recv_socket, tx);
|
||||
|
||||
// Send a packet via UDP
|
||||
let pkt = build_test_packet(3, 42, 4);
|
||||
send_socket.send_to(&pkt, recv_addr).unwrap();
|
||||
|
||||
// Read from the socket and handle
|
||||
let mut buf = [0u8; 2048];
|
||||
// Small delay to let the packet arrive
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
if let Ok((n, _)) = agg.socket.recv_from(&mut buf) {
|
||||
agg.handle_packet(&buf[..n]);
|
||||
}
|
||||
|
||||
let frame = rx.try_recv().unwrap();
|
||||
assert_eq!(frame.metadata.node_id, 3);
|
||||
assert_eq!(frame.metadata.sequence, 42);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! UDP aggregator CLI for receiving ESP32 CSI frames (ADR-018).
|
||||
//!
|
||||
//! Listens for ADR-018 binary CSI frames on a UDP socket, parses each
|
||||
//! packet, and prints a one-line summary to stdout.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run -p wifi-densepose-hardware --bin aggregator -- --bind 0.0.0.0:5005
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::process;
|
||||
|
||||
use clap::Parser;
|
||||
use wifi_densepose_hardware::Esp32CsiParser;
|
||||
|
||||
/// UDP aggregator for ESP32 CSI nodes (ADR-018).
|
||||
#[derive(Parser)]
|
||||
#[command(name = "aggregator", about = "Receive and display live CSI frames from ESP32 nodes")]
|
||||
struct Cli {
|
||||
/// Address:port to bind the UDP listener to.
|
||||
#[arg(long, default_value = "0.0.0.0:5005")]
|
||||
bind: String,
|
||||
|
||||
/// Print raw hex dump alongside parsed output.
|
||||
#[arg(long, short)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let socket = match UdpSocket::bind(&cli.bind) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Error: cannot bind to {}: {}", cli.bind, e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("Listening on {}...", cli.bind);
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
|
||||
loop {
|
||||
let (n, src) = match socket.recv_from(&mut buf) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("recv error: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if cli.verbose {
|
||||
eprintln!(" [{} bytes from {}]", n, src);
|
||||
}
|
||||
|
||||
match Esp32CsiParser::parse_frame(&buf[..n]) {
|
||||
Ok((frame, _consumed)) => {
|
||||
let mean_amp = frame.mean_amplitude();
|
||||
println!(
|
||||
"[node:{} seq:{}] sc={} rssi={} amp={:.1}",
|
||||
frame.metadata.node_id,
|
||||
frame.metadata.sequence,
|
||||
frame.subcarrier_count(),
|
||||
frame.metadata.rssi_dbm,
|
||||
mean_amp,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
if cli.verbose {
|
||||
eprintln!(" parse error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! CsiFrame → CsiData bridge (ADR-018 Layer 3).
|
||||
//!
|
||||
//! Converts hardware-level `CsiFrame` (I/Q pairs) into the pipeline-ready
|
||||
//! `CsiData` format (amplitude/phase vectors). No ndarray dependency —
|
||||
//! uses plain `Vec<f64>`.
|
||||
|
||||
use crate::csi_frame::CsiFrame;
|
||||
|
||||
/// Pipeline-ready CSI data with amplitude and phase vectors (ADR-018).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CsiData {
|
||||
/// Unix timestamp in milliseconds when the frame was received.
|
||||
pub timestamp_unix_ms: u64,
|
||||
/// Node identifier (0-255).
|
||||
pub node_id: u8,
|
||||
/// Number of antennas.
|
||||
pub n_antennas: usize,
|
||||
/// Number of subcarriers per antenna.
|
||||
pub n_subcarriers: usize,
|
||||
/// Amplitude values: sqrt(I² + Q²) for each (antenna, subcarrier).
|
||||
/// Length = n_antennas * n_subcarriers, laid out antenna-major.
|
||||
pub amplitude: Vec<f64>,
|
||||
/// Phase values: atan2(Q, I) for each (antenna, subcarrier).
|
||||
/// Length = n_antennas * n_subcarriers.
|
||||
pub phase: Vec<f64>,
|
||||
/// RSSI in dBm.
|
||||
pub rssi_dbm: i8,
|
||||
/// Noise floor in dBm.
|
||||
pub noise_floor_dbm: i8,
|
||||
/// Channel center frequency in MHz.
|
||||
pub channel_freq_mhz: u32,
|
||||
/// Sequence number.
|
||||
pub sequence: u32,
|
||||
}
|
||||
|
||||
impl CsiData {
|
||||
/// Compute SNR as RSSI - noise floor (in dB).
|
||||
pub fn snr_db(&self) -> f64 {
|
||||
self.rssi_dbm as f64 - self.noise_floor_dbm as f64
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CsiFrame> for CsiData {
|
||||
fn from(frame: CsiFrame) -> Self {
|
||||
let n_antennas = frame.metadata.n_antennas as usize;
|
||||
let n_subcarriers = frame.metadata.n_subcarriers as usize;
|
||||
let total = frame.subcarriers.len();
|
||||
|
||||
let mut amplitude = Vec::with_capacity(total);
|
||||
let mut phase = Vec::with_capacity(total);
|
||||
|
||||
for sc in &frame.subcarriers {
|
||||
let i = sc.i as f64;
|
||||
let q = sc.q as f64;
|
||||
amplitude.push((i * i + q * q).sqrt());
|
||||
phase.push(q.atan2(i));
|
||||
}
|
||||
|
||||
let timestamp_unix_ms = frame.metadata.timestamp.timestamp_millis() as u64;
|
||||
|
||||
CsiData {
|
||||
timestamp_unix_ms,
|
||||
node_id: frame.metadata.node_id,
|
||||
n_antennas,
|
||||
n_subcarriers,
|
||||
amplitude,
|
||||
phase,
|
||||
rssi_dbm: frame.metadata.rssi_dbm,
|
||||
noise_floor_dbm: frame.metadata.noise_floor_dbm,
|
||||
channel_freq_mhz: frame.metadata.channel_freq_mhz,
|
||||
sequence: frame.metadata.sequence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::csi_frame::{AntennaConfig, Bandwidth, CsiMetadata, SubcarrierData};
|
||||
use chrono::Utc;
|
||||
|
||||
fn make_frame(
|
||||
node_id: u8,
|
||||
n_antennas: u8,
|
||||
subcarriers: Vec<SubcarrierData>,
|
||||
) -> CsiFrame {
|
||||
let n_subcarriers = if n_antennas == 0 {
|
||||
subcarriers.len()
|
||||
} else {
|
||||
subcarriers.len() / n_antennas as usize
|
||||
};
|
||||
|
||||
CsiFrame {
|
||||
metadata: CsiMetadata {
|
||||
timestamp: Utc::now(),
|
||||
node_id,
|
||||
n_antennas,
|
||||
n_subcarriers: n_subcarriers as u16,
|
||||
channel_freq_mhz: 2437,
|
||||
rssi_dbm: -45,
|
||||
noise_floor_dbm: -90,
|
||||
bandwidth: Bandwidth::Bw20,
|
||||
antenna_config: AntennaConfig {
|
||||
tx_antennas: 1,
|
||||
rx_antennas: n_antennas,
|
||||
},
|
||||
sequence: 42,
|
||||
},
|
||||
subcarriers,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bridge_from_known_iq() {
|
||||
let subs = vec![
|
||||
SubcarrierData { i: 3, q: 4, index: -1 }, // amp = 5.0
|
||||
SubcarrierData { i: 0, q: 10, index: 1 }, // amp = 10.0
|
||||
];
|
||||
let frame = make_frame(1, 1, subs);
|
||||
let data: CsiData = frame.into();
|
||||
|
||||
assert_eq!(data.amplitude.len(), 2);
|
||||
assert!((data.amplitude[0] - 5.0).abs() < 0.001);
|
||||
assert!((data.amplitude[1] - 10.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bridge_multi_antenna() {
|
||||
// 2 antennas, 3 subcarriers each = 6 total
|
||||
let subs = vec![
|
||||
SubcarrierData { i: 1, q: 0, index: -1 },
|
||||
SubcarrierData { i: 2, q: 0, index: 0 },
|
||||
SubcarrierData { i: 3, q: 0, index: 1 },
|
||||
SubcarrierData { i: 4, q: 0, index: -1 },
|
||||
SubcarrierData { i: 5, q: 0, index: 0 },
|
||||
SubcarrierData { i: 6, q: 0, index: 1 },
|
||||
];
|
||||
let frame = make_frame(1, 2, subs);
|
||||
let data: CsiData = frame.into();
|
||||
|
||||
assert_eq!(data.n_antennas, 2);
|
||||
assert_eq!(data.n_subcarriers, 3);
|
||||
assert_eq!(data.amplitude.len(), 6);
|
||||
assert_eq!(data.phase.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bridge_snr_computation() {
|
||||
let subs = vec![SubcarrierData { i: 1, q: 0, index: 0 }];
|
||||
let frame = make_frame(1, 1, subs);
|
||||
let data: CsiData = frame.into();
|
||||
|
||||
// rssi=-45, noise=-90, SNR=45
|
||||
assert!((data.snr_db() - 45.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bridge_preserves_metadata() {
|
||||
let subs = vec![SubcarrierData { i: 10, q: 20, index: 0 }];
|
||||
let frame = make_frame(7, 1, subs);
|
||||
let data: CsiData = frame.into();
|
||||
|
||||
assert_eq!(data.node_id, 7);
|
||||
assert_eq!(data.channel_freq_mhz, 2437);
|
||||
assert_eq!(data.sequence, 42);
|
||||
assert_eq!(data.rssi_dbm, -45);
|
||||
assert_eq!(data.noise_floor_dbm, -90);
|
||||
}
|
||||
}
|
||||
@@ -57,25 +57,27 @@ impl CsiFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata associated with a CSI frame.
|
||||
/// 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>,
|
||||
/// 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
|
||||
/// 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
|
||||
/// Antenna configuration (populated from n_antennas)
|
||||
pub antenna_config: AntennaConfig,
|
||||
/// Source MAC address (if available)
|
||||
pub source_mac: Option<[u8; 6]>,
|
||||
/// Sequence number for ordering
|
||||
pub sequence: u32,
|
||||
}
|
||||
@@ -143,13 +145,14 @@ mod tests {
|
||||
CsiFrame {
|
||||
metadata: CsiMetadata {
|
||||
timestamp: Utc::now(),
|
||||
rssi: -50,
|
||||
noise_floor: -95,
|
||||
channel: 6,
|
||||
secondary_channel: 0,
|
||||
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(),
|
||||
source_mac: None,
|
||||
sequence: 1,
|
||||
},
|
||||
subcarriers: vec![
|
||||
|
||||
@@ -39,6 +39,12 @@ pub enum ParseError {
|
||||
value: i32,
|
||||
},
|
||||
|
||||
/// Invalid antenna count (must be 1-4 for ESP32).
|
||||
#[error("Invalid antenna count: {count} (expected 1-4)")]
|
||||
InvalidAntennaCount {
|
||||
count: u8,
|
||||
},
|
||||
|
||||
/// Generic byte-level parse error.
|
||||
#[error("Parse error at offset {offset}: {message}")]
|
||||
ByteError {
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
//! ESP32 CSI frame parser.
|
||||
//! ESP32 CSI frame parser (ADR-018 binary format).
|
||||
//!
|
||||
//! Parses binary CSI data as produced by ESP-IDF's `wifi_csi_info_t` structure,
|
||||
//! typically streamed over serial (UART at 921600 baud) or UDP.
|
||||
//! Parses binary CSI data as produced by ADR-018 compliant firmware,
|
||||
//! typically streamed over UDP from ESP32/ESP32-S3 nodes.
|
||||
//!
|
||||
//! # ESP32 CSI Binary Format
|
||||
//!
|
||||
//! The ESP32 CSI callback produces a buffer with the following layout:
|
||||
//! # ADR-018 Binary Frame Format
|
||||
//!
|
||||
//! ```text
|
||||
//! Offset Size Field
|
||||
//! ------ ---- -----
|
||||
//! 0 4 Magic (0xCSI10001 or as configured in firmware)
|
||||
//! 4 4 Sequence number
|
||||
//! 8 1 Channel
|
||||
//! 9 1 Secondary channel
|
||||
//! 10 1 RSSI (signed)
|
||||
//! 11 1 Noise floor (signed)
|
||||
//! 12 2 CSI data length (number of I/Q bytes)
|
||||
//! 14 6 Source MAC address
|
||||
//! 20 N I/Q data (pairs of i8 values, 2 bytes per subcarrier)
|
||||
//! 0 4 Magic: 0xC5110001
|
||||
//! 4 1 Node ID
|
||||
//! 5 1 Number of antennas
|
||||
//! 6 2 Number of subcarriers (LE u16)
|
||||
//! 8 4 Frequency MHz (LE u32)
|
||||
//! 12 4 Sequence number (LE u32)
|
||||
//! 16 1 RSSI (i8)
|
||||
//! 17 1 Noise floor (i8)
|
||||
//! 18 2 Reserved
|
||||
//! 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
//! ```
|
||||
//!
|
||||
//! Each subcarrier contributes 2 bytes: one signed byte for I, one for Q.
|
||||
//! For 20 MHz bandwidth with 56 subcarriers: N = 112 bytes.
|
||||
//! Each I/Q pair is 2 signed bytes: I then Q.
|
||||
//!
|
||||
//! # No-Mock Guarantee
|
||||
//!
|
||||
@@ -36,17 +34,19 @@ use std::io::Cursor;
|
||||
use crate::csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
|
||||
use crate::error::ParseError;
|
||||
|
||||
/// ESP32 CSI binary frame magic number.
|
||||
///
|
||||
/// This is a convention for the firmware framing protocol.
|
||||
/// The actual ESP-IDF callback doesn't include a magic number;
|
||||
/// our recommended firmware adds this for reliable frame sync.
|
||||
/// ESP32 CSI binary frame magic number (ADR-018).
|
||||
const ESP32_CSI_MAGIC: u32 = 0xC5110001;
|
||||
|
||||
/// Maximum valid subcarrier count for ESP32 (80MHz bandwidth).
|
||||
/// ADR-018 header size in bytes (before I/Q data).
|
||||
const HEADER_SIZE: usize = 20;
|
||||
|
||||
/// Maximum valid subcarrier count for ESP32 (80 MHz bandwidth).
|
||||
const MAX_SUBCARRIERS: usize = 256;
|
||||
|
||||
/// Parser for ESP32 CSI binary frames.
|
||||
/// Maximum antenna count for ESP32.
|
||||
const MAX_ANTENNAS: u8 = 4;
|
||||
|
||||
/// Parser for ESP32 CSI binary frames (ADR-018 format).
|
||||
pub struct Esp32CsiParser;
|
||||
|
||||
impl Esp32CsiParser {
|
||||
@@ -55,16 +55,16 @@ impl Esp32CsiParser {
|
||||
/// The buffer must contain at least the header (20 bytes) plus the I/Q data.
|
||||
/// Returns the parsed frame and the number of bytes consumed.
|
||||
pub fn parse_frame(data: &[u8]) -> Result<(CsiFrame, usize), ParseError> {
|
||||
if data.len() < 20 {
|
||||
if data.len() < HEADER_SIZE {
|
||||
return Err(ParseError::InsufficientData {
|
||||
needed: 20,
|
||||
needed: HEADER_SIZE,
|
||||
got: data.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut cursor = Cursor::new(data);
|
||||
|
||||
// Read magic
|
||||
// Magic (offset 0, 4 bytes)
|
||||
let magic = cursor.read_u32::<LittleEndian>().map_err(|_| ParseError::InsufficientData {
|
||||
needed: 4,
|
||||
got: 0,
|
||||
@@ -77,72 +77,70 @@ impl Esp32CsiParser {
|
||||
});
|
||||
}
|
||||
|
||||
// Sequence number
|
||||
let sequence = cursor.read_u32::<LittleEndian>().map_err(|_| ParseError::InsufficientData {
|
||||
needed: 8,
|
||||
got: 4,
|
||||
// Node ID (offset 4, 1 byte)
|
||||
let node_id = cursor.read_u8().map_err(|_| ParseError::ByteError {
|
||||
offset: 4,
|
||||
message: "Failed to read node ID".into(),
|
||||
})?;
|
||||
|
||||
// Channel info
|
||||
let channel = cursor.read_u8().map_err(|_| ParseError::ByteError {
|
||||
offset: 8,
|
||||
message: "Failed to read channel".into(),
|
||||
// Number of antennas (offset 5, 1 byte)
|
||||
let n_antennas = cursor.read_u8().map_err(|_| ParseError::ByteError {
|
||||
offset: 5,
|
||||
message: "Failed to read antenna count".into(),
|
||||
})?;
|
||||
|
||||
let secondary_channel = cursor.read_u8().map_err(|_| ParseError::ByteError {
|
||||
offset: 9,
|
||||
message: "Failed to read secondary channel".into(),
|
||||
})?;
|
||||
|
||||
// RSSI (signed)
|
||||
let rssi = cursor.read_i8().map_err(|_| ParseError::ByteError {
|
||||
offset: 10,
|
||||
message: "Failed to read RSSI".into(),
|
||||
})? as i32;
|
||||
|
||||
if rssi > 0 || rssi < -100 {
|
||||
return Err(ParseError::InvalidRssi { value: rssi });
|
||||
if n_antennas == 0 || n_antennas > MAX_ANTENNAS {
|
||||
return Err(ParseError::InvalidAntennaCount { count: n_antennas });
|
||||
}
|
||||
|
||||
// Noise floor (signed)
|
||||
let noise_floor = cursor.read_i8().map_err(|_| ParseError::ByteError {
|
||||
offset: 11,
|
||||
message: "Failed to read noise floor".into(),
|
||||
})? as i32;
|
||||
|
||||
// CSI data length
|
||||
let iq_length = cursor.read_u16::<LittleEndian>().map_err(|_| ParseError::ByteError {
|
||||
offset: 12,
|
||||
message: "Failed to read I/Q length".into(),
|
||||
// Number of subcarriers (offset 6, 2 bytes LE)
|
||||
let n_subcarriers = cursor.read_u16::<LittleEndian>().map_err(|_| ParseError::ByteError {
|
||||
offset: 6,
|
||||
message: "Failed to read subcarrier count".into(),
|
||||
})? as usize;
|
||||
|
||||
// Source MAC
|
||||
let mut mac = [0u8; 6];
|
||||
for (i, byte) in mac.iter_mut().enumerate() {
|
||||
*byte = cursor.read_u8().map_err(|_| ParseError::ByteError {
|
||||
offset: 14 + i,
|
||||
message: "Failed to read MAC address".into(),
|
||||
})?;
|
||||
}
|
||||
|
||||
// Validate I/Q length
|
||||
let subcarrier_count = iq_length / 2;
|
||||
if subcarrier_count > MAX_SUBCARRIERS {
|
||||
if n_subcarriers > MAX_SUBCARRIERS {
|
||||
return Err(ParseError::InvalidSubcarrierCount {
|
||||
count: subcarrier_count,
|
||||
count: n_subcarriers,
|
||||
max: MAX_SUBCARRIERS,
|
||||
});
|
||||
}
|
||||
|
||||
if iq_length % 2 != 0 {
|
||||
return Err(ParseError::IqLengthMismatch {
|
||||
expected: subcarrier_count * 2,
|
||||
got: iq_length,
|
||||
});
|
||||
}
|
||||
// Frequency MHz (offset 8, 4 bytes LE)
|
||||
let channel_freq_mhz = cursor.read_u32::<LittleEndian>().map_err(|_| ParseError::ByteError {
|
||||
offset: 8,
|
||||
message: "Failed to read frequency".into(),
|
||||
})?;
|
||||
|
||||
// Sequence number (offset 12, 4 bytes LE)
|
||||
let sequence = cursor.read_u32::<LittleEndian>().map_err(|_| ParseError::ByteError {
|
||||
offset: 12,
|
||||
message: "Failed to read sequence number".into(),
|
||||
})?;
|
||||
|
||||
// RSSI (offset 16, 1 byte signed)
|
||||
let rssi_dbm = cursor.read_i8().map_err(|_| ParseError::ByteError {
|
||||
offset: 16,
|
||||
message: "Failed to read RSSI".into(),
|
||||
})?;
|
||||
|
||||
// Noise floor (offset 17, 1 byte signed)
|
||||
let noise_floor_dbm = cursor.read_i8().map_err(|_| ParseError::ByteError {
|
||||
offset: 17,
|
||||
message: "Failed to read noise floor".into(),
|
||||
})?;
|
||||
|
||||
// Reserved (offset 18, 2 bytes) — skip
|
||||
let _reserved = cursor.read_u16::<LittleEndian>().map_err(|_| ParseError::ByteError {
|
||||
offset: 18,
|
||||
message: "Failed to read reserved bytes".into(),
|
||||
})?;
|
||||
|
||||
// I/Q data: n_antennas * n_subcarriers * 2 bytes
|
||||
let iq_pair_count = n_antennas as usize * n_subcarriers;
|
||||
let iq_byte_count = iq_pair_count * 2;
|
||||
let total_frame_size = HEADER_SIZE + iq_byte_count;
|
||||
|
||||
// Check we have enough bytes for the I/Q data
|
||||
let total_frame_size = 20 + iq_length;
|
||||
if data.len() < total_frame_size {
|
||||
return Err(ParseError::InsufficientData {
|
||||
needed: total_frame_size,
|
||||
@@ -150,33 +148,34 @@ impl Esp32CsiParser {
|
||||
});
|
||||
}
|
||||
|
||||
// Parse I/Q pairs
|
||||
let iq_start = 20;
|
||||
let mut subcarriers = Vec::with_capacity(subcarrier_count);
|
||||
// Parse I/Q pairs — stored as [ant0_sc0_I, ant0_sc0_Q, ant0_sc1_I, ant0_sc1_Q, ..., ant1_sc0_I, ...]
|
||||
let iq_start = HEADER_SIZE;
|
||||
let mut subcarriers = Vec::with_capacity(iq_pair_count);
|
||||
|
||||
// Subcarrier index mapping for 20 MHz: -28 to +28 (skipping 0)
|
||||
let half = subcarrier_count as i16 / 2;
|
||||
let half = n_subcarriers as i16 / 2;
|
||||
|
||||
for sc_idx in 0..subcarrier_count {
|
||||
let byte_offset = iq_start + sc_idx * 2;
|
||||
let i_val = data[byte_offset] as i8 as i16;
|
||||
let q_val = data[byte_offset + 1] as i8 as i16;
|
||||
for ant in 0..n_antennas as usize {
|
||||
for sc_idx in 0..n_subcarriers {
|
||||
let byte_offset = iq_start + (ant * n_subcarriers + sc_idx) * 2;
|
||||
let i_val = data[byte_offset] as i8 as i16;
|
||||
let q_val = data[byte_offset + 1] as i8 as i16;
|
||||
|
||||
let index = if (sc_idx as i16) < half {
|
||||
-(half - sc_idx as i16)
|
||||
} else {
|
||||
sc_idx as i16 - half + 1
|
||||
};
|
||||
let index = if (sc_idx as i16) < half {
|
||||
-(half - sc_idx as i16)
|
||||
} else {
|
||||
sc_idx as i16 - half + 1
|
||||
};
|
||||
|
||||
subcarriers.push(SubcarrierData {
|
||||
i: i_val,
|
||||
q: q_val,
|
||||
index,
|
||||
});
|
||||
subcarriers.push(SubcarrierData {
|
||||
i: i_val,
|
||||
q: q_val,
|
||||
index,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Determine bandwidth from subcarrier count
|
||||
let bandwidth = match subcarrier_count {
|
||||
let bandwidth = match n_subcarriers {
|
||||
0..=56 => Bandwidth::Bw20,
|
||||
57..=114 => Bandwidth::Bw40,
|
||||
115..=242 => Bandwidth::Bw80,
|
||||
@@ -186,16 +185,17 @@ impl Esp32CsiParser {
|
||||
let frame = CsiFrame {
|
||||
metadata: CsiMetadata {
|
||||
timestamp: Utc::now(),
|
||||
rssi,
|
||||
noise_floor,
|
||||
channel,
|
||||
secondary_channel,
|
||||
node_id,
|
||||
n_antennas,
|
||||
n_subcarriers: n_subcarriers as u16,
|
||||
channel_freq_mhz,
|
||||
rssi_dbm,
|
||||
noise_floor_dbm,
|
||||
bandwidth,
|
||||
antenna_config: AntennaConfig {
|
||||
tx_antennas: 1,
|
||||
rx_antennas: 1,
|
||||
rx_antennas: n_antennas,
|
||||
},
|
||||
source_mac: Some(mac),
|
||||
sequence,
|
||||
},
|
||||
subcarriers,
|
||||
@@ -204,7 +204,7 @@ impl Esp32CsiParser {
|
||||
Ok((frame, total_frame_size))
|
||||
}
|
||||
|
||||
/// Parse multiple frames from a byte buffer (e.g., from a serial read).
|
||||
/// Parse multiple frames from a byte buffer (e.g., from a UDP read).
|
||||
///
|
||||
/// Returns all successfully parsed frames and the total bytes consumed.
|
||||
pub fn parse_stream(data: &[u8]) -> (Vec<CsiFrame>, usize) {
|
||||
@@ -244,28 +244,35 @@ impl Esp32CsiParser {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a valid ESP32 CSI frame with known I/Q values.
|
||||
fn build_test_frame(subcarrier_pairs: &[(i8, i8)]) -> Vec<u8> {
|
||||
/// Build a valid ADR-018 ESP32 CSI frame with known parameters.
|
||||
fn build_test_frame(node_id: u8, n_antennas: u8, subcarrier_pairs: &[(i8, i8)]) -> Vec<u8> {
|
||||
let n_subcarriers = if n_antennas == 0 {
|
||||
subcarrier_pairs.len()
|
||||
} else {
|
||||
subcarrier_pairs.len() / n_antennas as usize
|
||||
};
|
||||
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// Magic
|
||||
// Magic (offset 0)
|
||||
buf.extend_from_slice(&ESP32_CSI_MAGIC.to_le_bytes());
|
||||
// Sequence
|
||||
// Node ID (offset 4)
|
||||
buf.push(node_id);
|
||||
// Number of antennas (offset 5)
|
||||
buf.push(n_antennas);
|
||||
// Number of subcarriers (offset 6, LE u16)
|
||||
buf.extend_from_slice(&(n_subcarriers as u16).to_le_bytes());
|
||||
// Frequency MHz (offset 8, LE u32)
|
||||
buf.extend_from_slice(&2437u32.to_le_bytes());
|
||||
// Sequence number (offset 12, LE u32)
|
||||
buf.extend_from_slice(&1u32.to_le_bytes());
|
||||
// Channel
|
||||
buf.push(6);
|
||||
// Secondary channel
|
||||
buf.push(0);
|
||||
// RSSI
|
||||
// RSSI (offset 16, i8)
|
||||
buf.push((-50i8) as u8);
|
||||
// Noise floor
|
||||
// Noise floor (offset 17, i8)
|
||||
buf.push((-95i8) as u8);
|
||||
// I/Q length
|
||||
let iq_len = (subcarrier_pairs.len() * 2) as u16;
|
||||
buf.extend_from_slice(&iq_len.to_le_bytes());
|
||||
// MAC
|
||||
buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
// I/Q data
|
||||
// Reserved (offset 18, 2 bytes)
|
||||
buf.extend_from_slice(&[0u8; 2]);
|
||||
// I/Q data (offset 20)
|
||||
for (i, q) in subcarrier_pairs {
|
||||
buf.push(*i as u8);
|
||||
buf.push(*q as u8);
|
||||
@@ -276,15 +283,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_frame() {
|
||||
// 1 antenna, 56 subcarriers
|
||||
let pairs: Vec<(i8, i8)> = (0..56).map(|i| (i as i8, (i * 2 % 127) as i8)).collect();
|
||||
let data = build_test_frame(&pairs);
|
||||
let data = build_test_frame(1, 1, &pairs);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
|
||||
assert_eq!(consumed, 20 + 112);
|
||||
assert_eq!(consumed, HEADER_SIZE + 56 * 2);
|
||||
assert_eq!(frame.subcarrier_count(), 56);
|
||||
assert_eq!(frame.metadata.rssi, -50);
|
||||
assert_eq!(frame.metadata.channel, 6);
|
||||
assert_eq!(frame.metadata.node_id, 1);
|
||||
assert_eq!(frame.metadata.n_antennas, 1);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 56);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -50);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2437);
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
@@ -298,7 +309,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_magic() {
|
||||
let mut data = build_test_frame(&[(10, 20)]);
|
||||
let mut data = build_test_frame(1, 1, &[(10, 20)]);
|
||||
// Corrupt magic
|
||||
data[0] = 0xFF;
|
||||
let result = Esp32CsiParser::parse_frame(&data);
|
||||
@@ -308,10 +319,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_amplitude_phase_from_known_iq() {
|
||||
let pairs = vec![(100i8, 0i8), (0, 50), (30, 40)];
|
||||
let data = build_test_frame(&pairs);
|
||||
let data = build_test_frame(1, 1, &pairs);
|
||||
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
|
||||
let (amps, phases) = frame.to_amplitude_phase();
|
||||
let (amps, _phases) = frame.to_amplitude_phase();
|
||||
assert_eq!(amps.len(), 3);
|
||||
|
||||
// I=100, Q=0 -> amplitude=100
|
||||
@@ -325,8 +336,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_parse_stream_with_multiple_frames() {
|
||||
let pairs: Vec<(i8, i8)> = (0..4).map(|i| (10 + i, 20 + i)).collect();
|
||||
let frame1 = build_test_frame(&pairs);
|
||||
let frame2 = build_test_frame(&pairs);
|
||||
let frame1 = build_test_frame(1, 1, &pairs);
|
||||
let frame2 = build_test_frame(2, 1, &pairs);
|
||||
|
||||
let mut combined = Vec::new();
|
||||
combined.extend_from_slice(&frame1);
|
||||
@@ -334,12 +345,14 @@ mod tests {
|
||||
|
||||
let (frames, _consumed) = Esp32CsiParser::parse_stream(&combined);
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0].metadata.node_id, 1);
|
||||
assert_eq!(frames[1].metadata.node_id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stream_with_garbage() {
|
||||
let pairs: Vec<(i8, i8)> = (0..4).map(|i| (10 + i, 20 + i)).collect();
|
||||
let frame = build_test_frame(&pairs);
|
||||
let frame = build_test_frame(1, 1, &pairs);
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // garbage
|
||||
@@ -350,14 +363,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mac_address_parsed() {
|
||||
let pairs = vec![(10i8, 20i8)];
|
||||
let data = build_test_frame(&pairs);
|
||||
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
fn test_multi_antenna_frame() {
|
||||
// 3 antennas, 4 subcarriers each = 12 I/Q pairs total
|
||||
let mut pairs = Vec::new();
|
||||
for ant in 0..3u8 {
|
||||
for sc in 0..4u8 {
|
||||
pairs.push(((ant * 10 + sc) as i8, ((ant * 10 + sc) * 2) as i8));
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
frame.metadata.source_mac,
|
||||
Some([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
|
||||
);
|
||||
let data = build_test_frame(5, 3, &pairs);
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
|
||||
assert_eq!(consumed, HEADER_SIZE + 12 * 2);
|
||||
assert_eq!(frame.metadata.node_id, 5);
|
||||
assert_eq!(frame.metadata.n_antennas, 3);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 4);
|
||||
assert_eq!(frame.subcarrier_count(), 12); // 3 antennas * 4 subcarriers
|
||||
assert_eq!(frame.metadata.antenna_config.rx_antennas, 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
//! This crate provides platform-agnostic types and parsers for WiFi CSI data
|
||||
//! from various hardware sources:
|
||||
//!
|
||||
//! - **ESP32/ESP32-S3**: Parses binary CSI frames from ESP-IDF `wifi_csi_info_t`
|
||||
//! streamed over serial (UART) or UDP
|
||||
//! - **Intel 5300**: Parses CSI log files from the Linux CSI Tool
|
||||
//! - **Linux WiFi**: Reads RSSI/signal info from standard Linux wireless interfaces
|
||||
//! for commodity sensing (ADR-013)
|
||||
//! - **ESP32/ESP32-S3**: Parses ADR-018 binary CSI frames streamed over UDP
|
||||
//! - **UDP Aggregator**: Receives frames from multiple ESP32 nodes (ADR-018 Layer 2)
|
||||
//! - **Bridge**: Converts CsiFrame → CsiData for the detection pipeline (ADR-018 Layer 3)
|
||||
//!
|
||||
//! # Design Principles
|
||||
//!
|
||||
@@ -21,8 +19,8 @@
|
||||
//! ```rust
|
||||
//! use wifi_densepose_hardware::{CsiFrame, Esp32CsiParser, ParseError};
|
||||
//!
|
||||
//! // Parse ESP32 CSI data from serial bytes
|
||||
//! let raw_bytes: &[u8] = &[/* ESP32 CSI binary frame */];
|
||||
//! // Parse ESP32 CSI data from UDP bytes
|
||||
//! let raw_bytes: &[u8] = &[/* ADR-018 binary frame */];
|
||||
//! match Esp32CsiParser::parse_frame(raw_bytes) {
|
||||
//! Ok((frame, consumed)) => {
|
||||
//! println!("Parsed {} subcarriers ({} bytes)", frame.subcarrier_count(), consumed);
|
||||
@@ -39,7 +37,10 @@
|
||||
mod csi_frame;
|
||||
mod error;
|
||||
mod esp32_parser;
|
||||
pub mod aggregator;
|
||||
mod bridge;
|
||||
|
||||
pub use csi_frame::{CsiFrame, CsiMetadata, SubcarrierData, Bandwidth, AntennaConfig};
|
||||
pub use error::ParseError;
|
||||
pub use esp32_parser::Esp32CsiParser;
|
||||
pub use bridge::CsiData;
|
||||
|
||||
Reference in New Issue
Block a user