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

esp-csi-rs

esp-csi-rs is the Rust crate for collecting Channel State Information (CSI) on ESP32 series devices using the no_std embedded framework. It builds on top of Espressif’s low-level abstractions to enable easy CSI collection on embedded ESP devices; its purpose is to allow developers to create their own no_std CSI collection programs.

If you are looking for an out-of-the-box CSI extraction tool, check out the esp-csi-cli-rs crate. It provides a command line interface for working with esp-csi-rs and requires no embedded Rust at all.

In the context of the csi-rs ecosystem, esp-csi-rs is the crate that provides the data collection capabilities for ESP32 series devices. It lives in Tier 0 of the pipeline — the tier responsible for collecting data from the actual environment, rather than dealing with any data processing or visualization. Those are tasks that esp-csi-rs is built to facilitate, but are not provided by it.

This section describes version 0.10.1, the latest release at the time of writing.

Setting Up a Project

esp-csi-rs is a library for no_std ESP projects, so the first step is a project of that shape. Espressif’s esp-generate tool is the recommended way to scaffold one:

cargo install esp-generate
esp-generate --chip=esp32c3 your-project

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 — all of which this chapter assumes.

Then add the crate. At minimum you must select your device and a logging backend:

[dependencies]
esp-csi-rs = { version = "0.10", features = ["esp32c3", "println"] }

The crate uses Rust edition 2024 and tracks the current Espressif Rust ecosystem (esp-hal 1.1, esp-radio 0.18, esp-rtos 0.3).

Feature Flags

Features fall into four groups. The device group is mandatory and single-choice; the logging backends are mutually exclusive.

GroupFeaturesNotes
Deviceesp32, esp32c3, esp32c5, esp32c6, esp32s3Exactly one is required
Loggingprintln (default), defmt, no-printMutually exclusive
Transportauto (default), jtag-serial, uartauto picks the backend at runtime by detecting USB SOF
Behaviourstatistics, async-print, external-defmt-logger, no-stdasync-print forces the async logging path, overriding auto

Two of these are worth expanding on.

defmt versus println. println emits plain text that any serial monitor can read. defmt emits compact binary frames over the same USB-Serial-JTAG channel, decoded on the host with espflash flash --monitor --log-format defmt <elf>. defmt moves far less data per packet, which matters when a collector is producing hundreds of CSI reports a second. Using it from your own application needs three things beyond the feature flag:

  1. Add defmt = "1.0" as a direct dependency — the crate’s log_ln! macro expands to defmt::println! at your call site, so the crate must resolve from your code. Do not add defmt-rtt or another logger; one is already provided via esp-println/defmt-espflash.
  2. Add -Tdefmt.x to your linker flags in your own .cargo/config.toml, since Cargo does not propagate linker arguments from a dependency’s build script:
    [target.'cfg(target_arch = "riscv32")']
    rustflags = ["-C", "link-arg=-Tlinkall.x", "-C", "link-arg=-Tdefmt.x"]
    
  3. Decode with espflash, as above. No probe-rs or J-Link is required.

statistics. Enables runtime counters — packets transmitted and received, rate in Hz, dropped packets, and sequence-gap detection. Collected CSI is tagged with the sequence number of the frame that triggered it, and because an emitter’s frames carry driver-assigned incrementing sequence numbers, a collector can measure exactly how much of the sounding traffic it actually captured, per source MAC. This is the feature that turns “am I losing packets?” from a guess into a number, and it is the backbone of Troubleshooting & Calibration.

Node Roles

A node is created with a [NodeRole], which has four variants. Two are the roles proper — Emitter and Collector — and two name the ends of an ESP-NOW pair, which keeps the Central / Peripheral spelling it has always had:

VariantWhat it does
EmitterForces a fixed TX PHY and loop-injects a raw sounding frame
CollectorCaptures the channel response via one of the three capture paths
CentralESP-NOW initiator; can also capture
PeripheralESP-NOW responder; can also capture

