Skip to main content

viva_gige/
gvsp.rs

1//! GVSP packet parsing, reassembly and resend orchestration.
2//!
3//! The GigE Vision Streaming Protocol delivers image data over UDP. Packets can
4//! arrive out of order or be dropped entirely; this module reconstructs complete
5//! frames while coordinating resend requests over GVCP. The implementation keeps
6//! copies to a minimum by writing directly into pooled [`BytesMut`] buffers that
7//! are subsequently frozen into [`Bytes`] once a frame is ready.
8
9use std::collections::VecDeque;
10use std::net::Ipv4Addr;
11use std::ops::RangeInclusive;
12use std::time::{Duration, Instant};
13
14use crate::nic::Iface;
15use crate::stats::StreamStatsAccumulator;
16use bytes::{Buf, Bytes, BytesMut};
17use thiserror::Error;
18use tracing::{debug, warn};
19
20/// GVSP payload type for image data as defined by the specification (Table
21/// 36). Other payload types are currently not supported by the reassembler but
22/// will still be parsed.
23const PAYLOAD_TYPE_IMAGE: u8 = 0x01;
24
25/// Size of the GVSP header preceding payload packets. The reassembler uses the
26/// value when allocating buffers so the application payload fits within the
27/// negotiated packet size.
28const GVSP_HEADER_SIZE: usize = 8;
29
30/// Destination for GVSP packets received by the stream.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum StreamDest {
33    /// Standard unicast delivery towards a single host.
34    Unicast {
35        /// Destination IPv4 address configured on the camera.
36        dst_ip: Ipv4Addr,
37        /// UDP port used for streaming.
38        dst_port: u16,
39    },
40    /// Multicast delivery towards one or more hosts joined to the group.
41    Multicast {
42        /// Multicast group IPv4 address.
43        group: Ipv4Addr,
44        /// UDP port used for streaming.
45        port: u16,
46        /// Whether loopback is enabled on the local socket.
47        loopback: bool,
48        /// Outbound multicast time-to-live.
49        ttl: u32,
50    },
51}
52
53impl StreamDest {
54    /// Retrieve the configured UDP port.
55    pub fn port(&self) -> u16 {
56        match self {
57            StreamDest::Unicast { dst_port, .. } => *dst_port,
58            StreamDest::Multicast { port, .. } => *port,
59        }
60    }
61
62    /// Retrieve the configured IPv4 destination address.
63    pub fn addr(&self) -> Ipv4Addr {
64        match self {
65            StreamDest::Unicast { dst_ip, .. } => *dst_ip,
66            StreamDest::Multicast { group, .. } => *group,
67        }
68    }
69
70    /// Whether the destination represents multicast delivery.
71    pub fn is_multicast(&self) -> bool {
72        matches!(self, StreamDest::Multicast { .. })
73    }
74}
75
76/// Stream configuration shared between the control plane and GVSP receiver.
77#[derive(Debug, Clone)]
78pub struct StreamConfig {
79    /// Destination configuration for the GVSP stream.
80    pub dest: StreamDest,
81    /// Interface used for receiving packets and multicast subscription.
82    pub iface: Iface,
83    /// Override for GVSP packet size determined via control plane.
84    pub packet_size: Option<u32>,
85    /// Override for GVSP packet delay determined via control plane.
86    pub packet_delay: Option<u32>,
87    /// Optional source filter restricting packets to the configured IPv4 address.
88    pub source_filter: Option<Ipv4Addr>,
89    /// Whether GVCP resend requests should be issued when drops are detected.
90    pub resend_enabled: bool,
91}
92
93/// Errors raised while handling GVSP packets.
94#[derive(Debug, Error)]
95#[non_exhaustive]
96pub enum GvspError {
97    #[error("unsupported packet type: {0}")]
98    Unsupported(&'static str),
99    #[error("invalid packet: {0}")]
100    Invalid(&'static str),
101    #[error("resend timeout")]
102    ResendTimeout,
103}
104
105/// Raw GVSP chunk extracted from a payload or trailer block.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ChunkRaw {
108    pub id: u16,
109    pub data: Bytes,
110}
111
112/// Parse a chunk payload following the `[id][reserved][length][data...]` layout.
113pub fn parse_chunks(mut payload: &[u8]) -> Vec<ChunkRaw> {
114    let mut chunks = Vec::new();
115    while !payload.is_empty() {
116        if payload.len() < 8 {
117            warn!(remaining = payload.len(), "chunk header truncated");
118            break;
119        }
120        let mut cursor = payload;
121        let id = cursor.get_u16();
122        let _reserved = cursor.get_u16();
123        let length = cursor.get_u32() as usize;
124        let total = 8 + length;
125        if payload.len() < total {
126            warn!(
127                chunk_id = format_args!("{:#06x}", id),
128                len = payload.len(),
129                expected = total,
130                "chunk data truncated"
131            );
132            break;
133        }
134        let data = Bytes::copy_from_slice(&payload[8..total]);
135        debug!(
136            chunk_id = format_args!("{:#06x}", id),
137            len = length,
138            "parsed chunk"
139        );
140        chunks.push(ChunkRaw { id, data });
141        payload = &payload[total..];
142    }
143    chunks
144}
145
146/// Representation of a GVSP packet.
147///
148/// Block IDs are `u64` and packet IDs are `u32` to support both standard
149/// (16-bit block / 24-bit packet) and extended ID mode (64-bit block /
150/// 32-bit packet) as defined in GigE Vision 2.0+.
151#[derive(Debug, Clone)]
152pub enum GvspPacket {
153    /// Start-of-frame leader packet with metadata.
154    Leader {
155        block_id: u64,
156        packet_id: u32,
157        payload_type: u8,
158        timestamp: u64,
159        width: u32,
160        height: u32,
161        pixel_format: u32,
162    },
163    /// Payload data packet carrying pixel bytes.
164    Payload {
165        block_id: u64,
166        packet_id: u32,
167        data: Bytes,
168    },
169    /// End-of-frame trailer packet.
170    Trailer {
171        block_id: u64,
172        packet_id: u32,
173        /// GVSP status from the packet *header* (offset 0), not from the
174        /// trailer payload — the payload's first two bytes are `reserved`.
175        status: u16,
176        /// Payload type the device is closing off, from the trailer payload.
177        /// Carries the chunk flag (`0x4001`) that the leader's copy loses.
178        payload_type: u16,
179        /// Actual number of lines delivered, for variable-height payloads.
180        size_y: u32,
181        chunk_data: Bytes,
182    },
183}
184
185/// Parse a raw UDP payload into a GVSP packet.
186/// Parse a GVSP packet from raw bytes.
187///
188/// GVSP header layout (8 bytes):
189///
190/// | Offset | Size | Field         |
191/// |--------|------|---------------|
192/// |      0 |    2 | Status        |
193/// |      2 |    2 | Block ID      |
194/// |      4 |    1 | Packet format |
195/// |      5 |    3 | Packet ID     |
196/// Size of the extended GVSP header (GigE Vision 2.0+).
197const GVSP_EXTENDED_HEADER_SIZE: usize = 20;
198
199/// Extended ID flag: bit 7 of the packet_format byte.
200const EXTENDED_ID_FLAG: u8 = 0x80;
201
202pub fn parse_packet(payload: &[u8]) -> Result<GvspPacket, GvspError> {
203    if payload.len() < GVSP_HEADER_SIZE {
204        return Err(GvspError::Invalid("GVSP header truncated"));
205    }
206
207    let packet_format_byte = payload[4];
208    let extended = (packet_format_byte & EXTENDED_ID_FLAG) != 0;
209    let packet_format = packet_format_byte & 0x0F;
210
211    let (block_id, packet_id, data_offset) = if extended {
212        // Extended ID header (20 bytes):
213        // [0-1]  status
214        // [2-3]  block_id low 16 (backward compat)
215        // [4]    packet_format | 0x80
216        // [5-7]  packet_id low 24
217        // [8-15] block_id 64-bit
218        // [16-19] packet_id 32-bit
219        if payload.len() < GVSP_EXTENDED_HEADER_SIZE {
220            return Err(GvspError::Invalid("extended GVSP header truncated"));
221        }
222        let block_id = u64::from_be_bytes([
223            payload[8],
224            payload[9],
225            payload[10],
226            payload[11],
227            payload[12],
228            payload[13],
229            payload[14],
230            payload[15],
231        ]);
232        let packet_id = u32::from_be_bytes([payload[16], payload[17], payload[18], payload[19]]);
233        (block_id, packet_id, GVSP_EXTENDED_HEADER_SIZE)
234    } else {
235        // Standard header (8 bytes)
236        let block_id = u16::from_be_bytes([payload[2], payload[3]]) as u64;
237        let packet_id = u32::from_be_bytes([0, payload[5], payload[6], payload[7]]);
238        (block_id, packet_id, GVSP_HEADER_SIZE)
239    };
240
241    // Bytes 0-1 of every GVSP header are the status word (see the table
242    // above). They were previously shifted right by four and passed down as a
243    // "payload type", which the leader parser then discarded — so the real
244    // status was never examined anywhere in the receive path.
245    let status = u16::from_be_bytes([payload[0], payload[1]]);
246
247    match packet_format {
248        0x01 => parse_leader(packet_id, block_id, &payload[data_offset..]),
249        0x03 => parse_payload(packet_id, block_id, &payload[data_offset..]),
250        0x02 => parse_trailer(packet_id, block_id, status, &payload[data_offset..]),
251        _ => Err(GvspError::Unsupported("packet format")),
252    }
253}
254
255/// Parse a GVSP Data Leader packet.
256///
257/// Leader payload layout:
258///
259/// | Offset | Size | Field        |
260/// |--------|------|--------------|
261/// |      0 |    2 | Reserved     |
262/// |      2 |    2 | Payload type |
263/// |      4 |    8 | Timestamp    |
264/// |     12 |    4 | Pixel format |
265/// |     16 |    4 | Width        |
266/// |     20 |    4 | Height       |
267fn parse_leader(packet_id: u32, block_id: u64, payload: &[u8]) -> Result<GvspPacket, GvspError> {
268    if payload.len() < 24 {
269        return Err(GvspError::Invalid("leader payload truncated"));
270    }
271    let mut cursor = payload;
272    let _reserved = cursor.get_u16();
273    let payload_type = cursor.get_u16() as u8;
274    if payload_type != PAYLOAD_TYPE_IMAGE {
275        return Err(GvspError::Unsupported("payload type"));
276    }
277    let timestamp = cursor.get_u64();
278    let pixel_format = cursor.get_u32();
279    let width = cursor.get_u32();
280    let height = cursor.get_u32();
281    Ok(GvspPacket::Leader {
282        block_id,
283        packet_id,
284        payload_type,
285        timestamp,
286        width,
287        height,
288        pixel_format,
289    })
290}
291
292fn parse_payload(packet_id: u32, block_id: u64, payload: &[u8]) -> Result<GvspPacket, GvspError> {
293    Ok(GvspPacket::Payload {
294        block_id,
295        packet_id,
296        data: Bytes::copy_from_slice(payload),
297    })
298}
299
300/// Parse a GVSP Data Trailer packet.
301///
302/// Trailer payload layout (8 bytes):
303///
304/// | Offset | Size | Field        |
305/// |--------|------|--------------|
306/// |      0 |    2 | Reserved     |
307/// |      2 |    2 | Payload type |
308/// |      4 |    4 | Size Y       |
309/// |      8 |    * | Chunk data   |
310///
311/// Chunk data begins at offset **8**. Reading it from offset 2 — as this
312/// function used to — feeds `payload_type` and `size_y` to the chunk parser
313/// as if they were a chunk header, which is where the per-frame
314/// `chunk header truncated remaining=6` came from: those six bytes *are* the
315/// two fields. With chunk mode on it is worse than noise, because the 6-byte
316/// prefix desynchronises every chunk that follows.
317///
318/// `status` comes from the packet header, not from here; the first two bytes
319/// of this payload are reserved and carry no status.
320fn parse_trailer(
321    packet_id: u32,
322    block_id: u64,
323    status: u16,
324    payload: &[u8],
325) -> Result<GvspPacket, GvspError> {
326    if payload.len() < 2 {
327        return Err(GvspError::Invalid("trailer truncated"));
328    }
329    let mut cursor = payload;
330    let _reserved = cursor.get_u16();
331
332    // The specification fixes this payload at 8 bytes. A device that sends
333    // fewer still gets its frame delivered — we simply have no chunk region
334    // to report — rather than having the short read treated as chunk data.
335    let (payload_type, size_y, chunk_data) = if payload.len() >= 8 {
336        let payload_type = cursor.get_u16();
337        let size_y = cursor.get_u32();
338        (payload_type, size_y, Bytes::copy_from_slice(&payload[8..]))
339    } else {
340        debug!(
341            block_id,
342            len = payload.len(),
343            "trailer payload shorter than the specified 8 bytes"
344        );
345        (0, 0, Bytes::new())
346    };
347
348    Ok(GvspPacket::Trailer {
349        block_id,
350        packet_id,
351        status,
352        payload_type,
353        size_y,
354        chunk_data,
355    })
356}
357
358/// Bitmap tracking received packets within a block.
359#[derive(Debug, Clone)]
360pub struct PacketBitmap {
361    words: Vec<u64>,
362    received: usize,
363    total: usize,
364}
365
366impl PacketBitmap {
367    /// Create a bitmap with the given packet capacity.
368    pub fn new(total: usize) -> Self {
369        let words = total.div_ceil(64);
370        Self {
371            words: vec![0; words],
372            received: 0,
373            total,
374        }
375    }
376
377    fn mask_for(&self, packet_id: usize) -> (usize, u64) {
378        let word = packet_id / 64;
379        let bit = packet_id % 64;
380        (word, 1u64 << bit)
381    }
382
383    /// Mark a packet index as received.
384    pub fn set(&mut self, packet_id: usize) -> bool {
385        if packet_id >= self.total {
386            return false;
387        }
388        let (word, mask) = self.mask_for(packet_id);
389        let entry = &mut self.words[word];
390        if *entry & mask == 0 {
391            *entry |= mask;
392            self.received += 1;
393            true
394        } else {
395            false
396        }
397    }
398
399    /// Check whether the bitmap reports all packets received.
400    pub fn is_complete(&self) -> bool {
401        self.received == self.total
402    }
403
404    /// Return missing packet ranges as inclusive `[start, end]` indices.
405    pub fn missing_ranges(&self) -> Vec<RangeInclusive<u32>> {
406        let mut ranges = Vec::new();
407        let mut current: Option<(u32, u32)> = None;
408        for idx in 0..self.total {
409            let (word, mask) = self.mask_for(idx);
410            let present = (self.words[word] & mask) != 0;
411            match (present, current) {
412                (false, None) => current = Some((idx as u32, idx as u32)),
413                (false, Some((start, _))) => current = Some((start, idx as u32)),
414                (true, Some((start, end))) => {
415                    ranges.push(start..=end);
416                    current = None;
417                }
418                _ => {}
419            }
420        }
421        if let Some((start, end)) = current {
422            ranges.push(start..=end);
423        }
424        ranges
425    }
426}
427
428/// Representation of a partially received frame.
429#[derive(Debug)]
430pub struct FrameAssembly {
431    block_id: u64,
432    expected_packets: usize,
433    packet_payload: usize,
434    bitmap: PacketBitmap,
435    buffer: BytesMut,
436    lengths: Vec<usize>,
437    deadline: Instant,
438}
439
440impl FrameAssembly {
441    /// Create a new frame assembly using the supplied buffer.
442    pub fn new(
443        block_id: u64,
444        expected_packets: usize,
445        packet_payload: usize,
446        buffer: BytesMut,
447        deadline: Instant,
448    ) -> Self {
449        Self {
450            block_id,
451            expected_packets,
452            packet_payload,
453            bitmap: PacketBitmap::new(expected_packets),
454            buffer,
455            lengths: vec![0; expected_packets],
456            deadline,
457        }
458    }
459
460    /// Returns the block identifier associated with this frame.
461    pub fn block_id(&self) -> u64 {
462        self.block_id
463    }
464
465    /// Whether the reassembly deadline has elapsed.
466    pub fn is_expired(&self, now: Instant) -> bool {
467        now >= self.deadline
468    }
469
470    /// Insert a packet payload into the buffer.
471    pub fn ingest(&mut self, packet_id: usize, payload: &[u8]) -> bool {
472        if packet_id >= self.expected_packets || payload.len() > self.packet_payload {
473            return false;
474        }
475        if !self.bitmap.set(packet_id) {
476            return true;
477        }
478        // Track actual payload length for compaction at finish time.
479        self.lengths[packet_id] = payload.len();
480        let offset = packet_id * self.packet_payload;
481        if self.buffer.len() < offset + payload.len() {
482            self.buffer.resize(offset + payload.len(), 0);
483        }
484        self.buffer[offset..offset + payload.len()].copy_from_slice(payload);
485        true
486    }
487
488    /// Finalise the frame if all packets have been received.
489    pub fn finish(self) -> Option<Bytes> {
490        if !self.bitmap.is_complete() {
491            return None;
492        }
493
494        // If all packets except possibly the last are full-sized, we can
495        // return a slice of the existing buffer without extra copying.
496        let full_sized_prefix = if self.expected_packets > 0 {
497            self.lengths
498                .iter()
499                .take(self.expected_packets.saturating_sub(1))
500                .all(|&len| len == self.packet_payload)
501        } else {
502            true
503        };
504
505        if full_sized_prefix {
506            let last_len = *self.lengths.last().unwrap_or(&0);
507            let used = self
508                .packet_payload
509                .saturating_mul(self.expected_packets.saturating_sub(1))
510                + last_len;
511            let mut buf = self.buffer;
512            if buf.len() > used {
513                buf.truncate(used);
514            }
515            return Some(buf.freeze());
516        }
517
518        // Otherwise, compact the data to remove any gaps introduced by
519        // shorter packets occurring before the last packet.
520        let total: usize = self.lengths.iter().sum();
521        let mut out = BytesMut::with_capacity(total);
522        for (i, &len) in self.lengths.iter().enumerate() {
523            if len == 0 {
524                continue;
525            }
526            let start = i * self.packet_payload;
527            let end = start + len;
528            out.extend_from_slice(&self.buffer[start..end]);
529        }
530        Some(out.freeze())
531    }
532}
533
534/// Helper struct tracking resend attempts for a given block.
535#[derive(Debug, Clone)]
536pub struct ResendPlanner {
537    retries: u32,
538    max_retries: u32,
539    base_delay: Duration,
540    next_deadline: Instant,
541}
542
543impl ResendPlanner {
544    pub fn new(max_retries: u32, base_delay: Duration) -> Self {
545        Self {
546            retries: 0,
547            max_retries,
548            base_delay,
549            next_deadline: Instant::now(),
550        }
551    }
552
553    /// Determine whether a resend can be attempted at the provided instant.
554    pub fn should_resend(&self, now: Instant) -> bool {
555        self.retries < self.max_retries && now >= self.next_deadline
556    }
557
558    /// Record a resend attempt and compute the next deadline.
559    pub fn record_attempt(&mut self, now: Instant, jitter: Duration) {
560        self.retries += 1;
561        let base = self
562            .base_delay
563            .checked_mul(self.retries)
564            .unwrap_or(self.base_delay);
565        self.next_deadline = now + base + jitter;
566    }
567
568    /// Whether the resend planner exhausted all retries.
569    pub fn is_exhausted(&self) -> bool {
570        self.retries >= self.max_retries
571    }
572}
573
574/// Representation of a fully reassembled frame ready for consumption.
575#[derive(Debug, Clone)]
576pub struct CompletedFrame {
577    pub block_id: u64,
578    pub timestamp: Instant,
579    pub payload: Bytes,
580}
581
582/// Frame queue used for communicating between the receiver task and the
583/// application.
584#[derive(Debug)]
585pub struct FrameQueue {
586    inner: VecDeque<CompletedFrame>,
587    capacity: usize,
588}
589
590impl FrameQueue {
591    pub fn new(capacity: usize) -> Self {
592        Self {
593            inner: VecDeque::with_capacity(capacity),
594            capacity,
595        }
596    }
597
598    pub fn push(&mut self, frame: CompletedFrame, stats: &StreamStatsAccumulator) {
599        if self.inner.len() == self.capacity {
600            self.inner.pop_front();
601            stats.record_backpressure_drop();
602        }
603        self.inner.push_back(frame);
604    }
605
606    pub fn pop(&mut self) -> Option<CompletedFrame> {
607        self.inner.pop_front()
608    }
609}
610
611/// Coalesce missing packet ranges into resend requests.
612pub fn coalesce_missing(bitmap: &PacketBitmap, max_range: usize) -> Vec<RangeInclusive<u32>> {
613    bitmap
614        .missing_ranges()
615        .into_iter()
616        .flat_map(|range| split_range(range, max_range))
617        .collect()
618}
619
620fn split_range(range: RangeInclusive<u32>, max_len: usize) -> Vec<RangeInclusive<u32>> {
621    let start = *range.start() as usize;
622    let end = *range.end() as usize;
623    if max_len == 0 {
624        return vec![range];
625    }
626    let mut result = Vec::new();
627    let mut current = start;
628    while current <= end {
629        let upper = (current + max_len - 1).min(end);
630        result.push(current as u32..=upper as u32);
631        current = upper + 1;
632    }
633    result
634}
635
636/// Zero-copy block assembly state machine.
637#[derive(Debug)]
638pub struct Reassembler {
639    active: Option<FrameAssembly>,
640    packet_payload: usize,
641    stats: StreamStatsAccumulator,
642}
643
644impl Reassembler {
645    pub fn new(packet_payload: usize, stats: StreamStatsAccumulator) -> Self {
646        Self {
647            active: None,
648            packet_payload,
649            stats,
650        }
651    }
652
653    /// Start a new block, evicting the previous one when necessary.
654    pub fn start_block(&mut self, block_id: u64, expected_packets: usize, buffer: BytesMut) {
655        let deadline = Instant::now() + Duration::from_millis(50);
656        self.active = Some(FrameAssembly::new(
657            block_id,
658            expected_packets,
659            self.packet_payload,
660            buffer,
661            deadline,
662        ));
663    }
664
665    /// Insert a packet belonging to the active block.
666    pub fn push_packet(&mut self, packet_id: usize, payload: &[u8]) {
667        if let Some(assembly) = self.active.as_mut()
668            && assembly.ingest(packet_id, payload)
669        {
670            self.stats.record_packet();
671        }
672    }
673
674    /// Attempt to finish the current block.
675    pub fn finish_block(&mut self) -> Option<Bytes> {
676        self.active.take().and_then(FrameAssembly::finish)
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    #[test]
685    fn parse_multiple_chunks() {
686        let mut payload = Vec::new();
687        payload.extend_from_slice(&0x0001u16.to_be_bytes());
688        payload.extend_from_slice(&0u16.to_be_bytes());
689        payload.extend_from_slice(&4u32.to_be_bytes());
690        payload.extend_from_slice(&[1, 2, 3, 4]);
691        payload.extend_from_slice(&0x0002u16.to_be_bytes());
692        payload.extend_from_slice(&0u16.to_be_bytes());
693        payload.extend_from_slice(&2u32.to_be_bytes());
694        payload.extend_from_slice(&[5, 6]);
695        let chunks = parse_chunks(&payload);
696        assert_eq!(chunks.len(), 2);
697        assert_eq!(chunks[0].id, 0x0001);
698        assert_eq!(chunks[0].data.as_ref(), &[1, 2, 3, 4]);
699        assert_eq!(chunks[1].id, 0x0002);
700        assert_eq!(chunks[1].data.as_ref(), &[5, 6]);
701    }
702
703    #[test]
704    fn truncated_chunk_is_ignored() {
705        let payload = vec![0u8; 6];
706        let chunks = parse_chunks(&payload);
707        assert!(chunks.is_empty());
708    }
709
710    /// A GVSP data trailer, assembled from the specification rather than from
711    /// what our own parser or fake camera happen to produce (ADR-0019).
712    ///
713    /// Standard header (8 bytes): `status(2) | block_id(2) | format(1) |
714    /// packet_id(3)`, then the trailer payload (8 bytes): `reserved(2) |
715    /// payload_type(2) | size_y(4)`, then any chunk region.
716    fn golden_trailer(status: u16, payload_type: u16, size_y: u32, chunks: &[u8]) -> Vec<u8> {
717        let mut pkt = Vec::new();
718        pkt.extend_from_slice(&status.to_be_bytes());
719        pkt.extend_from_slice(&0x0007u16.to_be_bytes()); // block_id
720        pkt.push(0x02); // packet format: trailer
721        pkt.extend_from_slice(&[0x00, 0x00, 0x42]); // packet_id (24-bit)
722        pkt.extend_from_slice(&0u16.to_be_bytes()); // reserved
723        pkt.extend_from_slice(&payload_type.to_be_bytes());
724        pkt.extend_from_slice(&size_y.to_be_bytes());
725        pkt.extend_from_slice(chunks);
726        pkt
727    }
728
729    /// The regression behind the per-frame `chunk header truncated
730    /// remaining=6` a JAI produced on issue #70. A trailer closing a plain
731    /// image block carries no chunk region at all — the six bytes previously
732    /// reported as a truncated chunk header were `payload_type` and `size_y`.
733    #[test]
734    fn trailer_without_chunks_yields_no_chunk_region() {
735        let pkt = golden_trailer(0, PAYLOAD_TYPE_IMAGE as u16, 1536, &[]);
736        assert_eq!(pkt.len(), 16, "8-byte header + 8-byte trailer payload");
737
738        let GvspPacket::Trailer {
739            block_id,
740            packet_id,
741            status,
742            payload_type,
743            size_y,
744            chunk_data,
745        } = parse_packet(&pkt).expect("parse trailer")
746        else {
747            panic!("expected a trailer");
748        };
749
750        assert_eq!(block_id, 0x0007);
751        assert_eq!(packet_id, 0x42);
752        assert_eq!(status, 0);
753        assert_eq!(payload_type, 0x0001);
754        assert_eq!(size_y, 1536);
755        assert!(
756            chunk_data.is_empty(),
757            "chunk region begins at offset 8, so a plain image trailer has none"
758        );
759        assert!(parse_chunks(&chunk_data).is_empty());
760    }
761
762    /// With chunk mode on the same six bytes desynchronised every chunk that
763    /// followed, so `ChunkTimestamp` could not decode on a real camera.
764    #[test]
765    fn trailer_chunk_region_starts_after_the_payload_header() {
766        let mut chunks = Vec::new();
767        chunks.extend_from_slice(&0x0001u16.to_be_bytes()); // ChunkTimestamp
768        chunks.extend_from_slice(&0u16.to_be_bytes()); // reserved
769        chunks.extend_from_slice(&8u32.to_be_bytes()); // length
770        chunks.extend_from_slice(&0x0123_4567_89AB_CDEFu64.to_be_bytes());
771
772        // 0x4001 — Image Extended Chunk, the payload type real cameras use to
773        // deliver chunks.
774        let pkt = golden_trailer(0, 0x4001, 1536, &chunks);
775
776        let GvspPacket::Trailer {
777            payload_type,
778            chunk_data,
779            ..
780        } = parse_packet(&pkt).expect("parse trailer")
781        else {
782            panic!("expected a trailer");
783        };
784
785        assert_eq!(payload_type, 0x4001);
786        let parsed = parse_chunks(&chunk_data);
787        assert_eq!(
788            parsed.len(),
789            1,
790            "exactly one chunk, not a desynchronised run"
791        );
792        assert_eq!(parsed[0].id, 0x0001);
793        assert_eq!(
794            parsed[0].data.as_ref(),
795            &0x0123_4567_89AB_CDEFu64.to_be_bytes()
796        );
797    }
798
799    /// The status word lives in the packet header, not in the trailer payload
800    /// whose first two bytes are reserved. Reading it from the payload meant
801    /// the receiver's `status != 0` frame check tested the wrong field.
802    #[test]
803    fn trailer_status_comes_from_the_packet_header() {
804        // A non-zero header status with an all-zero (reserved) payload prefix:
805        // the old code read the payload and saw success.
806        let pkt = golden_trailer(0x8004, PAYLOAD_TYPE_IMAGE as u16, 0, &[]);
807        let GvspPacket::Trailer { status, .. } = parse_packet(&pkt).expect("parse trailer") else {
808            panic!("expected a trailer");
809        };
810        assert_eq!(status, 0x8004);
811    }
812
813    #[test]
814    fn parse_chunks_tolerates_padding() {
815        for _ in 0..128 {
816            let count = fastrand::usize(..6);
817            let mut payload = Vec::new();
818            let mut entries = Vec::new();
819            for _ in 0..count {
820                let id = fastrand::u16(..);
821                let len = fastrand::usize(..16);
822                let mut data = vec![0u8; len];
823                for byte in &mut data {
824                    *byte = fastrand::u8(..);
825                }
826                payload.extend_from_slice(&id.to_be_bytes());
827                payload.extend_from_slice(&0u16.to_be_bytes());
828                payload.extend_from_slice(&(data.len() as u32).to_be_bytes());
829                payload.extend_from_slice(&data);
830                entries.push((id, data));
831            }
832            let padding_len = fastrand::usize(..8);
833            for _ in 0..padding_len {
834                payload.push(fastrand::u8(..));
835            }
836            let parsed = parse_chunks(&payload);
837            assert!(parsed.len() <= entries.len());
838            for (idx, chunk) in parsed.iter().enumerate() {
839                assert_eq!(chunk.id, entries[idx].0);
840                assert_eq!(chunk.data.as_ref(), entries[idx].1.as_slice());
841            }
842        }
843    }
844
845    #[test]
846    fn bitmap_missing_ranges_coalesce() {
847        let mut bitmap = PacketBitmap::new(10);
848        for &idx in &[0usize, 1, 5, 6, 9] {
849            bitmap.set(idx);
850        }
851        let ranges = bitmap.missing_ranges();
852        assert_eq!(ranges.len(), 2);
853        assert_eq!(ranges[0], 2..=4);
854        assert_eq!(ranges[1], 7..=8);
855    }
856
857    #[test]
858    fn coalesce_splits_large_ranges() {
859        let mut bitmap = PacketBitmap::new(20);
860        for idx in [0usize, 1, 2, 18, 19] {
861            bitmap.set(idx);
862        }
863        let ranges = coalesce_missing(&bitmap, 4);
864        assert_eq!(ranges, vec![3..=6, 7..=10, 11..=14, 15..=17]);
865    }
866
867    #[test]
868    fn reassembler_finishes_frame() {
869        let stats = StreamStatsAccumulator::new();
870        let mut reassembler = Reassembler::new(4, stats);
871        reassembler.start_block(1, 3, BytesMut::with_capacity(12));
872        reassembler.push_packet(0, &[1, 2, 3]);
873        reassembler.push_packet(1, &[4, 5, 6]);
874        reassembler.push_packet(2, &[7, 8, 9]);
875        let frame = reassembler.finish_block().expect("frame");
876        assert_eq!(frame.as_ref(), &[1, 2, 3, 4, 5, 6, 7, 8, 9]);
877    }
878}