Skip to main content

Crate esp_csi_rs

Crate esp_csi_rs 

Source
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 via println!().

  • 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 auto the backend follows the transport, with or without async-print: a UART console is never run through the async backend.
    • jtag-serial / uart force transport selection; auto is for runtime transport selection.
  • defmt — Enable logging via defmt.

  • jtag-serial — Force the JTAG backend for logging.

    With auto (and without explicit async-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 with async-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. Without auto (or on the ESP32) this selects the async backend. With auto it 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-trace plus the rtos-trace crate), so a CPU-utilization device under test can measure absolute idle time from the system_idle / task_exec_begin switch hooks by installing a rtos_trace::global_trace! sink. No in-tree target installs one; the matching traffic generator is esp_now_bench in experiments/ (see cpu-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_bench in experiments/, with MEASURE = 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 via esp-println. Decoded by any serial monitor.
  • defmt — compact binary frames via esp-println’s defmt-espflash backend, decoded by espflash --monitor --log-format defmt. The build.rs adds -Tdefmt.x automatically 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 # defmt

Replace 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:

ConstructorModeSession roleSensing roleReporting
CsiNode::snifferWi-Fi sniffernone (observer)receiverreport, threshold-based or decimate
CsiNode::stationWi-Fi stationinitiator or nonetransmitter-receiver or receiverany
CsiNode::access_pointWi-Fi access pointinitiatorreceiverany
CsiNode::emitterEmitter (transmit-only sounding)nonetransmitternone
CsiNode::esp_nowESP-NOWinitiator or respondertransmitter-receiverany
CsiNode::esp_now_simplex_sourceESP-NOW simplexinitiatortransmitternone
CsiNode::esp_now_simplex_peerESP-NOW simplexresponderreceiverreport

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:

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):

IndexFieldDescription
0sequence_numberSequence number of the packet that triggered the CSI capture
1rssiReceived Signal Strength Indicator (dBm)
2ratePHY rate encoding (valid for non-HT / 802.11b/g packets)
3noise_floorNoise floor of the RF module (dBm)
4channelPrimary channel on which the packet was received
5timestampLocal timestamp when the packet was received (microseconds)
6sig_lenLength of the packet including Frame Check Sequence (FCS)
7rx_stateReception state: 0 = no error, non-zero = error code
8secondary_channelSecondary channel: 0 = none, 1 = above, 2 = below (not on ESP32-C5/C6)
9sgiShort Guard Interval: 0 = Long GI, 1 = Short GI (not on ESP32-C5/C6)
10antennaAntenna number: 0 = antenna 0, 1 = antenna 1 (not on ESP32-C5/C6)
11ampdu_cntNumber of subframes aggregated in AMPDU (not on ESP32-C5/C6)
12sig_modeProtocol: 0 = non-HT (11b/g), 1 = HT (11n), 3 = VHT (11ac) (not on ESP32-C5/C6)
13mcsModulation Coding Scheme; for HT packets ranges from 0 (MCS0) to 76 (MCS76) (not on ESP32-C5/C6)
14bandwidthChannel bandwidth: 0 = 20 MHz, 1 = 40 MHz (not on ESP32-C5/C6)
15smoothingChannel estimate smoothing: 0 = unsmoothed, 1 = smoothing recommended (not on ESP32-C5/C6)
16not_soundingSounding PPDU flag: 0 = sounding PPDU, 1 = not a sounding PPDU (not on ESP32-C5/C6)
17aggregationAggregation type: 0 = MPDU, 1 = AMPDU (not on ESP32-C5/C6)
18stbcSpace-Time Block Code: 0 = non-STBC, 1 = STBC (not on ESP32-C5/C6)
19fec_codingForward Error Correction / LDPC flag; set for 11n LDPC packets (not on ESP32-C5/C6)
20sig_lenPacket length including FCS (repeated)
21csi_data_lenLength 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 wire contract: a postcard-encoded wire::Envelope followed by a wire::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_cobs does exactly that). The first frame of each run is a wire::SessionInfo announcement. 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_DATA

    role is a label, not a node attribute: set it with logging::logging::set_role (STA, AP or PASSIVE). On the ESP32-C5/C6, which do not report the per-packet PHY fields, those columns are written as 0. logging::logging::set_csi_tool_emit_cap caps 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.

ExampleWhat it does
snifferObserver — locks a channel and measures every frame overheard
emitterSensing transmitter — unassociated sounding at HT20, HT40 or (C5/C6) HE20; pair with sniffer
stationAssociates to an AP or router; initiator or passive
access_pointSelf-contained softAP with DHCP; associated stations generate the uplink
esp_nowThe symmetric pair — initiator and responder, any reporting, HT20/HT40/HE20
esp_now_simplexThe asymmetric pair — the highest CSI rate of any pairing
csi_callbackThe two CSI delivery paths — inline callback vs. queued
runtime_configReconfiguring one node between runs without reflashing
synthetic_sourceA 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 CsiNode orchestrator 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.

Macros§

log_ln
Logging macro that routes to println!/defmt based on features.
log_raw
Log raw bytes without any added newline (blocking path only; no-op when async is active).