Skip to main content

irbt_proto/
codec.rs

1//! Framing: postcard body + CRC-16, COBS-encoded, `0x00`-delimited.
2
3use alloc::vec::Vec;
4use core::fmt;
5
6use crc::{Crc, CRC_16_IBM_SDLC};
7
8use crate::Frame;
9
10const CRC: Crc<u16> = Crc::<u16>::new(&CRC_16_IBM_SDLC);
11const CRC_LEN: usize = 2;
12
13/// Largest postcard body + CRC accepted on either side. Keeps the device's
14/// buffers bounded; senders must split anything larger (see `ListKeymap`).
15pub const MAX_FRAME_LEN: usize = 512;
16
17/// COBS adds one byte per 254 bytes of input, plus one.
18const MAX_ENCODED_LEN: usize = MAX_FRAME_LEN + MAX_FRAME_LEN / 254 + 1;
19
20#[derive(Debug)]
21pub enum EncodeError {
22    Serialize(postcard::Error),
23    TooLarge(usize),
24}
25
26impl fmt::Display for EncodeError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            EncodeError::Serialize(e) => write!(f, "serialization failed: {e}"),
30            EncodeError::TooLarge(len) => {
31                write!(f, "frame is {len} bytes, max is {MAX_FRAME_LEN}")
32            }
33        }
34    }
35}
36
37/// Encode `frame` into a complete wire frame, delimiters included.
38///
39/// # Errors
40///
41/// [`EncodeError::Serialize`] if postcard can't serialize `frame`, or
42/// [`EncodeError::TooLarge`] if the result exceeds [`MAX_FRAME_LEN`].
43pub fn encode(frame: &Frame) -> Result<Vec<u8>, EncodeError> {
44    let mut body = postcard::to_allocvec(frame).map_err(EncodeError::Serialize)?;
45    body.extend_from_slice(&CRC.checksum(&body).to_le_bytes());
46    if body.len() > MAX_FRAME_LEN {
47        return Err(EncodeError::TooLarge(body.len()));
48    }
49
50    let mut out = Vec::with_capacity(body.len() + body.len() / 254 + 3);
51    out.push(0);
52    cobs_encode(&body, &mut out);
53    out.push(0);
54    Ok(out)
55}
56
57/// One item produced by [`Decoder`].
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Decoded {
60    Frame(Frame),
61    /// Bytes that didn't form a valid frame: plain-text output such as boot
62    /// or panic messages, or a corrupted/truncated frame.
63    Raw(Vec<u8>),
64}
65
66/// Streaming frame decoder. Feed it bytes as they arrive.
67#[derive(Debug, Default)]
68pub struct Decoder {
69    buf: Vec<u8>,
70}
71
72impl Decoder {
73    #[must_use]
74    pub const fn new() -> Self {
75        Self { buf: Vec::new() }
76    }
77
78    pub fn push(&mut self, byte: u8) -> Option<Decoded> {
79        if byte == 0 {
80            return self.take().map(|data| match decode_body(&data) {
81                Some(frame) => Decoded::Frame(frame),
82                None => Decoded::Raw(data),
83            });
84        }
85
86        // Too long to be a frame: hand what we have over as raw output and
87        // start again, so plain-text output can't grow the buffer unbounded.
88        let overflow = if self.buf.len() >= MAX_ENCODED_LEN {
89            self.take().map(Decoded::Raw)
90        } else {
91            None
92        };
93        self.buf.push(byte);
94        overflow
95    }
96
97    /// Return any buffered bytes as [`Decoded::Raw`]. Call this when the
98    /// input has been idle for a while, so plain text that isn't followed by
99    /// a frame delimiter still gets delivered.
100    pub fn flush(&mut self) -> Option<Decoded> {
101        self.take().map(Decoded::Raw)
102    }
103
104    fn take(&mut self) -> Option<Vec<u8>> {
105        (!self.buf.is_empty()).then(|| core::mem::take(&mut self.buf))
106    }
107}
108
109fn decode_body(encoded: &[u8]) -> Option<Frame> {
110    let mut body = Vec::with_capacity(encoded.len());
111    cobs_decode(encoded, &mut body)?;
112
113    let split = body.len().checked_sub(CRC_LEN)?;
114    let (payload, crc) = body.split_at(split);
115    if CRC.checksum(payload).to_le_bytes() != crc {
116        return None;
117    }
118
119    match postcard::take_from_bytes::<Frame>(payload) {
120        Ok((frame, [])) => Some(frame),
121        _ => None,
122    }
123}
124
125fn cobs_encode(src: &[u8], out: &mut Vec<u8>) {
126    let mut code_at = out.len();
127    let mut code = 1u8;
128    out.push(0);
129
130    for &byte in src {
131        if byte != 0 {
132            out.push(byte);
133            code += 1;
134        }
135        if byte == 0 || code == 0xFF {
136            out[code_at] = code;
137            code_at = out.len();
138            code = 1;
139            out.push(0);
140        }
141    }
142    out[code_at] = code;
143}
144
145fn cobs_decode(src: &[u8], out: &mut Vec<u8>) -> Option<()> {
146    let mut i = 0;
147    while i < src.len() {
148        let code = usize::from(src[i]);
149        if code == 0 {
150            return None;
151        }
152        let end = i + code;
153        out.extend_from_slice(src.get(i + 1..end)?);
154        i = end;
155        if code != 0xFF && i < src.len() {
156            out.push(0);
157        }
158    }
159    Some(())
160}
161
162#[cfg(test)]
163mod tests {
164    use alloc::string::String;
165    use alloc::vec;
166
167    use super::*;
168    use crate::*;
169
170    fn decode_all(decoder: &mut Decoder, bytes: &[u8]) -> Vec<Decoded> {
171        bytes.iter().filter_map(|&b| decoder.push(b)).collect()
172    }
173
174    fn sample_frames() -> Vec<Frame> {
175        let mapping = Mapping {
176            ir: 0x00FF_02FD,
177            key: HidKey::Home,
178        };
179        let commands = [
180            Command::Ping,
181            Command::GetInfo,
182            Command::GetStatus,
183            Command::ListKeymap { offset: 3 },
184            Command::SetMapping(mapping),
185            Command::RemoveMapping { ir: 0x1234_5678 },
186            Command::ClearKeymap,
187            Command::ResetKeymap,
188            Command::Learn {
189                key: HidKey::VolumeUp,
190                timeout_ms: 10_000,
191            },
192            Command::CancelLearn,
193            Command::SendHid { key: HidKey::Up },
194            Command::SendIr { code: 0x20DF_10EF },
195            Command::SetMonitor { enabled: true },
196            Command::GetBuildInfo,
197        ];
198        let replies = [
199            Ok(Reply::Ok),
200            Ok(Reply::Pong),
201            Ok(Reply::Info(DeviceInfo {
202                protocol_version: PROTOCOL_VERSION,
203                firmware_version: "0.1.0".into(),
204                max_mappings: 64,
205            })),
206            Ok(Reply::Status(Status {
207                uptime_ms: 123_456_789,
208                ble_connected: true,
209                mappings: 12,
210                monitor: false,
211                learning: Some(HidKey::Back),
212                battery: Some(Battery {
213                    millivolts: 4_380,
214                    percent: Some(85),
215                }),
216            })),
217            Ok(Reply::Keymap(KeymapPage {
218                offset: 0,
219                total: 2,
220                entries: vec![mapping; 2],
221            })),
222            Ok(Reply::BuildInfo(BuildInfo {
223                git: "c7f30e8-dirty 2026-10-27".into(),
224                components: vec![Component {
225                    name: "ESP-IDF".into(),
226                    version: "v5.5.6".into(),
227                }],
228            })),
229            Err(String::from("nope")),
230        ];
231        let events = [
232            Event::IrReceived {
233                code: 0xFFFF_FFFF,
234                repeat: true,
235                key: None,
236            },
237            Event::Learned(mapping),
238            Event::LearnTimedOut,
239            Event::BleConnection { connected: false },
240            Event::LearnFailed {
241                reason: "keymap full".into(),
242            },
243        ];
244
245        let mut frames: Vec<Frame> = commands
246            .into_iter()
247            .enumerate()
248            .map(|(id, command)| {
249                Frame::Request(Request {
250                    id: u32::try_from(id).unwrap(),
251                    command,
252                })
253            })
254            .collect();
255        frames.extend(
256            replies
257                .into_iter()
258                .map(|result| Frame::Response(Response { id: 7, result })),
259        );
260        frames.extend(events.into_iter().map(Frame::Event));
261        frames.push(Frame::Log(Log {
262            level: LogLevel::Warn,
263            message: "W (42) ir_bt_bridge: hello\0with nul".into(),
264        }));
265        frames
266    }
267
268    #[test]
269    fn round_trips_every_frame() {
270        let mut decoder = Decoder::new();
271        for frame in sample_frames() {
272            let wire = encode(&frame).unwrap();
273            assert_eq!(wire.first(), Some(&0));
274            assert_eq!(wire.last(), Some(&0));
275            assert!(!wire[1..wire.len() - 1].contains(&0));
276            assert_eq!(decode_all(&mut decoder, &wire), vec![Decoded::Frame(frame)]);
277        }
278    }
279
280    #[test]
281    fn cobs_round_trips_edge_lengths() {
282        for len in [0, 1, 253, 254, 255, 256, 508, 600] {
283            for fill in [0u8, 1, 0xFF] {
284                let src: Vec<u8> = (0..len)
285                    .map(|i| {
286                        if i % 7 == 3 {
287                            fill
288                        } else {
289                            u8::try_from(i % 251 + 1).unwrap()
290                        }
291                    })
292                    .collect();
293                let mut encoded = Vec::new();
294                cobs_encode(&src, &mut encoded);
295                assert!(!encoded.contains(&0), "len {len} fill {fill}");
296                let mut decoded = Vec::new();
297                cobs_decode(&encoded, &mut decoded).unwrap();
298                assert_eq!(decoded, src, "len {len} fill {fill}");
299            }
300        }
301    }
302
303    #[test]
304    fn text_between_frames_comes_out_raw() {
305        let ping = Frame::Request(Request {
306            id: 1,
307            command: Command::Ping,
308        });
309        let mut stream = b"ESP-ROM:esp32s3-20210327\r\n".to_vec();
310        stream.extend(encode(&ping).unwrap());
311        stream.extend(b"panic text");
312
313        let mut decoder = Decoder::new();
314        assert_eq!(
315            decode_all(&mut decoder, &stream),
316            vec![
317                Decoded::Raw(b"ESP-ROM:esp32s3-20210327\r\n".to_vec()),
318                Decoded::Frame(ping),
319            ]
320        );
321        assert_eq!(decoder.flush(), Some(Decoded::Raw(b"panic text".to_vec())));
322        assert_eq!(decoder.flush(), None);
323    }
324
325    #[test]
326    fn corrupted_frame_is_rejected_and_decoder_resyncs() {
327        let frame = Frame::Event(Event::LearnTimedOut);
328        let mut bad = encode(&frame).unwrap();
329        bad[2] ^= 0x40;
330        let mut stream = bad.clone();
331        stream.extend(encode(&frame).unwrap());
332
333        let mut decoder = Decoder::new();
334        assert_eq!(
335            decode_all(&mut decoder, &stream),
336            vec![
337                Decoded::Raw(bad[1..bad.len() - 1].to_vec()),
338                Decoded::Frame(frame),
339            ]
340        );
341    }
342
343    #[test]
344    fn frame_split_across_reads_decodes() {
345        let frame = Frame::Request(Request {
346            id: 99,
347            command: Command::SendHid {
348                key: HidKey::PlayPause,
349            },
350        });
351        let wire = encode(&frame).unwrap();
352        let (a, b) = wire.split_at(wire.len() / 2);
353
354        let mut decoder = Decoder::new();
355        assert_eq!(decode_all(&mut decoder, a), []);
356        assert_eq!(decode_all(&mut decoder, b), vec![Decoded::Frame(frame)]);
357    }
358
359    #[test]
360    fn endless_text_is_flushed_in_bounded_chunks() {
361        let mut decoder = Decoder::new();
362        let out = decode_all(&mut decoder, &[b'x'; 3 * MAX_ENCODED_LEN]);
363        assert_eq!(out.len(), 2);
364        assert!(out
365            .iter()
366            .all(|d| matches!(d, Decoded::Raw(r) if r.len() == MAX_ENCODED_LEN)));
367    }
368
369    #[test]
370    fn oversized_frame_is_refused() {
371        let frame = Frame::Log(Log {
372            level: LogLevel::Info,
373            message: "x".repeat(MAX_FRAME_LEN),
374        });
375        assert!(matches!(encode(&frame), Err(EncodeError::TooLarge(_))));
376    }
377
378    /// Pins the wire encoding. If this fails you changed the protocol ABI:
379    /// append instead of reordering, or bump `PROTOCOL_VERSION`.
380    #[test]
381    fn golden_encodings() {
382        let ping = Frame::Request(Request {
383            id: 1,
384            command: Command::Ping,
385        });
386        assert_eq!(postcard::to_allocvec(&ping).unwrap(), [0, 1, 0]);
387
388        let set = Frame::Request(Request {
389            id: 300,
390            command: Command::SetMapping(Mapping {
391                ir: 0x00FF_02FD,
392                key: HidKey::Home,
393            }),
394        });
395        // id 300 = varint [0xAC, 0x02]; command 4; ir varint; key 6
396        assert_eq!(
397            postcard::to_allocvec(&set).unwrap(),
398            [0, 0xAC, 0x02, 4, 0xFD, 0x85, 0xFC, 0x07, 6]
399        );
400
401        let err = Frame::Response(Response {
402            id: 2,
403            result: Err("e".into()),
404        });
405        assert_eq!(postcard::to_allocvec(&err).unwrap(), [1, 2, 1, 1, b'e']);
406
407        assert_eq!(
408            postcard::to_allocvec(&Frame::Event(Event::LearnTimedOut)).unwrap(),
409            [2, 2]
410        );
411
412        let build_info = Frame::Request(Request {
413            id: 3,
414            command: Command::GetBuildInfo,
415        });
416        assert_eq!(postcard::to_allocvec(&build_info).unwrap(), [0, 3, 13]);
417
418        let status = Frame::Response(Response {
419            id: 4,
420            result: Ok(Reply::Status(Status {
421                uptime_ms: 1,
422                ble_connected: true,
423                mappings: 2,
424                monitor: false,
425                learning: None,
426                battery: Some(Battery {
427                    millivolts: 4_380,
428                    percent: Some(85),
429                }),
430            })),
431        });
432        // Response 1, id 4, Ok 0, Status 3, uptime, ble, mappings, monitor,
433        // learning None, battery Some, 4380 mV = varint [0x9C, 0x22], Some 85
434        assert_eq!(
435            postcard::to_allocvec(&status).unwrap(),
436            [1, 4, 0, 3, 1, 1, 2, 0, 0, 1, 0x9C, 0x22, 1, 85]
437        );
438        // COBS([0, 1, 0, crc16_le]) between delimiters
439        assert_eq!(
440            encode(&ping).unwrap(),
441            [0, 0x01, 0x02, 0x01, 0x03, 0x14, 0xDF, 0]
442        );
443    }
444
445    #[test]
446    fn hid_key_indices_are_stable() {
447        for (index, key) in HidKey::ALL.into_iter().enumerate() {
448            assert_eq!(
449                postcard::to_allocvec(&key).unwrap(),
450                [u8::try_from(index).unwrap()]
451            );
452        }
453    }
454
455    #[test]
456    fn hid_key_serde_names_match_cli_names() {
457        for key in HidKey::ALL {
458            let json = serde_json::to_string(&key).unwrap();
459            assert_eq!(json, alloc::format!("\"{}\"", key.name()));
460        }
461    }
462
463    #[test]
464    fn hid_key_names_parse_back() {
465        for key in HidKey::ALL {
466            assert_eq!(key.name().parse(), Ok(key));
467        }
468        assert_eq!("VOL_UP".parse(), Ok(HidKey::VolumeUp));
469        assert_eq!("volup".parse::<HidKey>(), Err(UnknownKey));
470    }
471}