The CAD pre-TX loop previously ran up to 10 retries with a fixed 10 ms
sleep — roughly one max-length packet only at SF7/BW500. At SF10/BW125
the 100 ms total was ~4% of one packet; at SF12/BW125 LBT was a no-op.
Now the per-retry sleep is sized to one time-on-air of an assumed-peer
80-byte packet under the active config, cached on RadioContext and
recomputed on every SET_CONFIG. Retries scale as
clamp(floor(BUDGET/T)+1, 1, 10) with BUDGET=2.5 s, so fast configs
still get up to 10 retries while SF11/12 degrade to single-CAD-and-bail
(logged at config-apply). Sleeps are jittered ±20% per iteration to
decorrelate simultaneously-backing-off contenders.
The ToA math, CR→{1..4} mapping, retry-count derivation, and jitter
helper live in a pure src/lbt.rs module so they're host-testable via
DONGLORA_HOST_TEST=1 cargo test (13 tests).
Validated on-device: 20 iterations of the AI Bot test flow that
previously saw ~2 ChannelBusy errors now see zero at the same SF/BW.
286 lines
11 KiB
Rust
286 lines
11 KiB
Rust
//! Pure-math helpers for Listen-Before-Talk (CAD) retry timing.
|
||
//!
|
||
//! The CAD retry loop in `radio::do_tx` scales its backoff to the active
|
||
//! LoRa configuration: per-retry sleep is one time-on-air of an assumed
|
||
//! peer packet, total retry budget is capped at `CAD_BUDGET_US`. This
|
||
//! module owns the ToA math (Semtech AN1200.13) and the retry-count
|
||
//! derivation, split out so it can be unit-tested host-side without the
|
||
//! radio module's hardware dependencies.
|
||
|
||
use crate::protocol::{LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
|
||
|
||
/// Total LBT budget: the maximum time the CAD retry loop is allowed to
|
||
/// tie up the radio before returning `ChannelBusy`.
|
||
pub const CAD_BUDGET_US: u32 = 2_500_000;
|
||
/// Upper bound on retries, independent of budget — caps the work on
|
||
/// very fast configs (SF7/BW500 would otherwise get 60+ retries).
|
||
pub const CAD_HARD_CAP: u8 = 10;
|
||
/// Assumed peer-packet payload (bytes) used to size the per-retry sleep.
|
||
/// 80 is a representative median for typical mesh traffic. Peers sending
|
||
/// larger packets may be partially under-waited (accepted trade-off —
|
||
/// host-level retry handles collisions).
|
||
#[allow(dead_code)] // consumed by `radio.rs`, which is gated out of test builds
|
||
pub const CAD_ASSUMED_PEER_PAYLOAD_B: u16 = 80;
|
||
/// Jitter applied to each sleep, in tenths of a percent (±20 % = 200).
|
||
pub const CAD_JITTER_TENTHS_PCT: u32 = 200;
|
||
|
||
/// ToA formula expects CR ∈ {1..4} for 4/5..4/8 (each coded symbol carries
|
||
/// `CR+4` bits). The protocol enum starts at 0, so map explicitly — using
|
||
/// `as u32` alone would underestimate airtime by one code bit everywhere.
|
||
pub fn cr_numeric(cr: LoRaCodingRate) -> u32 {
|
||
match cr {
|
||
LoRaCodingRate::Cr4_5 => 1,
|
||
LoRaCodingRate::Cr4_6 => 2,
|
||
LoRaCodingRate::Cr4_7 => 3,
|
||
LoRaCodingRate::Cr4_8 => 4,
|
||
}
|
||
}
|
||
|
||
/// Worst-case LoRa time-on-air in microseconds (Semtech AN1200.13).
|
||
///
|
||
/// Uses `N_preamble + 4.25` quarter-symbols for preamble duration; SX126x
|
||
/// uses 6.25 at SF5/SF6, so the result is low by 2·T_sym at those SFs
|
||
/// (sub-millisecond — negligible at the LBT-budget scale).
|
||
pub fn toa_us(cfg: &LoRaConfig, payload_len: u16) -> u32 {
|
||
let sf = cfg.sf as u64;
|
||
let bw_hz = cfg.bw.as_hz() as u64;
|
||
// Symbol time in µs: (2^SF / BW_Hz) * 1e6. u64 avoids overflow at
|
||
// SF12/BW7 where T_sym ≈ 524 ms.
|
||
let t_sym_us = (1u64 << sf) * 1_000_000 / bw_hz.max(1);
|
||
let de: i64 = if t_sym_us > 16_000 { 1 } else { 0 };
|
||
let crc: i64 = if cfg.payload_crc { 1 } else { 0 };
|
||
let h: i64 = if matches!(cfg.header_mode, LoRaHeaderMode::Implicit) {
|
||
1
|
||
} else {
|
||
0
|
||
};
|
||
let pl = payload_len as i64;
|
||
let sf_i = sf as i64;
|
||
|
||
// Payload symbol count (signed intermediates — `num` goes negative
|
||
// for small PL at SF12 with H=1, CRC=0).
|
||
let num = 8 * pl - 4 * sf_i + 28 + 16 * crc - 20 * h;
|
||
let den = 4 * (sf_i - 2 * de);
|
||
let payload_sym_ceil = if num <= 0 || den <= 0 {
|
||
0i64
|
||
} else {
|
||
(num + den - 1) / den
|
||
};
|
||
let cr_num = cr_numeric(cfg.cr) as i64;
|
||
let n_payload = 8 + (payload_sym_ceil * (cr_num + 4)).max(0);
|
||
let t_payload_us = n_payload as u64 * t_sym_us;
|
||
|
||
// Preamble duration: (N + 4.25) * T_sym = (4·N + 17) * T_sym / 4.
|
||
let t_preamble_us = (cfg.preamble_len as u64 * 4 + 17) * t_sym_us / 4;
|
||
|
||
(t_preamble_us + t_payload_us).min(u32::MAX as u64) as u32
|
||
}
|
||
|
||
/// Derive the CAD retry count and per-retry sleep from the cached
|
||
/// `cad_retry_delay_us`. Retries form: N CADs separated by N-1 sleeps.
|
||
/// When one assumed-packet wait exceeds the total budget, `retries = 1`
|
||
/// — a single CAD, bail on busy, no sleep.
|
||
pub fn cad_retries(cad_retry_delay_us: u32) -> (u8, u32) {
|
||
if cad_retry_delay_us == 0 {
|
||
// Unconfigured should never reach this path; fall back to a
|
||
// single CAD with no sleep rather than divide-by-zero.
|
||
return (1, 0);
|
||
}
|
||
let n_sleeps = CAD_BUDGET_US / cad_retry_delay_us;
|
||
let retries = (n_sleeps + 1).clamp(1, CAD_HARD_CAP as u32) as u8;
|
||
(retries, cad_retry_delay_us)
|
||
}
|
||
|
||
/// Apply ±`CAD_JITTER_TENTHS_PCT`/10 % jitter around `base_us`.
|
||
/// `entropy` is a raw 32-bit source; caller provides it so this stays
|
||
/// pure (radio task passes `Instant::now().as_ticks() as u32`).
|
||
pub fn jittered_sleep_us(base_us: u32, entropy: u32) -> u32 {
|
||
let mixed = entropy.wrapping_mul(0x9E37_79B9);
|
||
let span = 2 * CAD_JITTER_TENTHS_PCT + 1; // e.g. 401 for ±20 %
|
||
let r = mixed % span; // 0..=2·CAD_JITTER_TENTHS_PCT
|
||
// factor_thousandths ∈ [1000 − CAD_JITTER_TENTHS_PCT, 1000 + CAD_JITTER_TENTHS_PCT]
|
||
let factor_thousandths = 1000 - CAD_JITTER_TENTHS_PCT + r;
|
||
((base_us as u64 * factor_thousandths as u64) / 1000) as u32
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::protocol::{LoRaBandwidth, LoRaCodingRate, LoRaConfig, LoRaHeaderMode};
|
||
|
||
fn cfg(sf: u8, bw: LoRaBandwidth, cr: LoRaCodingRate, preamble: u16, implicit: bool, crc: bool) -> LoRaConfig {
|
||
LoRaConfig {
|
||
freq_hz: 915_000_000,
|
||
sf,
|
||
bw,
|
||
cr,
|
||
preamble_len: preamble,
|
||
sync_word: 0x1424,
|
||
tx_power_dbm: 0,
|
||
header_mode: if implicit {
|
||
LoRaHeaderMode::Implicit
|
||
} else {
|
||
LoRaHeaderMode::Explicit
|
||
},
|
||
payload_crc: crc,
|
||
iq_invert: false,
|
||
}
|
||
}
|
||
|
||
/// Helper: assert `actual` is within `tolerance_pct`% of `expected_us`.
|
||
fn approx(actual_us: u32, expected_us: u32, tolerance_pct: u32) {
|
||
let diff = actual_us.abs_diff(expected_us);
|
||
let tol = expected_us * tolerance_pct / 100;
|
||
assert!(
|
||
diff <= tol,
|
||
"actual {} us not within {}% of expected {} us (diff {}, tol {})",
|
||
actual_us,
|
||
tolerance_pct,
|
||
expected_us,
|
||
diff,
|
||
tol
|
||
);
|
||
}
|
||
|
||
// Reference values sanity-checked against Semtech's LoRa calculator
|
||
// and AN1200.13.
|
||
|
||
#[test]
|
||
fn toa_sf7_bw125_cr45_pl10_explicit_crc() {
|
||
let c = cfg(7, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||
// Hand-computed reference from AN1200.13:
|
||
// T_sym = 128/125 kHz = 1.024 ms
|
||
// T_preamble = (8 + 4.25) · 1.024 = 12.544 ms
|
||
// n_payload = 8 + ceil((80 − 28 + 28 + 16) / 28) · 5 = 8 + 4·5 = 28
|
||
// T_payload = 28 · 1.024 = 28.672 ms
|
||
// Total ≈ 41.22 ms
|
||
approx(toa_us(&c, 10), 41_216, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn toa_sf12_bw125_cr45_pl10_explicit_crc() {
|
||
let c = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||
// Reference: ~991 ms for PL=10 at SF12/BW125/CR4/5 (LDRO on).
|
||
approx(toa_us(&c, 10), 991_000, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn toa_sf10_bw125_cr45_pl255_max() {
|
||
let c = cfg(10, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||
// Reference: ~2.34 s for PL=255 at SF10/BW125/CR4/5.
|
||
approx(toa_us(&c, 255), 2_335_000, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn toa_ldro_activates_at_sf11_and_sf12_bw125() {
|
||
// T_sym > 16 ms → DE=1. SF11/BW125: T_sym = 16.384 ms. SF10/BW125: 8.192 ms.
|
||
let sf10 = cfg(10, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||
let sf11 = cfg(11, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||
let sf12 = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, false, true);
|
||
// Crude check: each step roughly doubles airtime (LDRO doesn't
|
||
// break the doubling trend — it just shifts constants).
|
||
let t10 = toa_us(&sf10, 80);
|
||
let t11 = toa_us(&sf11, 80);
|
||
let t12 = toa_us(&sf12, 80);
|
||
assert!(t11 > t10 * 15 / 10, "SF11 should be > 1.5x SF10, got {} vs {}", t11, t10);
|
||
assert!(t12 > t11 * 15 / 10, "SF12 should be > 1.5x SF11, got {} vs {}", t12, t11);
|
||
}
|
||
|
||
#[test]
|
||
fn toa_assumed_peer_80b_matches_plan_table() {
|
||
let mk = |sf, bw| cfg(sf, bw, LoRaCodingRate::Cr4_5, 8, false, true);
|
||
// Plan table values (approximate, within 5%).
|
||
approx(toa_us(&mk(7, LoRaBandwidth::Khz500), 80), 36_000, 5);
|
||
approx(toa_us(&mk(7, LoRaBandwidth::Khz250), 80), 72_000, 5);
|
||
approx(toa_us(&mk(7, LoRaBandwidth::Khz125), 80), 144_000, 5);
|
||
approx(toa_us(&mk(8, LoRaBandwidth::Khz125), 80), 257_000, 5);
|
||
approx(toa_us(&mk(9, LoRaBandwidth::Khz125), 80), 452_000, 5);
|
||
approx(toa_us(&mk(10, LoRaBandwidth::Khz125), 80), 862_000, 5);
|
||
approx(toa_us(&mk(11, LoRaBandwidth::Khz125), 80), 1_806_000, 5);
|
||
approx(toa_us(&mk(12, LoRaBandwidth::Khz125), 80), 3_285_000, 5);
|
||
}
|
||
|
||
#[test]
|
||
fn toa_no_underflow_at_small_pl_sf12_implicit_no_crc() {
|
||
// num = 8·PL − 4·SF + 28 + 16·CRC − 20·H goes negative here; the
|
||
// i64 + max(0) guard must kick in without panicking or wrapping.
|
||
let c = cfg(12, LoRaBandwidth::Khz125, LoRaCodingRate::Cr4_5, 8, true, false);
|
||
let t = toa_us(&c, 1);
|
||
// Only 8 payload symbols survive (the formula floors to 0 extra);
|
||
// should be preamble + 8·T_sym ≈ 401 + 262 = 663 ms roughly.
|
||
approx(t, 663_000, 5);
|
||
}
|
||
|
||
// Retry-count derivation table from the plan.
|
||
|
||
#[test]
|
||
fn retries_sf7_bw500_hits_hard_cap() {
|
||
let (n, s) = cad_retries(36_000);
|
||
assert_eq!(n, 10);
|
||
assert_eq!(s, 36_000);
|
||
}
|
||
|
||
#[test]
|
||
fn retries_sf10_bw125() {
|
||
// T_assumed ≈ 862 ms; budget 2.5 s ⇒ 2 sleeps ⇒ 3 retries.
|
||
let (n, _) = cad_retries(862_000);
|
||
assert_eq!(n, 3);
|
||
}
|
||
|
||
#[test]
|
||
fn retries_sf11_bw125() {
|
||
// T_assumed ≈ 1.81 s; budget 2.5 s ⇒ 1 sleep ⇒ 2 retries.
|
||
let (n, _) = cad_retries(1_806_000);
|
||
assert_eq!(n, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn retries_sf12_bw125_degraded() {
|
||
// T_assumed ≈ 3.29 s > 2.5 s budget ⇒ 0 sleeps ⇒ 1 retry (bail on busy).
|
||
let (n, _) = cad_retries(3_285_000);
|
||
assert_eq!(n, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn retries_zero_delay_falls_back_to_single_cad() {
|
||
// Unconfigured safety: avoid divide-by-zero.
|
||
let (n, s) = cad_retries(0);
|
||
assert_eq!(n, 1);
|
||
assert_eq!(s, 0);
|
||
}
|
||
|
||
// Jitter sanity.
|
||
|
||
#[test]
|
||
fn jitter_stays_within_pm_20_pct() {
|
||
let base = 1_000_000u32;
|
||
// Exhaustive over the low byte — enough to cover the `% span` range.
|
||
let mut saw_low = false;
|
||
let mut saw_high = false;
|
||
for e in 0u32..=u16::MAX as u32 {
|
||
let j = jittered_sleep_us(base, e);
|
||
// ±20% ⇒ [800_000, 1_200_000].
|
||
assert!(
|
||
(800_000..=1_200_000).contains(&j),
|
||
"jitter {} us out of ±20% bounds for entropy {}",
|
||
j,
|
||
e
|
||
);
|
||
if j <= 850_000 {
|
||
saw_low = true;
|
||
}
|
||
if j >= 1_150_000 {
|
||
saw_high = true;
|
||
}
|
||
}
|
||
assert!(saw_low && saw_high, "jitter didn't span the expected range");
|
||
}
|
||
|
||
#[test]
|
||
fn jitter_zero_base_is_zero() {
|
||
for e in [0u32, 1, 42, 0xDEAD_BEEF] {
|
||
assert_eq!(jittered_sleep_us(0, e), 0);
|
||
}
|
||
}
|
||
}
|