esp_csi_rs/wire/mod.rs
1//! The CSI wire contract: what a node emits, independent of the radio that measured it.
2//!
3//! Every frame on the serialized stream is an [`Envelope`] followed by a [`Body`], postcard-encoded
4//! back to back inside one COBS frame. The envelope comes first so a decoder can read the format
5//! version and the source **before** it commits to decoding the body — postcard is not
6//! self-describing, so a decoder that guessed the layout would mis-read a newer body silently
7//! instead of rejecting it.
8//!
9//! The contract is deliberately radio-neutral. ESP's receive-control struct used to *be* the data
10//! contract; here it is one [`SourceKind`] among several. Its normalised fields live in [`RxMeta`],
11//! whatever is specific to one chip family lives in [`VendorRx`], and the CSI itself is a tagged
12//! [`CsiPayload`] that records the layout it was captured in rather than leaving the host to guess
13//! it from the chip name. An IEEE 802.11bf source, or a synthetic one, fills the same types.
14//!
15//! **This module has no `esp-*` imports**, only `serde`, `heapless` and `postcard`, so host tooling
16//! can depend on the identical definitions instead of mirroring them by hand. Keep it that way.
17
18use heapless::Vec;
19use postcard::experimental::max_size::MaxSize;
20use serde::{Deserialize, Serialize};
21
22pub mod layout;
23pub mod setup;
24#[cfg(feature = "synthetic")]
25pub mod synthetic;
26pub use layout::LayoutId;
27pub use setup::{AcquisitionFilter, MeasurementSetup, PpduSet, ReportType, SetupError, StimulusParams};
28
29/// Version of the wire format described by this module.
30///
31/// Bump it on any change that alters the encoding of [`Envelope`] or [`Body`]: a field added,
32/// removed or reordered anywhere inside them, or a variant inserted before an existing one. Adding a
33/// variant at the **end** of a `#[non_exhaustive]` enum does not need a bump — an older decoder
34/// rejects that frame cleanly instead of mis-reading it.
35pub const WIRE_VERSION: u8 = 1;
36
37/// Largest raw CSI buffer any supported chip produces, in bytes.
38pub const MAX_CSI_BYTES: usize = 612;
39
40/// Who emitted a frame, when, and from which measurement source. Encoded ahead of every [`Body`].
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
42#[non_exhaustive]
43pub struct Envelope {
44 /// [`WIRE_VERSION`] of the encoder.
45 pub version: u8,
46 /// Identity of the node that delivered the frame: its base MAC address.
47 pub node_id: [u8; 6],
48 /// The measurement session this frame belongs to. Set by the controller with
49 /// `set_session`, or drawn at random when a run starts without one.
50 pub session_id: u32,
51 /// The kind of source that produced the measurement.
52 pub source: SourceKind,
53 /// Per-source frame counter assigned by the delivering node, incremented once per frame it
54 /// emits. A gap is a frame lost between capture and the host. Unlike an 802.11 sequence
55 /// number it never repeats on a retry and does not depend on who transmitted the sounding.
56 pub stream_seq: u32,
57}
58
59impl Envelope {
60 /// An envelope at the current [`WIRE_VERSION`].
61 pub const fn new(node_id: [u8; 6], session_id: u32, source: SourceKind, stream_seq: u32) -> Self {
62 Self {
63 version: WIRE_VERSION,
64 node_id,
65 session_id,
66 source,
67 stream_seq,
68 }
69 }
70}
71
72/// The kind of measurement source behind a frame.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
74#[non_exhaustive]
75pub enum SourceKind {
76 /// The vendor CSI callback of an Espressif radio.
77 EspVendor,
78 /// An IEEE 802.11bf sensing measurement.
79 Ieee80211bf,
80 /// A synthetic source, for testing the pipeline without hardware.
81 Synthetic,
82}
83
84/// What follows an [`Envelope`].
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, MaxSize)]
86#[non_exhaustive]
87pub enum Body {
88 /// One channel measurement.
89 Csi(CsiFrame),
90 /// Session announcement, emitted when a run starts. Anchors [`RxMeta::timestamp_us`] to wall
91 /// time when the controller supplied one.
92 Session(SessionInfo),
93}
94
95/// Session announcement: what a host needs to interpret the frames that follow it.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
97#[non_exhaustive]
98pub struct SessionInfo {
99 /// The chip that delivers the frames.
100 pub chip: Chip,
101 /// Version of the crate that encoded the frames: major, minor, patch.
102 pub crate_version: [u8; 3],
103 /// The node's receive clock at the moment of the announcement, on the same time base as
104 /// [`RxMeta::timestamp_us`].
105 pub timestamp_us: u64,
106 /// Wall-clock time (UNIX epoch, microseconds) corresponding to `timestamp_us`, when the
107 /// controller supplied one.
108 pub epoch_unix_us: Option<u64>,
109}
110
111impl SessionInfo {
112 /// A session announcement.
113 pub const fn new(
114 chip: Chip,
115 crate_version: [u8; 3],
116 timestamp_us: u64,
117 epoch_unix_us: Option<u64>,
118 ) -> Self {
119 Self {
120 chip,
121 crate_version,
122 timestamp_us,
123 epoch_unix_us,
124 }
125 }
126}
127
128/// One channel measurement.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, MaxSize)]
130#[non_exhaustive]
131pub struct CsiFrame {
132 /// Normalised receive metadata.
133 pub meta: RxMeta,
134 /// What excited the channel.
135 pub stimulus: Stimulus,
136 /// The measured PPDU's MAC header, when the source captured it.
137 pub header: Option<HeaderDigest>,
138 /// The channel measurement itself.
139 pub payload: CsiPayload,
140}
141
142impl CsiFrame {
143 /// A measurement frame.
144 pub fn new(
145 meta: RxMeta,
146 stimulus: Stimulus,
147 header: Option<HeaderDigest>,
148 payload: CsiPayload,
149 ) -> Self {
150 Self {
151 meta,
152 stimulus,
153 header,
154 payload,
155 }
156 }
157
158 /// The transmitter of the measured PPDU: the header's `addr2` when present, else the
159 /// stimulus' transmitter address.
160 pub fn transmitter(&self) -> Option<[u8; 6]> {
161 if let Some(h) = &self.header {
162 return Some(h.addr2);
163 }
164 match self.stimulus {
165 Stimulus::Ambient { ta }
166 | Stimulus::Observed { ta, .. }
167 | Stimulus::Controlled { ta, .. } => Some(ta),
168 }
169 }
170}
171
172/// Normalised receive metadata: the fields every source can state in the same units.
173///
174/// A field is an `Option` when some source cannot state it. The Espressif 802.11ax-generation
175/// MAC (C5/C6), for instance, reports no MCS, STBC, guard-interval or antenna bit, and an absent
176/// value is `None` rather than a guess.
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, MaxSize)]
178#[non_exhaustive]
179pub struct RxMeta {
180 /// Receive time on the node's clock, microseconds. 64-bit: it does not wrap within a session.
181 pub timestamp_us: u64,
182 /// Received signal strength, dBm.
183 pub rssi: i8,
184 /// Noise floor, dBm.
185 pub noise_floor: i8,
186 /// Primary channel number.
187 pub channel: u8,
188 /// Position of the secondary channel, for a 40 MHz-capable channel.
189 pub secondary: Secondary,
190 /// Bandwidth of the measured PPDU, when the source reports or implies it.
191 pub bandwidth: Option<Bandwidth>,
192 /// Format of the measured PPDU.
193 pub ppdu: PpduFormat,
194 /// MCS index, for HT/VHT/HE PPDUs where the source reports it.
195 pub mcs: Option<u8>,
196 /// Whether the PPDU used space-time block coding.
197 pub stbc: Option<bool>,
198 /// Whether the PPDU used a short guard interval.
199 pub sgi: Option<bool>,
200 /// Receive chains the measurement covers.
201 pub n_rx: u8,
202 /// Spatial streams in the measured PPDU, when known.
203 pub n_ss: Option<u8>,
204 /// Index of the antenna the PPDU was received on, when reported.
205 pub antenna: Option<u8>,
206 /// Length of the received PPDU including its FCS, bytes.
207 pub sig_len: u16,
208 /// Receive state: 0 = no error, others are source-specific error codes.
209 pub rx_state: u8,
210 /// Whether the PPDU was *not* a sounding PPDU (`Some(false)` = sounding, i.e. an NDP), when
211 /// the source distinguishes them.
212 pub not_sounding: Option<bool>,
213 /// Whether the MPDU arrived inside an A-MPDU, when reported.
214 pub aggregation: Option<bool>,
215 /// The 12-bit 802.11 sequence number of the measured MPDU, when the source reports it.
216 /// Assigned by the *transmitter*, per TID, and repeated on retries, so it measures loss on the
217 /// air per transmitter. Use [`Envelope::stream_seq`] to count loss between node and host.
218 pub frame_seq: Option<u16>,
219 /// Fields specific to the source's chip family.
220 pub vendor: VendorRx,
221}
222
223/// Position of the secondary channel.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
225#[non_exhaustive]
226pub enum Secondary {
227 /// No secondary channel.
228 None,
229 /// Secondary channel above the primary.
230 Above,
231 /// Secondary channel below the primary.
232 Below,
233}
234
235/// Bandwidth of a measured PPDU.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
237#[non_exhaustive]
238pub enum Bandwidth {
239 /// 20 MHz.
240 Mhz20,
241 /// 40 MHz.
242 Mhz40,
243 /// 80 MHz.
244 Mhz80,
245 /// 160 MHz.
246 Mhz160,
247 /// 320 MHz.
248 Mhz320,
249}
250
251impl Bandwidth {
252 /// Width in MHz.
253 pub const fn mhz(self) -> u16 {
254 match self {
255 Self::Mhz20 => 20,
256 Self::Mhz40 => 40,
257 Self::Mhz80 => 80,
258 Self::Mhz160 => 160,
259 Self::Mhz320 => 320,
260 }
261 }
262}
263
264/// Format of a measured PPDU.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
266#[non_exhaustive]
267pub enum PpduFormat {
268 /// The source did not report a format it could classify.
269 Unknown,
270 /// DSSS/CCK (802.11b).
271 Dsss,
272 /// Non-HT OFDM (802.11a/g).
273 NonHt,
274 /// High throughput (802.11n).
275 Ht,
276 /// Very high throughput (802.11ac), single user.
277 Vht,
278 /// Very high throughput (802.11ac), multi user.
279 VhtMu,
280 /// High efficiency (802.11ax), single user.
281 HeSu,
282 /// High efficiency (802.11ax), multi user.
283 HeMu,
284 /// High efficiency (802.11ax), extended-range single user.
285 HeErSu,
286 /// High efficiency (802.11ax), trigger based.
287 HeTb,
288 /// Extremely high throughput (802.11be).
289 Eht,
290}
291
292/// What excited the channel that was measured.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
294#[non_exhaustive]
295pub enum Stimulus {
296 /// A sounding this network controls: an ESP-NOW exchange today, a negotiated 802.11bf
297 /// session later. `setup_id` names the measurement setup and `instance_id` the sounding
298 /// sequence within it.
299 Controlled {
300 /// Measurement setup the sounding belongs to.
301 setup_id: u8,
302 /// Sounding sequence within the setup.
303 instance_id: u16,
304 /// Transmitter of the measured PPDU. With several responders in one setup, this is what
305 /// tells their measurements apart.
306 ta: [u8; 6],
307 },
308 /// Ordinary traffic overheard on the channel, from transmitter `ta`.
309 Ambient {
310 /// Transmitter address of the measured PPDU.
311 ta: [u8; 6],
312 },
313 /// Someone else's sounding, observed without taking part: the NDP that followed an NDPA from
314 /// `ta` carrying `dialog_token`.
315 Observed {
316 /// Transmitter of the sounding.
317 ta: [u8; 6],
318 /// Sounding dialog token from the NDPA.
319 dialog_token: u8,
320 },
321}
322
323/// The fixed part of the measured MPDU's MAC header: enough to drop retries, tell uplink from
324/// downlink and attribute the frame to a transmitter.
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
326pub struct HeaderDigest {
327 /// Frame control field.
328 pub frame_control: u16,
329 /// Receiver address.
330 pub addr1: [u8; 6],
331 /// Transmitter address.
332 pub addr2: [u8; 6],
333 /// Third address (BSSID, or source/destination depending on To/From DS).
334 pub addr3: [u8; 6],
335 /// Sequence control field.
336 pub seq_ctrl: u16,
337}
338
339impl HeaderDigest {
340 /// Length of the header prefix the digest is read from: FC, duration, three addresses and
341 /// sequence control.
342 pub const HEADER_LEN: usize = 24;
343
344 /// Parse the first [`Self::HEADER_LEN`] bytes of an 802.11 MAC header. `None` if `hdr` is
345 /// shorter, or the frame type carries no transmitter address (ACK, CTS).
346 pub fn parse(hdr: &[u8]) -> Option<Self> {
347 if hdr.len() < Self::HEADER_LEN {
348 return None;
349 }
350 let frame_control = u16::from_le_bytes([hdr[0], hdr[1]]);
351 let mut a = [[0u8; 6]; 3];
352 for (i, addr) in a.iter_mut().enumerate() {
353 addr.copy_from_slice(&hdr[4 + 6 * i..10 + 6 * i]);
354 }
355 let digest = Self {
356 frame_control,
357 addr1: a[0],
358 addr2: a[1],
359 addr3: a[2],
360 seq_ctrl: u16::from_le_bytes([hdr[22], hdr[23]]),
361 };
362 if digest.has_transmitter() { Some(digest) } else { None }
363 }
364
365 /// Frame type (bits 2..4 of frame control): 0 management, 1 control, 2 data.
366 pub const fn frame_type(&self) -> u8 {
367 ((self.frame_control >> 2) & 0b11) as u8
368 }
369
370 /// Frame subtype (bits 4..8 of frame control).
371 pub const fn subtype(&self) -> u8 {
372 ((self.frame_control >> 4) & 0b1111) as u8
373 }
374
375 /// Whether the Retry bit is set: the MPDU is a retransmission.
376 pub const fn is_retry(&self) -> bool {
377 self.frame_control & (1 << 11) != 0
378 }
379
380 /// Whether the frame carries a transmitter address. ACK and CTS carry only a receiver.
381 pub const fn has_transmitter(&self) -> bool {
382 // Control subtypes 12 (CTS) and 13 (ACK) have no addr2.
383 !(self.frame_type() == 1 && matches!(self.subtype(), 12 | 13))
384 }
385
386 /// The 12-bit sequence number.
387 pub const fn sequence_number(&self) -> u16 {
388 self.seq_ctrl >> 4
389 }
390}
391
392/// The channel measurement itself.
393#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, MaxSize)]
394#[non_exhaustive]
395pub enum CsiPayload {
396 /// An Espressif CSI buffer exactly as the radio produced it: interleaved `(imag, real)` `i8`
397 /// pairs, segmented per training field as `layout` describes. Lossless, and costs the device
398 /// nothing to produce — the host owns the unpacking, through [`LayoutId`].
399 EspRaw {
400 /// Chip that produced the buffer.
401 chip: Chip,
402 /// How the buffer maps onto subcarriers.
403 layout: LayoutId,
404 /// The first four bytes are invalid (a hardware limitation of some parts).
405 first_word_invalid: bool,
406 /// The raw buffer.
407 bytes: Vec<i8, MAX_CSI_BYTES>,
408 },
409 /// A grouped, quantised channel report, the shape an IEEE 802.11bf sensing measurement
410 /// report takes.
411 Grouped {
412 /// Subcarrier grouping.
413 ng: u8,
414 /// Bits per quantised value.
415 nb: u8,
416 /// Index of the first reported subcarrier.
417 sc_start: i16,
418 /// Number of reported subcarriers.
419 n_sc: u16,
420 /// Receive chains.
421 n_rx: u8,
422 /// Transmit chains.
423 n_tx: u8,
424 /// Packed report.
425 data: Vec<u8, MAX_CSI_BYTES>,
426 },
427 /// A threshold-based report: only how far the channel moved, not the channel itself.
428 Variation {
429 /// Variation metric, scaled to the full `u16` range.
430 value: u16,
431 },
432}
433
434/// Chip-family-specific receive fields that have no normalised home in [`RxMeta`].
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
436#[non_exhaustive]
437pub enum VendorRx {
438 /// No vendor fields.
439 None,
440 /// Espressif classic MAC (ESP32, ESP32-S2/S3, ESP32-C3).
441 EspClassic {
442 /// PHY rate encoding (non-HT PPDUs).
443 rate: u8,
444 /// Raw `sig_mode`: 0 non-HT, 1 HT, 3 VHT.
445 sig_mode: u8,
446 /// Channel-estimate smoothing recommended.
447 smoothing: bool,
448 /// LDPC FEC coding.
449 fec_ldpc: bool,
450 /// Subframes in the A-MPDU.
451 ampdu_cnt: u8,
452 },
453 /// Espressif 802.11ax-generation MAC (ESP32-C5, ESP32-C6).
454 EspHe {
455 /// PHY rate encoding.
456 rate: u8,
457 /// Raw `cur_bb_format` (`wifi_rx_bb_format_t`): 0 11b, 1 11a/g, 2 HT, 3 VHT, 4 HE SU,
458 /// 5 HE MU, 6 HE ER-SU, 7 HE TB, 11 VHT MU.
459 cur_bb_format: u8,
460 /// The channel estimate is valid (`rx_channel_estimate_info_vld`).
461 estimate_valid: bool,
462 /// Length of the channel estimate the radio produced.
463 estimate_len: u16,
464 /// Length of the dump buffer.
465 dump_len: u16,
466 /// Group-addressed frame.
467 is_group: bool,
468 /// Receive-end state.
469 rxend_state: u8,
470 /// Interface-match bits: bit *n* is `rxmatch<n>`.
471 rxmatch: u8,
472 /// HE-SIG-A1, raw.
473 he_siga1: u32,
474 /// HE-SIG-A2, raw.
475 he_siga2: u16,
476 /// HE-SIG-B length (ESP32-C6 only; 0 elsewhere).
477 sigb_len: u8,
478 /// Single-MPDU reception (ESP32-C6 only; `false` elsewhere).
479 single_mpdu: bool,
480 },
481}
482
483/// Chips that can produce [`CsiPayload::EspRaw`].
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
485#[non_exhaustive]
486pub enum Chip {
487 /// ESP32.
488 Esp32,
489 /// ESP32-S2.
490 Esp32S2,
491 /// ESP32-S3.
492 Esp32S3,
493 /// ESP32-C3.
494 Esp32C3,
495 /// ESP32-C5.
496 Esp32C5,
497 /// ESP32-C6.
498 Esp32C6,
499}
500
501/// Bytes a COBS-encoded frame can occupy at most: both postcard bodies plus COBS overhead and the
502/// terminating zero.
503pub const MAX_ENCODED_LEN: usize = {
504 let raw = Envelope::POSTCARD_MAX_SIZE + Body::POSTCARD_MAX_SIZE;
505 raw + raw / 254 + 2
506};
507
508/// Encode `envelope` and `body` as one COBS frame into `buf`, returning the used slice (including
509/// the trailing `0x00` delimiter).
510pub fn encode_cobs<'b>(
511 envelope: &Envelope,
512 body: &Body,
513 buf: &'b mut [u8],
514) -> Result<&'b mut [u8], postcard::Error> {
515 postcard::to_slice_cobs(&(envelope, body), buf)
516}
517
518/// Why a frame could not be decoded.
519#[derive(Debug, Clone, PartialEq, Eq)]
520#[non_exhaustive]
521pub enum DecodeError {
522 /// The frame was written by a different [`WIRE_VERSION`]. The envelope still decoded, so the
523 /// version is known; the body was not attempted.
524 UnsupportedVersion(u8),
525 /// The bytes are not a valid frame.
526 Malformed(postcard::Error),
527}
528
529impl From<postcard::Error> for DecodeError {
530 fn from(e: postcard::Error) -> Self {
531 Self::Malformed(e)
532 }
533}
534
535/// Decode one COBS frame (with or without its trailing delimiter). Decodes in place.
536///
537/// Like [`decode`], this rejects a frame from another [`WIRE_VERSION`].
538///
539/// The envelope is read first and its version checked before the body is touched, so a frame from a
540/// newer encoder is reported as [`DecodeError::UnsupportedVersion`] rather than mis-read.
541pub fn decode_cobs(frame: &mut [u8]) -> Result<(Envelope, Body), DecodeError> {
542 let len = cobs::decode_in_place(frame)
543 .map_err(|_| DecodeError::Malformed(postcard::Error::DeserializeBadEncoding))?;
544 decode(&frame[..len])
545}
546
547/// Decode one frame that has already been COBS-decoded. See [`decode_cobs`].
548pub fn decode(bytes: &[u8]) -> Result<(Envelope, Body), DecodeError> {
549 let (envelope, rest) = postcard::take_from_bytes::<Envelope>(bytes)?;
550 if envelope.version != WIRE_VERSION {
551 return Err(DecodeError::UnsupportedVersion(envelope.version));
552 }
553 let (body, _) = postcard::take_from_bytes::<Body>(rest)?;
554 Ok((envelope, body))
555}