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:
swaits committed 2026-04-25 20:15:44 -06:00
1 parent 23ddc15557
commit d262abe853
6 files changed
+370 -21

No files matched your search

+50 -8
View File
@@ -47,7 +47,11 @@ use crate::protocol::{
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>;
@@ -152,6 +156,11 @@ pub struct RadioContext {
/// `ResumingRx`. Reset on any `StartRxOk`. See `MAX_RESUME_RETRIES`
/// for the limit before falling through to `Idle`.
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 {
@@ -259,7 +268,7 @@ statechart! {
default(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) => Idle;
on(ChannelBusy) => respond_tx_channel_busy, Idle;
@@ -267,7 +276,7 @@ statechart! {
}
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) => ResumingRx;
// 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")]
crate::debug_blink::set(6);
let Some(tx) = pending.take() else {
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();
match do_tx(lora, &tx).await {
match do_tx(lora, &tx, retries, sleep_us).await {
Ok(TxOutcome::Transmitted) => RadioInput::TransmitDone {
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 {
// Hardware failure: drop to Unconfigured (spec §6.3).
self.config = None;
self.cad_retry_delay_us = 0;
self.send(tag, DeviceMessage::Err(ErrorCode::ERadio)).await;
let _ = self.emit(RadioInput::ConfigFailedRadio);
return;
}
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.announce(RadioEvent::ConfigChanged(cfg));
let result = SetConfigResult {
@@ -707,7 +734,12 @@ async fn reconfigure_radio(lora: &mut LoRaDriver, cfg: &LoRaConfig) -> Result<()
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)?;
if !tx.skip_cad {
@@ -719,13 +751,22 @@ async fn do_tx(lora: &mut LoRaDriver, tx: &PendingTx) -> Result<TxOutcome, Radio
#[cfg(feature = "debug-checkpoint")]
crate::debug_blink::set(19);
let mut saw_activity = false;
for _ in 0..CAD_MAX_RETRIES {
for i in 0..retries {
if !lora.cad(&mdltn).await? {
saw_activity = false;
break;
}
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?;
}
if saw_activity {
@@ -830,6 +871,7 @@ pub async fn radio_task(
last_tx_tag: None,
pending_rx_start_tag: None,
resume_retries: 0,
cad_retry_delay_us: 0,
};
static RADIO_INTERNAL_CH: embassy_sync::channel::Channel<