Bound failed RX retries without blocking power-saving sleep

This commit is contained in:
mikecarper committed 2026-10-16 01:27:24 -07:00
1 parent 862260de17
commit 339079c439
14 files changed
+463 -12

No files matched your search

+1
View File
@@ -412,6 +412,7 @@ jobs:
run: |
python3 -B test/test_dispatcher_startup_watchdog.py -v
python3 -B test/test_radio_receive_contract.py
python3 -B test/test_radio_receive_retry.py -v
python3 -B test/test_adaptive_agc_roles.py -v
python3 - <<'PY'
from pathlib import Path
+3 -1
View File
@@ -2912,7 +2912,9 @@ noise floor, or a successful transmission as proof of full receiver sensitivity.
TX cannot continually postpone adaptive maintenance. Pending RX/TX, a BUSY
chip outside known RX duty cycling, carrier/Morse ownership, and an active
noise-floor block can defer it; deferred attempts do not advance backoff and
are retried after 50 ms. The
normally retry after 50 ms. When a failed receive start already has a later
retry deadline, adaptive AGC waits at least until that deadline too, allowing
sleep during persistent receive-start failures. The
60-second bound is a scheduling interval, not permission to interrupt a packet.
An armed RXPS radio uses the existing explicit wake/reset path, avoiding retry
polling that could miss every short listen window.
+16 -5
View File
@@ -11,6 +11,16 @@ 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.
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:
packet/TX completion and explicit retune/CAD restoration still rearm promptly.
Calibration and recovery requests retain the failed-start backoff; successful
RX or a new radio session clears it. A retry blocked by BUSY or an RX owner
waits 2000 ms without issuing another RX command. The Dispatcher includes this
retry deadline in its wake query, and deferred adaptive AGC joins a later RX
retry instead of forcing 50 ms wakeups throughout a persistent fault.
In single-profile RXPS, routine refreshes wait at least 60 seconds after the
last completed or timed-out block before requesting another continuous-RX
window. Both periodic maintenance and passive retry checks use this cooldown;
@@ -108,11 +118,12 @@ radio windows of at least 12 hours use a 12-hour deadline; shorter windows
disable that reboot watchdog until the window ends. The temporary timing
overrides are held in RAM and do not replace saved normal-mode preferences.
The status read uses the two-byte GetStatus transaction. The pinned RadioLib
getStatus() helper requests zero copied data bytes, so its return value is not
usable for this mode check. The dedicated read restores the normal SPI command
layout afterward and does not wake a duty-cycling radio to inspect its mode.
The status read uses the two-byte GetStatus transaction. RadioLib 7.8.1 also
reads the response byte directly; the dedicated probe additionally preserves
the existing SPI layout, checks transport errors, and avoids waiting on BUSY.
It does not wake a duty-cycling radio to inspect its mode.
Validation lives in `test_noise_floor_estimator`, `test_rx_power_saving`,
`test_radio_liveness`, `test_radio_receive_contract.py`, and
`test_sx126x_receive_mode.py`.
`test_sx126x_receive_mode.py`, and `test_radio_receive_retry.py`. These host
checks do not establish battery-life or hardware fault-recovery results.
+18 -1
View File
@@ -584,10 +584,18 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const {
}
}
uint32_t receive_retry_delay = 0;
const bool receive_retry = radio_available
&& _radio->getReceiveRetryDelay(now, receive_retry_delay);
if (outbound != NULL) {
if (outbound_radio_retry_pending) {
int32_t signed_retry_delay = (int32_t)(outbound_radio_retry_at - now);
delay_millis = signed_retry_delay > 0 ? (uint32_t)signed_retry_delay : 0;
// This backoff branch services checkRecv(), but returns before AGC.
// Wake for a failed RX retry without making overdue AGC a hot poll.
if (receive_retry && receive_retry_delay < delay_millis) {
delay_millis = receive_retry_delay;
}
} else {
// TX completion/timeout still needs the normal fast lifecycle path.
delay_millis = 0;
@@ -595,6 +603,11 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const {
return true;
}
if (receive_retry) {
if (!found || receive_retry_delay < shortest_delay) shortest_delay = receive_retry_delay;
found = true;
}
if (outbound_restore_cr) {
const int32_t restore_delay = (int32_t)(outbound_restore_retry_at - now);
const uint32_t delay = restore_delay > 0 ? (uint32_t)restore_delay : 0;
@@ -854,7 +867,11 @@ void Dispatcher::loop() {
agc_reset.noteAttempt(_ms->getMillis());
} else {
// Ownership/calibration deferral is not a reset or health evidence.
agc_reset.noteDeferred(_ms->getMillis());
const uint32_t deferred_at = _ms->getMillis();
uint32_t retry_delay = 50, receive_delay;
if (adaptive && _radio->getReceiveRetryDelay(deferred_at, receive_delay)
&& receive_delay > retry_delay) retry_delay = receive_delay;
agc_reset.noteDeferred(deferred_at, retry_delay);
}
}
+5
View File
@@ -215,6 +215,11 @@ public:
return getNoiseFloorCalibrationSecondsRemaining();
}
virtual bool isCalibratingNoiseFloor() const { return false; }
// A failed receive start may need a later retry without keeping the MCU
// awake. Transports without a deferred receive retry retain their cadence.
virtual bool getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const {
(void)now; (void)delay_ms; return false;
}
virtual void triggerNoiseFloorCalibrate(int threshold) { }
+3 -2
View File
@@ -54,8 +54,9 @@ public:
// A busy operation changes only its retry deadline, never the reset cadence
// or adaptive stage. This permits sleep instead of spinning on a due reset.
void noteDeferred(uint32_t now) {
if (_state) _next = now + 50;
void noteDeferred(uint32_t now, uint32_t retry_delay_ms = 50) {
if (retry_delay_ms == 0 || retry_delay_ms > INT32_MAX) retry_delay_ms = 50;
if (_state) _next = now + retry_delay_ms;
}
// Call only after an actual accepted reset sequence, not a busy/deferred one.
+46 -1
View File
@@ -207,6 +207,9 @@ void RadioLibWrapper::begin() {
}
#endif
state = STATE_IDLE;
_rx_retry_at = 0;
_rx_retry_delay_ms = 0;
_rx_retry_pending = false;
if (_board->getStartupReason() == BD_STARTUP_RX_PACKET) { // received a LoRa packet (while in deep sleep)
_loop_event_pending = true; // Main-owned wake event; ISR remains sole publisher
@@ -1877,6 +1880,8 @@ void RadioLibWrapper::startRecv() {
// Keep the partial block, but wait for the frontend before its next sample.
_nf_sample_from = millis() + NF_CALIB_SETTLE_MS;
_startrx_fails = 0;
_rx_retry_pending = false;
_rx_retry_delay_ms = 0;
if (_rx_ps_armed) {
// (re)base the duty-cycle watchdog on the freshly armed cycle
_wd_last_busy = isChipBusy();
@@ -1895,6 +1900,15 @@ void RadioLibWrapper::startRecv() {
if (_wd_observe_ms > 1500) _wd_observe_ms = 1500;
}
} else {
state = STATE_IDLE;
_rx_ps_armed = false;
// Recover a transient failure promptly, then permit whole-second timed
// sleep under persistent failure. Direct retune/CAD/packet rearms remain
// immediate; only recvRaw's repeated idle fallback observes this deadline.
_rx_retry_delay_ms = _rx_retry_delay_ms == 0 ? 20
: (_rx_retry_delay_ms < 1000 ? _rx_retry_delay_ms * 2 : 2000);
_rx_retry_at = millis() + _rx_retry_delay_ms;
_rx_retry_pending = true;
if (_startrx_fails < 255) _startrx_fails++;
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceiveMode(%d)", err);
}
@@ -1933,6 +1947,15 @@ bool RadioLibWrapper::isInRecvMode() const {
return state == STATE_RX;
}
bool RadioLibWrapper::getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const {
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) return false;
#endif
if (!_rx_retry_pending || state != STATE_IDLE || _cw_active) return false;
delay_ms = static_cast<int32_t>(now - _rx_retry_at) >= 0 ? 0 : _rx_retry_at - now;
return true;
}
// RX PowerSaving
bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) {
#if MESH_ENABLE_TX_BEACONS
@@ -1965,7 +1988,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) return 0;
#endif
if (_cw_active) return 0;
if (_cw_active || state == STATE_TX_WAIT) return 0;
int len = 0;
#if defined(USE_LR2021)
bool receiver_stopped = false;
@@ -2005,6 +2028,8 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
// Serial.print(" readData() -> "); Serial.println(len);
n_recv++;
last_recv_millis = millis();
_rx_retry_pending = false;
_rx_retry_delay_ms = 0;
}
}
}
@@ -2022,6 +2047,9 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
return 0;
}
#endif
// Serviced receive events retain the normal immediate rearm, even if an
// earlier idle start failed. A pending IRQ probe above still owns its FIFO.
_rx_retry_pending = false;
state = STATE_IDLE; // need another startReceive()
}
@@ -2033,16 +2061,32 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) {
int err = _radio->startReceive();
if (err == RADIOLIB_ERR_NONE) {
state = STATE_RX;
_rx_retry_pending = false;
_rx_retry_delay_ms = 0;
if (_nf_calib_active) {
_nf_sample_from = millis() + NF_CALIB_SETTLE_MS;
}
} else {
_rx_retry_delay_ms = 20; // the delivered packet just proved RX was working
_rx_retry_at = millis() + _rx_retry_delay_ms;
_rx_retry_pending = true;
MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive after packet (%d)", err);
}
return len;
}
if (state != STATE_RX) {
if (_rx_retry_pending) {
const uint32_t now = millis();
if (static_cast<int32_t>(now - _rx_retry_at) < 0) return len;
// A command error can leave hardware receiving despite the idle software
// state. Preserve that owner and move the retry deadline forward instead
// of keeping a permanently due wake or issuing SPI while BUSY is high.
if (isChipBusy() || isPacketPendingOrReceiving() || hasPendingRadioInterrupt()) {
_rx_retry_at = now + 2000;
return len;
}
}
startRecv();
}
return len;
@@ -2164,6 +2208,7 @@ void RadioLibWrapper::onSendFinished() {
claimRadioInterrupt();
_irq_probe_failures = 0;
_board->onAfterTransmit();
_rx_retry_pending = false; // TX retirement gets its usual immediate RX rearm
state = STATE_IDLE;
}
+8 -2
View File
@@ -58,6 +58,11 @@ protected:
NoiseFloorEstimator _secondary_floor_estimator;
uint32_t _rx_mode_checked_at = 0;
uint8_t _rx_mode_failures = 0;
// Independent of calibration and watchdog counters: repeated failed starts
// must retain their backoff through an AGC reset or radio recovery attempt.
uint32_t _rx_retry_at = 0;
uint16_t _rx_retry_delay_ms = 0;
bool _rx_retry_pending = false;
unsigned long last_recv_millis;
unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors
bool _rx_ps_enabled;
@@ -530,10 +535,11 @@ public:
// true while a noise-floor batch needs prompt loop service; the app's
// hasPendingWork() keeps the MCU awake for the bounded spaced-sample window.
bool isCalibratingNoiseFloor() const override {
return _nf_calib_active
return isInRecvMode() && !_rx_ps_armed && (_nf_calib_active
|| (_nf_refresh_requested
&& (!_rx_ps_enabled || _rx_ps_continuous_fallback));
&& (!_rx_ps_enabled || _rx_ps_continuous_fallback)));
}
bool getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const override;
void resetStats() { n_recv = n_sent = n_recv_errors = 0; }
uint8_t getRadioState() const override;
+177
View File
@@ -0,0 +1,177 @@
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;
};
static uint32_t retryDelay(const RadioLibWrapper& w) {
uint32_t delay = UINT32_MAX;
assert(w.getReceiveRetryDelay(now_ms, delay));
return delay;
}
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);
}
w.begin(); assert(!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);
}
int main() {
persistentFailure(); rampAndRollover(); ownersAndImmediateRearms();
pendingSetupAndTx(); retainedOutboundWake(); busyAfterFailure();
std::puts("PASS: receive retry lifecycle");
}
@@ -216,6 +216,36 @@ TEST(AGCResetScheduler, DeferredRetriesWaitFiftyMillisWithoutPromotingStage) {
EXPECT_TRUE(scheduler.due(39));
}
TEST(AGCResetScheduler, ReceiveRetryDeferralPreservesStageAndValidatesDelay) {
for (uint32_t origin : {100u, UINT32_MAX - 1999u}) {
AGCResetScheduler scheduler;
scheduler.configure(origin, 8000, true, 0);
scheduler.noteAttempt(origin + 8000u); // one admitted attempt, no promotion yet
scheduler.noteDeferred(origin, 2000);
EXPECT_EQ(scheduler.interval(), 8000u);
EXPECT_EQ(scheduler.nextAt(), origin + 2000u);
EXPECT_FALSE(scheduler.due(origin + 1999u));
EXPECT_TRUE(scheduler.due(origin + 2000u));
scheduler.noteAttempt(origin + 2000u);
EXPECT_EQ(scheduler.interval(), 16000u); // deferral retained the first attempt
}
for (uint32_t requested : {0u, uint32_t(INT32_MAX) + 1u, UINT32_MAX}) {
AGCResetScheduler scheduler;
scheduler.configure(0, 8000, true, 0);
scheduler.noteDeferred(UINT32_MAX - 10u, requested);
EXPECT_EQ(scheduler.nextAt(), 39u);
EXPECT_FALSE(scheduler.due(38));
EXPECT_TRUE(scheduler.due(39));
}
AGCResetScheduler scheduler;
scheduler.configure(0, 8000, true, 0);
scheduler.noteDeferred(100, INT32_MAX);
EXPECT_FALSE(scheduler.due(100 + uint32_t(INT32_MAX) - 1));
EXPECT_TRUE(scheduler.due(100 + uint32_t(INT32_MAX)));
scheduler.reset(); scheduler.noteDeferred(100, 2000);
uint32_t ignored = 0; EXPECT_FALSE(scheduler.nextDelay(100, ignored));
}
TEST(AGCResetScheduler, LateServiceDoesNotReplayMissedResetSlots) {
AGCResetScheduler scheduler;
scheduler.configure(10, 8000, true, 0);
+3
View File
@@ -88,6 +88,9 @@ struct RadioLibWrapper {
bool _rx_boosted_gain_valid = true, _cur_rx_boosted_gain = true;
bool _wd_last_busy = false;
uint8_t _startrx_fails = 0, _irq_probe_failures = 0;
uint32_t _rx_retry_at = 0;
uint16_t _rx_retry_delay_ms = 0;
bool _rx_retry_pending = false;
uint32_t _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000;
uint32_t _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0;
uint32_t n_wd_soft = 0, n_wd_hard = 0;
+5
View File
@@ -108,6 +108,9 @@ struct RadioLibWrapper {
bool _rx_ps_continuous_fallback=false, _nf_calib_active=false;
float _last_snr=0, _last_rssi=0;
unsigned n_recv=0,n_recv_errors=0,n_sent=0;
uint32_t _rx_retry_at=0;
uint16_t _rx_retry_delay_ms=0;
bool _rx_retry_pending=false;
uint32_t last_recv_millis=0,last_radio_interrupt_millis=0,_nf_sample_from=0;
Board board;Board* _board=&board;
Chip chip;Chip* _radio=&chip;
@@ -121,6 +124,8 @@ public:
int recvRaw(uint8_t*,int);
void onSendFinished();
void idle(){state=STATE_IDLE;}
bool isChipBusy(){return false;}
bool isPacketPendingOrReceiving(){return hasPendingRadioInterrupt();}
bool recoverRadio(bool){return !hasPendingRadioInterrupt();}
void stopReceiveDutyCycle(){_rx_ps_armed=false;chip.standby();}
void startRecv(){if(chip.startReceive()==0)state=STATE_RX;}
+3
View File
@@ -84,6 +84,9 @@ struct RadioLibWrapper {
bool _rx_boosted_gain_valid = false, _cur_rx_boosted_gain = false;
bool _wd_last_busy = false;
uint8_t _startrx_fails = 0, _rx_mode_failures = 0;
uint32_t _rx_retry_at = 0;
uint16_t _rx_retry_delay_ms = 0;
bool _rx_retry_pending = false;
uint32_t _rx_mode_checked_at = 0, _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000;
unsigned long _nf_last_calib = 0, _nf_calib_deadline = 0, _nf_sample_from = 0;
unsigned long _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0;
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Run failed RX starts through actual Dispatcher and radio lifecycle methods."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import HARNESS, ROOT, method
class RadioReceiveRetryTests(unittest.TestCase):
def build(self, folder, mutation=None):
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
header = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.h').read_text()
dispatcher = (ROOT / 'src/Dispatcher.h').read_text()
rxps = (ROOT / 'src/helpers/radiolib/RXPowerSaving.h').read_text()
if mutation == 'unpaced':
source = source.replace(' if (_rx_retry_pending) {', ' if (false) {')
getter = method(header, 'bool isCalibratingNoiseFloor() const')
if mutation == 'pending_veto':
getter = getter.replace('isInRecvMode() && !_rx_ps_armed && ', '')
names = [('int16_t', 'performChannelScanWithTimeout'),
('bool', 'isPacketPendingOrReceiving'), ('bool', 'isChannelActive'),
('bool', 'isReceivingPassive'), ('bool', 'setRxPowerSaving'),
('bool', 'setRxBoostedGainMode'), ('void', 'startRecv'),
('void', 'checkReceiveMode'), ('void', 'loop'),
('void', 'triggerNoiseFloorCalibrate'), ('void', 'requestRestartRecv'),
('void', 'noiseFloorCalibCheck'), ('void', 'endNoiseFloorCalib'),
('void', 'requestNoiseFloorRefresh'), ('void', 'requestPeriodicNoiseFloorRefresh'),
('void', 'recalibrateNoiseFloor'), ('bool', 'resetAGCIfIdle'),
('bool', 'tryResetAGC'), ('void', 'resetAGC'), ('int', 'recvRaw'),
('void', 'onSendFinished'), ('void', 'begin'),
('bool', 'getReceiveRetryDelay'), ('void', 'rxPsWatchdogCheck')]
methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n'
methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(')
for kind, name in names)
constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE))
constants += '\n' + re.search(
r'^#define RX_POWERSAVING_CONTINUOUS_FALLBACK_US\s+[^\n]+', rxps, re.MULTILINE).group(0)
start = header.index('#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LLCC68)')
settle = header[start:header.index('#endif', start) + len('#endif')]
harness = HARNESS[:HARNESS.index('int main() {')]
harness = harness.replace('#include <RadioProfiles.h>',
'#include <Dispatcher.h>\n#include <helpers/StaticPoolPacketManager.h>\n'
'#include <helpers/PowerManagementUtils.h>\n#define BD_STARTUP_RX_PACKET 1')
harness = harness.replace('struct RadioLibWrapper {', 'struct RadioLibWrapper : mesh::Radio {')
harness = harness.replace('Radio radio{&events}; Radio* _radio',
'::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(' void serviceWatchdog()',
' unsigned after = 0;\n int getStartupReason() { return 0; }\n'
' void onAfterTransmit() { ++after; }\n void serviceWatchdog()')
harness = harness.replace(' int startChannelScan()', '''
unsigned packet_length = 0, direct_arms = 0;
int startReceive() { ++direct_arms; return start_result; }
int setPreambleLength(uint16_t) { return 0; }
int finishTransmit() { return 0; }
size_t getPacketLength() { return packet_length; }
float getSNR() { return 0; }
float getRSSI() { return 0; }
int readData(uint8_t* out, int len) { for (int i=0;i<len;++i) out[i]=0x42; return 0; }
int startChannelScan()''')
harness = harness.replace('return events.pending();', 'return _loop_event_pending || events.pending();')
harness = harness.replace('bool claimRadioInterrupt() { return events.claim(); }', '''
bool claimRadioInterrupt() {
const bool pending = _loop_event_pending; _loop_event_pending = false;
return events.claim() || pending;
}''')
harness = harness.replace(' @RECOVERY_GETTER@', '''
int recvRaw(uint8_t*, int) override;
void begin() override;
void onSendFinished() override;
bool getReceiveRetryDelay(uint32_t, uint32_t&) const override;
bool registerInterruptAction() { _loop_event_pending=false; events.claim(); return true; }
uint16_t currentPreambleLength() const { return 32; }
bool isPacketReady() { return true; }
bool isInRecvMode() const override { return state == STATE_RX; }
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;
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;
uint8_t _wd_stage = 0, _wd_strikes = 0, _irq_probe_failures = 0;
unsigned long _wd_observe_until = 0;
''' + getter.replace(' override', '') + '\n @RECOVERY_GETTER@')
harness = harness.replace(
'bool recoverRadio(bool use_hard) { use_hard ? ++hard : ++soft; return true; }',
method(source, 'bool RadioLibWrapper::recoverRadio(').replace('RadioLibWrapper::', '')
+ '\n bool supportsRadioDeepInit() const { return false; }\n'
+ ' bool restoreAfterDeepInit() { recalibrateNoiseFloor(); return false; }')
harness = (harness.replace('@METHODS@', methods).replace('@NF_CONSTANTS@', constants)
.replace('@SETTLE_CONSTANT@', settle)
.replace('@RECOVERY_GETTER@', method(header, 'uint32_t getRecoveryCount() const'))
.replace('@LEGACY_TRY_RESET@', method(dispatcher, 'virtual bool tryResetAGC('))
.replace('@LEGACY_RECOVERY_GETTER@', method(dispatcher, 'virtual uint32_t getRecoveryCount(')))
harness += (ROOT / 'test/fixtures/radio_receive_retry.cpp').read_text()
folder = Path(folder)
cpp, binary = folder / 'retry.cpp', folder / 'retry'
cpp.write_text(harness.replace('\u2014', '-'), encoding='ascii')
dispatcher_source = ROOT / 'src/Dispatcher.cpp'
if mutation == 'agc_fast':
original = dispatcher_source.read_text()
alignment = (' if (adaptive && _radio->getReceiveRetryDelay(deferred_at, receive_delay)\n'
' && receive_delay > retry_delay) retry_delay = receive_delay;\n')
self.assertEqual(original.count(alignment), 1)
dispatcher_source = folder / 'Dispatcher.cpp'
dispatcher_source.write_text(original.replace(alignment, ''), encoding='ascii')
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-O1',
'-DUSE_SX1262=1', '-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie', '-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
str(cpp), str(dispatcher_source), str(ROOT / 'src/Packet.cpp'),
str(ROOT / 'src/helpers/StaticPoolPacketManager.cpp'), '-o', str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
return binary
def run_probe(self, binary):
env = os.environ.copy()
env['ASAN_OPTIONS'] = env.get('ASAN_OPTIONS', '') + ':detect_leaks=0'
return subprocess.run([str(binary)], capture_output=True, text=True, env=env, timeout=20)
def test_actual_dispatcher_and_receive_lifecycle(self):
with tempfile.TemporaryDirectory(prefix='meshcore-rx-retry-') as folder:
result = self.run_probe(self.build(folder))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS: receive retry lifecycle', result.stdout)
print(result.stdout, end='')
def test_old_hot_poll_and_pending_calibration_veto_negative_controls(self):
for mutation in ('unpaced', 'pending_veto', 'agc_fast'):
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory(
prefix='meshcore-rx-retry-negative-') as folder:
result = self.run_probe(self.build(folder, mutation))
self.assertNotEqual(result.returncode, 0)
self.assertIn('busyAfterFailure' if mutation == 'agc_fast'
else 'persistentFailure', result.stderr)
if __name__ == '__main__':
unittest.main()