Your First Collector
This section builds the same capture as the last one, but as firmware you wrote. The reason to do that is not the capture itself — the CLI already does it — but everything that becomes possible once the CSI packet is a value in your own program rather than a line on a terminal.
Toolchain
Which toolchain you need depends on the architecture of your board.
RISC-V parts (C3, C5, C6) build on stable Rust. Add the target:
rustup target add riscv32imac-unknown-none-elf
Xtensa parts (ESP32, S3) need Espressif’s fork of the compiler, installed
with espup:
cargo install espup
espup install
# then, in each shell:
source ~/export-esp.sh
Both paths also want espflash, which you installed in the previous section,
and the project generator:
cargo install esp-generate
If you have not built for an ESP part in Rust before, work through The Rust on ESP Book first. It covers the toolchain, the
stdandno_stdsplit, and flashing in far more detail than is appropriate here.
Create the Project
esp-generate --chip=esp32c6 first-collector
cd first-collector
Then add the crate, selecting your chip and a logging backend:
[dependencies]
esp-csi-rs = { version = "0.10", features = ["esp32c6", "println"] }
Exactly one device feature is required. println is the default logging backend
and needs nothing else; defmt is denser on the wire but needs the three extra
steps described in the
esp-csi-rs section.
The Shape of a Collector
Every node follows the same five steps, whatever role it plays:
- Initialise the hardware and the RTOS.
- Initialise the logger, choosing an output format.
- Build a
NodeHardwarefrom the radio interfaces and controller. - Build a
CSINodedescribing the role. - Run it.
Adapted from examples/sniffer_wifi.rs, with the boilerplate kept so the shape
is visible:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_futures::join::join;
use embassy_time::Timer;
use esp_csi_rs::config::CsiConfig;
use esp_csi_rs::csi::CSIDataPacket;
use esp_csi_rs::logging::logging::{LogMode, init_logger};
use esp_csi_rs::{
CSINode, CSINodeClient, CollectorMode, NodeHardware, WifiSnifferConfig,
log_ln, set_csi_callback,
};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_radio::wifi::WifiController;
use portable_atomic::{AtomicI32, AtomicU32, Ordering};
use {esp_backtrace as _, esp_println as _};
extern crate alloc;
const CHANNEL: u8 = 7;
static WIFI_CONTROLLER: static_cell::StaticCell<WifiController<'static>> =
static_cell::StaticCell::new();
esp_bootloader_esp_idf::esp_app_desc!();
// Shared state written by the inline CSI callback, read by `stats_task`.
static LATEST_RSSI: AtomicI32 = AtomicI32::new(0);
static CSI_PKT_COUNT: AtomicU32 = AtomicU32::new(0);
fn on_csi(packet: &CSIDataPacket) {
LATEST_RSSI.store(packet.rssi as i32, Ordering::Relaxed);
CSI_PKT_COUNT.fetch_add(1, Ordering::Relaxed);
}
async fn stats_task() {
let mut last_count = 0u32;
loop {
Timer::after_secs(1).await;
let total = CSI_PKT_COUNT.load(Ordering::Relaxed);
let delta = total.wrapping_sub(last_count);
last_count = total;
log_ln!(
"CSI rate: {}/s, total: {}, last RSSI: {}",
delta,
total,
LATEST_RSSI.load(Ordering::Relaxed),
);
}
}
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
// 1. Hardware and RTOS.
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
// 2. Logger, and the output format it will use.
init_logger(spawner, LogMode::Text);
esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 61440);
let timg0 = TimerGroup::new(peripherals.TIMG0);
let sw_interrupt = esp_hal::interrupt::software::SoftwareInterruptControl::new(
peripherals.SW_INTERRUPT,
);
esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
// 3. The radio, wrapped for the node to take.
let config_radio = esp_radio::wifi::ControllerConfig::default();
let (wifi_controller, mut interfaces) =
esp_radio::wifi::new(peripherals.WIFI, config_radio)
.expect("Failed to initialize Wi-Fi controller");
let controller = WIFI_CONTROLLER.init(wifi_controller);
// 4. The node: a sniffer collector locked to one channel.
let csi_hardware = NodeHardware::new(&mut interfaces, controller);
let mut node = CSINode::new_collector(
CollectorMode::Sniffer(WifiSnifferConfig::default().with_channel(CHANNEL)),
Some(CsiConfig::default()),
None, // traffic generation frequency; a sniffer generates none
csi_hardware,
);
node.set_protocol(esp_radio::wifi::Protocol::N);
// 5. Register the inline callback and run.
let mut node_handle = CSINodeClient::new();
set_csi_callback(on_csi);
let _ = &mut node_handle;
join(node.run(), stats_task()).await;
loop {
Timer::after_secs(1).await;
}
}
Build and flash it with the per-chip alias, or directly:
cargo run --release
You should see a CSI rate: N/s line once a second. If N is zero, the channel
is quiet — see the troubleshooting notes in
Getting Started.
What the Callback Can and Cannot Do
on_csi runs inside the Wi-Fi task’s CSI callback. That is what makes it
the lowest-latency path available: the packet is not copied, not queued, and not
formatted before you see it. It is also what constrains it. The rules are
absolute:
- No heap allocation.
- No locks.
- No serial or UART I/O.
- No
.await.
Everything the example does — two atomic stores — is the right order of
magnitude. Anything heavier belongs in your own task. Copy what you need out of
the borrowed packet and hand it across with atomics, a lock-free queue, or a
channel, exactly as stats_task consumes the two atomics above.
If the work genuinely cannot be made that cheap, the callback is the wrong path.
Switch to the async delivery mode instead: CsiDeliveryMode::Async enqueues
each packet onto a lock-free queue drained by
CSINodeClient::next_csi_packet().await on a task of your own. That costs a
~640-byte copy per packet, and buys you the freedom to do real work.
set_csi_delivery_mode switches between them at runtime, and
examples/csi_callback_test.rs runs both back to back and reports the rate of
each.
Exactly one path is active at a time, by design — the callback never pays both a function dispatch and a queue copy for the same packet. The async drain also has exactly one consumer slot, so spawn one drainer task per node and no more.
Adding an Emitter
A sniffer measuring ambient traffic is at the mercy of whatever else is on the channel. The controlled version sounds the channel deliberately, from a second board, at a rate you choose. It is a much shorter program, because an emitter associates with nothing and needs no configuration beyond channel, bandwidth, and period:
use embassy_time::Duration;
use esp_csi_rs::{CSINode, EmitterConfig, HtBandwidth, NodeHardware};
let hardware = NodeHardware::new(&mut interfaces, controller);
// 20 ms between frames is roughly 50 sounding frames per second.
let emitter = EmitterConfig::new(7, HtBandwidth::Ht20)
.with_period(Duration::from_millis(20));
let mut node = CSINode::new_emitter(emitter, hardware);
node.run().await;
Flash that onto a second board and the collector’s reported rate should jump to
roughly the emitter’s frame rate. examples/ht20_emitter.rs and
examples/collector_sniffer.rs are the complete pair, and the latter reports
CSI rate per source MAC, which tells you immediately whether the emitter is
being heard at all.
Both boards must be on the same primary channel. This is worth checking twice: the emitter examples default to channel 7, and several other applications in this ecosystem default to channel 1.
Where to Go Next
You have firmware producing CSI and a place in it to do something with each packet. For the reverse direction — driving a board without writing firmware — continue to Driving the CLI. For what to do with the packets once you have them, skip ahead to Signal Processing in Rust.