Skip to main content

ados_protocol/
framebus.rs

1//! Vision frame transport contract.
2//!
3//! Camera frames are large (a 640x480 RGB24 frame is ~900 KiB, a 1280x720 one
4//! ~2.7 MiB), so they do not travel inside the plugin RPC envelope (capped at
5//! 4 MiB and copied through msgpack on a drop-on-full broadcast). Instead the
6//! vision engine writes normalized frames into a shared-memory ring and
7//! publishes only a small [`FrameDescriptor`] on the `vision.frame` topic. A
8//! consumer (a Rust or Python plugin) maps the same ring and reads the slot the
9//! descriptor names.
10//!
11//! This module owns two things so the publisher and every consumer agree
12//! byte-for-byte:
13//!
14//! - the descriptor wire shape ([`FrameDescriptor`], msgpack map with the same
15//!   field names in Rust and Python), and
16//! - the ring memory layout ([`RingLayout`]) plus the per-slot seqlock used to
17//!   detect a torn read when the single writer recycles a slot under a reader.
18//!
19//! The module is deliberately free of any OS mapping code: it operates on plain
20//! byte slices, so it builds and unit-tests on any host. The caller maps
21//! `/dev/shm/<shm_name>` (for example with `memmap2`) and hands the slice to
22//! [`write_slot`] / [`read_slot_into`].
23//!
24//! Detections travel the other way as small structured payloads on the
25//! `vision.detection` topic and ride the plugin envelope directly; only frames
26//! need the ring.
27
28use std::collections::BTreeMap;
29use std::sync::atomic::{compiler_fence, fence, Ordering};
30
31use serde::{Deserialize, Serialize};
32use thiserror::Error;
33
34mod seqlock;
35use seqlock::{load_seq_volatile, store_seq_volatile};
36
37/// Topic the vision engine publishes frame descriptors on. Reserved to the
38/// host: plugins subscribe (with `vision.frame.read`) but never publish here.
39pub const VISION_FRAME_TOPIC: &str = "vision.frame";
40
41/// The largest slot count a ring header can carry. The header records
42/// `slot_count` in two little-endian bytes, so a ring is capped here; a writer
43/// that asked for more would diverge from a reader that re-derives the layout
44/// from the truncated header field, so it is rejected at header-write time and
45/// validated at engine startup. Sane ring depths are single digits, so this
46/// cap is never approached in practice.
47pub const MAX_SLOT_COUNT: u32 = u16::MAX as u32;
48
49/// Topic detections are published on, labelled by model id.
50pub const VISION_DETECTION_TOPIC: &str = "vision.detection";
51
52/// Normalized pixel format of a frame in the ring. The engine downscales and
53/// converts the camera's native format to one of these before publishing.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum FrameFormat {
57    /// Packed 24-bit RGB, 3 bytes per pixel.
58    Rgb24,
59    /// Semi-planar YUV 4:2:0 (Y plane then interleaved UV), 1.5 bytes per pixel.
60    Nv12,
61    /// Planar YUV 4:2:0 (Y, then U, then V), 1.5 bytes per pixel.
62    Yuv420p,
63    /// 8-bit grayscale, 1 byte per pixel.
64    Gray8,
65    /// 16-bit grayscale, little-endian, 2 bytes per pixel.
66    Gray16,
67    /// 8-bit Bayer mosaic, RGGB pattern, 1 byte per pixel.
68    BayerRggb8,
69    /// 16-bit Bayer mosaic, RGGB pattern, little-endian, 2 bytes per pixel.
70    BayerRggb16,
71}
72
73impl FrameFormat {
74    /// Exact byte length of one `width` x `height` frame in this format.
75    ///
76    /// The 4:2:0 formats require even dimensions; callers normalize to even
77    /// width/height before sizing a ring.
78    pub fn frame_bytes(self, width: u32, height: u32) -> usize {
79        let px = width as usize * height as usize;
80        match self {
81            FrameFormat::Rgb24 => px * 3,
82            // Y plane (px) + chroma (px / 2) = px * 3 / 2.
83            FrameFormat::Nv12 | FrameFormat::Yuv420p => px + px / 2,
84            FrameFormat::Gray8 | FrameFormat::BayerRggb8 => px,
85            FrameFormat::Gray16 | FrameFormat::BayerRggb16 => px * 2,
86        }
87    }
88
89    /// The ffmpeg `-pix_fmt` name of this layout.
90    pub fn ffmpeg_pix_fmt(self) -> &'static str {
91        match self {
92            FrameFormat::Rgb24 => "rgb24",
93            FrameFormat::Nv12 => "nv12",
94            FrameFormat::Yuv420p => "yuv420p",
95            FrameFormat::Gray8 => "gray",
96            FrameFormat::Gray16 => "gray16le",
97            FrameFormat::BayerRggb8 => "bayer_rggb8",
98            FrameFormat::BayerRggb16 => "bayer_rggb16le",
99        }
100    }
101
102    /// A raw Bayer mosaic (not yet demosaiced).
103    pub fn is_bayer(self) -> bool {
104        matches!(self, FrameFormat::BayerRggb8 | FrameFormat::BayerRggb16)
105    }
106}
107
108/// The current wire version of a [`FrameDescriptor`] on the `vision.frame`
109/// topic. Bumped whenever the descriptor's on-wire shape changes; a decode of a
110/// descriptor stamped with any other version fails loudly rather than silently
111/// mis-parsing. Mirrors the `framebus.descriptor` entry in the contract
112/// registry (`contracts.toml`).
113pub const FRAMEBUS_DESCRIPTOR_VERSION: u16 = 2;
114
115/// `ts_sensor_ns` is absent or on the host monotonic clock.
116pub const TIMEBASE_HOST: u8 = 0;
117/// `ts_sensor_ns` is on the device/driver clock.
118pub const TIMEBASE_SENSOR: u8 = 1;
119/// `ts_sensor_ns` is simulation time in ns.
120pub const TIMEBASE_SIM: u8 = 2;
121
122mod clock;
123pub use clock::mono_ns;
124
125/// The small message published on `vision.frame`. It names the ring slot a
126/// consumer should read and carries the `seq` that the per-slot seqlock must
127/// still hold for the read to be valid.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct FrameDescriptor {
130    /// Wire version, stamped on every descriptor and checked on decode. A
131    /// consumer that sees a version it does not speak rejects the frame instead
132    /// of mis-parsing it. Deliberately carries no serde default: a payload
133    /// missing this field fails to decode.
134    #[serde(rename = "v")]
135    pub v: u16,
136    /// Source camera id (a UVC/CSI device the engine owns, or the FPV camera
137    /// tapped from the video pipeline). Lets a consumer filter by camera.
138    pub camera_id: String,
139    /// Monotonic frame counter for this camera, starting at 1.
140    pub frame_id: u64,
141    /// Host CLOCK_MONOTONIC ns: exposure midpoint when known, else arrival.
142    pub ts_ns: u64,
143    /// Source-clock timestamp in ns, interpreted per `timebase`.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub ts_sensor_ns: Option<u64>,
146    /// Exposure duration in ns, when the source reports it.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub exposure_ns: Option<u32>,
149    /// Hardware trigger sequence that produced this frame, when known.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub trigger_seq: Option<u64>,
152    /// Clock domain of `ts_sensor_ns` (`TIMEBASE_*`).
153    pub timebase: u8,
154    pub width: u32,
155    pub height: u32,
156    pub format: FrameFormat,
157    /// `/dev/shm` name of the ring this frame lives in (one ring per camera).
158    pub shm_name: String,
159    /// Slot index within the ring holding this frame's pixels.
160    pub slot: u32,
161    /// Ring sequence stamped on the slot. A consumer re-checks this against the
162    /// slot's seqlock after copying; a mismatch means the writer recycled the
163    /// slot mid-read and the frame must be dropped.
164    pub seq: u64,
165    /// Length of the valid pixel bytes in the slot (`format.frame_bytes`).
166    pub byte_len: u32,
167}
168
169impl FrameDescriptor {
170    /// Encode as a msgpack map with named keys (matches the Python reader).
171    pub fn to_msgpack(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
172        rmp_serde::to_vec_named(self)
173    }
174
175    /// Decode from a msgpack map, rejecting a descriptor whose wire version this
176    /// build does not speak. A missing `v` field fails the msgpack decode; a
177    /// present-but-unknown `v` returns [`DescriptorError::Version`].
178    pub fn from_msgpack(bytes: &[u8]) -> Result<Self, DescriptorError> {
179        let desc: FrameDescriptor = rmp_serde::from_slice(bytes)?;
180        if desc.v != FRAMEBUS_DESCRIPTOR_VERSION {
181            return Err(DescriptorError::Version {
182                got: desc.v,
183                ours: FRAMEBUS_DESCRIPTOR_VERSION,
184            });
185        }
186        Ok(desc)
187    }
188}
189
190/// Errors decoding a [`FrameDescriptor`] from its msgpack wire form.
191#[derive(Debug, Error)]
192pub enum DescriptorError {
193    #[error("msgpack decode error: {0}")]
194    Decode(#[from] rmp_serde::decode::Error),
195    #[error("unsupported vision.frame descriptor version {got} (this build speaks {ours})")]
196    Version { got: u16, ours: u16 },
197}
198
199#[derive(Debug, Error, PartialEq, Eq)]
200pub enum RingError {
201    #[error("slot {slot} out of range (ring has {slot_count} slots)")]
202    SlotOutOfRange { slot: u32, slot_count: u32 },
203    #[error("payload of {len} bytes exceeds slot capacity {cap}")]
204    PayloadTooLarge { len: usize, cap: usize },
205    #[error("shared region is {got} bytes, layout needs {need}")]
206    RegionTooSmall { got: usize, need: usize },
207    #[error("slot_count {slot_count} exceeds the header maximum {max}")]
208    SlotCountTooLarge { slot_count: u32, max: u32 },
209}
210
211/// Memory layout of a single-writer, many-reader frame ring.
212///
213/// Layout (all integers little-endian):
214///
215/// ```text
216/// [ ring header  ]  HEADER_LEN bytes: magic, version, slot_count, slot_bytes
217/// [ slot 0       ]  SLOT_HEADER_LEN + slot_bytes + SLOT_TRAILER_LEN
218/// [ slot 1       ]
219///   ...
220/// ```
221///
222/// Each slot is `seq_begin:u64 | byte_len:u32 | _pad:u32 | <slot_bytes data> | seq_end:u64`.
223/// The writer stores `seq_begin`, then the data and length, then `seq_end`; a
224/// reader loads `seq_end`, copies the data, then re-checks `seq_begin` and
225/// `seq_end` against the descriptor's `seq`. If either differs the read was torn
226/// by a slot recycle and is discarded. Sizing `slot_count` above the number of
227/// concurrent consumers plus their read latency makes torn reads rare.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct RingLayout {
230    pub slot_count: u32,
231    /// Capacity of one slot's pixel region (the largest frame the ring holds).
232    pub slot_bytes: u32,
233}
234
235impl RingLayout {
236    /// "ADV1" — ADOS vision ring, version 1.
237    pub const MAGIC: u32 = 0x4144_5631;
238    pub const VERSION: u16 = 1;
239    pub const HEADER_LEN: usize = 16;
240    /// seq_begin (u64) + byte_len (u32) + pad (u32).
241    pub const SLOT_HEADER_LEN: usize = 16;
242    /// seq_end (u64).
243    pub const SLOT_TRAILER_LEN: usize = 8;
244
245    /// Size a ring that holds frames up to `width` x `height` in `format`.
246    pub fn for_frame(slot_count: u32, width: u32, height: u32, format: FrameFormat) -> Self {
247        RingLayout {
248            slot_count,
249            slot_bytes: format.frame_bytes(width, height) as u32,
250        }
251    }
252
253    pub fn slot_stride(&self) -> usize {
254        Self::SLOT_HEADER_LEN + self.slot_bytes as usize + Self::SLOT_TRAILER_LEN
255    }
256
257    /// Total bytes the shared region must provide.
258    pub fn total_len(&self) -> usize {
259        Self::HEADER_LEN + self.slot_count as usize * self.slot_stride()
260    }
261
262    fn slot_offset(&self, slot: u32) -> usize {
263        Self::HEADER_LEN + slot as usize * self.slot_stride()
264    }
265
266    /// Validate the layout against the on-disk header's field widths. The
267    /// header stores `slot_count` in two bytes, so a layout above
268    /// [`MAX_SLOT_COUNT`] would truncate on write and the reader's
269    /// header-derived layout would name a different slot than the writer used.
270    /// Callers validate at ring-creation / engine startup so a misconfigured
271    /// depth is rejected loudly rather than silently truncated.
272    pub fn validate(&self) -> Result<(), RingError> {
273        if self.slot_count > MAX_SLOT_COUNT {
274            return Err(RingError::SlotCountTooLarge {
275                slot_count: self.slot_count,
276                max: MAX_SLOT_COUNT,
277            });
278        }
279        Ok(())
280    }
281
282    /// Write the ring header at the front of a freshly created region. Rejects a
283    /// `slot_count` the two-byte header field cannot represent so the writer's
284    /// slot math (`seq % slot_count`) can never diverge from a reader that
285    /// re-derives the layout from the header.
286    pub fn write_header(&self, region: &mut [u8]) -> Result<(), RingError> {
287        self.validate()?;
288        self.check_region(region.len())?;
289        region[0..4].copy_from_slice(&Self::MAGIC.to_le_bytes());
290        region[4..6].copy_from_slice(&Self::VERSION.to_le_bytes());
291        region[6..8].copy_from_slice(&(self.slot_count as u16).to_le_bytes());
292        region[8..12].copy_from_slice(&self.slot_bytes.to_le_bytes());
293        region[12..16].copy_from_slice(&0u32.to_le_bytes());
294        Ok(())
295    }
296
297    /// Read the layout a writer recorded in a region's header.
298    pub fn read_header(region: &[u8]) -> Option<RingLayout> {
299        if region.len() < Self::HEADER_LEN {
300            return None;
301        }
302        let magic = u32::from_le_bytes(region[0..4].try_into().unwrap());
303        let version = u16::from_le_bytes(region[4..6].try_into().unwrap());
304        if magic != Self::MAGIC || version != Self::VERSION {
305            return None;
306        }
307        let slot_count = u16::from_le_bytes(region[6..8].try_into().unwrap()) as u32;
308        let slot_bytes = u32::from_le_bytes(region[8..12].try_into().unwrap());
309        Some(RingLayout {
310            slot_count,
311            slot_bytes,
312        })
313    }
314
315    fn check_region(&self, got: usize) -> Result<(), RingError> {
316        let need = self.total_len();
317        if got < need {
318            return Err(RingError::RegionTooSmall { got, need });
319        }
320        Ok(())
321    }
322}
323
324/// Write one frame into `slot` of the ring, stamping it with `seq`.
325///
326/// The single writer is expected to choose `slot = seq % slot_count` and call
327/// this once per captured frame, then publish the matching [`FrameDescriptor`].
328pub fn write_slot(
329    region: &mut [u8],
330    layout: &RingLayout,
331    slot: u32,
332    seq: u64,
333    data: &[u8],
334) -> Result<(), RingError> {
335    if slot >= layout.slot_count {
336        return Err(RingError::SlotOutOfRange {
337            slot,
338            slot_count: layout.slot_count,
339        });
340    }
341    let cap = layout.slot_bytes as usize;
342    if data.len() > cap {
343        return Err(RingError::PayloadTooLarge {
344            len: data.len(),
345            cap,
346        });
347    }
348    layout.check_region(region.len())?;
349
350    let base = layout.slot_offset(slot);
351    let data_off = base + RingLayout::SLOT_HEADER_LEN;
352    let trailer_off = data_off + cap;
353
354    // Seqlock write order (single writer, many cross-process readers):
355    //   1. stamp seq_begin
356    //   2. Release fence so the begin stamp is visible before the data copy
357    //   3. copy the pixel bytes + length
358    //   4. Release fence so the data is visible before seq_end commits
359    //   5. stamp seq_end
360    // The two fences keep a weakly-ordered CPU (the aarch64 SBC target) from
361    // reordering the data copy past either guard, so a reader that observes
362    // matching begin/end guards is guaranteed to have observed this frame's
363    // bytes, not a torn mix of two frames.
364    store_seq_volatile(region, base, seq);
365    region[base + 8..base + 12].copy_from_slice(&(data.len() as u32).to_le_bytes());
366    region[base + 12..base + 16].copy_from_slice(&0u32.to_le_bytes());
367    fence(Ordering::Release);
368    region[data_off..data_off + data.len()].copy_from_slice(data);
369    fence(Ordering::Release);
370    store_seq_volatile(region, trailer_off, seq);
371    Ok(())
372}
373
374/// Read the frame a [`FrameDescriptor`] points at into `out`, validating the
375/// seqlock. `out` is cleared and refilled, reusing its capacity.
376///
377/// Returns the byte length, or `Ok(None)` if the slot no longer holds
378/// `expected_seq` (the writer recycled it); `out` is unspecified then and the
379/// caller drops that frame and waits for the next descriptor.
380pub fn read_slot_into(
381    region: &[u8],
382    layout: &RingLayout,
383    slot: u32,
384    expected_seq: u64,
385    out: &mut Vec<u8>,
386) -> Result<Option<usize>, RingError> {
387    if slot >= layout.slot_count {
388        return Err(RingError::SlotOutOfRange {
389            slot,
390            slot_count: layout.slot_count,
391        });
392    }
393    layout.check_region(region.len())?;
394
395    let cap = layout.slot_bytes as usize;
396    let base = layout.slot_offset(slot);
397    let data_off = base + RingLayout::SLOT_HEADER_LEN;
398    let trailer_off = data_off + cap;
399
400    // Seqlock read order (mirrors the write fences in reverse):
401    //   1. load seq_end (the committed marker the writer stamps last)
402    //   2. Acquire fence so the data copy cannot be hoisted above the load
403    //   3. copy the pixel bytes
404    //   4. Acquire fence so the guard re-reads cannot be hoisted above the copy
405    //   5. re-read seq_begin + seq_end; a mismatch means the writer recycled
406    //      the slot mid-copy (the read was torn) and the frame is dropped.
407    let seq_end = load_seq_volatile(region, trailer_off);
408    if seq_end != expected_seq {
409        return Ok(None);
410    }
411    let byte_len = u32::from_le_bytes(region[base + 8..base + 12].try_into().unwrap()) as usize;
412    if byte_len > cap {
413        return Ok(None);
414    }
415    fence(Ordering::Acquire);
416    // The copy must not be reordered before the seq_end check above nor after
417    // the guard re-reads below; the volatile guard loads plus the fences pin it.
418    compiler_fence(Ordering::Acquire);
419    out.clear();
420    out.extend_from_slice(&region[data_off..data_off + byte_len]);
421    fence(Ordering::Acquire);
422    let seq_begin = load_seq_volatile(region, base);
423    let seq_end2 = load_seq_volatile(region, trailer_off);
424    if seq_begin != expected_seq || seq_end2 != expected_seq {
425        return Ok(None);
426    }
427    Ok(Some(byte_len))
428}
429
430// ---------------------------------------------------------------------------
431// Detection + model contracts.
432//
433// Unlike frames, these payloads are small, so they ride the plugin RPC
434// envelope and the `vision.detection` event directly (no shared memory). They
435// live here so the engine, the plugin host bridge, and the SDK share one shape.
436// ---------------------------------------------------------------------------
437
438/// Plugin RPC method names for the vision surface. The plugin host gates each
439/// on the matching capability before routing to the vision engine over
440/// `/run/ados/vision.sock`. The request args of `REGISTER_MODEL`, `INFER`,
441/// `PUBLISH_DETECTION` and `DESIGNATE_TRACK` are defined in
442/// [`crate::vision_rpc`].
443pub mod methods;
444
445/// What a model produces, so consumers know how to read its output.
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(rename_all = "lowercase")]
448pub enum ModelKind {
449    Detection,
450    Segmentation,
451    Classification,
452    Tracking,
453    /// Keypoint / pose estimation: the model emits per-detection `keypoints`
454    /// (skeleton joints or landmarks) rather than only a box.
455    Pose,
456    /// Monocular depth estimation: the model emits a per-detection `depth`
457    /// (metres) so a consumer can place the object in space.
458    Depth,
459    /// Appearance re-identification: the model emits an embedding used to
460    /// re-associate the same object across frames (feeds the tracker, not a box).
461    Reid,
462}
463
464/// A registered model's info, for the engine's list-models read-back (the
465/// [`methods::LIST_MODELS`] reply). Lets the GCS vision hub show every model
466/// loaded on the drone — its task, how it runs, whether the engine loaded a
467/// backend for it — not only the models currently publishing detections.
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469pub struct ModelInfo {
470    pub id: String,
471    pub kind: ModelKind,
472    pub execution: ModelExecution,
473    /// True when the engine holds a loaded backend model for it (an engine-run
474    /// model whose file loaded); false for a plugin-side or failed-to-load model.
475    pub backend_loaded: bool,
476    pub output_classes: Vec<String>,
477    /// Measured inference rate in Hz (an EWMA over inter-inference intervals).
478    /// `0.0` when the model has not run yet — the honest "no data" reading, not a
479    /// claim of zero throughput. Additive: an older payload without it decodes to
480    /// `0.0`.
481    #[serde(default)]
482    pub fps: f32,
483    /// Measured per-inference latency in ms (an EWMA). `0.0` until the model runs.
484    #[serde(default, rename = "latencyMs")]
485    pub latency_ms: f32,
486    /// Whether this model actually runs a real backend (an engine-run model whose
487    /// file loaded on an inference-capable backend). A mock-backend engine reports
488    /// `false` so a status surface never presents a placeholder as a working
489    /// detector. Additive; defaults `false`.
490    #[serde(default, rename = "isInferenceCapable")]
491    pub is_inference_capable: bool,
492}
493
494/// How a registered model is executed.
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum ModelExecution {
498    /// The engine loads the model file and runs it on the shared backend, then
499    /// publishes detections itself.
500    EngineRun,
501    /// The plugin runs the model and publishes detections (or calls `infer`).
502    PluginSide,
503}
504
505/// The output-tensor layout of a detection model's head, so the decoder reads
506/// the right axes. `Yolo8` is the transposed `[1, 4+nc, anchors]` head — box
507/// (cx, cy, w, h) plus one score per class, no objectness — that ultralytics
508/// YOLOv8/v11 export. `Yolo5` is the legacy `[1, anchors, 5+nc]` head — box plus
509/// an objectness column plus per-class scores — of YOLOv5/v7. They are decoded
510/// differently (a v8 model read as v5 shifts every field by one and mistakes the
511/// first class score for objectness), so the layout travels with the model.
512/// Defaults to `Yolo8`, the current export path.
513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
514#[serde(rename_all = "lowercase")]
515pub enum DetectionHead {
516    #[default]
517    Yolo8,
518    Yolo5,
519}
520
521/// Metadata a plugin supplies when registering a model.
522#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
523pub struct ModelMetadata {
524    /// Reverse-DNS-ish unique model id (e.g. `com.example.weeds`).
525    pub id: String,
526    pub kind: ModelKind,
527    pub execution: ModelExecution,
528    /// Input the model expects; the engine downscales/converts frames to match.
529    pub input_width: u32,
530    pub input_height: u32,
531    pub input_format: FrameFormat,
532    /// Class labels in output-index order (empty for non-detection kinds).
533    #[serde(default)]
534    pub output_classes: Vec<String>,
535    /// Path to the model file on the agent, for engine-run models.
536    #[serde(default)]
537    pub model_path: Option<String>,
538    /// Output-head layout for decoding (detection models). Defaults to `Yolo8`;
539    /// a model that ships the legacy YOLOv5/v7 head pins `Yolo5`. A payload that
540    /// predates this field deserializes to the `Yolo8` default.
541    #[serde(default)]
542    pub head: DetectionHead,
543}
544
545impl ModelMetadata {
546    pub fn to_msgpack(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
547        rmp_serde::to_vec_named(self)
548    }
549    pub fn from_msgpack(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
550        rmp_serde::from_slice(bytes)
551    }
552}
553
554/// A pixel-space bounding box (origin top-left), in the frame's own resolution.
555#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
556pub struct BoundingBox {
557    pub x: f32,
558    pub y: f32,
559    pub width: f32,
560    pub height: f32,
561}
562
563/// One keypoint of a detection (a pose joint or a landmark), in the frame's own
564/// pixel resolution, with its own detection confidence.
565#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
566pub struct Keypoint {
567    pub x: f32,
568    pub y: f32,
569    /// Confidence this keypoint was located correctly, `0..1`.
570    pub confidence: f32,
571}
572
573/// Confidence that this frame's detection belongs to the same object as the
574/// track it was associated with. `Locked` = the tracker is confident the
575/// identity held; `Uncertain` = the association is weak (occlusion, a nearby
576/// similar object, a low-confidence re-match) so a downstream consumer should
577/// treat the identity as provisional; `Lost` = the track could not be
578/// re-associated this frame. Carrying this on the wire makes a silent identity
579/// swap impossible to hide: the uncertainty travels with the detection instead
580/// of being collapsed away by the tracker.
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
582#[serde(rename_all = "lowercase")]
583pub enum LockState {
584    Locked,
585    Uncertain,
586    Lost,
587}
588
589/// One percept from a model: the self-describing per-frame unit a consumer
590/// reads. It began as a 2D box and grows by ADDING typed optional fields (each
591/// serde-default + skipped when absent), so a producer emits only what it
592/// measured and an older producer/reader round-trips unchanged. The 2D box is
593/// itself now optional: a box detector always sets it, while a box-less percept
594/// (a segmentation mask, a pose, a depth-only reading) may omit it and carry its
595/// geometry in the typed fields below.
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
597pub struct Detection {
598    /// The 2D bounding box in the frame's own pixel resolution. `Some` for the
599    /// common box detector (always set before box-less percepts existed, so an
600    /// older producer's payload still decodes to `Some`); `None` for a box-less
601    /// percept. Skipped on the wire when absent.
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub bbox: Option<BoundingBox>,
604    pub class_label: String,
605    pub confidence: f32,
606    /// Stable track id across frames (tracking models only).
607    #[serde(default)]
608    pub track_id: Option<u64>,
609    /// How confident the tracker is that this detection is the same object as
610    /// its `track_id` (0..1). `None` when the source does not score
611    /// association (e.g. a stateless detector). Distinct from `confidence`,
612    /// which scores the class/object detection itself.
613    #[serde(default)]
614    pub assoc_confidence: Option<f32>,
615    /// Discrete lock state of the track's identity this frame. `None` when the
616    /// source does not report a lock state.
617    #[serde(default)]
618    pub lock_state: Option<LockState>,
619    /// Open, self-describing per-detection attributes: the extension point for
620    /// richer perception a model may carry beyond the 2D box — a segmentation
621    /// mask reference, keypoints, a depth estimate, a world-frame position — as
622    /// well as any model-specific metadata, keyed by name. A concrete primitive
623    /// promotes to its own typed field once a consumer needs it as first-class;
624    /// until then it rides here so the contract extends without a schema change.
625    /// Absent (skipped on the wire) when the source reports none.
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub attributes: Option<BTreeMap<String, rmpv::Value>>,
628    /// Segmentation mask as a polygon of `(x, y)` vertices tracing the object
629    /// outline, in the frame's own pixel resolution. `None` when the source
630    /// reports no mask.
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub mask: Option<Vec<[f32; 2]>>,
633    /// Keypoints (pose joints or landmarks) for this detection, in frame pixels.
634    /// `None` when the source reports none.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub keypoints: Option<Vec<Keypoint>>,
637    /// Estimated distance from the camera to the detection, in metres. `None`
638    /// when the source reports no depth.
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub depth: Option<f32>,
641    /// The detection's position in a world frame, as a bare 3-array so the frame
642    /// convention can evolve without a schema change: local ENU metres
643    /// `[x, y, z]`, or geographic `[lat, lon, alt]` once a producer geolocates.
644    /// `None` when the source reports no world position.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub world_pos: Option<[f64; 3]>,
647}
648
649/// The current wire version of a [`DetectionBatch`] on the `vision.detection`
650/// topic. Bumped whenever the batch's on-wire shape changes; a decode of a batch
651/// stamped with any other version fails loudly rather than silently mis-parsing.
652/// Mirrors the `vision.detection` entry in the contract registry
653/// (`contracts.toml`).
654pub const VISION_DETECTION_VERSION: u16 = 3;
655
656/// The payload on `vision.detection`, labelled by source model and frame so
657/// overlays and consumers can align boxes to the frame they came from.
658#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
659pub struct DetectionBatch {
660    /// Wire version, stamped on every batch and checked on decode. A consumer
661    /// that sees a version it does not speak rejects the batch instead of
662    /// mis-parsing it. Deliberately carries no serde default: a payload missing
663    /// this field fails to decode.
664    #[serde(rename = "v")]
665    pub v: u16,
666    pub model_id: String,
667    pub camera_id: String,
668    pub frame_id: u64,
669    /// Capture time of the source frame: the frame's host CLOCK_MONOTONIC
670    /// timestamp in milliseconds (`FrameDescriptor::ts_ns / 1_000_000`).
671    pub ts_ms: i64,
672    /// The source frame's pixel size, so a consumer scales the pixel-space boxes
673    /// to the rendered frame. `0` when the source did not report it (the consumer
674    /// then falls back to its own default). Additive + serde-default, so a batch
675    /// from an older producer (no dims) still decodes (mirrors `attributes`).
676    #[serde(default)]
677    pub frame_width: u32,
678    #[serde(default)]
679    pub frame_height: u32,
680    pub detections: Vec<Detection>,
681}
682
683impl DetectionBatch {
684    pub fn to_msgpack(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
685        rmp_serde::to_vec_named(self)
686    }
687
688    /// Decode from a msgpack map, rejecting a batch whose wire version this build
689    /// does not speak. A missing `v` field fails the msgpack decode; a
690    /// present-but-unknown `v` returns [`DetectionBatchError::Version`].
691    pub fn from_msgpack(bytes: &[u8]) -> Result<Self, DetectionBatchError> {
692        let batch: DetectionBatch = rmp_serde::from_slice(bytes)?;
693        if batch.v != VISION_DETECTION_VERSION {
694            return Err(DetectionBatchError::Version {
695                got: batch.v,
696                ours: VISION_DETECTION_VERSION,
697            });
698        }
699        Ok(batch)
700    }
701}
702
703/// Errors decoding a [`DetectionBatch`] from its msgpack wire form.
704#[derive(Debug, Error)]
705pub enum DetectionBatchError {
706    #[error("msgpack decode error: {0}")]
707    Decode(#[from] rmp_serde::decode::Error),
708    #[error("unsupported vision.detection version {got} (this build speaks {ours})")]
709    Version { got: u16, ours: u16 },
710}
711
712#[cfg(test)]
713mod contract_tests {
714    use super::*;
715
716    #[test]
717    fn model_metadata_round_trips() {
718        let m = ModelMetadata {
719            id: "com.example.weeds".into(),
720            kind: ModelKind::Detection,
721            execution: ModelExecution::EngineRun,
722            input_width: 640,
723            input_height: 480,
724            input_format: FrameFormat::Rgb24,
725            output_classes: vec!["weed".into(), "crop".into()],
726            model_path: Some("/opt/ados/models/vision/weeds.onnx".into()),
727            head: DetectionHead::Yolo8,
728        };
729        let bytes = m.to_msgpack().unwrap();
730        assert_eq!(ModelMetadata::from_msgpack(&bytes).unwrap(), m);
731    }
732
733    #[test]
734    fn model_metadata_without_head_defaults_to_yolo8() {
735        // A payload serialized before the `head` field existed (no `head` key)
736        // must still decode, defaulting to the YOLOv8 head.
737        let legacy = ModelMetadataLegacy {
738            id: "com.example.old".into(),
739            kind: ModelKind::Detection,
740            execution: ModelExecution::EngineRun,
741            input_width: 640,
742            input_height: 640,
743            input_format: FrameFormat::Rgb24,
744            output_classes: vec!["uav".into()],
745            model_path: None,
746        };
747        let bytes = rmp_serde::to_vec_named(&legacy).unwrap();
748        let decoded = ModelMetadata::from_msgpack(&bytes).unwrap();
749        assert_eq!(decoded.head, DetectionHead::Yolo8);
750        assert_eq!(decoded.id, "com.example.old");
751    }
752
753    // The pre-`head` shape, to prove forward-compatibility of the new field.
754    #[derive(Serialize)]
755    struct ModelMetadataLegacy {
756        id: String,
757        kind: ModelKind,
758        execution: ModelExecution,
759        input_width: u32,
760        input_height: u32,
761        input_format: FrameFormat,
762        output_classes: Vec<String>,
763        model_path: Option<String>,
764    }
765
766    #[test]
767    fn detection_head_serializes_lowercase() {
768        assert_eq!(
769            serde_json::to_string(&DetectionHead::Yolo8).unwrap(),
770            "\"yolo8\""
771        );
772        assert_eq!(
773            serde_json::to_string(&DetectionHead::Yolo5).unwrap(),
774            "\"yolo5\""
775        );
776    }
777
778    #[test]
779    fn detection_batch_round_trips() {
780        let b = DetectionBatch {
781            v: VISION_DETECTION_VERSION,
782            model_id: "com.example.weeds".into(),
783            camera_id: "uvc-0".into(),
784            frame_id: 7,
785            ts_ms: 1_700_000_000_000,
786            frame_width: 640,
787            frame_height: 480,
788            detections: vec![Detection {
789                bbox: Some(BoundingBox {
790                    x: 12.0,
791                    y: 20.0,
792                    width: 64.0,
793                    height: 32.0,
794                }),
795                class_label: "weed".into(),
796                confidence: 0.87,
797                track_id: None,
798                assoc_confidence: None,
799                lock_state: None,
800                attributes: None,
801                mask: None,
802                keypoints: None,
803                depth: None,
804                world_pos: None,
805            }],
806        };
807        let bytes = b.to_msgpack().unwrap();
808        assert_eq!(DetectionBatch::from_msgpack(&bytes).unwrap(), b);
809    }
810
811    #[test]
812    fn detection_version_matches_registry() {
813        assert_eq!(
814            VISION_DETECTION_VERSION,
815            crate::contracts::contract_version("vision.detection").unwrap()
816        );
817    }
818
819    #[test]
820    fn detection_rejects_a_future_version() {
821        // A batch stamped with a version this build does not speak must fail the
822        // decode loudly rather than silently mis-parse.
823        let batch = DetectionBatch {
824            v: VISION_DETECTION_VERSION + 1,
825            model_id: "m".into(),
826            camera_id: "c".into(),
827            frame_id: 1,
828            ts_ms: 0,
829            frame_width: 640,
830            frame_height: 480,
831            detections: vec![],
832        };
833        let bytes = batch.to_msgpack().unwrap();
834        assert!(matches!(
835            DetectionBatch::from_msgpack(&bytes),
836            Err(DetectionBatchError::Version { .. })
837        ));
838    }
839
840    #[test]
841    fn detection_missing_version_fails_decode() {
842        // A payload that predates the version field (no `v` key) must fail to
843        // decode: the field is required, with no serde default.
844        #[derive(Serialize)]
845        struct NoVersionBatch {
846            model_id: String,
847            camera_id: String,
848            frame_id: u64,
849            ts_ms: i64,
850            detections: Vec<Detection>,
851        }
852        let old = NoVersionBatch {
853            model_id: "m".into(),
854            camera_id: "c".into(),
855            frame_id: 1,
856            ts_ms: 0,
857            detections: vec![],
858        };
859        let bytes = rmp_serde::to_vec_named(&old).unwrap();
860        assert!(matches!(
861            DetectionBatch::from_msgpack(&bytes),
862            Err(DetectionBatchError::Decode(_))
863        ));
864    }
865
866    #[test]
867    fn detection_lock_fields_round_trip_present() {
868        let d = Detection {
869            bbox: Some(BoundingBox {
870                x: 1.0,
871                y: 2.0,
872                width: 10.0,
873                height: 20.0,
874            }),
875            class_label: "target".into(),
876            confidence: 0.91,
877            track_id: Some(42),
878            assoc_confidence: Some(0.73),
879            lock_state: Some(LockState::Uncertain),
880            attributes: None,
881            mask: None,
882            keypoints: None,
883            depth: None,
884            world_pos: None,
885        };
886        let bytes = rmp_serde::to_vec_named(&d).unwrap();
887        let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
888        assert_eq!(back, d);
889    }
890
891    #[test]
892    fn detection_lock_state_serializes_lowercase() {
893        let s = rmp_serde::to_vec_named(&LockState::Locked).unwrap();
894        assert_eq!(rmp_serde::from_slice::<String>(&s).unwrap(), "locked");
895        let s = rmp_serde::to_vec_named(&LockState::Lost).unwrap();
896        assert_eq!(rmp_serde::from_slice::<String>(&s).unwrap(), "lost");
897    }
898
899    #[test]
900    fn detection_attributes_round_trip() {
901        // The open self-describing attributes map survives the msgpack wire.
902        let mut attrs = BTreeMap::new();
903        attrs.insert("depth_m".to_string(), rmpv::Value::from(4.2_f64));
904        attrs.insert("source".to_string(), rmpv::Value::from("plugin"));
905        let d = Detection {
906            bbox: Some(BoundingBox {
907                x: 1.0,
908                y: 2.0,
909                width: 3.0,
910                height: 4.0,
911            }),
912            class_label: "target".into(),
913            confidence: 0.9,
914            track_id: Some(7),
915            assoc_confidence: None,
916            lock_state: Some(LockState::Locked),
917            attributes: Some(attrs.clone()),
918            mask: None,
919            keypoints: None,
920            depth: None,
921            world_pos: None,
922        };
923        let bytes = rmp_serde::to_vec_named(&d).unwrap();
924        let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
925        assert_eq!(back, d);
926        assert_eq!(back.attributes, Some(attrs));
927    }
928
929    #[test]
930    fn detection_without_attributes_defaults_none() {
931        // A producer predating `attributes` (no such key) still decodes, with
932        // attributes defaulting to None; and a detection with None skips the
933        // key on the wire (no per-detection overhead in the common case).
934        #[derive(Serialize)]
935        struct NoAttrsDetection {
936            bbox: BoundingBox,
937            class_label: String,
938            confidence: f32,
939            track_id: Option<u64>,
940            assoc_confidence: Option<f32>,
941            lock_state: Option<LockState>,
942        }
943        let old = NoAttrsDetection {
944            bbox: BoundingBox {
945                x: 0.0,
946                y: 0.0,
947                width: 5.0,
948                height: 5.0,
949            },
950            class_label: "target".into(),
951            confidence: 0.5,
952            track_id: None,
953            assoc_confidence: None,
954            lock_state: None,
955        };
956        let bytes = rmp_serde::to_vec_named(&old).unwrap();
957        let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
958        assert_eq!(back.attributes, None);
959    }
960
961    #[test]
962    fn detection_new_fields_default_when_absent() {
963        // A msgpack named-map written by an old producer that predates the
964        // lock fields must still decode (the new fields default to None).
965        #[derive(Serialize)]
966        struct OldDetection {
967            bbox: BoundingBox,
968            class_label: String,
969            confidence: f32,
970            track_id: Option<u64>,
971        }
972        let old = OldDetection {
973            bbox: BoundingBox {
974                x: 0.0,
975                y: 0.0,
976                width: 5.0,
977                height: 5.0,
978            },
979            class_label: "weed".into(),
980            confidence: 0.5,
981            track_id: Some(7),
982        };
983        let bytes = rmp_serde::to_vec_named(&old).unwrap();
984        let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
985        assert_eq!(back.track_id, Some(7));
986        assert_eq!(back.assoc_confidence, None);
987        assert_eq!(back.lock_state, None);
988    }
989
990    #[test]
991    fn detection_new_fields_skipped_for_old_readers() {
992        // A new producer's bytes must still decode for a reader that only
993        // knows the old fields (forward compatibility).
994        #[derive(Deserialize)]
995        #[allow(dead_code)]
996        struct OldReader {
997            bbox: BoundingBox,
998            class_label: String,
999            confidence: f32,
1000            #[serde(default)]
1001            track_id: Option<u64>,
1002        }
1003        let d = Detection {
1004            bbox: Some(BoundingBox {
1005                x: 1.0,
1006                y: 1.0,
1007                width: 2.0,
1008                height: 2.0,
1009            }),
1010            class_label: "target".into(),
1011            confidence: 0.8,
1012            track_id: Some(3),
1013            assoc_confidence: Some(0.4),
1014            lock_state: Some(LockState::Lost),
1015            attributes: None,
1016            mask: None,
1017            keypoints: None,
1018            depth: None,
1019            world_pos: None,
1020        };
1021        let bytes = rmp_serde::to_vec_named(&d).unwrap();
1022        let back: OldReader = rmp_serde::from_slice(&bytes).unwrap();
1023        assert_eq!(back.track_id, Some(3));
1024        assert_eq!(back.class_label, "target");
1025    }
1026
1027    #[test]
1028    fn enums_serialize_to_expected_strings() {
1029        let k = rmp_serde::to_vec_named(&ModelKind::Tracking).unwrap();
1030        assert_eq!(rmp_serde::from_slice::<String>(&k).unwrap(), "tracking");
1031        let e = rmp_serde::to_vec_named(&ModelExecution::PluginSide).unwrap();
1032        assert_eq!(rmp_serde::from_slice::<String>(&e).unwrap(), "plugin_side");
1033    }
1034
1035    #[test]
1036    fn model_kind_new_variants_serialize_lowercase() {
1037        for (kind, want) in [
1038            (ModelKind::Pose, "pose"),
1039            (ModelKind::Depth, "depth"),
1040            (ModelKind::Reid, "reid"),
1041        ] {
1042            let bytes = rmp_serde::to_vec_named(&kind).unwrap();
1043            assert_eq!(rmp_serde::from_slice::<String>(&bytes).unwrap(), want);
1044        }
1045    }
1046
1047    #[test]
1048    fn percept_typed_fields_round_trip() {
1049        // A rich percept carrying a mask, keypoints, a depth, and a world
1050        // position survives the msgpack wire byte-for-byte.
1051        let d = Detection {
1052            bbox: Some(BoundingBox {
1053                x: 10.0,
1054                y: 12.0,
1055                width: 30.0,
1056                height: 40.0,
1057            }),
1058            class_label: "person".into(),
1059            confidence: 0.88,
1060            track_id: Some(9),
1061            assoc_confidence: Some(0.7),
1062            lock_state: Some(LockState::Locked),
1063            attributes: None,
1064            mask: Some(vec![[10.0, 12.0], [40.0, 12.0], [40.0, 52.0], [10.0, 52.0]]),
1065            keypoints: Some(vec![
1066                Keypoint {
1067                    x: 25.0,
1068                    y: 18.0,
1069                    confidence: 0.9,
1070                },
1071                Keypoint {
1072                    x: 25.0,
1073                    y: 40.0,
1074                    confidence: 0.6,
1075                },
1076            ]),
1077            depth: Some(4.5),
1078            world_pos: Some([12.5, -3.0, 1.2]),
1079        };
1080        let bytes = rmp_serde::to_vec_named(&d).unwrap();
1081        let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
1082        assert_eq!(back, d);
1083        assert_eq!(back.mask.as_ref().unwrap().len(), 4);
1084        assert_eq!(back.keypoints.as_ref().unwrap().len(), 2);
1085        assert_eq!(back.depth, Some(4.5));
1086        assert_eq!(back.world_pos, Some([12.5, -3.0, 1.2]));
1087    }
1088
1089    #[test]
1090    fn boxless_percept_round_trips() {
1091        // A percept with no 2D box (a mask/pose/depth-only reading) still
1092        // decodes: bbox is absent on the wire and reads back as None.
1093        let d = Detection {
1094            bbox: None,
1095            class_label: "region".into(),
1096            confidence: 0.5,
1097            track_id: None,
1098            assoc_confidence: None,
1099            lock_state: None,
1100            attributes: None,
1101            mask: Some(vec![[0.0, 0.0], [100.0, 0.0], [50.0, 100.0]]),
1102            keypoints: None,
1103            depth: Some(2.0),
1104            world_pos: None,
1105        };
1106        let bytes = rmp_serde::to_vec_named(&d).unwrap();
1107        let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
1108        assert_eq!(back, d);
1109        assert_eq!(back.bbox, None);
1110    }
1111
1112    #[test]
1113    fn detection_predating_percept_fields_defaults_none() {
1114        // A producer that predates the mask/keypoints/depth/world_pos fields
1115        // (no such keys, and a required box) still decodes; the new typed
1116        // fields default to None.
1117        #[derive(Serialize)]
1118        struct PrePerceptDetection {
1119            bbox: BoundingBox,
1120            class_label: String,
1121            confidence: f32,
1122            track_id: Option<u64>,
1123            assoc_confidence: Option<f32>,
1124            lock_state: Option<LockState>,
1125        }
1126        let old = PrePerceptDetection {
1127            bbox: BoundingBox {
1128                x: 0.0,
1129                y: 0.0,
1130                width: 5.0,
1131                height: 5.0,
1132            },
1133            class_label: "target".into(),
1134            confidence: 0.6,
1135            track_id: Some(1),
1136            assoc_confidence: None,
1137            lock_state: Some(LockState::Locked),
1138        };
1139        let bytes = rmp_serde::to_vec_named(&old).unwrap();
1140        let back: Detection = rmp_serde::from_slice(&bytes).unwrap();
1141        assert_eq!(
1142            back.bbox,
1143            Some(BoundingBox {
1144                x: 0.0,
1145                y: 0.0,
1146                width: 5.0,
1147                height: 5.0,
1148            })
1149        );
1150        assert_eq!(back.mask, None);
1151        assert_eq!(back.keypoints, None);
1152        assert_eq!(back.depth, None);
1153        assert_eq!(back.world_pos, None);
1154    }
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160
1161    #[test]
1162    fn mono_ns_is_monotonic() {
1163        let a = mono_ns();
1164        let b = mono_ns();
1165        assert!(a > 0 && b >= a);
1166    }
1167
1168    #[test]
1169    fn frame_bytes_matches_format() {
1170        assert_eq!(FrameFormat::Rgb24.frame_bytes(640, 480), 640 * 480 * 3);
1171        assert_eq!(FrameFormat::Nv12.frame_bytes(640, 480), 640 * 480 * 3 / 2);
1172        assert_eq!(
1173            FrameFormat::Yuv420p.frame_bytes(1280, 720),
1174            1280 * 720 * 3 / 2
1175        );
1176        assert_eq!(FrameFormat::Gray8.frame_bytes(640, 480), 640 * 480);
1177        assert_eq!(FrameFormat::Gray16.frame_bytes(640, 480), 640 * 480 * 2);
1178        assert_eq!(FrameFormat::BayerRggb8.frame_bytes(641, 481), 641 * 481);
1179        assert_eq!(
1180            FrameFormat::BayerRggb16.frame_bytes(640, 480),
1181            640 * 480 * 2
1182        );
1183    }
1184
1185    #[test]
1186    fn descriptor_round_trips_through_msgpack() {
1187        let base = FrameDescriptor {
1188            v: FRAMEBUS_DESCRIPTOR_VERSION,
1189            camera_id: "uvc-0".into(),
1190            frame_id: 42,
1191            ts_ns: 1_700_000_000_000_000_000,
1192            ts_sensor_ns: None,
1193            exposure_ns: None,
1194            trigger_seq: None,
1195            timebase: TIMEBASE_HOST,
1196            width: 640,
1197            height: 480,
1198            format: FrameFormat::Rgb24,
1199            shm_name: "ados-vision-uvc-0".into(),
1200            slot: 3,
1201            seq: 1234,
1202            byte_len: (640 * 480 * 3) as u32,
1203        };
1204        let bytes = base.to_msgpack().unwrap();
1205        assert_eq!(FrameDescriptor::from_msgpack(&bytes).unwrap(), base);
1206        for format in [
1207            FrameFormat::Gray8,
1208            FrameFormat::Gray16,
1209            FrameFormat::BayerRggb8,
1210            FrameFormat::BayerRggb16,
1211        ] {
1212            let d = FrameDescriptor {
1213                ts_sensor_ns: Some(123_456_789),
1214                exposure_ns: Some(4_000_000),
1215                trigger_seq: Some(77),
1216                timebase: TIMEBASE_SENSOR,
1217                format,
1218                byte_len: format.frame_bytes(640, 480) as u32,
1219                ..base.clone()
1220            };
1221            let bytes = d.to_msgpack().unwrap();
1222            assert_eq!(FrameDescriptor::from_msgpack(&bytes).unwrap(), d);
1223        }
1224    }
1225
1226    #[test]
1227    fn descriptor_version_matches_registry() {
1228        assert_eq!(
1229            FRAMEBUS_DESCRIPTOR_VERSION,
1230            crate::contracts::contract_version("framebus.descriptor").unwrap()
1231        );
1232    }
1233
1234    #[test]
1235    fn ring_version_matches_registry() {
1236        assert_eq!(
1237            RingLayout::VERSION,
1238            crate::contracts::contract_version("framebus.ring").unwrap()
1239        );
1240    }
1241
1242    #[test]
1243    fn descriptor_rejects_a_future_version() {
1244        // A descriptor stamped with a version this build does not speak must
1245        // fail the decode loudly rather than silently mis-parse.
1246        let d = FrameDescriptor {
1247            v: FRAMEBUS_DESCRIPTOR_VERSION + 1,
1248            camera_id: "uvc-0".into(),
1249            frame_id: 1,
1250            ts_ns: 0,
1251            ts_sensor_ns: None,
1252            exposure_ns: None,
1253            trigger_seq: None,
1254            timebase: TIMEBASE_HOST,
1255            width: 8,
1256            height: 8,
1257            format: FrameFormat::Rgb24,
1258            shm_name: "ados-vision-uvc-0".into(),
1259            slot: 0,
1260            seq: 1,
1261            byte_len: 192,
1262        };
1263        let bytes = d.to_msgpack().unwrap();
1264        assert!(matches!(
1265            FrameDescriptor::from_msgpack(&bytes),
1266            Err(DescriptorError::Version { .. })
1267        ));
1268    }
1269
1270    #[test]
1271    fn descriptor_v1_with_ts_ms_is_rejected() {
1272        // A v1 descriptor (millisecond `ts_ms`, no `ts_ns`) must not decode.
1273        #[derive(Serialize)]
1274        struct V1Descriptor {
1275            v: u16,
1276            camera_id: String,
1277            frame_id: u64,
1278            ts_ms: i64,
1279            width: u32,
1280            height: u32,
1281            format: FrameFormat,
1282            shm_name: String,
1283            slot: u32,
1284            seq: u64,
1285            byte_len: u32,
1286        }
1287        let old = V1Descriptor {
1288            v: 1,
1289            camera_id: "uvc-0".into(),
1290            frame_id: 1,
1291            ts_ms: 0,
1292            width: 8,
1293            height: 8,
1294            format: FrameFormat::Rgb24,
1295            shm_name: "ados-vision-uvc-0".into(),
1296            slot: 0,
1297            seq: 1,
1298            byte_len: 192,
1299        };
1300        let bytes = rmp_serde::to_vec_named(&old).unwrap();
1301        assert!(matches!(
1302            FrameDescriptor::from_msgpack(&bytes),
1303            Err(DescriptorError::Decode(_) | DescriptorError::Version { .. })
1304        ));
1305    }
1306
1307    #[test]
1308    fn format_serializes_lowercase() {
1309        // The Python reader matches on the lowercase string.
1310        let bytes = rmp_serde::to_vec_named(&FrameFormat::Yuv420p).unwrap();
1311        let back: String = rmp_serde::from_slice(&bytes).unwrap();
1312        assert_eq!(back, "yuv420p");
1313        for (f, name) in [
1314            (FrameFormat::Gray8, "gray8"),
1315            (FrameFormat::Gray16, "gray16"),
1316            (FrameFormat::BayerRggb8, "bayerrggb8"),
1317            (FrameFormat::BayerRggb16, "bayerrggb16"),
1318        ] {
1319            let bytes = rmp_serde::to_vec_named(&f).unwrap();
1320            let back: String = rmp_serde::from_slice(&bytes).unwrap();
1321            assert_eq!(back, name);
1322        }
1323    }
1324
1325    #[test]
1326    fn header_round_trips() {
1327        let layout = RingLayout::for_frame(4, 64, 48, FrameFormat::Rgb24);
1328        let mut region = vec![0u8; layout.total_len()];
1329        layout.write_header(&mut region).unwrap();
1330        let read = RingLayout::read_header(&region).unwrap();
1331        assert_eq!(read, layout);
1332    }
1333
1334    #[test]
1335    fn write_then_read_returns_the_frame() {
1336        let layout = RingLayout::for_frame(4, 8, 8, FrameFormat::Rgb24); // 192-byte slots
1337        let mut region = vec![0u8; layout.total_len()];
1338        layout.write_header(&mut region).unwrap();
1339
1340        let frame: Vec<u8> = (0..layout.slot_bytes as u8).collect();
1341        let seq = 9u64;
1342        let slot = (seq % layout.slot_count as u64) as u32;
1343        write_slot(&mut region, &layout, slot, seq, &frame).unwrap();
1344
1345        let mut out = Vec::new();
1346        let got = read_slot_into(&region, &layout, slot, seq, &mut out).unwrap();
1347        assert_eq!(got, Some(frame.len()));
1348        assert_eq!(out, frame);
1349    }
1350
1351    #[test]
1352    fn read_slot_into_reuses_capacity() {
1353        let layout = RingLayout::for_frame(2, 8, 8, FrameFormat::Rgb24);
1354        let mut region = vec![0u8; layout.total_len()];
1355        layout.write_header(&mut region).unwrap();
1356        let mut out = Vec::with_capacity(layout.slot_bytes as usize);
1357        out.extend_from_slice(&[0xAA; 5]);
1358        let (ptr, cap) = (out.as_ptr(), out.capacity());
1359        for seq in 1..=4u64 {
1360            let frame = vec![seq as u8; layout.slot_bytes as usize];
1361            let slot = (seq % 2) as u32;
1362            write_slot(&mut region, &layout, slot, seq, &frame).unwrap();
1363            let n = read_slot_into(&region, &layout, slot, seq, &mut out).unwrap();
1364            assert_eq!(n, Some(frame.len()));
1365            assert_eq!(out, frame);
1366            assert_eq!(out.as_ptr(), ptr);
1367            assert_eq!(out.capacity(), cap);
1368        }
1369    }
1370
1371    #[test]
1372    fn stale_seq_reads_none() {
1373        let layout = RingLayout::for_frame(2, 4, 4, FrameFormat::Rgb24);
1374        let mut region = vec![0u8; layout.total_len()];
1375        layout.write_header(&mut region).unwrap();
1376        write_slot(&mut region, &layout, 0, 10, &[1, 2, 3]).unwrap();
1377        // The consumer asks for a sequence the slot never held.
1378        let mut out = Vec::new();
1379        assert_eq!(
1380            read_slot_into(&region, &layout, 0, 11, &mut out).unwrap(),
1381            None
1382        );
1383    }
1384
1385    #[test]
1386    fn recycled_slot_is_detected_as_torn() {
1387        let layout = RingLayout::for_frame(1, 4, 4, FrameFormat::Rgb24);
1388        let mut region = vec![0u8; layout.total_len()];
1389        layout.write_header(&mut region).unwrap();
1390        write_slot(&mut region, &layout, 0, 100, &[7, 7, 7, 7]).unwrap();
1391        // Writer recycles the single slot for the next frame.
1392        write_slot(&mut region, &layout, 0, 101, &[8, 8, 8, 8]).unwrap();
1393        // A consumer still holding the old descriptor sees the new seq and drops.
1394        let mut out = Vec::new();
1395        assert_eq!(
1396            read_slot_into(&region, &layout, 0, 100, &mut out).unwrap(),
1397            None
1398        );
1399        assert_eq!(
1400            read_slot_into(&region, &layout, 0, 101, &mut out).unwrap(),
1401            Some(4)
1402        );
1403        assert_eq!(out, vec![8; 4]);
1404    }
1405
1406    #[test]
1407    fn oversized_payload_rejected() {
1408        let layout = RingLayout::for_frame(2, 2, 2, FrameFormat::Rgb24); // 12-byte slots
1409        let mut region = vec![0u8; layout.total_len()];
1410        layout.write_header(&mut region).unwrap();
1411        let too_big = vec![0u8; layout.slot_bytes as usize + 1];
1412        assert!(matches!(
1413            write_slot(&mut region, &layout, 0, 1, &too_big),
1414            Err(RingError::PayloadTooLarge { .. })
1415        ));
1416    }
1417
1418    #[test]
1419    fn slot_out_of_range_rejected() {
1420        let layout = RingLayout::for_frame(2, 2, 2, FrameFormat::Rgb24);
1421        let mut region = vec![0u8; layout.total_len()];
1422        layout.write_header(&mut region).unwrap();
1423        assert!(matches!(
1424            write_slot(&mut region, &layout, 2, 1, &[0]),
1425            Err(RingError::SlotOutOfRange { .. })
1426        ));
1427    }
1428
1429    #[test]
1430    fn small_region_rejected() {
1431        let layout = RingLayout::for_frame(4, 64, 64, FrameFormat::Rgb24);
1432        let mut tiny = vec![0u8; 8];
1433        assert!(matches!(
1434            layout.write_header(&mut tiny),
1435            Err(RingError::RegionTooSmall { .. })
1436        ));
1437    }
1438
1439    #[test]
1440    fn slot_count_at_the_header_maximum_round_trips() {
1441        // u16::MAX is the largest count the two-byte header field carries; it
1442        // must validate and round-trip exactly (no truncation).
1443        let layout = RingLayout {
1444            slot_count: MAX_SLOT_COUNT,
1445            slot_bytes: 4,
1446        };
1447        assert!(layout.validate().is_ok());
1448        // The header is only HEADER_LEN bytes; write it into a region sized to
1449        // just the header so the round-trip exercises the field, not the slots.
1450        let mut header_region = vec![0u8; layout.total_len()];
1451        layout.write_header(&mut header_region).unwrap();
1452        let read = RingLayout::read_header(&header_region).unwrap();
1453        assert_eq!(read.slot_count, MAX_SLOT_COUNT);
1454    }
1455
1456    #[test]
1457    fn slot_count_above_the_header_maximum_is_rejected() {
1458        // A slot_count that the u16 header field would truncate is refused at
1459        // header-write time rather than silently wrapping to a smaller ring.
1460        let layout = RingLayout {
1461            slot_count: MAX_SLOT_COUNT + 1,
1462            slot_bytes: 4,
1463        };
1464        assert!(matches!(
1465            layout.validate(),
1466            Err(RingError::SlotCountTooLarge { .. })
1467        ));
1468        let mut region = vec![0u8; 64];
1469        assert!(matches!(
1470            layout.write_header(&mut region),
1471            Err(RingError::SlotCountTooLarge { .. })
1472        ));
1473    }
1474
1475    #[test]
1476    fn concurrent_writer_and_reader_never_yield_a_torn_frame() {
1477        // A genuine cross-thread seqlock stress: one writer recycles a small
1478        // ring as fast as it can while a reader copies the slot the latest
1479        // descriptor named. Every successful read must be an internally
1480        // consistent frame (all bytes equal the frame's marker), never a torn
1481        // mix of two frames. The volatile guards + Acquire/Release fences are
1482        // what make this hold on a weakly-ordered CPU.
1483        use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as O};
1484        use std::sync::Arc;
1485
1486        const SLOTS: u32 = 3;
1487        const FRAME: usize = 256;
1488        let layout = RingLayout {
1489            slot_count: SLOTS,
1490            slot_bytes: FRAME as u32,
1491        };
1492        let total = layout.total_len();
1493
1494        // The shared region behind an UnsafeCell-equivalent: a raw buffer the
1495        // writer mutates and the reader reads, exactly the /dev/shm aliasing the
1496        // seqlock is designed for. A Mutex would serialize the two and defeat
1497        // the test, so a raw pointer wrapper carries the aliasing explicitly.
1498        struct Shared(*mut u8, usize);
1499        unsafe impl Send for Shared {}
1500        unsafe impl Sync for Shared {}
1501        let mut backing = vec![0u8; total];
1502        layout.write_header(&mut backing).unwrap();
1503        let ptr = backing.as_mut_ptr();
1504        let shared = Arc::new(Shared(ptr, total));
1505
1506        // The latest committed seq the reader chases.
1507        let latest = Arc::new(AtomicU64::new(0));
1508        let stop = Arc::new(AtomicBool::new(false));
1509
1510        let w_shared = shared.clone();
1511        let w_latest = latest.clone();
1512        let w_stop = stop.clone();
1513        let writer = std::thread::spawn(move || {
1514            // SAFETY: the writer is the single mutator of the region for the
1515            // test's lifetime; the reader only reads. This mirrors the
1516            // single-writer / many-reader ring contract.
1517            let region = unsafe { std::slice::from_raw_parts_mut(w_shared.0, w_shared.1) };
1518            let mut seq = 1u64;
1519            while !w_stop.load(O::Relaxed) {
1520                let marker = (seq & 0xFF) as u8;
1521                let frame = vec![marker; FRAME];
1522                let slot = (seq % SLOTS as u64) as u32;
1523                write_slot(region, &layout, slot, seq, &frame).unwrap();
1524                w_latest.store(seq, O::Release);
1525                seq += 1;
1526            }
1527        });
1528
1529        let r_shared = shared.clone();
1530        let r_latest = latest.clone();
1531        let mut reads = 0u64;
1532        // Read until a healthy number of committed frames have been observed,
1533        // bounded so a wedged writer cannot spin forever. A fixed iteration
1534        // budget is flaky under loaded CI scheduling: a fast reader can drain
1535        // its whole budget before a starved writer commits its first frame,
1536        // a test-timing artifact rather than a seqlock failure.
1537        let mut iters = 0u64;
1538        let mut data = Vec::new();
1539        while reads < 10_000 && iters < 50_000_000 {
1540            iters += 1;
1541            let seq = r_latest.load(O::Acquire);
1542            if seq == 0 {
1543                std::hint::spin_loop();
1544                continue;
1545            }
1546            let slot = (seq % SLOTS as u64) as u32;
1547            // SAFETY: read-only view of the same region the writer mutates; the
1548            // seqlock discards any read torn by a concurrent recycle.
1549            let region = unsafe { std::slice::from_raw_parts(r_shared.0, r_shared.1) };
1550            if read_slot_into(region, &layout, slot, seq, &mut data)
1551                .unwrap()
1552                .is_some()
1553            {
1554                reads += 1;
1555                let marker = (seq & 0xFF) as u8;
1556                assert!(
1557                    data.iter().all(|&b| b == marker),
1558                    "torn read at seq {seq}: expected all {marker:#x}"
1559                );
1560            }
1561        }
1562        stop.store(true, O::Relaxed);
1563        writer.join().unwrap();
1564        // The reader saw at least some committed frames (not a vacuous pass).
1565        assert!(reads > 0, "reader observed no committed frames");
1566    }
1567}