pub const LINK_FEEDBACK_VERSION: u8 = 2;
pub const LINK_FEEDBACK_LEN: usize = 21;
pub const FLAG_HAS_MEASUREMENT: u8 = 0b0000_0001;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinkFeedback {
pub loss_percent: f64,
pub rssi_dbm: f64,
pub snr_db: f64,
pub packets_received: u32,
pub fec_failed: u32,
pub bitrate_kbps: u32,
pub has_measurement: bool,
pub target_slot: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkFeedbackError {
TooShort(usize),
UnsupportedVersion(u8),
}
impl LinkFeedback {
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(LINK_FEEDBACK_LEN);
out.push(LINK_FEEDBACK_VERSION);
out.push(if self.has_measurement {
FLAG_HAS_MEASUREMENT
} else {
0
});
let loss = (self.loss_percent.clamp(0.0, 100.0) * 100.0).round() as u16;
out.extend_from_slice(&loss.to_be_bytes());
let rssi = self.rssi_dbm.clamp(-32768.0, 32767.0).round() as i16;
out.extend_from_slice(&rssi.to_be_bytes());
let snr = (self.snr_db.clamp(-3276.8, 3276.7) * 10.0).round() as i16;
out.extend_from_slice(&snr.to_be_bytes());
out.extend_from_slice(&self.packets_received.to_be_bytes());
out.extend_from_slice(&self.fec_failed.to_be_bytes());
out.extend_from_slice(&self.bitrate_kbps.to_be_bytes());
out.push(self.target_slot);
out
}
pub fn decode(buf: &[u8]) -> Result<Self, LinkFeedbackError> {
if buf.len() < LINK_FEEDBACK_LEN {
return Err(LinkFeedbackError::TooShort(buf.len()));
}
if buf[0] != LINK_FEEDBACK_VERSION {
return Err(LinkFeedbackError::UnsupportedVersion(buf[0]));
}
let u16at = |i: usize| u16::from_be_bytes([buf[i], buf[i + 1]]);
let i16at = |i: usize| i16::from_be_bytes([buf[i], buf[i + 1]]);
let u32at = |i: usize| u32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]);
Ok(Self {
has_measurement: buf[1] & FLAG_HAS_MEASUREMENT != 0,
loss_percent: f64::from(u16at(2)) / 100.0,
rssi_dbm: f64::from(i16at(4)),
snr_db: f64::from(i16at(6)) / 10.0,
packets_received: u32at(8),
fec_failed: u32at(12),
bitrate_kbps: u32at(16),
target_slot: buf[20],
})
}
}
pub const LINK_FEEDBACK_SIDECAR: &str = "/run/ados/link-feedback.json";
pub fn sidecar_path() -> std::path::PathBuf {
match std::env::var("ADOS_RUN_DIR") {
Ok(dir) if !dir.trim().is_empty() => {
std::path::PathBuf::from(dir).join("link-feedback.json")
}
_ => std::path::PathBuf::from(LINK_FEEDBACK_SIDECAR),
}
}
pub const FEEDBACK_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(3);
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LinkFeedbackSidecar {
pub version: u8,
pub received_at_unix_ms: u64,
pub loss_percent: f64,
pub rssi_dbm: f64,
pub snr_db: f64,
pub packets_received: u32,
pub fec_failed: u32,
pub bitrate_kbps: u32,
pub has_measurement: bool,
#[serde(default)]
pub target_slot: u8,
}
impl LinkFeedbackSidecar {
pub fn now(fb: &LinkFeedback) -> Self {
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
Self::stamped(fb, ms)
}
pub fn stamped(fb: &LinkFeedback, received_at_unix_ms: u64) -> Self {
Self {
version: LINK_FEEDBACK_VERSION,
received_at_unix_ms,
loss_percent: fb.loss_percent,
rssi_dbm: fb.rssi_dbm,
snr_db: fb.snr_db,
packets_received: fb.packets_received,
fec_failed: fb.fec_failed,
bitrate_kbps: fb.bitrate_kbps,
has_measurement: fb.has_measurement,
target_slot: fb.target_slot,
}
}
pub fn is_fresh_at(&self, now_unix_ms: u64) -> bool {
let age = now_unix_ms.saturating_sub(self.received_at_unix_ms);
now_unix_ms >= self.received_at_unix_ms && age <= FEEDBACK_STALE_AFTER.as_millis() as u64
}
pub fn is_usable_at(&self, now_unix_ms: u64) -> bool {
self.has_measurement && self.is_fresh_at(now_unix_ms)
}
}
pub fn read_sidecar_from(path: &std::path::Path) -> Option<LinkFeedbackSidecar> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
pub fn write_sidecar_to(path: &std::path::Path, s: &LinkFeedbackSidecar) -> std::io::Result<()> {
let body = serde_json::to_vec(s)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body)?;
std::fs::rename(&tmp, path)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> LinkFeedback {
LinkFeedback {
loss_percent: 24.29,
rssi_dbm: -36.0,
snr_db: 12.4,
packets_received: 485,
fec_failed: 25,
bitrate_kbps: 2242,
has_measurement: true,
target_slot: 1,
}
}
#[test]
fn round_trips_a_real_sample() {
let s = sample();
let got = LinkFeedback::decode(&s.encode()).expect("decode");
assert!((got.loss_percent - 24.29).abs() < 0.01);
assert_eq!(got.rssi_dbm, -36.0);
assert!((got.snr_db - 12.4).abs() < 0.05);
assert_eq!(got.packets_received, 485);
assert_eq!(got.fec_failed, 25);
assert_eq!(got.bitrate_kbps, 2242);
assert!(got.has_measurement);
}
#[test]
fn encodes_to_the_declared_length() {
assert_eq!(sample().encode().len(), LINK_FEEDBACK_LEN);
}
#[test]
fn the_record_names_the_slot_it_measures() {
let s = LinkFeedback {
target_slot: 3,
..sample()
};
assert_eq!(
LinkFeedback::decode(&s.encode())
.expect("decode")
.target_slot,
3
);
}
#[test]
fn a_reader_that_predates_the_addressee_refuses_the_record() {
let encoded = sample().encode();
let mut as_v1 = encoded.clone();
as_v1[0] = 1;
assert_eq!(
LinkFeedback::decode(&as_v1),
Err(LinkFeedbackError::UnsupportedVersion(1)),
"a v1 record must not be accepted by a build that needs an addressee"
);
}
#[test]
fn the_addressee_survives_into_the_sidecar() {
let s = LinkFeedbackSidecar::stamped(
&LinkFeedback {
target_slot: 2,
..sample()
},
1000,
);
assert_eq!(s.target_slot, 2);
}
#[test]
fn heard_nothing_is_distinct_from_a_clean_link() {
let deaf = LinkFeedback {
has_measurement: false,
packets_received: 0,
loss_percent: 0.0,
..sample()
};
let decoded = LinkFeedback::decode(&deaf.encode()).expect("decode");
assert!(!decoded.has_measurement);
}
#[test]
fn a_short_record_is_rejected_not_zero_filled() {
let full = sample().encode();
for n in 0..LINK_FEEDBACK_LEN {
assert_eq!(
LinkFeedback::decode(&full[..n]),
Err(LinkFeedbackError::TooShort(n)),
"len {n} must be rejected"
);
}
}
#[test]
fn trailing_bytes_are_ignored_so_a_newer_peer_still_decodes() {
let mut buf = sample().encode();
buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(LinkFeedback::decode(&buf).expect("decode"), sample());
}
#[test]
fn an_unknown_version_is_rejected() {
let mut buf = sample().encode();
buf[0] = 0x7F;
assert_eq!(
LinkFeedback::decode(&buf),
Err(LinkFeedbackError::UnsupportedVersion(0x7F))
);
}
#[test]
fn a_fresh_measurement_is_usable() {
let s = LinkFeedbackSidecar::stamped(&sample(), 10_000);
assert!(s.is_usable_at(10_500));
assert!(s.is_usable_at(13_000), "3s is the edge, still usable");
}
#[test]
fn a_stale_record_is_not_usable() {
let s = LinkFeedbackSidecar::stamped(&sample(), 10_000);
assert!(!s.is_usable_at(13_001));
assert!(!s.is_usable_at(60_000));
}
#[test]
fn a_record_stamped_in_the_future_is_stale_not_infinitely_fresh() {
let s = LinkFeedbackSidecar::stamped(&sample(), 50_000);
assert!(!s.is_usable_at(10_000));
}
#[test]
fn a_fresh_non_measurement_is_not_usable() {
let deaf = LinkFeedback {
has_measurement: false,
..sample()
};
let s = LinkFeedbackSidecar::stamped(&deaf, 10_000);
assert!(s.is_fresh_at(10_100), "it did arrive recently");
assert!(
!s.is_usable_at(10_100),
"but a receiver that heard nothing is not a loss measurement"
);
}
#[test]
fn the_sidecar_round_trips_through_a_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("link-feedback.json");
let s = LinkFeedbackSidecar::stamped(&sample(), 1234);
write_sidecar_to(&path, &s).expect("write");
let back = read_sidecar_from(&path).expect("read");
assert_eq!(back.received_at_unix_ms, 1234);
assert!((back.loss_percent - 24.29).abs() < 0.001);
assert!(back.has_measurement);
}
#[test]
fn a_missing_sidecar_reads_as_none_rather_than_a_default() {
let missing = std::path::Path::new("/nonexistent/ados/link-feedback.json");
assert!(read_sidecar_from(missing).is_none());
}
#[test]
fn out_of_range_inputs_clamp_rather_than_wrap() {
let wild = LinkFeedback {
loss_percent: 900.0,
rssi_dbm: -99999.0,
snr_db: 1e9,
..sample()
};
let got = LinkFeedback::decode(&wild.encode()).expect("decode");
assert_eq!(got.loss_percent, 100.0, "loss clamps to 100, never wraps");
assert!(
got.rssi_dbm <= 0.0,
"rssi stays negative, never wraps positive"
);
assert!(got.snr_db > 0.0, "snr stays positive, never wraps negative");
}
}