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}