Skip to main content

sdr_acars/
frame.rs

1//! ACARS frame parser. Bit-by-bit streaming state machine that
2//! consumes the output of [`crate::msk::MskDemod`] and emits
3//! [`AcarsMessage`]s when complete frames pass parity + CRC
4//! (with optional FEC recovery via [`crate::syndrom`]).
5//!
6//! Faithful port of acarsdec's `acars.c::decodeAcars`,
7//! restructured into a single-threaded sync emitter (the C
8//! version uses a worker thread + condition variable; we
9//! pass messages out via a callback to keep the API simple
10//! and avoid threading constraints inside the library crate).
11
12use std::time::SystemTime;
13
14use arrayvec::ArrayString;
15
16use crate::msk::BitSink;
17
18// ACARS framing constants. These match acarsdec's `acars.c`
19// L22-27 verbatim; note that ETX and ETB include the high parity
20// bit (`0x03 | 0x80 = 0x83` and `0x17 | 0x80 = 0x97`) because the
21// MSK demod hands bytes to the parser **with** parity intact.
22const SYN: u8 = 0x16;
23const SYN_INV: u8 = !SYN; // 0xE9
24const SOH: u8 = 0x01;
25const ETX: u8 = 0x83; // 0x03 + odd parity
26const ETB: u8 = 0x97; // 0x17 + odd parity
27const DLE: u8 = 0x7F;
28
29/// Maximum frame body length (Mode through ETX/ETB inclusive)
30/// before the parser gives up and resets. Mirrors `acars.c:334`.
31const MAX_FRAME_LEN: usize = 240;
32
33/// Minimum buffer length before the DLE-escape recovery path is
34/// considered. Mirrors `acars.c:324`.
35const DLE_ESCAPE_MIN_LEN: usize = 20;
36
37/// One decoded ACARS message.
38#[derive(Clone, Debug)]
39pub struct AcarsMessage {
40    /// Wall-clock time when the closing bit arrived.
41    pub timestamp: SystemTime,
42    /// Channel index this message came from. `0` for the
43    /// single-channel WAV-input path; `0..N` for `ChannelBank`.
44    pub channel_idx: u8,
45    /// Channel center frequency (Hz). `0.0` if unknown
46    /// (e.g. WAV input where no center is supplied).
47    pub freq_hz: f64,
48    /// Matched-filter output magnitude in dB. Volatile —
49    /// stripped from e2e diff. Filled in by `ChannelBank`; the
50    /// parser leaves it at `0.0`.
51    pub level_db: f32,
52    /// Number of bytes corrected by parity FEC. Volatile —
53    /// stripped from e2e diff.
54    pub error_count: u8,
55    /// Mode character (acarsdec field).
56    pub mode: u8,
57    /// 2-byte label code (e.g. b"H1").
58    pub label: [u8; 2],
59    /// Block ID (acarsdec field).
60    pub block_id: u8,
61    /// ACK character (acarsdec field).
62    pub ack: u8,
63    /// Aircraft registration including leading dot, e.g.
64    /// ".N12345". 7 chars + leading dot = up to 8 chars.
65    pub aircraft: ArrayString<8>,
66    /// Optional flight ID (downlink only). 6 chars max.
67    pub flight_id: Option<ArrayString<7>>,
68    /// Optional message number. 4 chars max.
69    pub message_no: Option<ArrayString<5>>,
70    /// Variable-length text body. Up to ~220 bytes.
71    pub text: String,
72    /// `true` if the closing byte was `ETX` (final block);
73    /// `false` if `ETB` (multi-block, more to come).
74    pub end_of_message: bool,
75    /// Number of frames that were reassembled into this
76    /// message by [`crate::reassembly::MessageAssembler`]. `1`
77    /// for a single-block message (the parser's default — no
78    /// reassembly took place); `≥ 2` when an ETB chain was
79    /// merged into a single logical message. Surfaced for the
80    /// caller's "[N blocks]" indicator.
81    pub reassembled_block_count: u8,
82    /// OOOI metadata (origin/destination airports + event
83    /// times) extracted from `text` based on `label`. `None`
84    /// if the label has no parser, validation failed, or the
85    /// text was too short. Populated post-reassembly by
86    /// [`crate::ChannelBank::process`] so multi-block messages
87    /// parse the concatenated text.
88    pub parsed: Option<crate::label_parsers::Oooi>,
89}
90
91/// Internal state of the byte-level state machine. Mirrors
92/// the enum in acars.c:88 (we collapse the trivial `END` state
93/// into "go directly back to `WaitingSyn`" since `Crc2` success
94/// already does that and the C only used END as a one-byte
95/// holdover before resetting).
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97enum State {
98    WaitingSyn,
99    Syn2,
100    SeekingSoh,
101    Text,
102    Crc1,
103    Crc2,
104}
105
106/// Frame parser. One per channel.
107pub struct FrameParser {
108    state: State,
109    /// Bits accumulated for the current byte (LSB-first).
110    out_bits: u8,
111    /// How many bits remain to fill `out_bits`. **Critical**:
112    /// the state machine sets this to 1 in `reset_to_idle` so
113    /// `BitSink::put_bit` per-bit re-syncs (each new bit
114    /// produces a shifted byte candidate the state machine
115    /// re-evaluates). `put_bit` MUST drive `consume_byte`
116    /// synchronously — buffering bytes between MSK demod and
117    /// state machine breaks the re-sync (we lose 7 of every 8
118    /// bit-shift candidates). Mirrors C `acars.c::putbit` +
119    /// `decodeAcars` running per-bit interleaved.
120    n_bits: u8,
121    /// Bytes accumulated for the current frame: Mode through
122    /// the trailing ETX/ETB inclusive. NOT including the
123    /// 2-byte BCS — those land in `crc_bytes`.
124    buf: Vec<u8>,
125    /// Per-character parity error positions in `buf`. Used by
126    /// `fix_parity_errors` at CRC2 verify time.
127    parity_errors: Vec<usize>,
128    /// Running parity-error count (acarsdec `blk->err`). Used
129    /// for the `> MAXPERR + 1` abort check during TXT.
130    parity_err_count: u8,
131    /// The two BCS bytes captured during CRC1 + CRC2 states.
132    /// `[crc_low, crc_high]` matching ACARS wire order.
133    crc_bytes: [u8; 2],
134    /// Polarity-flip flag set when WSYN/SYN2 sees `~SYN` (0xE9).
135    /// `ChannelBank::process` polls and clears via
136    /// `take_polarity_flip()` after each demod block.
137    polarity_flip_pending: bool,
138    /// Decoded messages awaiting `drain()`. `BitSink::put_bit`
139    /// drives `consume_byte` synchronously (so per-bit re-sync
140    /// works); decoded messages buffer here until the caller
141    /// pulls them out.
142    pending_messages: std::collections::VecDeque<AcarsMessage>,
143    /// Channel index to stamp into emitted messages.
144    channel_idx: u8,
145    /// Channel center frequency to stamp into emitted messages.
146    channel_freq_hz: f64,
147}
148
149impl FrameParser {
150    /// Create a parser stamping the given channel index + freq
151    /// onto every emitted message.
152    #[must_use]
153    pub fn new(channel_idx: u8, channel_freq_hz: f64) -> Self {
154        Self {
155            state: State::WaitingSyn,
156            out_bits: 0,
157            n_bits: 8,
158            buf: Vec::with_capacity(256),
159            parity_errors: Vec::new(),
160            parity_err_count: 0,
161            crc_bytes: [0, 0],
162            polarity_flip_pending: false,
163            pending_messages: std::collections::VecDeque::new(),
164            channel_idx,
165            channel_freq_hz,
166        }
167    }
168
169    /// Reset to look for the next frame's preamble. Called
170    /// internally on completion or on a hard sync loss
171    /// (parity-error overrun, frame-too-long, malformed sync,
172    /// etc.). Mirrors `acars.c::resetAcars` (L239-244) plus
173    /// our own buf/parity-errors clear.
174    ///
175    /// **Critical: does NOT clear `out_bits`.** acarsdec's
176    /// `resetAcars` only touches state + nbits — leaving the
177    /// byte register intact is what makes per-bit re-sync
178    /// work: a new single bit shifts the existing register one
179    /// position, producing a fresh 8-bit candidate the state
180    /// machine evaluates against SYN. Clearing here would
181    /// prevent re-sync from a false-positive SYN.
182    fn reset_to_idle(&mut self) {
183        self.state = State::WaitingSyn;
184        // C `resetAcars` sets nbits=1 (per-bit re-sync).
185        self.n_bits = 1;
186        self.buf.clear();
187        self.parity_errors.clear();
188        self.parity_err_count = 0;
189        self.crc_bytes = [0, 0];
190    }
191
192    /// Polarity-flip handshake. `ChannelBank` reads + clears this
193    /// after each `MskDemod::process` round; if true, it calls
194    /// `MskDemod::toggle_polarity()` to recover from 180° phase
195    /// slip detected via the inverted-SYN preamble.
196    pub fn take_polarity_flip(&mut self) -> bool {
197        std::mem::replace(&mut self.polarity_flip_pending, false)
198    }
199
200    /// Drain decoded messages buffered by synchronous
201    /// `BitSink::put_bit` → `consume_byte` runs. Production
202    /// callers (`ChannelBank::process`) invoke this after each
203    /// demod block. Tests use `feed_bytes()` instead.
204    pub fn drain<F: FnMut(AcarsMessage)>(&mut self, mut on_message: F) {
205        while let Some(msg) = self.pending_messages.pop_front() {
206            on_message(msg);
207        }
208    }
209
210    /// Consume one fully-assembled byte. Drives the state
211    /// machine; pushes an `AcarsMessage` onto `pending_messages`
212    /// when CRC2 closes a successful frame. Mirrors the byte-
213    /// level switch in `acars.c::decodeAcars` (L246-388). The C
214    /// `decodeAcars` runs SYNCHRONOUSLY per byte from `putbit` —
215    /// our Rust port does the same via this method being called
216    /// from `BitSink::put_bit` (NOT buffered for later) so the
217    /// `n_bits = 1` per-bit re-sync semantic in `reset_to_idle`
218    /// works correctly.
219    fn consume_byte(&mut self, byte: u8) {
220        match self.state {
221            // acars.c:252-265
222            State::WaitingSyn => {
223                if byte == SYN {
224                    self.state = State::Syn2;
225                    self.n_bits = 8;
226                } else if byte == SYN_INV {
227                    // Inverted SYN: 180° phase slip. Signal upper
228                    // layer to flip polarity; advance state.
229                    self.polarity_flip_pending = true;
230                    self.state = State::Syn2;
231                    self.n_bits = 8;
232                } else {
233                    // No sync — keep advancing one bit at a time.
234                    self.n_bits = 1;
235                }
236            }
237            // acars.c:267-279
238            State::Syn2 => {
239                if byte == SYN {
240                    self.state = State::SeekingSoh;
241                    self.n_bits = 8;
242                } else if byte == SYN_INV {
243                    // Inverted SYN at SYN2: still polarity slip,
244                    // stay in SYN2 (matches the C — no state
245                    // transition here, only the polarity flip).
246                    self.polarity_flip_pending = true;
247                    self.n_bits = 8;
248                } else {
249                    self.reset_to_idle();
250                }
251            }
252            // acars.c:281-301
253            State::SeekingSoh => {
254                if byte == SOH {
255                    // Frame start: reset accumulators and enter TXT.
256                    self.buf.clear();
257                    self.parity_errors.clear();
258                    self.parity_err_count = 0;
259                    self.crc_bytes = [0, 0];
260                    self.state = State::Text;
261                    self.n_bits = 8;
262                } else {
263                    self.reset_to_idle();
264                }
265            }
266            // acars.c:303-341
267            State::Text => {
268                self.buf.push(byte);
269                let pos = self.buf.len() - 1;
270                if !has_odd_parity(byte) {
271                    self.parity_err_count = self.parity_err_count.saturating_add(1);
272                    self.parity_errors.push(pos);
273                    if usize::from(self.parity_err_count) > crate::syndrom::MAX_PARITY_ERRORS + 1 {
274                        // Too many parity errors — bail.
275                        self.reset_to_idle();
276                        return;
277                    }
278                }
279                if byte == ETX || byte == ETB {
280                    self.state = State::Crc1;
281                    self.n_bits = 8;
282                    return;
283                }
284                // DLE escape recovery (acars.c:324-332): if we've
285                // accumulated more than 20 bytes and see a DLE, we
286                // treat the previous 3 bytes as `padding | crc[0] |
287                // crc[1]` (the C truncates len by 3 and copies
288                // txt[len] / txt[len+1] into crc[0] / crc[1] — note
289                // that means `padding` is whatever was at the new
290                // `txt[len-1]` and is left in place — implementer
291                // matches the C even though it looks odd).
292                if self.buf.len() > DLE_ESCAPE_MIN_LEN && byte == DLE {
293                    let new_len = self.buf.len() - 3;
294                    // Capture crc[0] and crc[1] from the now-trimmed
295                    // tail. C: crc[0] = txt[len], crc[1] = txt[len+1]
296                    // where `len` is the post-truncation length.
297                    self.crc_bytes[0] = self.buf[new_len];
298                    self.crc_bytes[1] = self.buf[new_len + 1];
299                    self.buf.truncate(new_len);
300                    // Drop parity-error offsets that pointed into the
301                    // 3 bytes we just removed; otherwise
302                    // fix_parity_errors would index past frame.len()
303                    // in finalize_frame (panic in debug, wrong-bit
304                    // flip / syndrome OOB in release). Sync the
305                    // running count so the AcarsMessage error_count
306                    // stays accurate.
307                    self.parity_errors.retain(|&pos| pos < new_len);
308                    self.parity_err_count =
309                        u8::try_from(self.parity_errors.len()).unwrap_or(u8::MAX);
310                    // Jump straight to the CRC-verify / putmsg path.
311                    self.finalize_frame();
312                    return;
313                }
314                if self.buf.len() > MAX_FRAME_LEN {
315                    self.reset_to_idle();
316                    return;
317                }
318                self.n_bits = 8;
319            }
320            // acars.c:343-347
321            State::Crc1 => {
322                self.crc_bytes[0] = byte;
323                self.state = State::Crc2;
324                self.n_bits = 8;
325            }
326            // acars.c:348-373 (putmsg_lbl), then END→reset
327            State::Crc2 => {
328                self.crc_bytes[1] = byte;
329                self.finalize_frame();
330            }
331        }
332    }
333
334    /// CRC-verify, optionally FEC-recover, build the
335    /// `AcarsMessage`, push it onto `pending_messages`, and
336    /// reset. Shared between the normal CRC2 path and the
337    /// DLE-escape recovery (`acars.c::putmsg_lbl`).
338    fn finalize_frame(&mut self) {
339        // Compute the CRC over buf + crc_bytes. acars.c:160-165
340        // does this one-shot: fold every byte in `txt` then both
341        // BCS bytes; expect 0.
342        let mut crc = crate::crc::compute(&self.buf);
343        crc = crate::crc::update(crc, self.crc_bytes[0]);
344        crc = crate::crc::update(crc, self.crc_bytes[1]);
345
346        // Try FEC if non-zero. acars.c:170-192:
347        //   if (pn) {
348        //       fixprerr(...) — try parity-error correction
349        //   } else if (crc) {
350        //       fixdberr(...) — try double-bit-flip recovery
351        //   }
352        if crc != 0 {
353            let recovered = if self.parity_errors.is_empty() {
354                crate::syndrom::fix_double_error(&mut self.buf, crc)
355            } else {
356                crate::syndrom::fix_parity_errors(&mut self.buf, crc, &self.parity_errors)
357            };
358            if !recovered {
359                self.reset_to_idle();
360                return;
361            }
362        }
363
364        // Frame must be at least Mode + Address(7) + ACK + Label(2)
365        // + BlockID + STX + ETX = 13 bytes (acars.c:124).
366        if self.buf.len() < 13 {
367            self.reset_to_idle();
368            return;
369        }
370
371        // Field extraction. Strip parity (& 0x7F) on every byte
372        // that becomes user-facing text. Mirrors output.c:494-525.
373        let mode = self.buf[0] & 0x7F;
374        let mut aircraft = ArrayString::<8>::new();
375        // C output.c:503-508 skips '.' chars; we keep them so the
376        // caller sees the leading dot the wire actually carries.
377        for &b in &self.buf[1..8] {
378            // Push silently ignores overflow — the slice is exactly
379            // 7 chars and the buffer holds 8, so this is safe by
380            // construction.
381            let _ = aircraft.try_push((b & 0x7F) as char);
382        }
383        // NAK character (0x15) is non-printable — normalize to
384        // '!' (0x21) here so consumers can compare against the
385        // printable sentinel. Mirrors `output.c::buildmsg:513-514`.
386        let mut ack = self.buf[8] & 0x7F;
387        if ack == 0x15 {
388            ack = b'!';
389        }
390        let mut label = [self.buf[9] & 0x7F, self.buf[10] & 0x7F];
391        // DEL (0x7F) in second label byte → 'd' (output.c:520).
392        if label[1] == 0x7F {
393            label[1] = b'd';
394        }
395        let block_id = self.buf[11] & 0x7F;
396        // self.buf[12] is STX (0x02 with parity → 0x82); skipped.
397        // Downlink frames (block_id ∈ '0'..='9' per
398        // `output.c::IS_DOWNLINK_BLK`) carry a 4-char message
399        // number then a 6-char flight ID immediately after STX,
400        // before the visible text. Uplinks have no such prefix —
401        // text starts at buf[13]. We extract these here so the
402        // e2e diff against acarsdec's text printer matches.
403        let is_downlink = block_id.is_ascii_digit();
404        let text_end = self.buf.len() - 1;
405        let mut message_no: Option<ArrayString<5>> = None;
406        let mut flight_id: Option<ArrayString<7>> = None;
407        // Downlink prefix is up to 4 msgno bytes then up to 6
408        // flight-id bytes. Each field is independently
409        // bounds-checked against `text_end`, so a partial
410        // msgno (text_end < 17) still extracts what's there
411        // — mirrors the C's per-byte `i < N && k < blk->len
412        // - 1` guards in `output.c:548, 561`. Previously gated on
413        // `text_end >= 17`, which dropped partial-prefix
414        // downlink frames.
415        let text_start: usize = if is_downlink && text_end > 13 {
416            let msgno_finish = 17.min(text_end);
417            if msgno_finish > 13 {
418                let mut no = ArrayString::<5>::new();
419                for &b in &self.buf[13..msgno_finish] {
420                    let _ = no.try_push((b & 0x7F) as char);
421                }
422                if !no.is_empty() {
423                    message_no = Some(no);
424                }
425            }
426            let flight_start = msgno_finish;
427            let flight_finish = 23.min(text_end);
428            if flight_start < flight_finish {
429                let mut fid = ArrayString::<7>::new();
430                for &b in &self.buf[flight_start..flight_finish] {
431                    let _ = fid.try_push((b & 0x7F) as char);
432                }
433                if !fid.is_empty() {
434                    flight_id = Some(fid);
435                }
436            }
437            flight_finish
438        } else {
439            13
440        };
441        let mut text = String::with_capacity(text_end.saturating_sub(text_start));
442        if text_end > text_start {
443            for &b in &self.buf[text_start..text_end] {
444                text.push((b & 0x7F) as char);
445            }
446        }
447        let end_of_message = (self.buf[text_end] & 0x7F) == 0x03;
448
449        let msg = AcarsMessage {
450            timestamp: SystemTime::now(),
451            channel_idx: self.channel_idx,
452            freq_hz: self.channel_freq_hz,
453            level_db: 0.0, // filled in by ChannelBank in T7.
454            error_count: self.parity_err_count,
455            mode,
456            label,
457            block_id,
458            ack,
459            aircraft,
460            flight_id,
461            message_no,
462            text,
463            end_of_message,
464            // The parser produces single-block messages by
465            // construction; reassembly into multi-block
466            // logical messages happens later, in
467            // `crate::reassembly::MessageAssembler`.
468            reassembled_block_count: 1,
469            // Population deferred to ChannelBank::process so
470            // multi-block reassembly text is parsed once on the
471            // final concatenated body.
472            parsed: None,
473        };
474        self.pending_messages.push_back(msg);
475        self.reset_to_idle();
476    }
477
478    /// Convenience: drive the parser with a sequence of fully-
479    /// formed bytes — used by unit tests that bypass MSK demod
480    /// and feed hand-crafted byte sequences directly. Also
481    /// drains the resulting messages into `on_message` for test
482    /// ergonomics.
483    pub fn feed_bytes<F: FnMut(AcarsMessage)>(&mut self, bytes: &[u8], mut on_message: F) {
484        for &b in bytes {
485            self.consume_byte(b);
486        }
487        self.drain(&mut on_message);
488    }
489}
490
491impl BitSink for FrameParser {
492    fn take_polarity_flip(&mut self) -> bool {
493        // Delegate to the inherent method (also kept public so
494        // ChannelBank's pre-existing per-block poll keeps
495        // working — though per-block polling is now redundant
496        // for ACARS since MskDemod polls per-bit).
497        FrameParser::take_polarity_flip(self)
498    }
499
500    fn put_bit(&mut self, value: f32) {
501        // LSB-first byte accumulator (acarsdec putbit, msk.c:53-63):
502        // shift right, set bit 7 on a positive sample. When the
503        // count hits 0, hand the assembled byte to consume_byte
504        // SYNCHRONOUSLY — the C does this from inside putbit, and
505        // crucially the state machine sets nbits=1 (per-bit re-sync)
506        // when the candidate doesn't match SYN. Buffering bytes for
507        // a later drain breaks that re-sync (we'd lose 7 of every 8
508        // bit-shift candidates).
509        self.out_bits >>= 1;
510        if value > 0.0 {
511            self.out_bits |= 0x80;
512        }
513        self.n_bits = self.n_bits.saturating_sub(1);
514        if self.n_bits == 0 {
515            // n_bits is set to 8 (or 1 for re-sync) by consume_byte
516            // via the state-machine transitions; do NOT pre-set it
517            // here.
518            let byte = self.out_bits;
519            self.consume_byte(byte);
520        }
521    }
522}
523
524/// Odd-parity check: returns `true` if the byte has an odd
525/// number of 1-bits (ACARS valid byte). Mirrors `numbits[byte]
526/// & 1 == 1` in `acars.c:138`.
527fn has_odd_parity(b: u8) -> bool {
528    b.count_ones() & 1 == 1
529}
530
531#[cfg(test)]
532#[allow(clippy::unwrap_used)]
533mod tests {
534    use super::*;
535
536    /// Apply odd parity (set bit 7 if needed) to every byte in
537    /// `bytes`. ACARS uses 7-bit ASCII with the high bit chosen
538    /// so the total bit count is odd.
539    fn add_odd_parity(bytes: &mut [u8]) {
540        for b in bytes.iter_mut() {
541            if (b.count_ones() & 1) == 0 {
542                *b |= 0x80;
543            }
544        }
545    }
546
547    /// Build a known-good ACARS frame as a byte sequence ready
548    /// to feed into `FrameParser`. Address ".N12345", label "H1",
549    /// block `block_id`, text `text`.
550    ///
551    /// Layout: `[SYN][SYN][SOH][Mode][Addr×7][ACK][Label×2]
552    ///          [BlockID][STX][text...][ETX][CRC_lo][CRC_hi]`.
553    fn synthesize_frame(block_id: u8, text: &[u8]) -> Vec<u8> {
554        let mut buf = vec![0x16, 0x16, 0x01];
555        buf.push(b'2'); // Mode
556        buf.extend_from_slice(b".N12345"); // Address (7 bytes)
557        buf.push(b'!'); // ACK = 0x21
558        buf.extend_from_slice(b"H1"); // Label
559        buf.push(block_id);
560        buf.push(0x02); // STX
561        buf.extend_from_slice(text);
562        buf.push(0x03); // ETX (will get parity bit added below)
563        // Apply odd parity over Mode through ETX (the CRC payload).
564        let payload_start = 3;
565        let payload_end = buf.len();
566        add_odd_parity(&mut buf[payload_start..payload_end]);
567        // Compute CRC over the parity-applied payload (the buffer
568        // the receiver folds through update_crc).
569        let crc = crate::crc::compute(&buf[payload_start..payload_end]);
570        buf.push((crc & 0xFF) as u8); // BCS low
571        buf.push((crc >> 8) as u8); // BCS high
572        buf
573    }
574
575    /// Backwards-compatible default: uplink frame (block 'A')
576    /// with a short body. Uplink avoids the
577    /// `msgno`/`flight_id` field-extraction so callers checking
578    /// raw `text` see exactly what they passed in.
579    fn synthesize_minimal_frame() -> Vec<u8> {
580        synthesize_frame(b'A', b"TEST")
581    }
582
583    #[test]
584    fn parses_a_known_good_uplink_frame() {
585        // Uplink (block 'A' is not '0'..='9' so IS_DOWNLINK_BLK
586        // is false): no msgno/flight_id extraction; text body is
587        // the entire payload between STX and ETX.
588        let bytes = synthesize_minimal_frame();
589        let mut parser = FrameParser::new(0, 0.0);
590        let mut decoded = Vec::new();
591        parser.feed_bytes(&bytes, |msg| decoded.push(msg));
592
593        assert_eq!(decoded.len(), 1, "expected exactly one frame");
594        let msg = &decoded[0];
595        assert_eq!(msg.mode, b'2');
596        assert_eq!(&msg.aircraft[..], ".N12345");
597        assert_eq!(msg.label, *b"H1");
598        assert_eq!(msg.block_id, b'A');
599        assert_eq!(msg.ack, b'!');
600        assert_eq!(msg.text, "TEST");
601        assert!(msg.end_of_message);
602        assert_eq!(msg.channel_idx, 0);
603        assert!(msg.flight_id.is_none(), "uplink has no flight_id");
604        assert!(msg.message_no.is_none(), "uplink has no message_no");
605    }
606
607    #[test]
608    fn parses_a_known_good_downlink_frame() {
609        // Downlink (block '0' ∈ '0'..='9' triggers
610        // IS_DOWNLINK_BLK): text payload starts with 4-char
611        // msgno + 6-char flight_id, then the visible body.
612        // We pass a 14-char payload: "S64A" + "BA031T" + "BODY"
613        // → msgno=S64A, flight=BA031T, text=BODY.
614        let bytes = synthesize_frame(b'0', b"S64ABA031TBODY");
615        let mut parser = FrameParser::new(0, 0.0);
616        let mut decoded = Vec::new();
617        parser.feed_bytes(&bytes, |msg| decoded.push(msg));
618
619        assert_eq!(decoded.len(), 1, "expected exactly one frame");
620        let msg = &decoded[0];
621        assert_eq!(msg.block_id, b'0');
622        assert_eq!(msg.message_no.as_deref(), Some("S64A"));
623        assert_eq!(msg.flight_id.as_deref(), Some("BA031T"));
624        assert_eq!(msg.text, "BODY");
625    }
626
627    #[test]
628    fn rejects_a_corrupted_frame_when_fec_cant_recover() {
629        let mut bytes = synthesize_minimal_frame();
630        // Wreck the CRC bytes so neither parity-error correction
631        // nor double-bit-flip recovery can salvage it.
632        let n = bytes.len();
633        bytes[n - 2] = 0x00;
634        bytes[n - 1] = 0x00;
635
636        let mut parser = FrameParser::new(0, 0.0);
637        let mut decoded = Vec::new();
638        parser.feed_bytes(&bytes, |msg| decoded.push(msg));
639
640        assert!(decoded.is_empty(), "corrupted frame must not decode");
641    }
642
643    #[test]
644    fn ignores_bytes_outside_a_frame() {
645        let mut parser = FrameParser::new(0, 0.0);
646        let mut decoded = Vec::new();
647        parser.feed_bytes(b"\x00\xFF\x00\xFF\x00", |msg| decoded.push(msg));
648        assert!(decoded.is_empty());
649    }
650
651    #[test]
652    fn dle_recovery_drops_stale_parity_offsets() {
653        // Regression: the DLE recovery branch trims `self.buf` by
654        // 3 bytes but used to leave `self.parity_errors` holding
655        // offsets pointing into the now-removed tail. The next
656        // `fix_parity_errors` call would then index `frame[stale]`
657        // past `frame.len()` (panic in debug, wrong-bit-flip /
658        // syndrome OOB in release).
659        //
660        // Construction: drop into Text state, accumulate 22 bytes
661        // with valid odd parity, then 3 even-parity bytes (recorded
662        // at positions 22, 23, 24), then send DLE. The parser
663        // truncates buf to len 22 and goes to finalize_frame; the
664        // CRC is non-zero, so finalize_frame routes through
665        // fix_parity_errors which would panic without the fix.
666        let mut bytes = vec![SYN, SYN, SOH];
667        // 22 odd-parity bytes (0x80 has one 1-bit). Body bytes —
668        // the parser doesn't care about content during Text.
669        bytes.extend(std::iter::repeat_n(0x80, 22));
670        // 3 even-parity bytes that go on parity_errors at
671        // positions 22, 23, 24. NUL is even-parity (0 ones).
672        bytes.extend_from_slice(&[0x00, 0x00, 0x00]);
673        // DLE at position 25 — buf.len()=25 > DLE_ESCAPE_MIN_LEN=20,
674        // triggers the recovery branch.
675        bytes.push(DLE);
676
677        let mut parser = FrameParser::new(0, 0.0);
678        let mut decoded = Vec::new();
679        // The frame must NOT decode (CRC garbage), and the parser
680        // must NOT panic — the only assertion that matters here is
681        // "we got past feed_bytes alive".
682        parser.feed_bytes(&bytes, |msg| decoded.push(msg));
683        assert!(
684            decoded.is_empty(),
685            "synthetic DLE-recovery frame must not decode"
686        );
687    }
688}