Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. NLOS Capability: RF signals penetrate common building materials, allowing for non-line-of-sight operations.
  2. Device-Free: Targets do not need to carry or wear transceivers or peripherals.
  3. 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.


  1. IEEE 802.11bf Task Group Report URL

  2. 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.

  1. 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

  2. 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:

  1. The Intel 5300 CSI Tool: Required specific PCIe desktop/laptop-class NICs running modified Linux kernels. 1
  2. 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.


  1. 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

  2. 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

  3. 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.

  1. 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

  2. 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

  3. NTU MARS Lab. (2025). Awesome WiFi Sensing. GitHub Repository. https://github.com/NTUMARS/Awesome-WiFi-CSI-Sensing

  4. 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 .textTotal image
ESP-IDF C passive594 KiB849 KiB
Rust no_std sniffer487 KiB634 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_std Rust 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.


  1. esp-csi-rs, Binary size comparison: ESP-IDF passive vs. Rust no_std sniffer_wifi. specs/SIZE_DIFF_passive_vs_sniffer_wifi_exper.md in 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 pathHow it gets frames
SnifferLocks a channel in promiscuous mode and measures every frame overheard
StationAssociates to an AP or commercial router and measures what it receives
Access PointRuns 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 values collector and listener. 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 is CSINode::set_csi_output_enabled, in the CLI it is set-csi-output, and over HTTP it is POST /config/csi-output. If you find collection-mode or --mode=listener in 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-modecsi-output rename, 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.


  1. 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-rs and requires no embedded Rust at all.

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

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

Setting Up a Project

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

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

If you have not built for an ESP part in Rust before, work through The Rust on ESP Book first. It covers the toolchain, the std and no_std split, and flashing — all of which this chapter assumes.

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

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

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

Feature Flags

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

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

Two of these are worth expanding on.

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

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

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

Node Roles

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

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

A collector’s capture path is chosen with CollectorMode:

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

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

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

CSI Configuration

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

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

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

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

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

Bandwidth

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

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

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

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

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

Building a Node

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

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

const CHANNEL: u8 = 7;

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

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

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

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

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

use embassy_time::Duration;
use esp_csi_rs::{CSINode, EmitterConfig, HtBandwidth, NodeHardware};

let hardware = NodeHardware::new(&mut interfaces, controller);

// 20 ms between frames is roughly 50 sounding frames per second.
let emitter = EmitterConfig::new(7, HtBandwidth::Ht20)
    .with_period(Duration::from_millis(20));
let mut node = CSINode::new_emitter(emitter, hardware);

node.run().await;

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

Getting the Data Out

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

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

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

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

set_csi_callback(on_csi);

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

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

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

Examples

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

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

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

A Note on esp-csi-rs-core

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

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

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

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

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

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

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:

  1. The four logging modes, which decide what a running node prints.
  2. The ESP-NOW wire format, used when two nodes coordinate directly.
  3. 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.

ModeShapeUse it for
TextMany key: value lines per packetReading on a serial terminal
SerializedOne COBS-framed postcard recordHost ingest — the densest option
ArrayListOne JSON-array-shaped lineParsing without a schema; compact but readable
EspCsiTool26-column CSVDrop-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 rxmatch3rxmatch0 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_len appears twice in Text and in ArrayList — 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 0x00 byte delimits each record; the encoded body never contains 0x00, 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 raw i8 bytes.
  • 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:

