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}