feat: initial working P2P LoRa bridge

iroh QUIC datagrams for peer-to-peer networking with NAT traversal,
donglora-client for radio access (direct USB or mux), ratatui TUI
with four-panel dashboard (peers, radio config, stats, packet log).

- Packet envelope with origin tracking, sequence numbers, RSSI/SNR
- Dual dedup: iroh-level (origin+seq) and radio echo (blake3 hash)
- SNR filtering per PROTOCOL.md (forward GOOD+MARGINAL, drop rest)
- Auto-generate config + identity on first run
- Graceful mux config fallback (adopts locked config if SetConfig rejected)
- WouldBlock workaround for donglora-client socket timeout bug
- Reconnect with exponential backoff on dongle disconnect
- CLI: show-id, generate-config subcommands, --log-only headless mode
This commit is contained in:
swaits committed 2026-04-05 11:43:00 -06:00
commit 4387549e7f
10 files changed
+6891

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
use iroh::PublicKey;
/// Wire protocol version.
const VERSION: u8 = 0;
/// Envelope header size: version(1) + origin(32) + seq(8) + rssi(2) + snr(2) + payload_len(2) = 47
const HEADER_SIZE: usize = 47;
/// SNR quality grade for a received LoRa packet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
/// SNR well above demodulation floor (margin >= 3 dB).
Good,
/// SNR near demodulation floor (0 <= margin < 3 dB).
Marginal,
/// SNR below demodulation floor — likely corrupt.
Unreliable,
/// SNR outside valid range (-32..32) — firmware/decode error.
Invalid,
}
impl Grade {
/// Returns true if the packet is reliable enough to forward.
pub 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"),
}
}
}
/// Grade a received packet's SNR against the demodulation floor for the given
/// spreading factor. See PROTOCOL.md for the formula.
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
}
}
/// A packet received from the radio, before wrapping in an envelope.
#[derive(Debug, Clone)]
pub struct RadioPacket {
pub rssi: i16,
pub snr: i16,
pub payload: Vec<u8>,
}
/// A bridge envelope — the wire format sent between iroh peers.
#[derive(Debug, Clone)]
pub struct Envelope {
/// NodeId of the bridge that first heard this packet on radio.
pub origin: PublicKey,
/// Per-origin sequence number (monotonically increasing).
pub seq: u64,
/// RSSI at the originating bridge (informational).
pub rssi: i16,
/// SNR at the originating bridge (informational).
pub snr: i16,
/// Raw LoRa payload.
pub payload: Vec<u8>,
}
impl Envelope {
/// Encode this envelope into bytes for sending as a QUIC datagram.
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE + self.payload.len());
buf.push(VERSION);
buf.extend_from_slice(self.origin.as_bytes());
buf.extend_from_slice(&self.seq.to_le_bytes());
buf.extend_from_slice(&self.rssi.to_le_bytes());
buf.extend_from_slice(&self.snr.to_le_bytes());
let len = self.payload.len() as u16;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&self.payload);
buf
}
/// Decode an envelope from bytes received as a QUIC datagram.
pub fn decode(data: &[u8]) -> Option<Self> {
if data.len() < HEADER_SIZE {
return None;
}
if data[0] != VERSION {
return None;
}
let origin = PublicKey::from_bytes(data[1..33].try_into().ok()?).ok()?;
let seq = u64::from_le_bytes(data[33..41].try_into().ok()?);
let rssi = i16::from_le_bytes(data[41..43].try_into().ok()?);
let snr = i16::from_le_bytes(data[43..45].try_into().ok()?);
let payload_len = u16::from_le_bytes(data[45..47].try_into().ok()?) as usize;
if data.len() != HEADER_SIZE + payload_len {
return None;
}
Some(Self {
origin,
seq,
rssi,
snr,
payload: data[HEADER_SIZE..].to_vec(),
})
}
}
/// Compute a 16-byte content hash for deduplication.
pub fn content_hash(payload: &[u8]) -> [u8; 16] {
let hash = blake3::hash(payload);
let bytes = hash.as_bytes();
let mut out = [0u8; 16];
out.copy_from_slice(&bytes[..16]);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snr_grading() {
// SF7: min_snr = -2.5 * (7 - 4) = -7.5
assert_eq!(snr_grade(10, 7), Grade::Good); // margin = 17.5
assert_eq!(snr_grade(-5, 7), Grade::Marginal); // margin = 2.5 < 3
assert_eq!(snr_grade(-8, 7), Grade::Unreliable); // margin = -0.5
assert_eq!(snr_grade(-50, 7), Grade::Invalid); // out of range
// SF12: min_snr = -2.5 * (12 - 4) = -20.0
assert_eq!(snr_grade(-15, 12), Grade::Good); // margin = 5.0
assert_eq!(snr_grade(-18, 12), Grade::Marginal); // margin = 2.0
assert_eq!(snr_grade(-22, 12), Grade::Unreliable); // margin = -2.0
}
#[test]
fn envelope_roundtrip() {
let key = iroh::SecretKey::generate(&mut rand::rng());
let env = Envelope {
origin: key.public(),
seq: 42,
rssi: -85,
snr: 8,
payload: vec![1, 2, 3, 4, 5],
};
let encoded = env.encode();
let decoded = Envelope::decode(&encoded).unwrap();
assert_eq!(decoded.origin, env.origin);
assert_eq!(decoded.seq, env.seq);
assert_eq!(decoded.rssi, env.rssi);
assert_eq!(decoded.snr, env.snr);
assert_eq!(decoded.payload, env.payload);
}
#[test]
fn envelope_reject_short() {
assert!(Envelope::decode(&[0u8; 10]).is_none());
}
#[test]
fn envelope_reject_bad_version() {
let mut data = vec![1u8]; // version 1
data.extend_from_slice(&[0u8; 46]);
assert!(Envelope::decode(&data).is_none());
}
#[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);
}
}