CSI-RS
by Omar Hiari, Ammar Saleh, Abdelrahman Salah, Yousef Kitaneh, and Maryam Odat.
The Channel State Information Rust Ecosystem Book.
This book tracks a fast-moving ecosystem, and parts of it will age before the code does. If you spot any errors or have any suggestions, all feedback and contributions are welcome!
Preface
Channel State Information has been a research instrument for fifteen years and a deployable technology for about three. The gap between those dates is mostly a story about hardware: extracting CSI used to mean a patched Broadcom firmware or a specific Intel NIC in a desktop running a modified kernel, and it now means a three-dollar microcontroller that runs on a battery.
That change is what csi-rs exists to take advantage of. It is an umbrella
project building an open, community-driven ecosystem of libraries, tools, and
documentation for CSI-based work in Rust. The current focus is narrow and
deliberate: making CSI collection accessible, convenient, and robust on
cheap and widely available devices, so that the barrier to starting is as low as
we can make it. What you do with the data afterwards is a much larger field, and
one we would rather feed than fence in.
We chose Rust because collection is the part of the problem where correctness is
cheapest to enforce at compile time and most expensive to debug at three in the
morning — a borrowed buffer in an interrupt callback, a concurrent queue drained
from another core — and because no_std Rust turns out to fit these parts
comfortably. The csi-rs chapter makes that
case with numbers.
Who This Book Is For
Two audiences, reading in different directions.
If you are new to Wi-Fi sensing, start at the beginning and read straight through. The first three chapters build up from what Wi-Fi sensing is and why the channel carries information about a room, through the mathematics of CSI, to how the field has historically extracted it and why that changed. No radio background is assumed.
If you already know CSI and want the tooling, skip to Crates & Libraries for the reference, or straight to Getting Started to have a board producing data within the hour.
Either way, we assume you are comfortable with Rust, and that you can find your way around a terminal. Embedded experience helps but is not required — The Rust on ESP Book covers that ground, and we point at it where it matters rather than reproducing it.
How the Book Is Organised
- Chapters 0–2 are explanation. What CSI is, what the field has done with it, and why this project exists.
- Chapter 3 is reference. One section per crate, covering what it is, where it sits, and how to use it.
- Chapter 4 is the walkthrough. From an unopened board to a recorded dataset, in five steps.
- Chapter 5 is what comes after collection: processing, classification, and the diagnosis of captures that do not look right.
The appendix carries a consolidated reference list and an FAQ.
A Note on Honesty
This ecosystem moves quickly, and parts of it are further along than others. Where something does not work, the book says so plainly rather than leaving you to discover it. Where documentation in the source repositories has fallen out of step with the code, the book follows the code and says which is which. Where a technique is genuinely unsolved, such as making a trained model survive a change of room, it is described as unsolved.
We would rather be useful than impressive.
Contributing
This book is a work in progress, and so is everything it documents. If you find an error, a gap, or an instruction that does not work on your hardware, every kind of contribution is welcome — including the kind that just says “this section confused me”.
Wi-Fi Sensing 101
Wi-Fi as a Sensor Modality
Wi-Fi sensing utilizes the Physical (PHY) and Medium Access Control (MAC) layer properties of the IEEE 802.11 standard to acquire measurements that estimate specific features of objects within an area of interest.1
- Features: Range, velocity, angular motion, and presence.
- Objects: People, vehicles, or the environment itself, depending on the application task.
- Area of Interest: A strictly bounded physical space, such as a single room, a vehicle cabin, or a parking lot.
The Physical Layer
The Physical layer is where we look at how Wi-Fi packets are modulated into electromagnetic waves (EM waves) and transmitted over the air (EM waves do not require a medium to travel through, but are nonetheless affected by one if it is present). We also look at how that signal gets affected as it propagates through the environment via phenomena such as scattering, diffraction, and interference. These effects occur on the wave while it is travelling through the channel, changing its amplitude and phase. The change from each effect depends on the channel’s state (e.g., are there obstacles blocking the channel, is the channel moving or static).
A key effect one needs to consider when using Wi-Fi (whether for sensing or communication) is multipath propagation. As radio frequency (RF) signals travel from transmitter to receiver, they reflect, diffract, and scatter off physical boundaries and dynamic obstacles. Each multipath component travels a different path, meaning each has its own unique journey that must be accounted for and compensated for at the receiver to recover the original signal. The receiver captures these signal variations as Channel State Information (CSI). Currently, CSI is the most robust metric available for Wi-Fi sensing.2
The primary utilization of the Physical layer in Wi-Fi sensing is to extract CSI, via sending known training symbols and measuring the channel’s response to those symbols, allowing us to infer the channel’s state.
Radio Frequency (RF) refers to a specific range (band) of the electromagnetic spectrum, spanning from around 20 kHz to 300 GHz. The most common Wi-Fi bands are 2.4 GHz, 5 GHz, and 6 GHz. As the band increases in frequency, the wavelength decreases, and the signal becomes more easily affected by its environment. This means it can capture more detailed information, but it is also more susceptible to noise and attenuation (the weakening of a signal as it travels through a medium). Consequently, specific applications are better suited for certain frequency bands over others.
The Medium Access Control Layer
When mapping Wi-Fi sensing to the OSI model, the MAC layer is the highest layer from which we extract metadata. The MAC layer is responsible for governing flow control, channel allocation, multiplexing, multiple access, and MAC addressing.
In Wi-Fi sensing, we utilize the MAC layer properties of MAC addressing to manage multiple stations, enabling the construction of targeted sensing topologies (e.g., a sensing mesh).
Which topology we use depends on the application. For example, a star topology yields more accurate results for specific human activity recognition (HAR) applications, but the complexity of setting up and maintaining a star topology may not be worth it for a simple presence detection application, where a simpler point-to-point topology may suffice.
To maintain a high sampling rate without paralyzing the network with massive amounts of data, Wi-Fi sensing systems often exploit specific MAC frame types, such as Null Data Packets (NDPs) or beacon frames. An NDP contains no payload data; it consists entirely of the preamble and header necessary for the receiver to measure the channel. Because they are exceptionally short, NDPs consume minimal airtime and reduce the probability of collisions.
TLDR
The basic premise of using Wi-Fi as a sensing modality is to analyze how the EM waves behave as they propogate through the enironment, and infer from that the state of the environment, as the IEE 802.11 is vast and enables many protocols to build on it (such as ESP-NOW), we focus our attention on two OSI layers and their corresponding properties:
- Physical Layer: Used for channel estimation and extracting signal metadata (trhough metrics such as Channel State Information).
- Medium Access Control Layer: Used to manage sensing topologies via MAC addressing, coordinate multiple sensors, and to exploit specific frame types (like Null Data Packets) to maintain high sampling rates without causing network congestion.
When to Utilize Wi-Fi Sensing
Compared to dedicated sensing hardware like cameras or LiDAR, Wi-Fi transceivers are ubiquitous and inexpensive. This accessibility makes Wi-Fi an optimal candidate for experimenting with edge computing deployments, enabling localized processing on resource-constrained microcontrollers without external servers. Furthermore, it offers three distinct operational advantages:
- NLOS Capability: RF signals penetrate common building materials, allowing for non-line-of-sight operations.
- Device-Free: Targets do not need to carry or wear transceivers or peripherals.
- Privacy-Preserving: The coarse-grained nature of RF sensing provides protection against personally identifiable information, such as facial features.
Each Wi-Fi frequency band offers unique trade-offs between spatial resolution and environmental penetration, making them suitable for different sensing applications.
Sub-6 GHz Bands (2.4 GHz, 5 GHz, 6 GHz) These bands offer broad spatial coverage and high environmental penetration, making them optimal for macroscopic tasks such as:
- Human Activity Recognition (HAR): Detecting falls or tracking routines in care facilities.
- People Counting: Estimating crowd density or room occupancy.
- Sleep Analysis: Passively monitoring sleep stages and macro-movements.
mmWave Bands (60 GHz) Millimeter-wave operates at higher frequencies with wider channel bandwidths, yielding high spatial resolution at the cost of limited range and strict line-of-sight (LOS) requirements.
- Micro-Movement Detection: Capturing localized physiological metrics like precise heart rate and respiration.
- Gesture Recognition: Identifying fine-grained finger movements for human-computer interaction.
Limitations and Challenges
Of course, the No Free Lunch theorem applies here as well. We have to acknowledge the limitations of Wi-Fi sensing and approach the task of building Wi-Fi sensing systems with these constraints in mind, which include:
- Coarse-Grained Sensing: The very same property that makes Wi-Fi sensing privacy-preserving also limits its ability to capture fine-grained details. This also means that the resultant data of Wi-Fi sensing systems is often not directly human-interpretable, requiring machine learning or advanced signal processing to extract meaningful information and insights.
- Domain Dependency: The models used to process PHY metadata are stochastic and highly environment-dependent. Models trained in one physical space frequently fail when deployed in another due to entirely different multipath characteristics. Systems require baseline calibration and remain highly sensitive to static environmental changes (e.g., moved furniture).
- Hardware & Data Fragmentation: The 802.11 PHY layer is mathematically optimized for robust data transmission, not radar telemetry. Most standard network interface controllers (NICs) do not expose PHY metadata to higher level layeers. Accessing it requires reverse engineering or relying on specific firmware patches for limited hardware, although this is becoming less of an issue as we will explore later on.
Originally coming from the optimization field, the No Free Lunch theorem states that there is no such thing as a free lunch. In other words, you don’t get something for nothing, and the benefits you get from using one algorithm often come at the cost of losing out on the benefits of another algorithm. As the solution for this problem is to use a combination of algorithms, we can assume that the same applies to using Wi-Fi as a sensing modality; using a multimodal approach can help us squeeze out the benefits of WLAN sensing.
To address these systems engineering challenges, the IEEE 802.11bf Task Group (WLAN Sensing) has developed an amendment to standardize sensing operations. This standard formalizes capability exchanges and measurement setups at the MAC and PHY layers, providing a unified framework to mitigate current hardware fragmentation.
-
Steven M. Hernandez and Eyuphan Bulut. 2023. WiFi Sensing on the Edge: Signal Processing Techniques and Challenges for Real-World Systems. Commun. Surveys Tuts. 25, 1 (Firstquarter 2023), 46–76. URL ↩
What is CSI?
At the heart of almost every Wi-Fi sensing system is Channel State Information (CSI). As previously mentioned, CSI is the metric used to mitigate the effects of the transmission channel on the signal, and optimize aspects such as beamforming, power allocation, and modulation schemes. 1
Assuming a transmitter wants to transmit a baseband signal \( X \), as it travels through the channel, it gets affected by the physical environment. We represent this channel state as a complex scalar \( H = I + jQ \), where the real and imaginary parts encapsulate the physical distortion.
The receiver captures the distorted signal as \( Y \), which is a combination of the original signal, the channel’s effect, and additive noise \( N \). For a single point in time or a single sample, this leads to the equation: $$Y = H \cdot X + N$$
It can be inferred from the above equation that if we wish to recover the original signal \( X \) from the received signal \( Y \), we need to know the channel’s state \( H \). To acquire \( H \), the system performs channel estimation. This is the process of prepending known training symbols in the Wi-Fi preamble, known as Long Training Fields (LTFs). Because the receiver already knows the exact sequence of the transmitted LTF (\( X \)), it measures the received sequence (\( Y \)) and solves for \( H \). 1
Symbols are specific sequences of bits that are mapped to specific modulation states, allowing the connection to utilize both phase and amplitude to encode information. Thus, a symbol can represent multiple bits of information via higher-order modulation schemes such as Quadrature Amplitude Modulation (QAM). However, in noisy and interference-prone channels, lower-order modulation schemes such as Binary Phase Shift Keying (BPSK), which offer lower throughput but higher robustness, may be preferred. Rate adaptation based on CSI is one way systems mitigate channel degradation.
Wi-Fi uses Orthogonal Frequency Division Multiplexing (OFDM), which divides the frequency channel into multiple subcarriers. The three types of subcarriers are data, pilot, and guard/null subcarriers.
- Data subcarriers transmit the actual user payload.
- Pilot subcarriers transmit known reference symbols continuously during the payload to track and correct phase drift over time.
- Guard/null subcarriers are set to zero (empty) to prevent interference between adjacent frequency channels and to prevent hardware leakage at the center frequency.
In terms of sensing, we make use of the channel estimates derived from the data and pilot subcarriers to extract meaningful features about the physical environment.
It should be noted that in MIMO systems, where the transmitter possesses \( M \) antennas and the receiver has \( N \) antennas, there will be an \( M \times N \) channel matrix. This means we get \( M \times N \times S \) complex CSI values, where \( S \) is the number of usable subcarriers. \( S \) depends on the Wi-Fi protocol and band in use. For example, while a 20 MHz 802.11n channel uses a 64-point FFT, the guard and null subcarriers are discarded, resulting in exactly 52 usable CSI subcarriers per antenna pair. 1
Since the IEEE 802.11ax amendment, OFDM was upgraded to OFDMA, which divides each frequency band into multiple resource units (RUs) to make multi-user frequency multiplexing possible. We, however, are mainly concerned with the concept of subcarriers, which remain the fundamental building blocks of both OFDM and OFDMA. 2
What can we do with CSI?
Raw Channel State Information is inherently noisy and difficult to interpret directly. To extract utility from the raw complex numbers, the system must process the data through feature extraction and subsequent denoising pipelines.
Feature Extraction
We can convert the raw complex pairs into two clear physical metrics: amplitude and phase.
- Amplitude (Attenuation): Represents the signal power degradation caused by the channel (e.g., physical obstacles or distance). It is calculated as the magnitude of the complex number: $$|H| = \sqrt{I^2 + Q^2}$$
- Phase (Shift): Represents the delay and multipath reflection introduced by the physical environment. It is calculated as the angle: $$\theta = \arctan\left(\frac{Q}{I}\right)$$
Alongside the raw CSI arrays, hardware drivers typically report macro-level metrics such as the Received Signal Strength Indicator (RSSI) and Signal-to-Noise Ratio (SNR). By feeding the raw CSI matrices and these supplementary features into various correlators, classifiers, and filtering algorithms, we can extract insights about the environment, such as human presence, activity recognition, and gesture recognition. 1 2
Summary
In this introductory chapter, we looked at:
- The premise of Wi-Fi Sensing.
- The optimal use cases for Wi-Fi Sensing.
- CSI as the ideal metric to reconstruct the wave’s state and form a picture of the environment.
- The structure of CSI, and what physical data we can construct from it.
- The variation in CSI dimensions as we change the Wi-Fi protocol, bandwidth, and antenna configurations.
-
Armenta-Garcia, J.A.; Gonzalez-Navarro, F.F.; Caro-Gutierrez, J.; Garcia-Reyes, C.I. Tools and Methods for Achieving Wi-Fi Sensing in Embedded Devices. Sensors 2025, 25, 6220. https://doi.org/10.3390/s25196220 ↩ ↩2 ↩3 ↩4
-
Steven M. Hernandez and Eyuphan Bulut. 2023. WiFi Sensing on the Edge: Signal Processing Techniques and Challenges for Real-World Systems. Commun. Surveys Tuts. 25, 1 (Firstquarter 2023), 46–76. https://doi.org/10.1109/COMST.2022.3209144 ↩ ↩2
Existing Tools & Ecosystems
The Legacy of CSI Data Collection
Traditionally, the telemetry data exposed to user space by commodity 802.11 NICs was strictly limited to abstracted metrics like RSSI. To extract raw CSI, researchers had to rely on clever firmware hacks.
Two notable examples of this are:
- The Intel 5300 CSI Tool: Required specific PCIe desktop/laptop-class NICs running modified Linux kernels. 1
- Nexmon CSI: An open-source firmware patching framework targeting Broadcom chips, bringing CSI extraction to mobile and IoT devices like smartphones and Raspberry Pis. 2
While these tools proved Wi-Fi sensing was possible, they were not practical for real-world commercial deployments. They were hardware-specific, required complex kernel-level debugging, and mandated running a full, power-hungry Linux OS just to extract the data.
Espressif’s Native Support
More recently, semiconductor vendors have recognized the value of WLAN sensing and started exposing CSI natively. A massive catalyst for the edge sensing community has been Espressif’s official support for CSI data collection.
Through drivers like esp-idf, Espressif provides access to detailed CSI arrays
directly to user-space applications. 3 This eliminates the need for hacky
firmware patches and, more importantly, shifts CSI extraction from expensive,
high-power Linux processors down to $3 RTOS/bare-metal microcontrollers drawing
minimal power.
This hardware shift makes battery-powered, ubiquitous edge deployments feasible. Tools like the C-based ESP32-CSI-Tool emerged, with Wi-Fi sensing on the edge in mind as a primary goal.
-
Halperin, D., Hu, W., Sheth, A., & Wetherall, D. (2011). Tool Release: Gathering 802.11n Traces with Channel State Information. ACM SIGCOMM Computer Communication Review, 41(1), 53-53. https://dl.acm.org/doi/10.1145/1925861.1925870 ↩
-
Gringoli, F., Schulz, M., Link, J., & Hollick, M. (2019). Free Your CSI: A Channel State Information Extraction Platform For Modern Wi-Fi Devices. Proceedings of the 13th International Workshop on Wireless Network Testbeds, Experimental evaluation & Characterization. https://github.com/seemoo-lab/nexmon_csi ↩
-
Espressif Systems. (n.d.). ESP-CSI Solution Introduction. ESP Techpedia. https://docs.espressif.com/projects/esp-techpedia/en/latest/esp-friends/solution-introduction/esp-csi/esp-csi-solution.html ↩
CSI Enabled Literature & Projects
The transition of Wi-Fi sensing from a theoretical concept to a deployable engineering solution is well documented across recent academic literature and open-source implementations. The following sections break down a few papers that proved edge-based sensing is viable, alongside some datasets and tools driving that are pupular in the space.
Existing Literature
Many academic papers look into and implement Wi-Fi sensing techniques via CSI to achieve goals such as HAR, Wi-Fi positioning & localization, crowd counting, sleep analysis etc. A few notable papers that look specifically into edge Wi-Fi sensing are:
1. WiFi Sensing on the Edge: Signal Processing Techniques and Challenges (Hernandez & Bulut, 2022) 1
This paper evaluates the feasibility of migrating CSI signal processing pipelines, historically reliant on powerful computers directly onto constrained microcontrollers (specifically the ESP32).
-
Proofs of Concept Achieved:
-
Validated that ESP32 microcontrollers can handle local CSI sampling, filtering, and machine learning inference without cloud offloading.
-
Demonstrated three tiers of sensing: small-scale hand gesture recognition (Human-Computer Interaction), medium-scale human activity recognition (HAR), and large-scale localization.
-
-
Lessons Learned:
-
Raw CSI processing is computationally expensive, but quantization techniques can be employed to fit models onto edge devices.
-
Standalone IoT devices drastically reduce the cost and physical footprint of sensing arrays compared to legacy Linux-based setups.
-
-
Remaining Gaps:
-
Environment-Specific Calibration: Most models fail when moved to a new room. The paper highlights the urgent need for online, real-time calibration methods rather than offline, lab-based training.
-
Scalability is still hindered by the data-hungry nature of the models, requiring massive data collection at every new deployment site.
-
2. Tools and Methods for Achieving Wi-Fi Sensing in Embedded Devices (Armenta-Garcia et al., 2025) 2
This paper directly attacks the hardware and memory constraints of edge AI, proposing an end-to-end CSI collection and inference framework explicitly built for the ESP32-S3.
-
Proofs of Concept Achieved:
-
Developed a DenseNet-based HAR model that operates entirely on-device, achieving 92.43% accuracy with an inference latency of just 232 ms.
-
Shrank the operational memory footprint of the deep learning model to 127 kB.
-
Solved the scarcity of training data using an Empirical Mode Decomposition (EMD)-based data augmentation strategy, bumping model accuracy from an initial 59.91% to over 97%.
-
-
Lessons Learned:
-
True privacy-preserving HAR is possible. Because inference happens entirely at the edge, no raw RF data or personal activity data needs to be transmitted to the cloud.
-
Hardware specifics matter: Efficient packet rate handling under standard baud rates is critical to prevent pipeline bottlenecks on microcontrollers.
-
-
Remaining Gaps:
-
Non-Stationary Conditions: Robust signal preprocessing under changing environmental conditions (furniture moving, temperature changes) remains unsolved for long-term stability.
-
Multi-Antenna Synchronization: To improve spatial resolution, multi-antenna arrays are needed, but synchronizing them accurately on embedded RTOS hardware without dedicated clock lines is highly difficult.
-
CSI Enabled Projects & Open Ecosystems
The open-source community has made good use of CSI enabled sensing, a few
examples borrowed from the Awesome-WiFi-CSI-Sensing3 are:
Frameworks and Visualization Tools
-
SenseFi: An extensive library and benchmarking suite for evaluating deep learning models specifically designed for Wi-Fi human sensing. It provides the Python boilerplate for testing LSTMs, CNNs, and Autoencoders on raw CSI arrays.
-
ESP32-CSI-Tool: Developed alongside Hernandez’s research, this C-based tool provides the foundational scripts for extracting CSI payloads from Espressif hardware and piping them via serial or WebSockets for visualization. 4
Open Datasets
-
MM-Fi: A large-scale multimodal dataset that aligns Wi-Fi CSI with RGB-D cameras, LiDAR, and mmWave Radar. It covers 40 human subjects and over 20 action categories, useful for cross-modal training.
-
Widar 3.0: Focused strictly on gesture recognition, containing 258,000 instances of hand gestures collected across 75 different physical domains.
-
NTU-Fi: A high-resolution dataset containing 114 subcarriers per antenna pair, tracking 6 human activities and 14 specific gait patterns.
Advanced Applications
-
Vital Sign Detection: Extracting micro-movements (chest displacement from respiration and heartbeats) from phase shifts.
-
Pose Estimation (DensePose_from_WiFi): Using deep learning to map CSI scattering directly to human skeletal wireframes, effectively giving Wi-Fi “x-ray” vision through walls.
-
LLM Integration (HoloLLM / X-Fi): The latest edge research is feeding processed CSI tokens directly into multimodal Large Language Models to allow systems to perform semantic reasoning based on physical Wi-Fi disturbances.
Summary
This Chapter was a short one, but to summarize, we looked at:
- How this is still a very much new field, with native support at the hardware level only emerging in the last few years.
- How the research community has been quick to employ CSI sensing in all sorts of applications.
- What cool projects and datasets are out there for people to use and build on.
-
Hernandez, S. M., & Bulut, E. (2022). WiFi Sensing on the Edge: Signal Processing Techniques and Challenges for Real-World Systems. IEEE Communications Surveys & Tutorials. https://ieeexplore.ieee.org/document/9900419 ↩
-
Armenta-Garcia, J.A., Gonzalez-Navarro, F.F., Caro-Gutierrez, J., & Garcia-Reyes, C.I. (2025). Tools and Methods for Achieving Wi-Fi Sensing in Embedded Devices. Sensors, 25(19), 6220. https://doi.org/10.3390/s25196220 ↩
-
NTU MARS Lab. (2025). Awesome WiFi Sensing. GitHub Repository. https://github.com/NTUMARS/Awesome-WiFi-CSI-Sensing ↩
-
Hernandez, S. M. (2020). ESP32-CSI-Tool. GitHub Repository. https://stevenmhernandez.github.io/ESP32-CSI-Tool/ ↩
csi-rs
csi-rs is an umbrella project whose goal is to create an open,
community-driven ecosystem composing libraries, tools, and documentation for
channel state information (CSI) based projects and research in Rust. Currently,
the focus is on making CSI data collection more accessible, convenient, and
robust.
To that end, we are targeting cheap, low-power, and widely available devices to provide as low a barrier to entry as possible.
Why Rust?
The previous chapter described what CSI collection has historically cost: patched firmware, kernel modules, and a full Linux host. Espressif’s native CSI support removed most of that cost, which left a genuine question — if the platform is now a $3 microcontroller, what should the software on it be written in?
We choose Rust over C for several reasons.
Memory Safety (No Garbage Collector)
CSI collection is, structurally, an interrupt-driven system: the Wi-Fi driver hands you a borrowed buffer from a hot callback, and you have microseconds to decide what to do with it before it is reused. That is exactly the shape of problem that produces use-after-free and buffer-overrun bugs in C. Rust’s borrow checker makes the lifetime of that buffer part of the type, so the compiler rejects the mistake instead of the field deployment discovering it. None of this costs a runtime or a garbage collector, which a bare-metal device could not afford anyway.
Type and Thread Safety
A collection node is concurrent whether or not you wanted it to be. The radio
callback, the traffic generator, the logging drain, and your own processing all
run against shared state. Rust encodes “this value may cross a thread boundary”
and “this value may be shared across threads” in the type system, so the
compiler refuses to build a data race rather than leaving you to find it with an
oscilloscope. In practice this is what lets esp-csi-rs offer a lock-free async
delivery path and an inline callback path side by side without either one
silently corrupting the other.
Zero-Cost Abstractions
The abstractions that make the API pleasant — typed node roles, configuration
builders, iterators over subcarriers — compile away. This is measurable rather
than aspirational. The crate’s own size study compares a Rust no_std sniffer
against Espressif’s C passive CSI example on the same hardware:1
| Build (speed-optimised) | Flash .text | Total image |
|---|---|---|
ESP-IDF C passive | 594 KiB | 849 KiB |
Rust no_std sniffer | 487 KiB | 634 KiB |
The Rust image is 25% smaller at the speed-optimised tier and 33% smaller when
both are optimised for size — and even Rust’s max-speed build comes in 22%
under the C build’s max-size build. The single largest contributor is simply
not linking libstdc++ (~190 KiB), which a pure-C ESP-IDF build also avoids;
the remainder is the runtime swap underneath (lwIP for smoltcp, FreeRTOS for
esp-rtos). The point is not that Rust is magic, but that the safety above is
not being paid for in flash.
These figures come from a specific pair of firmware images built for a specific chip, and the two applications are not feature-identical. Treat them as an order-of-magnitude answer to “does
no_stdRust fit?”, not as a benchmark.
Superior Tooling and Debugging
cargo is the build system, the dependency manager, the test runner, and the
documentation generator, and it is the same on every host OS. For embedded work
specifically, the ESP Rust toolchain adds esp-generate for project scaffolding
and espflash for flashing and monitoring, and defmt gives deferred, compact
binary logging — which matters a great deal when the thing you are logging is a
few hundred CSI samples arriving a hundred times a second.
Community Ecosystem
The crates this project stands on — esp-hal, esp-radio, esp-rtos,
embassy — are actively maintained, open source, and shared with the rest of
the embedded Rust world. csi-rs inherits their work rather than reimplementing
it, and publishes to the same registry, so a CSI crate is installed exactly the
way any other Rust dependency is.
Fun Fact, in 2024, the white house office of national cybersecurity published a report urging software developers to adopt memory-safe languages like Rust, and “stop” using unsafe languages such as C/C++.
Supported SoCs
Currently, csi-rs supports a variety of
Espressif SoCs,
with support for other SoCs in the works.
-
esp-csi-rs, Binary size comparison: ESP-IDFpassivevs. Rustno_stdsniffer_wifi.specs/SIZE_DIFF_passive_vs_sniffer_wifi_exper.mdin the esp-csi-rs repository. ↩
Crates & Libraries
The esp-csi-rs ecosystem is a collection of Rust crates for Espressif SoCs
that build upon the esp-hal and esp-radio crates to provide a no_std CSI
data collection and logging suite. esp-csi-rs provides both a low-level API
for building your own CSI-related applications, as well as a variety of
out-of-the-box tools for logging, visualizing, and controlling CSI data
collection.
In this chapter, we will walk through the crates that make up the esp-csi-rs
ecosystem, their use case, their role in the csi-rs data collection pipeline,
and later on in the
ecosystem walkthrough
chapter, we will provide a hands-on example of getting started with CSI data
collection to tie together all of the tools and concepts discussed.
Two Roles: Emitter and Collector
Before any of the crates make sense, one piece of vocabulary has to be settled, because every crate in the ecosystem uses it and it is the thing most likely to confuse a reader coming from older CSI tooling.
A CSI measurement needs two things: energy in the channel, and something to measure the channel’s response to that energy. Those are the two roles, and they are exhaustive:
- An emitter puts known RF energy into the channel and never captures. It forces its transmit PHY to a fixed format and loop-injects a raw sounding frame, without associating to anything.
- A collector captures the channel’s response and delivers it.
Because an emitter’s frames carry no meaning — they exist to be measured, not read — it needs no peer, no handshake, and no protocol. That is what lets the two roles compose freely into whatever arrangement a deployment needs.
How a collector obtains frames to measure is a separate question from what it is for, so the three capture paths are variants of the collector role, not roles of their own:
| Capture path | How it gets frames |
|---|---|
| Sniffer | Locks a channel in promiscuous mode and measures every frame overheard |
| Station | Associates to an AP or commercial router and measures what it receives |
| Access Point | Runs a softAP with DHCP so an associated station generates uplink traffic |
A third topology sits alongside these: an ESP-NOW pair, a central and a
peripheral exchanging connectionless frames with no AP and no association.
Either end can collect, or both can. It keeps the Central / Peripheral
spelling it has always had.
On a rename you will run into. A collector delivers its CSI by default, and delivery can be switched off while capture keeps running. That setting used to be called
collection-mode, with valuescollectorandlistener. Once “collector” became the name of the receive role, it could no longer also name a delivery setting, so the control was renamed: in the library it isCSINode::set_csi_output_enabled, in the CLI it isset-csi-output, and over HTTP it isPOST /config/csi-output. If you findcollection-modeor--mode=listenerin an older document or script, this is its replacement.
The Pipeline, and Its Three Tiers
The crates divide cleanly by where they run and how much they decide for you. This book refers to those divisions as tiers:
- Tier 0 — the library on the device.
esp-csi-rs. You write the firmware; the crate handles the radio, the capture path, and the output format. - Tier 1 — ready-made firmware on the device.
esp-csi-cli-rs,esp-csi-litetui-rs,esp-csi-litegui-rs. Flash a binary, configure it over serial or a touchscreen, and collect without writing embedded Rust. - Tier 2 — host-side tooling.
csi-webserver,csi-webserver-core,csi-webclient. These run on your computer, take the stream off the serial port, and turn it into something you can drive, watch, and store.
Every tier above 0 is built on the one below it, and none of them is mandatory:
TIER 0 TIER 1 TIER 2
┌──────────────┐ ┌──────────────────┐ ┌────────────────────┐
│ esp-csi-rs │───▶│ esp-csi-cli-rs │─────▶│ csi-webserver │
│ │ │ (serial console)│ USB │ HTTP + WebSocket │
│ radio, roles │ └──────────────────┘ │ Parquet dumps │
│ capture path │ ┌──────────────────┐ └─────────┬──────────┘
│ log formats │───▶│ esp-csi-litetui │ │
│ │ │ esp-csi-litegui │ ▼
└──────────────┘ │ (on-device UI) │ ┌────────────────────┐
│ └──────────────────┘ │ csi-webclient │
│ │ │ plots, export │
▼ ▼ └────────────────────┘
your own firmware SD card / screen
Inside a Tier 0 device, a captured packet travels a short and deliberately gated path. Knowing its shape explains most of the configuration surface you will meet later:1
esp-radio Wi-Fi driver (CSI event, Wi-Fi task context)
└─ capture_csi_info() [hot path]
├─ statistics counters (always, `statistics` feature)
├─ CSI output enabled? ── no ─▶ return (the master gate)
├─ build CSIDataPacket (~640 B, stack; per-chip shape)
├─ delivery mode:
│ Off → nothing
│ Callback → your fn(&CSIDataPacket), inline
│ Async → lock-free queue + waker, drained by CSINodeClient
└─ inline logging enabled? → serial output in the configured LogMode
The three gates are independent, which is what makes combinations like “process every packet on-device but do not flood the serial port” a matter of setting two flags rather than rebuilding the firmware.
Supported Espressif SoCs
The capabilities of your sensing network are strictly bound by the RF hardware
of the specific SoC. csi-rs supports the following chips, your ideal choice
depends on your use case and budget:
ESP32 Series:
- ESP32:
- 2.4 GHz Wi-Fi 4 (802.11n), Dual-core Xtensa.
ESP32-C Series:
- ESP32-C3:
- 2.4 GHz Wi-Fi 4, Single-core RISC-V.
- ESP32-C5:
- Dual-band (2.4/5 GHz) Wi-Fi 6 (802.11ax), Single-core RISC-V.
- The only dual-band part in this list. That matters for sensing: the 5 GHz band is where the wider channels live, and the two bands see the same room differently, so it is a choice worth having.
- ESP32-C6:
- 2.4 GHz Wi-Fi 6 (802.11ax), Single-core RISC-V. It also carries 802.15.4 (Thread/Zigbee), which the CSI path does not use.
ESP32-S Series:
- ESP32-S3:
- 2.4 GHz Wi-Fi 4, Dual-core Xtensa with vector SIMD support.
For more information on the ESP SoCs naming conventions, check out this espressif blog post
Every supported chip can act as a collector. Emitter support depends on the part
and on the esp-radio version you build against, so if you are buying
specifically to run an emitter, check the current status in the
esp-csi-rs repository rather than
relying on this page.
Summary
In this chapter we looked at:
- The two exhaustive node roles, emitter and collector, and the three collector capture paths — sniffer, station, and access point.
- The
collection-mode→csi-outputrename, and why it happened. - The three tiers the ecosystem’s crates fall into, and how a CSI packet travels from the radio to your host.
- Which Espressif parts are supported, and which of them can act as an emitter.
The sections that follow take each crate in turn, starting at Tier 0.
-
Adapted from
specs/crate_architecture_spec.md§5 in the esp-csi-rs repository. ↩
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
CSI Data Formats
Every tool in this ecosystem eventually agrees on the same thing: the bytes that carry one CSI measurement from a device to whatever is going to look at it. This section documents those bytes.
It is not a crate. Earlier drafts of this book reserved a csi-protocol chapter
for a crate that was never published, on the assumption that the formats would
need a library of their own. They did not — the formats are defined by
esp-csi-rs and consumed directly by the host tools, so this is a specification
chapter rather than an API one.
Three things are specified here:
- The four logging modes, which decide what a running node prints.
- The ESP-NOW wire format, used when two nodes coordinate directly.
- The firmware identification contract, which is how a host tool recognises a board before it tries to talk to it.
Logging Modes
LogMode selects how a captured CSIDataPacket is rendered to the transport.
The setting is global: init_logger(spawner, mode) establishes it, and
set_log_mode(mode) changes it at runtime.
| Mode | Shape | Use it for |
|---|---|---|
Text | Many key: value lines per packet | Reading on a serial terminal |
Serialized | One COBS-framed postcard record | Host ingest — the densest option |
ArrayList | One JSON-array-shaped line | Parsing without a schema; compact but readable |
EspCsiTool | 26-column CSV | Drop-in compatibility with ESP32-CSI-Tool |
One structural fact governs all four: CSIDataPacket has two shapes. The
classic parts (ESP32, C3, S3) carry the 802.11n-era metadata — sig_mode,
mcs, bandwidth, stbc, and so on. The newer parts (C5, C6) carry a
redesigned driver field set — dump_len, cur_bb_format, the channel-estimate
fields, and the rxmatch flags. Which fields appear is a property of the chip,
not of the mode.
LogMode::Text
One packet becomes a run of key: value lines, \r\n-terminated, ending with a
csi raw data: [...] array. It is meant for eyeballing, not for parsing.
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
...
data length: 128
csi raw data: [0, 0, 0, 0, -6, 0, 6, 0, -24, 10, -23, 9, ...]
The block between rx state and the trailing sig_len / data length pair is
the chip-dependent part. On classic parts it is secondary channel, sgi,
ant, ampdu cnt, sig_mode, mcs, cwb, smoothing, not sounding,
aggregation, stbc, fec coding. On C5/C6 it is dump len, sigb len (C6
only), cur single mpdu (C6 only), cur bb format,
rx channel estimate info vld, rx channel estimate len, time seconds,
channel, is group, rxend state, and the rxmatch3–rxmatch0 flags.
A Recieved at YYYY-MM-DD HH:MM:SS.mmm header precedes the record when an NTP
DateTime is attached to the packet. The misspelling is the real on-wire
string; if you are writing a parser, match what the firmware emits.
sig_lenappears twice inTextand inArrayList— once in the metadata block and again immediately before the sample count. This is a deliberate carry-over from the original field layout, not a bug in your parser.
LogMode::Serialized
The binary path, and the right default for host ingest. Each packet becomes one COBS-framed postcard record, with no other framing and no text interleaved:
- Encoded with
postcard::to_slice_cobs(&CSIDataPacket, &mut buf). - A single
0x00byte delimits each record; the encoded body never contains0x00, which is what makes resynchronisation after a dropped byte trivial. - Field order on the wire is the declaration order of
CSIDataPacket. The CSI sample vector is a postcard varint length followed by that many rawi8bytes. Option<DateTime>is one discriminant byte (0= none,1= present) followed by the value when present.
The critical caveat: there is no self-describing tag on the wire, and there are two schemas. A decoder built for the classic layout will happily produce nonsense from a C6 stream. Decoders must be built with matching feature flags, or with a schema chosen from the device’s reported chip.
Under defmt the situation is slightly different, because defmt is itself a
framed log channel and raw bytes cannot be streamed through it. Each record is
emitted as a single defmt::println!("{=[u8]}", cobs_slice) frame instead, so
the host decodes the defmt frame first and COBS-decodes the payload second.
LogMode::ArrayList
One line per packet, shaped like a JSON array, terminated ]]\r\n. Compact
enough to stream and structured enough to parse with a split on commas:
[3916,-93,11,157,1,1815804,256,0,260,2,0,1,1,128,0,1,1,0,1,0,0,0,256,128,[...]]
On classic parts the fields are, in order:
| Index | Field | Description |
|---|---|---|
| 0 | sequence_number | Sequence number of the frame that triggered the capture |
| 1 | rssi | Received signal strength (dBm) |
| 2 | rate | PHY rate encoding (valid for non-HT 802.11b/g frames) |
| 3 | noise_floor | Noise floor of the RF module (dBm) |
| 4 | channel | Primary channel the frame arrived on |
| 5 | timestamp | Local receive timestamp (microseconds) |
| 6 | sig_len | Frame length including FCS |
| 7 | rx_state | 0 = no error, non-zero = error code |
| 8 | secondary_channel | 0 = none, 1 = above, 2 = below |
| 9 | sgi | 0 = long guard interval, 1 = short |
| 10 | antenna | Antenna number |
| 11 | ampdu_cnt | Subframes aggregated in the AMPDU |
| 12 | sig_mode | 0 = non-HT (11b/g), 1 = HT (11n), 3 = VHT (11ac) |
| 13 | mcs | Modulation and coding scheme; 0–76 for HT frames |
| 14 | bandwidth | 0 = 20 MHz, 1 = 40 MHz |
| 15 | smoothing | 0 = unsmoothed, 1 = smoothing recommended |
| 16 | not_sounding | 0 = sounding PPDU, 1 = not a sounding PPDU |
| 17 | aggregation | 0 = MPDU, 1 = AMPDU |
| 18 | stbc | 0 = non-STBC, 1 = STBC |
| 19 | fec_coding | LDPC flag; set for 11n LDPC frames |
| 20 | sig_len | Frame length including FCS (repeated) |
| 21 | csi_data_len | Number of raw i8 samples that follow |
| 22 | [csi_data] | Inner array of raw i8 samples |
On C5/C6 indices 8–19 are replaced by that family’s metadata block, in struct order, while indices 0–7 and the trailing three entries are unchanged.
Integers are plain decimal with no padding. Sample values are comma-separated with no spaces and no trailing comma.
LogMode::EspCsiTool
Drop-in compatible with the capture format of the C-based
ESP32-CSI-Tool, so
existing analysis scripts written against that tool work unchanged. A session
prints one header line, then one CSI_DATA,... row per packet, in 26 columns:
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
Column 2 (role) is STA, AP, or PASSIVE, set with set_role(Role).
Column 26 is the sample array, space-separated i8 values inside [ ].
Two details matter in practice. On C5 and C6, columns 6–14, 16, 18 and 20 are
emitted as literal 0 — the upstream schema has no equivalent for those
parts’ metadata, so it is discarded. If you need those fields on a C5 or C6, use
Text, ArrayList, or Serialized.
And there is an emit cap: set_csi_tool_emit_cap(u16) truncates the sample
array, with column 25 always reporting the number actually emitted. Setting it
to 128 mirrors the upstream tool’s CONFIG_SHOULD_COLLECT_ONLY_LLTF=128 and
holds every line to roughly 475 bytes regardless of the captured PHY — which is
what lets sniffer mode reach the same UART-bound packet rate as a peripheral.
Transport Is a Separate Axis
LogMode decides what bytes; feature flags decide which wire. All four
modes work over every transport.
| Feature | Output path |
|---|---|
println | esp_println::println! via the board’s default backend |
defmt | Global defmt logger, framed by esp-println’s defmt-espflash |
uart | Direct UART0 at the build-time baud rate |
jtag-serial | USB-Serial-JTAG (not available on the original ESP32) |
auto | JTAG if a USB start-of-frame is detected, otherwise UART |
no-print | Discards output |
async-print | Inserts a bounded queue and a drain task between callback and wire |
The sync path formats and writes inside the Wi-Fi receive callback. It has the
lower memory cost, and the write latency sits on the callback’s critical path —
which is precisely why it is the path that reaches the UART ceiling. The async
path enqueues the packet (capacity 32) and lets a drain task format and write
it, keeping the callback short at the cost of around 20 KiB of static memory.
When the queue overflows, packets are dropped and counted:
get_log_packet_drops() reports how many, which is the first number to check
when a capture looks thinner than the emitter’s configured rate.
The ESP-NOW Wire Format
When two nodes coordinate as an ESP-NOW pair rather than via an access point, they exchange two small serde-serialized structures.
ControlPacket travels from central to peripheral:
pub struct ControlPacket {
/// Whether the central is currently collecting; the peripheral mirrors
/// this flag to keep the pair in sync.
pub is_collector: bool,
/// Monotonic sequence number for drop and reorder detection. Present only
/// under the `statistics` feature, to keep the frame small.
#[cfg(feature = "statistics")]
pub sequence_number: u32,
}
PeripheralPacket travels the other way as a presence beacon.
Framing depends on the pairing mode. In auto-pairing mode each frame is
prefixed with a four-byte little-endian magic number — 0xA8912BF0 from the
central, its bitwise complement from the peripheral — so an unrelated ESP-NOW
frame on the same channel is rejected cheaply. In manual-pairing mode no
magic is sent and the source-MAC filter is the discriminator instead.
Both ends must agree on the pairing mode and on whether the statistics
feature is enabled, since that feature changes the frame layout. A mismatch
presents as frames that arrive but never parse.
The Firmware Identification Contract
Host tooling needs to know what it is talking to before it sends a command, and
a USB serial port tells it almost nothing. esp-csi-cli-rs therefore emits a
magic prefix on two surfaces.
Passively, the first line of the welcome banner after every reset is the magic line, followed immediately by the device’s MAC:
ESP-CSI-CLI/0.7.0
mac=D0:CF:13:E2:90:E8
******* Welcome to the CSI Collection CLI utility! *******
A host can match the first non-bootloader line against
^ESP-CSI-CLI/\d+\.\d+\.\d+$ with no command round-trip at all.
Actively, the info command returns the same prefix plus a key=value body
terminated by END-INFO, carrying name, version, chip, protocol,
baud, and features.
The versioning rules are worth internalising if you are writing a host tool:
versionis cosmetic and bumps with releases.protocolis the wire-format version. Host tooling should refuse protocol values it does not understand rather than guess. Theinfogrammar is stable within a protocol value; adding keys requires a bump.featuresis informational and unordered. The presence ofstatisticstells the host whethershow-statsexists.macis the stable device key. Pin per-device state to it rather than to the/dev/ttyACM*path, so arestartor USB re-enumeration rebinds to the same physical board.
Summary
In this section we looked at:
- The four logging modes —
Text,Serialized,ArrayList, andEspCsiTool— and what each is for. - The two
CSIDataPacketschemas, and why aSerializeddecoder must be built against the right one. - The ESP-NOW
ControlPacket/PeripheralPacketexchange and its magic-prefix framing. - The
ESP-CSI-CLI/<version>identification contract that every host tool in the next few sections relies on.
esp-csi-cli-rs
esp-csi-cli-rs is the ready-made command-line tool for CSI collection on ESP32
series devices. Built directly on top of the esp-csi-rs crate, its primary
purpose is to provide easy configuration and control without requiring
developers to write custom embedded Rust code.
In the context of the csi-rs ecosystem, esp-csi-cli-rs occupies Tier 1.
While it is flashed directly onto the edge device, it operates at a higher
abstraction layer than the raw peripheral drivers found in Tier 0
(esp-csi-rs).
Its core operational function is to provide a streamlined interface for
extracting CSI data from the edge device and transmitting it to a host machine
via the serial port for downstream processing and storage. esp-csi-cli-rs acts
as the intermediary bridging esp-csi-rs and higher-level host-side
applications like csi-webserver and csi-webclient.
This section describes version 0.7.0. Note that esp-csi-cli-rs is firmware
rather than a library, so it is not published to crates.io — you get it from
GitHub, either as a prebuilt binary or by building from source.
Getting It onto a Board
The fastest route is a prebuilt binary. Tagged releases publish a per-chip
.bin alongside a manifest.json describing each artifact, including the baud
rate it was built for. Flash it with espflash and open the monitor:
espflash flash --monitor <chip>.bin
To build from source instead, clone the repository and use the per-chip cargo aliases, which build, flash, and open a monitor in one step:
git clone https://github.com/csi-rs/esp-csi-cli-rs && cd esp-csi-cli-rs
cargo esp32c6 # build + flash + monitor, println logging
cargo esp32c6-defmt # the same with defmt logging
cargo esp32c6-build # compile only, no flash
Replace esp32c6 with any of esp32, esp32c3, esp32c5, esp32s3. For a
fully explicit invocation:
cargo build --no-default-features \
--features "no-std,esp32c6,println,jtag-serial,statistics" \
--target riscv32imac-unknown-none-elf --release
Feature selection mirrors the library: one device feature, one logging backend
(println by default or defmt), a transport (auto by default,
jtag-serial, or uart), and statistics, which is on by default here
because show-stats depends on it.
When the board resets you will see the identification banner and the root menu:
ESP-CSI-CLI/0.7.0
mac=D0:CF:13:E2:90:E8
******* Welcome to the CSI Collection CLI utility! *******
That first line is the firmware identification contract described in the
previous section — it is how csi-webserver and
every other host tool recognises the board.
How the CLI Behaves
Three properties of the runtime are worth knowing before the command list, since they explain most of what surprises first-time users.
Configuration is staged, not live. The CLI holds a UserConfig in memory.
Most commands write into it and take effect on the next start, not
immediately. The exceptions are set-log-mode, restart, and
set-csi-delivery (other than its raw setting), which apply at once.
show-config prints the current staged configuration; reset-config restores
defaults.
start owns the session. It builds a node from the staged configuration and
runs it, either for --duration=<seconds> or indefinitely. While a session is
running, pressing q aborts it and returns to the root menu.
Quoting matters. SSIDs and passwords containing spaces must be wrapped in
single or double quotes — --sta-ssid='My WiFi' — and underscores pass through
as literal underscores rather than as space substitutes.
Command Reference
| Command | Purpose | Applies |
|---|---|---|
help [command] | Help text, per command | — |
set-wifi | Mode, credentials, channel, peer, emitter | Next start |
set-csi | CSI acquisition flags (varies by chip) | Next start |
set-traffic | Traffic generator frequency | Next start |
set-csi-output | Deliver captured CSI, or capture silently | Next start |
set-csi-filter | Restrict which frames are delivered | Next start |
set-log-mode | Output format | Immediate |
set-csi-delivery | Delivery mode and the inline log gate | Immediate |
set-protocol | Wi-Fi PHY protocol | Next start |
set-io-tasks | Toggle the TX and RX direction tasks | Next start |
set-rate | Record a PHY rate | See below |
start | Begin collection | — |
show-config | Print the staged configuration | — |
show-stats | Runtime counter snapshot | — |
reset-config | Restore defaults | Next start |
restart | Software reset | Immediate |
info | Firmware identification block | — |
The CLI’s own help <command> output is authoritative and always matches the
firmware you have flashed; the tables below summarise the flags you will reach
for most.
set-wifi
The command that decides what the node is. --mode takes one of nine values,
which map onto the roles from the
previous chapter:
| Mode | Role |
|---|---|
sniffer (default) | Collector, promiscuous capture path |
station | Collector, associated to an AP |
wifi-ap | Collector, self-contained softAP |
ht20-emitter | Emitter at 20 MHz |
ht40-emitter | Emitter at 40 MHz |
esp-now-central | ESP-NOW initiator |
esp-now-peripheral | ESP-NOW responder |
esp-now-fast-collector | Asymmetric ESP-NOW, receive side |
esp-now-fast-source | Asymmetric ESP-NOW, transmit side |
Supporting flags:
--sta-ssid,--sta-password— station credentials.--ap-ssid(defaultesp-csi-ap),--ap-password,--ap-dhcp=<on|off>,--ap-leases=<1-8>(default 4),--ap-burst=<on|off>. With more than one lease the ICMP traffic round-robins across associated stations;--ap-burst=oninstead sends one frame back-to-back to every station each tick, for time-aligned multi-receiver CSI. Burst airtime isfrequency-hz × leases.--set-channel=<n>— default 1, or 149 on the C5. On the C5 the channel number also selects the band, which is a common first stumble: associating to a 2.4 GHz AP from the 5 GHz default reports only “no access point found”.--peer-mac=<mac>— one field with two meanings. In emitter modes it is the destination of injected frames, and unicasting to a collector’s MAC usually raises that collector’s CSI rate; empty means broadcast. In ESP-NOW modes it is the explicit peer address.--ht40=<above|below|none>— the secondary channel forht40-emitter.--inject-period-ms=<ms>— emitter frame period, default 20 ms.--emitter-iface=<sta|ap>— which interface injects, defaultsta.
set-csi
The flags here are the CsiConfig fields, so the command has two variants
depending on your chip.
On the classic parts (ESP32, C3, S3) the flags are --lltf, --htltf,
--stbc-htltf, and --ltf-merge, each on or off and all defaulting to
on.
On the C5 and C6 they are --csi, --csi-legacy, --csi-ht20,
--csi-ht40, --dump-ack (each on/off), --val-scale-cfg=<0-3> (default
2), and --preset=<default>. The C5 additionally accepts --csi-force-lltf and
--csi-vht.
For a clean HT40 capture, turn the legacy and ACK acquisition off:
set-csi --csi-legacy=off --csi-ht20=off --csi-ht40=on --dump-ack=off
Leaving them on is the usual cause of a capture that stays stubbornly at ~53 subcarriers when HT40 was configured.
set-csi-filter
A collector is promiscuous: it reports CSI for every frame its radio decodes, including your AP’s beacons and ACKs and any third-party device on the channel. Those rows are valid CSI, but they look wrong next to your own traffic — the leading field of a row is the frame’s own 802.11 sequence number, which is per-transmitter and so neither starts at zero nor shares a counter with yours, and a legacy-rate frame carries the shorter L-LTF-only payload.
set-csi-filter --peer-mac=aa:bb:cc:dd:ee:ff
set-csi-filter --min-phy=ht
--peer-mac delivers CSI only for frames from that source (any clears it).
--min-phy=ht keeps 802.11n and better, dropping the legacy-rate management and
control frames.
Filtering on the device rather than on the host also returns console bandwidth
to the traffic you asked for: a rejected frame is dropped in the Wi-Fi callback
before the packet copy and before any formatting. Rejected frames are still
counted in show-stats as RX drops, so the gap between captured and delivered
stays visible rather than unexplained.
set-log-mode, set-csi-output, set-csi-delivery
These three are the CLI’s face of the three independent gates described in Crates & Libraries.
set-log-mode --mode=<text|array-list|serialized|esp-csi-tool>chooses the output format. The firmware’s own default at boot isarray-list.set-csi-output --enabled=<true|false>is the master delivery gate. Withfalsethe radio keeps capturing and its timing is unchanged, but nothing is decoded, logged, or handed onward. This is the command that replaced the oldset-collection-mode --mode=listener.set-csi-delivery --mode=<off|callback|async> --logging=<on|off>selects the delivery path and independently gates the inline serial log.
set-traffic, set-io-tasks, set-protocol, set-rate
set-traffic --frequency-hz=<n> sets the traffic generator rate (default 100;
0 disables it). set-io-tasks --tx=<on|off> --rx=<on|off> toggles the two
direction tasks. set-protocol selects the Wi-Fi PHY protocol.
set-rate --rate=<rate> is the odd one out: it records a PHY rate that most
modes never apply. The emitter modes force their own PHY, and the fast ESP-NOW
modes ignore it; only the symmetric ESP-NOW central/peripheral pair acts on it.
Treat it as reporting metadata unless you are running that pair.
start, show-config, show-stats, info
start # run until 'q'
start --duration=60 # run for 60 seconds
show-config prints the staged configuration, which is also what a host tool
parses to learn a device’s current state. show-stats prints the runtime
counters — packets transmitted and received, rates in Hz, and drop counts — and
exists only when the firmware was built with the statistics feature, which is
on by default. info prints the identification block that host tooling keys on.
Console Throughput
A collector can capture faster than a serial console can carry the results, so the output format and the transport are throughput decisions, not cosmetic ones. Three things to know:
defmtdoes not raise the CSI rate by itself. It shrinks each line on the wire, which helps when the console is the bottleneck, but it does not make the radio capture more.- The baud rate is fixed at build time, which is why release artifacts
publish it in
manifest.json— a host that opens the port at the wrong rate sees garbage, not a slow stream. serializedis the densest format and the one the host tools use. Reach fortextwhen a human is reading and foresp-csi-toolwhen an existing analysis script is.
If a capture looks thinner than the rate you configured, show-stats is where
to start: a large drop count means the console or the async logging queue is the
limit, not the radio.
Relevant Links & Resources
User guide and source code can be viewed (and contributed to) on
GitHub. The full command
specification lives in specs/SPECS.md in that repository, and
specs/WEBSERVER.md documents the integration contract that host tooling
implements.
esp-csi-litetui-rs
esp-csi-litetui-rs turns an M5Stack CoreS3 SE — an ESP32-S3 with a 320×240
touchscreen and a microSD slot — into a handheld Wi-Fi CSI scope. It captures
through esp-csi-rs, renders live views to the LCD, and logs every packet to
the SD card for off-line analysis.
It sits in Tier 1: firmware you flash rather than a library you build against.
What distinguishes it from esp-csi-cli-rs is that there is no host in the loop
at all — configuration happens on the touchscreen and the capture lands on a
memory card. That makes it the right tool for walking a building, or for any
measurement where dragging a laptop along is the awkward part.
Like the other on-device front-ends, it is not published to crates.io; build it from the repository.
What You See
The setup screen configures node mode, channel, traffic rate (which doubles as the emitter’s injection rate), the HT40 secondary channel, the CSI sub-options, the delivery mode, and the log format — all by touch. Starting a capture switches to the live screen, which cycles through five instrument tabs:
- Spectrum — amplitude against subcarrier index.
- Phase — unwrapped phase in radians against subcarrier index.
- Waterfall — an amplitude heatmap over time and subcarrier.
- Signal — RSSI and SNR (
rssi − noise_floor) trended over time. - Stats — live
esp-csi-rscounters (RX and TX packets per second, rate, totals, drops) alongside last-packet metadata: PHY, bandwidth, MCS, frame format classification, noise floor, sequence number, and CSI length.
The axes carry real units and there is no decorative chrome, which is the point — it is an instrument, not a demo.
Because the device is normally a collector, selecting one of the emitter modes replaces the instrument tabs with a transmit-status screen. An emitter captures nothing; point a second device at the same channel in sniffer mode to measure what it is putting out.
Logging to SD
The card must be formatted FAT32 or FAT16 — exFAT is not supported. A session opens its file once and writes incrementally, with dropped-record counts and SD status surfaced live on the Stats tab.
Two formats are available. The default is compact binary (CSInnnnn.BIN) —
COBS-framed postcard records, exactly the Serialized format described in
CSI Data Formats. A bundled pure-Python script
converts it:
python3 tools/bin_to_csv.py CAPTURE.BIN # -> CAPTURE.csv
The alternative is CSV (CSInnnnn.CSV), written directly — one row per
packet, with an fmt column carrying the frame classification and a csi
column holding the raw samples space-separated.
Building It
The Espressif Rust toolchain (via espup) and espflash are prerequisites. The
target, runner, and build-std settings are preconfigured.
# Station mode joins this network — edit before flashing:
# src/config.rs : WIFI_SSID / WIFI_PASSWORD
cargo run --release # builds, flashes, and opens the serial monitor
Two Notes on Node Modes
Its README is out of step with upstream on ESP-NOW. The repository’s upgrade
note states that esp-csi-rs removed its ESP-NOW transport and that mode
indices 3–6 are permanently retired. That was true of an interim branch, but not
of the release: esp-csi-rs 0.10.1 retains the ESP-NOW central and
peripheral topologies, and esp-csi-cli-rs still exposes four ESP-NOW modes. If
you are reading that note while planning a deployment, treat it as a statement
about this firmware’s own mode list rather than about the ecosystem.
A saved configuration from an older build may not restore. Retired mode indices resolve to nothing, fall back to Sniffer, and log a one-line notice on the serial port. Reselect the mode you want on the setup screen. Station, Sniffer, and AP collector keep their indices and are unaffected.
Relevant Links & Resources
Source: csi-rs/esp-csi-litetui-rs
esp-csi-litegui-rs
esp-csi-litegui-rs is a no_std firmware application for ESP32-S3 boards that
renders live Wi-Fi CSI as an AMOLED heatmap. It is built on esp-csi-rs for
collection, embedded-graphics for drawing, and rm690b0-rs for driving the
panel.
Like esp-csi-litetui-rs it is a Tier 1 front-end with no host in the loop, but
it is narrower by design: one view, rendered as well as the hardware allows,
with gesture control and nothing else. Where the LiteTUI is an instrument you
configure, the LiteGUI is a display you point at a room.
It is not published to crates.io; build it from the repository.
Supported Boards
Exactly one board feature must be enabled —
waveshare-esp32-s3-touch-amoled-1_8 or lilygo-t4. Touch handling is
board-specific: the LilyGo reads CST226 gestures, while the Waveshare uses
FT3x68 gesture IDs with a coordinate-based swipe fallback.
Runtime Architecture
CSI processing runs on the main executor and display rendering runs on a dedicated second-core executor. That split is what keeps the display responsive when packets arrive faster than frames can be drawn — the renderer falls behind gracefully instead of stalling the capture path.
Capture Modes
This board is always a collector. It renders a heatmap, so it needs CSI to render; the mode features differ only in where the measurable frames come from.
| Feature | Capture path |
|---|---|
mode-snf (default) | Promiscuous capture on a locked channel; no peer configuration |
mode-sta | Associate to an AP and capture CSI from that link |
mode-ap | softAP (esp-csi-ap) with DHCP; captures an associated station’s uplink |
Only one of mode-sta and mode-ap may be enabled at a time.
cargo run --release --features="lilygo-t4" # sniffer
cargo run --release --features="lilygo-t4,mode-sta"
cargo run --release --features="waveshare-esp32-s3-touch-amoled-1_8,mode-ap"
For station mode, set the SSID and password in src/main.rs where the station
configuration is created, before flashing.
The logging features mirror the library’s: println, defmt,
external-defmt-logger, async-print, jtag-serial, and uart.
Pairing It
In the default sniffer mode the board needs something to measure. Pair it with a
second board running the esp-csi-rs ht20_emitter or ht40_emitter example —
an emitter needs no association, so nothing has to be configured on the LiteGUI
side beyond the channel.
Check the channel first. This application uses channel 1, while the
ht20_emitterandht40_emitterexamples default to channel 7. Change one side to match the other, or the heatmap will show only ambient traffic and look, misleadingly, as though the emitter is not transmitting. This is the single most common first-run failure with this pairing.
In mode-ap, any Wi-Fi station that joins esp-csi-ap works — the esp-csi-rs
wifi_station example, or a phone. Clients get a 192.168.13.x lease from the
built-in DHCP server, and CSI flows as soon as one associates.
Relevant Links & Resources
Source: csi-rs/esp-csi-litegui-rs
csi-webserver
csi-webserver is the host-side bridge. It discovers ESP32 boards over USB,
takes the CSI stream arriving from each one over serial, and serves it to
clients over HTTP and WebSocket — so the board does not have to speak HTTP and
the client does not have to speak serial.
It is the piece that makes a capture watchable from another machine: the device session stays where the hardware is, and anything with a network connection can attach. It can also write each session straight to Apache Parquet, which is usually the shortest path from a board to a dataframe.
This is Tier 2 — it runs on your computer, not on a board. The published crate is at 0.2.1 at the time of writing.
Naming. The repository is
csi-webserver-rs, but the published crate drops the suffix and is simplycsi-webserver. Earlier drafts of this book called itesp-csi-webserver-rs; that name was never published. Theesp-prefix marks the crates that run on an Espressif part, and this one runs on your computer.
Prerequisites
The board must already be running esp-csi-cli-rs.
The server drives the device by issuing CLI commands over the serial port, so a
board with custom esp-csi-rs firmware will be discovered but refused.
Install and Run
cargo install csi-webserver
csi-webserver --help
Or from a source checkout:
cargo run -p csi-webserver
cargo run -p csi-webserver -- --interface 127.0.0.1 --port 3000 --baud-rate 921600
cargo run -p csi-webserver -- --device lab1=/dev/ttyUSB0 --scan-interval-ms 1000
| Option | Default | Purpose |
|---|---|---|
--interface <ADDR> | 0.0.0.0 | Bind address |
--port <PORT> | 3000 | TCP port |
--baud-rate <RATE> | 115200 | Serial baud (env CSI_BAUD_RATE) |
--device <ALIAS=PORT_OR_MAC> | — | Stable device id override, repeatable |
--scan-interval-ms <MS> | 2000 | Hotplug rescan interval |
CSI_SERIAL_PORT pins a single port instead of auto-detecting, and RUST_LOG
sets the tracing filter (default csi_webserver_core=debug).
A first capture, end to end:
csi-webserver
curl -sS "http://127.0.0.1:3000/api/devices"
curl -sS "http://127.0.0.1:3000/api/devices/<id>/info"
curl -sS -X POST "http://127.0.0.1:3000/api/devices/<id>/control/start"
The stream itself is at ws://127.0.0.1:3000/api/devices/<id>/ws.
Devices Are Addressed Individually
The server handles multiple boards at once. A hotplug supervisor scans for attached devices, assigns each a stable id, spawns a dedicated serial worker, and tears it down on unplug after a short debounce. It starts and serves happily with no device attached at all.
Every per-device endpoint lives under /api/devices/{id}/.... The id defaults
to the sanitised port basename — /dev/ttyUSB0 becomes ttyUSB0 — and
--device lab1=/dev/ttyUSB0 pins a friendlier one. State is entirely per
device: connection, firmware verification, collection status, config cache, and
the CSI stream are independent, and each device’s WebSocket carries only its own
frames.
The Firmware Gate
Before the server dispatches any command to a device, the firmware must be
verified as esp-csi-cli-rs. On every successful serial connect the server runs
an internal info exchange and looks for the ESP-CSI-CLI/<version> magic
prefix and the END-INFO sentinel — the contract described in
CSI Data Formats.
While a device is unverified, command endpoints return
412 Precondition Failed. A handful of endpoints are always reachable,
because they are how you recover: GET /, GET /api/devices,
GET /api/devices/{id}/info, GET /api/devices/{id}/config,
GET /api/devices/{id}/control/status, and
POST /api/devices/{id}/control/reset.
The reset path differs by adapter, which is worth knowing when a board seems
stuck. On UART adapters (CP210x, CH340) the server pulses RTS, waits for the
chip to boot, and re-runs the info exchange synchronously, so the HTTP
response tells you whether re-verification succeeded. On native USB-Serial-JTAG
boards, pulsing RTS would re-enumerate and wedge the port, so the server sends
the firmware’s own restart command and returns immediately — poll
GET /api/devices to see the board come back verified.
Endpoints
Command endpoints return { "success": true, "message": "..." }. Validation
errors are 400, a disconnected device is 503, and an unverified one is
412.
| Method | Path | Purpose |
|---|---|---|
| GET | / | Health |
| GET | /api/devices | List attached devices and their status |
| GET | /api/devices/{id}/info | Firmware identification; refreshes cache |
| GET | /api/devices/{id}/config | Read the cached configuration |
| POST | /api/devices/{id}/config/reset | Restore firmware defaults |
| POST | /api/devices/{id}/config/wifi | Mode, credentials, channel, peer, emitter |
| POST | /api/devices/{id}/config/traffic | Traffic generator frequency |
| POST | /api/devices/{id}/config/csi | CSI acquisition flags |
| POST | /api/devices/{id}/config/csi-output | Master delivery gate |
| POST | /api/devices/{id}/config/output-mode | stream, dump, or both |
| POST | /api/devices/{id}/config/rate | PHY rate |
| POST | /api/devices/{id}/config/protocol | Wi-Fi PHY protocol |
| POST | /api/devices/{id}/config/io-tasks | Toggle TX / RX tasks |
| POST | /api/devices/{id}/config/csi-delivery | Delivery mode and inline log gate |
| GET | /api/devices/{id}/control/status | Runtime status |
| POST | /api/devices/{id}/control/start | Begin collection |
| POST | /api/devices/{id}/control/stop | End collection |
| POST | /api/devices/{id}/control/reset | Reset the chip and re-verify |
| POST | /api/devices/{id}/control/stats | Runtime counter snapshot |
| GET | /api/devices/{id}/ws | Per-device CSI WebSocket stream |
Two changes will trip up anything written against an older server.
config/csi-output replaces the former config/collection-mode, for the
naming reason given in Crates & Libraries.
And config/log-mode has been removed outright: the server always runs
devices in the serialized format, because that is the only one it decodes.
Two scope notes are worth carrying into any integration work.
First, config/wifi names five modes — station, sniffer, wifi-ap,
ht20-emitter, and ht40-emitter. The firmware still supports the four ESP-NOW
modes, but the server does not name them; they reach a device either through an
embedder’s CsiProfile::extra_wifi_modes (see
csi-webserver-core) or over the serial console
directly.
Second, config/csi-output is documented ahead of its implementation. The
firmware-side contract — set-csi-output --enabled=<true|false>, defaulting to
true — is settled, but the matching handler had not landed in
csi-webserver-core at the time of writing. Treat the HTTP path as
provisional; the command and the body are not.
Output Modes and Parquet Dumps
POST /api/devices/{id}/config/output-mode selects what happens to a session’s
frames:
| Mode | WebSocket | Parquet dump |
|---|---|---|
stream (default) | yes | no |
dump | no | yes |
both | yes | yes |
Dumps are one Apache Parquet file per session, named with the device id so
concurrent devices never collide — csi_dump_ttyUSB0_20260621_120000.parquet.
The server decodes the device’s serialized frames into typed columns, so the
file opens directly in pandas, polars, pyarrow, or DuckDB with no format
knowledge required:
import pyarrow.parquet as pq
t = pq.read_table("csi_dump_ttyUSB0_20260621_120000.parquet")
print(t.schema)
One superset schema covers every chip. Columns that exist only on some parts
are nullable and left null elsewhere, so the classic 802.11n metadata
(sig_mode, mcs, bandwidth, stbc, …) is null on C5/C6 rows, and the C5/C6
driver fields (dump_len, cur_bb_format, the rxmatch flags, …) are null on
ESP32-family rows. Check the chip column to know which apply.
Two timestamps are recorded per row and they mean different things:
host_rx_time is the server’s wall clock, and timestamp is the
device’s microseconds-since-boot counter. Correlating across devices means
using host_rx_time.
Stop cleanly. The Parquet footer is written when a session ends — on
stop, an output-mode switch back tostream, a device disconnect, or server shutdown. A hard crash or power loss leaves the file without a footer and any unflushed rows lost, and such a file will not open at all.
Relevant Links & Resources
- Crate:
csi-webserveron crates.io - Source: csi-rs/csi-webserver-rs
- The complete endpoint reference, with payloads and status codes, is
API.mdin the repository.
The repository also carries
specs/SPECS.MD, which predates multi-device support and documents single-device routes such as/api/config/...without the/devices/{id}segment.API.mdis the current reference; treat the spec as historical.
csi-webserver-core
csi-webserver-core is the library that
csi-webserver is built from. The executable is a
thin wrapper: it parses command-line flags and calls into this crate. Everything
described in the previous section — device discovery, the firmware gate, the
HTTP routes, the WebSocket stream, the Parquet sink — lives here.
Reach for it when you want the CSI server running inside your own process rather than beside it: to register devices programmatically, to mount the API under your own routes, or to build a host application that does something more than bridge.
The published crate is at 0.1.1 at the time of writing, with the repository ahead at 0.2.0.
Embedding the Server
[dependencies]
csi-webserver-core = "0.1"
tokio = { version = "1", features = ["full"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
The default arrangement is three calls: build the shared state, spawn the hotplug supervisor against it, and serve.
use std::time::Duration;
use csi_webserver_core::{
AppState, ServerConfig, SupervisorConfig, run_supervisor, serve,
};
#[tokio::main]
async fn main() -> std::io::Result<()> {
tracing_subscriber::fmt::init();
let state = AppState::new();
tokio::spawn(run_supervisor(SupervisorConfig {
registry: state.devices.clone(),
baud_rate: 115_200,
scan_interval: Duration::from_secs(2),
aliases: vec![],
}));
serve(ServerConfig { bind: "0.0.0.0:3000".into() }, state).await
}
Registering a Device Directly
Hotplug discovery is convenient but not mandatory. If you already know which port a board is on — a fixed lab rig, a container with a device passed through, a test harness — attach it explicitly and skip the supervisor entirely:
use csi_webserver_core::{AppState, DeviceAttachSpec};
let state = AppState::new();
state.devices.attach(DeviceAttachSpec {
id: "lab1".into(),
port_path: "/dev/ttyUSB0".into(),
baud_rate: 115_200,
native_usb: false,
mac: None,
..Default::default()
});
native_usb matters more than it looks: it decides which reset strategy the
firmware gate uses. supervisor::probe_port reads it, along with the MAC, from
USB enumeration, so the honest way to fill the struct is to probe first rather
than to guess.
Mounting It Inside Your Own Router
build_router hands back a plain Axum Router, which you can serve as-is or
nest under your own paths alongside whatever else your application does:
use axum::Router;
use csi_webserver_core::{build_router, AppState};
let state = AppState::new();
let app: Router = build_router(state);
The individual handler functions are exported through the routes module for
cases where you want to compose routes yourself rather than take the set
wholesale.
Public API Surface
| Export | Purpose |
|---|---|
AppState, DeviceRegistry, DeviceHandle, DeviceAttachSpec | Shared runtime state |
ServerConfig, build_router, serve | HTTP server |
SupervisorConfig, run_supervisor, detect_esp_ports, probe_port | Hotplug discovery |
models | JSON request/response types and CLI command mappers |
csi | COBS + postcard frame decoder |
routes | Axum handlers, for custom routers |
serial, parquet_sink | The lower-level pipelines |
CsiProfile, StandardCsiProfile | The extension seam, below |
The Extension Seam
The crate’s node-mode table names five modes — station, sniffer, wifi-ap,
ht20-emitter, and ht40-emitter — mirroring that subset of the firmware’s
set-wifi --mode= grammar.
Any mode this crate does not name can be supplied by an embedder through
CsiProfile::extra_wifi_modes. Its mode-specific flags ride through a flattened
extra map on the request body and are re-emitted verbatim as --{key}={value}
to the firmware, so the crate forwards modes it knows nothing about. The effect
is that the core stays honest — it never names a mode it does not itself
implement — and an embedder can extend the mode set without forking the server.
Relevant Links & Resources
- Crate:
csi-webserver-coreon crates.io - Documentation: docs.rs/csi-webserver-core
- Source: csi-rs/csi-webserver-core-rs
csi-webclient
csi-webclient is the operator’s end of the pair. It is a native desktop
application — egui/eframe — that connects to a running
csi-webserver over HTTP and WebSocket, discovers
whatever boards that server has found, and gives you a place to configure them,
start and stop collection, watch frames arrive, and record captures locally.
Together the two are the shortest path from a board to something you can drive and look at: the webserver moves frames off the host’s serial ports, and the webclient is where a person actually works.
The published crate is at 0.2.1 at the time of writing, with the repository
ahead at 0.3.0. It targets csi-webserver 0.1.5 or newer — the multi-device
API.
Naming. As with the webserver, the repository is
csi-webclient-rswhile the published crate iscsi-webclient. Theesp-csi-webclient-rsspelling used in earlier drafts does not exist on crates.io.
Running It
cargo build --release
cargo run --release
On start, set the host and port in the top bar to match your webserver — the
default is 127.0.0.1:3000 — and click Connect. From there the client polls
for attached devices roughly every two seconds, so boards appear and disappear
as they are plugged in and out.
The Five Tabs
| Tab | What it is for |
|---|---|
| Devices | Fleet overview, per-device start/stop, refresh, and the event log |
| Dashboard | Per-device status, firmware identification, and stream counters |
| Config | Every per-device configuration endpoint, as a form |
| Control | Start and stop collection; connect and disconnect the WebSocket |
| Stream | Frame counters, hex previews, and local Parquet recording |
Selecting several devices — from the Devices tab or the top-bar combo box — drives the detail tabs side by side, which is how you compare two nodes of a pairing without switching back and forth.
What It Adds Over curl
Anything the client does can be done by hand against the server’s HTTP API. Four things are enough easier here that they are worth calling out.
Fleet operations. Start All and Stop All act across every discovered
device, and multi-select gives synchronized collection across a set of boards.
For a multi-receiver measurement, starting nodes one curl at a time introduces
exactly the skew you were trying to measure.
Pairing presets. The Devices tab applies two-device presets — a softAP lab pair, or an HT20/HT40 emitter plus a sniffer — in one action. These are the arrangements from Crates & Libraries, pre-wired so the channel and mode on both ends agree without you checking twice.
Local Parquet export. The Stream tab records to
csi_export_{id}_YYYYMMDD_HHmmss.parquet, with a schema matching the
server-side dumps described in the
webserver section. The difference is where the file
lands: server dumps are written next to the server, these are written next to
you.
Configuration snapshots. Device configuration saves and loads as JSON
(csi_config_{id}_YYYYMMDD_HHmmss.json), and the Config tab can copy a
configuration from one device to another — the quickest way to bring a second
board into line with a first.
Those snapshots contain Wi-Fi passwords in plain text. They are convenient to check into a lab notebook and a poor thing to check into a repository.
Configuration Surface
The client exposes the server’s full per-device surface: the emitter and
collector Wi-Fi modes (station, sniffer, wifi-ap, ht20-emitter,
ht40-emitter) with their softAP options, traffic frequency, CSI acquisition
flags, the CSI-output gate, delivery mode, PHY rate and protocol, the I/O task
toggles, and the stream / dump / both output-mode switch.
There is no log-mode control, and its absence is deliberate: the server always
runs devices in the serialized format, because that is the format it decodes.
The hex previews in the Stream tab are the raw COBS + postcard frames described
in CSI Data Formats, shown before decoding
precisely so that a malformed stream is visible as bytes rather than as a silent
absence of rows.
Architecture
The codebase keeps three domains deliberately separate, which is worth knowing before contributing:
src/state— the source of truth for application data and UI-visible state.src/ui— rendering only; no network calls and no orchestration.src/core— side effects: HTTP requests, the WebSocket loop, the async runtime, and the channels between them.src/export— the host-side serialized CSI decoder and the Parquet writer.
Intent orchestration and event application happen in src/app.rs, and nowhere
else.
Relevant Links & Resources
- Crate:
csi-webclienton crates.io - Source: csi-rs/csi-webclient-rs
- The client’s own view of the API is
docs/HTTP_API.mdin the repository.
Getting Familiar with the Ecosystem
The previous chapter described each crate on its own terms. This one puts them together, in the order you would actually meet them, and ends with CSI on a screen.
The walkthrough is deliberately built around the cheapest possible arrangement and grows from there. You need one ESP32-family board to see anything at all, and two to run the controlled emitter-and-collector pairing that most sensing work is built on. Nothing here requires an oscilloscope, a spectrum analyser, or a lab.
The five sections are meant to be read in order, but each stands alone:
- Getting Started — what to buy, what to install, and how to get the first CSI line printing on your terminal. The fastest path, using a prebuilt firmware binary and no Rust code at all.
- Your First Collector — the same
capture, written yourself against
esp-csi-rs. This is the section to read if you intend to build something rather than to operate something. - Driving the CLI — the full configuration
surface of
esp-csi-cli-rs, including the two-board emitter/collector pairing and how to confirm on hardware that it is working. - Streaming to a Host — putting
csi-webserverandcsi-webclientin front of the boards, and recording a session to Parquet. - Viewing On-Device — the paths that need
no host at all, using
esp-csi-litetui-rsandesp-csi-litegui-rs.
Two habits will save you time throughout. First, every node in a capture set
must agree on the primary channel — more first-run failures trace to a channel
mismatch than to anything else. Second, when something produces no data, reach
for the counters before reaching for the configuration: show-stats on the
device, and the drop counts it reports, distinguish “the radio captured nothing”
from “the console could not carry it” in a few seconds.
Getting Started
This section takes you from nothing to CSI printing on your terminal. It uses a prebuilt firmware binary, so no Rust code is written and no embedded toolchain is needed — only a flashing tool.
What to Buy
Any supported board works. If you have no preference, an ESP32-C6 or ESP32-C5 development board is the most flexible starting point: both are RISC-V, so they build on stable Rust, and both have native USB-Serial-JTAG.
| Chip | Band | Notes |
|---|---|---|
| ESP32-C6 | 2.4 GHz | RISC-V, native USB-Serial-JTAG |
| ESP32-C5 | 2.4 / 5 GHz | The only dual-band part |
| ESP32-C3 | 2.4 GHz | The cheapest of the supported parts |
| ESP32-S3 | 2.4 GHz | Xtensa; needs espup. Has vector SIMD |
| ESP32 | 2.4 GHz | Xtensa; no USB-Serial-JTAG, UART only |
One board is enough to run a sniffer collector against whatever ambient Wi-Fi traffic already exists in the room. That is the fastest way to confirm the hardware works, and it is enough for this section.
Two boards unlock the controlled pairing — one emitting a known sounding frame at a known rate, one measuring it — which is what almost every sensing experiment actually wants. If you are buying anyway, buy two.
You will also need a USB cable that carries data. A surprising number do not.
Install the Host Tools
Only one tool is required to flash a prebuilt binary:
cargo install espflash
On Linux, your user needs permission to open the serial port. On most
distributions that means joining the dialout group (uucp on Arch):
sudo usermod -aG dialout $USER
Log out and back in for the change to take effect. Then plug the board in and confirm it appears:
ls /dev/ttyACM* /dev/ttyUSB*
Native USB-Serial-JTAG boards (the C3, C5, C6, and S3) enumerate as
/dev/ttyACM*. Boards behind a UART bridge — the original ESP32, and many
third-party carriers — appear as /dev/ttyUSB*.
Flash the CLI Firmware
Download the binary for your chip from the
esp-csi-cli-rs releases page.
Each release publishes one .bin per chip, named esp-csi-cli-rs-<chip>.bin,
plus a manifest.json listing each file’s SHA-256, flash address, and the baud
rate it was built for.
The baud rate is fixed at build time. That is why the manifest publishes it. A monitor opened at the wrong rate shows garbage rather than a slow stream, which looks alarmingly like a dead board.
Flash it and open the monitor in one step:
espflash flash --monitor esp-csi-cli-rs-esp32c6.bin
If the board does not enter the bootloader on its own, hold BOOT, tap RESET, release BOOT, and run the command again.
First Contact
After a reset you should see the identification banner:
ESP-CSI-CLI/0.7.0
mac=D0:CF:13:E2:90:E8
******* Welcome to the CSI Collection CLI utility! *******
Available Commands:
set-wifi Configure WiFi settings (e.g., mode).
...
That first line is the firmware identification contract from
CSI Data Formats, and
seeing it means the board, the cable, the port, and the baud rate are all
correct. Write down the mac= value — it is the stable device key that host
tooling uses to recognise this specific board across resets.
Your First Capture
The firmware defaults to sniffer mode on channel 1, which is a collector measuring whatever it overhears. That is enough for a first run:
set-log-mode --mode=text
start --duration=10
Within a second or two, records should begin to appear:
mac: 56:6C:EB:6F:BC:3D
sequence number: 426
rssi: -82
rate: 11
noise floor: 165
channel: 1
timestamp: 2424915
...
data length: 128
csi raw data: [0, 0, 0, 0, -6, 0, 6, 0, -24, 10, -23, 9, ...]
That array is a CSI measurement: the channel’s response, sampled per subcarrier, as complex pairs. Wave your hand between the board and whatever is transmitting and the numbers will move.
Press q to stop early, or let the duration expire.
If Nothing Appears
Work through these in order; the first two account for most cases.
No records at all. Channel 1 may simply be quiet. Ambient sniffing depends on there being ambient traffic, so try a channel your own Wi-Fi is on:
set-wifi --set-channel=6
start --duration=10
Nothing changes until the next start — most CLI commands stage configuration
rather than applying it live.
Records, but far fewer than expected. Check the counters:
show-stats
A large drop count means the serial console or the logging queue is the bottleneck, not the radio. Switching to a denser format helps immediately:
set-log-mode --mode=array-list
Garbled output. Almost always a baud mismatch. Confirm the rate against the release manifest and reopen the monitor.
The banner never appears. Confirm the port with
ls /dev/ttyACM* /dev/ttyUSB* and your group membership with groups. A cable
that only carries power looks identical to a broken board.
Where to Go Next
You now have one board producing CSI. Three directions from here, and they are independent:
- To write your own firmware rather than operate a prebuilt one, continue to Your First Collector.
- To set up the controlled emitter/collector pairing and use the full configuration surface, continue to Driving the CLI.
- To get the data onto your computer in a form you can analyse, jump to Streaming to a Host.
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
stdandno_stdsplit, 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:
- Initialise the hardware and the RTOS.
- Initialise the logger, choosing an output format.
- Build a
NodeHardwarefrom the radio interfaces and controller. - Build a
CSINodedescribing the role. - 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.
Driving the CLI
Getting Started flashed esp-csi-cli-rs and ran a
capture on the defaults. This section uses the rest of it: the real
configuration surface, the two-board pairings, and how to tell on hardware
whether a pairing is actually working.
Everything here happens over the serial console. The command reference lives in
the
esp-csi-cli-rs section;
this is the operating guide.
The One Rule
Most commands stage configuration; they do not apply it. They write into an
in-memory UserConfig, and the next start builds a node from it. Only
set-log-mode, restart, and set-csi-delivery take effect immediately.
This explains most confusion at the console. If a change appears to do nothing,
it is because you have not restarted the session. show-config prints the
staged state; reset-config restores defaults.
A Single-Board Capture, Configured
Ambient sniffing on a channel your own network uses, in a format worth parsing:
reset-config
set-wifi --mode=sniffer --set-channel=6
set-protocol --protocol=n
set-traffic --frequency-hz=0
set-log-mode --mode=array-list
show-config
start
Two choices there are deliberate. --frequency-hz=0 disables the traffic
generator, because a sniffer has nothing to generate traffic for — it is
measuring what already exists. And array-list is one line per packet instead
of text’s twenty-odd, which matters as soon as the rate rises.
Emitter and Collector
This is the arrangement most sensing work is built on, and the reason to own two boards. One sounds the channel at a known rate and bandwidth; the other measures it. Nothing associates, so there is no handshake to fail.
Collector board — lock the emitter’s channel and measure everything overheard:
set-wifi --mode=sniffer --set-channel=6
set-traffic --frequency-hz=0
set-log-mode --mode=array-list
start
Emitter board — 20 MHz injection every 20 ms, so roughly 50 frames a second:
set-wifi --mode=ht20-emitter --set-channel=6 --inject-period-ms=20
start
Use --mode=ht40-emitter for 40 MHz.
A single emitter sounds every collector in range at once, and adding collectors
costs the emitter nothing. Going the other way, several emitters can share one
collector: each frame carries its transmitter’s MAC, so the collector attributes
measurements by source. Unicasting to one collector with --peer-mac tends to
raise that collector’s CSI rate noticeably compared to broadcasting.
Cleaning Up the Capture
Out of the box, that collector reports CSI for everything it decodes — your AP’s beacons and ACKs, the association exchange, and any third-party device on the channel — mixed in with your emitter’s frames. Those rows are valid CSI, but they look wrong next to the ones you want: the leading field is the frame’s own 802.11 sequence number, which is per-transmitter and so neither starts at zero nor shares a counter with your traffic, and a legacy-rate frame carries the shorter L-LTF-only payload.
Two commands fix this, and both are worth applying by default:
set-csi-filter --peer-mac=aa:bb:cc:dd:ee:ff
set-csi --csi-legacy=off --csi-ht20=off --csi-ht40=on --dump-ack=off
The first delivers CSI only from your emitter’s MAC —
set-csi-filter --min-phy=ht is the looser version, keeping 802.11n and better
while dropping legacy-rate management and control frames. The second restricts
what the radio acquires at all. On classic parts (ESP32, C3, S3) set-csi takes
--lltf, --htltf, --stbc-htltf, and --ltf-merge instead.
Filtering on the device rather than on the host returns console bandwidth to the
traffic you asked for: a rejected frame is dropped in the Wi-Fi callback before
the packet copy and before any formatting. Rejected frames are still counted in
show-stats as RX drops, so the gap between captured and delivered stays
visible rather than unexplained.
Confirming HT40 Actually Engaged
If you configured ht40-emitter and the collector’s rows still show about 53
subcarriers, HT40 did not engage. Check the collector’s csi_data_len: 100 or
more (commonly ~117) confirms 40 MHz; ~53 or ~56 means it fell back to legacy
or HT20.
The usual causes, in order of likelihood:
- Legacy and ACK acquisition still on. Those reports arrive at ambient
rates and swamp the HT40 ones. Apply the
set-csiline above. - No room in the band.
--ht40=aboveon channel 7 occupies up to channel 11 and--ht40=belowoccupies down to channel 3. A primary too close to the band edge silently falls back. - Channel mismatch. The two boards are not on the same primary channel.
The Associated Link
When you want CSI from an ordinary Wi-Fi link rather than from blind sounding, pair a softAP collector with a station.
Board A, the AP collector:
reset-config
set-wifi --mode=wifi-ap --set-channel=6 --ap-ssid=esp-csi-ap
set-protocol --protocol=n
set-traffic --frequency-hz=4000
set-log-mode --mode=array-list
start
Board B, the station:
reset-config
set-wifi --mode=station --sta-ssid=esp-csi-ap --set-channel=6
set-protocol --protocol=n
set-traffic --frequency-hz=4000
set-log-mode --mode=array-list
start
CSI appears primarily on board A, from the station’s uplink replies.
The AP’s DHCP pool holds four leases by default, and with more than one lease
the traffic round-robins across all associated stations — so the pair scales to
several station boards with no extra configuration. --ap-leases=<1-8> sizes
the pool (1 is a single-target flood). The offered rate is shared: with N
stations, each sees roughly frequency-hz / N packets per second.
For time-aligned multi-receiver measurements, --ap-burst=on sends one frame
back-to-back to every station each tick instead of round-robining. Total airtime
then becomes frequency-hz × leases, so size it accordingly.
On a C5, the channel number selects the band. The default is 149, which is 5 GHz. Associating to a 2.4 GHz AP from that default reports only “no access point found” — pass an explicit 2.4 GHz channel.
ESP-NOW Pairs
Connectionless: no AP, no DHCP, no association, and both sides capture.
# Central board
set-wifi --mode=esp-now-central --set-channel=6
set-rate --rate=mcs0-lgi
set-log-mode --mode=array-list
start
# Peripheral board
set-wifi --mode=esp-now-peripheral --set-channel=6
set-rate --rate=mcs0-lgi
set-log-mode --mode=array-list
start
With more than two boards on one channel, set --peer-mac on both ends to pin
the pair explicitly rather than relying on magic-prefix discovery.
The asymmetric variant reaches the highest CSI rate of any pairing, because the collector stops transmitting once it hears a source and all airtime then belongs to one transmitter. Start the collector first:
# Collector board
set-wifi --mode=esp-now-fast-collector --set-channel=6
set-log-mode --mode=serialized
start
# Source board
set-wifi --mode=esp-now-fast-source --set-channel=6
start
set-rate does not apply to this pair — the fast profile fixes its own PHY.
Reading the Counters
show-stats is the first thing to reach for whenever a capture looks wrong. It
reports packets transmitted and received, rates in Hz, and drop counts.
Interpreting it is mostly one distinction. If the received count tracks your
emitter’s rate but the rows arriving on your terminal are fewer, the bottleneck
is the console or the async logging queue, not the radio — switch to a denser
format (array-list, or serialized) or raise the emitter’s period. If the
received count itself is low, the radio genuinely is not hearing the emitter,
and the problem is channel, band, distance, or acquisition configuration.
Drop counts also include frames rejected by set-csi-filter, which is
intentional: it keeps the difference between captured and delivered visible
rather than making filtered frames vanish silently.
Where to Go Next
Driving two boards from two serial terminals stops scaling quickly. The next section puts a server in front of them, so configuration, control, and recording happen in one place: Streaming to a Host.
Streaming to a Host
Two boards driven from two serial terminals works, and stops working the moment
you want a third board, a recording, or a colleague watching from another
machine. This section puts csi-webserver in front of the hardware and
csi-webclient in front of that.
The boards must already be running esp-csi-cli-rs — the server drives them by
issuing CLI commands over serial, and refuses anything it cannot identify as
that firmware.
Start the Server
cargo install csi-webserver
csi-webserver
It binds 0.0.0.0:3000 by default and starts scanning for attached boards
immediately, whether or not any are plugged in. Useful variations:
# Local only, faster serial, faster hotplug scanning.
csi-webserver --interface 127.0.0.1 --port 3000 \
--baud-rate 921600 --scan-interval-ms 1000
# Pin friendly, stable ids instead of ttyUSB0 / ttyACM1.
csi-webserver --device emitter=/dev/ttyACM0 --device collector=/dev/ttyACM1
Those aliases are worth setting up early. Without them a device’s id is the sanitised port basename, which changes when boards are unplugged in a different order — and every endpoint is addressed by id.
Confirm the Devices
curl -sS "http://127.0.0.1:3000/api/devices"
Each entry reports its connection state and, separately, whether its firmware
has been verified. Verification is not optional: on every serial connect the
server runs an internal info exchange and looks for the
ESP-CSI-CLI/<version> magic prefix. Until that succeeds, every
command-dispatching endpoint returns 412 Precondition Failed.
If a board shows up connected but unverified, the recovery path is:
curl -sS "http://127.0.0.1:3000/api/devices/collector/info"
curl -sS -X POST "http://127.0.0.1:3000/api/devices/collector/control/reset"
On UART adapters the reset pulses RTS and re-verifies synchronously, so the
response tells you whether it worked. On native USB-Serial-JTAG boards it sends
the firmware’s own restart and returns immediately — poll /api/devices to
watch it come back.
Configure and Run a Pairing
The same emitter/collector arrangement from the previous section, over HTTP instead of two terminals:
BASE=http://127.0.0.1:3000/api/devices
# Collector: sniffer on channel 6, no traffic generation.
curl -sS -X POST "$BASE/collector/config/wifi" \
-H 'content-type: application/json' \
-d '{"mode":"sniffer","channel":6}'
curl -sS -X POST "$BASE/collector/config/traffic" \
-H 'content-type: application/json' \
-d '{"frequency_hz":0}'
# Emitter: 20 MHz injection on the same channel.
curl -sS -X POST "$BASE/emitter/config/wifi" \
-H 'content-type: application/json' \
-d '{"mode":"ht20-emitter","channel":6,"inject_period_ms":20}'
# Start the collector first, then the emitter.
curl -sS -X POST "$BASE/collector/control/start"
curl -sS -X POST "$BASE/emitter/control/start"
Command endpoints answer { "success": true, "message": "..." }. A 400 means
the body failed validation, a 503 means the device is not connected, and a
412 means it is not verified.
Two scope notes on the server. Its
config/wifiroute names five modes —station,sniffer,wifi-ap,ht20-emitter,ht40-emitter— and does not itself name the ESP-NOW ones the firmware still supports; those reach the device either through an embedder’sCsiProfile::extra_wifi_modesor over the serial console directly. Andconfig/csi-outputis documented ahead of its handler: the firmware-side contract is settled, but treat the HTTP path as provisional until it lands.
Watch the Stream
Each device has its own WebSocket, carrying only its own frames:
ws://127.0.0.1:3000/api/devices/collector/ws
The frames are the serialized format — COBS-framed postcard records described
in CSI Data Formats. The
server always runs devices in that format, which is why there is no log-mode
setting over HTTP; it is the only format the server decodes.
Record to Parquet
Streaming is for watching. For anything you intend to analyse later, switch the device’s output mode:
curl -sS -X POST "$BASE/collector/config/output-mode" \
-H 'content-type: application/json' -d '{"mode":"both"}'
| Mode | WebSocket | Parquet dump |
|---|---|---|
stream (default) | yes | no |
dump | /ws returns 403 | yes |
both | yes | yes |
The new mode applies on the next received frame. Files are named
csi_dump_<id>_YYYYMMDD_HHmmss.parquet, one per session, so concurrent devices
never collide. The server decodes into typed columns, so the result opens
directly:
import polars as pl
df = pl.read_parquet("csi_dump_collector_20260621_120000.parquet")
print(df.schema)
One superset schema covers every chip, with per-family columns left null
elsewhere — the 802.11n metadata is null on C5/C6 rows, the C5/C6 driver fields
are null on ESP32-family rows, and the chip column tells you which apply.
Two timestamps per row mean different things. host_rx_time is the server’s
wall clock; timestamp is the device’s microseconds-since-boot counter.
Correlating measurements across two boards means using host_rx_time, because
the device counters share no origin.
Stop the session cleanly. The Parquet footer is written when a session ends — on
stop, a switch back tostream, a disconnect, or server shutdown. A crash or a yanked cable leaves the file without a footer, and such a file will not open at all. Unplugging a board to end a capture is the most common way to lose one.
curl -sS -X POST "$BASE/emitter/control/stop"
curl -sS -X POST "$BASE/collector/control/stop"
Use the Desktop Client
Everything above can be done by hand, and after the third board it stops being
worth doing by hand. csi-webclient is a native desktop application that talks
to the same server:
cargo run --release # from a csi-webclient-rs checkout
Set the host and port in the top bar — 127.0.0.1:3000 by default — and click
Connect. It polls for devices every couple of seconds, so boards appear and
disappear as they are plugged in.
Four things it does that curl does not do comfortably:
- Fleet control. Start All and Stop All, and multi-select for synchronized collection. Starting nodes one request at a time introduces exactly the skew a multi-receiver measurement is trying to avoid.
- Pairing presets. The Devices tab applies a softAP lab pair, or an HT20/HT40 emitter plus sniffer, in one action — with the channel and mode on both ends already agreeing.
- Local Parquet export. The Stream tab records to
csi_export_{id}_YYYYMMDD_HHmmss.parquetnext to you, rather than next to the server. - Configuration snapshots. Save and load device configuration as JSON, and copy a configuration from one device to another. Note that those files contain Wi-Fi passwords in plain text.
The five tabs are Devices (fleet overview and event log), Dashboard (status, firmware info, counters), Config (every configuration endpoint as a form), Control (start, stop, WebSocket connect), and Stream (counters, hex previews, recording). Selecting several devices drives the detail tabs side by side.
Embedding the Server Instead
If you are building a host application rather than operating one, the server is
a library: csi-webserver-core gives you AppState, run_supervisor, and
serve, plus build_router to mount the API inside your own Axum application
and DeviceRegistry::attach to register a known port without hotplug discovery.
See csi-webserver-core.
Where to Go Next
For captures with no host machine at all, continue to Viewing On-Device. For what to do with the Parquet files you are now producing, see Signal Processing in Rust.
Viewing On-Device
Everything so far has assumed a host: a serial terminal, or a server and a desktop client. This section covers the two firmware applications that need neither, because sometimes the laptop is the awkward part of the measurement.
Both are Tier 1 front-ends built on esp-csi-rs, and both require specific
hardware — they drive a display, so the board is not interchangeable.
When to Reach for These
Use esp-csi-litetui-rs when you want to walk around with the instrument:
survey a building, find where a link degrades, or take a measurement somewhere a
laptop cannot conveniently go. It logs to SD, so the capture comes back with
you.
Use esp-csi-litegui-rs when you want a live display of what the channel is
doing — a heatmap you can point at a room and leave running. It does not log;
it renders.
Use neither if you need the data on a computer anyway. The CLI plus a webserver is a shorter path.
The Handheld Scope
esp-csi-litetui-rs targets the M5Stack CoreS3 SE — an ESP32-S3 with a
320×240 touchscreen and a microSD slot.
Build and flash it from the repository; the target, runner, and build-std
settings are preconfigured, and the Espressif Rust toolchain (espup) is needed
because the S3 is an Xtensa part:
# Station mode joins this network — edit before flashing:
# src/config.rs : WIFI_SSID / WIFI_PASSWORD
cargo run --release # builds, flashes, and opens the serial monitor
Everything else happens on the screen. The setup screen configures node mode, channel, traffic rate, HT40 secondary channel, CSI sub-options, delivery mode, and log format. Tap the top-left and top-right to move between fields, the middle-left and middle-right to change a value, and START CAPTURE to begin.
The live screen cycles five instrument tabs with NEXT:
- Spectrum — amplitude against subcarrier index.
- Phase — unwrapped phase in radians against subcarrier index.
- Waterfall — an amplitude heatmap over time and subcarrier.
- Signal — RSSI and SNR (
rssi − noise_floor) trended over time. - Stats — live counters (RX and TX packets per second, totals, drops) plus last-packet metadata: PHY, bandwidth, MCS, frame format, noise floor, sequence number, and CSI length.
STOP ends the capture and offers to KEEP or DELETE the SD file.
Getting the Capture Off the Card
The card must be FAT32 or FAT16; exFAT is not supported. The default output
is CSInnnnn.BIN — the same COBS-framed postcard records as the serialized
log mode — converted with the bundled, dependency-free script:
python3 tools/bin_to_csv.py CAPTURE.BIN # -> CAPTURE.csv
The alternative is CSV written directly on the device (CSInnnnn.CSV), one row
per packet, with an fmt column carrying the frame classification. Prefer the
binary format unless you have a reason not to: it is smaller on the card and
cheaper to write, which matters at high packet rates.
Driving a Companion Board
The scope is normally a collector — it needs CSI to display. It can also be set to one of the emitter modes, in which case the instrument tabs are replaced by a transmit-status screen, since an emitter captures nothing. Point a second device at the same channel in sniffer mode to measure it.
The repository’s upgrade note states that
esp-csi-rsremoved its ESP-NOW transport and that mode indices 3–6 are retired. That is true of this firmware’s own mode list, but not of the ecosystem:esp-csi-rs0.10.1 retains the ESP-NOW topologies andesp-csi-cli-rsstill exposes four ESP-NOW modes. A saved configuration from an older build that selected one of those indices falls back to Sniffer and logs a notice on the serial port; reselect the mode you want.
The Live Heatmap
esp-csi-litegui-rs renders CSI as an AMOLED heatmap on one of two boards:
Exactly one board feature is required, and the capture path is a feature too:
cargo run --release --features="lilygo-t4" # sniffer
cargo run --release --features="lilygo-t4,mode-sta"
cargo run --release --features="waveshare-esp32-s3-touch-amoled-1_8,mode-ap"
The board is always a collector; mode-snf (the default), mode-sta, and
mode-ap differ only in where the measurable frames come from. Only one of
mode-sta and mode-ap may be enabled. For station mode, set the SSID and
password in src/main.rs before flashing.
CSI processing runs on the main executor and rendering on a second-core executor, which is what keeps the display responsive when packets arrive faster than frames can be drawn.
Pairing It
In the default sniffer mode the board needs something to measure. Pair it with a
second board running the esp-csi-rs ht20_emitter or ht40_emitter example —
an emitter associates with nothing, so no configuration is needed on the LiteGUI
side beyond the channel.
Check the channel before anything else. The LiteGUI application uses channel 1; the emitter examples default to channel 7. Change one side to match. Left mismatched, the heatmap shows only ambient traffic and looks exactly as though the emitter is dead. This is the most common first-run failure with this pairing, and it costs people an afternoon.
In mode-ap any Wi-Fi station that joins esp-csi-ap works — the
wifi_station example, or a phone. Clients get a 192.168.13.x lease from the
built-in DHCP server, and CSI flows as soon as one associates.
Where to Go Next
That completes the walkthrough: you have collected CSI from a prebuilt binary, from firmware you wrote, over a network, and on a device with no host at all.
What remains is what to do with the data. Advanced Topics covers processing it, classifying it, and — most usefully — diagnosing the captures that do not look the way they should.
Advanced Topics
By the end of the previous chapter you can produce CSI reliably, in several arrangements, and get them onto a host in a format that opens in a dataframe. That is the part of the problem this project solves directly.
What follows is the part it does not. Turning a stream of complex samples into a statement about a room is a signal-processing and machine-learning problem, and one where the honest state of the art is that results transfer poorly between environments. Both of the earlier chapters flagged this: raw CSI is noisy and hard to interpret directly, and the literature’s most consistent finding is that a model trained in one room degrades sharply in another.
These three sections are guidance rather than reference. There is no first-party
public csi-rs crate for filtering, inference, or calibration yet, so nothing
here documents an API we ship. What it does do is anchor each topic to something
concrete — what esp-csi-rs actually hands you, what the hardware actually
reports, what the measurement harnesses in the repository actually measure — and
name the third-party Rust crates worth reaching for, clearly marked as
third-party.
- Signal Processing in Rust — from
raw
i8pairs to amplitude and phase you can trust, and the pipeline stages between them. - Edge AI & Classification — framing a sensing task, engineering features for it, and where inference should run.
- Troubleshooting & Calibration — the one to read first if your captures already look wrong. It is the section with the most hard evidence behind it.
If you are here because a capture is not behaving, skip to the third.
Signal Processing in Rust
What is CSI? introduced amplitude and phase as the two physical quantities recoverable from a CSI sample. This section is about the distance between that formula and a usable signal — which is larger than it looks, and where most of the practical difficulty in Wi-Fi sensing lives.
Reading the Raw Buffer
What esp-csi-rs hands you is csi_data, a slice of i8 values, and
csi_data_len telling you how many. The samples are interleaved pairs, one
pair per subcarrier.
Establish which element of the pair is which before trusting any phase
result. Code in the wild disagrees, including two firmwares in this ecosystem:
esp-csi-litetui-rs reads the first element of each pair as the imaginary
component, while esp-csi-litegui-rs reads it as the real one.
// One ordering. Verify against your own hardware before relying on it.
let imag = raw[2 * i] as f32;
let real = raw[2 * i + 1] as f32;
let amplitude = (real * real + imag * imag).sqrt();
let phase = imag.atan2(real);
The disagreement is easy to miss because amplitude cannot reveal it:
I² + Q² is symmetric in its arguments, so a swapped convention looks identical
in a spectrum plot. What it produces is a conjugated phase — a sign flip
that silently inverts anything phase-derived. If your phase slope runs the wrong
way, this is the first thing to check.
Two cheap optimisations are worth knowing because you will meet them in existing
code. Comparing magnitudes does not need the square root, so real² + imag² is
often kept as-is; and a pair of exact zeros marks a subcarrier the radio did not
report, which is usually carried forward from its neighbour rather than plotted
as a null.
Subcarrier Layout
An 802.11n 20 MHz channel uses a 64-point FFT, and not every bin carries data. Guard bands occupy the outermost bins and the DC bin sits in the middle, leaving 52 usable subcarriers:
// Indices 0-5 and 59-63 are guard; index 32 is DC.
valid[0..26].copy_from_slice(&litude[6..32]);
valid[26..52].copy_from_slice(&litude[33..59]);
The buffer also arrives in FFT order, with positive and negative frequencies in the halves the FFT produced rather than in monotonic frequency order. Swapping the halves puts them in the order a plot should show:
pub fn swap_upper_lower<T>(arr: &mut [T]) {
let mid = arr.len() / 2;
for i in 0..mid {
arr.swap(i, i + mid);
}
}
Skip that step and the spectrum appears discontinuous in the middle, which is easy to misread as interference.
HT40 roughly doubles everything: expect ~117–128 samples where HT20 gives ~56
and legacy 20 MHz L-LTF gives ~53. Do not hard-code the count — read
csi_data_len, and use data_format (the RxCSIFmt classification) to know
which layout you are looking at. A capture that mixes formats is common and
legitimate; a pipeline that assumes one is not.
Preparing the Signal
Raw CSI fails a consumer in four distinct ways, and each has its own remedy. The order below is the order the problems have to be solved in — you cannot filter across a gap you have not filled, and you cannot compare scales you have not corrected — but nothing here is a fixed pipeline. Take what your data needs.
Missing Samples
Fill what is missing. Subcarriers the radio reported as zero, and gaps in the packet stream where frames were lost, both need a decision. For subcarriers, carrying the neighbouring value forward is the cheap option and linear interpolation across the gap is the better one. For time, resampling onto a uniform grid matters more than it seems: CSI does not arrive at a uniform rate, because Wi-Fi frames are not uniformly spaced, and every frequency-domain technique downstream assumes uniform sampling. An emitter at a fixed period gets you closer to uniform than ambient traffic ever will, which is a large part of why the controlled pairing exists.
Noise and Outliers
Remove what should not be there. The canonical tools, roughly in order of how often they earn their place:
- Hampel filtering — a running median with a MAD-based outlier threshold. The right first reach for CSI, because the dominant artefact is impulsive spikes rather than additive noise, and a mean-based filter smears them instead of removing them.
- Moving median or moving average across time per subcarrier.
- Low-pass filtering, once you know the bandwidth of the motion you care about. Breathing is well under 1 Hz; walking is a few Hz; a gesture is tens. A filter matched to the phenomenon removes a great deal and costs nothing.
- Smoothing across subcarriers — a 3-tap smoother is often enough, and both on-device front-ends in this ecosystem use exactly that.
Third-party crates worth knowing: biquad for IIR filter sections, ndarray
for the array handling, and medians for the order statistics.
Hardware-Induced Phase Terms
Remove what is real but not about the environment. This is what makes phase usable at all, and skipping it is the most common reason phase data looks like noise.
The transmitter and receiver oscillators are free-running and not locked to one another, so each packet is measured against a slightly different reference. Three consequences dominate the raw phase measurement:
- Phase-locked loop jitter — the receiver’s PLL does not reacquire to the same phase on every packet, leaving a different constant offset each time.
- Carrier frequency offset (CFO) — the two oscillators differ in frequency, so the error accumulates as phase between packets.
- Sampling frequency offset (SFO) and packet detection delay (PDD) — timing errors that tilt phase across subcarriers, by a different amount in every packet.
These are larger than the channel effect you are trying to measure. The standard remedies:
- Linear detrending. Fit a line to phase against subcarrier index within each packet and subtract it. This removes the slope and the constant together, costs one least-squares fit per packet, and is the correct default.
- Phase unwrapping first.
atan2returns values in(-π, π], so a genuine slope shows up as a sawtooth. Unwrap along the subcarrier axis — add or subtract 2π wherever consecutive values jump by more than π — before fitting anything. Both on-device front-ends do this inline. - The CSI-ratio trick. If you have two receive antennas, divide one antenna’s CSI by the other’s. The offsets are common to both chains and cancel in the ratio, leaving a quantity that is genuinely about the channel. This is the cleanest available answer, and it is why multi-antenna hardware is worth the cost when phase matters.
Amplitude needs detrending too, though less dramatically: automatic gain control moves the whole vector when the receiver changes gain, and per-packet normalisation — or tracking the AGC state where it is exposed — keeps that from appearing as motion.
Scale Drift
Put measurements on a comparable footing. Per-subcarrier standardisation, min-max scaling to a fixed range, or normalisation against a static baseline recorded in an empty room. Which one depends entirely on what consumes the output, but doing none is what makes a model trained on Tuesday fail on Wednesday.
Beyond Per-Packet Processing
Three transformations recur once the per-packet pipeline is in place.
Dimensionality reduction. 52 subcarriers are highly correlated; the signal
you want usually lives in the first two or three principal components. PCA is
the standard reduction, and it doubles as a denoiser — discarded components are
mostly noise. nalgebra provides the decomposition.
Channel impulse response. An inverse FFT over the subcarrier axis converts
the frequency response into a time-domain impulse response, separating multipath
components by delay. This is how you begin to distinguish a reflection off a
moving person from the direct path. rustfft is the crate; the resolution is
bounded by bandwidth, which is the concrete reason HT40 is worth configuring and
why the C5’s 5 GHz band is interesting.
Time-frequency analysis. A short-time Fourier transform over a subcarrier’s amplitude across packets produces a spectrogram whose bright bands are Doppler shifts from motion. Most activity-recognition work operates on these rather than on raw CSI, because the representation makes the motion explicit.
Where the Processing Should Run
esp-csi-rs offers three placements, and the choice is a real one.
Inline, in the CSI callback. Lowest possible latency, zero copies, and hard
constraints: no heap, no locks, no I/O, no .await. Enough for amplitude,
phase, a threshold, or a running statistic — which is exactly what a
battery-powered presence detector needs, and it lets the device transmit an
event rather than a stream.
On-device, off the hot path. The async delivery mode queues packets for a
task of your own, at the cost of a ~640-byte copy each. This is where filtering
and small transforms belong. Be realistic about the budget: these are
microcontrollers, and micromath is the practical maths library. Check your
part’s datasheet for what it offers beyond scalar arithmetic before budgeting
for anything heavier.
On the host. Everything else. Once the stream is Parquet, the normal scientific stack applies, and the question stops being what fits and starts being what is correct.
The useful rule is to push decisions to the device and leave analysis on the host. A device that transmits “someone entered” costs a fraction of the airtime and power of one that transmits every measurement — but you cannot write that detector until you have analysed a great many measurements on a host first.
Summary
In this section we looked at:
- How to read the raw
i8buffer, and why the interleaving order is worth verifying rather than assuming. - The 64-point FFT layout, the 52 usable subcarriers, and the half-swap that puts them in frequency order.
- The four ways raw CSI fails a consumer — missing samples, outliers, hardware-induced phase terms, scale drift — and why the phase terms are the ones that decide whether phase is usable at all.
- PCA, the channel impulse response, and time-frequency analysis as the next layer up.
- Where each kind of processing belongs, given what a microcontroller can afford.
Edge AI & Classification
Classification is where Wi-Fi sensing pays off and where it most often disappoints. The disappointment is usually not the model — it is the framing, the features, or the assumption that a result measured in one room will hold in another.
This section is about getting those three right. It names third-party crates
where they are useful; none of them is part of csi-rs.
Pick the Easiest Task That Solves Your Problem
Sensing tasks form a ladder, and each rung costs substantially more than the one below in data, in model size, and in how badly it degrades when the environment changes.
| Task | What it decides | Difficulty |
|---|---|---|
| Presence detection | Is anyone here? | Easiest; often a threshold on variance |
| Motion detection | Is anything moving? | Easy; a filtered variance over time |
| Occupancy counting | How many people? | Hard; degrades fast beyond two or three |
| Activity recognition | Walking, sitting, falling | Hard; needs labelled data per environment |
| Gesture recognition | Which of a fixed set | Hard; very sensitive to position |
| Vital signs | Breathing and heart rate | Hardest; needs a still subject and phase |
The single most common mistake is reaching for the bottom of that table when the top would do. Presence detection frequently needs no model at all — the variance of filtered amplitude across subcarriers, thresholded against a baseline measured in an empty room, is a genuinely effective detector, and it runs in a CSI callback on a device with no floating-point unit to spare.
Spend the model budget where a threshold demonstrably fails.
Features Beat Architecture
CSI is not an image, and treating it as one wastes most of what you have. The representations that carry the most signal per parameter:
- Amplitude across subcarriers, over a time window. The basic input. Shape
it as
(subcarriers × time). - Variance or standard deviation per subcarrier over a window. Motion raises it; a static environment does not.
- Principal components of the amplitude matrix. The first two or three usually carry the motion, and the reduction from 52 dimensions to 3 is what makes a small model viable.
- Doppler spectrograms from a short-time Fourier transform. Most published activity-recognition results operate on these, because the transform makes motion explicit rather than leaving the model to discover it.
- Phase with the oscillator terms removed, or phase differences between antennas. Powerful, and only after the correction described in Signal Processing in Rust. Raw phase is dominated by hardware offsets and will teach a model nothing but the oscillator.
Two properties of the capture matter as much as the feature choice. Sampling rate bounds what you can see — Doppler from walking needs tens of packets per second, breathing needs a stable rate more than a high one, and nothing recovers detail the packet rate did not carry. And an emitter at a fixed period gives far more uniform sampling than ambient traffic, which is why the controlled pairing is the right arrangement for anything you intend to model.
The Domain Problem
Both earlier chapters flagged environment-specific calibration as the field’s central open problem, and it deserves stating plainly here, because it is the thing most likely to invalidate a result you are pleased with.
A model trained in one room will degrade in another. Often severely. The channel response encodes the room’s geometry, the furniture, the wall materials, and the exact positions of the transmitter and receiver, alongside the human activity you care about — and the model has no way to know which is which. Move a node by a metre and the input distribution shifts.
Mitigations exist and none of them is a solution:
- Train across domains. Widar 3.0 exists for precisely this reason — 258,000 gesture instances across 75 physical domains.
- Choose domain-invariant features. Doppler is less position-dependent than raw amplitude; the CSI ratio between antennas cancels hardware-specific offsets.
- Calibrate on deployment. Record a baseline in the empty environment and normalise against it. Cheap, and effective for presence and motion.
- Fine-tune on site. Powerful when you can collect a little labelled data in the real environment, and often impractical.
The honest framing for a deployment is that some on-site calibration will be required, and designing for it from the start is cheaper than discovering it after a pilot.
Where Inference Runs
On the host is the default and should be. Once the stream is Parquet, the whole Python scientific stack applies — including SenseFi, which exists to benchmark deep models on exactly this data. Prototype here. Always.
On the device is worth it when the constraint is airtime, power, or privacy rather than accuracy. A node that transmits “motion detected” instead of 100 CSI packets a second uses a tiny fraction of the airtime and power, and never puts channel measurements of someone’s home on a network. The ceiling is low — these are microcontrollers with a few hundred kilobytes of RAM — but the tasks at the top of the ladder fit under it comfortably.
For on-device inference in Rust, the honest state of the ecosystem:
microflow— ano_stdinference engine designed for microcontrollers. The most direct fit for this hardware.burn— a full framework with ano_stdstory; heavier, and the right choice if you are also training in Rust.candleandtract— host-side inference.tractis the more practical of the two for running an exported ONNX model in a Rust host application.
Classical methods deserve a fairer hearing than they usually get. A decision tree, an SVM, or a threshold on a well-chosen feature will fit in a callback, needs no framework, and is frequently competitive on the tasks at the top of the ladder. Reach for a neural network when you have measured that you need one.
Datasets to Start From
Rather than collecting from scratch, the datasets introduced in CSI Enabled Literature & Projects are the fastest way to get a baseline:
- MM-Fi — multimodal, aligning CSI with RGB-D, LiDAR, and mmWave. The cross-modal alignment is what makes labelling tractable.
- Widar 3.0 — gesture-focused, and the reference for cross-domain evaluation.
- NTU-Fi — 114 subcarriers per antenna pair, covering activities and gait.
Use them to establish that your pipeline and model are sound. Then collect in your own environment anyway, because of everything in the section above.
Summary
In this section we looked at:
- The ladder of sensing tasks, and why the top rungs often need no model.
- The features that carry the most signal — variance, principal components, Doppler spectrograms, corrected phase — and why raw phase is not among them.
- Domain dependence as the field’s central unsolved problem, and the partial mitigations available.
- Where inference should run, and the Rust crates for each placement.
- The public datasets worth starting from.
Troubleshooting & Calibration
Most CSI problems are not subtle. They are a channel mismatch, an acquisition setting, or a console that cannot carry what the radio captured. This section is ordered by how often each cause actually turns out to be the one, so working through it in order is usually faster than reasoning about it.
The second half covers calibration — the adjustments that make measurements comparable across time, across devices, and across rooms.
Start With the Counters
Before changing anything, find out which half of the system is failing. On a
device running esp-csi-cli-rs:
show-stats
It reports packets transmitted and received, rates in Hz, and drop counts. One distinction resolves most cases:
- The received count is low. The radio is not hearing the traffic. The problem is upstream: channel, band, acquisition configuration, distance, or an emitter that is not transmitting.
- The received count is fine but fewer rows reach you. The radio heard it and the delivery path lost it. The problem is downstream: console bandwidth, the async logging queue, or a filter.
From firmware, the same numbers are available as get_total_rx_packets,
get_pps_rx, get_dropped_packets_rx, and — specifically for the async logging
queue — get_log_packet_drops.
Note that drops include frames rejected by set-csi-filter. That is deliberate:
a filtered frame stays visible as a drop rather than vanishing, so the gap
between captured and delivered is never unexplained.
No CSI at All
Check the channel first. More first-run failures trace to this than to
everything else combined. Every node in a capture set must agree on the primary
channel, and the defaults across this ecosystem do not agree with each other:
esp-csi-cli-rs defaults to channel 1, the esp-csi-rs emitter examples to 7,
and esp-csi-litegui-rs to 1 while expecting an emitter that defaults to 7.
On a C5, check the band. The channel number selects it. The default is 149, which is 5 GHz; a 2.4 GHz AP is simply invisible from there and reports only “no access point found”.
Check that the emitter is actually radiating. An emitter reports no errors
when its frames are accepted by the driver, which is not the same as them
reaching the air. collector_sniffer reports CSI rate per source MAC, so
point a collector at the channel and look for the emitter’s MAC specifically —
that distinguishes “transmitting but unheard” from “not transmitting”. If the
emitter’s MAC never appears, try the associated-link arrangement (softAP
collector plus station) instead, which puts energy in the channel through the
ordinary association path rather than through raw injection.
Check that ambient traffic exists. A sniffer on a quiet channel correctly reports nothing. Move to a channel your own network uses, or add an emitter.
Too Few Packets
If the rate is well below the emitter’s configured frame rate, work down this list:
- Console bandwidth.
textmode emits twenty-odd lines per packet. Switch toarray-listorserializedand re-measure before concluding anything else. This is the most common cause by a wide margin. - Baud rate. Fixed at build time, which is why release artifacts publish it
in
manifest.json. A monitor at the wrong rate produces garbage, not a slow stream — but a low build-time baud produces exactly a slow stream. - Logging queue overflow. Under
async-print, the callback enqueues onto a 32-slot channel.get_log_packet_drops()counts what it could not enqueue. - Filters.
set-csi-filter --peer-macand--min-phy=htare doing their job; confirm they are filtering what you meant. - Emitter period.
--inject-period-ms=20is 50 frames per second, not 500.
If throughput is the binding constraint, the ESP-NOW fast simplex pair sustains a markedly higher rate than any other arrangement, because the collector stops transmitting once it hears a source and all airtime belongs to one transmitter.
The Subcarrier Count Is Wrong
csi_data_len is the most informative single number in a capture, because it
tells you which PHY actually produced the measurement:
| Samples | What it means |
|---|---|
| ~53 | Legacy 20 MHz, L-LTF only |
| ~56 | HT20, HT-LTF |
| ~117–128 | HT40 |
If you configured HT40 and see ~53, HT40 did not engage. In order of likelihood:
- Legacy and ACK acquisition are still on. The default
CsiConfigacquires everything, and those reports arrive at ambient rates and swamp the HT40 ones. The symptom is distinctive: the count sits at ~53 and the CSI rate tracks ambient traffic rather than the emitter’s period. Fix withset-csi --csi-legacy=off --csi-ht20=off --csi-ht40=on --dump-ack=off, oremitter::phy::ht_csi_acquisitionfrom firmware, which does the right thing on every chip. - No room in the band.
Ht40Aboveon channel 7 occupies up to channel 11;Ht40Belowoccupies down to channel 3. A primary too close to the edge silently falls back to 20 MHz. - The collector’s receive path is still 20 MHz. Setting a secondary channel is not the same as widening the interface bandwidth. The library does both together; hand-rolled configuration often does not.
If the count varies packet to packet, nothing is wrong — you are capturing a
mix of formats. Read data_format (the RxCSIFmt classification) per packet
and handle each layout, or filter with --min-phy=ht so only HT frames arrive.
Rows That Look Like Someone Else’s
A collector is promiscuous. It reports CSI for every frame it decodes, including beacons, ACKs, association exchanges, and any third-party device on the channel. Those rows are valid CSI and they look wrong next to yours in two specific ways: the leading field is the frame’s own 802.11 sequence number, which is per-transmitter and per-TID and so neither starts at zero nor shares a counter with your traffic; and legacy-rate frames carry the shorter L-LTF-only payload.
set-csi-filter --peer-mac=<emitter MAC> is the precise fix, --min-phy=ht the
looser one. Filtering on the device is also cheaper than filtering on the host,
because a rejected frame is dropped in the Wi-Fi callback before the packet copy
and before any formatting.
Host-Side Failures
412 Precondition Failed from csi-webserver means the firmware has not
been verified as esp-csi-cli-rs. Call GET /api/devices/{id}/info, then
POST /api/devices/{id}/control/reset if that does not clear it.
503 Service Unavailable means the device is not connected at all — check
the cable and the port.
A Parquet file that will not open was almost certainly not stopped cleanly. The footer is written when the session ends; a crash, a power loss, or an unplugged board leaves the file without one and any unflushed rows lost. Unplugging a board to end a capture is the most common way to lose a dataset.
Device ids that change between runs mean you are relying on port basenames.
Pin them with --device lab1=/dev/ttyUSB0, and key per-device state on the MAC
from info rather than on the path — that is what the MAC is published for.
Decoded frames that are nonsense usually mean a schema mismatch. The
serialized format carries no self-describing tag and there are two
CSIDataPacket schemas, one for the classic parts and one for C5/C6. A decoder
built for the wrong one produces plausible-looking garbage rather than an error.
Calibration
Diagnosis gets the capture working. Calibration makes it comparable.
Amplitude
Automatic gain control moves the entire CSI vector when the receiver changes gain, and that movement is indistinguishable from the environment changing unless you account for it. Three levers:
- Per-packet normalisation. Divide by the vector norm, or by RSSI. Removes the AGC step at the cost of any genuine absolute-power information.
- Manual scaling.
manu_scalewithshifton the classic parts, andval_scale_cfg(0–3, default 2) on the C5 and C6, fix the scaling instead of letting the radio choose. Fixed scaling makes packets comparable; it also means a strong signal can clip and a weak one can quantise badly. Worth it for controlled measurements, not for a survey. - A static baseline. Record the empty environment and normalise against it. This is the most effective calibration available for presence and motion, and the cheapest.
Phase
Everything in Signal Processing in Rust about CFO, SFO, and packet detection delay is a calibration problem. The practical sequence: unwrap along the subcarrier axis, fit and remove a linear trend per packet, and use the antenna ratio where multiple chains are available. Raw phase without this is not a measurement of the room.
Geometry
Node placement dominates results more than any software setting.
- Keep the geometry fixed across a measurement campaign. Moving a node by a metre changes the channel response as much as the activity you are trying to detect.
- Put the subject between the nodes. Line-of-sight obstruction produces far stronger signal than reflection alone.
- Separate the nodes. Boards close together see a dominant direct path that swamps the environmental component.
- Record the geometry with every dataset. A capture without positions is very difficult to reproduce or to compare against later.
Channel Selection
Prefer a quiet channel for controlled emitter/collector work: interference appears as variance you did not cause. In 2.4 GHz, channels 1, 6, and 11 are the non-overlapping set — pick the one your neighbours are not on. On a C5, the 5 GHz band is generally quieter and its wider channels give better delay resolution for impulse-response work.
Characterising, Not Just Fixing
When you need numbers rather than a working capture, the esp-csi-rs repository
carries formal measurement specifications under specs/, with the firmware in
experiments/. They are methodology documents — each defines what is measured,
how, and what counts as a pass — and they are worth reading before designing
your own comparison, because each one exists to avoid a specific unfair
comparison:
packet_drop_rate_test_spec.mdand its ESP-NOW variant. The key insight is that comparing a receive count against an assumed transmit rate is not a loss measurement — any pacing drift or transmitter stall misreports as loss. Carrying an explicit sequence number lets the receiver computedropped / (received + dropped)from what was actually sent.cpu_utilization_test_spec.md— CPU load as a function of offered traffic, separated from the cost of the CSI callback itself.heap_usage_test_spec.md— memory, with rules that make Rust’s static-BSS layout comparable against ESP-IDF’s single DRAM pool.power_consumption_test_spec.md— workload power asactive − idle, so the always-on radio and the USB bridge cancel out.binary_footprint_test_spec.md— flash footprint by role, decomposed by memory section and by crate.
The experiments/README.md in that repository is explicit that these are
characterisation harnesses and not usage examples. Do not start from them.
Summary
In this section we looked at:
- Reading the counters first, to separate a radio problem from a delivery problem.
- Channel and band mismatches as the dominant first-run failure, and the conflicting defaults that cause them.
- Using
csi_data_lento tell which PHY actually produced a measurement, and what to do when HT40 does not engage. - The host-side failure modes: the firmware gate, unfinalised Parquet files, and schema mismatches in the serialized stream.
- Calibrating amplitude, phase, and geometry so measurements stay comparable.
- The measurement specifications available when you need numbers rather than a fix.
Acknowledgements & References
Acknowledgements
csi-rs is built on work we did not do, and it is worth naming.
The Espressif Rust ecosystem. esp-hal, esp-radio, esp-rtos,
esp-println, esp-backtrace, esp-generate, and espflash are the
foundation this project stands on. Every capability in esp-csi-rs is a thin
layer over a driver somebody else maintains, and the no_std story described in
this book would not exist without them. Espressif’s decision to expose CSI
natively in their SDK is, more than any other single event, the reason edge
Wi-Fi sensing became practical.
The embedded Rust community. embassy for the async executor and timers,
heapless for the collections that make a lock-free hot path possible,
postcard and serde for serialization, and defmt from Ferrous Systems for
logging that a microcontroller can actually afford.
The researchers who made CSI extractable at all. The Intel 5300 CSI Tool
(Halperin et al.) and Nexmon CSI (Gringoli et al.) proved the thing was possible
on commodity hardware, years before any vendor supported it. Steven M.
Hernandez’s ESP32-CSI-Tool did the same for Espressif parts and remains the
reference implementation many analysis pipelines were written against —
esp-csi-rs implements its CSV format directly for that reason.
The dataset and benchmark authors. SenseFi, MM-Fi, Widar 3.0, and NTU-Fi are
what make it possible to evaluate a sensing idea before building the hardware
for it, and the NTU MARS Lab’s Awesome-WiFi-CSI-Sensing is how most people
find them.
Contributors to this book and to the crates it documents. Including everyone who has filed an issue describing a capture that did not work — a surprising amount of Troubleshooting & Calibration came from exactly those reports.
References
Consolidated from the citations throughout this book.
Papers
Armenta-Garcia, J. A., Gonzalez-Navarro, F. F., Caro-Gutierrez, J., & Garcia-Reyes, C. I. (2025). Tools and Methods for Achieving Wi-Fi Sensing in Embedded Devices. Sensors, 25(19), 6220. https://doi.org/10.3390/s25196220
Gringoli, F., Schulz, M., Link, J., & Hollick, M. (2019). Free Your CSI: A Channel State Information Extraction Platform For Modern Wi-Fi Devices. Proceedings of the 13th International Workshop on Wireless Network Testbeds, Experimental Evaluation & Characterization. https://github.com/seemoo-lab/nexmon_csi
Halperin, D., Hu, W., Sheth, A., & Wetherall, D. (2011). Tool Release: Gathering 802.11n Traces with Channel State Information. ACM SIGCOMM Computer Communication Review, 41(1), 53. https://dl.acm.org/doi/10.1145/1925861.1925870
Hernandez, S. M., & Bulut, E. (2023). WiFi Sensing on the Edge: Signal Processing Techniques and Challenges for Real-World Systems. IEEE Communications Surveys & Tutorials, 25(1), 46–76. https://doi.org/10.1109/COMST.2022.3209144
Standards
IEEE 802.11bf Task Group. WLAN Sensing. https://www.ieee802.org/11/Reports/tgbf_update.htm
Tools & Datasets
Espressif Systems. ESP-CSI Solution Introduction. ESP Techpedia. https://docs.espressif.com/projects/esp-techpedia/en/latest/esp-friends/solution-introduction/esp-csi/esp-csi-solution.html
Espressif Systems. The Rust on ESP Book. https://docs.espressif.com/projects/rust/book/
Hernandez, S. M. (2020). ESP32-CSI-Tool. https://stevenmhernandez.github.io/ESP32-CSI-Tool/
NTU MARS Lab. (2025). Awesome WiFi CSI Sensing. https://github.com/NTUMARS/Awesome-WiFi-CSI-Sensing
csi-rs Crates and Repositories
| Crate / repository | Role |
|---|---|
| esp-csi-rs | The collection library (Tier 0) |
| esp-csi-cli-rs | Ready-made CLI firmware (Tier 1) |
| esp-csi-litetui-rs | Handheld touchscreen scope (Tier 1) |
| esp-csi-litegui-rs | Live AMOLED heatmap (Tier 1) |
| csi-webserver-rs | Host-side HTTP/WebSocket server (Tier 2) |
| csi-webserver-core-rs | The embeddable server library |
| csi-webclient-rs | Desktop client (Tier 2) |
License
The csi-rs crates are dual-licensed under MIT or Apache-2.0, at your option.
FAQ
Hardware
Which board should I buy?
An ESP32-C6 if you have no other constraint. It is RISC-V (so it builds on stable Rust), it has native USB-Serial-JTAG, and it is verified as an emitter. An ESP32-C5 is the same answer plus dual-band 2.4/5 GHz, which is worth having if you care about wider channels or better delay resolution.
How many boards do I need?
One is enough to sniff ambient traffic and confirm everything works. Two is what you actually want, because the controlled emitter/collector pairing — one board sounding the channel at a known rate, one measuring it — is the basis of almost all sensing work.
Which chips can act as an emitter?
Every supported chip can collect. Emitter support varies by part and by the
esp-radio version underneath, so check the current status in the
esp-csi-rs repository before buying
for that purpose. If raw injection is not an option on the hardware you have,
the associated-link arrangement — a softAP collector paired with a station —
puts energy in the channel without it.
Do I need special antennas or RF equipment?
No. Development board antennas are fine. Node placement affects results far more than antenna quality — see Troubleshooting & Calibration.
Getting Data
I flashed the firmware and see nothing.
In order: is the channel right (defaults across this ecosystem disagree — 1 in
some places, 7 in others)? On a C5, is the channel in the band you meant (the
default 149 is 5 GHz)? Is there any traffic on that channel at all? Is the baud
rate right? Then run show-stats and see whether the radio received anything.
I get far fewer packets than the emitter is sending.
Almost always the console, not the radio. text mode emits twenty-odd lines per
packet; switch to array-list or serialized and measure again. show-stats
distinguishes the two cases: a healthy receive count with few delivered rows
means the delivery path is the limit.
My subcarrier count is stuck at ~53 even though I configured HT40.
The default CSI configuration acquires legacy and ACK frames too, and those
arrive at ambient rates and swamp the HT40 reports. Turn them off:
set-csi --csi-legacy=off --csi-ht20=off --csi-ht40=on --dump-ack=off. Also
check that the 40 MHz block fits in the band — above on channel 7 reaches
channel 11.
Some rows have sequence numbers that make no sense.
Those are other people’s frames. A collector is promiscuous and reports CSI for
every frame it decodes, and the leading field is each frame’s own 802.11
sequence number, which is per-transmitter. Filter with
set-csi-filter --peer-mac=<emitter> or --min-phy=ht.
Naming and Versions
What happened to set-collection-mode?
It became set-csi-output --enabled=<true|false> (and POST /config/csi-output
over HTTP). Once “collector” became the name of the receive role, it could no
longer also name a delivery setting. The behaviour is unchanged: capture keeps
running, delivery stops.
What happened to esp-csi-rs-core?
It existed only between 0.9.0 and 0.10.0, and the split has been undone —
everything is back in esp-csi-rs. The core crate stays published and is
not yanked, because esp-csi-rs 0.9.0 depends on it, but it receives no
further versions. Change esp_csi_rs_core:: to esp_csi_rs::; the paths are
otherwise identical.
Is it csi-webserver or csi-webserver-rs?
The repository is csi-webserver-rs; the published crate is csi-webserver.
Same for the client. The esp- prefix is reserved for crates that run on an
Espressif part, which is why there is no esp-csi-webserver-rs — that name was
never published.
Why isn’t esp-csi-cli-rs on crates.io?
It is firmware, not a library. Get it from the GitHub releases as a prebuilt
per-chip .bin, or build it from source. The same is true of
esp-csi-litetui-rs and esp-csi-litegui-rs.
Did ESP-NOW get removed?
No. Some front-end READMEs say so, and they are out of step: esp-csi-rs 0.10.1
retains the ESP-NOW central and peripheral topologies, and esp-csi-cli-rs
still exposes four ESP-NOW modes. What is true is that the open webserver
baseline names only five Wi-Fi modes and does not route the ESP-NOW ones.
Choices
2.4 GHz or 5 GHz?
Only the C5 gives you the choice. 5 GHz has wider channels (better delay resolution for impulse-response work) and is usually less congested; 2.4 GHz propagates further and through more walls. The two bands see the same room differently, which is itself sometimes the point.
Which log mode should I use?
text when a human is reading. array-list when you want one parseable line
per packet. serialized when a program is consuming it — it is the densest, and
it is what the host tools decode. esp-csi-tool when you have an existing
analysis script written against ESP32-CSI-Tool.
Callback or async delivery?
Callback if the work fits inside the Wi-Fi hot path — no heap, no locks, no I/O,
no .await. Async if it does not; it costs a ~640-byte copy per packet and buys
you a normal task to work in. Exactly one is active at a time, and
set_csi_delivery_mode switches between them at runtime.
defmt or println?
println to start with: any serial monitor reads it. defmt when console
bandwidth is the constraint — it is much denser on the wire, at the cost of
needing espflash --log-format defmt to decode and three extra lines of project
setup. Note that defmt shrinks each line; it does not make the radio capture
more.
Doing Something With It
Why does my phase data look like noise?
Because the receiver’s own clock is in it. The transmitter and receiver oscillators are free-running and not locked to each other, so every packet is measured against a slightly different reference. Three effects follow:
- Phase-locked loop jitter — the receiver’s PLL does not reacquire to the same phase on each packet, so every measurement carries a different constant offset.
- Carrier frequency offset — the oscillators differ in frequency, so the error accumulates as phase between packets.
- Sampling frequency offset and packet detection delay — timing errors that tilt phase across subcarriers, by a different amount in each packet.
None of these are properties of the room, and together they are larger than the signal you are looking for. Raw phase is therefore genuinely not a measurement of the environment until those terms are accounted for — see Signal Processing in Rust for the standard ways of doing that.
My model works in one room and fails in another.
That is the field’s central open problem, not a bug in your pipeline. The channel response encodes the room’s geometry alongside the activity, and the model cannot tell them apart. Partial mitigations — training across domains, choosing position-invariant features such as Doppler, calibrating against an empty-room baseline on deployment — are discussed in Edge AI & Classification.
Can I run inference on the device?
For the easy tasks, yes, and it is often the right call: a node that transmits “motion detected” instead of a CSI stream saves airtime, power, and privacy. Presence detection frequently needs no model at all — a variance threshold against a baseline works. For anything harder, prototype on a host first.
My Parquet file will not open.
It was not stopped cleanly. The footer is written when the session ends;
unplugging a board to end a capture leaves the file without one. Use stop.