Skip to main content

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}