Skip to main content

ados_protocol/
tap.rs

1//! Contract F — the vision-tap wire format.
2//!
3//! The frozen seam between the video pipeline (the writer, `ados-video`) and the
4//! vision engine (the reader, `ados-vision`). A tap carries decoded rawvideo
5//! frames from the encode pipeline to the engine over a Unix socket: the engine
6//! *connects* as a client, the writer *serves* the socket and streams
7//! `[header][pixels]` per frame.
8//!
9//! **Framing (v2):** a 24-byte little-endian header, then `byte_len` raw pixel
10//! bytes:
11//!
12//! ```text
13//! magic:u32=0x41445654 ("ADVT") | format:u8 | _pad:u8 | width:u16 | height:u16 | _pad:u16 | byte_len:u32 | ts_mono_ns:u64
14//! ```
15//!
16//! `ts_mono_ns` is host CLOCK_MONOTONIC ns stamped by the writer at frame arrival.
17//! Self-describing so a consumer can size a ring without a side channel. This
18//! module is the single definition both sides build against — freeze it here,
19//! change both sides in the same version bump (the same frozen-wire-contract
20//! discipline the mavlink, state, and plugin sockets already follow, now
21//! extended to the vision seam).
22
23use thiserror::Error;
24use tokio::io::AsyncWriteExt;
25
26use crate::framebus::FrameFormat;
27
28/// The tap wire-contract version. Bump on any framing change; both the writer
29/// and the reader must move together.
30pub const TAP_CONTRACT_VERSION: u32 = 2;
31
32/// Header magic — ASCII "ADVT", little-endian.
33pub const TAP_MAGIC: u32 = 0x4144_5654;
34/// Fixed header length in bytes.
35pub const TAP_HEADER_LEN: usize = 24;
36/// Cap a single tapped frame so a corrupt header cannot drive an unbounded
37/// allocation. 4K RGB24 is ~25 MiB; 64 MiB is a generous ceiling.
38pub const TAP_MAX_FRAME: usize = 64 * 1024 * 1024;
39
40/// A malformed tap header.
41#[derive(Debug, Error, PartialEq, Eq)]
42pub enum TapHeaderError {
43    /// The 4-byte magic did not match [`TAP_MAGIC`].
44    #[error("bad tap frame magic {0:#x}")]
45    BadMagic(u32),
46    /// The format tag was not one of the known [`FrameFormat`] values.
47    #[error("bad tap format tag {0}")]
48    BadFormatTag(u8),
49    /// The declared payload length exceeds [`TAP_MAX_FRAME`].
50    #[error("tap frame of {0} bytes exceeds the {TAP_MAX_FRAME}-byte cap")]
51    FrameTooLarge(usize),
52}
53
54/// A decoded tap frame header.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct TapHeader {
57    pub format: FrameFormat,
58    pub width: u32,
59    pub height: u32,
60    pub byte_len: usize,
61    /// Host CLOCK_MONOTONIC ns at frame arrival on the writer side.
62    pub ts_mono_ns: u64,
63}
64
65/// The pixel-format wire tag for a [`FrameFormat`].
66pub fn format_tag(f: FrameFormat) -> u8 {
67    match f {
68        FrameFormat::Rgb24 => 0,
69        FrameFormat::Nv12 => 1,
70        FrameFormat::Yuv420p => 2,
71        FrameFormat::Gray8 => 3,
72        FrameFormat::Gray16 => 4,
73        FrameFormat::BayerRggb8 => 5,
74        FrameFormat::BayerRggb16 => 6,
75    }
76}
77
78/// The [`FrameFormat`] for a wire tag, or `None` for an unknown tag.
79pub fn format_from_tag(t: u8) -> Option<FrameFormat> {
80    match t {
81        0 => Some(FrameFormat::Rgb24),
82        1 => Some(FrameFormat::Nv12),
83        2 => Some(FrameFormat::Yuv420p),
84        3 => Some(FrameFormat::Gray8),
85        4 => Some(FrameFormat::Gray16),
86        5 => Some(FrameFormat::BayerRggb8),
87        6 => Some(FrameFormat::BayerRggb16),
88        _ => None,
89    }
90}
91
92/// Encode the tap frame header. The writer prepends this to each frame's pixels.
93pub fn encode_tap_header(
94    format: FrameFormat,
95    width: u32,
96    height: u32,
97    byte_len: u32,
98    ts_mono_ns: u64,
99) -> [u8; TAP_HEADER_LEN] {
100    let mut h = [0u8; TAP_HEADER_LEN];
101    h[0..4].copy_from_slice(&TAP_MAGIC.to_le_bytes());
102    h[4] = format_tag(format);
103    h[6..8].copy_from_slice(&(width as u16).to_le_bytes());
104    h[8..10].copy_from_slice(&(height as u16).to_le_bytes());
105    h[12..16].copy_from_slice(&byte_len.to_le_bytes());
106    h[16..24].copy_from_slice(&ts_mono_ns.to_le_bytes());
107    h
108}
109
110/// Decode a tap frame header.
111pub fn decode_tap_header(h: &[u8; TAP_HEADER_LEN]) -> Result<TapHeader, TapHeaderError> {
112    let magic = u32::from_le_bytes(h[0..4].try_into().unwrap());
113    if magic != TAP_MAGIC {
114        return Err(TapHeaderError::BadMagic(magic));
115    }
116    let format = format_from_tag(h[4]).ok_or(TapHeaderError::BadFormatTag(h[4]))?;
117    let width = u16::from_le_bytes(h[6..8].try_into().unwrap()) as u32;
118    let height = u16::from_le_bytes(h[8..10].try_into().unwrap()) as u32;
119    let byte_len = u32::from_le_bytes(h[12..16].try_into().unwrap()) as usize;
120    if byte_len > TAP_MAX_FRAME {
121        return Err(TapHeaderError::FrameTooLarge(byte_len));
122    }
123    let ts_mono_ns = u64::from_le_bytes(h[16..24].try_into().unwrap());
124    Ok(TapHeader {
125        format,
126        width,
127        height,
128        byte_len,
129        ts_mono_ns,
130    })
131}
132
133/// Write one `[header][pixels]` tap frame to `w`. The shared writer the video
134/// pipeline shim and any producer use so the framing matches the reader exactly.
135pub async fn write_tap_frame<W>(
136    w: &mut W,
137    format: FrameFormat,
138    width: u32,
139    height: u32,
140    pixels: &[u8],
141    ts_mono_ns: u64,
142) -> std::io::Result<()>
143where
144    W: AsyncWriteExt + Unpin,
145{
146    let header = encode_tap_header(format, width, height, pixels.len() as u32, ts_mono_ns);
147    w.write_all(&header).await?;
148    if !pixels.is_empty() {
149        w.write_all(pixels).await?;
150    }
151    w.flush().await
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    /// The hand-written tap contract version must match the registry entry so a
159    /// future drift is a build-time test failure, not a silent mis-parse.
160    #[test]
161    fn version_matches_registry() {
162        assert_eq!(
163            TAP_CONTRACT_VERSION as u16,
164            crate::contracts::contract_version("tap.frame").unwrap()
165        );
166    }
167
168    #[test]
169    fn header_round_trips() {
170        for (fmt, w, h, ts) in [
171            (FrameFormat::Rgb24, 640u32, 480u32, 0u64),
172            (FrameFormat::Nv12, 1280, 720, 1),
173            (FrameFormat::Yuv420p, 1920, 1080, u64::MAX),
174            (FrameFormat::Gray8, 640, 480, 123_456_789_012),
175            (FrameFormat::Gray16, 640, 480, 7),
176            (FrameFormat::BayerRggb8, 640, 480, 8),
177            (FrameFormat::BayerRggb16, 640, 480, 9),
178        ] {
179            let len = fmt.frame_bytes(w, h);
180            let hdr = encode_tap_header(fmt, w, h, len as u32, ts);
181            let d = decode_tap_header(&hdr).unwrap();
182            assert_eq!(
183                d,
184                TapHeader {
185                    format: fmt,
186                    width: w,
187                    height: h,
188                    byte_len: len,
189                    ts_mono_ns: ts,
190                }
191            );
192        }
193    }
194
195    #[test]
196    fn header_layout_is_pinned() {
197        // A wire-shape regression tripwire: the exact bytes for a known frame.
198        let hdr = encode_tap_header(FrameFormat::Rgb24, 8, 4, 96, 0x0102_0304_0506_0708);
199        assert_eq!(&hdr[0..4], b"TVDA"); // 0x41445654 little-endian
200        assert_eq!(hdr[4], 0); // rgb24
201        assert_eq!(u16::from_le_bytes([hdr[6], hdr[7]]), 8);
202        assert_eq!(u16::from_le_bytes([hdr[8], hdr[9]]), 4);
203        assert_eq!(u32::from_le_bytes([hdr[12], hdr[13], hdr[14], hdr[15]]), 96);
204        assert_eq!(
205            u64::from_le_bytes(hdr[16..24].try_into().unwrap()),
206            0x0102_0304_0506_0708
207        );
208    }
209
210    #[test]
211    fn decode_rejects_bad_magic_tag_and_oversize() {
212        let mut hdr = encode_tap_header(FrameFormat::Rgb24, 8, 4, 96, 0);
213        let good = hdr;
214        hdr[0] ^= 0xFF;
215        assert!(matches!(
216            decode_tap_header(&hdr),
217            Err(TapHeaderError::BadMagic(_))
218        ));
219        let mut bad_tag = good;
220        bad_tag[4] = 9;
221        assert_eq!(
222            decode_tap_header(&bad_tag),
223            Err(TapHeaderError::BadFormatTag(9))
224        );
225        let mut big = good;
226        big[12..16].copy_from_slice(&((TAP_MAX_FRAME as u32) + 1).to_le_bytes());
227        assert!(matches!(
228            decode_tap_header(&big),
229            Err(TapHeaderError::FrameTooLarge(_))
230        ));
231    }
232}