IndexFieldDescription
0sequence_numberSequence number of the frame that triggered the capture
1rssiReceived signal strength (dBm)
2ratePHY rate encoding (valid for non-HT 802.11b/g frames)
3noise_floorNoise floor of the RF module (dBm)
4channelPrimary channel the frame arrived on
5timestampLocal receive timestamp (microseconds)
6sig_lenFrame length including FCS
7rx_state0 = no error, non-zero = error code
8secondary_channel0 = none, 1 = above, 2 = below
9sgi0 = long guard interval, 1 = short
10antennaAntenna number
11ampdu_cntSubframes aggregated in the AMPDU
12sig_mode0 = non-HT (11b/g), 1 = HT (11n), 3 = VHT (11ac)
13mcsModulation and coding scheme; 0–76 for HT frames
14bandwidth0 = 20 MHz, 1 = 40 MHz
15smoothing0 = unsmoothed, 1 = smoothing recommended
16not_sounding0 = sounding PPDU, 1 = not a sounding PPDU
17aggregation0 = MPDU, 1 = AMPDU
18stbc0 = non-STBC, 1 = STBC
19fec_codingLDPC flag; set for 11n LDPC frames
20sig_lenFrame length including FCS (repeated)
21csi_data_lenNumber 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.

FeatureOutput path
printlnesp_println::println! via the board’s default backend
defmtGlobal defmt logger, framed by esp-println’s defmt-espflash
uartDirect UART0 at the build-time baud rate
jtag-serialUSB-Serial-JTAG (not available on the original ESP32)
autoJTAG if a USB start-of-frame is detected, otherwise UART
no-printDiscards output
async-printInserts 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:

  • version is cosmetic and bumps with releases.
  • protocol is the wire-format version. Host tooling should refuse protocol values it does not understand rather than guess. The info grammar is stable within a protocol value; adding keys requires a bump.
  • features is informational and unordered. The presence of statistics tells the host whether show-stats exists.
  • mac is the stable device key. Pin per-device state to it rather than to the /dev/ttyACM* path, so a restart or USB re-enumeration rebinds to the same physical board.

Summary

In this section we looked at:

  • The four logging modes — Text, Serialized, ArrayList, and EspCsiTool — and what each is for.
  • The two CSIDataPacket schemas, and why a Serialized decoder must be built against the right one.
  • The ESP-NOW ControlPacket / PeripheralPacket exchange 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

CommandPurposeApplies
help [command]Help text, per command
set-wifiMode, credentials, channel, peer, emitterNext start
set-csiCSI acquisition flags (varies by chip)Next start
set-trafficTraffic generator frequencyNext start
set-csi-outputDeliver captured CSI, or capture silentlyNext start
set-csi-filterRestrict which frames are deliveredNext start
set-log-modeOutput formatImmediate
set-csi-deliveryDelivery mode and the inline log gateImmediate
set-protocolWi-Fi PHY protocolNext start
set-io-tasksToggle the TX and RX direction tasksNext start
set-rateRecord a PHY rateSee below
startBegin collection
show-configPrint the staged configuration
show-statsRuntime counter snapshot
reset-configRestore defaultsNext start
restartSoftware resetImmediate
infoFirmware 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:

ModeRole
sniffer (default)Collector, promiscuous capture path
stationCollector, associated to an AP
wifi-apCollector, self-contained softAP
ht20-emitterEmitter at 20 MHz
ht40-emitterEmitter at 40 MHz
esp-now-centralESP-NOW initiator
esp-now-peripheralESP-NOW responder
esp-now-fast-collectorAsymmetric ESP-NOW, receive side
esp-now-fast-sourceAsymmetric ESP-NOW, transmit side

