Files
firmware/src/lbt.rs
T
swaits d262abe853 feat(radio): adaptive LBT — scale CAD retries to current LoRa config
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.
2026-04-25 20:15:44 -06:00

286 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}
}