A collector’s capture path is chosen with CollectorMode:

  • CollectorMode::Sniffer(WifiSnifferConfig) — promiscuous on a fixed channel.
  • CollectorMode::Station(WifiStationConfig) — associated to an AP or router.
  • CollectorMode::AccessPoint(WifiApConfig) — a self-contained softAP with a built-in DHCP server, so an associated station generates uplink traffic to measure.

Because the two most common cases are so common, there are shorthand constructors: CSINode::new_collector and CSINode::new_emitter.

Changed in 0.10. CSINode::new now takes a NodeRole and no longer takes a CollectionMode. If you are porting code that used CollectionMode::Listener to keep the radio capturing without delivering, that is now node.set_csi_output_enabled(false).

CSI Configuration

CsiConfig controls what the radio hardware acquires, and its fields differ by chip generation because the underlying PHY does:

  • Classic parts (ESP32, C3, S3) expose lltf_en, htltf_en, stbc_htltf2_en, ltf_merge_en, channel_filter_en, manu_scale, shift, and dump_ack_en.
  • Newer parts (C5, C6) expose enable, acquire_csi_legacy, acquire_csi_ht20, acquire_csi_ht40, val_scale_cfg (0–3), and dump_ack_en. The C5 adds acquire_csi_force_lltf and acquire_csi_vht.

CsiConfig::default() acquires everything, including legacy frames and ACKs. That is a sensible default for ambient sniffing and a poor one for a controlled HT40 capture, because the legacy and control-path reports will swamp the ones you care about. The symptom is distinctive: the subcarrier count sits stubbornly at ~53 and the CSI rate tracks ambient traffic rather than your emitter’s period.

The portable fix is emitter::phy::ht_csi_acquisition, which sets an HT-only acquisition on whichever chip you are on. By hand on a C5/C6:

let csi_cfg = CsiConfig {
    acquire_csi_legacy: 0,
    acquire_csi_ht20: 0,
    acquire_csi_ht40: 1,
    dump_ack_en: 0,
    ..CsiConfig::default()
};

Bandwidth

An emitter transmits HT20 or HT40 (HtBandwidth) — plain 802.11n, supported on every chip listed in the previous section. HT40 roughly doubles the subcarrier count, typically ~117–128 versus ~56 for HT20 HT-LTF or ~53 for legacy 20 MHz L-LTF.

40 MHz needs a secondary channel above or below the primary, and every node in a capture set must agree on the primary:

// Secondary channel above the primary: the 40 MHz block spans channels 7-11.
let emitter = EmitterConfig::new(7, HtBandwidth::Ht40Above);

Two things are easy to get wrong. Leave room in the bandHt40Above on channel 7 occupies up to channel 11 and Ht40Below occupies down to channel 3; a primary too close to the band edge silently falls back to 20 MHz. And the collector needs a 40 MHz receive path, not just a secondary-channel setting; the library widens the interface bandwidth for you, but hand-rolled configuration often does not.

To confirm HT40 actually engaged, check the collector’s captured packets: a subcarrier count of 100 or more (commonly ~117) confirms it, while ~53 or ~56 means it fell back.

Building a Node

The shape of a collector, adapted from examples/collector_sniffer.rs:

use esp_csi_rs::config::CsiConfig;
use esp_csi_rs::logging::logging::{LogMode, init_logger};
use esp_csi_rs::{
    CSINode, CSINodeClient, CollectorMode, NodeHardware, WifiSnifferConfig,
};

const CHANNEL: u8 = 7;

// 1. Bring up the logger, choosing an output format.
init_logger(spawner, LogMode::Text);

// 2. Wrap the radio interfaces and controller.
let hardware = NodeHardware::new(&mut interfaces, controller);

// 3. Describe the node: a sniffer collector locked to one channel.
let mut node = CSINode::new_collector(
    CollectorMode::Sniffer(WifiSnifferConfig::default().with_channel(CHANNEL)),
    Some(CsiConfig::default()),
    None,
    hardware,
);
node.set_protocol(esp_radio::wifi::Protocol::N);

