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    /// Any format the core library does not name (discriminant 14). Higher
82    /// numeric `cur_bb_format` values decode here with the raw value preserved
83    /// in the `cur_bb_format` column; a [`crate::profile::ClientProfile`] can
84    /// relabel them for the export.
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::Undefined => "Undefined",
107        }
108    }
109}
110
111/// esp32 / esp32c3 / esp32s3 layout — mirror of `CSIDataPacket`
112/// (`#[cfg(not(any(esp32c5, esp32c6)))]`). Field order is the wire order.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PacketA {
115    pub mac: [u8; 6],
116    pub rssi: i32,
117    pub timestamp: u32,
118    pub rate: u32,
119    pub sgi: u32,
120    pub secondary_channel: u32,
121    pub channel: u32,
122    pub bandwidth: u32,
123    pub antenna: u32,
124    pub sig_mode: u32,
125    pub mcs: u32,
126    pub smoothing: u32,
127    pub not_sounding: u32,
128    pub aggregation: u32,
129    pub stbc: u32,
130    pub fec_coding: u32,
131    pub ampdu_cnt: u32,
132    pub noise_floor: i32,
133    pub rx_state: u32,
134    pub sig_len: u32,
135    pub date_time: Option<DateTime>,
136    pub sequence_number: u16,
137    pub data_format: RxCsiFmt,
138    pub csi_data_len: u16,
139    pub csi_data: Vec<i8>,
140}
141
142/// esp32c5 layout — mirror of the `#[cfg(any(esp32c5, esp32c6))]` `CSIDataPacket`
143/// **without** the `#[cfg(feature = "esp32c6")]` fields. Field order is the wire
144/// order.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct PacketBc5 {
147    pub mac: [u8; 6],
148    pub rssi: i32,
149    pub timestamp: u32,
150    pub rate: u32,
151    pub noise_floor: i32,
152    pub sig_len: u32,
153    pub rx_state: u32,
154    pub dump_len: u32,
155    pub cur_bb_format: u32,
156    pub rx_channel_estimate_info_vld: u32,
157    pub rx_channel_estimate_len: u32,
158    pub second: u32,
159    pub channel: u32,
160    pub is_group: u32,
161    pub rxend_state: u32,
162    pub rxmatch3: u32,
163    pub rxmatch2: u32,
164    pub rxmatch1: u32,
165    pub date_time: Option<DateTime>,
166    pub sequence_number: u16,
167    pub csi_data_len: u16,
168    pub data_format: RxCsiFmt,
169    pub csi_data: Vec<i8>,
170}
171
172/// esp32c6 layout — the c5 layout plus the three `#[cfg(feature = "esp32c6")]`
173/// fields (`he_sigb_len`, `cur_single_mpdu`, `rxmatch0`) at their declared
174/// positions. Field order is the wire order.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct PacketBc6 {
177    pub mac: [u8; 6],
178    pub rssi: i32,
179    pub timestamp: u32,
180    pub rate: u32,
181    pub noise_floor: i32,
182    pub sig_len: u32,
183    pub rx_state: u32,
184    pub dump_len: u32,
185    pub he_sigb_len: u32,
186    pub cur_single_mpdu: u32,
187    pub cur_bb_format: u32,
188    pub rx_channel_estimate_info_vld: u32,
189    pub rx_channel_estimate_len: u32,
190    pub second: u32,
191    pub channel: u32,
192    pub is_group: u32,
193    pub rxend_state: u32,
194    pub rxmatch3: u32,
195    pub rxmatch2: u32,
196    pub rxmatch1: u32,
197    pub rxmatch0: u32,
198    pub date_time: Option<DateTime>,
199    pub sequence_number: u16,
200    pub csi_data_len: u16,
201    pub data_format: RxCsiFmt,
202    pub csi_data: Vec<i8>,
203}
204
205/// Chip-agnostic decoded CSI record — the superset of every layout's fields.
206///
207/// Fields absent on the source chip are `None`. This is what the Parquet sink
208/// consumes; its column set is the union of all chip layouts plus the
209/// host-supplied receive time.
210#[derive(Debug, Clone)]
211pub struct DecodedCsi {
212    // ── Common to every layout ──────────────────────────────────────────
213    pub mac: [u8; 6],
214    pub rssi: i32,
215    pub timestamp: u32,
216    pub rate: u32,
217    pub noise_floor: i32,
218    pub sig_len: u32,
219    pub rx_state: u32,
220    pub channel: u32,
221    pub date_time: Option<DateTime>,
222    pub sequence_number: u16,
223    pub data_format: RxCsiFmt,
224    pub csi_data_len: u16,
225    pub csi_data: Vec<i8>,
226
227    // ── esp32-family only ───────────────────────────────────────────────
228    pub sgi: Option<u32>,
229    pub secondary_channel: Option<u32>,
230    pub bandwidth: Option<u32>,
231    pub antenna: Option<u32>,
232    pub sig_mode: Option<u32>,
233    pub mcs: Option<u32>,
234    pub smoothing: Option<u32>,
235    pub not_sounding: Option<u32>,
236    pub aggregation: Option<u32>,
237    pub stbc: Option<u32>,
238    pub fec_coding: Option<u32>,
239    pub ampdu_cnt: Option<u32>,
240
241    // ── c5 / c6 only ────────────────────────────────────────────────────
242    pub dump_len: Option<u32>,
243    pub cur_bb_format: Option<u32>,
244    pub rx_channel_estimate_info_vld: Option<u32>,
245    pub rx_channel_estimate_len: Option<u32>,
246    pub second: Option<u32>,
247    pub is_group: Option<u32>,
248    pub rxend_state: Option<u32>,
249    pub rxmatch3: Option<u32>,
250    pub rxmatch2: Option<u32>,
251    pub rxmatch1: Option<u32>,
252
253    // ── c6 only ─────────────────────────────────────────────────────────
254    pub he_sigb_len: Option<u32>,
255    pub cur_single_mpdu: Option<u32>,
256    pub rxmatch0: Option<u32>,
257}
258
259impl From<PacketA> for DecodedCsi {
260    fn from(p: PacketA) -> Self {
261        DecodedCsi {
262            mac: p.mac,
263            rssi: p.rssi,
264            timestamp: p.timestamp,
265            rate: p.rate,
266            noise_floor: p.noise_floor,
267            sig_len: p.sig_len,
268            rx_state: p.rx_state,
269            channel: p.channel,
270            date_time: p.date_time,
271            sequence_number: p.sequence_number,
272            data_format: p.data_format,
273            csi_data_len: p.csi_data_len,
274            csi_data: p.csi_data,
275            sgi: Some(p.sgi),
276            secondary_channel: Some(p.secondary_channel),
277            bandwidth: Some(p.bandwidth),
278            antenna: Some(p.antenna),
279            sig_mode: Some(p.sig_mode),
280            mcs: Some(p.mcs),
281            smoothing: Some(p.smoothing),
282            not_sounding: Some(p.not_sounding),
283            aggregation: Some(p.aggregation),
284            stbc: Some(p.stbc),
285            fec_coding: Some(p.fec_coding),
286            ampdu_cnt: Some(p.ampdu_cnt),
287            dump_len: None,
288            cur_bb_format: None,
289            rx_channel_estimate_info_vld: None,
290            rx_channel_estimate_len: None,
291            second: None,
292            is_group: None,
293            rxend_state: None,
294            rxmatch3: None,
295            rxmatch2: None,
296            rxmatch1: None,
297            he_sigb_len: None,
298            cur_single_mpdu: None,
299            rxmatch0: None,
300        }
301    }
302}
303
304impl From<PacketBc5> for DecodedCsi {
305    fn from(p: PacketBc5) -> Self {
306        DecodedCsi {
307            mac: p.mac,
308            rssi: p.rssi,
309            timestamp: p.timestamp,
310            rate: p.rate,
311            noise_floor: p.noise_floor,
312            sig_len: p.sig_len,
313            rx_state: p.rx_state,
314            channel: p.channel,
315            date_time: p.date_time,
316            sequence_number: p.sequence_number,
317            data_format: p.data_format,
318            csi_data_len: p.csi_data_len,
319            csi_data: p.csi_data,
320            sgi: None,
321            secondary_channel: None,
322            bandwidth: None,
323            antenna: None,
324            sig_mode: None,
325            mcs: None,
326            smoothing: None,
327            not_sounding: None,
328            aggregation: None,
329            stbc: None,
330            fec_coding: None,
331            ampdu_cnt: None,
332            dump_len: Some(p.dump_len),
333            cur_bb_format: Some(p.cur_bb_format),
334            rx_channel_estimate_info_vld: Some(p.rx_channel_estimate_info_vld),
335            rx_channel_estimate_len: Some(p.rx_channel_estimate_len),
336            second: Some(p.second),
337            is_group: Some(p.is_group),
338            rxend_state: Some(p.rxend_state),
339            rxmatch3: Some(p.rxmatch3),
340            rxmatch2: Some(p.rxmatch2),
341            rxmatch1: Some(p.rxmatch1),
342            he_sigb_len: None,
343            cur_single_mpdu: None,
344            rxmatch0: None,
345        }
346    }
347}
348
349impl From<PacketBc6> for DecodedCsi {
350    fn from(p: PacketBc6) -> Self {
351        DecodedCsi {
352            mac: p.mac,
353            rssi: p.rssi,
354            timestamp: p.timestamp,
355            rate: p.rate,
356            noise_floor: p.noise_floor,
357            sig_len: p.sig_len,
358            rx_state: p.rx_state,
359            channel: p.channel,
360            date_time: p.date_time,
361            sequence_number: p.sequence_number,
362            data_format: p.data_format,
363            csi_data_len: p.csi_data_len,
364            csi_data: p.csi_data,
365            sgi: None,
366            secondary_channel: None,
367            bandwidth: None,
368            antenna: None,
369            sig_mode: None,
370            mcs: None,
371            smoothing: None,
372            not_sounding: None,
373            aggregation: None,
374            stbc: None,
375            fec_coding: None,
376            ampdu_cnt: None,
377            dump_len: Some(p.dump_len),
378            cur_bb_format: Some(p.cur_bb_format),
379            rx_channel_estimate_info_vld: Some(p.rx_channel_estimate_info_vld),
380            rx_channel_estimate_len: Some(p.rx_channel_estimate_len),
381            second: Some(p.second),
382            is_group: Some(p.is_group),
383            rxend_state: Some(p.rxend_state),
384            rxmatch3: Some(p.rxmatch3),
385            rxmatch2: Some(p.rxmatch2),
386            rxmatch1: Some(p.rxmatch1),
387            he_sigb_len: Some(p.he_sigb_len),
388            cur_single_mpdu: Some(p.cur_single_mpdu),
389            rxmatch0: Some(p.rxmatch0),
390        }
391    }
392}
393
394/// Failure decoding a serialized CSI frame.
395#[derive(Debug)]
396pub struct DecodeError(postcard::Error);
397
398impl std::fmt::Display for DecodeError {
399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400        write!(f, "failed to decode CSI frame: {}", self.0)
401    }
402}
403
404impl std::error::Error for DecodeError {}
405
406impl From<postcard::Error> for DecodeError {
407    fn from(e: postcard::Error) -> Self {
408        DecodeError(e)
409    }
410}
411
412/// Decode one COBS-framed postcard CSI frame into a [`DecodedCsi`].
413///
414/// `frame` is the WebSocket payload (the COBS body without the trailing `\0`
415/// terminator the server strips). `take_from_bytes_cobs` decodes in place and
416/// tolerates trailing pad bytes after the encoded struct. The input is copied
417/// because COBS decoding mutates the buffer.
418pub fn decode(frame: &[u8], chip: ChipVariant) -> Result<DecodedCsi, DecodeError> {
419    let mut owned = frame.to_vec();
420    let decoded = match chip {
421        ChipVariant::Esp32Family => {
422            let (p, _) = postcard::take_from_bytes_cobs::<PacketA>(&mut owned)?;
423            p.into()
424        }
425        ChipVariant::Esp32c5 => {
426            let (p, _) = postcard::take_from_bytes_cobs::<PacketBc5>(&mut owned)?;
427            p.into()
428        }
429        ChipVariant::Esp32c6 => {
430            let (p, _) = postcard::take_from_bytes_cobs::<PacketBc6>(&mut owned)?;
431            p.into()
432        }
433    };
434    Ok(decoded)
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    /// Round-trip a `PacketA` through postcard+COBS exactly as the firmware
442    /// emits it (`to_slice_cobs`), then decode it back. Guards against wire
443    /// drift in field order/types for the esp32-family layout.
444    #[test]
445    fn decode_packet_a_roundtrip() {
446        let pkt = PacketA {
447            mac: [0xde, 0xad, 0xbe, 0xef, 0x00, 0x01],
448            rssi: -42,
449            timestamp: 123_456,
450            rate: 11,
451            sgi: 1,
452            secondary_channel: 0,
453            channel: 6,
454            bandwidth: 0,
455            antenna: 0,
456            sig_mode: 1,
457            mcs: 7,
458            smoothing: 0,
459            not_sounding: 1,
460            aggregation: 0,
461            stbc: 0,
462            fec_coding: 0,
463            ampdu_cnt: 0,
464            noise_floor: -96,
465            rx_state: 0,
466            sig_len: 128,
467            date_time: Some(DateTime {
468                year: 2026,
469                month: 6,
470                day: 22,
471                hour: 12,
472                minute: 30,
473                second: 15,
474                millisecond: 250,
475            }),
476            sequence_number: 4242,
477            data_format: RxCsiFmt::HtBw20,
478            csi_data_len: 4,
479            csi_data: vec![1, -2, 3, -4],
480        };
481
482        // Mirror the firmware: postcard-serialize then COBS-frame, then strip
483        // the trailing `\0` the server removes before broadcasting.
484        let mut buf = vec![0u8; 1024];
485        let cobs = postcard::to_slice_cobs(&pkt, &mut buf).unwrap();
486        let body = cobs.strip_suffix(&[0]).unwrap_or(cobs);
487
488        let out = decode(body, ChipVariant::Esp32Family).unwrap();
489        assert_eq!(out.mac, pkt.mac);
490        assert_eq!(out.rssi, -42);
491        assert_eq!(out.channel, 6);
492        assert_eq!(out.mcs, Some(7));
493        assert_eq!(out.noise_floor, -96);
494        assert_eq!(out.sequence_number, 4242);
495        assert_eq!(out.data_format, RxCsiFmt::HtBw20);
496        assert_eq!(out.csi_data, vec![1, -2, 3, -4]);
497        assert_eq!(out.dump_len, None);
498        let dt = out.date_time.expect("date_time present");
499        assert_eq!((dt.year, dt.month, dt.day), (2026, 6, 22));
500    }
501
502    #[test]
503    fn decode_packet_bc6_roundtrip() {
504        let pkt = PacketBc6 {
505            mac: [1, 2, 3, 4, 5, 6],
506            rssi: -55,
507            timestamp: 9,
508            rate: 1,
509            noise_floor: -90,
510            sig_len: 64,
511            rx_state: 0,
512            dump_len: 100,
513            he_sigb_len: 7,
514            cur_single_mpdu: 1,
515            cur_bb_format: 2,
516            rx_channel_estimate_info_vld: 1,
517            rx_channel_estimate_len: 64,
518            second: 3,
519            channel: 11,
520            is_group: 0,
521            rxend_state: 0,
522            rxmatch3: 0,
523            rxmatch2: 0,
524            rxmatch1: 1,
525            rxmatch0: 1,
526            date_time: None,
527            sequence_number: 7,
528            csi_data_len: 2,
529            data_format: RxCsiFmt::Undefined,
530            csi_data: vec![-1, 1],
531        };
532        let mut buf = vec![0u8; 1024];
533        let cobs = postcard::to_slice_cobs(&pkt, &mut buf).unwrap();
534        let body = cobs.strip_suffix(&[0]).unwrap_or(cobs);
535
536        let out = decode(body, ChipVariant::Esp32c6).unwrap();
537        assert_eq!(out.mac, [1, 2, 3, 4, 5, 6]);
538        assert_eq!(out.he_sigb_len, Some(7));
539        assert_eq!(out.rxmatch0, Some(1));
540        assert_eq!(out.sgi, None);
541        assert_eq!(out.csi_data, vec![-1, 1]);
542        assert!(out.date_time.is_none());
543    }
544
545    /// Wire-compat regression: a frame the firmware tags `Undefined` (the
546    /// discriminant is now 14 after the higher-format variants were dropped)
547    /// carrying a numeric `cur_bb_format` beyond the named set decodes cleanly,
548    /// with the raw `cur_bb_format` preserved for a profile to relabel.
549    #[test]
550    fn undefined_format_preserves_numeric_cur_bb_format() {
551        let pkt = PacketBc5 {
552            mac: [1, 2, 3, 4, 5, 6],
553            rssi: -50,
554            timestamp: 1,
555            rate: 1,
556            noise_floor: -90,
557            sig_len: 64,
558            rx_state: 0,
559            dump_len: 100,
560            cur_bb_format: 4,
561            rx_channel_estimate_info_vld: 1,
562            rx_channel_estimate_len: 64,
563            second: 0,
564            channel: 36,
565            is_group: 0,
566            rxend_state: 0,
567            rxmatch3: 0,
568            rxmatch2: 0,
569            rxmatch1: 0,
570            date_time: None,
571            sequence_number: 1,
572            csi_data_len: 2,
573            data_format: RxCsiFmt::Undefined,
574            csi_data: vec![1, -1],
575        };
576        let mut buf = vec![0u8; 1024];
577        let cobs = postcard::to_slice_cobs(&pkt, &mut buf).unwrap();
578        let body = cobs.strip_suffix(&[0]).unwrap_or(cobs);
579
580        let out = decode(body, ChipVariant::Esp32c5).unwrap();
581        assert_eq!(out.data_format, RxCsiFmt::Undefined);
582        assert_eq!(out.cur_bb_format, Some(4));
583    }
584
585    #[test]
586    fn chip_string_mapping() {
587        assert_eq!(ChipVariant::from_chip_str("ESP32"), Some(ChipVariant::Esp32Family));
588        assert_eq!(ChipVariant::from_chip_str("esp32c6"), Some(ChipVariant::Esp32c6));
589        assert_eq!(ChipVariant::from_chip_str("esp32c5"), Some(ChipVariant::Esp32c5));
590        assert_eq!(ChipVariant::from_chip_str("weird"), None);
591    }
592}