Restore RX power saving after deferred calibration cleanup

This commit is contained in:
mikecarper committed 2026-10-16 01:42:37 -07:00
1 parent 41405ee01b
commit b11cb44e06
5 files changed
+172 -3

No files matched your search

@@ -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:
+18 -2
View File
@@ -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<int32_t>(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();
+113
View File
@@ -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");
}
+1
View File
@@ -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;
+31 -1
View File
@@ -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()