diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fa815618..27e97b5b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3,6 +3,13 @@ #include // needed for PlatformIO #include +static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { + uint8_t shift = failure_count < 5 ? failure_count : 5; + if (failure_count < 6) failure_count++; + uint32_t delay_ms = 1000UL << shift; + return delay_ms > 30000UL ? 30000UL : delay_ms; +} + #ifndef RXPS_FIXED_ENABLED #define RXPS_FIXED_ENABLED 1 #endif @@ -1082,6 +1089,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _iter_started = false; _cli_rescue = false; saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; offline_queue_len = 0; app_target_ver = 0; clearPendingReqs(); @@ -2573,7 +2582,8 @@ void MyMesh::checkSerialInterface() { void MyMesh::loop() { BaseChatMesh::loop(); - if (saved_radio_apply_pending && !hasOutbound()) { + if (saved_radio_apply_pending && !hasOutbound() + && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) { // A power-saving wake can enter begin() with a complete packet already // waiting. Preserve that packet, then apply the persisted radio settings // once the receive/response path is idle. @@ -2581,6 +2591,10 @@ void MyMesh::loop() { if (applySavedRadioParams()) { radio_driver.setTxPower(_prefs.tx_power_dbm); saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + } else { + radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); } } if (has_next_ack_expiry @@ -2627,6 +2641,11 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0 - || emergency_client_repeat_packet != NULL; + if (radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor()) return true; + return hasQueuedWorkDue() || hasRetryWorkDue() + || (saved_radio_apply_pending + && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) + || (dirty_contacts_expiry != 0 && millisHasNowPassed(dirty_contacts_expiry)) + || (emergency_client_repeat_packet != NULL + && millisHasNowPassed(emergency_client_repeat_send_at)); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 4b59595f..fb325ab2 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -224,6 +224,8 @@ private: bool _iter_started; bool _cli_rescue; bool saved_radio_apply_pending; + unsigned long radio_apply_retry_at; + uint8_t radio_apply_failures; bool send_unscoped; // force un-scoped flood (instead of using send_scope) char cli_command[80]; uint8_t app_target_ver; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index c1458d2e..d86f37a0 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -105,6 +105,13 @@ #define LOW_BATTERY_CHECK_INTERVAL (30UL * 60UL * 1000UL) #define LOW_BATTERY_ALERT_INTERVAL (12UL * 60UL * 60UL * 1000UL) +static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { + uint8_t shift = failure_count < 5 ? failure_count : 5; + if (failure_count < 6) failure_count++; + uint32_t delay_ms = 1000UL << shift; + return delay_ms > 30000UL ? 30000UL : delay_ms; +} + static const char* skipLocalSpaces(const char* text) { while (text != NULL && *text == ' ') text++; return text; @@ -2322,6 +2329,14 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc active_cr = 0; saved_radio_apply_pending = false; temp_radio_handoff_pending = false; + scheduled_temp_radio_started = false; + next_scheduled_radio_time = 0; + next_scheduled_radio_check_at = 0; + scheduled_temp_radio_end_time = 0; + scheduled_temp_radio_end_check_at = 0; + scheduled_temp_radio_end_check_final = false; + scheduled_radio_retry_at = 0; + scheduled_radio_retry_failures = 0; memset(scheduled_radio_settings, 0, sizeof(scheduled_radio_settings)); _logging = false; region_load_active = false; @@ -2750,26 +2765,83 @@ bool MyMesh::applySavedRadioParams() { return applyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); } -bool MyMesh::hasStartedScheduledTempRadio() const { +void MyMesh::queueSavedRadioApply() { + saved_radio_apply_pending = true; + scheduled_radio_retry_at = 0; + scheduled_radio_retry_failures = 0; +} + +void MyMesh::refreshScheduledRadioState() { + next_scheduled_radio_time = 0; + scheduled_temp_radio_started = false; + scheduled_temp_radio_end_time = 0; for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; - if (setting.active && setting.temporary && setting.started) { - return true; + if (!setting.active) continue; + + uint32_t deadline = setting.start_time; + if (setting.temporary && setting.started) { + scheduled_temp_radio_started = true; + if (scheduled_temp_radio_end_time == 0 || setting.end_time < scheduled_temp_radio_end_time) { + scheduled_temp_radio_end_time = setting.end_time; + } + deadline = setting.end_time; + } + if (next_scheduled_radio_time == 0 || deadline < next_scheduled_radio_time) { + next_scheduled_radio_time = deadline; } } - return false; + const uint32_t now = (next_scheduled_radio_time != 0 || scheduled_temp_radio_end_time != 0) + ? getRTCClock()->getCurrentTime() + : 0; + if (next_scheduled_radio_time != 0) { + uint32_t delay_ms = 0; + if (next_scheduled_radio_time > now) { + uint32_t delay_secs = next_scheduled_radio_time - now; + // millis timers are only unambiguous for half of their rollover range. + // A minute checkpoint handles RTC corrections without per-loop RTC reads + // or table scans, while bounding a forward clock-sync delay to one minute. + if (delay_secs > SCHEDULED_RADIO_CLOCK_CHECKPOINT_SECS) { + delay_secs = SCHEDULED_RADIO_CLOCK_CHECKPOINT_SECS; + } + delay_ms = delay_secs * 1000UL; + } + next_scheduled_radio_check_at = futureMillis(delay_ms); + } else { + next_scheduled_radio_check_at = 0; + } + if (scheduled_temp_radio_end_time != 0) { + uint32_t delay_ms = 0; + scheduled_temp_radio_end_check_final = true; + if (scheduled_temp_radio_end_time > now) { + uint32_t delay_secs = scheduled_temp_radio_end_time - now; + if (delay_secs > SCHEDULED_RADIO_CLOCK_CHECKPOINT_SECS) { + delay_secs = SCHEDULED_RADIO_CLOCK_CHECKPOINT_SECS; + scheduled_temp_radio_end_check_final = false; + } + delay_ms = delay_secs * 1000UL; + } + scheduled_temp_radio_end_check_at = futureMillis(delay_ms); + } else { + scheduled_temp_radio_end_check_at = 0; + scheduled_temp_radio_end_check_final = false; + } + if (next_scheduled_radio_time == 0 && !saved_radio_apply_pending) { + scheduled_radio_retry_at = 0; + scheduled_radio_retry_failures = 0; + } +} + +bool MyMesh::hasStartedScheduledTempRadio() const { + return scheduled_temp_radio_started; } #if defined(ENABLE_OTA) bool MyMesh::isTempRadioActive() const { - const uint32_t now = getRTCClock()->getCurrentTime(); - for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { - const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; - if (setting.active && setting.temporary && setting.started && now < setting.end_time) { - return true; - } - } - return false; + return scheduled_temp_radio_started + && (!scheduled_temp_radio_end_check_final + || scheduled_temp_radio_end_check_at == 0 + || !millisHasNowPassed(scheduled_temp_radio_end_check_at)); } #endif @@ -2863,13 +2935,14 @@ void MyMesh::clearScheduledRadioSetting(int idx, bool restore_if_started) { && scheduled_radio_settings[idx].started; scheduled_radio_settings[idx].active = false; scheduled_radio_settings[idx].started = false; + refreshScheduledRadioState(); if (scheduled_radio_settings[idx].temporary && temp_radio_handoff_pending && countScheduledRadioSettings(true) == 0) { temp_radio_handoff_pending = false; - saved_radio_apply_pending = true; + queueSavedRadioApply(); } - if (restore_radio && !hasStartedScheduledTempRadio()) { - saved_radio_apply_pending = true; + if ((restore_radio || saved_radio_apply_pending) && !hasStartedScheduledTempRadio()) { + queueSavedRadioApply(); } } @@ -2984,6 +3057,11 @@ void MyMesh::addScheduledRadioParams(bool temporary, float freq, float bw, uint8 scheduled_radio_settings[slot].cr = cr; scheduled_radio_settings[slot].start_time = start_time; scheduled_radio_settings[slot].end_time = temporary ? end_time : 0; + // A newly requested schedule must not inherit the backoff of an older radio + // apply failure, especially when its deadline is sooner than that retry. + scheduled_radio_retry_at = 0; + scheduled_radio_retry_failures = 0; + refreshScheduledRadioState(); char delay[16]; formatScheduledRadioDuration(delay, sizeof(delay), start_time); @@ -3060,12 +3138,13 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch deleted++; } } - if (restore_radio && !hasStartedScheduledTempRadio()) { - saved_radio_apply_pending = true; + refreshScheduledRadioState(); + if ((restore_radio || saved_radio_apply_pending) && !hasStartedScheduledTempRadio()) { + queueSavedRadioApply(); } if (temporary && temp_radio_handoff_pending) { temp_radio_handoff_pending = false; - saved_radio_apply_pending = true; + queueSavedRadioApply(); } snprintf(reply, 160, "OK - deleted %d", deleted); return; @@ -3087,14 +3166,33 @@ void MyMesh::deleteScheduledRadioParams(bool temporary, const char* selector, ch } void MyMesh::processScheduledRadioSettings() { - // Never touch modulation registers while a packet is still on air. Due work - // remains queued and is retried on the first loop after TX completion. - if (hasOutbound()) return; + if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) return; - uint32_t now = getRTCClock()->getCurrentTime(); + const bool schedule_check_due = next_scheduled_radio_time != 0 + && (next_scheduled_radio_check_at == 0 + || millisHasNowPassed(next_scheduled_radio_check_at)); + uint32_t now = 0; + bool schedule_due = false; + if (schedule_check_due) { + now = getRTCClock()->getCurrentTime(); + schedule_due = now >= next_scheduled_radio_time; + if (!schedule_due) refreshScheduledRadioState(); + } + bool saved_apply_due = saved_radio_apply_pending && !temp_radio_handoff_pending + && !scheduled_temp_radio_started; + if (!schedule_due && !saved_apply_due) return; + + // Never touch modulation registers while a packet is still on air. Back off + // this check too; a long packet should not make the scheduler poll every loop. + if (hasOutbound()) { + scheduled_radio_retry_at = futureMillis(RADIO_APPLY_RETRY_INTERVAL_MILLIS); + return; + } + + bool apply_failed = false; bool saved_params_changed = false; - while (true) { + while (schedule_due) { int due_idx = -1; for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; @@ -3125,36 +3223,46 @@ void MyMesh::processScheduledRadioSettings() { // persisted SF/BW. Manual RX/sleep timings intentionally remain fixed. CommonCLI::recalculateRxPowerSavingFromLevel(&_prefs); savePrefs(); - saved_radio_apply_pending = true; + queueSavedRadioApply(); } - for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { - ScheduledRadioSetting& setting = scheduled_radio_settings[i]; - if (setting.active && setting.temporary && setting.started && now >= setting.end_time) { - setting.active = false; - setting.started = false; - saved_radio_apply_pending = true; - } - } - - for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { - ScheduledRadioSetting& setting = scheduled_radio_settings[i]; - if (setting.active && setting.temporary && !setting.started && now >= setting.start_time) { - if (now >= setting.end_time) { + if (schedule_due) { + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary && setting.started && now >= setting.end_time) { setting.active = false; - if (temp_radio_handoff_pending) { + setting.started = false; + queueSavedRadioApply(); + } + } + + for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { + ScheduledRadioSetting& setting = scheduled_radio_settings[i]; + if (setting.active && setting.temporary && !setting.started && now >= setting.start_time) { + if (now >= setting.end_time) { + setting.active = false; + if (temp_radio_handoff_pending) { + temp_radio_handoff_pending = false; + queueSavedRadioApply(); + } + } else if (applyRadioParams(setting.freq, setting.bw, setting.sf, setting.cr)) { + setting.started = true; temp_radio_handoff_pending = false; + } else { + // setParams() can fail after changing only part of the modulation + // tuple. Restore the saved tuple if this temporary window expires + // before a later retry succeeds. saved_radio_apply_pending = true; + apply_failed = true; + break; } - } else if (applyRadioParams(setting.freq, setting.bw, setting.sf, setting.cr)) { - setting.started = true; - temp_radio_handoff_pending = false; } } } + refreshScheduledRadioState(); if (saved_radio_apply_pending && !temp_radio_handoff_pending - && !hasStartedScheduledTempRadio()) { + && !scheduled_temp_radio_started && !apply_failed) { // If begin() deferred the saved params to preserve a wake packet, its gain // update was deferred for the same reason. Retry both at the first safe // handoff; unsupported boosted-gain modes remain harmless here. @@ -3162,8 +3270,16 @@ void MyMesh::processScheduledRadioSettings() { if (applySavedRadioParams()) { radio_driver.setTxPower(_prefs.tx_power_dbm); saved_radio_apply_pending = false; + } else { + apply_failed = true; } } + if (apply_failed) { + scheduled_radio_retry_at = futureMillis(nextRadioApplyRetryDelay(scheduled_radio_retry_failures)); + } else { + scheduled_radio_retry_at = 0; + scheduled_radio_retry_failures = 0; + } } bool MyMesh::isMillisTimerDue(unsigned long timestamp) const { @@ -3171,27 +3287,12 @@ bool MyMesh::isMillisTimerDue(unsigned long timestamp) const { } bool MyMesh::hasScheduledRadioWorkDue() const { - if (saved_radio_apply_pending) return true; - - uint32_t now = getRTCClock()->getCurrentTime(); - for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { - const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; - if (!setting.active) { - continue; - } - if (!setting.temporary && now >= setting.start_time) { - return true; - } - if (setting.temporary) { - if (!setting.started && now >= setting.start_time) { - return true; - } - if (setting.started && now >= setting.end_time) { - return true; - } - } - } - return false; + if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) return false; + if (saved_radio_apply_pending && !temp_radio_handoff_pending + && !scheduled_temp_radio_started) return true; + return next_scheduled_radio_time != 0 + && (next_scheduled_radio_check_at == 0 + || millisHasNowPassed(next_scheduled_radio_check_at)); } uint32_t MyMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const { @@ -3207,32 +3308,11 @@ uint32_t MyMesh::limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; } -uint32_t MyMesh::limitSleepToRtcTime(uint32_t timestamp, uint32_t sleep_secs) const { - if (!timestamp || sleep_secs == 0) { - return sleep_secs; - } - uint32_t now = getRTCClock()->getCurrentTime(); - if (now >= timestamp) { - return 0; - } - uint32_t remaining_secs = timestamp - now; - return remaining_secs < sleep_secs ? remaining_secs : sleep_secs; -} - uint32_t MyMesh::limitSleepToScheduledRadioWork(uint32_t sleep_secs) const { - for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { - const ScheduledRadioSetting& setting = scheduled_radio_settings[i]; - if (!setting.active) { - continue; - } - if (!setting.temporary || !setting.started) { - sleep_secs = limitSleepToRtcTime(setting.start_time, sleep_secs); - } - if (setting.temporary && setting.started) { - sleep_secs = limitSleepToRtcTime(setting.end_time, sleep_secs); - } + if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at)) { + return limitSleepToMillisTimer(scheduled_radio_retry_at, sleep_secs); } - return sleep_secs; + return limitSleepToMillisTimer(next_scheduled_radio_check_at, sleep_secs); } uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { @@ -3241,6 +3321,16 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { } uint32_t sleep_secs = max_secs; + uint32_t queue_delay_ms; + if (getNextQueueWakeDelay(queue_delay_ms)) { + uint32_t queue_delay_secs = (queue_delay_ms + 999UL) / 1000UL; + if (queue_delay_secs < sleep_secs) sleep_secs = queue_delay_secs; + } + uint32_t retry_delay_ms; + if (getNextRetryWakeDelay(retry_delay_ms)) { + uint32_t retry_delay_secs = (retry_delay_ms + 999UL) / 1000UL; + if (retry_delay_secs < sleep_secs) sleep_secs = retry_delay_secs; + } sleep_secs = limitSleepToMillisTimer(next_flood_advert, sleep_secs); sleep_secs = limitSleepToMillisTimer(next_local_advert, sleep_secs); sleep_secs = limitSleepToMillisTimer(dirty_contacts_expiry, sleep_secs); @@ -3253,6 +3343,8 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { } void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + scheduled_radio_retry_at = 0; + scheduled_radio_retry_failures = 0; bool cancelled_started_temp = false; for (int i = 0; i < MAX_SCHEDULED_RADIO_SETTINGS; i++) { if (scheduled_radio_settings[i].active && scheduled_radio_settings[i].temporary) { @@ -3272,8 +3364,9 @@ void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, if (slot < 0) { if (temp_radio_handoff_pending) { temp_radio_handoff_pending = false; - saved_radio_apply_pending = true; + queueSavedRadioApply(); } + refreshScheduledRadioState(); return; } @@ -3287,6 +3380,7 @@ void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, scheduled_radio_settings[slot].cr = cr; scheduled_radio_settings[slot].start_time = start_time; scheduled_radio_settings[slot].end_time = start_time + ((uint32_t)timeout_mins * 60); + refreshScheduledRadioState(); } bool MyMesh::formatFileSystem() { @@ -4291,7 +4385,7 @@ bool MyMesh::hasPendingWork() const { #endif if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle if (radio_driver.isCalibratingNoiseFloor()) return true; // keep MCU awake for the noise-floor window - if (_mgr->getOutboundTotal() > 0) return true; + if (hasQueuedWorkDue() || hasRetryWorkDue()) return true; if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true; if (isMillisTimerDue(dirty_contacts_expiry)) return true; if (isMillisTimerDue(next_recent_repeater_sweep)) return true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 55dd3ea5..bc693110 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -115,6 +115,8 @@ struct NeighbourInfo { #define RECENT_REPEATER_MAX_AGE_MILLIS (24UL * 60UL * 60UL * 1000UL) #define RECENT_REPEATER_SWEEP_INTERVAL_MILLIS (3UL * 60UL * 60UL * 1000UL) +#define RADIO_APPLY_RETRY_INTERVAL_MILLIS 1000UL +#define SCHEDULED_RADIO_CLOCK_CHECKPOINT_SECS 60UL class MyMesh : public mesh::Mesh, public CommonCLICallbacks { struct ScheduledRadioSetting { @@ -189,6 +191,14 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t active_cr; // live CR, including temporary radio overrides bool saved_radio_apply_pending; bool temp_radio_handoff_pending; + bool scheduled_temp_radio_started; + uint32_t next_scheduled_radio_time; + unsigned long next_scheduled_radio_check_at; + uint32_t scheduled_temp_radio_end_time; + unsigned long scheduled_temp_radio_end_check_at; + bool scheduled_temp_radio_end_check_final; + unsigned long scheduled_radio_retry_at; + uint8_t scheduled_radio_retry_failures; ScheduledRadioSetting scheduled_radio_settings[MAX_SCHEDULED_RADIO_SETTINGS]; int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_MQTT_BRIDGE) @@ -265,6 +275,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { bool isLooped(const mesh::Packet* packet, const uint8_t max_counters[]); bool applyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr); bool applySavedRadioParams(); + void queueSavedRadioApply(); + void refreshScheduledRadioState(); void processScheduledRadioSettings(); bool isMillisTimerDue(unsigned long timestamp) const; void loadFloodChannelBlocks(); @@ -283,7 +295,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void formatFloodChannelBlockDetail(char* reply, int idx) const; bool hasScheduledRadioWorkDue() const; uint32_t limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_secs) const; - uint32_t limitSleepToRtcTime(uint32_t timestamp, uint32_t sleep_secs) const; uint32_t limitSleepToScheduledRadioWork(uint32_t sleep_secs) const; bool hasStartedScheduledTempRadio() const; int findFreeScheduledRadioSlot() const; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index eabf4abe..bea1e402 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,6 +1,13 @@ #include "MyMesh.h" #include +static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { + uint8_t shift = failure_count < 5 ? failure_count : 5; + if (failure_count < 6) failure_count++; + uint32_t delay_ms = 1000UL << shift; + return delay_ms > 30000UL ? 30000UL : delay_ms; +} + #define REPLY_DELAY_MILLIS 1500 #define PUSH_NOTIFY_DELAY_MILLIS 2000 #define SYNC_PUSH_INTERVAL 1200 @@ -655,6 +662,8 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc active_cr = LORA_CR; temp_radio_applied = false; saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; recv_pkt_region = NULL; // defaults @@ -853,6 +862,8 @@ void MyMesh::sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, ui } void MyMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + radio_apply_retry_at = 0; + radio_apply_failures = 0; set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params pending_freq = freq; pending_bw = bw; @@ -1146,12 +1157,16 @@ void MyMesh::loop() { } const bool revert_radio_due = revert_radio_at && millisHasNowPassed(revert_radio_at); + const bool radio_apply_ready = !radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at); + bool radio_apply_failed = false; if (revert_radio_due && !temp_radio_applied) { // The temporary window ended before it could be applied. Drop both timers // so a previously busy radio cannot switch to the expired channel later. set_radio_at = revert_radio_at = 0; + radio_apply_retry_at = 0; + radio_apply_failures = 0; MESH_DEBUG_PRINTLN("Temp radio params expired before apply"); - } else if (revert_radio_due && !hasOutbound()) { + } else if (revert_radio_due && !hasOutbound() && radio_apply_ready) { if (applySavedRadioParams()) { if (saved_radio_apply_pending) { radio_driver.setTxPower(_prefs.tx_power_dbm); @@ -1159,9 +1174,14 @@ void MyMesh::loop() { set_radio_at = revert_radio_at = 0; temp_radio_applied = false; saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; MESH_DEBUG_PRINTLN("Radio params restored"); + } else { + radio_apply_failed = true; } - } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound()) { + } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound() + && radio_apply_ready) { uint32_t rx_us = _prefs.rx_ps_rx_us; uint32_t sleep_us = _prefs.rx_ps_sleep_us; bool timing_ok = true; @@ -1179,14 +1199,30 @@ void MyMesh::loop() { set_radio_at = 0; active_cr = pending_cr; temp_radio_applied = true; + radio_apply_retry_at = 0; + radio_apply_failures = 0; MESH_DEBUG_PRINTLN("Temp radio params"); + } else { + // A failed setParams() may have applied only a prefix of the tuple. + // Ensure expiry restores the complete saved configuration. + saved_radio_apply_pending = true; + radio_apply_failed = true; } } if (saved_radio_apply_pending && !temp_radio_applied && !hasOutbound() - && applySavedRadioParams()) { - radio_driver.setTxPower(_prefs.tx_power_dbm); - saved_radio_apply_pending = false; + && radio_apply_ready && !radio_apply_failed) { + if (applySavedRadioParams()) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + } else { + radio_apply_failed = true; + } + } + if (radio_apply_failed) { + radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); } // is pending dirty contacts write needed? @@ -1230,13 +1266,28 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { } uint32_t sleep_secs = max_secs; + uint32_t queue_delay_ms; + if (getNextQueueWakeDelay(queue_delay_ms)) { + uint32_t queue_delay_secs = (queue_delay_ms + 999UL) / 1000UL; + if (queue_delay_secs < sleep_secs) sleep_secs = queue_delay_secs; + } + uint32_t retry_delay_ms; + if (getNextRetryWakeDelay(retry_delay_ms)) { + uint32_t retry_delay_secs = (retry_delay_ms + 999UL) / 1000UL; + if (retry_delay_secs < sleep_secs) sleep_secs = retry_delay_secs; + } if (acl.getNumClients() > 0) { sleep_secs = limitSleepToMillisTimer(next_push, sleep_secs); } sleep_secs = limitSleepToMillisTimer(next_flood_advert, sleep_secs); sleep_secs = limitSleepToMillisTimer(next_local_advert, sleep_secs); - sleep_secs = limitSleepToMillisTimer(set_radio_at, sleep_secs); - sleep_secs = limitSleepToMillisTimer(revert_radio_at, sleep_secs); + const bool radio_apply_backoff = radio_apply_retry_at && !millisHasNowPassed(radio_apply_retry_at); + if (radio_apply_backoff) { + sleep_secs = limitSleepToMillisTimer(radio_apply_retry_at, sleep_secs); + } else { + sleep_secs = limitSleepToMillisTimer(set_radio_at, sleep_secs); + sleep_secs = limitSleepToMillisTimer(revert_radio_at, sleep_secs); + } sleep_secs = limitSleepToMillisTimer(dirty_contacts_expiry, sleep_secs); return sleep_secs; } @@ -1248,9 +1299,12 @@ bool MyMesh::hasPendingWork() const { #endif if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle if (radio_driver.isCalibratingNoiseFloor()) return true; // keep MCU awake for the noise-floor window - if (_mgr->getOutboundTotal() > 0) return true; + if (hasQueuedWorkDue() || hasRetryWorkDue()) return true; if (acl.getNumClients() > 0 && isMillisTimerDue(next_push)) return true; if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return true; - if (isMillisTimerDue(set_radio_at) || isMillisTimerDue(revert_radio_at)) return true; + const bool radio_apply_backoff = radio_apply_retry_at && !isMillisTimerDue(radio_apply_retry_at); + if (!radio_apply_backoff + && (isMillisTimerDue(set_radio_at) || isMillisTimerDue(revert_radio_at) + || (saved_radio_apply_pending && !temp_radio_applied))) return true; return isMillisTimerDue(dirty_contacts_expiry); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 0ca5c2dc..2d5691f3 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -125,6 +125,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { uint8_t active_cr; bool temp_radio_applied; bool saved_radio_apply_pending; + unsigned long radio_apply_retry_at; + uint8_t radio_apply_failures; int matching_peer_indexes[MAX_CLIENTS]; #ifdef WITH_MQTT_BRIDGE MQTTBridge* bridge; diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 4020362b..5df0480c 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1,5 +1,12 @@ #include "SensorMesh.h" +static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { + uint8_t shift = failure_count < 5 ? failure_count : 5; + if (failure_count < 6) failure_count++; + uint32_t delay_ms = 1000UL << shift; + return delay_ms > 30000UL ? 30000UL : delay_ms; +} + /* ------------------------------ Config -------------------------------- */ #ifndef LORA_FREQ @@ -753,6 +760,8 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise active_cr = LORA_CR; temp_radio_applied = false; saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; // defaults memset(&_prefs, 0, sizeof(_prefs)); @@ -875,6 +884,8 @@ void SensorMesh::saveIdentity(const mesh::LocalIdentity& new_id) { } void SensorMesh::applyTempRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, int timeout_mins) { + radio_apply_retry_at = 0; + radio_apply_failures = 0; set_radio_at = futureMillis(2000); // give CLI reply some time to be sent back, before applying temp radio params pending_freq = freq; pending_bw = bw; @@ -987,11 +998,15 @@ void SensorMesh::loop() { } const bool revert_radio_due = revert_radio_at && millisHasNowPassed(revert_radio_at); + const bool radio_apply_ready = !radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at); + bool radio_apply_failed = false; if (revert_radio_due && !temp_radio_applied) { // Never apply a temporary channel after its window has already ended. set_radio_at = revert_radio_at = 0; + radio_apply_retry_at = 0; + radio_apply_failures = 0; MESH_DEBUG_PRINTLN("Temp radio params expired before apply"); - } else if (revert_radio_due && !hasOutbound()) { + } else if (revert_radio_due && !hasOutbound() && radio_apply_ready) { if (applySavedRadioParams()) { if (saved_radio_apply_pending) { radio_driver.setTxPower(_prefs.tx_power_dbm); @@ -999,9 +1014,14 @@ void SensorMesh::loop() { set_radio_at = revert_radio_at = 0; temp_radio_applied = false; saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; MESH_DEBUG_PRINTLN("Radio params restored"); + } else { + radio_apply_failed = true; } - } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound()) { + } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound() + && radio_apply_ready) { uint32_t rx_us = _prefs.rx_ps_rx_us; uint32_t sleep_us = _prefs.rx_ps_sleep_us; bool timing_ok = true; @@ -1019,14 +1039,30 @@ void SensorMesh::loop() { set_radio_at = 0; active_cr = pending_cr; temp_radio_applied = true; + radio_apply_retry_at = 0; + radio_apply_failures = 0; MESH_DEBUG_PRINTLN("Temp radio params"); + } else { + // A failed setParams() may have applied only a prefix of the tuple. + // Ensure expiry restores the complete saved configuration. + saved_radio_apply_pending = true; + radio_apply_failed = true; } } if (saved_radio_apply_pending && !temp_radio_applied && !hasOutbound() - && applySavedRadioParams()) { - radio_driver.setTxPower(_prefs.tx_power_dbm); - saved_radio_apply_pending = false; + && radio_apply_ready && !radio_apply_failed) { + if (applySavedRadioParams()) { + radio_driver.setTxPower(_prefs.tx_power_dbm); + saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + } else { + radio_apply_failed = true; + } + } + if (radio_apply_failed) { + radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); } uint32_t curr = getRTCClock()->getCurrentTime(); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 6916ee37..9cf8fff5 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -165,6 +165,8 @@ private: uint8_t active_cr; bool temp_radio_applied; bool saved_radio_apply_pending; + unsigned long radio_apply_retry_at; + uint8_t radio_apply_failures; bool applySavedRadioParams(); diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index be02fca5..a7c9f5a8 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -13,7 +13,7 @@ namespace mesh { #define MIN_TX_BUDGET_AIRTIME_DIV 2 // require at least 1/N of estimated airtime as budget before TX #ifndef NOISE_FLOOR_CALIB_INTERVAL - #define NOISE_FLOOR_CALIB_INTERVAL 2000 // 2 seconds + #define NOISE_FLOOR_CALIB_INTERVAL 30000 // request at most every 30 seconds #endif void Dispatcher::begin() { @@ -80,6 +80,42 @@ uint32_t Dispatcher::getCADFailMaxDuration() const { return 4000; // 4 seconds } +bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { + const uint32_t now = _ms->getMillis(); + bool found = false; + uint32_t shortest_delay = 0; + + if (outbound != NULL) { + // TX completion/timeout still needs the normal fast lifecycle path. + delay_millis = 0; + return true; + } + + uint32_t scheduled_for; + if (_mgr->getNextOutboundTime(now, scheduled_for)) { + int32_t signed_queue_delay = (int32_t)(scheduled_for - now); + uint32_t outbound_delay = signed_queue_delay > 0 ? (uint32_t)signed_queue_delay : 0; + int32_t signed_tx_delay = (int32_t)(next_tx_time - now); + if (signed_tx_delay > 0 && (uint32_t)signed_tx_delay > outbound_delay) { + outbound_delay = (uint32_t)signed_tx_delay; + } + shortest_delay = outbound_delay; + found = true; + } + + if (_mgr->getNextInboundTime(now, scheduled_for)) { + int32_t signed_inbound_delay = (int32_t)(scheduled_for - now); + uint32_t inbound_delay = signed_inbound_delay > 0 ? (uint32_t)signed_inbound_delay : 0; + if (!found || inbound_delay < shortest_delay) { + shortest_delay = inbound_delay; + found = true; + } + } + + if (found) delay_millis = shortest_delay; + return found; +} + #ifdef WITH_MQTT_BRIDGE uint32_t Dispatcher::getRadioWatchdogMillis() const { return RADIO_WATCHDOG_MS; @@ -191,9 +227,17 @@ void Dispatcher::loop() { // check inbound (delayed) queue { - Packet* pkt = _mgr->getNextInbound(_ms->getMillis()); - if (pkt) { - processRecvPacket(pkt); + const uint32_t now = _ms->getMillis(); + uint32_t next_inbound; + // Packet managers with deadline support avoid a full priority scan while + // every delayed packet is still in the future. Legacy managers retain the + // original getNextInbound() behavior. + if (!_mgr->getNextInboundTime(now, next_inbound) + || (int32_t)(next_inbound - now) <= 0) { + Packet* pkt = _mgr->getNextInbound(now); + if (pkt) { + processRecvPacket(pkt); + } } } checkRecv(); @@ -340,7 +384,15 @@ void Dispatcher::processRecvPacket(Packet* pkt) { } void Dispatcher::checkSend() { - if (_mgr->getOutboundCount(_ms->getMillis()) == 0) return; + const uint32_t now = _ms->getMillis(); + uint32_t next_outbound; + if (_mgr->getNextOutboundTime(now, next_outbound)) { + if ((int32_t)(next_outbound - now) > 0) return; + } else if (_mgr->getOutboundCount(now) == 0) { + // Compatibility fallback for custom PacketManager implementations that do + // not provide the optional O(1) deadline query. + return; + } updateTxBudget(); diff --git a/src/Dispatcher.h b/src/Dispatcher.h index fd9fc017..a3254653 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -136,11 +136,24 @@ public: virtual Packet* getNextDroppedOutbound() { return NULL; } virtual int getOutboundCount(uint32_t now) const = 0; virtual int getOutboundTotal() const = 0; + // Returns the earliest runnable time in the queue. A queue with any overdue + // entry reports `now`, which keeps rollover-safe timer comparisons local to + // the queue implementation. + virtual bool getNextOutboundTime(uint32_t now, uint32_t& scheduled_for) const { + (void)now; + (void)scheduled_for; + return false; + } virtual int getFreeCount() const = 0; virtual Packet* getOutboundByIdx(int i) = 0; virtual Packet* removeOutboundByIdx(int i) = 0; virtual void queueInbound(Packet* packet, uint32_t scheduled_for) = 0; virtual Packet* getNextInbound(uint32_t now) = 0; + virtual bool getNextInboundTime(uint32_t now, uint32_t& scheduled_for) const { + (void)now; + (void)scheduled_for; + return false; + } }; typedef uint32_t DispatcherAction; @@ -242,6 +255,14 @@ protected: virtual uint32_t getRadioWatchdogMillis() const; // observer-only radio recovery #endif const Packet* getOutboundInFlight() const { return outbound; } + // Milliseconds until Dispatcher can next make progress on a queued packet. + // This includes queue schedules, delayed inbound processing, airtime-budget + // waits, and short channel-busy deferrals. + bool getNextQueueWakeDelay(uint32_t& delay_millis) const; + bool hasQueuedWorkDue() const { + uint32_t delay_millis; + return getNextQueueWakeDelay(delay_millis) && delay_millis == 0; + } bool queueOutboundPacket(Packet* packet, uint8_t priority, uint32_t delay_millis); bool tryParsePacket(Packet* pkt, const uint8_t* raw, int len); diff --git a/src/Mesh.cpp b/src/Mesh.cpp index c24ce545..451c253c 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -140,6 +140,10 @@ uint8_t Mesh::getOtaHopLimit() const { return ota::ota_ctx().manager.max_hops(); #endif void Mesh::begin() { + _waiting_direct_retry_count = 0; + _waiting_flood_retry_count = 0; + _next_direct_retry_timeout = 0; + _next_flood_retry_timeout = 0; for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { _direct_retries[i].packet = NULL; _direct_retries[i].trigger_packet = NULL; @@ -192,12 +196,12 @@ void Mesh::begin() { void Mesh::loop() { Dispatcher::loop(); - for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { - if (!_direct_retries[i].active) { - continue; - } - - if (_direct_retries[i].waiting_final_echo) { + if (_waiting_direct_retry_count != 0 + && millisHasNowPassed(_next_direct_retry_timeout)) { + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active || !_direct_retries[i].waiting_final_echo) { + continue; + } if (!millisHasNowPassed(_direct_retries[i].retry_at)) { continue; } @@ -213,42 +217,15 @@ void Mesh::loop() { _direct_retries[i].payload_type); onDirectRetryFailed(_direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len); clearDirectRetrySlot(i); - continue; - } - - Packet* tracked_packet = _direct_retries[i].queued - ? _direct_retries[i].packet - : _direct_retries[i].trigger_packet; - if (tracked_packet == NULL - || (!isDirectRetryQueued(tracked_packet) && tracked_packet != getOutboundInFlight())) { - uint32_t elapsed_millis = _direct_retries[i].retry_started_at == 0 - ? 0 - : (uint32_t)(_ms->getMillis() - _direct_retries[i].retry_started_at); - uint8_t attempt = _direct_retries[i].queued - ? _direct_retries[i].retry_attempts_sent + 1 - : 1; - onDirectRetryEvent("dropped_queue_removed", NULL, elapsed_millis, attempt, - _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len, - _direct_retries[i].payload_type); - onDirectRetryEvent("failure", NULL, elapsed_millis, attempt, - _direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len, - _direct_retries[i].payload_type); - // A local queue eviction says nothing about the next hop's RF quality. - clearDirectRetrySlot(i); - continue; - } - - if (!_direct_retries[i].queued || !millisHasNowPassed(_direct_retries[i].retry_at)) { - continue; } } - for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { - if (!_flood_retries[i].active) { - continue; - } - - if (_flood_retries[i].waiting_final_echo) { + if (_waiting_flood_retry_count != 0 + && millisHasNowPassed(_next_flood_retry_timeout)) { + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (!_flood_retries[i].active || !_flood_retries[i].waiting_final_echo) { + continue; + } if (!millisHasNowPassed(_flood_retries[i].retry_at)) { continue; } @@ -259,28 +236,6 @@ void Mesh::loop() { onFloodRetryEvent("failed_all_tries", _flood_retries[i].packet, elapsed_millis, _flood_retries[i].retry_attempts_sent); onFloodRetryEvent("failure", _flood_retries[i].packet, elapsed_millis, _flood_retries[i].retry_attempts_sent); clearFloodRetrySlot(i); - continue; - } - - Packet* tracked_packet = _flood_retries[i].queued - ? _flood_retries[i].packet - : _flood_retries[i].trigger_packet; - if (tracked_packet == NULL - || (!isFloodRetryQueued(tracked_packet) && tracked_packet != getOutboundInFlight())) { - uint32_t elapsed_millis = _flood_retries[i].retry_started_at == 0 - ? 0 - : (uint32_t)(_ms->getMillis() - _flood_retries[i].retry_started_at); - uint8_t attempt = _flood_retries[i].queued - ? _flood_retries[i].retry_attempts_sent + 1 - : 1; - onFloodRetryEvent("dropped_queue_removed", NULL, elapsed_millis, attempt); - onFloodRetryEvent("failure", NULL, elapsed_millis, attempt); - clearFloodRetrySlot(i); - continue; - } - - if (!_flood_retries[i].queued || !millisHasNowPassed(_flood_retries[i].retry_at)) { - continue; } } #if defined(ENABLE_OTA) @@ -904,6 +859,13 @@ void Mesh::routeDirectRecvAcks(Packet* packet, uint32_t delay_millis) { } void Mesh::clearDirectRetrySlot(int idx) { + const bool rebuild_timeout = _direct_retries[idx].active + && _direct_retries[idx].waiting_final_echo + && _direct_retries[idx].retry_at == _next_direct_retry_timeout; + if (_direct_retries[idx].active && _direct_retries[idx].waiting_final_echo + && _waiting_direct_retry_count > 0) { + _waiting_direct_retry_count--; + } _direct_retries[idx].packet = NULL; _direct_retries[idx].trigger_packet = NULL; _direct_retries[idx].retry_started_at = 0; @@ -922,15 +884,24 @@ void Mesh::clearDirectRetrySlot(int idx) { _direct_retries[idx].waiting_final_echo = false; _direct_retries[idx].queued = false; _direct_retries[idx].active = false; + if (rebuild_timeout) rebuildNextDirectRetryTimeout(); } -bool Mesh::isDirectRetryQueued(const Packet* packet) const { - for (int i = 0; i < _mgr->getOutboundTotal(); i++) { - if (_mgr->getOutboundByIdx(i) == packet) { - return true; +void Mesh::rebuildNextDirectRetryTimeout() { + bool found = false; + uint32_t shortest_delay = 0; + const uint32_t now = _ms->getMillis(); + for (int i = 0; i < MAX_DIRECT_RETRY_SLOTS; i++) { + if (!_direct_retries[i].active || !_direct_retries[i].waiting_final_echo) continue; + int32_t signed_delay = (int32_t)(_direct_retries[i].retry_at - now); + uint32_t delay = signed_delay > 0 ? (uint32_t)signed_delay : 0; + if (!found || delay < shortest_delay) { + shortest_delay = delay; + _next_direct_retry_timeout = _direct_retries[i].retry_at; + found = true; } } - return false; + if (!found) _next_direct_retry_timeout = 0; } bool Mesh::usePassiveChannelCheck(const Packet* packet) const { @@ -956,6 +927,27 @@ bool Mesh::usePassiveChannelCheck(const Packet* packet) const { return false; } +bool Mesh::getNextRetryWakeDelay(uint32_t& delay_millis) const { + const uint32_t now = _ms->getMillis(); + bool found = false; + uint32_t shortest_delay = 0; + + if (_waiting_direct_retry_count != 0) { + int32_t signed_delay = (int32_t)(_next_direct_retry_timeout - now); + shortest_delay = signed_delay > 0 ? (uint32_t)signed_delay : 0; + found = true; + } + if (_waiting_flood_retry_count != 0) { + int32_t signed_delay = (int32_t)(_next_flood_retry_timeout - now); + uint32_t flood_delay = signed_delay > 0 ? (uint32_t)signed_delay : 0; + if (!found || flood_delay < shortest_delay) shortest_delay = flood_delay; + found = true; + } + + if (found) delay_millis = shortest_delay; + return found; +} + void Mesh::calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) const { uint8_t type = packet->getPayloadType(); Utils::sha256(dest_key, MAX_HASH_SIZE, &type, 1, packet->payload, packet->payload_len); @@ -1058,6 +1050,11 @@ void Mesh::armDirectRetryOnSendComplete(const Packet* packet) { _direct_retries[i].packet = NULL; _direct_retries[i].retry_at = futureMillis(_direct_retries[i].retry_delay); _direct_retries[i].waiting_final_echo = true; + if (_waiting_direct_retry_count == 0 + || (int32_t)(_direct_retries[i].retry_at - _next_direct_retry_timeout) < 0) { + _next_direct_retry_timeout = _direct_retries[i].retry_at; + } + _waiting_direct_retry_count++; _direct_retries[i].queued = false; continue; } @@ -1345,7 +1342,13 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool } void Mesh::clearFloodRetrySlot(int idx) { + const bool rebuild_timeout = _flood_retries[idx].active + && _flood_retries[idx].waiting_final_echo + && _flood_retries[idx].retry_at == _next_flood_retry_timeout; if (_flood_retries[idx].active) { + if (_flood_retries[idx].waiting_final_echo && _waiting_flood_retry_count > 0) { + _waiting_flood_retry_count--; + } onFloodRetrySlotReleased(_flood_retries[idx].retry_key); } if (_flood_retries[idx].waiting_final_echo && _flood_retries[idx].packet != NULL) { @@ -1363,6 +1366,24 @@ void Mesh::clearFloodRetrySlot(int idx) { _flood_retries[idx].waiting_final_echo = false; _flood_retries[idx].queued = false; _flood_retries[idx].active = false; + if (rebuild_timeout) rebuildNextFloodRetryTimeout(); +} + +void Mesh::rebuildNextFloodRetryTimeout() { + bool found = false; + uint32_t shortest_delay = 0; + const uint32_t now = _ms->getMillis(); + for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) { + if (!_flood_retries[i].active || !_flood_retries[i].waiting_final_echo) continue; + int32_t signed_delay = (int32_t)(_flood_retries[i].retry_at - now); + uint32_t delay = signed_delay > 0 ? (uint32_t)signed_delay : 0; + if (!found || delay < shortest_delay) { + shortest_delay = delay; + _next_flood_retry_timeout = _flood_retries[i].retry_at; + found = true; + } + } + if (!found) _next_flood_retry_timeout = 0; } bool Mesh::cancelActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) { @@ -1442,15 +1463,6 @@ bool Mesh::hasActiveRetries(const uint8_t retry_key[MAX_HASH_SIZE]) const { return false; } -bool Mesh::isFloodRetryQueued(const Packet* packet) const { - for (int i = 0; i < _mgr->getOutboundTotal(); i++) { - if (_mgr->getOutboundByIdx(i) == packet) { - return true; - } - } - return false; -} - bool Mesh::isFloodRetryEchoTarget(const Packet* packet, uint8_t progress_marker) const { return packet->isRouteFlood() && packet->getPathHashCount() > progress_marker; } @@ -1523,6 +1535,11 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) { _flood_retries[i].packet = NULL; _flood_retries[i].retry_at = futureMillis(_flood_retries[i].retry_delay); _flood_retries[i].waiting_final_echo = true; + if (_waiting_flood_retry_count == 0 + || (int32_t)(_flood_retries[i].retry_at - _next_flood_retry_timeout) < 0) { + _next_flood_retry_timeout = _flood_retries[i].retry_at; + } + _waiting_flood_retry_count++; _flood_retries[i].queued = false; continue; } diff --git a/src/Mesh.h b/src/Mesh.h index c178284c..90b651ee 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -92,11 +92,16 @@ class Mesh : public Dispatcher { MeshTables* _tables; DirectRetryEntry _direct_retries[MAX_DIRECT_RETRY_SLOTS]; FloodRetryEntry _flood_retries[MAX_FLOOD_RETRY_SLOTS]; + uint8_t _waiting_direct_retry_count; + uint8_t _waiting_flood_retry_count; + unsigned long _next_direct_retry_timeout; + unsigned long _next_flood_retry_timeout; void removePathPrefix(Packet* packet, uint8_t prefix_count); void routeDirectRecvAcks(Packet* packet, uint32_t delay_millis); + void rebuildNextDirectRetryTimeout(); + void rebuildNextFloodRetryTimeout(); void clearDirectRetrySlot(int idx); - bool isDirectRetryQueued(const Packet* packet) const; void calculateDirectRetryKey(const Packet* packet, uint8_t* dest_key) const; bool cancelDirectRetryOnEcho(const Packet* packet); void armDirectRetryOnSendComplete(const Packet* packet); @@ -106,7 +111,6 @@ class Mesh : public Dispatcher { bool canDecodeDirectPayloadForSelf(const Packet* packet); void maybeScheduleDirectRetry(const Packet* packet, uint8_t priority, bool final_hop_retry = false); void clearFloodRetrySlot(int idx); - bool isFloodRetryQueued(const Packet* packet) const; bool cancelFloodRetryOnEcho(const Packet* packet); void armFloodRetryOnSendComplete(const Packet* packet); void clearPendingFloodRetryOnSendFail(const Packet* packet); @@ -120,6 +124,11 @@ protected: void onSendFail(Packet* packet) override; bool allowPacketTransmit(const Packet* packet) const override; bool usePassiveChannelCheck(const Packet* packet) const override; + bool getNextRetryWakeDelay(uint32_t& delay_millis) const; + bool hasRetryWorkDue() const { + uint32_t delay_millis; + return getNextRetryWakeDelay(delay_millis) && delay_millis == 0; + } virtual uint32_t getCADFailRetryDelay() const override; diff --git a/src/helpers/SimpleMeshTables.h b/src/helpers/SimpleMeshTables.h index ba4fc3e1..0ca6f39d 100644 --- a/src/helpers/SimpleMeshTables.h +++ b/src/helpers/SimpleMeshTables.h @@ -329,44 +329,46 @@ public: prefix_len = MAX_ROUTE_HASH_BYTES; } - // Keep exact prefixes distinct so a 1-byte path prefix does not collapse - // independent 2/3-byte repeaters that share the same first byte. + int empty_idx = -1; + int oldest_idx = 0; +#if ARDUINO + const uint32_t now = millis(); + uint32_t oldest_age = 0; + bool have_oldest = false; +#endif + + // Find a match, the first empty slot, and the oldest occupied slot in one + // pass. Keep exact prefixes distinct so a 1-byte path prefix does not + // collapse independent 2/3-byte repeaters that share the same first byte. for (int i = 0; i < _max_recent_repeaters; i++) { RecentRepeaterInfo& existing = _recent_repeaters[i]; + if (existing.prefix_len == 0) { + if (empty_idx < 0) empty_idx = i; + continue; + } if (existing.prefix_len != prefix_len || memcmp(existing.prefix, prefix, prefix_len) != 0) { + #if ARDUINO + uint32_t age = (uint32_t)(now - existing.last_heard_millis); + if (!have_oldest || age > oldest_age) { + oldest_age = age; + oldest_idx = i; + have_oldest = true; + } + #endif continue; } existing.snr_x4 = weightedSnrX4RoundUp(existing.snr_x4, snr_x4); #if ARDUINO - existing.last_heard_millis = millis(); + existing.last_heard_millis = now; #else existing.last_heard_millis = 0; #endif return true; } - int slot_idx = -1; - for (int i = 0; i < _max_recent_repeaters; i++) { - if (_recent_repeaters[i].prefix_len == 0) { - slot_idx = i; - break; - } - } - if (slot_idx < 0) { - // Table is full: evict the oldest heard entry. - slot_idx = 0; -#if ARDUINO - uint32_t now = millis(); - uint32_t oldest_age = (uint32_t)(now - _recent_repeaters[0].last_heard_millis); - for (int i = 1; i < _max_recent_repeaters; i++) { - uint32_t age = (uint32_t)(now - _recent_repeaters[i].last_heard_millis); - if (age > oldest_age) { - oldest_age = age; - slot_idx = i; - } - } -#endif - } + // Non-Arduino tests have no monotonic clock, so a full table retains the + // historical deterministic fallback of evicting slot zero. + int slot_idx = empty_idx >= 0 ? empty_idx : oldest_idx; RecentRepeaterInfo& slot = _recent_repeaters[slot_idx]; memset(slot.prefix, 0, sizeof(slot.prefix)); @@ -374,7 +376,7 @@ public: slot.prefix_len = prefix_len; slot.snr_x4 = snr_x4; #if ARDUINO - slot.last_heard_millis = millis(); + slot.last_heard_millis = now; #else slot.last_heard_millis = 0; #endif diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index a49752c6..a896eb77 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -6,6 +6,7 @@ PacketQueue::PacketQueue(int max_entries) { _schedule_table = new uint32_t[max_entries]; _size = max_entries; _num = 0; + _next_schedule = 0; } int PacketQueue::countBefore(uint32_t now) const { @@ -19,6 +20,25 @@ int PacketQueue::countBefore(uint32_t now) const { return n; } +bool PacketQueue::getNextTime(uint32_t now, uint32_t& scheduled_for) const { + if (_num == 0) return false; + scheduled_for = (int32_t)(_next_schedule - now) <= 0 ? now : _next_schedule; + return true; +} + +void PacketQueue::rebuildNextTime() { + if (_num == 0) { + _next_schedule = 0; + return; + } + _next_schedule = _schedule_table[0]; + for (int i = 1; i < _num; i++) { + if ((int32_t)(_schedule_table[i] - _next_schedule) < 0) { + _next_schedule = _schedule_table[i]; + } + } +} + mesh::Packet* PacketQueue::get(uint32_t now) { uint8_t min_pri = 0xFF; int best_idx = -1; @@ -31,16 +51,7 @@ mesh::Packet* PacketQueue::get(uint32_t now) { } if (best_idx < 0) return NULL; // empty, or all items are still in the future - mesh::Packet* top = _table[best_idx]; - int i = best_idx; - _num--; - while (i < _num) { - _table[i] = _table[i+1]; - _pri_table[i] = _pri_table[i+1]; - _schedule_table[i] = _schedule_table[i+1]; - i++; - } - return top; + return removeByIdx(best_idx); } mesh::Packet* PacketQueue::peek(uint32_t now) const { @@ -57,9 +68,10 @@ mesh::Packet* PacketQueue::peek(uint32_t now) const { } mesh::Packet* PacketQueue::removeByIdx(int i) { - if (i >= _num) return NULL; // invalid index + if (i < 0 || i >= _num) return NULL; // invalid index mesh::Packet* item = _table[i]; + uint32_t removed_schedule = _schedule_table[i]; _num--; while (i < _num) { _table[i] = _table[i+1]; @@ -67,6 +79,7 @@ mesh::Packet* PacketQueue::removeByIdx(int i) { _schedule_table[i] = _schedule_table[i+1]; i++; } + if (_num == 0 || removed_schedule == _next_schedule) rebuildNextTime(); return item; } @@ -77,6 +90,9 @@ bool PacketQueue::add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled _table[_num] = packet; _pri_table[_num] = priority; _schedule_table[_num] = scheduled_for; + if (_num == 0 || (int32_t)(scheduled_for - _next_schedule) < 0) { + _next_schedule = scheduled_for; + } _num++; return true; } @@ -121,6 +137,10 @@ int StaticPoolPacketManager::getOutboundTotal() const { return send_queue.count(); } +bool StaticPoolPacketManager::getNextOutboundTime(uint32_t now, uint32_t& scheduled_for) const { + return send_queue.getNextTime(now, scheduled_for); +} + int StaticPoolPacketManager::getFreeCount() const { return unused.count(); } @@ -141,3 +161,7 @@ void StaticPoolPacketManager::queueInbound(mesh::Packet* packet, uint32_t schedu mesh::Packet* StaticPoolPacketManager::getNextInbound(uint32_t now) { return rx_queue.get(now); } + +bool StaticPoolPacketManager::getNextInboundTime(uint32_t now, uint32_t& scheduled_for) const { + return rx_queue.getNextTime(now, scheduled_for); +} diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index f3e7fc31..f0c390e3 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -7,6 +7,9 @@ class PacketQueue { uint8_t* _pri_table; uint32_t* _schedule_table; int _size, _num; + uint32_t _next_schedule; + + void rebuildNextTime(); public: PacketQueue(int max_entries); @@ -15,6 +18,7 @@ public: bool add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for); int count() const { return _num; } int countBefore(uint32_t now) const; + bool getNextTime(uint32_t now, uint32_t& scheduled_for) const; mesh::Packet* itemAt(int i) const { return _table[i]; } mesh::Packet* removeByIdx(int i); }; @@ -32,9 +36,11 @@ public: mesh::Packet* peekNextOutbound(uint32_t now) override; int getOutboundCount(uint32_t now) const override; int getOutboundTotal() const override; + bool getNextOutboundTime(uint32_t now, uint32_t& scheduled_for) const override; int getFreeCount() const override; mesh::Packet* getOutboundByIdx(int i) override; mesh::Packet* removeOutboundByIdx(int i) override; void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override; mesh::Packet* getNextInbound(uint32_t now) override; + bool getNextInboundTime(uint32_t now, uint32_t& scheduled_for) const override; }; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index bad6302d..1de8bf76 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -8,13 +8,16 @@ #define STATE_TX_DONE 4 #define STATE_INT_READY 16 -#define NUM_NOISE_FLOOR_SAMPLES 64 +#define NUM_NOISE_FLOOR_SAMPLES 16 #define SAMPLING_THRESHOLD 14 -// periodic noise-floor calibration windows (RX duty-cycle powersaving only) -#define NF_CALIB_INTERVAL_MS 60000UL // at least once a minute +// On-demand noise-floor calibration windows (RX duty-cycle powersaving only). +// Requests are coalesced so retries cannot repeatedly force continuous RX. +#define NF_CALIB_INTERVAL_MS 300000UL // no more than once every five minutes #define NF_CALIB_TIMEOUT_MS 5000UL // give up on the batch (busy channel) +#define NF_CONTINUOUS_TIMEOUT_MS 1000UL // bound awake time without RX powersaving #define NF_CALIB_SETTLE_MS 20UL // frontend/AGC settle after RX entry +#define NF_SAMPLE_INTERVAL_MS 20UL // avoid back-to-back SPI RSSI reads static volatile uint8_t state = STATE_IDLE; @@ -40,12 +43,19 @@ void RadioLibWrapper::begin() { } _noise_floor = 0; + _noise_floor_valid = false; _threshold = 0; _cad_enabled = false; // start average out some samples _num_floor_samples = 0; _floor_sample_sum = 0; + _nf_calib_active = false; + _nf_last_calib = 0; + _nf_sample_from = 0; + _nf_refresh_requested = true; // establish one baseline after startup + _nf_calib_deadline = millis() + NF_CONTINUOUS_TIMEOUT_MS; + _nf_next_sample_at = 0; } uint32_t RadioLibWrapper::getRngSeed() { @@ -124,10 +134,19 @@ void RadioLibWrapper::idle() { void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; - if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling - _num_floor_samples = 0; - _floor_sample_sum = 0; - } + // With interference detection disabled, Dispatcher polling is a free flag + // update: passive retries request a refresh themselves only when the cached + // floor is stale. An enabled threshold keeps the floor periodically fresh. + if (threshold != 0) requestNoiseFloorRefresh(); +} + +void RadioLibWrapper::requestNoiseFloorRefresh() { + if (_nf_refresh_requested) return; + _nf_refresh_requested = true; + _num_floor_samples = 0; + _floor_sample_sum = 0; + _nf_calib_deadline = millis() + NF_CONTINUOUS_TIMEOUT_MS; + _nf_next_sample_at = 0; } void RadioLibWrapper::doResetAGC() { @@ -147,8 +166,15 @@ void RadioLibWrapper::resetAGC() { // too low (-106) to accept normal samples (~-105), self-reinforcing the // stuck value even after the receiver has recovered. _noise_floor = 0; + _noise_floor_valid = false; + _nf_calib_active = false; + _nf_refresh_requested = true; + _nf_last_calib = 0; + _nf_sample_from = 0; _num_floor_samples = 0; _floor_sample_sum = 0; + _nf_calib_deadline = millis() + NF_CONTINUOUS_TIMEOUT_MS; + _nf_next_sample_at = 0; } void RadioLibWrapper::rxPsWatchdogCheck() { @@ -225,28 +251,30 @@ void RadioLibWrapper::rxPsWatchdogCheck() { } } -// Periodic noise-floor calibration, active only with RX duty-cycle powersaving: +// On-demand noise-floor calibration, active only with RX duty-cycle powersaving: // a duty-cycled receiver can't be sampled reliably (the frontend is off in the -// sleep windows and settling right after each wake), so at least once a minute -// the receive mode is dropped to plain continuous RX, a fresh sample batch is -// collected exactly like the non-powersaving path does, and the duty cycle is -// re-armed. The published average stays in _noise_floor as usual. -void RadioLibWrapper::noiseFloorCalibCheck() { - unsigned long now = millis(); +// sleep windows and settling right after each wake), so a requested refresh +// drops receive mode to plain continuous RX, collects a fresh sample batch, +// and re-arms the duty cycle. Requests are rate-limited and coalesced. +void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) { if (_nf_calib_active) { - if (!_rx_ps_enabled || (long)(now - _nf_calib_deadline) >= 0) { + if (!_rx_ps_enabled + || ((long)(now - _nf_calib_deadline) >= 0 + && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES)) { // powersaving turned off mid-window, or the batch couldn't complete // (busy channel / stuck filter) - keep the previous floor endNoiseFloorCalib(now); } - } else if (_rx_ps_enabled && _rx_ps_armed && state == STATE_RX - && now - _nf_last_calib >= NF_CALIB_INTERVAL_MS + } else if (_nf_refresh_requested && _rx_ps_enabled && _rx_ps_armed && state == STATE_RX + && ((!_noise_floor_valid && _nf_last_calib == 0) + || now - _nf_last_calib >= NF_CALIB_INTERVAL_MS) && !isReceivingPacket()) { // never interrupt an ongoing reception to calibrate (a TX in flight is // already excluded by state == STATE_RX); retries next loop iteration _nf_calib_active = true; _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; _nf_sample_from = now + NF_CALIB_SETTLE_MS; + _nf_next_sample_at = _nf_sample_from; _num_floor_samples = 0; // start a fresh batch for this window _floor_sample_sum = 0; state = STATE_IDLE; // recvRaw() re-arms; startReceiveMode() sees the @@ -256,7 +284,9 @@ void RadioLibWrapper::noiseFloorCalibCheck() { void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { _nf_calib_active = false; + _nf_refresh_requested = false; _nf_last_calib = now; + _nf_next_sample_at = 0; // force a receive re-arm back into duty-cycle mode, but don't clobber a // completed-but-unread packet or an in-flight TX (recvRaw()/onSendFinished() // will re-arm right after those anyway; same guard style as setRxPowerSaving) @@ -269,33 +299,58 @@ void RadioLibWrapper::loop() { if (_rx_ps_enabled) { rxPsWatchdogCheck(); } - noiseFloorCalibCheck(); - - if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - // Noise floor is only sampled outside RX duty-cycle mode: continuously in - // plain RX (powersaving off), or inside the periodic calibration window - // (powersaving on), skipping the first moments after RX entry there while - // the frontend/AGC settles (unsettled GetRssiInst reads ~-127 dBm garbage). - if (!_rx_ps_armed - && !(_nf_calib_active && (long)(millis() - _nf_sample_from) < 0) - && !isReceivingPacket()) { - int rssi = getCurrentRSSI(); - if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD - _num_floor_samples++; - _floor_sample_sum += rssi; - } - } - } else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) { + unsigned long now = millis(); + if (_nf_calib_active || _nf_refresh_requested) { + noiseFloorCalibCheck(now); + } + if (_nf_refresh_requested && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES + && _floor_sample_sum != 0) { _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; if (_noise_floor < -120) { _noise_floor = -120; // clamp to lower bound of -120dBi } _floor_sample_sum = 0; + _noise_floor_valid = true; + _nf_refresh_requested = false; + _nf_last_calib = now; + _nf_next_sample_at = 0; MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); if (_nf_calib_active) { - endNoiseFloorCalib(millis()); // fresh floor published - back to duty cycle + endNoiseFloorCalib(now); // fresh floor published - back to duty cycle + } + return; + } + + if (_nf_refresh_requested && !_rx_ps_enabled && _nf_calib_deadline != 0 + && (long)(now - _nf_calib_deadline) >= 0) { + // A continuously busy channel can reject every candidate sample. Do not + // keep the MCU awake indefinitely; retain the previous floor and wait for + // the normal stale-floor interval before another requested burst. + _nf_refresh_requested = false; + _nf_last_calib = now; + _nf_next_sample_at = 0; + _num_floor_samples = 0; + _floor_sample_sum = 0; + } + + if (_nf_refresh_requested && state == STATE_RX + && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES + && (_nf_next_sample_at == 0 || (long)(now - _nf_next_sample_at) >= 0)) { + // Noise floor is only sampled outside RX duty-cycle mode: continuously in + // plain RX (powersaving off), or inside an on-demand calibration window + // (powersaving on), skipping the first moments after RX entry there while + // the frontend/AGC settles (unsettled GetRssiInst reads ~-127 dBm garbage). + if (!_rx_ps_armed + && !(_nf_calib_active && (long)(now - _nf_sample_from) < 0) + && !isReceivingPacket()) { + int rssi = getCurrentRSSI(); + _nf_next_sample_at = now + NF_SAMPLE_INTERVAL_MS; + if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD + _num_floor_samples++; + _floor_sample_sum += rssi; + } } } } @@ -471,6 +526,16 @@ bool RadioLibWrapper::isReceivingPassive(int interference_margin_db) { if (isChipBusy()) return true; if (isReceivingPacket()) return true; + unsigned long now = millis(); + if ((!_noise_floor_valid && _nf_last_calib == 0) + || now - _nf_last_calib >= NF_CALIB_INTERVAL_MS) { + requestNoiseFloorRefresh(); + } + // Until the startup baseline is ready, defer rather than compare against the + // zero-initialized floor and transmit blind. Dispatcher retains its bounded + // busy timeout if sampling cannot complete. + if (!_noise_floor_valid) return true; + // Use a fixed margin for retries even when the normal interference threshold // is disabled. This is passive (RSSI only): unlike CAD it does not restart RX // and cannot erase the forwarding echo that would cancel the retry. diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 1b51c2dd..90cb7495 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -17,6 +17,8 @@ protected: uint32_t n_recv, n_sent, n_recv_errors; int16_t _noise_floor, _threshold; bool _cad_enabled; + bool _noise_floor_valid; + bool _nf_refresh_requested; uint16_t _num_floor_samples; int32_t _floor_sample_sum; unsigned long last_recv_millis; @@ -51,20 +53,21 @@ protected: bool _cur_rx_boosted_gain; bool _params_valid, _dbm_valid, _rx_boosted_gain_valid; - // Periodic noise-floor calibration (only while RX duty-cycle powersaving is - // armed): a duty-cycled receiver can't be sampled reliably, so at least once - // a minute the wrapper drops to plain continuous RX, collects a fresh sample - // batch exactly like the non-powersaving path does, publishes the average - // into _noise_floor and re-arms the duty cycle. + // On-demand noise-floor calibration while RX duty-cycle powersaving is + // armed. A duty-cycled receiver can't be sampled reliably, so a requested + // refresh briefly drops to continuous RX, publishes an average, then re-arms + // the duty cycle. bool _nf_calib_active; unsigned long _nf_last_calib; // millis of last completed/attempted window unsigned long _nf_calib_deadline; // abort window if the batch can't complete unsigned long _nf_sample_from; // no samples before this (RX entry settle) + unsigned long _nf_next_sample_at; // pace SPI RSSI reads during a sample batch void idle() override; void startRecv() override; void rxPsWatchdogCheck(); - void noiseFloorCalibCheck(); + void requestNoiseFloorRefresh(); + void noiseFloorCalibCheck(unsigned long now); void endNoiseFloorCalib(unsigned long now); void cacheParams(float freq, float bw, uint8_t sf, uint8_t cr) { _cur_freq = freq; _cur_bw = bw; _cur_sf = sf; _cur_cr = cr; _params_valid = true; @@ -89,12 +92,14 @@ protected: public: RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) - : _radio(&radio), _board(&board), _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false), + : _radio(&radio), _board(&board), _noise_floor_valid(false), _nf_refresh_requested(true), + _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false), _rx_ps_rx_us(RX_PS_FALLBACK_RX_US), _rx_ps_sleep_us(RX_PS_FALLBACK_SLEEP_US), _wd_last_busy(false), _wd_stage(0), _wd_strikes(0), _startrx_fails(0), _wd_last_transition(0), _wd_stuck_thresh(0), _wd_observe_until(0), _wd_observe_ms(0), _cur_rx_boosted_gain(false), _params_valid(false), _dbm_valid(false), _rx_boosted_gain_valid(false), - _nf_calib_active(false), _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0) + _nf_calib_active(false), _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0), + _nf_next_sample_at(0) { n_recv = n_sent = n_recv_errors = n_wd_soft = n_wd_hard = 0; last_recv_millis = 0; @@ -154,10 +159,11 @@ public: // true while the watchdog is actively watching for BUSY transitions; used by // the app's hasPendingWork() to keep the MCU out of light sleep for the window bool isWatchdogObserving() const { return _wd_observe_until != 0; } - // true while a periodic noise-floor calibration window is in progress; the - // app's hasPendingWork() must keep the MCU awake so the sample batch and the - // return to duty-cycle complete promptly - bool isCalibratingNoiseFloor() const { return _nf_calib_active; } + // true while a noise-floor batch needs prompt loop service; the app's + // hasPendingWork() keeps the MCU awake only for this short sample burst. + bool isCalibratingNoiseFloor() const { + return _nf_calib_active || (_nf_refresh_requested && !_rx_ps_enabled); + } void resetStats() { n_recv = n_sent = n_recv_errors = 0; } uint8_t getRadioState() const override; diff --git a/test/test_mesh_tables/test_simple_mesh_tables.cpp b/test/test_mesh_tables/test_simple_mesh_tables.cpp index ec6af7f3..09d5e5ec 100644 --- a/test/test_mesh_tables/test_simple_mesh_tables.cpp +++ b/test/test_mesh_tables/test_simple_mesh_tables.cpp @@ -252,6 +252,23 @@ TEST(SimpleMeshTables, RecentRepeatersExpireOnlyAfterTwentyFourHours) { EXPECT_EQ(nullptr, t.findRecentRepeaterByHash(first, 3)); } +TEST(SimpleMeshTables, FullRecentRepeaterTableStillEvictsDeterministically) { + SimpleMeshTables::RecentRepeaterInfo storage[2]; + SimpleMeshTables t(storage, 2); + const uint8_t first[] = {0x10}; + const uint8_t second[] = {0x20}; + const uint8_t replacement[] = {0x30}; + + ASSERT_TRUE(t.setRecentRepeater(first, 1, 4)); + ASSERT_TRUE(t.setRecentRepeater(second, 1, 8)); + ASSERT_TRUE(t.setRecentRepeater(replacement, 1, 12)); + + EXPECT_EQ(nullptr, t.findRecentRepeaterByHash(first, 1)); + EXPECT_NE(nullptr, t.findRecentRepeaterByHash(second, 1)); + EXPECT_NE(nullptr, t.findRecentRepeaterByHash(replacement, 1)); + EXPECT_EQ(2, t.getRecentRepeaterCount()); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp index 7804f398..094f9a36 100644 --- a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp +++ b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp @@ -11,6 +11,7 @@ public: class TestRadio : public mesh::Radio { public: int send_starts = 0; + bool receiving = false; int recvRaw(uint8_t*, int) override { return 0; } uint32_t getEstAirtimeFor(int) override { return 1; } @@ -19,6 +20,7 @@ public: bool isSendComplete() override { return false; } void onSendFinished() override { } bool isInRecvMode() const override { return true; } + bool isReceiving() override { return receiving; } }; class TestDispatcher : public mesh::Dispatcher { @@ -37,8 +39,96 @@ public: TestDispatcher(TestRadio& radio, TestClock& clock, RxReservePacketManager& mgr) : mesh::Dispatcher(radio, clock, mgr), manager(mgr) { } + + bool nextQueueWakeDelay(uint32_t& delay_millis) const { + return getNextQueueWakeDelay(delay_millis); + } + bool queuedWorkDue() const { return hasQueuedWorkDue(); } }; +TEST(StaticPoolPacketManager, ReportsEarliestQueueTimesWithoutDequeuing) { + StaticPoolPacketManager manager(8); + mesh::Packet* later = manager.allocNew(); + mesh::Packet* earlier = manager.allocNew(); + mesh::Packet* inbound = manager.allocNew(); + ASSERT_NE(later, nullptr); + ASSERT_NE(earlier, nullptr); + ASSERT_NE(inbound, nullptr); + + ASSERT_TRUE(manager.queueOutbound(later, 0, 500)); + ASSERT_TRUE(manager.queueOutbound(earlier, 0, 300)); + manager.queueInbound(inbound, 250); + + uint32_t scheduled_for = 0; + ASSERT_TRUE(manager.getNextOutboundTime(100, scheduled_for)); + EXPECT_EQ(300U, scheduled_for); + ASSERT_TRUE(manager.getNextInboundTime(100, scheduled_for)); + EXPECT_EQ(250U, scheduled_for); + EXPECT_EQ(2, manager.getOutboundTotal()); + + ASSERT_TRUE(manager.getNextOutboundTime(400, scheduled_for)); + EXPECT_EQ(400U, scheduled_for); // overdue work is runnable now + + manager.free(manager.getNextInbound(250)); + manager.free(manager.getNextOutbound(500)); + manager.free(manager.getNextOutbound(500)); +} + +TEST(StaticPoolPacketManager, EarliestQueueTimeIsCorrectAcrossMillisRollover) { + StaticPoolPacketManager manager(4); + mesh::Packet* before_wrap = manager.allocNew(); + mesh::Packet* after_wrap = manager.allocNew(); + ASSERT_NE(before_wrap, nullptr); + ASSERT_NE(after_wrap, nullptr); + + // Add these in reverse chronological order to exercise the cached minimum. + ASSERT_TRUE(manager.queueOutbound(after_wrap, 0, 0x00000004UL)); + ASSERT_TRUE(manager.queueOutbound(before_wrap, 0, 0xFFFFFFF5UL)); + + uint32_t scheduled_for = 0; + ASSERT_TRUE(manager.getNextOutboundTime(0xFFFFFFF0UL, scheduled_for)); + EXPECT_EQ(0xFFFFFFF5UL, scheduled_for); + EXPECT_EQ(before_wrap, manager.getNextOutbound(0xFFFFFFF5UL)); + manager.free(before_wrap); + + ASSERT_TRUE(manager.getNextOutboundTime(0xFFFFFFF6UL, scheduled_for)); + EXPECT_EQ(0x00000004UL, scheduled_for); + EXPECT_EQ(after_wrap, manager.getNextOutbound(0x00000004UL)); + manager.free(after_wrap); +} + +TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) { + RxReservePacketManager manager(8, 4); + TestClock clock; + clock.now = 100; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.begin(); + + mesh::Packet* packet = dispatcher.obtainNewPacket(); + ASSERT_NE(packet, nullptr); + packet->header = ROUTE_TYPE_DIRECT | (PAYLOAD_TYPE_RAW_CUSTOM << PH_TYPE_SHIFT); + packet->payload[0] = 0x42; + packet->payload_len = 1; + ASSERT_TRUE(dispatcher.sendPacket(packet, 0, 500)); + + uint32_t delay_millis = 0; + ASSERT_TRUE(dispatcher.nextQueueWakeDelay(delay_millis)); + EXPECT_EQ(500U, delay_millis); + EXPECT_FALSE(dispatcher.queuedWorkDue()); + + clock.now = 600; + radio.receiving = true; + dispatcher.loop(); + ASSERT_TRUE(dispatcher.nextQueueWakeDelay(delay_millis)); + EXPECT_EQ(200U, delay_millis); + EXPECT_FALSE(dispatcher.queuedWorkDue()); + + clock.now = 800; + radio.receiving = false; + EXPECT_TRUE(dispatcher.queuedWorkDue()); +} + TEST(RxReservePacketManager, RejectedOutboundRemainsOwnedByCaller) { RxReservePacketManager manager(8, 4); mesh::Packet* held[6];