Skip to main content

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}