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