// 4. Run it. `run_duration(secs, &mut client)` stops after a fixed time.
let mut node_handle = CSINodeClient::new();
node.run().await;

And the emitter that pairs with it, from examples/ht20_emitter.rs:

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;

By default the emitter broadcasts. Addressing a specific collector with with_dst_mac tends to raise that collector’s CSI rate noticeably.

Getting the Data Out

Once a node is running, captured packets can leave it three ways, selected by delivery mode and composed with two independent gates.

Inline callback. Register a fn(&CSIDataPacket) with set_csi_callback and it runs inside the Wi-Fi task’s CSI callback — zero copies, lowest possible latency. It runs on the hot path, so it must be fast and non-blocking: no heap allocation, no locking, no serial I/O. Copy what you need out of the borrowed packet and hand it to your own task via atomics or a queue.

use esp_csi_rs::{set_csi_callback, csi::CSIDataPacket};

fn on_csi(packet: &CSIDataPacket) {
    // your processing — keep it fast
}

set_csi_callback(on_csi);

Async queue. CsiDeliveryMode::Async enqueues each packet onto a lock-free queue drained by CSINodeClient::next_csi_packet(). This costs a ~640-byte copy per packet but moves your processing off the hot path entirely.

Serial logging. Independently of the above, the node can print each packet to the host in one of four formats. Those formats are the subject of the next section.

The gates compose, which is the point. set_csi_output_enabled(false) stops delivery while leaving capture and its timing untouched — useful for a node whose only job is to keep traffic on air, or for measuring capture overhead without the delivery cost. set_csi_logging_enabled(false) combined with set_csi_callback(f) gives you “process every packet on-device, print nothing”. Filtering is available too: set_csi_peer_filter restricts capture to one source MAC, and set_csi_min_sig_mode discards anything below a minimum PHY protocol.

Examples

The repository ships runnable firmware for every supported topology. Build one with the per-chip cargo aliases — cargo esp32c6 --example esp_now_central for println, or cargo esp32c6-defmt --example ... for defmt. The -build and -build-defmt variants compile without flashing, and esp32c6 can be replaced with any of esp32, esp32c3, esp32c5, esp32s3.

ExampleWhat it does
sniffer_wifiPromiscuous collector — locks a channel, measures every frame
wifi_station / wifi_apAssociated collector: station side / self-contained softAP side
ht20_emitter / ht40_emitterRaw 802.11n injection at 20 or 40 MHz; pair with a sniffer
collector_snifferThe collector half of the emitter/collector pairing
esp_now_central / esp_now_peripheralConnectionless ESP-NOW pair; both sides can capture
esp_now_fast_collector / esp_now_fast_sourceAsymmetric simplex ESP-NOW — the highest CSI rate of any pairing
esp_now_*_ht40The ESP-NOW pair with a forced HT40 per-peer TX PHY
csi_callback_testThe two delivery paths side by side — inline callback vs. queued
runtime_configChanging collection settings between runs without reflashing

Measurement and characterization harnesses live separately under experiments/ and are not usage examples; they are documented in experiments/README.md.

A Note on esp-csi-rs-core

Between 0.9.0 and 0.10.0 the engine lived in a separate esp-csi-rs-core crate that esp-csi-rs re-exported wholesale. That split has been undone: it moved the implementation and the documentation away from the name people actually depend on, while buying nothing a module boundary does not already give.

esp-csi-rs-core 0.1.x remains published and is not yanked, because esp-csi-rs 0.9.0 depends on it. It receives no further versions. Code written against esp_csi_rs_core:: should move to esp_csi_rs::; the paths are otherwise unchanged.

One seam is deliberate and worth knowing if you extend the crate: RadioProfile is the hook for driving a PHY the crate does not implement itself, and esp_radio is re-exported so an out-of-tree profile builds against the same WifiController and CsiConfig types the engine uses.

For build instructions and usage examples, the latest version of the crate can be found on crates.io

The documentation for the crate can be found on docs.rs

The source code can be viewed (and contributed to) on GitHub