kenwood_thd75/protocol/
codec.rs

1//! Frame-level codec for `\r`-terminated CAT protocol messages.
2//!
3//! The TH-D75 CAT protocol uses carriage return (`\r`, 0x0D) as the
4//! frame delimiter for both commands and responses. Each message is a
5//! sequence of ASCII bytes terminated by a single `\r`. There is no
6//! length prefix or checksum — framing relies entirely on the delimiter.
7//!
8//! This codec sits between the raw serial byte stream and the protocol
9//! parser. The data flow is:
10//!
11//! ```text
12//! Serial port  -->  Codec::feed()  -->  Codec::next_frame()  -->  parse()
13//!              raw bytes          buffered             complete frame    typed Response
14//! ```
15//!
16//! On the transmit side, [`super::serialize`] produces the wire bytes
17//! (including the trailing `\r`) that are written directly to the serial
18//! port — the codec is not involved in outbound framing.
19//!
20//! The codec maintains an internal buffer that accumulates bytes from
21//! successive [`Codec::feed`] calls. When [`Codec::next_frame`] finds a
22//! `\r`, it extracts everything before it as a complete frame (without
23//! the delimiter) and drains those bytes from the buffer. The buffer is
24//! capped at 64 KB to prevent unbounded growth if the serial link
25//! delivers noise without any `\r` terminators.
26
27/// Frame-level codec for `\r`-terminated CAT protocol messages.
28///
29/// Buffers incoming bytes and emits complete frames. Handles partial
30/// reads gracefully — the radio may send responses in multiple chunks.
31#[derive(Debug)]
32pub struct Codec {
33    buffer: Vec<u8>,
34}
35
36impl Codec {
37    /// Creates a new codec with an empty buffer.
38    #[must_use]
39    pub const fn new() -> Self {
40        Self { buffer: Vec::new() }
41    }
42
43    /// Maximum buffer size (64 KB). Prevents unbounded growth if the
44    /// radio never sends a `\r` terminator (e.g., corrupted serial link).
45    const MAX_BUFFER: usize = 64 * 1024;
46
47    /// Appends raw bytes to the internal buffer.
48    ///
49    /// If the buffer would exceed 64 KB, it is truncated to prevent
50    /// unbounded memory growth.
51    pub fn feed(&mut self, data: &[u8]) {
52        tracing::trace!(bytes = data.len(), "codec: feeding bytes");
53        self.buffer.extend_from_slice(data);
54        if self.buffer.len() > Self::MAX_BUFFER {
55            tracing::warn!(
56                len = self.buffer.len(),
57                "codec buffer exceeded max size, truncating"
58            );
59            drop(self.buffer.drain(..self.buffer.len() - Self::MAX_BUFFER));
60        }
61    }
62
63    /// Extracts the next complete frame from the buffer, if available.
64    ///
65    /// Searches for a `\r` delimiter, extracts everything before it as a
66    /// frame (without the trailing `\r`), and removes the consumed bytes
67    /// from the buffer. Returns `None` if no complete frame is available.
68    pub fn next_frame(&mut self) -> Option<Vec<u8>> {
69        let pos = self.buffer.iter().position(|&b| b == b'\r')?;
70        let frame = self.buffer[..pos].to_vec();
71        let _ = self.buffer.drain(..=pos);
72        tracing::debug!(frame_len = frame.len(), "codec: extracted frame");
73        tracing::trace!(frame = %String::from_utf8_lossy(&frame), "codec: frame content");
74        Some(frame)
75    }
76}
77
78impl Default for Codec {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn single_complete_frame() {
90        let mut codec = Codec::new();
91        codec.feed(b"FV 1.03.000\r");
92        assert_eq!(codec.next_frame(), Some(b"FV 1.03.000".to_vec()));
93        assert_eq!(codec.next_frame(), None);
94    }
95
96    #[test]
97    fn partial_then_complete() {
98        let mut codec = Codec::new();
99        codec.feed(b"FV 1.0");
100        assert_eq!(codec.next_frame(), None);
101        codec.feed(b"3.000\r");
102        assert_eq!(codec.next_frame(), Some(b"FV 1.03.000".to_vec()));
103    }
104
105    #[test]
106    fn multiple_frames_in_one_feed() {
107        let mut codec = Codec::new();
108        codec.feed(b"ID TH-D75\rFV 1.03.000\r");
109        assert_eq!(codec.next_frame(), Some(b"ID TH-D75".to_vec()));
110        assert_eq!(codec.next_frame(), Some(b"FV 1.03.000".to_vec()));
111        assert_eq!(codec.next_frame(), None);
112    }
113
114    #[test]
115    fn error_frame() {
116        let mut codec = Codec::new();
117        codec.feed(b"?\r");
118        assert_eq!(codec.next_frame(), Some(b"?".to_vec()));
119    }
120
121    #[test]
122    fn empty_feed() {
123        let mut codec = Codec::new();
124        codec.feed(b"");
125        assert_eq!(codec.next_frame(), None);
126    }
127
128    #[test]
129    fn frame_with_commas() {
130        let mut codec = Codec::new();
131        codec.feed(b"FO 0,0145000000,0000600000,0,0,0,0,0,0,0,0,0,0,2,08,08,000,0,CQCQCQ,0,00\r");
132        let frame = codec.next_frame().unwrap();
133        assert!(frame.starts_with(b"FO"));
134    }
135
136    #[test]
137    fn buffer_capped_at_max_size() {
138        let mut codec = Codec::new();
139        // Feed >64KB without a \r terminator
140        let chunk = [b'A'; 4096];
141        for _ in 0..20 {
142            codec.feed(&chunk); // 20 * 4096 = 80KB
143        }
144        assert!(codec.buffer.len() <= Codec::MAX_BUFFER);
145        // No frame should be extractable (no \r in the noise)
146        assert_eq!(codec.next_frame(), None);
147    }
148}