ados_protocol/link_feedback.rs
1//! Ground-measured video-link quality, reported back to the transmitting drone.
2//!
3//! ## Why this contract exists
4//!
5//! The drone's adaptive bitrate ladder steps on measured packet loss and RSSI.
6//! On a drone those inputs are permanently absent: it transmits its own
7//! downlink, and a single radio in monitor mode cannot capture its own injected
8//! frames, so `packets_received` never leaves zero and every loss-driven
9//! decision is gated off. The ladder therefore sat on its top rung for the whole
10//! flight and could not shed rate on a link that was visibly over-fed.
11//!
12//! A local congestion signal (transmit-queue depth) covers only the case where
13//! the offered rate exceeds what the radio will *accept*. It cannot see the case
14//! that actually bites: `wfb_tx` accepts and injects everything, and the packets
15//! are lost **in the air**, so the queue stays empty while a quarter of the
16//! stream never arrives. The only honest measurement of that lives at the
17//! receiver.
18//!
19//! So the receiver sends it back. The ground station already decodes the video
20//! stream and counts exactly what was lost; this contract carries that count up
21//! the existing aux uplink so the drone's ladder can act on a real sample
22//! instead of a sentinel. No new radio, no new socket — one more channel on a
23//! lane that already runs.
24//!
25//! ## Wire format
26//!
27//! Fixed 20-byte big-endian record. Fixed-size and not msgpack because this
28//! rides a lossy radio lane at 1 Hz: a fixed record cannot be partially decoded
29//! into a plausible-but-wrong sample, and its size is knowable before the read.
30//!
31//! ```text
32//! offset size field
33//! 0 1 version
34//! 1 1 flags
35//! 2 2 loss_percent u16, hundredths (5.25% -> 525)
36//! 4 2 rssi_dbm i16, whole dBm
37//! 6 2 snr_db i16, tenths (12.4 dB -> 124)
38//! 8 4 packets_received u32, this interval
39//! 12 4 fec_failed u32, this interval
40//! 16 4 bitrate_kbps u32, delivered
41//! ```
42//!
43//! Trailing bytes are IGNORED, not rejected, so a later build may append fields
44//! without stranding an older peer mid-upgrade. A SHORT record is rejected: a
45//! truncated sample would decode as artificially low loss, which would push the
46//! ladder the wrong way on exactly the bad link that truncated it.
47
48/// The framing version this build speaks.
49///
50/// v2 added the addressee. The version was bumped rather than the field
51/// appended, deliberately: trailing bytes are ignored by design, so a v1 reader
52/// handed a v2 record would silently drop the addressee and act on a
53/// measurement of somebody else's link. Bumping makes an old drone REJECT the
54/// record and fall back to having no sample, which is the safe direction.
55pub const LINK_FEEDBACK_VERSION: u8 = 2;
56
57/// Encoded size of a v2 record.
58pub const LINK_FEEDBACK_LEN: usize = 21;
59
60/// Set when the sender had a real decode this interval. Clear means "I am
61/// listening and heard nothing", which is a materially different statement from
62/// "I heard a clean link" and must never be folded into one.
63pub const FLAG_HAS_MEASUREMENT: u8 = 0b0000_0001;
64
65/// A ground-measured sample of the drone's downlink.
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct LinkFeedback {
68 /// Percent of expected packets that did not arrive, 0.0–100.0.
69 pub loss_percent: f64,
70 /// Received signal strength, whole dBm (negative).
71 pub rssi_dbm: f64,
72 /// Signal-to-noise ratio in dB.
73 pub snr_db: f64,
74 /// Packets decoded this interval. Zero with [`FLAG_HAS_MEASUREMENT`] clear
75 /// means the receiver heard nothing at all.
76 pub packets_received: u32,
77 /// FEC blocks that could not be recovered this interval. This is the
78 /// counter that distinguishes a busy-but-fine link from a lossy one, and
79 /// the one the local transmit queue can never see.
80 pub fec_failed: u32,
81 /// Delivered application bitrate in kbps, as measured at the receiver.
82 pub bitrate_kbps: u32,
83 /// Whether the sender actually decoded anything this interval.
84 pub has_measurement: bool,
85 /// The fleet slot this measurement describes.
86 ///
87 /// The uplink is one transmission for the whole fleet, so every drone
88 /// receives every record; this is what tells a drone whether the record is
89 /// about its own link. A drone acts only on its own slot.
90 pub target_slot: u8,
91}
92
93/// Why a record could not be decoded.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum LinkFeedbackError {
96 /// Fewer than [`LINK_FEEDBACK_LEN`] bytes.
97 TooShort(usize),
98 /// A version this build does not speak.
99 UnsupportedVersion(u8),
100}
101
102impl LinkFeedback {
103 /// Encode as a v1 record.
104 ///
105 /// Saturating conversions throughout: a nonsense input (a wild RSSI from a
106 /// driver glitch, a loss above 100) clamps to the representable edge rather
107 /// than wrapping into a value of the opposite meaning.
108 pub fn encode(&self) -> Vec<u8> {
109 let mut out = Vec::with_capacity(LINK_FEEDBACK_LEN);
110 out.push(LINK_FEEDBACK_VERSION);
111 out.push(if self.has_measurement {
112 FLAG_HAS_MEASUREMENT
113 } else {
114 0
115 });
116 let loss = (self.loss_percent.clamp(0.0, 100.0) * 100.0).round() as u16;
117 out.extend_from_slice(&loss.to_be_bytes());
118 let rssi = self.rssi_dbm.clamp(-32768.0, 32767.0).round() as i16;
119 out.extend_from_slice(&rssi.to_be_bytes());
120 let snr = (self.snr_db.clamp(-3276.8, 3276.7) * 10.0).round() as i16;
121 out.extend_from_slice(&snr.to_be_bytes());
122 out.extend_from_slice(&self.packets_received.to_be_bytes());
123 out.extend_from_slice(&self.fec_failed.to_be_bytes());
124 out.extend_from_slice(&self.bitrate_kbps.to_be_bytes());
125 out.push(self.target_slot);
126 out
127 }
128
129 /// Decode a v1 record. Trailing bytes are ignored; a short record is an
130 /// error.
131 pub fn decode(buf: &[u8]) -> Result<Self, LinkFeedbackError> {
132 if buf.len() < LINK_FEEDBACK_LEN {
133 return Err(LinkFeedbackError::TooShort(buf.len()));
134 }
135 if buf[0] != LINK_FEEDBACK_VERSION {
136 return Err(LinkFeedbackError::UnsupportedVersion(buf[0]));
137 }
138 let u16at = |i: usize| u16::from_be_bytes([buf[i], buf[i + 1]]);
139 let i16at = |i: usize| i16::from_be_bytes([buf[i], buf[i + 1]]);
140 let u32at = |i: usize| u32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]);
141 Ok(Self {
142 has_measurement: buf[1] & FLAG_HAS_MEASUREMENT != 0,
143 loss_percent: f64::from(u16at(2)) / 100.0,
144 rssi_dbm: f64::from(i16at(4)),
145 snr_db: f64::from(i16at(6)) / 10.0,
146 packets_received: u32at(8),
147 fec_failed: u32at(12),
148 bitrate_kbps: u32at(16),
149 target_slot: buf[20],
150 })
151 }
152}
153
154/// Where the drone publishes the last feedback record it received.
155///
156/// The receiving process (the MAVLink router, which owns the aux uplink) and
157/// the consuming process (the radio service, which owns the bitrate ladder) are
158/// separate, so the sample crosses via a sidecar file rather than a socket —
159/// the same shape the encoder-ceiling reconcile already uses, and readable by
160/// an operator running `cat` during a bench session.
161pub const LINK_FEEDBACK_SIDECAR: &str = "/run/ados/link-feedback.json";
162
163/// The sidecar path, honouring the `ADOS_RUN_DIR` override so a test (and a
164/// sim-bench run) can point the writer and the reader at the same temp dir
165/// rather than the real `/run/ados`.
166pub fn sidecar_path() -> std::path::PathBuf {
167 match std::env::var("ADOS_RUN_DIR") {
168 Ok(dir) if !dir.trim().is_empty() => {
169 std::path::PathBuf::from(dir).join("link-feedback.json")
170 }
171 _ => std::path::PathBuf::from(LINK_FEEDBACK_SIDECAR),
172 }
173}
174
175/// How long a received sample stays usable.
176///
177/// A stale sample is worse than none: the ladder would hold a rate chosen for a
178/// link state that has since changed, and the most likely reason feedback
179/// stopped arriving is that the link got WORSE — so treating an old sample as
180/// current would hold the rate high exactly when it should fall. Three missed
181/// 1 Hz reports is the trip.
182pub const FEEDBACK_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(3);
183
184/// The on-disk form: the received record plus when it arrived.
185#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
186pub struct LinkFeedbackSidecar {
187 pub version: u8,
188 /// Wall-clock milliseconds since the epoch when this record was received.
189 pub received_at_unix_ms: u64,
190 pub loss_percent: f64,
191 pub rssi_dbm: f64,
192 pub snr_db: f64,
193 pub packets_received: u32,
194 pub fec_failed: u32,
195 pub bitrate_kbps: u32,
196 pub has_measurement: bool,
197 /// The slot the record was addressed to, carried through so the consumer
198 /// can prove it was the intended recipient rather than assuming it.
199 #[serde(default)]
200 pub target_slot: u8,
201}
202
203impl LinkFeedbackSidecar {
204 /// Stamp a received record with the current wall clock.
205 pub fn now(fb: &LinkFeedback) -> Self {
206 let ms = std::time::SystemTime::now()
207 .duration_since(std::time::UNIX_EPOCH)
208 .map(|d| d.as_millis() as u64)
209 .unwrap_or(0);
210 Self::stamped(fb, ms)
211 }
212
213 /// Stamp with an explicit time (the seam the tests drive).
214 pub fn stamped(fb: &LinkFeedback, received_at_unix_ms: u64) -> Self {
215 Self {
216 version: LINK_FEEDBACK_VERSION,
217 received_at_unix_ms,
218 loss_percent: fb.loss_percent,
219 rssi_dbm: fb.rssi_dbm,
220 snr_db: fb.snr_db,
221 packets_received: fb.packets_received,
222 fec_failed: fb.fec_failed,
223 bitrate_kbps: fb.bitrate_kbps,
224 has_measurement: fb.has_measurement,
225 target_slot: fb.target_slot,
226 }
227 }
228
229 /// Whether this record is recent enough to act on, given a current epoch-ms
230 /// reading. A record stamped in the future (a clock step) is treated as
231 /// stale, not as infinitely fresh.
232 pub fn is_fresh_at(&self, now_unix_ms: u64) -> bool {
233 let age = now_unix_ms.saturating_sub(self.received_at_unix_ms);
234 now_unix_ms >= self.received_at_unix_ms && age <= FEEDBACK_STALE_AFTER.as_millis() as u64
235 }
236
237 /// Whether this record is a usable ladder input right now: fresh AND an
238 /// actual measurement. Both gates in one place so no caller can apply one
239 /// and forget the other.
240 pub fn is_usable_at(&self, now_unix_ms: u64) -> bool {
241 self.has_measurement && self.is_fresh_at(now_unix_ms)
242 }
243}
244
245/// Read the sidecar, or `None` when it is absent or unparseable.
246pub fn read_sidecar_from(path: &std::path::Path) -> Option<LinkFeedbackSidecar> {
247 let text = std::fs::read_to_string(path).ok()?;
248 serde_json::from_str(&text).ok()
249}
250
251/// Write the sidecar via a temp file and rename, so a reader mid-tick never
252/// observes a half-written record.
253pub fn write_sidecar_to(path: &std::path::Path, s: &LinkFeedbackSidecar) -> std::io::Result<()> {
254 let body = serde_json::to_vec(s)?;
255 if let Some(parent) = path.parent() {
256 std::fs::create_dir_all(parent)?;
257 }
258 let tmp = path.with_extension("json.tmp");
259 std::fs::write(&tmp, &body)?;
260 std::fs::rename(&tmp, path)
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 fn sample() -> LinkFeedback {
268 LinkFeedback {
269 loss_percent: 24.29,
270 rssi_dbm: -36.0,
271 snr_db: 12.4,
272 packets_received: 485,
273 fec_failed: 25,
274 bitrate_kbps: 2242,
275 has_measurement: true,
276 target_slot: 1,
277 }
278 }
279
280 #[test]
281 fn round_trips_a_real_sample() {
282 let s = sample();
283 let got = LinkFeedback::decode(&s.encode()).expect("decode");
284 assert!((got.loss_percent - 24.29).abs() < 0.01);
285 assert_eq!(got.rssi_dbm, -36.0);
286 assert!((got.snr_db - 12.4).abs() < 0.05);
287 assert_eq!(got.packets_received, 485);
288 assert_eq!(got.fec_failed, 25);
289 assert_eq!(got.bitrate_kbps, 2242);
290 assert!(got.has_measurement);
291 }
292
293 #[test]
294 fn encodes_to_the_declared_length() {
295 assert_eq!(sample().encode().len(), LINK_FEEDBACK_LEN);
296 }
297
298 #[test]
299 fn the_record_names_the_slot_it_measures() {
300 // The uplink is ONE transmission for the whole fleet, so every drone
301 // receives every record. Without an addressee each one reads it as its
302 // own link — and the ground measures only the primary slot, so every
303 // other drone would act on the primary's loss.
304 let s = LinkFeedback {
305 target_slot: 3,
306 ..sample()
307 };
308 assert_eq!(
309 LinkFeedback::decode(&s.encode())
310 .expect("decode")
311 .target_slot,
312 3
313 );
314 }
315
316 #[test]
317 fn a_reader_that_predates_the_addressee_refuses_the_record() {
318 // Trailing bytes are ignored by design, so appending the addressee
319 // without bumping the version would let an old drone silently drop it
320 // and act on somebody else's measurement. Rejecting is the safe
321 // direction: no sample, and the ladder holds.
322 let encoded = sample().encode();
323 let mut as_v1 = encoded.clone();
324 as_v1[0] = 1;
325 assert_eq!(
326 LinkFeedback::decode(&as_v1),
327 Err(LinkFeedbackError::UnsupportedVersion(1)),
328 "a v1 record must not be accepted by a build that needs an addressee"
329 );
330 }
331
332 #[test]
333 fn the_addressee_survives_into_the_sidecar() {
334 // The consumer gates on it, so it has to cross the process boundary.
335 let s = LinkFeedbackSidecar::stamped(
336 &LinkFeedback {
337 target_slot: 2,
338 ..sample()
339 },
340 1000,
341 );
342 assert_eq!(s.target_slot, 2);
343 }
344
345 #[test]
346 fn heard_nothing_is_distinct_from_a_clean_link() {
347 // Both have zero loss and zero packets; only the flag separates them,
348 // and conflating them would let a deaf receiver read as a perfect link.
349 let deaf = LinkFeedback {
350 has_measurement: false,
351 packets_received: 0,
352 loss_percent: 0.0,
353 ..sample()
354 };
355 let decoded = LinkFeedback::decode(&deaf.encode()).expect("decode");
356 assert!(!decoded.has_measurement);
357 }
358
359 #[test]
360 fn a_short_record_is_rejected_not_zero_filled() {
361 // A truncated record would decode as artificially LOW loss and push the
362 // ladder up on exactly the bad link that truncated it.
363 let full = sample().encode();
364 for n in 0..LINK_FEEDBACK_LEN {
365 assert_eq!(
366 LinkFeedback::decode(&full[..n]),
367 Err(LinkFeedbackError::TooShort(n)),
368 "len {n} must be rejected"
369 );
370 }
371 }
372
373 #[test]
374 fn trailing_bytes_are_ignored_so_a_newer_peer_still_decodes() {
375 let mut buf = sample().encode();
376 buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
377 assert_eq!(LinkFeedback::decode(&buf).expect("decode"), sample());
378 }
379
380 #[test]
381 fn an_unknown_version_is_rejected() {
382 let mut buf = sample().encode();
383 buf[0] = 0x7F;
384 assert_eq!(
385 LinkFeedback::decode(&buf),
386 Err(LinkFeedbackError::UnsupportedVersion(0x7F))
387 );
388 }
389
390 #[test]
391 fn a_fresh_measurement_is_usable() {
392 let s = LinkFeedbackSidecar::stamped(&sample(), 10_000);
393 assert!(s.is_usable_at(10_500));
394 assert!(s.is_usable_at(13_000), "3s is the edge, still usable");
395 }
396
397 #[test]
398 fn a_stale_record_is_not_usable() {
399 // The failure this guards: feedback stops because the link got WORSE,
400 // and holding the last good sample would keep the rate high exactly
401 // when it should fall.
402 let s = LinkFeedbackSidecar::stamped(&sample(), 10_000);
403 assert!(!s.is_usable_at(13_001));
404 assert!(!s.is_usable_at(60_000));
405 }
406
407 #[test]
408 fn a_record_stamped_in_the_future_is_stale_not_infinitely_fresh() {
409 let s = LinkFeedbackSidecar::stamped(&sample(), 50_000);
410 assert!(!s.is_usable_at(10_000));
411 }
412
413 #[test]
414 fn a_fresh_non_measurement_is_not_usable() {
415 let deaf = LinkFeedback {
416 has_measurement: false,
417 ..sample()
418 };
419 let s = LinkFeedbackSidecar::stamped(&deaf, 10_000);
420 assert!(s.is_fresh_at(10_100), "it did arrive recently");
421 assert!(
422 !s.is_usable_at(10_100),
423 "but a receiver that heard nothing is not a loss measurement"
424 );
425 }
426
427 #[test]
428 fn the_sidecar_round_trips_through_a_file() {
429 let dir = tempfile::tempdir().unwrap();
430 let path = dir.path().join("link-feedback.json");
431 let s = LinkFeedbackSidecar::stamped(&sample(), 1234);
432 write_sidecar_to(&path, &s).expect("write");
433 let back = read_sidecar_from(&path).expect("read");
434 assert_eq!(back.received_at_unix_ms, 1234);
435 assert!((back.loss_percent - 24.29).abs() < 0.001);
436 assert!(back.has_measurement);
437 }
438
439 #[test]
440 fn a_missing_sidecar_reads_as_none_rather_than_a_default() {
441 let missing = std::path::Path::new("/nonexistent/ados/link-feedback.json");
442 assert!(read_sidecar_from(missing).is_none());
443 }
444
445 #[test]
446 fn out_of_range_inputs_clamp_rather_than_wrap() {
447 let wild = LinkFeedback {
448 loss_percent: 900.0,
449 rssi_dbm: -99999.0,
450 snr_db: 1e9,
451 ..sample()
452 };
453 let got = LinkFeedback::decode(&wild.encode()).expect("decode");
454 assert_eq!(got.loss_percent, 100.0, "loss clamps to 100, never wraps");
455 assert!(
456 got.rssi_dbm <= 0.0,
457 "rssi stays negative, never wraps positive"
458 );
459 assert!(got.snr_db > 0.0, "snr stays positive, never wraps negative");
460 }
461}