Preserve OTA block progress and channel checks during radio recovery

Drain received packets in single-profile radio fault backoff as well as
dual-profile mode. Share the normal channel-clear and bounded busy policy
with driver retries so a failed send cannot retry blindly or bypass the
existing single-retry limit.

Retain an unadmitted OTA request flight and count v2 fallback evidence only
after a sparse retry enters the transmit queue. Local queue congestion no
longer disables v2 before a request was sent or discards received fragments.

Include primary airtime when temporary RX-only radio2 crosses requests onto
that channel, keeping response allowances conservative on slower profiles.

Add six native regressions, including five failing-before cases, and document
the queue-admission and crossover timing behavior.

Validation: 1,502 native tests, 29 integration checks, and five firmware
builds passed across ESP32-S3, nRF52, and STM32.
This commit is contained in:
mikecarper committed 2026-09-15 07:54:16 -07:00
1 parent 0582366527
commit dde87f1d15
9 files changed
+216 -44

No files matched your search

+45 -35
View File
@@ -355,7 +355,7 @@ void Dispatcher::loop() {
// The failed send has already returned the chip to RX. Drain a packet
// arriving during backoff before asking to retune; otherwise BUSY can
// keep the retry waiting forever on an unread RxDone interrupt.
if (isDualRadioActive()) checkRecv();
checkRecv();
// A cancelled or expired packet must retire even if the chip never
// becomes available for another retune (including an armed OTA reboot).
if (!isPacketRadioCurrent(outbound) || !allowPacketTransmit(outbound)) {
@@ -370,16 +370,20 @@ void Dispatcher::loop() {
return;
}
outbound_radio_retry_pending = false;
if (prepared == RadioParamApplyResult::FAILED || !isPacketRadioCurrent(outbound)
|| !allowPacketTransmit(outbound)) {
MESH_DEBUG_PRINTLN("%s Dispatcher::loop(): radio retry packet no longer allowed, type=%u",
getLogDateTime(), (uint32_t)outbound->getPayloadType());
failOutboundTransmit();
} else if (!startOutboundTransmit()) {
failOutboundTransmit();
} else {
return;
uint32_t retry_delay;
if (!isTransmitChannelReady(outbound, retry_delay)) {
outbound_radio_retry_at = futureMillis(retry_delay);
return;
}
outbound_radio_retry_pending = false;
if (!allowPacketTransmit(outbound) || !startOutboundTransmit()) failOutboundTransmit();
else return;
}
} else if (_radio->isSendComplete()) {
long t = _ms->getMillis() - outbound_start;
@@ -716,6 +720,37 @@ void Dispatcher::processRecvPacket(Packet* pkt) {
}
}
bool Dispatcher::isTransmitChannelReady(const Packet* packet, uint32_t& retry_delay) {
const bool busy = packet != NULL && usePassiveChannelCheck(packet)
? _radio->isReceivingPassive(getRetryInterferenceMargin())
: _radio->isReceiving();
const uint8_t profile = packet ? packet->radio_profile : 0;
uint32_t& profile_busy = profile_cad_busy[profile];
if (busy) {
const uint32_t now = _ms->getMillis();
// A driver retry owns its packet outside the queue, but remains one of
// the packets waiting for the channel's bounded busy allowance.
const int ready_count = _mgr->getOutboundCount(now) + (packet && outbound == packet ? 1 : 0);
cad_busy_start = profile_busy;
if (cad_busy_start == 0) {
cad_busy_start = now;
profile_busy = now;
}
const uint32_t maximum = scaleCADDelayForQueue(
getCADFailMaxDuration(), ready_count, MIN_CAD_FAIL_MAX_DURATION_MS);
if (now - cad_busy_start <= maximum) {
retry_delay = scaleCADDelayForQueue(
getCADFailRetryDelay(), ready_count, MIN_CAD_FAIL_RETRY_DELAY_MS);
return false;
}
_err_flags |= ERR_EVENT_CAD_TIMEOUT;
MESH_DEBUG_PRINTLN("%s Dispatcher: CAD busy max duration reached!", getLogDateTime());
}
cad_busy_start = 0;
profile_busy = 0;
return true;
}
void Dispatcher::checkSend() {
if (_radio->isCarrierWaveActive()) return;
const uint32_t now = _ms->getMillis();
@@ -789,38 +824,13 @@ void Dispatcher::checkSend() {
}
}
bool channel_busy = pending != NULL && usePassiveChannelCheck(pending)
? _radio->isReceivingPassive(getRetryInterferenceMargin())
: _radio->isReceiving();
if (channel_busy) {
const uint32_t cad_now = _ms->getMillis();
const int ready_count = _mgr->getOutboundCount(cad_now);
uint32_t& profile_busy = profile_cad_busy[pending ? pending->radio_profile : 0];
cad_busy_start = profile_busy;
if (cad_busy_start == 0) {
cad_busy_start = cad_now; // record when CAD busy state started
profile_busy = cad_now;
}
const uint32_t max_busy_duration = scaleCADDelayForQueue(
getCADFailMaxDuration(), ready_count, MIN_CAD_FAIL_MAX_DURATION_MS);
if (cad_now - cad_busy_start > max_busy_duration) {
_err_flags |= ERR_EVENT_CAD_TIMEOUT;
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): CAD busy max duration reached!", getLogDateTime());
// channel activity has gone on too long... (Radio might be in a bad state)
// force the pending transmit below...
} else {
const uint32_t retry_delay = scaleCADDelayForQueue(
getCADFailRetryDelay(), ready_count, MIN_CAD_FAIL_RETRY_DELAY_MS);
if (!isDualRadioActive() || !pending || !_mgr->deferOutbound(pending, futureMillis(retry_delay))) {
next_tx_time = futureMillis(retry_delay);
}
return;
uint32_t retry_delay;
if (!isTransmitChannelReady(pending, retry_delay)) {
if (!isDualRadioActive() || !pending || !_mgr->deferOutbound(pending, futureMillis(retry_delay))) {
next_tx_time = futureMillis(retry_delay);
}
return;
}
cad_busy_start = 0; // reset busy state
profile_cad_busy[pending ? pending->radio_profile : 0] = 0;
// Retuning and CAD can advance the clock. Keep the selection timestamp so
// a newly due higher-priority packet cannot skip its own profile, airtime,