Expand description
§A crate for CSI collection on ESP devices
§Overview
This crate builds on the low level Espressif abstractions to enable the collection of Channel State Information (CSI) on ESP devices with ease.
Currently this crate supports only the ESP no-std development framework.
§Choosing a device
In terms of hardware, you need to make sure that the device you choose supports WiFi and CSI collection. Currently supported devices include:
- ESP32
- ESP32-C3
- ESP32-C5 (dual-band 2.4/5 GHz)
- ESP32-C6
- ESP32-S3
In terms of project and software toolchain setup, you will need to specify the hardware you will be using. To minimize headache, it is recommended that you generate a project using esp-generate as explained next.
§Creating a project
To use this crate you would need to create and setup a project for your ESP device then import the crate. This crate is compatible with the no-std ESP development framework. You should also select the corresponding device by activating it in the crate features.
To create a projects it is highly recommended to refer the to instructions in The Rust on ESP Book before proceeding. The book explains the full esp-rs ecosystem, how to get started, and how to generate projects for both std and no-std.
Espressif has developed a project generation tool, esp-generate, to ease this process and is recommended for new projects. As an example, you can create a no-std project for the ESP32-C3 device as follows:
cargo install esp-generate
esp-generate --chip=esp32c3 [project-name]§Feature Flags
⚠️ At minimum you are required to choose only ONE of the device options. For the most efficient (and fastest) logging, you should combine defmt with the jtag-serial features.
-
no-std(enabled by default) — Build without the standard library. -
println(enabled by default) — Enable logging viaprintln!(). -
auto(enabled by default) — Automatically select the logging transport and backend at runtime.Runtime behavior (every chip except the original ESP32, which has no USB-Serial-JTAG):
- USB SOF detected (USB-Serial-JTAG path) -> async logging backend
- no USB SOF (UART path) -> sync inline logging
Precedence:
- Under
autothe backend follows the transport, with or withoutasync-print: a UART console is never run through the async backend. jtag-serial/uartforce transport selection;autois for runtime transport selection.
-
defmt— Enable logging via defmt. -
jtag-serial— Force the JTAG backend for logging.With
auto(and without explicitasync-print), runtime selection enables async drain when USB SOF indicates JTAG/USB-Serial-JTAG is active. -
uart— Force the UART backend for logging (not recommended, much slower than JTAG backend). Do NOT combine withasync-print: UART is already blocking and slow, and the async logging path provides no benefit while adding overhead and risk of unexpected behavior. -
no-print— Disable all logging. -
statistics— Enable collection of runtime statistics (e.g., latency, dropped packets). -
synthetic— A deterministic synthetic IEEE 802.11bf measurement source (wire::synthetic::SyntheticBf), for driving the delivery pipeline and host tooling without sensing hardware. -
async-print— Non-blocking async logging. Withoutauto(or on the ESP32) this selects the async backend. Withautoit no longer forces async: the backend still follows the transport detected at boot (async on USB-Serial-JTAG, sync on UART). -
external-defmt-logger— Use an external defmt global logger instead of registering one from this crate. -
cpu-trace— Enable esp-rtos scheduler tracing (esp-rtos/rtos-traceplus thertos-tracecrate), so a CPU-utilization device under test can measure absolute idle time from thesystem_idle/task_exec_beginswitch hooks by installing artos_trace::global_trace!sink. No in-tree target installs one; the matching traffic generator isesp_now_benchinexperiments/(seecpu-test-tx). Adds a hook call to every context switch. -
cpu-test-tx— Expose runtime TX controls (rate / payload / pause) that let the CPU-utilization harness (esp_now_benchinexperiments/, withMEASURE = Cpu) steer the real TX loops from its phase schedule: the ESP-NOW initiator’s control-packet loop and the emitter’s send loop. Only that measurement needs it. -
esp32— Support for the ESP32 -
esp32c3— Support for the ESP32-C3 -
esp32c6— Support for the ESP32-C6 -
esp32s3— Support for the ESP32-S3 (AIoT) -
esp32c5— Support for the ESP32-C5 (dual-band 2.4/5 GHz)
§Logging Backends
Two logging backends are supported and they are mutually exclusive:
println(default) — plain text viaesp-println. Decoded by any serial monitor.defmt— compact binary frames viaesp-println’sdefmt-espflashbackend, decoded byespflash --monitor --log-format defmt. Thebuild.rsadds-Tdefmt.xautomatically when this feature is on, so no manual linker-script edits are needed.
Per-chip cargo aliases ship in .cargo/config.toml for both flavors:
cargo esp32c3 --example sniffer # println
cargo esp32c3-defmt --example sniffer # defmtReplace esp32c3 with any of: esp32, esp32c3, esp32c5, esp32c6, esp32s3. -build and -build-defmt variants compile without flashing.
§Using the Crate
Each ESP device is one node in a CSI sensing network, described by independent attributes
named in IEEE 802.11bf’s terms: how it reaches the channel (its Mode), its part in a sensing
session (SessionRole: initiator, responder, or none for an observer), which end of a
sounding it is (SensingRole: transmitter, receiver, or both), and whether and how often its
measurements leave it (Reporting). crate::model is the normative description and the
only place it is written down — this page does not restate it.
In practice you pick a mode and the rest follows, because each mode offers only the attributes it admits:
| Constructor | Mode | Session role | Sensing role | Reporting |
|---|---|---|---|---|
CsiNode::sniffer | Wi-Fi sniffer | none (observer) | receiver | report, threshold-based or decimate |
CsiNode::station | Wi-Fi station | initiator or none | transmitter-receiver or receiver | any |
CsiNode::access_point | Wi-Fi access point | initiator | receiver | any |
CsiNode::emitter | Emitter (transmit-only sounding) | none | transmitter | none |
CsiNode::esp_now | ESP-NOW | initiator or responder | transmitter-receiver | any |
CsiNode::esp_now_simplex_source | ESP-NOW simplex | initiator | transmitter | none |
CsiNode::esp_now_simplex_peer | ESP-NOW simplex | responder | receiver | report |
Where an attribute is fixed there is no setter to call, so a transmitting sniffer, a silent sniffer or a reporting emitter cannot be built. Where it is free, set it on the mode’s config:
EspNowConfigcarriessessionandreporting;WifiStationConfigcarriessession(Some(Initiator)orNone) andreporting;WifiApConfigcarries onlyreporting, because an access point is always the initiator;WifiSnifferConfigofferswith_report_to,with_thresholdandwith_decimation, and no way to reportNone;- the emitter and simplex configs carry neither.
How often the node sounds the channel, at what width, and how it acquires the estimate is its
MeasurementSetup — passed to every constructor except the emitter’s. (The serialized form a
controller sends over a link is the separate wire::MeasurementSetup.)
The run is started and stopped by its Controller — whatever calls CsiNode::run and
CsiNodeClient::send_stop — which is never a node, and never the session’s initiator.
§Bandwidth and PHY
An emitter transmits HT20 or HT40 (EmitterPhy) — plain 802.11n, supported on every chip
listed above — or, on the ESP32-C5 and C6, HE20 (802.11ax SU). An HE20 capture needs HE-LTF
acquisition at the measuring node (CsiConfig::he20() on those chips); the full HE
estimate is ~242 subcarriers where the legacy L-LTF one is 53. Associated stations and access
points reach HE20 with CsiNode::set_protocol(Protocol::AX), and ESP-NOW with
EspNowConfig::with_he20. 40 MHz needs a secondary channel above or below the primary, and every node in a
capture set must agree on the primary channel. Confirm it engaged at the measuring node: a subcarrier
count >= 100 (commonly ~117) is HT40, ~53/~56 means it fell back.
§Output Formats & Logging Modes
esp-csi-rs is able to print CSI data in several formats. The output format can be configured when initializing the logger. The supported formats include:
- LogMode::ArrayList: This prints CSI data as an array, where the array represents the CSI values for a received packet. This format is more compact and easier to read for large volumes of CSI data.
Example output:
[3916,-93,11,157,1,1815804,256,0,260,2,0,1,1,128,0,1,1,0,1,0,0,0,256,128,[...]]The array fields are, in order (unchanged since 0.11, so existing parsers keep working;
timestamp is the low 32 bits of wire::RxMeta::timestamp_us):
| Index | Field | Description |
|---|---|---|
| 0 | sequence_number | Sequence number of the packet that triggered the CSI capture |
| 1 | rssi | Received Signal Strength Indicator (dBm) |
| 2 | rate | PHY rate encoding (valid for non-HT / 802.11b/g packets) |
| 3 | noise_floor | Noise floor of the RF module (dBm) |
| 4 | channel | Primary channel on which the packet was received |
| 5 | timestamp | Local timestamp when the packet was received (microseconds) |
| 6 | sig_len | Length of the packet including Frame Check Sequence (FCS) |
| 7 | rx_state | Reception state: 0 = no error, non-zero = error code |
| 8 | secondary_channel | Secondary channel: 0 = none, 1 = above, 2 = below (not on ESP32-C5/C6) |
| 9 | sgi | Short Guard Interval: 0 = Long GI, 1 = Short GI (not on ESP32-C5/C6) |
| 10 | antenna | Antenna number: 0 = antenna 0, 1 = antenna 1 (not on ESP32-C5/C6) |
| 11 | ampdu_cnt | Number of subframes aggregated in AMPDU (not on ESP32-C5/C6) |
| 12 | sig_mode | Protocol: 0 = non-HT (11b/g), 1 = HT (11n), 3 = VHT (11ac) (not on ESP32-C5/C6) |
| 13 | mcs | Modulation Coding Scheme; for HT packets ranges from 0 (MCS0) to 76 (MCS76) (not on ESP32-C5/C6) |
| 14 | bandwidth | Channel bandwidth: 0 = 20 MHz, 1 = 40 MHz (not on ESP32-C5/C6) |
| 15 | smoothing | Channel estimate smoothing: 0 = unsmoothed, 1 = smoothing recommended (not on ESP32-C5/C6) |
| 16 | not_sounding | Sounding PPDU flag: 0 = sounding PPDU, 1 = not a sounding PPDU (not on ESP32-C5/C6) |
| 17 | aggregation | Aggregation type: 0 = MPDU, 1 = AMPDU (not on ESP32-C5/C6) |
| 18 | stbc | Space-Time Block Code: 0 = non-STBC, 1 = STBC (not on ESP32-C5/C6) |
| 19 | fec_coding | Forward Error Correction / LDPC flag; set for 11n LDPC packets (not on ESP32-C5/C6) |
| 20 | sig_len | Packet length including FCS (repeated) |
| 21 | csi_data_len | Length of the raw CSI data (number of i8 samples) |
| 22 | [csi_data] | Inner array of raw CSI i8 samples |
- LogMode::Text: This output prints CSI data in a more verbose, human-readable format. This includes additional metadata and explanations alongside the raw CSI values, making it easier to understand the context of each packet’s CSI data.
Example output:
mac: 56:6C:EB:6F:BC:3D
sequence number: 426
rssi: -82
rate: 11
noise floor: 165
channel: 1
timestamp: 2424915
sig len: 332
rx state: 0
dump len: 336
sigb len: 2
cur single mpdu: 0
cur bb format: 1
rx channel estimate info vld: 1
rx channel estimate len: 128
time seconds: 0
channel: 1
is group: 1
rxend state: 0
rxmatch3: 1
rxmatch2: 0
rxmatch1: 0
rxmatch0: 0
sig_len: 332
data length: 128
csi raw data: [0, 0, 0, 0, 0, 0, 0, 0, -6, 0, 6, 0, -24, 10, -23, 9, -23, 8, -23, 7, -22, 6, -22, 5, -22, 6, -23, 5, -22, 6, -22, 6, -22, 7, -20, 7, -19, 9, -19, 10, -19, 12, -19, 12, -18, 14, -19, 14, -19, 16, -20, 17, -21, 18, -20, 18, -19, 18, -16, 18, -14, 19, -13, 18, 0, 0, -19, 22, -20, 22, -20, 22, -20, 21, -21, 19, -22, 18, -20, 16, -18, 16, -17, 15, -16, 15, -14, 15, -13, 13, -12, 13, -9, 13, -7, 14, -6, 14, -5, 13, -3, 12, 0, 13, 2, 12, 3, 12, 5, 12, 7, 13, 8, 13, 10, 13, 12, 14, 9, 1, -5, -4, 0, 0, 0, 0, 0, 0]-
LogMode::Serialized: Each packet goes out as the
wirecontract: a postcard-encodedwire::Envelopefollowed by awire::Body, in one COBS frame. The envelope carries the format version, node id, session id, source kind and a per-run frame counter, and is encoded first so a decoder checks the version before it reads the body (wire::decode_cobsdoes exactly that). The first frame of each run is awire::SessionInfoannouncement. Log lines written while this mode is active are COBS-delimited too, so they occupy a frame of their own instead of corrupting the packet that follows. -
LogMode::EspCsiTool: One
CSI_DATA,...CSV line per packet in the ESP32-CSI-Tool layout, with its header line printed once at startup, so existing tooling for that format reads it unchanged. The 26 columns are:type,role,mac,rssi,rate,sig_mode,mcs,bandwidth,smoothing,not_sounding,aggregation,stbc,fec_coding,sgi,noise_floor,ampdu_cnt,channel,secondary_channel,local_timestamp,ant,sig_len,rx_state,real_time_set,real_timestamp,len,CSI_DATAroleis a label, not a node attribute: set it withlogging::logging::set_role(STA,APorPASSIVE). On the ESP32-C5/C6, which do not report the per-packet PHY fields, those columns are written as0.logging::logging::set_csi_tool_emit_capcaps the number of samples in the last column.
§On-Device CSI Processing
Register a fn(&CsiPacket) with set_csi_callback to process
every captured CSI packet inline in the WiFi-task callback. Zero
channel hops, lowest possible latency. The callback runs on the WiFi
hot path so it must be fast and non-blocking — no heap allocation,
no locking, no UART I/O. Heavier work belongs in your own task; copy
what you need out of the borrowed packet and post it via atomics or
a queue. See examples/csi_callback.rs for a working demo.
use esp_csi_rs::{set_csi_callback, csi::CsiPacket};
fn on_csi(packet: &CsiPacket) {
// your processing — keep it fast
}
set_csi_callback(on_csi);§Example: a Wi-Fi station that drives and reports its own measurements
There are more examples in the repository. The example below demonstrates how to collect CSI data with an ESP configured in WIFI Station mode.
§Step 1: Initialize Logger
init_logger(spawner, LogMode::ArrayList);§Step 2: Create a Hardware Instance for the CSI Node
// `controller` is a `&mut WifiController<'static>` from `WifiController::new`, in a `StaticCell`.
// The hardware bundle claims the station/AP interfaces, the sniffer and ESP-NOW from it.
let csi_hardware = NodeHardware::new(controller);§Step 3: Create a Station Configuration
use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::AuthenticationMethodConfig;
let client_config = StationConfig::default()
.with_ssid("SSID".try_into().unwrap())
.with_authentication(AuthenticationMethodConfig::Wpa2Personal("PASS".try_into().unwrap()));
// `WifiStationConfig` carries the two attributes this mode admits; both default as shown.
let station_config = WifiStationConfig::new(client_config)
.session(Some(esp_csi_rs::SessionRole::Initiator))
.reporting(esp_csi_rs::Reporting::HOST);In esp-radio 1.0 with_ssid takes an Ssid (fallibly converted from a &str of at most 32 bytes),
and the password travels inside the AuthenticationMethodConfig variant.
§Step 4: Create a CSI Node with the Desired Configuration
let setup = MeasurementSetup::new()
.periodicity_hz(100) // gateway ping rate (Hz) — the uplink this initiator drives
.acquisition(CsiConfig::default());
let mut node = CsiNode::station(station_config, setup, csi_hardware);§Step 5: (Optional) Register an On-Device CSI Callback
set_csi_callback(|packet| {
// process `packet` inline — keep it fast
});§Step 6: Create a CSI Node Client to Control the Node
let mut node_handle = CsiNodeClient::new();§Step 7: Run the Node for a Fixed Duration
node.run_duration(1000, &mut node_handle).await;§Examples
The repository ships runnable firmware for every supported topology in the
examples directory.
Build one with the per-chip cargo aliases, e.g. cargo esp32c6 --example esp_now.
There is one example per mode, with the variants that used to be separate files folded into
consts at the top of each. Every per-mode example opens with its node’s model attributes.
| Example | What it does |
|---|---|
sniffer | Observer — locks a channel and measures every frame overheard |
emitter | Sensing transmitter — unassociated sounding at HT20, HT40 or (C5/C6) HE20; pair with sniffer |
station | Associates to an AP or router; initiator or passive |
access_point | Self-contained softAP with DHCP; associated stations generate the uplink |
esp_now | The symmetric pair — initiator and responder, any reporting, HT20/HT40/HE20 |
esp_now_simplex | The asymmetric pair — the highest CSI rate of any pairing |
csi_callback | The two CSI delivery paths — inline callback vs. queued |
runtime_config | Reconfiguring one node between runs without reflashing |
synthetic_source | A non-radio MeasurementSource feeding 802.11bf-shaped reports through emit |
Measurement and characterization harnesses live separately under experiments/, documented in
experiments/README.md.
§Architecture
Everything is in this crate. Between 0.9.0 and 0.10.0 the engine lived in a
separate esp-csi-rs-core crate that this one re-exported wholesale; that split
has been undone, because 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 stays published and is not yanked: esp-csi-rs
0.9.0 depends on it, so removing it would retroactively break that release. It
receives no further versions. Anything written against
esp_csi_rs_core:: should move to esp_csi_rs:: — the paths are otherwise
unchanged.
One seam is deliberate and worth knowing about if you extend this crate:
RadioProfile is the hook for driving a PHY this crate does not implement
itself, and esp_radio is re-exported so an out-of-tree profile is built
against the same WifiController / CsiConfig types the engine uses. Resolving
a different esp-radio would otherwise make impl RadioProfile silently fail
to satisfy the trait.
Re-exports§
pub use crate::csi::CsiPacket;pub use crate::csi::source::EspVendorSource;pub use crate::csi::source::MeasurementSource;pub use crate::csi::source::emit;pub use crate::csi::session::header_digest_enabled;pub use crate::csi::session::node_id;pub use crate::csi::session::session_id;pub use crate::csi::session::set_header_digest_enabled;pub use crate::csi::session::set_session;pub use crate::csi::delivery::CsiNodeClient;pub use crate::csi::delivery::CsiDeliveryMode;pub use crate::csi::delivery::clear_csi_callback;pub use crate::csi::delivery::csi_delivery_mode;pub use crate::csi::delivery::csi_logging_enabled;pub use crate::csi::delivery::csi_min_sig_mode;pub use crate::csi::delivery::csi_output_enabled;pub use crate::csi::delivery::csi_peer_filter;pub use crate::csi::delivery::run_process_csi_packet;pub use crate::csi::delivery::set_csi_callback;pub use crate::csi::delivery::set_csi_delivery_mode;pub use crate::csi::delivery::set_csi_logging_enabled;pub use crate::csi::delivery::set_csi_min_sig_mode;pub use crate::csi::delivery::set_csi_output_enabled;pub use crate::csi::delivery::set_csi_peer_filter;pub use crate::csi::delivery::set_csi_raw_callback;pub use crate::csi::delivery::OverflowPolicy;pub use crate::csi::delivery::csi_queue_overflow;pub use crate::csi::delivery::set_csi_queue_overflow;pub use crate::csi::delivery::set_reporting;pub use crate::csi::delivery::runtime_reporting;pub use crate::emitter::frame::BROADCAST;pub use crate::emitter::frame::PROBE_FRAME_LEN;pub use crate::emitter::frame::build_probe_frame;pub use crate::emitter::frame::inject_probe_once;pub use crate::emitter::EmitterConfig;pub use crate::emitter::EmitterPhy;pub use crate::emitter::HtBandwidth;Deprecated pub use crate::node::CsiNode;pub use crate::node::IOTaskConfig;pub use crate::node::NodeHardware;pub use crate::node::WifiApConfig;pub use crate::node::WifiSnifferConfig;pub use crate::node::WifiStationConfig;pub use crate::model::Controller;pub use crate::model::Mode;pub use crate::model::NodeView;pub use crate::model::ReportTo;pub use crate::model::Reporting;pub use crate::model::SensingRole;pub use crate::model::SessionRole;pub use crate::model::SimplexConfig;pub use crate::model::SimplexEnd;pub use crate::model::Threshold;pub use crate::measurement::MeasurementSetup;pub use crate::esp_now_config::EspNowConfig;pub use crate::esp_now_pool::set_raw_recv_callback;pub use crate::espnow_phy::PeerPhy;pub use crate::espnow_phy::apply_peer_espnow_phy;pub use crate::espnow_phy::install_static_espnow_recv;pub use crate::espnow_phy::set_peer_espnow_phy;pub use crate::responder::esp_now::set_raw_listen;pub use crate::protocol::ControlPacket;pub use crate::protocol::ResponderPacket;pub use crate::logging::logging::get_log_packet_drops;pub use crate::logging::logging::is_async_logging_active;pub use crate::logging::logging::log_line;pub use crate::profile::RadioProfile;pub use crate::profile::StandardProfile;pub use crate::config::CsiConfig;pub use crate::logging::logging::LogMode;pub use crate::logging::logging::init_logger;pub use esp_radio;
Modules§
- config
- Runtime configuration types.
- csi
- CSI packets: what a node delivers for every channel measurement.
- emitter
- The Emitter mode: transmit-only sounding.
- esp_
now_ config EspNowConfig, the configuration of both ESP-NOW modes.- esp_
now_ pool - ESP-NOW receive pool that bypasses esp-radio’s heap-allocating dispatcher.
- espnow_
phy - ESP-NOW PHY forcing: per-peer rate and HT bandwidth.
- initiator
- Drivers for the initiator end of the ESP-NOW modes.
- logging
- Logging backends and CSI-line emission for the crate.
- measurement
MeasurementSetup: how often a node sounds the channel, at what width, and how it acquires the estimate.- model
- The node model: the attributes that describe a node in a CSI sensing network, named in IEEE 802.11bf’s terms wherever the standard has a term.
- node
- The
CsiNodeorchestrator and the Wi-Fi mode configs. - profile
- Radio-profile seam.
- protocol
- ESP-NOW wire-format packets and the magic-prefix framing helpers.
- responder
- Drivers for the responder end of the ESP-NOW modes.
- stats
- Global statistics counters and sequence-drop detection state.
- time
- Calendar-time helpers for stamping CSI packets.
- wifi
- The Wi-Fi station and access-point engines: the association-based way of reaching the channel.
- wire
- The CSI wire contract: what a node emits, independent of the radio that measured it.