Skip to main content

dvb_bbframe/
packet.rs

1//! User packet extraction from BBFrame data fields.
2//!
3//! Supports Normal Mode (NM, 188-byte stride) and High Efficiency Mode
4//! (HEM, 187-byte stride) per EN 302 755 §5.1.8.
5//!
6//! In NM the first byte of each user packet in the data field is a CRC-8
7//! that replaces the original sync byte (0x47). In HEM the sync byte is
8//! simply absent and must be prepended.
9//!
10//! ## SYNCD handling
11//!
12//! `SYNCD` gives the bit offset from the start of the DATA FIELD to the
13//! first bit of the CRC-8 byte of the first user packet (NM) or the first
14//! byte of the first user packet (HEM).  Callers typically prepend any
15//! carry-over from the previous BBFrame and pass the result plus SYNCD.
16//!
17//! ## NPD/DNP reinsertion (HEM only)
18//!
19//! When NPD is active (`matype.npd == true`), each transmitted user
20//! packet is followed by a 1-byte DNP counter. The [`HemTsIter`] skips
21//! these DNP bytes automatically.
22
23use crate::header::{Bbheader, Mode, BBHEADER_LEN};
24
25/// User packet size in Normal Mode (188 bytes = full MPEG-2 TS packet).
26pub const NM_UP_SIZE: usize = 188;
27
28/// User packet size in High Efficiency Mode (187 bytes = TS minus sync byte).
29pub const HEM_UP_SIZE: usize = 187;
30
31/// MPEG-2 sync byte that CRC-8 replaces in NM.
32pub const TS_SYNC_BYTE: u8 = 0x47;
33
34/// Iterator over NM TS user packets.
35///
36/// Each item is a `[u8; 188]` with the sync byte restored to 0x47,
37/// replacing the CRC-8 byte that occupies position 0 in the data field.
38#[derive(Clone, Copy)]
39#[cfg_attr(feature = "yoke", derive(yoke::Yokeable))]
40pub struct NmTsIter<'a> {
41    data: &'a [u8],
42    pos: usize,
43}
44
45impl<'a> NmTsIter<'a> {
46    /// Create a new NM TS user packet iterator.
47    ///
48    /// `data` is the full data field (after skipping SYNCD bytes if
49    /// already aligned). The iterator starts at byte 0 of `data`.
50    pub fn new(data: &'a [u8]) -> Self {
51        Self { data, pos: 0 }
52    }
53
54    /// Return the unconsumed tail of the data field.
55    pub fn remaining(self) -> &'a [u8] {
56        self.data.get(self.pos..).unwrap_or(&[])
57    }
58}
59
60impl Iterator for NmTsIter<'_> {
61    type Item = [u8; NM_UP_SIZE];
62
63    fn next(&mut self) -> Option<Self::Item> {
64        if self.pos + NM_UP_SIZE > self.data.len() {
65            return None;
66        }
67        let mut pkt = [0u8; NM_UP_SIZE];
68        pkt[0] = TS_SYNC_BYTE; // Replace CRC-8 byte with sync byte
69        pkt[1..].copy_from_slice(&self.data[self.pos + 1..self.pos + NM_UP_SIZE]);
70        self.pos += NM_UP_SIZE;
71        Some(pkt)
72    }
73
74    fn size_hint(&self) -> (usize, Option<usize>) {
75        let remaining = self.data.len().saturating_sub(self.pos);
76        let count = remaining / NM_UP_SIZE;
77        (count, Some(count))
78    }
79}
80
81/// Iterator over HEM TS user packets.
82///
83/// Each item is a `[u8; 188]` with the sync byte prepended (0x47).
84/// The 187-byte user packets in the data field have no sync byte.
85/// If NPD is active, DNP bytes are skipped automatically.
86#[derive(Clone, Copy)]
87#[cfg_attr(feature = "yoke", derive(yoke::Yokeable))]
88pub struct HemTsIter<'a> {
89    data: &'a [u8],
90    pos: usize,
91    npd: bool,
92}
93
94impl<'a> HemTsIter<'a> {
95    /// Create a new HEM TS user packet iterator.
96    ///
97    /// `data` is the full data field (after skipping SYNCD bytes if
98    /// already aligned). The iterator starts at byte 0 of `data`.
99    pub fn new(data: &'a [u8], npd: bool) -> Self {
100        Self { data, pos: 0, npd }
101    }
102
103    /// Return the unconsumed tail of the data field.
104    pub fn remaining(self) -> &'a [u8] {
105        self.data.get(self.pos..).unwrap_or(&[])
106    }
107}
108
109impl Iterator for HemTsIter<'_> {
110    type Item = [u8; NM_UP_SIZE];
111
112    fn next(&mut self) -> Option<Self::Item> {
113        let stride = HEM_UP_SIZE + if self.npd { 1 } else { 0 };
114        if self.pos + stride > self.data.len() {
115            return None;
116        }
117        let mut pkt = [0u8; NM_UP_SIZE];
118        pkt[0] = TS_SYNC_BYTE;
119        pkt[1..].copy_from_slice(&self.data[self.pos..self.pos + HEM_UP_SIZE]);
120        self.pos += stride;
121        Some(pkt)
122    }
123
124    fn size_hint(&self) -> (usize, Option<usize>) {
125        let stride = HEM_UP_SIZE + if self.npd { 1 } else { 0 };
126        let remaining = self.data.len().saturating_sub(self.pos);
127        let count = remaining / stride;
128        (count, Some(count))
129    }
130}
131
132/// Concrete user-packet iterator returned by [`up_iter`].
133///
134/// Selects NM or HEM iteration at runtime without heap allocation or
135/// dynamic dispatch — the mode is baked into the variant.
136#[derive(Clone, Copy)]
137pub enum UpIter<'a> {
138    /// Normal Mode iteration.
139    Normal(NmTsIter<'a>),
140    /// High Efficiency Mode iteration.
141    HighEfficiency(HemTsIter<'a>),
142}
143
144impl Iterator for UpIter<'_> {
145    type Item = [u8; NM_UP_SIZE];
146
147    fn next(&mut self) -> Option<Self::Item> {
148        match self {
149            Self::Normal(it) => it.next(),
150            Self::HighEfficiency(it) => it.next(),
151        }
152    }
153
154    fn size_hint(&self) -> (usize, Option<usize>) {
155        match self {
156            Self::Normal(it) => it.size_hint(),
157            Self::HighEfficiency(it) => it.size_hint(),
158        }
159    }
160}
161
162/// Build an appropriate user-packet iterator for the given BBHEADER.
163///
164/// Returns either an NM or HEM iterator depending on the detected mode.
165/// The caller must handle SYNCD alignment before calling this — typically
166/// by skipping `syncd / 8` bytes at the start of the data field.
167pub fn up_iter<'a>(data: &'a [u8], bbheader: &Bbheader) -> UpIter<'a> {
168    match bbheader.mode {
169        Mode::Normal => UpIter::Normal(NmTsIter::new(data)),
170        Mode::HighEfficiency => UpIter::HighEfficiency(HemTsIter::new(data, bbheader.matype.npd)),
171    }
172}
173
174/// Stateful UP extractor that carries partial user packets across BBFrame
175/// boundaries — a single UP can span multiple frames, especially in HEM
176/// where stride=187 bytes.
177///
178/// Use `feed_nm` / `feed_hem` per received frame; the returned Vec holds
179/// whichever 188-byte TS packets completed during that frame.
180///
181/// Diagnostic counters are accumulated and exposed via [`stats`](CarryOverExtractor::stats).
182pub struct CarryOverExtractor {
183    /// Partial TS packet being assembled (sync byte position 0).
184    buf: [u8; NM_UP_SIZE],
185    /// Bytes already written into `buf`.
186    pos: usize,
187    /// Diagnostic counters (see [`CarryOverStats`]).
188    stats: CarryOverStats,
189}
190
191impl Default for CarryOverExtractor {
192    fn default() -> Self {
193        Self {
194            buf: [0u8; NM_UP_SIZE],
195            pos: 0,
196            stats: CarryOverStats::default(),
197        }
198    }
199}
200
201/// Diagnostic counters for a [`CarryOverExtractor`], read via
202/// [`CarryOverExtractor::stats`].
203///
204/// The extractor stays resilient (it never errors or panics on wire-derived
205/// input — bad frames are skipped so a stream keeps flowing). These counters
206/// make the otherwise-silent skips observable. Note the distinction:
207/// `npd_unsupported` counts **valid data we failed to recover** (a capability
208/// gap), whereas the others count malformed/misrouted input.
209#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
210#[non_exhaustive]
211pub struct CarryOverStats {
212    /// HEM frames skipped because Null-Packet-Deletion (DNP) reinsertion is not
213    /// implemented. These carry **valid** user packets that are NOT recovered —
214    /// a known capability gap, not wire corruption. **Non-zero means real data
215    /// was dropped**; treat it as a signal that NPD-HEM input is unsupported.
216    pub npd_unsupported: u64,
217    /// Frames whose 10-byte BBHEADER failed to parse.
218    pub header_parse_failures: u64,
219    /// Frames fed to the wrong mode path (an NM header to `feed_hem_into`, or a
220    /// HEM header to `feed_nm_into`).
221    pub mode_mismatches: u64,
222    /// Carried-over partial user packets discarded on a SYNCD/stride mismatch
223    /// (the extractor resynchronised at the frame's SYNCD).
224    pub partial_discards: u64,
225}
226
227impl CarryOverExtractor {
228    /// Create a fresh extractor with no carried-over state.
229    pub fn new() -> Self {
230        Self::default()
231    }
232
233    /// Diagnostic counters accumulated across all `feed_*` calls — see
234    /// [`CarryOverStats`]. Check `npd_unsupported` in particular: a non-zero
235    /// value means valid HEM frames were dropped (NPD reinsertion unsupported).
236    #[must_use]
237    pub fn stats(&self) -> CarryOverStats {
238        self.stats
239    }
240
241    /// Feed a HEM BBFrame's header + data field. Returns any TS packets that
242    /// completed during this frame.
243    ///
244    /// `npd` is the MATYPE-1 NPD flag for the frame — when true the stream
245    /// would additionally carry DNP bytes between UPs. NPD reinsertion is
246    /// NOT YET implemented here; callers must not pass `npd=true` until
247    /// the DNP path lands.
248    pub fn feed_hem(
249        &mut self,
250        bbheader_bytes: &[u8; BBHEADER_LEN],
251        data_field: &[u8],
252        npd: bool,
253    ) -> Vec<[u8; NM_UP_SIZE]> {
254        let mut out = Vec::new();
255        self.feed_hem_into(bbheader_bytes, data_field, npd, &mut out);
256        out
257    }
258
259    /// Buffer-reusing variant of [`feed_hem`](Self::feed_hem). Clears `out`,
260    /// then appends the TS packets that completed during this frame. Reuse the
261    /// same `Vec` across frames to avoid a per-frame heap allocation.
262    pub fn feed_hem_into(
263        &mut self,
264        bbheader_bytes: &[u8; BBHEADER_LEN],
265        data_field: &[u8],
266        npd: bool,
267        out: &mut Vec<[u8; NM_UP_SIZE]>,
268    ) {
269        out.clear();
270        // NPD/DNP reinsertion is not yet implemented; rather than panic on
271        // wire-derived input, produce no output (out is already cleared).
272        if npd {
273            self.stats.npd_unsupported += 1;
274            return;
275        }
276        let hdr = match Bbheader::parse(bbheader_bytes) {
277            Ok(h) => h,
278            Err(_) => {
279                self.stats.header_parse_failures += 1;
280                return;
281            }
282        };
283        // Mismatched mode (caller fed a non-HEM header): no output, no panic.
284        if hdr.mode != Mode::HighEfficiency {
285            self.stats.mode_mismatches += 1;
286            return;
287        }
288
289        let stride = HEM_UP_SIZE;
290        let syncd_bytes = (hdr.syncd / 8) as usize;
291        let dfl_bytes = (hdr.dfl / 8) as usize;
292        let data = &data_field[..dfl_bytes.min(data_field.len())];
293
294        // Complete the partial UP from the previous frame.
295        if self.pos > 0 {
296            let need = stride + 1 - self.pos; // +1 for the sync byte we'll prepend
297            if syncd_bytes == need && data.len() >= need {
298                self.buf[self.pos..self.pos + need].copy_from_slice(&data[..need]);
299                self.buf[0] = TS_SYNC_BYTE;
300                out.push(self.buf);
301                self.pos = 0;
302            } else {
303                // Stride mismatch — discard partial and resync to syncd.
304                self.stats.partial_discards += 1;
305                self.pos = 0;
306            }
307        }
308
309        // Extract complete UPs at stride.
310        let mut i = syncd_bytes;
311        while i + stride <= data.len() {
312            // HEM: 187 bytes of UP, prepend sync byte.
313            self.buf[0] = TS_SYNC_BYTE;
314            self.buf[1..1 + stride].copy_from_slice(&data[i..i + stride]);
315            out.push(self.buf);
316            i += stride;
317        }
318
319        // Buffer trailing partial packet (may be filled by next frame).
320        if i < data.len() {
321            let tail = (data.len() - i).min(stride);
322            // Store at offset 1 (reserving byte 0 for the sync we prepend later).
323            self.buf[1..1 + tail].copy_from_slice(&data[i..i + tail]);
324            self.pos = 1 + tail;
325        } else {
326            self.pos = 0;
327        }
328    }
329
330    /// Feed an NM BBFrame. Stride=188, `byte[0]` of each UP is the CRC-8 of
331    /// the previous UP and gets replaced with 0x47.
332    pub fn feed_nm(
333        &mut self,
334        bbheader_bytes: &[u8; BBHEADER_LEN],
335        data_field: &[u8],
336    ) -> Vec<[u8; NM_UP_SIZE]> {
337        let mut out = Vec::new();
338        self.feed_nm_into(bbheader_bytes, data_field, &mut out);
339        out
340    }
341
342    /// Buffer-reusing variant of [`feed_nm`](Self::feed_nm). Clears `out`, then
343    /// appends the TS packets that completed during that frame. Reuse the same
344    /// `Vec` across frames to avoid a per-frame heap allocation.
345    pub fn feed_nm_into(
346        &mut self,
347        bbheader_bytes: &[u8; BBHEADER_LEN],
348        data_field: &[u8],
349        out: &mut Vec<[u8; NM_UP_SIZE]>,
350    ) {
351        out.clear();
352        let hdr = match Bbheader::parse(bbheader_bytes) {
353            Ok(h) => h,
354            Err(_) => {
355                self.stats.header_parse_failures += 1;
356                return;
357            }
358        };
359        // Mismatched mode (caller fed a non-NM header): no output, no panic.
360        if hdr.mode != Mode::Normal {
361            self.stats.mode_mismatches += 1;
362            return;
363        }
364
365        let stride = NM_UP_SIZE;
366        let syncd_bytes = (hdr.syncd / 8) as usize;
367        let dfl_bytes = (hdr.dfl / 8) as usize;
368        let data = &data_field[..dfl_bytes.min(data_field.len())];
369
370        // Complete partial UP from previous frame.
371        if self.pos > 0 {
372            let need = stride - self.pos;
373            if syncd_bytes == need && data.len() >= need {
374                self.buf[self.pos..self.pos + need].copy_from_slice(&data[..need]);
375                self.buf[0] = TS_SYNC_BYTE; // replace CRC-8 with sync byte
376                out.push(self.buf);
377                self.pos = 0;
378            } else {
379                // Stride mismatch — discard partial and resync to syncd.
380                self.stats.partial_discards += 1;
381                self.pos = 0;
382            }
383        }
384
385        // Extract complete UPs at stride.
386        let mut i = syncd_bytes;
387        while i + stride <= data.len() {
388            self.buf.copy_from_slice(&data[i..i + stride]);
389            self.buf[0] = TS_SYNC_BYTE; // replace CRC-8 with sync byte
390            out.push(self.buf);
391            i += stride;
392        }
393
394        // Buffer trailing partial.
395        if i < data.len() {
396            let tail = (data.len() - i).min(stride);
397            self.buf[..tail].copy_from_slice(&data[i..i + tail]);
398            self.pos = tail;
399        } else {
400            self.pos = 0;
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::header::{Bbheader, Matype, Mode, TsGs};
409
410    fn make_nm_header(syncd: u16) -> Bbheader {
411        Bbheader {
412            matype: Matype {
413                ts_gs: TsGs::Ts,
414                sis: true,
415                ccm: true,
416                issyi: false,
417                npd: false,
418                ext: 0,
419                isi: 0,
420            },
421            upl: 0,
422            sync: 0x47,
423            dfl: 0,
424            syncd,
425            mode: Mode::Normal,
426            issy_in_header: None,
427        }
428    }
429
430    fn make_hem_header(npd: bool) -> Bbheader {
431        Bbheader {
432            matype: Matype {
433                ts_gs: TsGs::Ts,
434                sis: true,
435                ccm: true,
436                issyi: false,
437                npd,
438                ext: 0,
439                isi: 0,
440            },
441            upl: 0,
442            sync: 0,
443            dfl: 0,
444            syncd: 0,
445            mode: Mode::HighEfficiency,
446            issy_in_header: None,
447        }
448    }
449
450    #[test]
451    fn nm_extracts_single_complete_up() {
452        let mut data = vec![0xAA; NM_UP_SIZE];
453        data[0] = 0xFF; // CRC-8 byte (will be replaced with sync)
454        for (i, byte) in data.iter_mut().enumerate().skip(1) {
455            *byte = i as u8;
456        }
457
458        let _hdr = make_nm_header(0);
459        let pkts: Vec<_> = up_iter(&data, &_hdr).collect();
460
461        assert_eq!(pkts.len(), 1);
462        assert_eq!(pkts[0][0], TS_SYNC_BYTE);
463        assert_eq!(&pkts[0][1..], &data[1..]);
464    }
465
466    #[test]
467    fn nm_multiple_back_to_back_ups() {
468        let num_ups = 3;
469        let mut data = Vec::with_capacity(num_ups * NM_UP_SIZE);
470
471        for i in 0..num_ups {
472            data.push(0x00); // CRC-8 byte
473            for j in 1..NM_UP_SIZE {
474                data.push((i * 10 + j) as u8);
475            }
476        }
477
478        let _hdr = make_nm_header(0);
479        let pkts: Vec<_> = up_iter(&data, &_hdr).collect();
480
481        assert_eq!(pkts.len(), num_ups);
482        for pkt in &pkts {
483            assert_eq!(pkt[0], TS_SYNC_BYTE);
484        }
485    }
486
487    #[test]
488    fn nm_partial_tail_does_not_yield() {
489        let mut data = vec![0xAA; NM_UP_SIZE + 50];
490        data[0] = 0xFF; // CRC-8
491        for (i, byte) in data.iter_mut().enumerate().skip(1) {
492            *byte = i as u8;
493        }
494
495        let _hdr = make_nm_header(0);
496        let pkts: Vec<_> = up_iter(&data, &_hdr).collect();
497
498        assert_eq!(pkts.len(), 1); // Only one complete UP
499    }
500
501    #[test]
502    fn nm_crc_byte_replaced_with_sync_only() {
503        let mut data = vec![0u8; NM_UP_SIZE];
504        data[0] = 0x42; // Some CRC value
505        data[1] = 0x47; // Actual sync byte in payload position 1
506        for (i, byte) in data.iter_mut().enumerate().skip(2) {
507            *byte = i as u8;
508        }
509
510        let _hdr = make_nm_header(0);
511        let pkt = up_iter(&data, &_hdr).next().unwrap();
512
513        assert_eq!(pkt[0], TS_SYNC_BYTE); // CRC replaced
514        assert_eq!(pkt[1], 0x47); // Original byte preserved
515        assert_eq!(&pkt[2..], &data[2..]);
516    }
517
518    #[test]
519    fn hem_extracts_up_with_sync_prepend() {
520        let data: Vec<u8> = (0..HEM_UP_SIZE as u8).cycle().take(HEM_UP_SIZE).collect();
521        let mut expected = [0u8; NM_UP_SIZE];
522        expected[0] = TS_SYNC_BYTE;
523        expected[1..].copy_from_slice(&data[..HEM_UP_SIZE]);
524
525        let _hdr = make_hem_header(false);
526        let pkt = up_iter(&data, &_hdr).next().unwrap();
527
528        assert_eq!(pkt, expected);
529    }
530
531    #[test]
532    fn hem_multiple_ups_without_npd() {
533        let num_ups = 3;
534        let data = vec![0xAB; num_ups * HEM_UP_SIZE];
535        let expected: [u8; NM_UP_SIZE] = {
536            let mut e = [0xAB; NM_UP_SIZE];
537            e[0] = TS_SYNC_BYTE;
538            e
539        };
540
541        let _hdr = make_hem_header(false);
542        let pkts: Vec<_> = up_iter(&data, &_hdr).collect();
543
544        assert_eq!(pkts.len(), num_ups);
545        for pkt in &pkts {
546            assert_eq!(*pkt, expected);
547        }
548    }
549
550    #[test]
551    fn hem_with_npd_skips_dnp_bytes() {
552        let up_size = HEM_UP_SIZE;
553        let num_ups = 2;
554        // Each UP followed by a DNP byte
555        let stride = up_size + 1;
556        let mut data = Vec::with_capacity(num_ups * stride);
557
558        for i in 0..num_ups {
559            for j in 0..up_size {
560                data.push((i * up_size + j) as u8);
561            }
562            data.push(i as u8); // DNP counter
563        }
564
565        let _hdr = make_hem_header(true);
566        let pkts: Vec<_> = up_iter(&data, &_hdr).collect();
567
568        assert_eq!(pkts.len(), num_ups);
569        for (i, pkt) in pkts.iter().enumerate() {
570            assert_eq!(pkt[0], TS_SYNC_BYTE);
571            // Verify the 187 bytes match the expected slice
572            let offset = i * stride;
573            assert_eq!(&pkt[1..], &data[offset..offset + up_size]);
574        }
575    }
576
577    #[test]
578    fn nm_remaining_returns_unconsumed_tail() {
579        let data = vec![0xAA; NM_UP_SIZE * 2 + 50];
580        let _hdr = make_nm_header(0);
581        let mut iter = NmTsIter::new(&data);
582
583        let _p1 = iter.next().unwrap();
584        let _p2 = iter.next().unwrap();
585        let remaining = iter.remaining();
586
587        assert_eq!(remaining.len(), 50);
588    }
589
590    #[test]
591    fn hem_remaining_returns_unconsumed_tail() {
592        let data = vec![0xAA; HEM_UP_SIZE * 2 + 30];
593        let _hdr = make_hem_header(false);
594        let mut iter = HemTsIter::new(&data, false);
595
596        let _p1 = iter.next().unwrap();
597        let _p2 = iter.next().unwrap();
598        let remaining = iter.remaining();
599
600        assert_eq!(remaining.len(), 30);
601    }
602
603    #[test]
604    fn empty_data_yields_nothing() {
605        let _hdr = make_nm_header(0);
606        let pkts: Vec<_> = up_iter(&[], &_hdr).collect();
607        assert!(pkts.is_empty());
608    }
609
610    #[test]
611    fn data_shorter_than_one_up_yields_nothing() {
612        let data = vec![0xAA; 100]; // Less than NM_UP_SIZE or HEM_UP_SIZE
613        let _hdr = make_nm_header(0);
614        let pkts: Vec<_> = up_iter(&data, &_hdr).collect();
615        assert!(pkts.is_empty());
616    }
617
618    #[test]
619    fn carry_over_extractor_emits_ts_across_two_bbframes_hem() {
620        // Two HEM BBFrames where the first ends with a partial UP (70 bytes) and
621        // the second completes it (117 bytes) then starts a new UP.
622        let make_hem_header = |syncd_bits: u16, dfl_bits: u16| -> [u8; 10] {
623            let mut h = [0u8; 10];
624            // MATYPE-1: TS=0b11 → 0xC0, SIS=1 → 0x20, CCM=1 → 0x10, ISSYI=0, NPD=0, EXT=0
625            h[0] = 0xF0;
626            h[1] = 0x00; // ISI=0
627                         // UPL=0 (ignored in HEM)
628            h[2] = 0x00;
629            h[3] = 0x00;
630            h[4] = (dfl_bits >> 8) as u8;
631            h[5] = (dfl_bits & 0xFF) as u8;
632            h[6] = 0x00; // sync (ignored in HEM)
633            h[7] = (syncd_bits >> 8) as u8;
634            h[8] = (syncd_bits & 0xFF) as u8;
635            // byte 9 = CRC-8 XOR MODE with MODE=1 for HEM
636            let crc = crate::crc::crc8(&h[..9]);
637            h[9] = crc ^ 1;
638            h
639        };
640
641        // Two BBFrames, each 70 data bytes. Pattern: each data byte is (frame << 4) | offset_lo.
642        // We expect CarryOverExtractor to produce 0 packets on frame1 (partial tail),
643        // then 1 on frame2 (187-byte completion across the boundary).
644        let frame1_data = (0..70u8).map(|i| 0xA0 | (i & 0x0F)).collect::<Vec<u8>>();
645        let frame2_data = (0..200u8).map(|i| 0xB0 | (i & 0x0F)).collect::<Vec<u8>>();
646        let hdr1 = make_hem_header(0, (frame1_data.len() * 8) as u16);
647        let hdr2 = make_hem_header(0, (frame2_data.len() * 8) as u16);
648
649        let mut extractor = CarryOverExtractor::new();
650        let packets1 = extractor.feed_hem(&hdr1, &frame1_data, false);
651        assert_eq!(packets1.len(), 0, "70 bytes (< 187) must not yet emit a UP");
652
653        let packets2 = extractor.feed_hem(&hdr2, &frame2_data, false);
654        assert!(!packets2.is_empty(), "boundary UP should complete");
655        assert_eq!(
656            packets2[0][0], 0x47,
657            "first emitted packet has sync byte prepended"
658        );
659    }
660
661    #[test]
662    fn feed_into_matches_allocating_api() {
663        // Run the same two-frame HEM sequence (partial UP carried across the
664        // boundary) through the allocating `feed_hem` and the buffer-reusing
665        // `feed_hem_into` (one Vec reused across both frames). Identical output
666        // proves `_into` clears + appends equivalently — if it failed to clear,
667        // frame 2's buffer would still hold frame 1's packets and diverge.
668        let make_hem_header = |syncd_bits: u16, dfl_bits: u16| -> [u8; 10] {
669            let mut h = [0u8; 10];
670            h[0] = 0xF0;
671            h[4] = (dfl_bits >> 8) as u8;
672            h[5] = (dfl_bits & 0xFF) as u8;
673            h[7] = (syncd_bits >> 8) as u8;
674            h[8] = (syncd_bits & 0xFF) as u8;
675            let crc = crate::crc::crc8(&h[..9]);
676            h[9] = crc ^ 1;
677            h
678        };
679        let f1 = (0..70u8).map(|i| 0xA0 | (i & 0x0F)).collect::<Vec<u8>>();
680        let f2 = (0..200u8).map(|i| 0xB0 | (i & 0x0F)).collect::<Vec<u8>>();
681        let h1 = make_hem_header(0, (f1.len() * 8) as u16);
682        let h2 = make_hem_header(0, (f2.len() * 8) as u16);
683
684        let mut alloc = CarryOverExtractor::new();
685        let a1 = alloc.feed_hem(&h1, &f1, false);
686        let a2 = alloc.feed_hem(&h2, &f2, false);
687
688        let mut reuse = CarryOverExtractor::new();
689        let mut buf = Vec::new();
690        reuse.feed_hem_into(&h1, &f1, false, &mut buf);
691        let b1 = buf.clone();
692        reuse.feed_hem_into(&h2, &f2, false, &mut buf);
693        let b2 = buf.clone();
694
695        assert_eq!(a1, b1, "frame 1 output matches across APIs");
696        assert_eq!(
697            a2, b2,
698            "frame 2 (carry-over) output matches; buffer was cleared"
699        );
700    }
701
702    #[test]
703    fn remaining_safe_when_pos_equals_len() {
704        let data = vec![0xAA; NM_UP_SIZE];
705        let mut iter = NmTsIter::new(&data);
706        let _p = iter.next().unwrap();
707        // pos == data.len() — must not panic
708        let remaining = iter.remaining();
709        assert!(remaining.is_empty());
710    }
711
712    #[test]
713    fn remaining_safe_when_pos_exceeds_len() {
714        // Construct an iterator and manually set pos beyond data length.
715        // This cannot happen through normal iteration, but the safe
716        // get().unwrap_or(&[]) handles it gracefully.
717        let data = vec![0xAA; 10];
718        let iter = NmTsIter {
719            data: &data,
720            pos: 20,
721        };
722        let remaining = iter.remaining();
723        assert!(remaining.is_empty());
724    }
725
726    #[test]
727    fn stats_count_npd_skip_and_mode_mismatch() {
728        let mut ext = CarryOverExtractor::new();
729        let mut out = Vec::new();
730
731        // Valid HEM header but NPD set: unsupported → no output, counted as a
732        // dropped-valid-data event (not wire corruption).
733        let hem = make_hem_header(true).serialize();
734        ext.feed_hem_into(&hem, &[0u8; NM_UP_SIZE], true, &mut out);
735        assert!(out.is_empty());
736        assert_eq!(ext.stats().npd_unsupported, 1);
737
738        // NM header fed to the HEM path → mode mismatch, counted.
739        let nm = make_nm_header(0).serialize();
740        ext.feed_hem_into(&nm, &[0u8; NM_UP_SIZE], false, &mut out);
741        assert!(out.is_empty());
742        assert_eq!(ext.stats().mode_mismatches, 1);
743        // Earlier counter is unchanged.
744        assert_eq!(ext.stats().npd_unsupported, 1);
745    }
746}