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}