Skip to main content

ecr17_protocol/
codec.rs

1//! ECR17 packet framing: encode/decode `STX payload ETX LRC` application frames,
2//! `SOH message EOT` progress updates (no LRC), and `ACK`/`NAK` control frames.
3//!
4//! [`PacketCodec::decode`] treats the input buffer as **exactly one frame**. Frame shapes
5//! differ: an application frame ends with its LRC byte, a progress frame ends with `EOT`
6//! (no LRC), and a control frame is recognized by its lead byte. Splitting a coalesced
7//! byte stream into individual frames is the transport/session layer's responsibility,
8//! not the codec's.
9//!
10//! Port of the reference C++ `PacketCodec`.
11
12use crate::lrc::LrcMode;
13
14/// Start of an application frame.
15pub const STX: u8 = 0x02;
16/// End of the application payload (precedes the LRC).
17pub const ETX: u8 = 0x03;
18/// Start of a progress-update frame.
19pub const SOH: u8 = 0x01;
20/// End of a progress-update frame (its final byte; no LRC).
21pub const EOT: u8 = 0x04;
22/// Positive acknowledgement control byte.
23pub const ACK: u8 = 0x06;
24/// Negative acknowledgement control byte.
25pub const NAK: u8 = 0x15;
26
27/// The kind of frame [`PacketCodec::decode`] recognized.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum PacketType {
30    /// `STX payload ETX LRC` application message.
31    Application,
32    /// `SOH message EOT` procedure progress update (no LRC).
33    Progress,
34    /// Positive acknowledgement.
35    Ack,
36    /// Negative acknowledgement.
37    Nak,
38    /// Unrecognized / malformed / truncated / coalesced buffer.
39    Unknown,
40}
41
42/// A decoded frame. For [`PacketType::Application`], [`valid_lrc`](DecodedPacket::valid_lrc)
43/// reports whether the received LRC matched the recomputed one.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct DecodedPacket {
46    /// Recognized frame kind.
47    pub packet_type: PacketType,
48    /// Payload bytes (between the framing bytes); empty for control/unknown frames.
49    pub payload: Vec<u8>,
50    /// Whether the frame's LRC verified (always `true` for `ACK`/`NAK`/`PROGRESS`,
51    /// always `false` for `UNKNOWN`).
52    pub valid_lrc: bool,
53}
54
55impl DecodedPacket {
56    fn unknown() -> Self {
57        Self {
58            packet_type: PacketType::Unknown,
59            payload: Vec::new(),
60            valid_lrc: false,
61        }
62    }
63
64    fn control(packet_type: PacketType) -> Self {
65        Self {
66            packet_type,
67            payload: Vec::new(),
68            valid_lrc: true,
69        }
70    }
71}
72
73/// Frames and parses ECR17 packets for a given [`LrcMode`].
74#[derive(Debug, Clone, Copy)]
75pub struct PacketCodec {
76    lrc_mode: LrcMode,
77}
78
79impl PacketCodec {
80    /// Creates a codec that computes LRCs under `mode`.
81    #[must_use]
82    pub fn new(mode: LrcMode) -> Self {
83        Self { lrc_mode: mode }
84    }
85
86    /// The configured LRC mode.
87    #[must_use]
88    pub fn lrc_mode(&self) -> LrcMode {
89        self.lrc_mode
90    }
91
92    /// Encodes an application frame: `STX + payload + ETX + LRC`.
93    #[must_use]
94    pub fn encode_application(&self, payload: &[u8]) -> Vec<u8> {
95        let mut frame = Vec::with_capacity(payload.len() + 3);
96        frame.push(STX);
97        frame.extend_from_slice(payload);
98        frame.push(ETX);
99        frame.push(self.lrc_mode.compute(payload));
100        frame
101    }
102
103    /// Encodes a control frame: `ctrl + ETX + LRC`, where the LRC is computed over the
104    /// single-byte payload `[ctrl]` under the configured [`LrcMode`] (so it folds
105    /// `STX`/`ETX` exactly as an application-frame LRC would).
106    #[must_use]
107    pub fn encode_control(&self, ctrl: u8) -> Vec<u8> {
108        vec![ctrl, ETX, self.lrc_mode.compute(&[ctrl])]
109    }
110
111    /// Decodes exactly one frame from `data`.
112    ///
113    /// `ACK`/`NAK` are recognized by their **lead byte** only: on the wire a control
114    /// frame is `ctrl + ETX + LRC` (what [`encode_control`](Self::encode_control)
115    /// produces and what the session frames), so `[ACK, ETX, LRC]` decodes to
116    /// [`PacketType::Ack`]. This leniency is required — the session hands `decode` the
117    /// full 3-byte control frame, and requiring a bare 1-byte `ACK` would make every
118    /// transaction's ACK handshake fail. See `docs/LESSON.md`.
119    ///
120    /// For `STX`/`SOH` frames the buffer must be exactly one frame: more than one frame,
121    /// trailing bytes after the LRC, a missing LRC, or a progress frame not terminated by
122    /// `EOT` all decode to [`PacketType::Unknown`].
123    #[must_use]
124    pub fn decode(&self, data: &[u8]) -> DecodedPacket {
125        let Some(&first) = data.first() else {
126            return DecodedPacket::unknown();
127        };
128
129        match first {
130            ACK => return DecodedPacket::control(PacketType::Ack),
131            NAK => return DecodedPacket::control(PacketType::Nak),
132            _ => {}
133        }
134
135        if first == SOH {
136            // Progress update: SOH + message + EOT (no LRC). Need at least SOH and the
137            // trailing EOT, and the final byte MUST be EOT — reject garbage/truncation.
138            if data.len() < 2 || *data.last().unwrap() != EOT {
139                return DecodedPacket::unknown();
140            }
141            return DecodedPacket {
142                packet_type: PacketType::Progress,
143                payload: data[1..data.len() - 1].to_vec(),
144                valid_lrc: true,
145            };
146        }
147
148        if first == STX {
149            let Some(etx_index) = data.iter().position(|&b| b == ETX) else {
150                return DecodedPacket::unknown();
151            };
152            // A well-formed frame is exactly STX + payload + ETX + LRC, so the LRC must
153            // be the final byte. Reject truncation (no LRC) and any trailing bytes (e.g.
154            // a coalesced read holding a second frame) — framing a byte stream is the
155            // transport's job.
156            if etx_index + 2 != data.len() {
157                return DecodedPacket::unknown();
158            }
159            let payload = &data[1..etx_index];
160            let rx_lrc = data[etx_index + 1];
161            let calc_lrc = self.lrc_mode.compute(payload);
162            return DecodedPacket {
163                packet_type: PacketType::Application,
164                payload: payload.to_vec(),
165                valid_lrc: rx_lrc == calc_lrc,
166            };
167        }
168
169        DecodedPacket::unknown()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn encode_application_frames_stx_payload_etx_lrc() {
179        let codec = PacketCodec::new(LrcMode::Std);
180        let frame = codec.encode_application(b"AB");
181        assert_eq!(frame, vec![STX, b'A', b'B', ETX, 0x7C]); // 0x7F ^ 'A' ^ 'B'
182    }
183
184    #[test]
185    fn application_round_trip_all_modes() {
186        for mode in [
187            LrcMode::Stx,
188            LrcMode::Std,
189            LrcMode::Noext,
190            LrcMode::StxNoext,
191        ] {
192            let codec = PacketCodec::new(mode);
193            let payload = b"123456780P0000065000";
194            let decoded = codec.decode(&codec.encode_application(payload));
195            assert_eq!(decoded.packet_type, PacketType::Application);
196            assert_eq!(decoded.payload, payload);
197            assert!(decoded.valid_lrc);
198        }
199    }
200
201    #[test]
202    fn application_detects_corrupted_lrc() {
203        let codec = PacketCodec::new(LrcMode::Std);
204        let mut frame = codec.encode_application(b"HELLO");
205        *frame.last_mut().unwrap() ^= 0xFF; // corrupt the LRC byte
206        let decoded = codec.decode(&frame);
207        assert_eq!(decoded.packet_type, PacketType::Application);
208        assert_eq!(decoded.payload, b"HELLO");
209        assert!(!decoded.valid_lrc);
210    }
211
212    #[test]
213    fn encode_control_frames_ctrl_etx_lrc() {
214        let codec = PacketCodec::new(LrcMode::Std);
215        let frame = codec.encode_control(ACK);
216        assert_eq!(frame.len(), 3);
217        assert_eq!(frame[0], ACK);
218        assert_eq!(frame[1], ETX);
219    }
220
221    #[test]
222    fn decode_ack() {
223        let decoded = PacketCodec::new(LrcMode::Std).decode(&[ACK]);
224        assert_eq!(decoded.packet_type, PacketType::Ack);
225        assert!(decoded.valid_lrc);
226    }
227
228    #[test]
229    fn decode_nak() {
230        let decoded = PacketCodec::new(LrcMode::Std).decode(&[NAK]);
231        assert_eq!(decoded.packet_type, PacketType::Nak);
232        assert!(decoded.valid_lrc);
233    }
234
235    // 💰 Money-critical invariant: the real 3-byte control frame `ctrl + ETX + LRC`
236    // (what encode_control produces and what the session frames) MUST decode as
237    // ACK/NAK by lead byte. Requiring a bare 1-byte control frame here would make every
238    // transaction's ACK handshake fail. Do NOT "tighten" this to data.len() == 1.
239    #[test]
240    fn decode_full_control_frame_from_encode_control() {
241        let codec = PacketCodec::new(LrcMode::Std);
242        assert_eq!(
243            codec.decode(&codec.encode_control(ACK)).packet_type,
244            PacketType::Ack
245        );
246        assert_eq!(
247            codec.decode(&codec.encode_control(NAK)).packet_type,
248            PacketType::Nak
249        );
250    }
251
252    // Documents (and locks) the intentional leniency: a control reply is recognized by
253    // its lead byte, so trailing bytes are ignored and the control-frame LRC is NOT
254    // verified (the session only inspects the type). Mirrors the C++ reference.
255    #[test]
256    fn decode_control_is_lead_byte_only_ignoring_trailing_and_lrc() {
257        let codec = PacketCodec::new(LrcMode::Std);
258        // Trailing byte after ACK -> still Ack.
259        assert_eq!(codec.decode(&[ACK, 0x00]).packet_type, PacketType::Ack);
260        // 3-byte control frame with a WRONG LRC -> still Ack (control LRC not checked).
261        assert_eq!(codec.decode(&[ACK, ETX, 0xFF]).packet_type, PacketType::Ack);
262        // Same for NAK with arbitrary trailing bytes.
263        assert_eq!(
264            codec.decode(&[NAK, 0x99, 0x42]).packet_type,
265            PacketType::Nak
266        );
267    }
268
269    #[test]
270    fn decode_empty_is_unknown() {
271        let decoded = PacketCodec::new(LrcMode::Std).decode(&[]);
272        assert_eq!(decoded.packet_type, PacketType::Unknown);
273        assert!(!decoded.valid_lrc);
274    }
275
276    // Regression: a lone SOH byte must not build an inverted slice range -> panic.
277    #[test]
278    fn decode_lone_soh_is_unknown_not_panic() {
279        let decoded = PacketCodec::new(LrcMode::Std).decode(&[SOH]);
280        assert_eq!(decoded.packet_type, PacketType::Unknown);
281        assert!(!decoded.valid_lrc);
282    }
283
284    #[test]
285    fn decode_progress_update() {
286        let codec = PacketCodec::new(LrcMode::Std);
287        let msg = b"ELABORAZIONE...     "; // 20 chars per spec
288        let mut frame = vec![SOH];
289        frame.extend_from_slice(msg);
290        frame.push(EOT);
291        let decoded = codec.decode(&frame);
292        assert_eq!(decoded.packet_type, PacketType::Progress);
293        assert_eq!(decoded.payload, msg);
294    }
295
296    #[test]
297    fn decode_stx_without_etx_is_unknown() {
298        let decoded = PacketCodec::new(LrcMode::Std).decode(&[STX, b'A', b'B']);
299        assert_eq!(decoded.packet_type, PacketType::Unknown);
300        assert!(!decoded.valid_lrc);
301    }
302
303    // Regression: ETX present but no trailing LRC must not read past the end nor
304    // mistake ETX for the LRC.
305    #[test]
306    fn decode_stx_with_etx_but_no_lrc_is_unknown() {
307        let decoded = PacketCodec::new(LrcMode::Std).decode(&[STX, b'A', ETX]);
308        assert_eq!(decoded.packet_type, PacketType::Unknown);
309        assert!(!decoded.valid_lrc);
310    }
311
312    #[test]
313    fn decode_unknown_lead_byte() {
314        let decoded = PacketCodec::new(LrcMode::Std).decode(&[0x99, 0x00]);
315        assert_eq!(decoded.packet_type, PacketType::Unknown);
316        assert!(!decoded.valid_lrc);
317    }
318
319    // Regression: trailing bytes after a complete frame's LRC must not be accepted.
320    #[test]
321    fn decode_stx_with_trailing_bytes_after_lrc_is_unknown() {
322        let codec = PacketCodec::new(LrcMode::Std);
323        let mut frame = codec.encode_application(b"AB"); // STX A B ETX LRC
324        frame.push(0x00); // stray trailing byte
325        let decoded = codec.decode(&frame);
326        assert_eq!(decoded.packet_type, PacketType::Unknown);
327        assert!(!decoded.valid_lrc);
328    }
329
330    // Regression: a coalesced read holding two frames must not be reported as one
331    // valid APPLICATION packet (framing a byte stream is the transport's job).
332    #[test]
333    fn decode_coalesced_frames_is_unknown() {
334        let codec = PacketCodec::new(LrcMode::Std);
335        let mut first = codec.encode_application(b"AB");
336        first.extend_from_slice(&codec.encode_application(b"CD"));
337        let decoded = codec.decode(&first);
338        assert_eq!(decoded.packet_type, PacketType::Unknown);
339        assert!(!decoded.valid_lrc);
340    }
341
342    // Regression: SOH frame not terminated by EOT must not be accepted as PROGRESS.
343    #[test]
344    fn decode_soh_without_eot_is_unknown() {
345        let codec = PacketCodec::new(LrcMode::Std);
346        let mut frame = vec![SOH];
347        frame.extend_from_slice(b"ELABORAZIONE...     ");
348        frame.push(0xFF); // wrong terminator
349        let decoded = codec.decode(&frame);
350        assert_eq!(decoded.packet_type, PacketType::Unknown);
351        assert!(!decoded.valid_lrc);
352    }
353}