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,
+1
View File
@@ -317,6 +317,7 @@ class Dispatcher {
void releaseDroppedOutbound();
bool startOutboundTransmit();
bool scheduleOutboundRadioRetry();
bool isTransmitChannelReady(const Packet* packet, uint32_t& retry_delay);
void failOutboundTransmit();
void restoreOutboundTxOverrides();
void updateTxBudget();
+5 -1
View File
@@ -640,7 +640,11 @@ uint32_t Mesh::getOtaPacketAirtime() const {
// Replies may occupy both profiles even with crossing off or radio2 RX-only.
uint8_t mask = (profiles->primary_temporary ? 1 : 0) | (profiles->secondary_temporary ? 2 : 0);
if (!mask) mask = 1;
if (profiles->canCross() && profiles->canTransmit(1)) mask |= 3;
// Each temporary origin can cross independently. In particular, an RX-only
// secondary can still send requests over the primary when crossing is on.
for (uint8_t origin = 0; origin < 2; ++origin) {
if (mask & (1U << origin)) mask |= profiles->transmitMask(origin);
}
if (profiles->reply_tx != RADIO_TX_AUTO) {
mask |= explicitRadioTxMask(profiles->reply_tx, profiles->canTransmit(1, profiles->reply_force));
}
+19 -6
View File
@@ -1092,6 +1092,7 @@ void OtaManager::clearReassembly() {
_req_count = 0;
_pipeline_width = OTA_FETCH_PIPELINE_INITIAL;
_flight_dirty = false;
_flight_request_pending = false;
noteFetchActivity();
}
@@ -1904,7 +1905,7 @@ bool OtaManager::requestFlight() {
item.block_idx = (uint16_t)slot.block;
item.want_mask = wireRequestMask(slot.block, slot.need, slot.mask, slot.encoded_len);
}
if (request.n_items == 0) return false;
if (request.n_items == 0) { _flight_request_pending = false; return false; }
_req_start = request.items[0].block_idx;
_req_count = request.n_items;
uint8_t b[5 + OTA_REQ_MAX_ITEMS * 4];
@@ -1912,6 +1913,7 @@ bool OtaManager::requestFlight() {
(unsigned)_req_start, (unsigned)_req_count,
(unsigned)_pipeline_width, (unsigned)OTA_FETCH_PIPELINE);
bool sent = emit(b, encode_req_window(b, sizeof(b), request), false);
_flight_request_pending = !sent;
if (sent) noteFetchActivity();
return sent;
}
@@ -1942,6 +1944,9 @@ bool OtaManager::fillPipeline() {
void OtaManager::requestMissing() {
if (_fstate != FETCHING) return;
if (activePipelineSlots() == 0) { fillPipeline(); return; }
// Admission backpressure is not evidence that the source ignored v2.
// Keep the original flight and retry it before evaluating network loss.
if (_flight_request_pending) { requestFlight(); return; }
// No v2 DATA at all means the request may have reached only deployed sources which ignored its profile
// bits (or the first response burst was lost). Retry once in the universal legacy profile instead of
@@ -1961,8 +1966,8 @@ void OtaManager::requestMissing() {
// Once v2 has been confirmed, the source can still disappear cleanly between blocks (or after a bad
// representation is discarded), leaving every active slot empty. Give the v2 flight one sparse retry,
// then clear the whole flight and ask again in the universal legacy profile so an old seeder can take over.
if (_wire_v2_session && _wire_v2_confirmed && !anyWireDataReceived() &&
++_wire_empty_stalls >= 2) {
const bool empty_v2 = _wire_v2_session && _wire_v2_confirmed && !anyWireDataReceived();
if (empty_v2 && _wire_empty_stalls >= 1) {
downgradeWireToLegacy();
requestFlight();
return;
@@ -1972,14 +1977,21 @@ void OtaManager::requestMissing() {
uint8_t slot = (uint8_t)((_retry_slot + offset) % OTA_FETCH_PIPELINE);
if (_reasm[slot].block == NO_BLOCK) continue;
ReassemblySlot& reassembly = _reasm[slot];
if (_wire_v2_session && reassembly.wire_v2 && reassembly.mask != 0 &&
!reassembly.awaiting_proof && ++reassembly.wire_stalls >= 2) {
const bool partial_v2 = _wire_v2_session && reassembly.wire_v2 && reassembly.mask != 0 &&
!reassembly.awaiting_proof;
if (partial_v2 && reassembly.wire_stalls >= 1) {
// A v2 seeder may disappear after establishing a representation while only deployed legacy seeders
// remain. Keep one sparse retry, then fall back the whole flight to the universal profile. Clearing
// every slot prevents geometry/representation mixing and guarantees that old sources can take over.
downgradeWireToLegacy();
}
if (requestSlot(slot)) _retry_slot = (uint8_t)((slot + 1) % OTA_FETCH_PIPELINE);
if (requestSlot(slot)) {
// Advance fallback evidence only when the sparse retry enters the
// transmit queue. Repeated rejection must not erase received fragments.
if (empty_v2 && _wire_v2_session) ++_wire_empty_stalls;
if (partial_v2 && _wire_v2_session) ++reassembly.wire_stalls;
_retry_slot = (uint8_t)((slot + 1) % OTA_FETCH_PIPELINE);
}
return;
}
}
@@ -2060,6 +2072,7 @@ void OtaManager::loop() {
return;
}
if (_fstate != FETCHING) return;
if (_flight_request_pending) { requestFlight(); return; }
if (activePipelineSlots() == 0) {
requestMissing();
return;
+1
View File
@@ -738,6 +738,7 @@ private:
uint8_t _retry_slot = 0; // round-robin stalled-slot retry cursor
uint8_t _pipeline_width = OTA_FETCH_PIPELINE_INITIAL; // live request window, adaptive up to array capacity
bool _flight_dirty = false; // a timeout/bad proof required recovery in this flight
bool _flight_request_pending = false; // local queue has not accepted this flight yet
uint32_t _fetch_wait_since_ms = 0; // last new fragment/proof/request; retry deadline anchor
uint32_t _radio_packet_airtime_ms = 0; // measured for MAX_TRANS_UNIT at active SF/BW
float _speed = OTA_SPEED_DEFAULT;