CSI Data Formats
Every tool in this ecosystem eventually agrees on the same thing: the bytes that carry one CSI measurement from a device to whatever is going to look at it. This section documents those bytes.
It is not a crate. Earlier drafts of this book reserved a csi-protocol chapter
for a crate that was never published, on the assumption that the formats would
need a library of their own. They did not — the formats are defined by
esp-csi-rs and consumed directly by the host tools, so this is a specification
chapter rather than an API one.
Three things are specified here:
- The four logging modes, which decide what a running node prints.
- The ESP-NOW wire format, used when two nodes coordinate directly.
- The firmware identification contract, which is how a host tool recognises a board before it tries to talk to it.
Logging Modes
LogMode selects how a captured CSIDataPacket is rendered to the transport.
The setting is global: init_logger(spawner, mode) establishes it, and
set_log_mode(mode) changes it at runtime.
| Mode | Shape | Use it for |
|---|---|---|
Text | Many key: value lines per packet | Reading on a serial terminal |
Serialized | One COBS-framed postcard record | Host ingest — the densest option |
ArrayList | One JSON-array-shaped line | Parsing without a schema; compact but readable |
EspCsiTool | 26-column CSV | Drop-in compatibility with ESP32-CSI-Tool |
One structural fact governs all four: CSIDataPacket has two shapes. The
classic parts (ESP32, C3, S3) carry the 802.11n-era metadata — sig_mode,
mcs, bandwidth, stbc, and so on. The newer parts (C5, C6) carry a
redesigned driver field set — dump_len, cur_bb_format, the channel-estimate
fields, and the rxmatch flags. Which fields appear is a property of the chip,
not of the mode.
LogMode::Text
One packet becomes a run of key: value lines, \r\n-terminated, ending with a
csi raw data: [...] array. It is meant for eyeballing, not for parsing.
mac: 56:6C:EB:6F:BC:3D
sequence number: 426
rssi: -82
rate: 11
noise floor: 165
channel: 1
timestamp: 2424915
sig len: 332
rx state: 0
...
data length: 128
csi raw data: [0, 0, 0, 0, -6, 0, 6, 0, -24, 10, -23, 9, ...]
The block between rx state and the trailing sig_len / data length pair is
the chip-dependent part. On classic parts it is secondary channel, sgi,
ant, ampdu cnt, sig_mode, mcs, cwb, smoothing, not sounding,
aggregation, stbc, fec coding. On C5/C6 it is dump len, sigb len (C6
only), cur single mpdu (C6 only), cur bb format,
rx channel estimate info vld, rx channel estimate len, time seconds,
channel, is group, rxend state, and the rxmatch3–rxmatch0 flags.
A Recieved at YYYY-MM-DD HH:MM:SS.mmm header precedes the record when an NTP
DateTime is attached to the packet. The misspelling is the real on-wire
string; if you are writing a parser, match what the firmware emits.
sig_lenappears twice inTextand inArrayList— once in the metadata block and again immediately before the sample count. This is a deliberate carry-over from the original field layout, not a bug in your parser.
LogMode::Serialized
The binary path, and the right default for host ingest. Each packet becomes one COBS-framed postcard record, with no other framing and no text interleaved:
- Encoded with
postcard::to_slice_cobs(&CSIDataPacket, &mut buf). - A single
0x00byte delimits each record; the encoded body never contains0x00, which is what makes resynchronisation after a dropped byte trivial. - Field order on the wire is the declaration order of
CSIDataPacket. The CSI sample vector is a postcard varint length followed by that many rawi8bytes. Option<DateTime>is one discriminant byte (0= none,1= present) followed by the value when present.
The critical caveat: there is no self-describing tag on the wire, and there are two schemas. A decoder built for the classic layout will happily produce nonsense from a C6 stream. Decoders must be built with matching feature flags, or with a schema chosen from the device’s reported chip.
Under defmt the situation is slightly different, because defmt is itself a
framed log channel and raw bytes cannot be streamed through it. Each record is
emitted as a single defmt::println!("{=[u8]}", cobs_slice) frame instead, so
the host decodes the defmt frame first and COBS-decodes the payload second.
LogMode::ArrayList
One line per packet, shaped like a JSON array, terminated ]]\r\n. Compact
enough to stream and structured enough to parse with a split on commas:
[3916,-93,11,157,1,1815804,256,0,260,2,0,1,1,128,0,1,1,0,1,0,0,0,256,128,[...]]
On classic parts the fields are, in order:
| Index | Field | Description |
|---|---|---|
| 0 | sequence_number | Sequence number of the frame that triggered the capture |
| 1 | rssi | Received signal strength (dBm) |
| 2 | rate | PHY rate encoding (valid for non-HT 802.11b/g frames) |
| 3 | noise_floor | Noise floor of the RF module (dBm) |
| 4 | channel | Primary channel the frame arrived on |
| 5 | timestamp | Local receive timestamp (microseconds) |
| 6 | sig_len | Frame length including FCS |
| 7 | rx_state | 0 = no error, non-zero = error code |
| 8 | secondary_channel | 0 = none, 1 = above, 2 = below |
| 9 | sgi | 0 = long guard interval, 1 = short |
| 10 | antenna | Antenna number |
| 11 | ampdu_cnt | Subframes aggregated in the AMPDU |
| 12 | sig_mode | 0 = non-HT (11b/g), 1 = HT (11n), 3 = VHT (11ac) |
| 13 | mcs | Modulation and coding scheme; 0–76 for HT frames |
| 14 | bandwidth | 0 = 20 MHz, 1 = 40 MHz |
| 15 | smoothing | 0 = unsmoothed, 1 = smoothing recommended |
| 16 | not_sounding | 0 = sounding PPDU, 1 = not a sounding PPDU |
| 17 | aggregation | 0 = MPDU, 1 = AMPDU |
| 18 | stbc | 0 = non-STBC, 1 = STBC |
| 19 | fec_coding | LDPC flag; set for 11n LDPC frames |
| 20 | sig_len | Frame length including FCS (repeated) |
| 21 | csi_data_len | Number of raw i8 samples that follow |
| 22 | [csi_data] | Inner array of raw i8 samples |
On C5/C6 indices 8–19 are replaced by that family’s metadata block, in struct order, while indices 0–7 and the trailing three entries are unchanged.
Integers are plain decimal with no padding. Sample values are comma-separated with no spaces and no trailing comma.
LogMode::EspCsiTool
Drop-in compatible with the capture format of the C-based
ESP32-CSI-Tool, so
existing analysis scripts written against that tool work unchanged. A session
prints one header line, then one CSI_DATA,... row per packet, in 26 columns:
type,role,mac,rssi,rate,sig_mode,mcs,bandwidth,smoothing,not_sounding,
aggregation,stbc,fec_coding,sgi,noise_floor,ampdu_cnt,channel,
secondary_channel,local_timestamp,ant,sig_len,rx_state,real_time_set,
real_timestamp,len,CSI_DATA
Column 2 (role) is STA, AP, or PASSIVE, set with set_role(Role).
Column 26 is the sample array, space-separated i8 values inside [ ].
Two details matter in practice. On C5 and C6, columns 6–14, 16, 18 and 20 are
emitted as literal 0 — the upstream schema has no equivalent for those
parts’ metadata, so it is discarded. If you need those fields on a C5 or C6, use
Text, ArrayList, or Serialized.
And there is an emit cap: set_csi_tool_emit_cap(u16) truncates the sample
array, with column 25 always reporting the number actually emitted. Setting it
to 128 mirrors the upstream tool’s CONFIG_SHOULD_COLLECT_ONLY_LLTF=128 and
holds every line to roughly 475 bytes regardless of the captured PHY — which is
what lets sniffer mode reach the same UART-bound packet rate as a peripheral.
Transport Is a Separate Axis
LogMode decides what bytes; feature flags decide which wire. All four
modes work over every transport.
| Feature | Output path |
|---|---|
println | esp_println::println! via the board’s default backend |
defmt | Global defmt logger, framed by esp-println’s defmt-espflash |
uart | Direct UART0 at the build-time baud rate |
jtag-serial | USB-Serial-JTAG (not available on the original ESP32) |
auto | JTAG if a USB start-of-frame is detected, otherwise UART |
no-print | Discards output |
async-print | Inserts a bounded queue and a drain task between callback and wire |
The sync path formats and writes inside the Wi-Fi receive callback. It has the
lower memory cost, and the write latency sits on the callback’s critical path —
which is precisely why it is the path that reaches the UART ceiling. The async
path enqueues the packet (capacity 32) and lets a drain task format and write
it, keeping the callback short at the cost of around 20 KiB of static memory.
When the queue overflows, packets are dropped and counted:
get_log_packet_drops() reports how many, which is the first number to check
when a capture looks thinner than the emitter’s configured rate.
The ESP-NOW Wire Format
When two nodes coordinate as an ESP-NOW pair rather than via an access point, they exchange two small serde-serialized structures.
ControlPacket travels from central to peripheral:
pub struct ControlPacket {
/// Whether the central is currently collecting; the peripheral mirrors
/// this flag to keep the pair in sync.
pub is_collector: bool,
/// Monotonic sequence number for drop and reorder detection. Present only
/// under the `statistics` feature, to keep the frame small.
#[cfg(feature = "statistics")]
pub sequence_number: u32,
}
PeripheralPacket travels the other way as a presence beacon.
Framing depends on the pairing mode. In auto-pairing mode each frame is
prefixed with a four-byte little-endian magic number — 0xA8912BF0 from the
central, its bitwise complement from the peripheral — so an unrelated ESP-NOW
frame on the same channel is rejected cheaply. In manual-pairing mode no
magic is sent and the source-MAC filter is the discriminator instead.
Both ends must agree on the pairing mode and on whether the statistics
feature is enabled, since that feature changes the frame layout. A mismatch
presents as frames that arrive but never parse.
The Firmware Identification Contract
Host tooling needs to know what it is talking to before it sends a command, and
a USB serial port tells it almost nothing. esp-csi-cli-rs therefore emits a
magic prefix on two surfaces.
Passively, the first line of the welcome banner after every reset is the magic line, followed immediately by the device’s MAC:
ESP-CSI-CLI/0.7.0
mac=D0:CF:13:E2:90:E8
******* Welcome to the CSI Collection CLI utility! *******
A host can match the first non-bootloader line against
^ESP-CSI-CLI/\d+\.\d+\.\d+$ with no command round-trip at all.
Actively, the info command returns the same prefix plus a key=value body
terminated by END-INFO, carrying name, version, chip, protocol,
baud, and features.
The versioning rules are worth internalising if you are writing a host tool:
versionis cosmetic and bumps with releases.protocolis the wire-format version. Host tooling should refuse protocol values it does not understand rather than guess. Theinfogrammar is stable within a protocol value; adding keys requires a bump.featuresis informational and unordered. The presence ofstatisticstells the host whethershow-statsexists.macis the stable device key. Pin per-device state to it rather than to the/dev/ttyACM*path, so arestartor USB re-enumeration rebinds to the same physical board.
Summary
In this section we looked at:
- The four logging modes —
Text,Serialized,ArrayList, andEspCsiTool— and what each is for. - The two
CSIDataPacketschemas, and why aSerializeddecoder must be built against the right one. - The ESP-NOW
ControlPacket/PeripheralPacketexchange and its magic-prefix framing. - The
ESP-CSI-CLI/<version>identification contract that every host tool in the next few sections relies on.