Skip to main content

sniffer/
sniffer.rs

1//! **Sniffer** — promiscuous capture on a locked channel.
2//!
3//! | Attribute | Value |
4//! |---|---|
5//! | Mode | Wi-Fi sniffer |
6//! | Session role | none (observer) — it neither originates nor answers the traffic it measures |
7//! | Sensing role | Receiver — it never transmits |
8//! | Reporting | `REPORTING` below — `Report`, `ThresholdBased` or `Decimate`; never `None`, since a sniffer that does not report observes nothing |
9//!
10//! Neither role is settable, so `CsiNode::sniffer` takes no role arguments. See
11//! `docs/network-model.md`.
12//!
13//! This is the only mode that needs no second device: the traffic it measures is whatever is
14//! already on air. Point it at a channel with an `emitter` on it and it becomes the receiving half
15//! of a controlled pairing instead — nothing about the node changes, only what is transmitting.
16//!
17//! Frames are attributed by transmitter MAC, so several emitters can share one sniffer and the
18//! per-source rate below separates them. If a source's rate is lower than the emitter's configured
19//! frame rate, the gap is what the sniffer missed. Each line also names the PPDU format and the
20//! subcarrier layout the buffer was captured in (`CsiPayload::EspRaw`'s `LayoutId`).
21//!
22//! `REPORTING` decides how many measurements leave the node: every one, every *n*th, or only while
23//! the channel is moving. On the ESP32-C5 / C6, `HE20` switches to HE-LTF capture for an HE20
24//! emitter (`EmitterPhy::He20`): ~242 subcarriers instead of the 53 of the legacy field.
25//!
26//! Build / run:
27//!   cargo esp32c6 --example sniffer
28//!
29//! Replace `esp32c6` with any supported chip — every chip can measure.
30
31#![no_std]
32#![no_main]
33
34use embassy_executor::Spawner;
35use embassy_futures::join::join;
36use embassy_sync::blocking_mutex::Mutex;
37use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
38use embassy_time::{Duration, Timer};
39use esp_csi_rs::csi::CsiPacket;
40use esp_csi_rs::logging::logging::{LogMode, init_logger};
41use esp_csi_rs::wire::{CsiPayload, PpduFormat};
42use esp_csi_rs::{
43    CsiConfig, CsiNode, CsiNodeClient, MeasurementSetup, NodeHardware, Reporting, Threshold,
44    WifiSnifferConfig, log_ln, set_csi_callback,
45};
46use esp_hal::clock::CpuClock;
47use esp_hal::timer::timg::TimerGroup;
48use esp_radio::wifi::WifiController;
49use {esp_backtrace as _, esp_println as _};
50
51extern crate alloc;
52
53/// Channel to lock. Must match the emitter's primary channel when pairing with one.
54const CHANNEL: u8 = 7;
55
56/// `Report { to }`, `ThresholdBased(..)` or `Decimate(n)`. A threshold must be calibrated: run
57/// with `Threshold::new(level, hold_ms).variation_only()` first and read the scores a still room
58/// gives.
59const REPORTING: Reporting = Reporting::HOST;
60#[allow(dead_code)]
61const EXAMPLE_THRESHOLD: Threshold = Threshold::new(6000, 500);
62
63/// HE-LTF capture (ESP32-C5 / C6 only).
64const HE20: bool = false;
65
66/// How many distinct transmitters to track.
67const MAX_SOURCES: usize = 4;
68
69static WIFI_CONTROLLER: static_cell::StaticCell<WifiController<'static>> =
70    static_cell::StaticCell::new();
71
72esp_bootloader_esp_idf::esp_app_desc!();
73
74#[derive(Clone, Copy)]
75struct SourceTally {
76    mac: [u8; 6],
77    count: u32,
78    rssi: i32,
79    ppdu: PpduFormat,
80    /// Subcarriers the buffer's layout maps; 0 when the layout is unknown.
81    mapped: usize,
82}
83
84fn ppdu_name(p: PpduFormat) -> &'static str {
85    match p {
86        PpduFormat::Dsss => "DSSS",
87        PpduFormat::NonHt => "non-HT",
88        PpduFormat::Ht => "HT",
89        PpduFormat::Vht | PpduFormat::VhtMu => "VHT",
90        PpduFormat::HeSu => "HE SU",
91        PpduFormat::HeMu | PpduFormat::HeErSu | PpduFormat::HeTb => "HE",
92        _ => "?",
93    }
94}
95
96/// Per-transmitter tallies, written from the CSI callback and drained by the reporting task.
97static SOURCES: Mutex<
98    CriticalSectionRawMutex,
99    core::cell::RefCell<heapless::Vec<SourceTally, MAX_SOURCES>>,
100> = Mutex::new(core::cell::RefCell::new(heapless::Vec::new()));
101
102fn on_csi(packet: &CsiPacket) {
103    // Two `i8` samples per subcarrier, so this is the width of the capture.
104    SUBCARRIERS.store(
105        packet.subcarriers() as u32,
106        core::sync::atomic::Ordering::Relaxed,
107    );
108    let ppdu = packet.meta().ppdu;
109    let mapped = match &packet.frame.payload {
110        CsiPayload::EspRaw { layout, .. } => layout.indices().count(),
111        _ => 0,
112    };
113    SOURCES.lock(|cell| {
114        let mut list = cell.borrow_mut();
115        if let Some(entry) = list.iter_mut().find(|e| e.mac == packet.mac()) {
116            entry.count += 1;
117            entry.rssi = packet.rssi() as i32;
118            entry.ppdu = ppdu;
119            entry.mapped = mapped;
120            return;
121        }
122        let _ = list.push(SourceTally {
123            mac: packet.mac(),
124            count: 1,
125            rssi: packet.rssi() as i32,
126            ppdu,
127            mapped,
128        });
129    });
130}
131
132async fn report_task() {
133    let mut previous: heapless::Vec<([u8; 6], u32), MAX_SOURCES> = heapless::Vec::new();
134    loop {
135        Timer::after_secs(1).await;
136        let snapshot = SOURCES.lock(|cell| cell.borrow().clone());
137        if snapshot.is_empty() {
138            log_ln!("No CSI yet — is anything transmitting on channel {}?", CHANNEL);
139            continue;
140        }
141        for entry in snapshot.iter() {
142            let last = previous
143                .iter()
144                .find(|(mac, _)| *mac == entry.mac)
145                .map(|(_, c)| *c)
146                .unwrap_or(0);
147            log_ln!(
148                "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}  {} CSI/s  total {}  RSSI {}  subcarriers {}  {}  layout maps {}",
149                entry.mac[0],
150                entry.mac[1],
151                entry.mac[2],
152                entry.mac[3],
153                entry.mac[4],
154                entry.mac[5],
155                entry.count.wrapping_sub(last),
156                entry.count,
157                entry.rssi,
158                SUBCARRIERS.load(core::sync::atomic::Ordering::Relaxed),
159                ppdu_name(entry.ppdu),
160                entry.mapped,
161            );
162        }
163        previous.clear();
164        for entry in snapshot.iter() {
165            let _ = previous.push((entry.mac, entry.count));
166        }
167    }
168}
169
170/// Last capture's subcarrier count. `>= 100` (commonly ~117) confirms HT40 actually engaged;
171/// ~53 or ~56 means it fell back to legacy or HT20. See `docs/bandwidth.md`.
172static SUBCARRIERS: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0);
173
174/// HE-LTF acquisition exists only on the 802.11ax parts.
175fn csi_config() -> CsiConfig {
176    #[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
177    if HE20 {
178        return CsiConfig::he20();
179    }
180    CsiConfig::default()
181}
182
183#[esp_rtos::main]
184async fn main(spawner: Spawner) -> ! {
185    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
186    let peripherals = esp_hal::init(config);
187    init_logger(spawner, LogMode::Text);
188
189    esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 61440);
190
191    let timg0 = TimerGroup::new(peripherals.TIMG0);
192    esp_rtos::start(timg0.timer0, peripherals.FROM_CPU_INTR0);
193
194    let config_radio = esp_radio::wifi::ControllerConfig::default();
195    let wifi_controller = esp_radio::wifi::WifiController::new(peripherals.WIFI, config_radio)
196        .expect("Failed to initialize Wi-Fi controller");
197    let controller = WIFI_CONTROLLER.init(wifi_controller);
198
199    log_ln!("Starting sniffer (observer, receiver) on channel {}", CHANNEL);
200
201    let mut node_handle = CsiNodeClient::new();
202    let hardware = NodeHardware::new(controller);
203    let mut sniffer = WifiSnifferConfig::default().with_channel(CHANNEL);
204    match REPORTING {
205        Reporting::Report { to } => sniffer = sniffer.with_report_to(to),
206        Reporting::ThresholdBased(t) => sniffer = sniffer.with_threshold(t),
207        Reporting::Decimate(n) => sniffer = sniffer.with_decimation(n),
208        _ => {}
209    }
210    let setup = MeasurementSetup::new().acquisition(csi_config());
211    let mut node = CsiNode::sniffer(sniffer, setup, hardware);
212    node.set_protocol(if HE20 {
213        esp_radio::wifi::Protocol::AX
214    } else {
215        esp_radio::wifi::Protocol::N
216    });
217
218    set_csi_callback(on_csi);
219    let _ = &mut node_handle;
220    join(node.run(), report_task()).await;
221
222    loop {
223        log_ln!("Sniffer stopped");
224        Timer::after(Duration::from_secs(5)).await;
225    }
226}