Skip to main content

ados_protocol/
aux_mux.rs

1//! Auxiliary-lane multiplex framing.
2//!
3//! The WFB radio carries three planes: video on radio id 0, tiny control frames
4//! on radio id 1, and a general-purpose application lane on radio id 2. The
5//! application lane is a single UDP-ish datagram pipe, so several logical
6//! streams sharing it need a way to say which stream a datagram belongs to.
7//! This module is that framing, and it is the ONLY place either rig decides
8//! what an aux datagram means.
9//!
10//! ## Why not reuse an existing envelope
11//!
12//! The `ados-mavlink-tunnel` crate chunks a body across 128-byte MAVLink TUNNEL frames
13//! and is sized for that budget. The aux lane is not so constrained: a decoded
14//! aux datagram carries a normal payload, so it needs a channel tag and a
15//! length, not a chunking protocol. The plugin msgpack envelope is far heavier
16//! than a per-frame header should be on a lane that also carries MAVLink at
17//! telemetry rates.
18//!
19//! ## Wire layout
20//!
21//! ```text
22//!   byte 0..2   magic   = [0xAD, 0x02]   (0xAD marks an ADOS frame, 0x02 the
23//!                                         aux lane; distinguishes our frames
24//!                                         from anything else sharing radio 2)
25//!   byte 2      version = 0x01
26//!   byte 3      channel                  (see [`AuxChannel`])
27//!   byte 4..6   len     (u16, big-endian) — payload byte count
28//!   byte 6..    payload
29//! ```
30//!
31//! Six bytes of overhead per datagram. Big-endian length matches the rest of
32//! the agent's framed IPC (the 4-byte-BE MAVLink socket framing), so a reader
33//! written against one does not silently misparse the other.
34//!
35//! A frame whose magic or version does not match is DROPPED, never guessed at.
36//! The lane is shared and an unrecognised frame is far more likely to be
37//! another application's traffic than a frame of ours worth salvaging.
38
39/// Fixed header size in bytes.
40pub const AUX_HEADER_LEN: usize = 6;
41
42/// Frame magic: an ADOS frame (0xAD) on the aux lane (0x02).
43pub const AUX_MAGIC: [u8; 2] = [0xAD, 0x02];
44
45/// The framing version this build speaks.
46pub const AUX_VERSION: u8 = 0x01;
47
48/// Largest payload one aux frame may carry.
49///
50/// The radio hands the aux receiver whole decoded datagrams. Keeping a frame
51/// under a typical MTU avoids relying on IP fragmentation over the loopback
52/// hand-off, and bounds the reassembly buffer a reader must hold.
53pub const AUX_MAX_PAYLOAD: usize = 1200;
54
55/// Which logical stream an aux frame belongs to.
56///
57/// Values are explicit and MUST NOT be renumbered: both rigs may run different
58/// agent builds across an upgrade, and a renumber would silently reroute one
59/// stream's frames into another stream's parser.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[repr(u8)]
62pub enum AuxChannel {
63    /// MAVLink frames from the drone's flight controller, so a ground station
64    /// can republish them and drive the vehicle exactly as a direct link would.
65    Mavlink = 1,
66    /// Periodic compact node-status snapshots (services, capabilities, health).
67    Status = 2,
68    /// Node identity and capability advertisement.
69    Identity = 3,
70    /// Relay-proxy HTTP request, ground → drone over the uplink (radio_id 3).
71    /// A ground station paired to a drone only over WFB has no IP reach to the
72    /// linked drone, so a proxy request rides the aux uplink instead. The
73    /// drone's `aux_uplink_consumer` decodes and dispatches against its own
74    /// HTTP API, then radiates a [`AuxChannel::Response`] back.
75    Request = 4,
76    /// Relay-proxy HTTP response, drone → ground over the downlink (radio_id
77    /// 2). Carries the same request id the matching `Request` was sent with.
78    Response = 5,
79    /// Ground-measured video-link quality, ground → drone over the uplink
80    /// (radio_id 3). A transmitting drone cannot measure its own downlink, so
81    /// the receiver reports what it actually decoded and the drone's adaptive
82    /// bitrate ladder steps on that instead of a permanent sentinel. Payload is
83    /// [`crate::link_feedback::LinkFeedback`].
84    LinkFeedback = 6,
85    /// Config-over-radio TUNNEL frames, both directions: a request rides the
86    /// uplink (radio_id 3) ground → drone and its response rides the downlink
87    /// (radio_id 2) drone → ground, so one channel carries the whole exchange
88    /// (the L3 chunk header's own `is_response` flag says which way a frame is
89    /// going). Payload is one complete MAVLink TUNNEL frame — at most ~145
90    /// bytes against [`AUX_MAX_PAYLOAD`], so a chunk is always exactly one aux
91    /// frame and the bridge never fragments.
92    ///
93    /// Each rig's plane consumer hands a frame on this channel to the local
94    /// `ados-tunnel-config` service's loopback ingress
95    /// ([`crate::config_tunnel_ingest`]); nothing else interprets it, and the
96    /// service is inert unless an operator opted the channel in.
97    ConfigTunnel = 7,
98    /// Application datagram stream, drone → ground over the downlink (radio_id
99    /// 2). A neutrally-named lane an on-board application (e.g. a vision or
100    /// analytics plugin) uses to radiate its own datagrams to the paired ground
101    /// node without borrowing the flight-control TUNNEL budget. Opaque to the
102    /// router: the payload is whatever the application framed, delivered to the
103    /// ground node's aux application sink. Reserved and MUST NOT be renumbered.
104    AppStream = 8,
105    /// Application command datagram, ground → drone over the uplink (radio_id
106    /// 3). The uplink mirror of [`AuxChannel::AppStream`]: a ground-side
107    /// application sends a datagram its on-board counterpart consumes. Opaque to
108    /// the router; carries an explicit application-level ack in its own payload.
109    /// Reserved and MUST NOT be renumbered.
110    AppCommand = 9,
111}
112
113impl AuxChannel {
114    /// Parse a channel byte. Unknown channels return `None` so a reader drops
115    /// the frame instead of feeding it to the wrong parser.
116    pub fn from_u8(v: u8) -> Option<Self> {
117        match v {
118            1 => Some(Self::Mavlink),
119            2 => Some(Self::Status),
120            3 => Some(Self::Identity),
121            4 => Some(Self::Request),
122            5 => Some(Self::Response),
123            6 => Some(Self::LinkFeedback),
124            7 => Some(Self::ConfigTunnel),
125            8 => Some(Self::AppStream),
126            9 => Some(Self::AppCommand),
127            _ => None,
128        }
129    }
130}
131
132/// Why a frame could not be decoded. Distinct variants so a caller can count
133/// them separately: foreign traffic on a shared lane is normal, whereas a
134/// truncated frame of ours points at a real transport fault.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum AuxDecodeError {
137    /// Shorter than the fixed header.
138    TooShort,
139    /// Magic did not match: almost certainly not our frame.
140    BadMagic,
141    /// A version this build does not speak.
142    UnsupportedVersion(u8),
143    /// A channel this build does not know.
144    UnknownChannel(u8),
145    /// The declared length does not match the bytes actually present.
146    LengthMismatch { declared: usize, actual: usize },
147    /// The declared length exceeds [`AUX_MAX_PAYLOAD`].
148    TooLong(usize),
149}
150
151/// Encode one payload as an aux frame.
152///
153/// Returns `None` when the payload exceeds [`AUX_MAX_PAYLOAD`]; callers split
154/// or drop rather than silently truncating, because a truncated MAVLink frame
155/// would fail CRC on the far side and look like radio corruption.
156pub fn encode(channel: AuxChannel, payload: &[u8]) -> Option<Vec<u8>> {
157    if payload.len() > AUX_MAX_PAYLOAD {
158        return None;
159    }
160    let mut out = Vec::with_capacity(AUX_HEADER_LEN + payload.len());
161    out.extend_from_slice(&AUX_MAGIC);
162    out.push(AUX_VERSION);
163    out.push(channel as u8);
164    out.extend_from_slice(&(payload.len() as u16).to_be_bytes());
165    out.extend_from_slice(payload);
166    Some(out)
167}
168
169/// Decode one aux frame into its channel and payload slice.
170pub fn decode(frame: &[u8]) -> Result<(AuxChannel, &[u8]), AuxDecodeError> {
171    if frame.len() < AUX_HEADER_LEN {
172        return Err(AuxDecodeError::TooShort);
173    }
174    if frame[0..2] != AUX_MAGIC {
175        return Err(AuxDecodeError::BadMagic);
176    }
177    if frame[2] != AUX_VERSION {
178        return Err(AuxDecodeError::UnsupportedVersion(frame[2]));
179    }
180    let channel = AuxChannel::from_u8(frame[3]).ok_or(AuxDecodeError::UnknownChannel(frame[3]))?;
181    let declared = u16::from_be_bytes([frame[4], frame[5]]) as usize;
182    if declared > AUX_MAX_PAYLOAD {
183        return Err(AuxDecodeError::TooLong(declared));
184    }
185    let actual = frame.len() - AUX_HEADER_LEN;
186    if declared != actual {
187        return Err(AuxDecodeError::LengthMismatch { declared, actual });
188    }
189    Ok((channel, &frame[AUX_HEADER_LEN..]))
190}
191
192// MAVLink frame boundaries live here rather than in the dialect-backed
193// `mavlink` module because splitting a batch needs only the header, and the
194// ground station must not pull an entire dialect crate in to receive frames
195// it merely forwards.
196
197/// Total on-wire byte length of the MAVLink frame starting at `bytes[0]`, or
198/// `None` when the slice does not yet hold a whole frame.
199///
200/// Reads only the header, never the dialect, so a message id this build does
201/// not know still yields a correct boundary. That matters wherever frames are
202/// concatenated: a dialect-dependent split would desynchronise the whole batch
203/// on the first unknown message.
204///
205/// v2: 10 header bytes + payload + 2 checksum, plus a 13-byte signature when
206/// the incompat flag bit 0 is set. v1: 6 header bytes + payload + 2 checksum.
207pub fn frame_len(bytes: &[u8]) -> Option<usize> {
208    match bytes.first()? {
209        0xFD => {
210            let payload = *bytes.get(1)? as usize;
211            let signed = (*bytes.get(2)? & 0x01) != 0;
212            Some(12 + payload + if signed { 13 } else { 0 })
213        }
214        0xFE => Some(8 + *bytes.get(1)? as usize),
215        _ => None,
216    }
217}
218
219/// Split a buffer of back-to-back MAVLink frames into whole frames.
220///
221/// Several frames are batched into one radio datagram, because the auxiliary
222/// lane's loss is driven by packets per second rather than bytes per second:
223/// measured on a live link, the same byte rate lost 15% as many small datagrams
224/// and 0% as a few large ones. Batching preserves the frames that a
225/// packet-rate cap would otherwise shed.
226///
227/// Stops at the first byte that does not begin a complete, recognisable frame
228/// and returns what was whole, so a truncated tail is dropped rather than
229/// mis-split into garbage.
230pub fn split_frames(buf: &[u8]) -> Vec<&[u8]> {
231    let mut out = Vec::new();
232    let mut at = 0usize;
233    while at < buf.len() {
234        let Some(len) = frame_len(&buf[at..]) else {
235            break;
236        };
237        if len == 0 || at + len > buf.len() {
238            break;
239        }
240        out.push(&buf[at..at + len]);
241        at += len;
242    }
243    out
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn round_trips_every_channel() {
252        for ch in [
253            AuxChannel::Mavlink,
254            AuxChannel::Status,
255            AuxChannel::Identity,
256            AuxChannel::Request,
257            AuxChannel::Response,
258        ] {
259            let body = b"payload bytes";
260            let framed = encode(ch, body).expect("within budget");
261            assert_eq!(framed.len(), AUX_HEADER_LEN + body.len());
262            let (got_ch, got) = decode(&framed).expect("decodes");
263            assert_eq!(got_ch, ch);
264            assert_eq!(got, body);
265        }
266    }
267
268    #[test]
269    fn round_trips_an_empty_payload() {
270        let framed = encode(AuxChannel::Status, &[]).unwrap();
271        let (ch, body) = decode(&framed).unwrap();
272        assert_eq!(ch, AuxChannel::Status);
273        assert!(body.is_empty());
274    }
275
276    #[test]
277    fn refuses_to_truncate_an_oversized_payload() {
278        // Silently truncating would produce a MAVLink frame that fails CRC on
279        // the far side and reads as radio corruption rather than our bug.
280        let too_big = vec![0u8; AUX_MAX_PAYLOAD + 1];
281        assert!(encode(AuxChannel::Mavlink, &too_big).is_none());
282        assert!(encode(AuxChannel::Mavlink, &vec![0u8; AUX_MAX_PAYLOAD]).is_some());
283    }
284
285    #[test]
286    fn drops_foreign_traffic_rather_than_guessing() {
287        assert_eq!(decode(b"hello!").unwrap_err(), AuxDecodeError::BadMagic);
288        assert_eq!(decode(b"\xAD").unwrap_err(), AuxDecodeError::TooShort);
289    }
290
291    #[test]
292    fn rejects_an_unsupported_version_and_unknown_channel() {
293        let mut f = encode(AuxChannel::Mavlink, b"x").unwrap();
294        f[2] = 0x99;
295        assert_eq!(
296            decode(&f).unwrap_err(),
297            AuxDecodeError::UnsupportedVersion(0x99)
298        );
299
300        let mut f = encode(AuxChannel::Mavlink, b"x").unwrap();
301        f[3] = 0x7F;
302        assert_eq!(
303            decode(&f).unwrap_err(),
304            AuxDecodeError::UnknownChannel(0x7F)
305        );
306    }
307
308    #[test]
309    fn catches_a_truncated_frame_of_ours() {
310        // Distinct from BadMagic: this is our frame arriving damaged, which
311        // points at a transport fault rather than another application's traffic.
312        let mut f = encode(AuxChannel::Mavlink, b"twelve bytes").unwrap();
313        f.truncate(f.len() - 3);
314        assert_eq!(
315            decode(&f).unwrap_err(),
316            AuxDecodeError::LengthMismatch {
317                declared: 12,
318                actual: 9
319            }
320        );
321    }
322
323    /// Build a minimal well-formed MAVLink v2 frame with `payload` bytes.
324    fn v2(payload_len: u8, signed: bool) -> Vec<u8> {
325        let mut f = vec![
326            0xFD,
327            payload_len,
328            if signed { 0x01 } else { 0x00 },
329            0,
330            0,
331            1,
332            1,
333            0,
334            0,
335            0,
336        ];
337        f.extend(std::iter::repeat_n(0xAB, payload_len as usize));
338        f.extend_from_slice(&[0, 0]); // checksum
339        if signed {
340            f.extend(std::iter::repeat_n(0u8, 13));
341        }
342        f
343    }
344
345    /// Build a minimal well-formed MAVLink v1 frame.
346    fn v1(payload_len: u8) -> Vec<u8> {
347        let mut f = vec![0xFE, payload_len, 0, 1, 1, 0];
348        f.extend(std::iter::repeat_n(0xCD, payload_len as usize));
349        f.extend_from_slice(&[0, 0]);
350        f
351    }
352
353    #[test]
354    fn frame_len_reads_v1_v2_and_the_signature_block() {
355        assert_eq!(frame_len(&v2(10, false)), Some(22));
356        assert_eq!(frame_len(&v2(10, true)), Some(35));
357        assert_eq!(frame_len(&v1(10)), Some(18));
358        assert_eq!(frame_len(b"\x00junk"), None);
359        assert_eq!(frame_len(&[]), None);
360    }
361
362    #[test]
363    fn splits_a_batch_back_into_the_frames_that_went_in() {
364        // The whole point of batching: the lane loses packets, not bytes, so
365        // several frames ride one datagram. Split must return exactly what was
366        // concatenated or the republished stream is corrupt.
367        let frames = vec![v2(5, false), v1(9), v2(0, false), v2(3, true)];
368        let mut batch = Vec::new();
369        for f in &frames {
370            batch.extend_from_slice(f);
371        }
372        let out = split_frames(&batch);
373        assert_eq!(out.len(), frames.len());
374        for (got, want) in out.iter().zip(frames.iter()) {
375            assert_eq!(*got, want.as_slice());
376        }
377    }
378
379    #[test]
380    fn a_single_frame_batch_is_unchanged() {
381        let f = v2(12, false);
382        assert_eq!(split_frames(&f), vec![f.as_slice()]);
383    }
384
385    #[test]
386    fn drops_a_truncated_tail_and_keeps_the_whole_frames() {
387        // A datagram clipped in transit must not be mis-split into garbage: the
388        // frames that arrived whole are kept, the partial one is discarded.
389        let good = v2(8, false);
390        let mut batch = good.clone();
391        let partial = v2(20, false);
392        batch.extend_from_slice(&partial[..6]);
393        let out = split_frames(&batch);
394        assert_eq!(out, vec![good.as_slice()]);
395    }
396
397    #[test]
398    fn stops_at_an_unrecognisable_byte_rather_than_scanning_on() {
399        let mut batch = v2(4, false);
400        batch.push(0x00);
401        batch.extend_from_slice(&v1(4));
402        // Everything after the junk byte is abandoned: resyncing by scanning
403        // could lock onto a payload byte that happens to look like a header.
404        assert_eq!(split_frames(&batch).len(), 1);
405    }
406
407    #[test]
408    fn an_empty_buffer_yields_no_frames() {
409        assert!(split_frames(&[]).is_empty());
410    }
411
412    #[test]
413    fn channel_numbers_are_pinned() {
414        // Both rigs may run different builds across an upgrade. Renumbering
415        // would route one stream's frames into another stream's parser.
416        assert_eq!(AuxChannel::Mavlink as u8, 1);
417        assert_eq!(AuxChannel::Status as u8, 2);
418        assert_eq!(AuxChannel::Identity as u8, 3);
419        assert_eq!(AuxChannel::Request as u8, 4);
420        assert_eq!(AuxChannel::Response as u8, 5);
421        assert_eq!(AuxChannel::LinkFeedback as u8, 6);
422        assert_eq!(AuxChannel::ConfigTunnel as u8, 7);
423        assert_eq!(AuxChannel::from_u8(1), Some(AuxChannel::Mavlink));
424        assert_eq!(AuxChannel::from_u8(7), Some(AuxChannel::ConfigTunnel));
425        assert_eq!(AuxChannel::from_u8(8), Some(AuxChannel::AppStream));
426        assert_eq!(AuxChannel::from_u8(9), Some(AuxChannel::AppCommand));
427        assert_eq!(AuxChannel::from_u8(0), None);
428        // 10 is the next free value: an older build must drop a channel it
429        // does not know rather than parse it as the highest one it does.
430        assert_eq!(AuxChannel::from_u8(10), None);
431    }
432}