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-rsand 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
stdandno_stdsplit, 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.
| Group | Features | Notes |
|---|---|---|
| Device | esp32, esp32c3, esp32c5, esp32c6, esp32s3 | Exactly one is required |
| Logging | println (default), defmt, no-print | Mutually exclusive |
| Transport | auto (default), jtag-serial, uart | auto picks the backend at runtime by detecting USB SOF |
| Behaviour | statistics, async-print, external-defmt-logger, no-std | async-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:
- Add
defmt = "1.0"as a direct dependency — the crate’slog_ln!macro expands todefmt::println!at your call site, so the crate must resolve from your code. Do not adddefmt-rttor another logger; one is already provided viaesp-println/defmt-espflash. - Add
-Tdefmt.xto 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"] - 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:
| Variant | What it does |
|---|---|
Emitter | Forces a fixed TX PHY and loop-injects a raw sounding frame |
Collector | Captures the channel response via one of the three capture paths |
Central | ESP-NOW initiator; can also capture |
Peripheral | ESP-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::newnow takes aNodeRoleand no longer takes aCollectionMode. If you are porting code that usedCollectionMode::Listenerto keep the radio capturing without delivering, that is nownode.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, anddump_ack_en. - Newer parts (C5, C6) expose
enable,acquire_csi_legacy,acquire_csi_ht20,acquire_csi_ht40,val_scale_cfg(0–3), anddump_ack_en. The C5 addsacquire_csi_force_lltfandacquire_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 band — Ht40Above 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.
| Example | What it does |
|---|---|
sniffer_wifi | Promiscuous collector — locks a channel, measures every frame |
wifi_station / wifi_ap | Associated collector: station side / self-contained softAP side |
ht20_emitter / ht40_emitter | Raw 802.11n injection at 20 or 40 MHz; pair with a sniffer |
collector_sniffer | The collector half of the emitter/collector pairing |
esp_now_central / esp_now_peripheral | Connectionless ESP-NOW pair; both sides can capture |
esp_now_fast_collector / esp_now_fast_source | Asymmetric simplex ESP-NOW — the highest CSI rate of any pairing |
esp_now_*_ht40 | The ESP-NOW pair with a forced HT40 per-peer TX PHY |
csi_callback_test | The two delivery paths side by side — inline callback vs. queued |
runtime_config | Changing 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.
Relevant Links & Resources
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