Skip to main content

csi_webclient/export/
csi.rs

1//! Host-side decoder for the firmware's `serialized` CSI wire format.
2//!
3//! WebSocket frames from `csi-webserver-rs` are the device's `serialized`
4//! records: each is `postcard::to_slice_cobs(&CSIDataPacket)` — a COBS-framed,
5//! postcard-encoded struct — with the trailing `\0` COBS terminator stripped by
6//! the server. This module mirrors that on-device struct so the client can
7//! decode frames into typed fields for local Parquet export, exactly as the
8//! server does for its own dumps.
9//!
10//! ## Why the struct is mirrored, not imported
11//! `esp-csi-rs` is an `esp-hal` crate and cannot compile for a desktop host, so
12//! the wire types are re-declared here. postcard is **not** self-describing and
13//! uses varint encoding, so these mirrors must match the firmware field-for-
14//! field, in order. **Pinned to `esp-csi-rs` 0.8.0.** When the firmware bumps
15//! its protocol/struct, update these definitions in lockstep with the server.
16
17use serde::{Deserialize, Serialize};
18
19/// Which on-device `CSIDataPacket` layout a connected chip produces.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ChipVariant {
22    /// esp32, esp32c3, esp32s3 — the full radio-metadata layout ([`PacketA`]).
23    Esp32Family,
24    /// esp32c5 — the reduced layout without the c6-only fields ([`PacketBc5`]).
25    Esp32c5,
26    /// esp32c6 — the reduced layout plus `he_sigb_len`/`cur_single_mpdu`/`rxmatch0`.
27    Esp32c6,
28}
29
30impl ChipVariant {
31    /// Map a firmware `chip=` string (case-insensitive) to its wire layout.
32    ///
33    /// Returns `None` for unrecognized chips so the caller can refuse to decode
34    /// rather than guess a layout.
35    pub fn from_chip_str(chip: &str) -> Option<Self> {
36        match chip.trim().to_ascii_lowercase().as_str() {
37            "esp32" | "esp32c3" | "esp32s3" | "esp32s2" => Some(Self::Esp32Family),
38            "esp32c5" => Some(Self::Esp32c5),
39            "esp32c6" => Some(Self::Esp32c6),
40            _ => None,
41        }
42    }
43}
44
45/// Optional NTP-derived calendar timestamp the firmware may attach to a packet.
46///
47/// Mirror of `esp_csi_rs::time::DateTime` (all fields `u64`).
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct DateTime {
50    pub year: u64,
51    pub month: u64,
52    pub day: u64,
53    pub hour: u64,
54    pub minute: u64,
55    pub second: u64,
56    pub millisecond: u64,
57}
58
59/// Compact CSI data-format descriptor.
60///
61/// Mirror of `esp_csi_rs::csi::RxCSIFmt` — **variant order is the wire encoding**
62/// (postcard encodes the discriminant as a varint of the declaration index), so
63/// do not reorder.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65pub enum RxCsiFmt {
66    Bw20,
67    HtBw20,
68    HtBw20Stbc,
69    SecbBw20,
70    SecbHtBw20,
71    SecbHtBw20Stbc,
72    SecbHtBw40,
73    SecbHtBw40Stbc,
74    SecaBw20,
75    SecaHtBw20,
76    SecaHtBw20Stbc,
77    SecaHtBw40,
78    SecaHtBw40Stbc,
79    /// VHT 20 MHz (`cur_bb_format == 3` on C5/C6).
80    VhtBw20,
81    /// HE20 SU (802.11ax single-user, `cur_bb_format == 4`). C5/C6.
82    He20Su,
83    /// HE20 MU (802.11ax multi-user, `cur_bb_format == 5`). C5/C6.
84    He20Mu,
85    Undefined,
86}
87
88impl RxCsiFmt {
89    /// Stable identifier for the Parquet `data_format` column.
90    pub fn as_str(self) -> &'static str {
91        match self {
92            Self::Bw20 => "Bw20",
93            Self::HtBw20 => "HtBw20",
94            Self::HtBw20Stbc => "HtBw20Stbc",
95            Self::SecbBw20 => "SecbBw20",
96            Self::SecbHtBw20 => "SecbHtBw20",
97            Self::SecbHtBw20Stbc => "SecbHtBw20Stbc",
98            Self::SecbHtBw40 => "SecbHtBw40",
99            Self::SecbHtBw40Stbc => "SecbHtBw40Stbc",
100            Self::SecaBw20 => "SecaBw20",
101            Self::SecaHtBw20 => "SecaHtBw20",
102            Self::SecaHtBw20Stbc => "SecaHtBw20Stbc",
103            Self::SecaHtBw40 => "SecaHtBw40",
104            Self::SecaHtBw40Stbc => "SecaHtBw40Stbc",
105            Self::VhtBw20 => "VhtBw20",
106            Self::He20Su => "He20Su",
107            Self::He20Mu => "He20Mu",
108            Self::Undefined => "Undefined",
109        }
110    }
111}
112
113/// esp32 / esp32c3 / esp32s3 layout — mirror of `CSIDataPacket`
114/// (`#[cfg(not(any(esp32c5, esp32c6)))]`). Field order is the wire order.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct PacketA {
117    pub mac: [u8; 6],
118    pub rssi: i32,
119    pub timestamp: u32,
120    pub rate: u32,
121    pub sgi: u32,
122    pub secondary_channel: u32,
123    pub channel: u32,
124    pub bandwidth: u32,
125    pub antenna: u32,
126    pub sig_mode: u32,
127    pub mcs: u32,
128    pub smoothing: u32,
129    pub not_sounding: u32,
130    pub aggregation: u32,
131    pub stbc: u32,
132    pub fec_coding: u32,
133    pub ampdu_cnt: u32,
134    pub noise_floor: i32,
135    pub rx_state: u32,
136    pub sig_len: u32,
137    pub date_time: Option<DateTime>,
138    pub sequence_number: u16,
139    pub data_format: RxCsiFmt,
140    pub csi_data_len: u16,
141    pub csi_data: Vec<i8>,
142}
143
144/// esp32c5 layout — mirror of the `#[cfg(any(esp32c5, esp32c6))]` `CSIDataPacket`
145/// **without** the `#[cfg(feature = "esp32c6")]` fields. Field order is the wire
146/// order.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct PacketBc5 {
149    pub mac: [u8; 6],
150    pub rssi: i32,
151    pub timestamp: u32,
152    pub rate: u32,
153    pub noise_floor: i32,
154    pub sig_len: u32,
155    pub rx_state: u32,
156    pub dump_len: u32,
157    pub cur_bb_format: u32,
158    pub rx_channel_estimate_info_vld: u32,
159    pub rx_channel_estimate_len: u32,
160    pub second: u32,
161    pub channel: u32,
162    pub is_group: u32,
163    pub rxend_state: u32,
164    pub rxmatch3: u32,
165    pub rxmatch2: u32,
166    pub rxmatch1: u32,
167    pub date_time: Option<DateTime>,
168    pub sequence_number: u16,
169    pub csi_data_len: u16,
170    pub data_format: RxCsiFmt,
171    pub csi_data: Vec<i8>,
172}
173
174/// esp32c6 layout — the c5 layout plus the three `#[cfg(feature = "esp32c6")]`
175/// fields (`he_sigb_len`, `cur_single_mpdu`, `rxmatch0`) at their declared
176/// positions. Field order is the wire order.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct PacketBc6 {
179    pub mac: [u8; 6],
180    pub rssi: i32,
181    pub timestamp: u32,
182    pub rate: u32,
183    pub noise_floor: i32,
184    pub sig_len: u32,
185    pub rx_state: u32,
186    pub dump_len: u32,
187    pub he_sigb_len: u32,
188    pub cur_single_mpdu: u32,
189    pub cur_bb_format: u32,
190    pub rx_channel_estimate_info_vld: u32,
191    pub rx_channel_estimate_len: u32,
192    pub second: u32,
193    pub channel: u32,
194    pub is_group: u32,
195    pub rxend_state: u32,
196    pub rxmatch3: u32,
197    pub rxmatch2: u32,
198    pub rxmatch1: u32,
199    pub rxmatch0: u32,
200    pub date_time: Option<DateTime>,
201    pub sequence_number: u16,
202    pub csi_data_len: u16,
203    pub data_format: RxCsiFmt,
204    pub csi_data: Vec<i8>,
205}
206
207/// Chip-agnostic decoded CSI record — the superset of every layout's fields.
208///
209/// Fields absent on the source chip are `None`. This is what the Parquet sink
210/// consumes; its column set is the union of all chip layouts plus the
211/// host-supplied receive time.
212#[derive(Debug, Clone)]
213pub struct DecodedCsi {
214    // ── Common to every layout ──────────────────────────────────────────
215    pub mac: [u8; 6],
216    pub rssi: i32,
217    pub timestamp: u32,
218    pub rate: u32,
219    pub noise_floor: i32,
220    pub sig_len: u32,
221    pub rx_state: u32,
222    pub channel: u32,
223    pub date_time: Option<DateTime>,
224    pub sequence_number: u16,
225    pub data_format: RxCsiFmt,
226    pub csi_data_len: u16,
227    pub csi_data: Vec<i8>,
228
229    // ── esp32-family only ───────────────────────────────────────────────
230    pub sgi: Option<u32>,
231    pub secondary_channel: Option<u32>,
232    pub bandwidth: Option<u32>,
233    pub antenna: Option<u32>,
234    pub sig_mode: Option<u32>,
235    pub mcs: Option<u32>,
236    pub smoothing: Option<u32>,
237    pub not_sounding: Option<u32>,
238    pub aggregation: Option<u32>,
239    pub stbc: Option<u32>,
240    pub fec_coding: Option<u32>,
241    pub ampdu_cnt: Option<u32>,
242
243    // ── c5 / c6 only ────────────────────────────────────────────────────
244    pub dump_len: Option<u32>,
245    pub cur_bb_format: Option<u32>,
246    pub rx_channel_estimate_info_vld: Option<u32>,
247    pub rx_channel_estimate_len: Option<u32>,
248    pub second: Option<u32>,
249    pub is_group: Option<u32>,
250    pub rxend_state: Option<u32>,
251    pub rxmatch3: Option<u32>,
252    pub rxmatch2: Option<u32>,
253    pub rxmatch1: Option<u32>,
254
255    // ── c6 only ─────────────────────────────────────────────────────────
256    pub he_sigb_len: Option<u32>,
257    pub cur_single_mpdu: Option<u32>,
258    pub rxmatch0: Option<u32>,
259}
260
261impl From<PacketA> for DecodedCsi {
262    fn from(p: PacketA) -> Self {
263        DecodedCsi {
264            mac: p.mac,
265            rssi: p.rssi,
266            timestamp: p.timestamp,
267            rate: p.rate,
268            noise_floor: p.noise_floor,
269            sig_len: p.sig_len,
270            rx_state: p.rx_state,
271            channel: p.channel,
272            date_time: p.date_time,
273            sequence_number: p.sequence_number,
274            data_format: p.data_format,
275            csi_data_len: p.csi_data_len,
276            csi_data: p.csi_data,
277            sgi: Some(p.sgi),
278            secondary_channel: Some(p.secondary_channel),
279            bandwidth: Some(p.bandwidth),
280            antenna: Some(p.antenna),
281            sig_mode: Some(p.sig_mode),
282            mcs: Some(p.mcs),
283            smoothing: Some(p.smoothing),
284            not_sounding: Some(p.not_sounding),
285            aggregation: Some(p.aggregation),
286            stbc: Some(p.stbc),
287            fec_coding: Some(p.fec_coding),
288            ampdu_cnt: Some(p.ampdu_cnt),
289            dump_len: None,
290            cur_bb_format: None,
291            rx_channel_estimate_info_vld: None,
292            rx_channel_estimate_len: None,
293            second: None,
294            is_group: None,
295            rxend_state: None,
296            rxmatch3: None,
297            rxmatch2: None,
298            rxmatch1: None,
299            he_sigb_len: None,
300            cur_single_mpdu: None,
301            rxmatch0: None,
302        }
303    }
304}
305
306impl From<PacketBc5> for DecodedCsi {
307    fn from(p: PacketBc5) -> Self {
308        DecodedCsi {
309            mac: p.mac,
310            rssi: p.rssi,
311            timestamp: p.timestamp,
312            rate: p.rate,
313            noise_floor: p.noise_floor,
314            sig_len: p.sig_len,
315            rx_state: p.rx_state,
316            channel: p.channel,
317            date_time: p.date_time,
318            sequence_number: p.sequence_number,
319            data_format: p.data_format,
320            csi_data_len: p.csi_data_len,
321            csi_data: p.csi_data,
322            sgi: None,
323            secondary_channel: None,
324            bandwidth: None,
325            antenna: None,
326            sig_mode: None,
327            mcs: None,
328            smoothing: None,
329            not_sounding: None,
330            aggregation: None,
331            stbc: None,
332            fec_coding: None,
333            ampdu_cnt: None,
334            dump_len: Some(p.dump_len),
335            cur_bb_format: Some(p.cur_bb_format),
336            rx_channel_estimate_info_vld: Some(p.rx_channel_estimate_info_vld),
337            rx_channel_estimate_len: Some(p.rx_channel_estimate_len),
338            second: Some(p.second),
339            is_group: Some(p.is_group),
340            rxend_state: Some(p.rxend_state),
341            rxmatch3: Some(p.rxmatch3),
342            rxmatch2: Some(p.rxmatch2),
343            rxmatch1: Some(p.rxmatch1),
344            he_sigb_len: None,
345            cur_single_mpdu: None,
346            rxmatch0: None,
347        }
348    }
349}
350
351impl From<PacketBc6> for DecodedCsi {
352    fn from(p: PacketBc6) -> Self {
353        DecodedCsi {
354            mac: p.mac,
355            rssi: p.rssi,
356            timestamp: p.timestamp,
357            rate: p.rate,
358            noise_floor: p.noise_floor,
359            sig_len: p.sig_len,
360            rx_state: p.rx_state,
361            channel: p.channel,
362            date_time: p.date_time,
363            sequence_number: p.sequence_number,
364            data_format: p.data_format,
365            csi_data_len: p.csi_data_len,
366            csi_data: p.csi_data,
367            sgi: None,
368            secondary_channel: None,
369            bandwidth: None,
370            antenna: None,
371            sig_mode: None,
372            mcs: None,
373            smoothing: None,
374            not_sounding: None,
375            aggregation: None,
376            stbc: None,
377            fec_coding: None,
378            ampdu_cnt: None,
379            dump_len: Some(p.dump_len),
380            cur_bb_format: Some(p.cur_bb_format),
381            rx_channel_estimate_info_vld: Some(p.rx_channel_estimate_info_vld),
382            rx_channel_estimate_len: Some(p.rx_channel_estimate_len),
383            second: Some(p.second),
384            is_group: Some(p.is_group),
385            rxend_state: Some(p.rxend_state),
386            rxmatch3: Some(p.rxmatch3),
387            rxmatch2: Some(p.rxmatch2),
388            rxmatch1: Some(p.rxmatch1),
389            he_sigb_len: Some(p.he_sigb_len),
390            cur_single_mpdu: Some(p.cur_single_mpdu),
391            rxmatch0: Some(p.rxmatch0),
392        }
393    }
394}
395
396/// Failure decoding a serialized CSI frame.
397#[derive(Debug)]
398pub struct DecodeError(postcard::Error);
399
400impl std::fmt::Display for DecodeError {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        write!(f, "failed to decode CSI frame: {}", self.0)
403    }
404}
405
406impl std::error::Error for DecodeError {}
407
408impl From<postcard::Error> for DecodeError {
409    fn from(e: postcard::Error) -> Self {
410        DecodeError(e)
411    }
412}
413
414/// Decode one COBS-framed postcard CSI frame into a [`DecodedCsi`].
415///
416/// `frame` is the WebSocket payload (the COBS body without the trailing `\0`
417/// terminator the server strips). `take_from_bytes_cobs` decodes in place and
418/// tolerates trailing pad bytes after the encoded struct. The input is copied
419/// because COBS decoding mutates the buffer.
420pub fn decode(frame: &[u8], chip: ChipVariant) -> Result<DecodedCsi, DecodeError> {
421    let mut owned = frame.to_vec();
422    let decoded = match chip {
423        ChipVariant::Esp32Family => {
424            let (p, _) = postcard::take_from_bytes_cobs::<PacketA>(&mut owned)?;
425            p.into()
426        }
427        ChipVariant::Esp32c5 => {
428            let (p, _) = postcard::take_from_bytes_cobs::<PacketBc5>(&mut owned)?;
429            p.into()
430        }
431        ChipVariant::Esp32c6 => {
432            let (p, _) = postcard::take_from_bytes_cobs::<PacketBc6>(&mut owned)?;
433            p.into()
434        }
435    };
436    Ok(decoded)
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    /// Round-trip a `PacketA` through postcard+COBS exactly as the firmware
444    /// emits it (`to_slice_cobs`), then decode it back. Guards against wire
445    /// drift in field order/types for the esp32-family layout.
446    #[test]
447    fn decode_packet_a_roundtrip() {
448        let pkt = PacketA {
449            mac: [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01],
450            rssi: -42,
451            timestamp: 123_456,
452            rate: 11,
453            sgi: 1,
454            secondary_channel: 0,
455            channel: 6,
456            bandwidth: 0,
457            antenna: 0,
458            sig_mode: 1,
459            mcs: 7,
460            smoothing: 0,
461            not_sounding: 1,
462            aggregation: 0,
463            stbc: 0,
464            fec_coding: 0,
465            ampdu_cnt: 0,
466            noise_floor: -96,
467            rx_state: 0,
468            sig_len: 128,
469            date_time: Some(DateTime {
470                year: 2026,
471                month: 6,
472                day: 22,
473                hour: 12,
474                minute: 30,
475                second: 15,
476                millisecond: 250,
477            }),
478            sequence_number: 4242,
479            data_format: RxCsiFmt::HtBw20,
480            csi_data_len: 4,
481            csi_data: vec![1, -2, 3, -4],
482        };
483
484        // Mirror the firmware: postcard-serialize then COBS-frame, then strip
485        // the trailing `\0` the server removes before broadcasting.
486        let mut buf = vec![0u8; 1024];
487        let cobs = postcard::to_slice_cobs(&pkt, &mut buf).unwrap();
488        let body = cobs.strip_suffix(&[0]).unwrap_or(cobs);
489
490        let out = decode(body, ChipVariant::Esp32Family).unwrap();
491        assert_eq!(out.mac, pkt.mac);
492        assert_eq!(out.rssi, -42);
493        assert_eq!(out.channel, 6);
494        assert_eq!(out.mcs, Some(7));
495        assert_eq!(out.noise_floor, -96);
496        assert_eq!(out.sequence_number, 4242);
497        assert_eq!(out.data_format, RxCsiFmt::HtBw20);
498        assert_eq!(out.csi_data, vec![1, -2, 3, -4]);
499        assert_eq!(out.dump_len, None);
500        let dt = out.date_time.expect("date_time present");
501        assert_eq!((dt.year, dt.month, dt.day), (2026, 6, 22));
502    }
503
504    #[test]
505    fn decode_packet_bc6_roundtrip() {
506        let pkt = PacketBc6 {
507            mac: [1, 2, 3, 4, 5, 6],
508            rssi: -55,
509            timestamp: 9,
510            rate: 1,
511            noise_floor: -90,
512            sig_len: 64,
513            rx_state: 0,
514            dump_len: 100,
515            he_sigb_len: 7,
516            cur_single_mpdu: 1,
517            cur_bb_format: 2,
518            rx_channel_estimate_info_vld: 1,
519            rx_channel_estimate_len: 64,
520            second: 3,
521            channel: 11,
522            is_group: 0,
523            rxend_state: 0,
524            rxmatch3: 0,
525            rxmatch2: 0,
526            rxmatch1: 1,
527            rxmatch0: 1,
528            date_time: None,
529            sequence_number: 7,
530            csi_data_len: 2,
531            data_format: RxCsiFmt::Undefined,
532            csi_data: vec![-1, 1],
533        };
534        let mut buf = vec![0u8; 1024];
535        let cobs = postcard::to_slice_cobs(&pkt, &mut buf).unwrap();
536        let body = cobs.strip_suffix(&[0]).unwrap_or(cobs);
537
538        let out = decode(body, ChipVariant::Esp32c6).unwrap();
539        assert_eq!(out.mac, [1, 2, 3, 4, 5, 6]);
540        assert_eq!(out.he_sigb_len, Some(7));
541        assert_eq!(out.rxmatch0, Some(1));
542        assert_eq!(out.sgi, None);
543        assert_eq!(out.csi_data, vec![-1, 1]);
544        assert!(out.date_time.is_none());
545    }
546
547    #[test]
548    fn chip_string_mapping() {
549        assert_eq!(ChipVariant::from_chip_str("ESP32"), Some(ChipVariant::Esp32Family));
550        assert_eq!(ChipVariant::from_chip_str("esp32c6"), Some(ChipVariant::Esp32c6));
551        assert_eq!(ChipVariant::from_chip_str("esp32c5"), Some(ChipVariant::Esp32c5));
552        assert_eq!(ChipVariant::from_chip_str("weird"), None);
553    }
554}