Restore RX power saving after deferred calibration cleanup
This commit is contained in:
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:
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user