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.
This commit is contained in:
6 files changed
+370
-21
No files matched your search
Generated
+5
@@ -1733,6 +1733,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "lora-modulation"
|
name = "lora-modulation"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defmt 0.3.100",
|
"defmt 0.3.100",
|
||||||
]
|
]
|
||||||
@@ -1740,6 +1741,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "lora-phy"
|
name = "lora-phy"
|
||||||
version = "3.0.2-alpha"
|
version = "3.0.2-alpha"
|
||||||
|
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defmt 0.3.100",
|
"defmt 0.3.100",
|
||||||
"document-features",
|
"document-features",
|
||||||
@@ -1753,6 +1755,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "lorawan"
|
name = "lorawan"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
|
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cmac",
|
"cmac",
|
||||||
@@ -1764,6 +1767,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "lorawan-device"
|
name = "lorawan-device"
|
||||||
version = "0.12.2"
|
version = "0.12.2"
|
||||||
|
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"defmt 0.3.100",
|
"defmt 0.3.100",
|
||||||
"document-features",
|
"document-features",
|
||||||
@@ -1778,6 +1782,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "lorawan-macros"
|
name = "lorawan-macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/swaits/lora-rs.git?branch=fix%2Fsx126x-busy-race#f9ebb303a7029cbc3f19f0760ae164946000b152"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+4
-9
@@ -32,7 +32,10 @@ ucobs = { version = "0.3", features = ["legacy-msrv"] }
|
|||||||
heapless = "0.9"
|
heapless = "0.9"
|
||||||
donglora-protocol = "1"
|
donglora-protocol = "1"
|
||||||
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
|
hsmc = { version = "0.2", default-features = false, features = ["embassy"] }
|
||||||
lora-phy = { version = "3.0.2-alpha", features = ["defmt-03"] } # matches the in-tree [patch.crates-io] override; drop the pre-release selector once upstream releases the fix
|
# Pulled from swaits/lora-rs fork until lora-rs/lora-rs#428 (pre-command
|
||||||
|
# BUSY check) is merged + released on crates.io. Switch back to a normal
|
||||||
|
# crates.io version then.
|
||||||
|
lora-phy = { git = "https://github.com/swaits/lora-rs.git", branch = "fix/sx126x-busy-race", features = ["defmt-03"] }
|
||||||
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
portable-atomic = { version = "1", optional = true, default-features = false, features = ["critical-section"] }
|
||||||
static_cell = "2.1"
|
static_cell = "2.1"
|
||||||
|
|
||||||
@@ -212,14 +215,6 @@ esp-rtos = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main"
|
|||||||
esp-backtrace = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
esp-backtrace = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||||
esp-println = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
esp-println = { git = "https://github.com/esp-rs/esp-hal.git", branch = "main" }
|
||||||
|
|
||||||
# Local fix for a BUSY-assertion race: lora-phy currently polls BUSY
|
|
||||||
# *after* each SPI op, but SX1261-2 §8.1.4 specifies a *pre*-command
|
|
||||||
# check (same pattern as RadioLib's SPIwriteStream). This override pulls
|
|
||||||
# in the corrected ordering and renames `wait_on_busy` to
|
|
||||||
# `wait_until_ready`. See TODO.md for the PR body. Remove this line once
|
|
||||||
# the fix is merged upstream + released on crates.io.
|
|
||||||
lora-phy = { path = "../../lora-rs/lora-phy" }
|
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
undocumented_unsafe_blocks = "deny"
|
undocumented_unsafe_blocks = "deny"
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ pointing at `../../lora-rs/lora-phy`; drop the patch line + pre-release
|
|||||||
selector once upstream releases.
|
selector once upstream releases.
|
||||||
|
|
||||||
Hardware-verified on Wio Tracker L1: 20/20 clean-boot TX after the fix
|
Hardware-verified on Wio Tracker L1: 20/20 clean-boot TX after the fix
|
||||||
(vs ~50% before). Additive fix: adds pre-command BUSY check, keeps
|
(vs ~50% before). Adds a pre-command BUSY check before each
|
||||||
existing post-command wait in place.
|
SpiInterface read/write op, keeping the existing post-command wait in
|
||||||
|
place. Belt-and-suspenders bracketing: pre-wait per spec, post-wait
|
||||||
|
closes the BUSY-assertion latency window (T_SW ≤ 600 ns) that a
|
||||||
|
single level-poll can miss.
|
||||||
|
|
||||||
### PR body
|
### PR body
|
||||||
|
|
||||||
@@ -44,9 +47,27 @@ On fast MCUs (nRF52840 at 64 MHz, release build) this is racy — `embedded-hal-
|
|||||||
|
|
||||||
## Fix
|
## Fix
|
||||||
|
|
||||||
Replace the post-command BUSY wait with a pre-command BUSY check at the start of each `SpiInterface` / `Lr1110SpiInterface` read/write method. Drop the redundant explicit `wait_on_busy()` calls in `sx126x` (after Calibrate, `ensure_ready` non-sleep arm) and `lr1110::mod` (4 regmem/bootloader sites) — all now covered by the interface pre-wait.
|
Bracket each `SpiInterface` / `Lr1110SpiInterface` read/write op with
|
||||||
|
both a pre- and post-command BUSY wait. The pre-wait is the new
|
||||||
|
addition required by the spec; the existing post-wait is preserved as
|
||||||
|
the belt that closes the BUSY-assertion latency window.
|
||||||
|
|
||||||
The `is_sleep_command` flag keeps its name but now gates the pre-wait; set `true` only when BUSY is held HIGH by design (the wake-from-sleep GetStatus pulse in `sx126x::ensure_ready`). Two callsite flips as a result: `ensure_ready` Sleep arm `false`→`true`; `set_sleep` `true`→`false`.
|
Why pre-wait alone isn't enough: `embedded-hal-async`'s
|
||||||
|
`wait_for_low` returns immediately when the pin is already low. The
|
||||||
|
chip takes up to T_SW ns to assert BUSY after NSS rises, so a single
|
||||||
|
polling chance — at any side of the SPI op — can see a stale LOW and
|
||||||
|
race the next command into a still-busy chip. The repro is
|
||||||
|
back-to-back writes inside `sx126x::do_cad` (SetCADParams immediately
|
||||||
|
followed by SetCAD): pre-wait alone races, SetCAD is dropped, CADDone
|
||||||
|
never fires, `lora.cad()` hangs forever. Keeping the post-wait gives a
|
||||||
|
second polling chance after enough time has elapsed for BUSY to be
|
||||||
|
reliably observable.
|
||||||
|
|
||||||
|
The `is_sleep_command` flag now gates both waits. One callsite flip:
|
||||||
|
`sx126x::ensure_ready`'s wake-from-sleep GetStatus pulse passes
|
||||||
|
`is_sleep_command=true` so the pre-wait doesn't hang on the
|
||||||
|
BUSY-stuck-HIGH chip-asleep state. The next SPI op's pre-wait absorbs
|
||||||
|
the wake-completion latency.
|
||||||
|
|
||||||
No public API signature changes.
|
No public API signature changes.
|
||||||
|
|
||||||
|
|||||||
+285
@@ -0,0 +1,285 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ mod driver;
|
|||||||
mod hal;
|
mod hal;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod host;
|
mod host;
|
||||||
|
mod lbt;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
mod radio;
|
mod radio;
|
||||||
|
|||||||
+50
-8
@@ -47,7 +47,11 @@ use crate::protocol::{
|
|||||||
SetConfigResultCode, TxDonePayload, TxResult, MAX_OTA_PAYLOAD,
|
SetConfigResultCode, TxDonePayload, TxResult, MAX_OTA_PAYLOAD,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CAD_MAX_RETRIES: u8 = 10;
|
// LBT retry timing lives in `crate::lbt` — the math is pure and
|
||||||
|
// host-testable, so it's split out of this (HW-gated) module.
|
||||||
|
use crate::lbt::{
|
||||||
|
cad_retries, jittered_sleep_us, toa_us, CAD_ASSUMED_PEER_PAYLOAD_B, CAD_BUDGET_US,
|
||||||
|
};
|
||||||
|
|
||||||
type LoRaDriver = LoRa<RadioDriver, Delay>;
|
type LoRaDriver = LoRa<RadioDriver, Delay>;
|
||||||
|
|
||||||
@@ -152,6 +156,11 @@ pub struct RadioContext {
|
|||||||
/// `ResumingRx`. Reset on any `StartRxOk`. See `MAX_RESUME_RETRIES`
|
/// `ResumingRx`. Reset on any `StartRxOk`. See `MAX_RESUME_RETRIES`
|
||||||
/// for the limit before falling through to `Idle`.
|
/// for the limit before falling through to `Idle`.
|
||||||
pub resume_retries: u8,
|
pub resume_retries: u8,
|
||||||
|
/// Per-CAD-retry sleep in microseconds: time-on-air for a
|
||||||
|
/// `CAD_ASSUMED_PEER_PAYLOAD_B`-byte packet under the current
|
||||||
|
/// config. Recomputed on every successful `SET_CONFIG`; `0` while
|
||||||
|
/// `Unconfigured` (the CAD path is unreachable without a config).
|
||||||
|
pub cad_retry_delay_us: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RadioContext {
|
impl RadioContext {
|
||||||
@@ -259,7 +268,7 @@ statechart! {
|
|||||||
default(TransmittingFromIdle);
|
default(TransmittingFromIdle);
|
||||||
|
|
||||||
state TransmittingFromIdle {
|
state TransmittingFromIdle {
|
||||||
during: perform_tx(lora, pending_tx);
|
during: perform_tx(lora, pending_tx, cad_retry_delay_us);
|
||||||
on(TransmitDone { airtime_us: u32 }) => respond_tx_done;
|
on(TransmitDone { airtime_us: u32 }) => respond_tx_done;
|
||||||
on(TransmitDone) => Idle;
|
on(TransmitDone) => Idle;
|
||||||
on(ChannelBusy) => respond_tx_channel_busy, Idle;
|
on(ChannelBusy) => respond_tx_channel_busy, Idle;
|
||||||
@@ -267,7 +276,7 @@ statechart! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state TransmittingFromRx {
|
state TransmittingFromRx {
|
||||||
during: perform_tx(lora, pending_tx);
|
during: perform_tx(lora, pending_tx, cad_retry_delay_us);
|
||||||
on(TransmitDone { airtime_us: u32 }) => respond_tx_done;
|
on(TransmitDone { airtime_us: u32 }) => respond_tx_done;
|
||||||
on(TransmitDone) => ResumingRx;
|
on(TransmitDone) => ResumingRx;
|
||||||
// Even on failure, we were receiving before the TX —
|
// Even on failure, we were receiving before the TX —
|
||||||
@@ -368,14 +377,23 @@ async fn next_packet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn perform_tx(lora: &mut LoRaDriver, pending: &mut Option<PendingTx>) -> RadioInput {
|
async fn perform_tx(
|
||||||
|
lora: &mut LoRaDriver,
|
||||||
|
pending: &mut Option<PendingTx>,
|
||||||
|
cad_retry_delay_us: &mut u32,
|
||||||
|
) -> RadioInput {
|
||||||
#[cfg(feature = "debug-checkpoint")]
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
crate::debug_blink::set(6);
|
crate::debug_blink::set(6);
|
||||||
let Some(tx) = pending.take() else {
|
let Some(tx) = pending.take() else {
|
||||||
return RadioInput::TransmitFailed;
|
return RadioInput::TransmitFailed;
|
||||||
};
|
};
|
||||||
|
// Scale CAD retry count to the cached per-retry sleep: fewer retries
|
||||||
|
// when each one-full-packet wait is expensive (slow SFs). When the
|
||||||
|
// one-packet wait exceeds the total budget, the helper returns
|
||||||
|
// `retries = 1` — a single CAD, bail on busy, no sleep.
|
||||||
|
let (retries, sleep_us) = cad_retries(*cad_retry_delay_us);
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
match do_tx(lora, &tx).await {
|
match do_tx(lora, &tx, retries, sleep_us).await {
|
||||||
Ok(TxOutcome::Transmitted) => RadioInput::TransmitDone {
|
Ok(TxOutcome::Transmitted) => RadioInput::TransmitDone {
|
||||||
airtime_us: Instant::now().duration_since(start).as_micros() as u32,
|
airtime_us: Instant::now().duration_since(start).as_micros() as u32,
|
||||||
},
|
},
|
||||||
@@ -427,11 +445,20 @@ impl RadioActions for RadioActionContext<'_> {
|
|||||||
if let Err(_e) = reconfigure_radio(&mut self.lora, &cfg).await {
|
if let Err(_e) = reconfigure_radio(&mut self.lora, &cfg).await {
|
||||||
// Hardware failure: drop to Unconfigured (spec §6.3).
|
// Hardware failure: drop to Unconfigured (spec §6.3).
|
||||||
self.config = None;
|
self.config = None;
|
||||||
|
self.cad_retry_delay_us = 0;
|
||||||
self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await;
|
self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await;
|
||||||
let _ = self.emit(RadioInput::ConfigFailedRadio);
|
let _ = self.emit(RadioInput::ConfigFailedRadio);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.config = Some(cfg);
|
self.config = Some(cfg);
|
||||||
|
self.cad_retry_delay_us = toa_us(&cfg, CAD_ASSUMED_PEER_PAYLOAD_B);
|
||||||
|
if self.cad_retry_delay_us > CAD_BUDGET_US {
|
||||||
|
defmt::debug!(
|
||||||
|
"LBT degraded: assumed-packet ToA {=u32} us > budget {=u32} us; single-CAD-and-bail",
|
||||||
|
self.cad_retry_delay_us,
|
||||||
|
CAD_BUDGET_US
|
||||||
|
);
|
||||||
|
}
|
||||||
self.packets_dropped = 0;
|
self.packets_dropped = 0;
|
||||||
self.announce(RadioEvent::ConfigChanged(cfg));
|
self.announce(RadioEvent::ConfigChanged(cfg));
|
||||||
let result = SetConfigResult {
|
let result = SetConfigResult {
|
||||||
@@ -707,7 +734,12 @@ async fn reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn do_tx(lora: &mut LoRaDriver, tx: &PendingTx) -> Result<TxOutcome, RadioError> {
|
async fn do_tx(
|
||||||
|
lora: &mut LoRaDriver,
|
||||||
|
tx: &PendingTx,
|
||||||
|
retries: u8,
|
||||||
|
sleep_us: u32,
|
||||||
|
) -> Result<TxOutcome, RadioError> {
|
||||||
let mdltn = to_lora_mod_params(lora, &tx.config)?;
|
let mdltn = to_lora_mod_params(lora, &tx.config)?;
|
||||||
|
|
||||||
if !tx.skip_cad {
|
if !tx.skip_cad {
|
||||||
@@ -719,13 +751,22 @@ async fn do_tx(lora: &mut LoRaDriver, tx: &PendingTx) -> Result<TxOutcome, Radio
|
|||||||
#[cfg(feature = "debug-checkpoint")]
|
#[cfg(feature = "debug-checkpoint")]
|
||||||
crate::debug_blink::set(19);
|
crate::debug_blink::set(19);
|
||||||
let mut saw_activity = false;
|
let mut saw_activity = false;
|
||||||
for _ in 0..CAD_MAX_RETRIES {
|
for i in 0..retries {
|
||||||
if !lora.cad(&mdltn).await? {
|
if !lora.cad(&mdltn).await? {
|
||||||
saw_activity = false;
|
saw_activity = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
saw_activity = true;
|
saw_activity = true;
|
||||||
embassy_time::Timer::after_millis(10).await;
|
// Skip the trailing sleep: if the last CAD was busy, we're
|
||||||
|
// about to return ChannelBusy, so sleeping + re-preparing
|
||||||
|
// is wasted work.
|
||||||
|
if i + 1 == retries {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let entropy = embassy_time::Instant::now().as_ticks() as u32;
|
||||||
|
let jittered_us = jittered_sleep_us(sleep_us, entropy);
|
||||||
|
embassy_time::Timer::after(embassy_time::Duration::from_micros(jittered_us as u64))
|
||||||
|
.await;
|
||||||
lora.prepare_for_cad(&mdltn).await?;
|
lora.prepare_for_cad(&mdltn).await?;
|
||||||
}
|
}
|
||||||
if saw_activity {
|
if saw_activity {
|
||||||
@@ -830,6 +871,7 @@ pub async fn radio_task(
|
|||||||
last_tx_tag: None,
|
last_tx_tag: None,
|
||||||
pending_rx_start_tag: None,
|
pending_rx_start_tag: None,
|
||||||
resume_retries: 0,
|
resume_retries: 0,
|
||||||
|
cad_retry_delay_us: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
static RADIO_INTERNAL_CH: embassy_sync::channel::Channel<
|
static RADIO_INTERNAL_CH: embassy_sync::channel::Channel<
|
||||||
|
|||||||
Reference in new issue
Block a user