Supporting flags:

  • --sta-ssid, --sta-password — station credentials.
  • --ap-ssid (default esp-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=on instead sends one frame back-to-back to every station each tick, for time-aligned multi-receiver CSI. Burst airtime is frequency-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 for ht40-emitter.
  • --inject-period-ms=<ms> — emitter frame period, default 20 ms.
  • --emitter-iface=<sta|ap> — which interface injects, default sta.

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 is array-list.
  • set-csi-output --enabled=<true|false> is the master delivery gate. With false the radio keeps capturing and its timing is unchanged, but nothing is decoded, logged, or handed onward. This is the command that replaced the old set-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:

  • defmt does 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.
  • serialized is the densest format and the one the host tools use. Reach for text when a human is reading and for esp-csi-tool when 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.

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:

  1. Spectrum — amplitude against subcarrier index.
  2. Phase — unwrapped phase in radians against subcarrier index.
  3. Waterfall — an amplitude heatmap over time and subcarrier.
  4. Signal — RSSI and SNR (rssi − noise_floor) trended over time.
  5. Stats — live esp-csi-rs counters (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.

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.

FeatureCapture path
mode-snf (default)Promiscuous capture on a locked channel; no peer configuration
mode-staAssociate to an AP and capture CSI from that link
mode-apsoftAP (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_emitter and ht40_emitter examples 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.

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 simply csi-webserver. Earlier drafts of this book called it esp-csi-webserver-rs; that name was never published. The esp- 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
OptionDefaultPurpose
--interface <ADDR>0.0.0.0Bind address
--port <PORT>3000TCP port
--baud-rate <RATE>115200Serial baud (env CSI_BAUD_RATE)
--device <ALIAS=PORT_OR_MAC>Stable device id override, repeatable
--scan-interval-ms <MS>2000Hotplug 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.

MethodPathPurpose
GET/Health
GET/api/devicesList attached devices and their status
GET/api/devices/{id}/infoFirmware identification; refreshes cache
GET/api/devices/{id}/configRead the cached configuration
POST/api/devices/{id}/config/resetRestore firmware defaults
POST/api/devices/{id}/config/wifiMode, credentials, channel, peer, emitter
POST/api/devices/{id}/config/trafficTraffic generator frequency
POST/api/devices/{id}/config/csiCSI acquisition flags
POST/api/devices/{id}/config/csi-outputMaster delivery gate
POST/api/devices/{id}/config/output-modestream, dump, or both
POST/api/devices/{id}/config/ratePHY rate
POST/api/devices/{id}/config/protocolWi-Fi PHY protocol
POST/api/devices/{id}/config/io-tasksToggle TX / RX tasks
POST/api/devices/{id}/config/csi-deliveryDelivery mode and inline log gate
GET/api/devices/{id}/control/statusRuntime status
POST/api/devices/{id}/control/startBegin collection
POST/api/devices/{id}/control/stopEnd collection
POST/api/devices/{id}/control/resetReset the chip and re-verify
POST/api/devices/{id}/control/statsRuntime counter snapshot
GET/api/devices/{id}/wsPer-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:

ModeWebSocketParquet dump
stream (default)yesno
dumpnoyes
bothyesyes

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 to stream, 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.

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.md is 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

ExportPurpose
AppState, DeviceRegistry, DeviceHandle, DeviceAttachSpecShared runtime state
ServerConfig, build_router, serveHTTP server
SupervisorConfig, run_supervisor, detect_esp_ports, probe_portHotplug discovery
modelsJSON request/response types and CLI command mappers
csiCOBS + postcard frame decoder
routesAxum handlers, for custom routers
serial, parquet_sinkThe lower-level pipelines
CsiProfile, StandardCsiProfileThe 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.

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-rs while the published crate is csi-webclient. The esp-csi-webclient-rs spelling 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

TabWhat it is for
DevicesFleet overview, per-device start/stop, refresh, and the event log
DashboardPer-device status, firmware identification, and stream counters
ConfigEvery per-device configuration endpoint, as a form
ControlStart and stop collection; connect and disconnect the WebSocket
StreamFrame 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.

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:

  1. 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.
  2. 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.
  3. 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.
  4. Streaming to a Host — putting csi-webserver and csi-webclient in front of the boards, and recording a session to Parquet.
  5. Viewing On-Device — the paths that need no host at all, using esp-csi-litetui-rs and esp-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.

ChipBandNotes
ESP32-C62.4 GHzRISC-V, native USB-Serial-JTAG
ESP32-C52.4 / 5 GHzThe only dual-band part
ESP32-C32.4 GHzThe cheapest of the supported parts
ESP32-S32.4 GHzXtensa; needs espup. Has vector SIMD
ESP322.4 GHzXtensa; 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 std and no_std split, 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:

  1. Initialise the hardware and the RTOS.
  2. Initialise the logger, choosing an output format.
  3. Build a NodeHardware from the radio interfaces and controller.
  4. Build a CSINode describing the role.
  5. 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:

  1. Legacy and ACK acquisition still on. Those reports arrive at ambient rates and swamp the HT40 ones. Apply the set-csi line above.
  2. No room in the band. --ht40=above on channel 7 occupies up to channel 11 and --ht40=below occupies down to channel 3. A primary too close to the band edge silently falls back.
  3. Channel mismatch. The two boards are not on the same primary channel.

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/wifi route 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’s CsiProfile::extra_wifi_modes or over the serial console directly. And config/csi-output is 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"}'
ModeWebSocketParquet dump
stream (default)yesno
dump/ws returns 403yes
bothyesyes

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 to stream, 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.parquet next 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:

  1. Spectrum — amplitude against subcarrier index.
  2. Phase — unwrapped phase in radians against subcarrier index.
  3. Waterfall — an amplitude heatmap over time and subcarrier.
  4. Signal — RSSI and SNR (rssi − noise_floor) trended over time.
  5. 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-rs removed 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-rs 0.10.1 retains the ESP-NOW topologies and esp-csi-cli-rs still 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.

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(&amplitude[6..32]);
valid[26..52].copy_from_slice(&amplitude[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. atan2 returns 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 i8 buffer, 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.

TaskWhat it decidesDifficulty
Presence detectionIs anyone here?Easiest; often a threshold on variance
Motion detectionIs anything moving?Easy; a filtered variance over time
Occupancy countingHow many people?Hard; degrades fast beyond two or three
Activity recognitionWalking, sitting, fallingHard; needs labelled data per environment
Gesture recognitionWhich of a fixed setHard; very sensitive to position
Vital signsBreathing and heart rateHardest; 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 — a no_std inference engine designed for microcontrollers. The most direct fit for this hardware.
  • burn — a full framework with a no_std story; heavier, and the right choice if you are also training in Rust.
  • candle and tract — host-side inference. tract is 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:

  1. Console bandwidth. text mode emits twenty-odd lines per packet. Switch to array-list or serialized and re-measure before concluding anything else. This is the most common cause by a wide margin.
  2. 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.
  3. Logging queue overflow. Under async-print, the callback enqueues onto a 32-slot channel. get_log_packet_drops() counts what it could not enqueue.
  4. Filters. set-csi-filter --peer-mac and --min-phy=ht are doing their job; confirm they are filtering what you meant.
  5. Emitter period. --inject-period-ms=20 is 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:

SamplesWhat it means
~53Legacy 20 MHz, L-LTF only
~56HT20, HT-LTF
~117–128HT40

If you configured HT40 and see ~53, HT40 did not engage. In order of likelihood:

  1. Legacy and ACK acquisition are still on. The default CsiConfig acquires 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 with set-csi --csi-legacy=off --csi-ht20=off --csi-ht40=on --dump-ack=off, or emitter::phy::ht_csi_acquisition from firmware, which does the right thing on every chip.
  2. No room in the band. Ht40Above on channel 7 occupies up to channel 11; Ht40Below occupies down to channel 3. A primary too close to the edge silently falls back to 20 MHz.
  3. 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_scale with shift on the classic parts, and val_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.md and 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 compute dropped / (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 as active − 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_len to 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 / repositoryRole
esp-csi-rsThe collection library (Tier 0)
esp-csi-cli-rsReady-made CLI firmware (Tier 1)
esp-csi-litetui-rsHandheld touchscreen scope (Tier 1)
esp-csi-litegui-rsLive AMOLED heatmap (Tier 1)
csi-webserver-rsHost-side HTTP/WebSocket server (Tier 2)
csi-webserver-core-rsThe embeddable server library
csi-webclient-rsDesktop 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.