diff --git a/docs/test-results/radio_receive_calibration.md b/docs/test-results/radio_receive_calibration.md index 928b565ca..41daf509d 100644 --- a/docs/test-results/radio_receive_calibration.md +++ b/docs/test-results/radio_receive_calibration.md @@ -11,6 +11,15 @@ Periodic requests coalesce while a block is active. RXPS resumes after a complet block, including a held block, or at timeout. Receive and transmit activity are never interrupted just to finish calibration. +If a reception delays the return to RXPS, the pending restoration is retried +after every Dispatcher receive poll, including empty polls. A false preamble +that expires without another packet interrupt therefore cannot leave the radio +in continuous RX until the next calibration or AGC reset. An unread packet or +active reception still owns the radio; TX completion retains its normal rearm. +The retry wake query reports this pending restoration as immediate work, keeping +event-driven sleepers awake until the existing preamble/header timeout or RX +completion releases the owner. The query does not issue SPI commands. + A calibration request waiting for successful RX does not keep the MCU awake. Repeated failed receive starts retry after 20, 40, 80, 160, 320, 640, and 1280 ms, then at most once every 2000 ms. Only the repeated idle fallback is paced: diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 684eb0014..bd297ca67 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -1231,6 +1231,13 @@ void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { if (state != STATE_TX_WAIT && !isPacketPendingOrReceiving()) { requestRestartRecv(); + } else if (state == STATE_RX && !_rx_ps_armed + && _rx_ps_enabled && !_rx_ps_continuous_fallback) { + // A false preamble can release RX without producing another interrupt. + // Keep the restoration pending: Dispatcher calls onReceiveProcessed() + // even after an empty poll, so its normal packet-owner guard retries once + // the reception or unread packet is gone. TX retirement rearms on its own. + _rx_hold_continuous = true; } } @@ -1951,7 +1958,15 @@ bool RadioLibWrapper::getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) con #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return false; #endif - if (!_rx_retry_pending || state != STATE_IDLE || _cw_active) return false; + if (_cw_active) return false; + if (state == STATE_RX && _rx_hold_continuous && _rx_ps_enabled + && !_rx_ps_continuous_fallback && !_rx_ps_armed && !_nf_calib_active) { + // A false preamble can expire without any wake interrupt. Keep the normal + // empty-poll completion path running until it can restore duty-cycle RX. + delay_ms = 0; + return true; + } + if (!_rx_retry_pending || state != STATE_IDLE) return false; delay_ms = static_cast(now - _rx_retry_at) >= 0 ? 0 : _rx_retry_at - now; return true; } @@ -2106,7 +2121,8 @@ void RadioLibWrapper::finishReceiveProcessing() { } _rx_hold_continuous = false; - if (!_rx_ps_enabled || _rx_ps_continuous_fallback || _nf_calib_active) return; + if (!_rx_ps_enabled || _rx_ps_continuous_fallback || _nf_calib_active + || _rx_ps_armed) return; // an empty/error IRQ may already have rearmed RXPS state = STATE_IDLE; startRecv(); diff --git a/test/fixtures/radio_receive_retry.cpp b/test/fixtures/radio_receive_retry.cpp index f08219554..7d073c8c2 100644 --- a/test/fixtures/radio_receive_retry.cpp +++ b/test/fixtures/radio_receive_retry.cpp @@ -11,6 +11,7 @@ struct RetryDispatcher : mesh::Dispatcher { 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) { @@ -170,8 +171,120 @@ static void busyAfterFailure() { 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); +} + int main() { persistentFailure(); rampAndRollover(); ownersAndImmediateRearms(); pendingSetupAndTx(); retainedOutboundWake(); busyAfterFailure(); + deferredCalibrationRestore(); std::puts("PASS: receive retry lifecycle"); } diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index a91cc293f..acb83904d 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -78,6 +78,7 @@ struct RadioLibWrapper { Board board; Board* _board = &board; Radio radio{&events}; Radio* _radio = &radio; bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_ps_continuous_fallback = false; + bool _rx_hold_continuous = false; bool _nf_calib_active = false, _nf_refresh_requested = true, _noise_floor_valid = true, _noise_floor_secondary_valid = true; bool _cad_enabled = true, packet = false, busy = false, inject_arm_irq = false; diff --git a/test/test_radio_receive_retry.py b/test/test_radio_receive_retry.py index b9683d841..0092853da 100644 --- a/test/test_radio_receive_retry.py +++ b/test/test_radio_receive_retry.py @@ -18,6 +18,19 @@ class RadioReceiveRetryTests(unittest.TestCase): rxps = (ROOT / 'src/helpers/radiolib/RXPowerSaving.h').read_text() if mutation == 'unpaced': source = source.replace(' if (_rx_retry_pending) {', ' if (false) {') + if mutation == 'lost_restore': + body = method(source, 'void RadioLibWrapper::endNoiseFloorCalib(') + self.assertEqual(body.count(' _rx_hold_continuous = true;'), 1) + source = source.replace(body, body.replace(' _rx_hold_continuous = true;', '')) + if mutation == 'duplicate_restore': + body = method(source, 'void RadioLibWrapper::finishReceiveProcessing(') + self.assertEqual(body.count(' || _rx_ps_armed'), 1) + source = source.replace(body, body.replace(' || _rx_ps_armed', '')) + if mutation == 'restore_sleep': + body = method(source, 'bool RadioLibWrapper::getReceiveRetryDelay(') + self.assertEqual(body.count('state == STATE_RX && _rx_hold_continuous'), 1) + source = source.replace(body, body.replace( + 'state == STATE_RX && _rx_hold_continuous', 'false && _rx_hold_continuous')) getter = method(header, 'bool isCalibratingNoiseFloor() const') if mutation == 'pending_veto': getter = getter.replace('isInRecvMode() && !_rx_ps_armed && ', '') @@ -31,6 +44,7 @@ class RadioReceiveRetryTests(unittest.TestCase): ('void', 'requestNoiseFloorRefresh'), ('void', 'requestPeriodicNoiseFloorRefresh'), ('void', 'recalibrateNoiseFloor'), ('bool', 'resetAGCIfIdle'), ('bool', 'tryResetAGC'), ('void', 'resetAGC'), ('int', 'recvRaw'), + ('void', 'finishReceiveProcessing'), ('void', 'onSendFinished'), ('void', 'begin'), ('bool', 'getReceiveRetryDelay'), ('void', 'rxPsWatchdogCheck')] methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n' @@ -50,6 +64,10 @@ class RadioReceiveRetryTests(unittest.TestCase): '::Radio radio{&events}; ::Radio* _radio') harness = harness.replace('unsigned long getEstAirtimeFor(int)', 'uint32_t getEstAirtimeFor(int)') harness = harness.replace('void rxPsWatchdogCheck() {}', 'void rxPsWatchdogCheck();') + harness = harness.replace('bool isChipBusy() { return busy; }', + 'bool isChipBusy() { ++busy_probes; return busy; }') + harness = harness.replace('bool isReceivingPacket() { return packet; }', + 'bool isReceivingPacket() { ++packet_probes; return packet; }') harness = harness.replace(' void serviceWatchdog()', ' unsigned after = 0;\n int getStartupReason() { return 0; }\n' ' void onAfterTransmit() { ++after; }\n void serviceWatchdog()') @@ -81,7 +99,11 @@ class RadioReceiveRetryTests(unittest.TestCase): bool startSendRaw(const uint8_t*, int) override { ++sends; return false; } bool isSendComplete() override { return false; } float packetScore(float, int) override { return 0; } - bool _rx_hold_continuous = false, _loop_event_pending = false; + bool _loop_event_pending = false; + void finishReceiveProcessing(); + void onReceiveProcessed() override { ++processed; finishReceiveProcessing(); } + unsigned processed = 0; + unsigned busy_probes = 0, packet_probes = 0; uint32_t last_radio_interrupt_millis = 0, last_recv_millis = 0; float _last_snr = 0, _last_rssi = 0; unsigned n_recv = 0, n_recv_errors = 0, sends = 0; @@ -140,6 +162,14 @@ class RadioReceiveRetryTests(unittest.TestCase): self.assertIn('busyAfterFailure' if mutation == 'agc_fast' else 'persistentFailure', result.stderr) + def test_lost_calibration_restore_negative_control(self): + for mutation in ('lost_restore', 'duplicate_restore', 'restore_sleep'): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory( + prefix='meshcore-rx-restore-negative-') as folder: + result = self.run_probe(self.build(folder, mutation)) + self.assertNotEqual(result.returncode, 0) + self.assertIn('deferredCalibrationRestore', result.stderr) + if __name__ == '__main__': unittest.main()