Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 std and no_std split, 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:

  1. Initialise the hardware and the RTOS.
  2. Initialise the logger, choosing an output format.
  3. Build a NodeHardware from the radio interfaces and controller.
  4. Build a CSINode describing the role.
  5. 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.