const FRAME_HEADER_SIZE: usize = 38;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
Good,
Marginal,
Unreliable,
Invalid,
}
impl Grade {
#[must_use]
pub const fn should_forward(self) -> bool {
matches!(self, Self::Good | Self::Marginal)
}
}
impl std::fmt::Display for Grade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Good => write!(f, "GOOD"),
Self::Marginal => write!(f, "MARG"),
Self::Unreliable => write!(f, "UNRL"),
Self::Invalid => write!(f, "INVL"),
}
}
}
#[must_use]
pub fn snr_grade(snr: i16, sf: u8) -> Grade {
if !(-32..=32).contains(&snr) {
return Grade::Invalid;
}
let min_snr = -2.5 * (f32::from(sf) - 4.0);
let margin = f32::from(snr) - min_snr;
if margin < 0.0 {
Grade::Unreliable
} else if margin < 3.0 {
Grade::Marginal
} else {
Grade::Good
}
}
#[derive(Debug, Clone)]
pub struct RadioPacket {
pub rssi: i16,
pub snr: i16,
pub payload: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct GossipFrame {
pub sender: [u8; 32],
pub rssi: i16,
pub snr: i16,
pub payload: Vec<u8>,
}
impl GossipFrame {
#[must_use]
pub fn new(sender: &iroh::PublicKey, rssi: i16, snr: i16, payload: Vec<u8>) -> Self {
Self { sender: *sender.as_bytes(), rssi, snr, payload }
}
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(FRAME_HEADER_SIZE + self.payload.len());
buf.extend_from_slice(&self.sender);
buf.extend_from_slice(&self.rssi.to_le_bytes());
buf.extend_from_slice(&self.snr.to_le_bytes());
#[allow(clippy::cast_possible_truncation)] let len = self.payload.len() as u16;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&self.payload);
buf
}
#[must_use]
pub fn decode(data: &[u8]) -> Option<Self> {
if data.len() < FRAME_HEADER_SIZE {
return None;
}
let sender: [u8; 32] = data[..32].try_into().ok()?;
let rssi = i16::from_le_bytes(data[32..34].try_into().ok()?);
let snr = i16::from_le_bytes(data[34..36].try_into().ok()?);
let payload_len = u16::from_le_bytes(data[36..38].try_into().ok()?) as usize;
if data.len() != FRAME_HEADER_SIZE + payload_len {
return None;
}
Some(Self { sender, rssi, snr, payload: data[FRAME_HEADER_SIZE..].to_vec() })
}
}
#[must_use]
pub fn content_hash(payload: &[u8]) -> [u8; 32] {
*blake3::hash(payload).as_bytes()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn snr_grading() {
assert_eq!(snr_grade(10, 7), Grade::Good); assert_eq!(snr_grade(-5, 7), Grade::Marginal); assert_eq!(snr_grade(-8, 7), Grade::Unreliable); assert_eq!(snr_grade(-50, 7), Grade::Invalid);
assert_eq!(snr_grade(-15, 12), Grade::Good); assert_eq!(snr_grade(-18, 12), Grade::Marginal); assert_eq!(snr_grade(-22, 12), Grade::Unreliable); }
#[test]
fn gossip_frame_roundtrip() {
let key = iroh::SecretKey::generate(&mut rand::rng());
let frame = GossipFrame::new(&key.public(), -85, 8, vec![1, 2, 3, 4, 5]);
let encoded = frame.encode();
let decoded = GossipFrame::decode(&encoded).expect("decode should succeed");
assert_eq!(decoded.sender, *key.public().as_bytes());
assert_eq!(decoded.rssi, -85);
assert_eq!(decoded.snr, 8);
assert_eq!(decoded.payload, vec![1, 2, 3, 4, 5]);
}
#[test]
fn gossip_frame_reject_short() {
assert!(GossipFrame::decode(&[0u8; 10]).is_none());
}
#[test]
fn gossip_frame_decode_empty_payload() {
let mut data = vec![0u8; 38];
data[36..38].copy_from_slice(&0u16.to_le_bytes()); let frame = GossipFrame::decode(&data);
assert!(frame.is_some(), "38-byte frame with 0-length payload must decode");
assert!(frame.unwrap().payload.is_empty());
}
#[test]
fn gossip_frame_reject_wrong_length() {
let mut data = vec![0u8; 38 + 3];
data[36..38].copy_from_slice(&5u16.to_le_bytes());
assert!(GossipFrame::decode(&data).is_none());
}
#[test]
fn should_forward_true_for_good_and_marginal() {
assert!(Grade::Good.should_forward());
assert!(Grade::Marginal.should_forward());
}
#[test]
fn should_forward_false_for_unreliable_and_invalid() {
assert!(!Grade::Unreliable.should_forward());
assert!(!Grade::Invalid.should_forward());
}
#[test]
fn grade_display() {
assert_eq!(format!("{}", Grade::Good), "GOOD");
assert_eq!(format!("{}", Grade::Marginal), "MARG");
assert_eq!(format!("{}", Grade::Unreliable), "UNRL");
assert_eq!(format!("{}", Grade::Invalid), "INVL");
}
#[test]
fn snr_grade_exact_boundaries() {
assert_eq!(snr_grade(-20, 12), Grade::Marginal);
assert_eq!(snr_grade(-21, 12), Grade::Unreliable);
assert_eq!(snr_grade(-17, 12), Grade::Good);
assert_eq!(snr_grade(-18, 12), Grade::Marginal);
}
#[test]
fn content_hash_deterministic() {
let h1 = content_hash(b"hello");
let h2 = content_hash(b"hello");
assert_eq!(h1, h2);
let h3 = content_hash(b"world");
assert_ne!(h1, h3);
}
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn gossip_frame_roundtrip_prop(
rssi in -200i16..=200i16,
snr in -32i16..=32i16,
payload in proptest::collection::vec(any::<u8>(), 0..256),
) {
let key = iroh::SecretKey::generate(&mut rand::rng());
let frame = GossipFrame::new(&key.public(), rssi, snr, payload.clone());
let encoded = frame.encode();
let decoded = GossipFrame::decode(&encoded).unwrap();
prop_assert_eq!(decoded.rssi, rssi);
prop_assert_eq!(decoded.snr, snr);
prop_assert_eq!(decoded.payload, payload);
}
#[test]
fn content_hash_deterministic_prop(data in proptest::collection::vec(any::<u8>(), 0..1024)) {
prop_assert_eq!(content_hash(&data), content_hash(&data));
}
#[test]
fn snr_grade_never_panics(snr in i16::MIN..=i16::MAX, sf in 5u8..=12u8) {
let _ = snr_grade(snr, sf);
}
}
}
}