Files
MeshCore/test/fixtures/radio_receive_retry.cpp
T
mikecarper 73c5f572eb Apply busy radio settings and recover service starts automatically
Save valid busy RX, fast-switch and Companion tuple changes once, then
apply the latest accepted intent at a safe radio gap. Preserve checked-save
rollback, runtime RXEN intent, TempRadio ownership and exact remote reply
delivery. Repair failed preamble compensation without hiding uncertain saves.

Retry WebConfig, MQTT and NimBLE startup failures automatically. Preserve
explicit off choices, OTA ownership and the latest WiFi credentials through
cooperative shutdown. Add regression coverage and update command guides.

Validation: focused host transaction, rollback, ownership and lifecycle suites;
Full RAK4631, Heltec V4 R8 TFT and RC32 packages; representative room, sensor,
LR1110, LR2021, SX1276, STM32WL and ESP-NOW builds. Linked RAK crypto and OTA
workspace checks pass; its complete OTA image uses 712644/712704 bytes.
2026-10-25 15:20:55 -07:00

381 lines
18 KiB
C++

struct RetryClock : mesh::MillisecondClock {
unsigned long getMillis() override { return now_ms; }
};
struct RetryDispatcher : mesh::Dispatcher {
int interval = 8000;
bool adaptive = true;
RetryDispatcher(RadioLibWrapper& r, RetryClock& c, StaticPoolPacketManager& m)
: Dispatcher(r, c, m) {}
mesh::DispatcherAction onRecvPacket(mesh::Packet*) override { return ACTION_RELEASE; }
int getAGCResetInterval() const override { return interval; }
bool useAdaptiveAGC() const override { return adaptive; }
uint32_t getCADFailRetryDelay() const override { return 5000; }
using Dispatcher::getNextQueueWakeDelay;
using Dispatcher::hasQueuedWorkDue;
};
static uint32_t retryDelay(const RadioLibWrapper& w) {
uint32_t delay = UINT32_MAX;
assert(w.getReceiveRetryDelay(now_ms, delay));
return delay;
}
static void noiseFloorDeadlines() {
for (unsigned mode : {0U, 1U, 2U}) {
RadioLibWrapper w; RetryClock clock; StaticPoolPacketManager manager{4};
RetryDispatcher d(w, clock, manager); d.interval = 0;
now_ms = 1000; d.begin(); selectMode(w, mode);
d.loop(); d.loop();
uint32_t wait = 0;
assert(d.getNextNoiseFloorWakeDelay(wait) && wait == NF_CALIB_SETTLE_MS);
// A subsecond sample is not a whole-second queue veto on timed sleepers.
uint32_t queue = UINT32_MAX;
assert(!d.getNextQueueWakeDelay(queue) && !d.hasQueuedWorkDue());
const unsigned reads = w.reads, probes = w.mode_reads;
const unsigned busy = w.busy_probes, packets = w.packet_probes;
for (unsigned i = 0; i < 100; ++i)
assert(d.getNextNoiseFloorWakeDelay(wait) && wait == NF_CALIB_SETTLE_MS);
assert(w.reads == reads && w.mode_reads == probes && w.busy_probes == busy
&& w.packet_probes == packets);
now_ms += wait; d.loop();
assert(w.reads == 1 && d.getNextNoiseFloorWakeDelay(wait) && wait == 50);
w.events.record();
assert(d.getNextNoiseFloorWakeDelay(wait) && w.hasPendingRadioInterrupt());
w.radio.packet_length = 1;
d.loop();
assert(w.n_recv == 1 && !w.hasPendingRadioInterrupt());
assert(!d.hasQueuedWorkDue());
}
std::puts("PASS: actual Dispatcher noise-floor deadlines (3 modes)");
}
static void persistentFailure() {
uint8_t bytes[8];
for (unsigned mode : {0U, 1U, 2U}) {
RadioLibWrapper w; RetryClock clock; StaticPoolPacketManager manager{4};
RetryDispatcher d(w, clock, manager);
now_ms = 1000; d.begin(); selectMode(w, mode); w.arm_result = -5;
now_ms = 31000; // application setup may outlast the sampling timeout
unsigned sleep_windows = 0;
for (unsigned i = 0; i <= 60000; ++i, ++now_ms) {
d.loop();
assert(!w.isCalibratingNoiseFloor());
uint32_t delay = 0;
assert(d.getNextQueueWakeDelay(delay));
sleep_windows += mesh::power::powerSaveSleepSeconds(delay) > 0;
assert(w.arms <= 37); // initial ramp followed by at most one attempt/2s
}
assert(w.arms == 36 && sleep_windows > 20000);
assert(w._nf_refresh_requested && w._nf_calib_deadline == 0 && w.reads == 0);
assert(w.getRecoveryCount() >= 7);
std::printf("60s actual Dispatcher mode=%u: arms=%u recoveries=%u sleep_windows=%u\n",
mode, w.arms, unsigned(w.getRecoveryCount()), sleep_windows);
// A real success clears the ramp and enters normal settled calibration.
w.arm_result = 0; now_ms += retryDelay(w); w.recvRaw(bytes, sizeof(bytes));
assert(w.state == STATE_RX && !w._rx_retry_pending && w._rx_retry_delay_ms == 0);
if (mode == 1) { w.loop(); w.recvRaw(bytes, sizeof(bytes)); }
assert(w.isCalibratingNoiseFloor());
w.loop();
assert(w._nf_calib_deadline == now_ms + NF_CONTINUOUS_TIMEOUT_MS);
now_ms += NF_CALIB_SETTLE_MS; w.loop();
assert(w._floor_estimator.count() == 1);
}
}
static void rampAndRollover() {
uint8_t bytes[8];
for (uint32_t origin : {100U, UINT32_MAX - 19U}) {
RadioLibWrapper w; w.state = STATE_IDLE; w.arm_result = -5; now_ms = origin;
for (uint32_t expected : {20U, 40U, 80U, 160U, 320U, 640U, 1280U, 2000U, 2000U}) {
const unsigned arms = w.arms;
w.recvRaw(bytes, sizeof(bytes));
assert(w.arms == arms + 1 && retryDelay(w) == expected);
// Periodic/forced calibration, recovery and watchdog counter resets
// cannot turn the existing failed-arm deadline into an immediate retry.
w.triggerNoiseFloorCalibrate(7); w.recalibrateNoiseFloor(); w.resetAGC();
w.recoverRadio(false); w._startrx_fails = 0;
assert(retryDelay(w) == expected && w._rx_retry_delay_ms == expected);
now_ms += expected - 1;
w.recvRaw(bytes, sizeof(bytes)); assert(w.arms == arms + 1 && retryDelay(w) == 1);
++now_ms; assert(retryDelay(w) == 0);
}
// Restarting the radio invalidates the hardware power verification along
// with the receive retry ramp; the next identical setting must be applied.
w._dbm_applied = true;
w.begin();
assert(!w._dbm_applied && !w._rx_retry_pending && w._rx_retry_delay_ms == 0);
w.recvRaw(bytes, sizeof(bytes)); assert(retryDelay(w) == 20);
}
}
static void ownersAndImmediateRearms() {
uint8_t bytes[8];
for (bool busy : {false, true}) {
RadioLibWrapper w; w.state = STATE_IDLE; w.arm_result = -5;
w.recvRaw(bytes, sizeof(bytes)); now_ms += retryDelay(w);
w.busy = busy; w.packet = !busy;
const unsigned arms = w.arms;
w.recvRaw(bytes, sizeof(bytes));
assert(w.arms == arms && retryDelay(w) == 2000 && !w.hasPendingRadioInterrupt());
w.busy = w.packet = false;
// A completed packet bypasses the timer and is delivered with its owner.
w.events.record(); w.radio.packet_length = 1; w.arm_result = 0;
assert(w.recvRaw(bytes, sizeof(bytes)) == 1 && w.arms == arms + 1);
assert(!w._rx_retry_pending && w._rx_retry_delay_ms == 0 && !w.hasPendingRadioInterrupt());
}
{
RadioLibWrapper w; w.state = STATE_IDLE; w.arm_result = -5;
w.recvRaw(bytes, sizeof(bytes));
w.state = STATE_TX_WAIT; w.events.record();
const unsigned arms = w.arms;
assert(w.recvRaw(bytes, sizeof(bytes)) == 0 && w.arms == arms && w.hasPendingRadioInterrupt());
uint32_t ignored = 0; assert(!w.getReceiveRetryDelay(now_ms, ignored));
w.onSendFinished(); w.arm_result = 0;
w.recvRaw(bytes, sizeof(bytes));
assert(w.arms == arms + 1 && w.state == STATE_RX && !w.hasPendingRadioInterrupt());
}
{
RadioLibWrapper w; w.state = STATE_IDLE; w.arm_result = -5;
w.recvRaw(bytes, sizeof(bytes)); const unsigned arms = w.arms;
// Direct rearm, as used by profile/CAD restoration, has no retry wait.
w.arm_result = 0; w.startRecv();
assert(w.arms == arms + 1 && w.state == STATE_RX && !w._rx_retry_pending);
}
{
RadioLibWrapper w; w._rx_ps_armed = true; w.arm_result = -5;
w.startRecv(); assert(w.state == STATE_IDLE && !w._rx_ps_armed);
}
{
RadioLibWrapper w; w._nf_calib_active = true; w._rx_ps_armed = true;
assert(!w.isCalibratingNoiseFloor());
w.state = STATE_IDLE; w._rx_ps_armed = false; assert(!w.isCalibratingNoiseFloor());
w.state = STATE_RX; assert(w.isCalibratingNoiseFloor());
w._cw_active = true; uint32_t delay = 0; assert(!w.getReceiveRetryDelay(now_ms, delay));
}
}
static void pendingSetupAndTx() {
RadioLibWrapper w; w.begin(); now_ms += 100000;
assert(!w.isCalibratingNoiseFloor() && w._nf_calib_deadline == 0);
w.loop(); assert(w._nf_refresh_requested && w._nf_calib_deadline == 0);
w.state = STATE_TX_WAIT; now_ms += 100000; w.loop();
assert(!w.isCalibratingNoiseFloor() && w._nf_refresh_requested && w._nf_calib_deadline == 0);
w.onSendFinished(); w.startRecv(); w.loop();
assert(w._nf_calib_deadline == now_ms + NF_CONTINUOUS_TIMEOUT_MS);
}
static void retainedOutboundWake() {
RadioLibWrapper w; RetryClock clock; StaticPoolPacketManager manager{4};
RetryDispatcher d(w, clock, manager);
now_ms = 1000; d.begin(); w.arm_result = -5;
d.loop();
auto* packet = d.obtainNewPacket(); assert(packet);
packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT);
packet->payload_len = 1; packet->payload[0] = 0x42;
assert(d.sendPacket(packet, 0)); d.loop(); assert(w.sends == 1 && d.hasOutbound());
uint32_t delay = 0;
assert(d.getNextQueueWakeDelay(delay) && delay == 20);
now_ms += 20; d.loop();
assert(w.arms == 2 && d.getNextQueueWakeDelay(delay) && delay == 40);
}
static void busyAfterFailure() {
RadioLibWrapper w; RetryClock clock; StaticPoolPacketManager manager{4};
RetryDispatcher d(w, clock, manager);
now_ms = 1000; d.begin(); w.arm_result = -5; d.loop(); w.busy = true;
unsigned sleep_windows = 0;
for (unsigned elapsed = 1; elapsed <= 30000; ++elapsed) {
now_ms = 1000 + elapsed; d.loop();
uint32_t delay = 0; assert(d.getNextQueueWakeDelay(delay));
if (elapsed > 8000) sleep_windows += mesh::power::powerSaveSleepSeconds(delay) > 0;
assert(w.arms == 1 && !w.isCalibratingNoiseFloor());
}
// A due AGC reset cannot admit SPI on BUSY. Its deferred deadline must join
// the failed-RX deadline instead of vetoing every timed sleep at 50 ms.
assert(sleep_windows > 8000);
w.busy = false; w.arm_result = 0; now_ms += retryDelay(w); d.loop();
assert(w.state == STATE_RX && !w._rx_retry_pending && w._rx_retry_delay_ms == 0);
w._nf_refresh_requested = false; w._nf_calib_active = false;
now_ms += 1; d.loop();
uint32_t ignored = 0; assert(!w.getReceiveRetryDelay(now_ms, ignored));
// The healthy non-RX-retry deferral contract remains 50 ms.
w.packet = true; now_ms += 10000; d.loop();
assert(d.getNextQueueWakeDelay(ignored) && ignored == 50);
std::printf("30s BUSY after RX error: arms=%u later_sleep_windows=%u\n", w.arms, sleep_windows);
}
static void deferredCalibrationRestore() {
enum Owner {
Receiving, PacketIRQ, Tx, DisableAfter, FallbackAfter, FailedRearm,
EmptyIRQ, DisableBefore, FallbackBefore
};
unsigned cases = 0;
for (bool complete : {false, true})
for (Owner owner : {Receiving, PacketIRQ, Tx, DisableAfter, FallbackAfter,
FailedRearm, EmptyIRQ, DisableBefore, FallbackBefore}) {
RadioLibWrapper w; RetryClock clock; StaticPoolPacketManager manager{4};
RetryDispatcher d(w, clock, manager); d.interval = 0;
now_ms = 1000; d.begin(); selectMode(w, 1);
d.loop(); ++now_ms; d.loop();
assert(w._nf_calib_active && w.state == STATE_RX && !w._rx_ps_armed);
if (complete) {
for (unsigned i = 0; i < NoiseFloorEstimator::SAMPLE_COUNT; ++i) {
now_ms += NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
assert(w._floor_estimator.add(-105, now_ms));
}
} else {
now_ms = w._nf_calib_deadline;
}
const unsigned arms = w.arms;
if (owner == PacketIRQ || owner == EmptyIRQ) {
w.events.record(); w.radio.packet_length = owner == PacketIRQ ? 1 : 0;
} else if (owner == Tx) { w.state = STATE_TX_WAIT; w.events.record(); }
else w.packet = true;
if (owner == DisableBefore) assert(w.setRxPowerSaving(false, 50000, 50000));
if (owner == FallbackBefore) {
assert(w.setRxPowerSaving(true, RX_POWERSAVING_CONTINUOUS_FALLBACK_US,
RX_POWERSAVING_CONTINUOUS_FALLBACK_US));
}
const unsigned processed = w.processed;
d.loop();
assert(!w._nf_calib_active && !w._nf_refresh_requested);
assert(w.processed == processed + 1); // includes empty receive polls
if (owner == PacketIRQ || owner == EmptyIRQ) {
assert(w.n_recv == (owner == PacketIRQ ? 1U : 0U) && !w.hasPendingRadioInterrupt());
assert(w._rx_ps_armed && !w._rx_hold_continuous && w.arms == arms + 1);
} else if (owner == Tx) {
assert(w.state == STATE_TX_WAIT && w.hasPendingRadioInterrupt());
assert(!w._rx_hold_continuous && w.arms == arms);
w.onSendFinished(); ++now_ms; d.loop();
assert(w._rx_ps_armed && w.arms == arms + 1);
} else if (owner == DisableBefore || owner == FallbackBefore) {
assert(!w._rx_hold_continuous && !w._rx_ps_armed && w.arms == arms);
w.packet = false; ++now_ms; d.loop();
assert(!w._rx_ps_armed && w.arms == arms); // no RXPS restoration is wanted
} else {
assert(w._rx_hold_continuous && !w._rx_ps_armed && w.arms == arms);
// Match the application pending-work gate for event-driven nRF52 sleep
// as well as ESP32 timed sleep. Calibration itself is already finished.
uint32_t wake = UINT32_MAX;
assert(!w.isCalibratingNoiseFloor());
assert(d.getNextQueueWakeDelay(wake) && wake == 0 && d.hasQueuedWorkDue());
assert(mesh::power::powerSaveSleepSeconds(wake) == 0);
// The const wake query must not read RSSI, IRQ status or other SPI state.
const unsigned reads = w.reads, probes = w.mode_reads;
const unsigned busy_probes = w.busy_probes, packet_probes = w.packet_probes;
assert(w.getReceiveRetryDelay(now_ms, wake) && wake == 0);
assert(w.reads == reads && w.mode_reads == probes
&& w.busy_probes == busy_probes && w.packet_probes == packet_probes);
assert(w._rx_hold_continuous && !w._rx_retry_pending);
// Known owners and intentional continuous RX do not request an extra
// prompt poll; active calibration already has its separate sleep veto.
for (unsigned excluded : {0U, 1U, 2U, 3U, 4U, 5U, 6U}) {
if (excluded == 0) w._cw_active = true;
if (excluded == 1) w._rx_ps_enabled = false;
if (excluded == 2) w._rx_ps_continuous_fallback = true;
if (excluded == 3) w._rx_ps_armed = true;
if (excluded == 4) w.state = STATE_TX_WAIT;
if (excluded == 5) w.state = STATE_IDLE;
if (excluded == 6) w._nf_calib_active = true;
assert(!w.getReceiveRetryDelay(now_ms, wake) && w._rx_hold_continuous);
w._cw_active = false; w._rx_ps_enabled = true;
w._rx_ps_continuous_fallback = w._rx_ps_armed = w._nf_calib_active = false;
w.state = STATE_RX;
}
// Keep a partial/false reception intact. No IRQ is needed to clear it.
for (unsigned i = 0; i < 100; ++i) { ++now_ms; d.loop(); }
assert(w._rx_hold_continuous && w.arms == arms);
if (owner == DisableAfter) assert(w.setRxPowerSaving(false, 50000, 50000));
if (owner == FallbackAfter) {
assert(w.setRxPowerSaving(true, RX_POWERSAVING_CONTINUOUS_FALLBACK_US,
RX_POWERSAVING_CONTINUOUS_FALLBACK_US));
}
if (owner == DisableAfter || owner == FallbackAfter) {
assert(!w.getReceiveRetryDelay(now_ms, wake));
}
w.packet = false;
if (owner == FailedRearm) w.arm_result = -5;
++now_ms; d.loop();
assert(!w._rx_hold_continuous);
if (owner == DisableAfter || owner == FallbackAfter) {
assert(!w._rx_ps_armed && w.arms == arms); // continuous RX is now intentional
} else if (owner == FailedRearm) {
assert(w.state == STATE_IDLE && !w._rx_ps_armed && w.arms == arms + 1);
assert(retryDelay(w) == 20);
++now_ms; d.loop(); assert(w.arms == arms + 1 && retryDelay(w) == 19);
w.arm_result = 0; now_ms += 19; d.loop();
assert(w._rx_ps_armed && w.arms == arms + 2 && !w._rx_retry_pending);
} else {
assert(w._rx_ps_armed && w.arms == arms + 1);
}
assert(!w._rx_hold_continuous && !d.hasQueuedWorkDue());
}
++cases;
}
std::printf("PASS: deferred calibration restoration (%u cases)\n", cases);
}
static void recoveryOwnershipAdmission() {
unsigned cases = 0;
for (unsigned mode : {0U, 1U, 2U}) for (bool hard : {false, true})
for (bool between_checks : {false, true}) {
RadioLibWrapper w; selectMode(w, mode);
w._rx_ps_armed = mode == 1;
w._rx_boosted_gain_valid = w._rx_boosted_gain_applied = w._cur_rx_boosted_gain = true;
w._nf_refresh_requested = false; w._nf_last_calib = now_ms;
assert(w._floor_estimator.add(-105, now_ms));
const uint8_t payload[] = {0x18, 0x27, 0x36, 0x45, 0x54};
memcpy(w.radio.fifo, payload, sizeof(payload));
w.radio.packet_length = sizeof(payload);
if (between_checks) w.inject_packet_irq_at = 1;
else w.events.record();
const auto state = w.state;
const auto armed = w._rx_ps_armed;
assert(!w.recoverRadio(hard) && "refused recovery must not report success");
assert(w.hasPendingRadioInterrupt() && w.state == state && w._rx_ps_armed == armed);
assert(w.agc_resets == 0 && w.gain_changes == 0 && w.arms == 0 && w.stops == 0);
assert(w._floor_estimator.count() == 1 && !w._nf_refresh_requested);
assert(w.radio.fifo_reads == 0 && w.radio.packet_length == sizeof(payload));
assert(!memcmp(w.radio.fifo, payload, sizeof(payload)));
assert(w.packet_probes == unsigned(between_checks));
// The receive owner still drains the original FIFO once, then a clean
// recovery can be admitted normally. Neither refusal consumes its IRQ.
uint8_t received[sizeof(payload)] = {};
assert(w.recvRaw(received, sizeof(received)) == int(sizeof(payload)));
assert(!memcmp(received, payload, sizeof(payload)) && w.radio.fifo_reads == 1);
assert(!w.hasPendingRadioInterrupt() && w.n_recv == 1);
w.onReceiveProcessed();
assert(w.recoverRadio(hard));
assert(w.agc_resets == 1 && w.gain_changes == 1 && w.state == STATE_IDLE);
assert(w._floor_estimator.count() == 0 && w._nf_refresh_requested);
++cases;
}
std::printf("PASS: recovery ownership admission (%u cases)\n", cases);
}
int main(int argc, char** argv) {
if (argc == 2) {
if (!strcmp(argv[1], "profile-repair")) {
RadioLibWrapper w; uint8_t bytes[8];
w._profile_repair_required=true; w.events.record(); w.radio.packet_length=1;
const auto arms=w.arms;
assert(w.recvRaw(bytes,sizeof(bytes))==0);
w.startRecv();
assert(w.radio.fifo_reads==0 && w.arms==arms && w.hasPendingRadioInterrupt());
// A trusted configuration refresh does not revoke old FIFO ownership.
w._profile_repair_required=false;
assert(w.recvRaw(bytes,sizeof(bytes))==1 && w.radio.fifo_reads==1);
std::puts("PASS: untrusted profile RX waits for repair");
return 0;
}
assert(!strcmp(argv[1], "recovery"));
recoveryOwnershipAdmission();
return 0;
}
assert(argc == 1);
noiseFloorDeadlines(); persistentFailure(); rampAndRollover(); ownersAndImmediateRearms();
pendingSetupAndTx(); retainedOutboundWake(); busyAfterFailure();
deferredCalibrationRestore();
std::puts("PASS: receive retry lifecycle");
}