//! LoRa-style constrained transport with mock RF medium, fragmentation, and EU868 duty-cycle budgeting. //! //! Real hardware typically uses a UART-attached module; this crate ships a [`LoRaMockMedium`] that //! delivers frames between registered node addresses for tests and the integration example. //! //! # Wire format (mock / modem-passthrough oriented) //! //! - **Whole datagram** (`0x01`): `LR` magic, type, 4-byte source, 4-byte destination, `u16` BE length, payload. //! - **Fragment** (`0x02`): same header prefix + `frag_id` (u32 BE), `idx`, `total`, `u16` BE chunk length, chunk. use std::collections::{HashMap, VecDeque}; use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::{bail, Result}; use tokio::sync::Mutex; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use crate::transport::{MeshTransport, TransportAddr, TransportInfo, TransportPacket}; const FRAME_MAGIC: [u8; 2] = *b"LR"; const TYPE_WHOLE: u8 = 0x01; const TYPE_FRAG: u8 = 0x02; const WHOLE_HEADER: usize = 2 + 1 + 4 + 4 + 2; const FRAG_HEADER: usize = 2 + 1 + 4 + 4 + 4 + 1 + 1 + 2; /// LoRa radio and serial link parameters (modem AT layer is out of scope; UART path is optional extension). #[derive(Clone, Debug)] pub struct LoRaConfig { /// Serial device path when using hardware (informational / future UART backend). pub port: String, pub baud_rate: u32, pub frequency: u64, pub spreading_factor: u8, pub bandwidth: u32, /// LoRa coding rate denominator n in 4/n (5..=8 → 4/5 .. 4/8). pub coding_rate: u8, pub tx_power: i8, /// Max frame size including headers (modem MTU). If `None`, derived from spreading factor. pub max_frame_len: Option, } impl Default for LoRaConfig { fn default() -> Self { Self { port: String::new(), baud_rate: 115_200, frequency: 868_100_000, spreading_factor: 7, bandwidth: 125_000, coding_rate: 5, tx_power: 14, max_frame_len: None, } } } impl LoRaConfig { /// Typical max MAC payload for EU868 / 125 kHz (order-of-magnitude; modem-specific). pub fn default_max_frame_len(&self) -> usize { if let Some(m) = self.max_frame_len { return m.clamp(WHOLE_HEADER + 1, 256); } let mtu = match self.spreading_factor { 7 | 8 => 222, 9 => 115, 10 | 11 | 12 => 51, _ => 128, }; mtu.clamp(WHOLE_HEADER + 1, 256) } fn cr_index(&self) -> u64 { match self.coding_rate.clamp(5, 8) { 5 => 1, 6 => 2, 7 => 3, 8 => 4, _ => 1, } } } /// Approximate LoRa time-on-air in milliseconds for a given PHY payload length (including our framing). pub fn lora_airtime_ms(payload_len: usize, cfg: &LoRaConfig) -> u64 { let sf = cfg.spreading_factor.clamp(7, 12) as u64; let bw = cfg.bandwidth.max(7_800) as u64; let t_sym_us = ((1u64 << sf) * 1_000_000u64) / bw; let preamble_syms = 12u64 + 4; let preamble_us = preamble_syms * t_sym_us; let de = 0i64; let pl = payload_len as i64; let sf_i = sf as i64; let numerator = 8 * pl - 4 * sf_i + 28 + 16 - 20; let denom = 4 * (sf_i - 2 * de); let payload_symb = if denom > 0 && numerator > 0 { let ceiled = (numerator + denom - 1) / denom; let cr = cfg.cr_index() as i64; 8 + ceiled * (cr + 4) } else { 8i64 }; let payload_us = (payload_symb as u64).saturating_mul(t_sym_us); (preamble_us + payload_us) / 1000 } /// Rough PHY bitrate estimate (bits/s) for routing metrics — not precise at low SNR. pub fn lora_nominal_bitrate_bps(cfg: &LoRaConfig) -> u64 { let sf = cfg.spreading_factor.clamp(7, 12) as u32; let bw = cfg.bandwidth.max(7_800); // bits per symbol ≈ SF; symbol rate ≈ BW / 2^SF let sym_rate = (bw as u64) / (1u64 << sf); sym_rate.saturating_mul(sf as u64) } /// EU868-style 1% duty cycle: at most 36_000 ms airtime per rolling hour. #[derive(Debug)] pub struct DutyCycleTracker { max_ms_per_hour: u64, window: Mutex>, } impl DutyCycleTracker { pub fn new(max_ms_airtime_per_hour: u64) -> Self { Self { max_ms_per_hour: max_ms_airtime_per_hour, window: Mutex::new(VecDeque::new()), } } /// 1% of one hour = 36 seconds of transmission time. pub fn eu868_one_percent() -> Self { Self::new(36_000) } fn prune_old( deque: &mut VecDeque<(Instant, u64)>) { let cutoff = Instant::now() - Duration::from_secs(3600); while let Some(&(t, _)) = deque.front() { if t < cutoff { deque.pop_front(); } else { break; } } } fn sum_ms(deque: &VecDeque<(Instant, u64)>) -> u64 { deque.iter().map(|(_, m)| m).sum() } /// Wait until `airtime_ms` fits in the budget, then record it. pub async fn acquire(&self, airtime_ms: u64) { loop { let sleep_for = { let mut deque = self.window.lock().await; Self::prune_old(&mut deque); let used = Self::sum_ms(&deque); if used + airtime_ms <= self.max_ms_per_hour { deque.push_back((Instant::now(), airtime_ms)); return; } if let Some(&(oldest, _)) = deque.front() { let elapsed = oldest.elapsed(); let until_refresh = Duration::from_secs(3600).saturating_sub(elapsed); until_refresh.max(Duration::from_millis(1)) } else { Duration::from_millis(1) } }; tokio::time::sleep(sleep_for).await; } } /// Total recorded airtime in the current window (for tests / diagnostics). pub async fn used_ms_in_window(&self) -> u64 { let mut deque = self.window.lock().await; Self::prune_old(&mut deque); Self::sum_ms(&deque) } } /// In-process mock RF cloud: addressed delivery between registered 4-byte LoRa addresses. #[derive(Debug)] pub struct LoRaMockMedium { nodes: Mutex>>>, } impl LoRaMockMedium { pub fn new() -> Arc { Arc::new(Self { nodes: Mutex::new(HashMap::new()), }) } /// Register a node; returns a transport bound to `my_addr`. pub async fn connect( self: &Arc, my_addr: [u8; 4], config: LoRaConfig, duty: Arc, ) -> Result { let (tx, rx) = unbounded_channel(); let mut map = self.nodes.lock().await; if map.contains_key(&my_addr) { bail!("LoRa address already registered on this medium"); } map.insert(my_addr, tx); drop(map); Ok(LoRaTransport { medium: Arc::clone(self), my_addr, inbox: Mutex::new(rx), config, duty, assembler: Mutex::new(FragmentAssembler::default()), }) } async fn deliver(self: &Arc, dest: [u8; 4], frame: Vec) -> Result<()> { let sender = { let map = self.nodes.lock().await; map.get(&dest) .cloned() .ok_or_else(|| anyhow::anyhow!("unknown LoRa destination {dest:02x?}"))? }; sender .send(frame) .map_err(|_| anyhow::anyhow!("LoRa peer inbox closed"))?; Ok(()) } async fn unregister(self: &Arc, addr: [u8; 4]) { let mut map = self.nodes.lock().await; map.remove(&addr); } } /// LoRa [`MeshTransport`] using [`LoRaMockMedium`]. pub struct LoRaTransport { medium: Arc, my_addr: [u8; 4], inbox: Mutex>>, config: LoRaConfig, duty: Arc, assembler: Mutex, } impl LoRaTransport { pub fn local_address(&self) -> [u8; 4] { self.my_addr } pub fn transport_addr(&self) -> TransportAddr { TransportAddr::LoRa(self.my_addr) } fn max_frame_len(&self) -> usize { self.config.default_max_frame_len() } fn whole_payload_cap(&self) -> usize { self.max_frame_len().saturating_sub(WHOLE_HEADER) } fn frag_payload_cap(&self) -> usize { self.max_frame_len().saturating_sub(FRAG_HEADER) } fn build_whole(src: [u8; 4], dst: [u8; 4], payload: &[u8]) -> Result> { let len = payload.len(); if len > u16::MAX as usize { bail!("LoRa payload too large"); } let mut v = Vec::with_capacity(WHOLE_HEADER + len); v.extend_from_slice(&FRAME_MAGIC); v.push(TYPE_WHOLE); v.extend_from_slice(&src); v.extend_from_slice(&dst); v.extend_from_slice(&(len as u16).to_be_bytes()); v.extend_from_slice(payload); Ok(v) } fn build_frag( src: [u8; 4], dst: [u8; 4], frag_id: u32, idx: u8, total: u8, chunk: &[u8], ) -> Result> { let len = chunk.len(); if len > u16::MAX as usize { bail!("fragment chunk too large"); } let mut v = Vec::with_capacity(FRAG_HEADER + len); v.extend_from_slice(&FRAME_MAGIC); v.push(TYPE_FRAG); v.extend_from_slice(&src); v.extend_from_slice(&dst); v.extend_from_slice(&frag_id.to_be_bytes()); v.push(idx); v.push(total); v.extend_from_slice(&(len as u16).to_be_bytes()); v.extend_from_slice(chunk); Ok(v) } fn parse_frame(buf: &[u8]) -> Result { if buf.len() < 2 || buf[0] != FRAME_MAGIC[0] || buf[1] != FRAME_MAGIC[1] { bail!("invalid LoRa frame magic"); } if buf.len() < 3 { bail!("truncated LoRa frame"); } match buf[2] { TYPE_WHOLE => { if buf.len() < WHOLE_HEADER { bail!("truncated whole frame"); } let mut src = [0u8; 4]; src.copy_from_slice(&buf[3..7]); let mut dst = [0u8; 4]; dst.copy_from_slice(&buf[7..11]); let plen = u16::from_be_bytes([buf[11], buf[12]]) as usize; if buf.len() != WHOLE_HEADER + plen { bail!("whole frame length mismatch"); } Ok(ParsedFrame::Whole { src, dst, payload: buf[WHOLE_HEADER..].to_vec(), }) } TYPE_FRAG => { if buf.len() < FRAG_HEADER { bail!("truncated fragment frame"); } let mut src = [0u8; 4]; src.copy_from_slice(&buf[3..7]); let mut dst = [0u8; 4]; dst.copy_from_slice(&buf[7..11]); let frag_id = u32::from_be_bytes([buf[11], buf[12], buf[13], buf[14]]); let idx = buf[15]; let total = buf[16]; let clen = u16::from_be_bytes([buf[17], buf[18]]) as usize; if buf.len() != FRAG_HEADER + clen { bail!("fragment length mismatch"); } Ok(ParsedFrame::Frag { src, dst, frag_id, idx, total, chunk: buf[FRAG_HEADER..].to_vec(), }) } t => bail!("unknown LoRa frame type {t}"), } } } enum ParsedFrame { Whole { src: [u8; 4], dst: [u8; 4], payload: Vec, }, Frag { src: [u8; 4], dst: [u8; 4], frag_id: u32, idx: u8, total: u8, chunk: Vec, }, } #[derive(Default)] struct FragmentAssembler { partials: HashMap<(u32, [u8; 4]), PartialFrag>, } struct PartialFrag { total: u8, pieces: HashMap>, started: Instant, } impl FragmentAssembler { const TIMEOUT: Duration = Duration::from_secs(120); fn push( &mut self, src: [u8; 4], frag_id: u32, idx: u8, total: u8, chunk: Vec, ) -> Result>> { self.gc(); let key = (frag_id, src); let entry = self .partials .entry(key) .or_insert_with(|| PartialFrag { total, pieces: HashMap::new(), started: Instant::now(), }); if entry.total != total { bail!("fragment total mismatch"); } entry.pieces.insert(idx, chunk); if entry.pieces.len() == total as usize { let mut out = Vec::new(); for i in 0..total { let piece = entry .pieces .get(&i) .ok_or_else(|| anyhow::anyhow!("missing fragment index {i}"))?; out.extend_from_slice(piece); } self.partials.remove(&key); return Ok(Some(out)); } Ok(None) } fn gc(&mut self) { let now = Instant::now(); self.partials .retain(|_, p| now.duration_since(p.started) < Self::TIMEOUT); } } #[async_trait::async_trait] impl MeshTransport for LoRaTransport { fn info(&self) -> TransportInfo { TransportInfo { name: "lora".to_string(), mtu: self.whole_payload_cap(), bitrate: lora_nominal_bitrate_bps(&self.config), bidirectional: true, } } async fn send(&self, dest: &TransportAddr, data: &[u8]) -> Result<()> { let dst = match dest { TransportAddr::LoRa(a) => *a, other => bail!("LoRaTransport cannot send to {other}"), }; let max_frame = self.max_frame_len(); let cap_whole = self.whole_payload_cap(); let cap_frag = self.frag_payload_cap().max(1); let frames: Vec> = if data.len() <= cap_whole { vec![Self::build_whole(self.my_addr, dst, data)?] } else { let frag_id = random_frag_id(); let chunk_sz = cap_frag; let total = data.chunks(chunk_sz).count(); if total > u8::MAX as usize { bail!("payload needs more than 255 fragments"); } let total_u8 = total as u8; let mut out = Vec::with_capacity(total); for (idx, chunk) in data.chunks(chunk_sz).enumerate() { out.push(Self::build_frag( self.my_addr, dst, frag_id, idx as u8, total_u8, chunk, )?); } out }; for frame in frames { let air = lora_airtime_ms(frame.len(), &self.config); self.duty.acquire(air).await; if frame.len() > max_frame { bail!("LoRa frame exceeds configured MTU"); } self.medium.deliver(dst, frame).await?; } Ok(()) } async fn recv(&self) -> Result { loop { let raw = { let mut inbox = self.inbox.lock().await; inbox .recv() .await .ok_or_else(|| anyhow::anyhow!("LoRa inbox closed"))? }; match Self::parse_frame(&raw)? { ParsedFrame::Whole { src, dst, payload } => { if dst != self.my_addr { continue; } return Ok(TransportPacket { from: TransportAddr::LoRa(src), data: payload, }); } ParsedFrame::Frag { src, dst, frag_id, idx, total, chunk, } => { if dst != self.my_addr { continue; } let mut asm = self.assembler.lock().await; if let Some(complete) = asm.push(src, frag_id, idx, total, chunk)? { return Ok(TransportPacket { from: TransportAddr::LoRa(src), data: complete, }); } } } } } async fn close(&self) -> Result<()> { self.medium.unregister(self.my_addr).await; Ok(()) } } fn random_frag_id() -> u32 { use rand::Rng; rand::thread_rng().gen::() } /// Split `data` into chunks suitable for a transport with `max_payload` bytes per frame (application layer). pub fn split_for_mtu(data: &[u8], max_payload: usize) -> Vec<&[u8]> { if max_payload == 0 { return vec![data]; } data.chunks(max_payload).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn airtime_increases_with_sf() { let mut low = LoRaConfig::default(); low.spreading_factor = 7; let mut high = LoRaConfig::default(); high.spreading_factor = 12; let n = 64; assert!(lora_airtime_ms(n, &high) >= lora_airtime_ms(n, &low)); } #[tokio::test] async fn mock_roundtrip() { let medium = LoRaMockMedium::new(); let duty = Arc::new(DutyCycleTracker::new(3600 * 1000)); let a = medium .connect([1, 0, 0, 0], LoRaConfig::default(), Arc::clone(&duty)) .await .expect("connect a"); let b = medium .connect([2, 0, 0, 0], LoRaConfig::default(), Arc::clone(&duty)) .await .expect("connect b"); let dest = TransportAddr::LoRa([2, 0, 0, 0]); let payload = b"mesh-over-lora"; let recv_h = tokio::spawn(async move { let pkt = b.recv().await.expect("recv"); assert_eq!(pkt.data, payload.to_vec()); match pkt.from { TransportAddr::LoRa(addr) => assert_eq!(addr, [1, 0, 0, 0]), _ => panic!("expected LoRa from-address"), } b.close().await.expect("close b"); }); tokio::time::sleep(Duration::from_millis(20)).await; a.send(&dest, payload).await.expect("send"); recv_h.await.expect("join"); a.close().await.expect("close a"); } #[tokio::test] async fn fragmentation_roundtrip() { let medium = LoRaMockMedium::new(); let duty = Arc::new(DutyCycleTracker::new(3600 * 1000)); let mut cfg = LoRaConfig::default(); cfg.max_frame_len = Some(48); let a = medium .connect([0x10, 0, 0, 0], cfg.clone(), Arc::clone(&duty)) .await .expect("a"); let b = medium .connect([0x20, 0, 0, 0], cfg, Arc::clone(&duty)) .await .expect("b"); let dest = TransportAddr::LoRa([0x20, 0, 0, 0]); let payload: Vec = (0u8..200).collect(); let expected = payload.clone(); let recv_h = tokio::spawn(async move { let pkt = b.recv().await.expect("recv"); assert_eq!(pkt.data, expected); b.close().await.ok(); }); tokio::time::sleep(Duration::from_millis(20)).await; a.send(&dest, &payload).await.expect("send frag"); recv_h.await.expect("join"); a.close().await.ok(); } #[tokio::test] async fn duty_cycle_records_airtime() { let duty = Arc::new(DutyCycleTracker::new(100_000)); duty.acquire(55).await; let used = duty.used_ms_in_window().await; assert!(used >= 55, "expected recorded airtime, got {used}"); } #[test] fn split_for_mtu_chunks() { let data = [1u8, 2, 3, 4, 5]; let parts = split_for_mtu(&data, 2); assert_eq!(parts.len(), 3); assert_eq!(parts[0], &[1, 2][..]); assert_eq!(parts[1], &[3, 4][..]); assert_eq!(parts[2], &[5][..]); } }