From f1d281a76654d0f094a1d4634eaf4c9301516e07 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 13:21:05 -0700 Subject: [PATCH] Allow safe nRF52 MCU idle between radio2 scan deadlines --- .github/workflows/run-unit-tests.yml | 2 + docs/power_saving_diagnostics.md | 5 + docs/radio_profiles.md | 34 +++ examples/companion_radio/MyMesh.cpp | 2 +- examples/companion_radio/main.cpp | 6 +- examples/simple_repeater/MyMesh.cpp | 2 +- examples/simple_repeater/main.cpp | 6 +- examples/simple_room_server/MyMesh.cpp | 2 +- examples/simple_room_server/main.cpp | 6 +- examples/simple_sensor/SensorMesh.cpp | 2 +- examples/simple_sensor/main.cpp | 15 +- src/Dispatcher.cpp | 18 +- src/Dispatcher.h | 14 + src/helpers/NRF52Board.cpp | 41 ++- src/helpers/NRF52Board.h | 15 +- src/helpers/nrf52/SleepCycleState.h | 3 +- src/helpers/radiolib/RadioLibWrappers.cpp | 59 +++- src/helpers/radiolib/RadioLibWrappers.h | 9 + test/fixtures/advert_cache/test.cpp | 2 + test/test_companion_contact_sleep_contract.py | 4 +- ...st_companion_radio_settings_transaction.py | 1 + test/test_esp32_full_power_lifecycle.py | 1 + test/test_esp32_full_sleep.py | 1 + test/test_logging_sleep_contract.py | 1 + test/test_nrf52_profile_idle.py | 261 ++++++++++++++++++ test/test_power_saving_role_diagnostics.py | 8 +- test/test_profile_idle.py | 222 +++++++++++++++ test/test_radio_profile_idle_dispatcher.py | 195 +++++++++++++ test/test_rs232_heartbeat_owner.py | 1 + test/test_sensor_tracker_sleep.py | 2 + 30 files changed, 916 insertions(+), 24 deletions(-) create mode 100644 test/test_nrf52_profile_idle.py create mode 100644 test/test_profile_idle.py create mode 100644 test/test_radio_profile_idle_dispatcher.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 98ea245ac..ec7831af3 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -329,6 +329,7 @@ jobs: python3 -B test/test_companion_power_diagnostics.py -v python3 -B test/test_nrf52_sleep_cycle.py -v python3 -B test/test_nrf52_sleep_events.py -v + python3 -B test/test_nrf52_profile_idle.py -v python3 -B test/test_esp32_scanner_power_yield.py -v python3 -B test/test_esp32_gpio_hold.py -v @@ -485,6 +486,7 @@ jobs: python3 -B test/test_radio_profile_spiffs_missing.py -v python3 -B test/test_radio_reply_mutation.py python3 -B test/test_radio_profile_scan.py + python3 -B test/test_radio_profile_idle_dispatcher.py -v python3 -B test/test_radio_rxps_rejection.py -v python3 -B test/test_repeater_radio_timing_integration.py python3 -B test/test_temp_radio_reply_delivery_contract.py diff --git a/docs/power_saving_diagnostics.md b/docs/power_saving_diagnostics.md index 7469a67dd..de2e6d71b 100644 --- a/docs/power_saving_diagnostics.md +++ b/docs/power_saving_diagnostics.md @@ -39,6 +39,11 @@ after off starts a fresh window. Collection defaults off at every boot. existing service guard is active. - `board`, `no.wake`: an inner board veto, SDK error or lack of a wake source. +`radio2` means the scanner cannot safely idle right now: for example, a short +remaining visit, calibration, pending reception or an unvalidated platform. +On nRF52, settled long visits can idle the MCU with power saving enabled; +the RF receiver stays on. See [radio profiles](radio_profiles.md#mcu-power-saving-while-scanning). + The role reports the first actual short-circuit blocker. It does not query otherwise skipped hardware just to populate an exhaustive list. Board guards may add reasons. Per-reason durations therefore are observed guard time, not diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 1113da913..ed06ecdc8 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -803,6 +803,40 @@ An RX-only `tempradio2` also blocks locally generated OTA requests under for a fetching node that needs to exchange requests and data. Infrastructure OTA responses follow `tx.reply` independently of this crossing table. +## MCU power saving while scanning + +On nRF52, `set powersaving on` also permits interrupt-driven MCU idle during a +settled `radio2` or `tempradio2` listening visit. The RF receiver stays on. +This includes a Meshtext LongFast visit (SF11 / 250 kHz), whose minimum visit +is about 37.7 ms. No extra command is needed. + +The scanner first completes its switch self-test and both noise-floor +baselines. It polls again for pending packets, calibration, changed settings, +retries, or short visits. All normal USB, logging, bridge, OTA, queue and role +sleep guards still apply. An nRF52 periodic interrupt bounds each wait to +about 0.977 ms; admission reserves another 0.488 ms before the visit deadline +and is rechecked immediately before sleeping. If that wake source is not +available, scanning keeps polling. Other MCU families retain polling until +their timing is validated. + +The integer admission gate requires at least 49 RTC2 ticks (about 1.495 ms) +remaining after the work checks. A configured visit must also cover the loop +work before that point. Eligibility is therefore per visit, not an SF/BW +cutoff: a fast SF5 secondary can still idle the MCU when the primary profile +gives it a sufficiently long listening visit. + +For example, the RAK3401 bench with USA primary `910.525,62.5,7,5,32` and +secondary `909.5,500,5,5,rxtx,384` had roughly 9.4/21.3 ms visits. MCU sleep +was observed in 35% of 4265 debugger samples with power saving on, versus +zero of 2129 with it off. This is a CPU-state spot check, not a current or +Ethernet throughput measurement. Enabling power saving changed neither preamble. + +Radio RX duty cycling remains suspended while scanning, then its previous +setting is restored when radio2 stops. Meshtext's fixed 16-symbol preamble +does not have the same sleep margin as a longer MeshCore preamble. Adding +RF sleep inside a scan visit would introduce extra blind time, so this +optimization saves MCU activity without shortening either listening visit. + ## Preamble Append the preamble to the end of a command: diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b68726899..ea1d9c883 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -11600,7 +11600,7 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const { #if COMPANION_FEATURE_NOTIFICATIONS if (_notifications.active()) return mask(Reason::Mesh); #endif - if (isDualRadioActive()) return mask(Reason::DualRadio); + if (!canIdleDualRadio()) return mask(Reason::DualRadio); if (_radio_available && radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog); uint32_t noise_floor_delay_ms; if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index dca2191e2..45a679ed9 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -4209,6 +4209,10 @@ void loop() { #ifdef ESP32_PLATFORM serviceCompanionPowerSaving(); #endif +#if defined(NRF52_PLATFORM) + if (the_mesh.getNodePrefs()->powersaving_enabled && the_mesh.isDualRadioActive() + && !morse_timing) board.prepareProfileIdleCycle(); +#endif // USB power alone (for example, a wall charger) does not inhibit sleep. // Host sessions, live logging, and button activity still need service. @@ -4254,7 +4258,7 @@ void loop() { if (noise_floor_wait && noise_floor_delay_ms == 0) sleep_reason = mask(Reason::Calibration); if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(sleep_reason, millis()); const bool can_sleep = sleep_reason == 0; - if (can_sleep) { + if (can_sleep && the_mesh.canIdleDualRadio()) { const uint32_t idle_slice_ms = noise_floor_wait && noise_floor_delay_ms < 10 ? noise_floor_delay_ms : 10; #if defined(NRF52_PLATFORM) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e3d9a3e47..fc841b6ac 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -13928,7 +13928,7 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const { using mesh::powerdiag::Reason; // Keep the original guard order and short circuit: diagnostics must not // probe additional hardware or change which work keeps the MCU awake. - if (isDualRadioActive()) return mask(Reason::DualRadio); + if (!canIdleDualRadio()) return mask(Reason::DualRadio); if (hasPendingOtaApply()) return mask(Reason::Ota); #if defined(ESP32_PLATFORM) if (mesh::wifi::networkPower().foregroundActive()) return mask(Reason::Network); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 22b1b8ee4..867ac752a 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -623,6 +623,10 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); +#endif +#if defined(NRF52_PLATFORM) + if (the_mesh.getNodePrefs()->powersaving_enabled && the_mesh.isDualRadioActive() + && !morse_timing) board.prepareProfileIdleCycle(); #endif // Report the guard that really stopped this iteration, without probing // later guards or changing their short-circuit hardware access. @@ -659,7 +663,7 @@ void loop() { if (sleep_ms > 0) external_watchdog.feed(); #endif #if defined(NRF52_PLATFORM) - if (sleep_ms > 0) { + if (sleep_ms > 0 && the_mesh.canIdleDualRadio()) { if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis()); if (noise_floor_wait) board.sleepMilliseconds(sleep_ms); else board.sleep(0); // nrf ignores seconds param, sleeps whenever possible diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index c42a07375..2a4441a65 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -4287,7 +4287,7 @@ bool MyMesh::hasPendingWork() const { mesh::powerdiag::Mask MyMesh::pendingWorkReason() const { using mesh::powerdiag::mask; using mesh::powerdiag::Reason; - if (isDualRadioActive()) return mask(Reason::DualRadio); + if (!canIdleDualRadio()) return mask(Reason::DualRadio); if (hasPendingOtaApply()) return mask(Reason::Ota); #if defined(ESP32_PLATFORM) if (mesh::wifi::networkPower().foregroundActive()) return mask(Reason::Network); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index f68b89527..7c6e32001 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -400,6 +400,10 @@ void loop() { #endif #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); +#endif +#if defined(NRF52_PLATFORM) + if (the_mesh.getNodePrefs()->powersaving_enabled && the_mesh.isDualRadioActive() + && !morse_timing) board.prepareProfileIdleCycle(); #endif const auto sleep_guard = [](bool allowed, mesh::powerdiag::Reason reason) { if (!allowed && mesh::powerdiag::enabled()) @@ -431,7 +435,7 @@ void loop() { if (sleep_ms > 0) external_watchdog.feed(); #endif #if defined(NRF52_PLATFORM) - if (sleep_ms > 0) { + if (sleep_ms > 0 && the_mesh.canIdleDualRadio()) { if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis()); if (noise_floor_wait) board.sleepMilliseconds(sleep_ms); else board.sleep(0); // nrf ignores seconds param, sleeps whenever possible diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index c001e6af5..7ece45383 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1725,7 +1725,7 @@ mesh::powerdiag::Mask SensorMesh::pendingWorkReason() const { || (tracker_awake_until && !millisHasNowPassed(tracker_awake_until)) || (!tracker_storage_fault && getRTCClock()->getCurrentTime() >= tracker_next_check))) return mask(Reason::Tracker); #endif - if (isDualRadioActive()) return mask(Reason::DualRadio); + if (!canIdleDualRadio()) return mask(Reason::DualRadio); if (hasPendingOtaApply()) return mask(Reason::Ota); if (_cli.hasActiveUserGpioTimer()) return mask(Reason::Scheduled); if (radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog); diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 5bf5d91c5..d74136a69 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -367,6 +367,10 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif +#if defined(NRF52_PLATFORM) + if ((the_mesh.getNodePrefs()->powersaving_enabled || the_mesh.isTrackerModeEnabled()) + && the_mesh.isDualRadioActive() && !morse_timing) board.prepareProfileIdleCycle(); +#endif const auto sleep_guard = [](bool allowed, mesh::powerdiag::Reason reason) { if (!allowed && mesh::powerdiag::enabled()) @@ -397,12 +401,15 @@ void loop() { if (sleep_ms > 0) external_watchdog.feed(); #endif #if defined(NRF52_PLATFORM) - if (sleep_ms > 0) { + if (sleep_ms > 0 && the_mesh.canIdleDualRadio()) { #if MESH_ENABLE_SENSOR_TRACKER if (!the_mesh.isTrackerModeEnabled() || armTrackerSleepWake(mesh::power::powerSaveSleepSeconds(sleep_ms))) { - if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis()); - if (noise_floor_wait) board.sleepMilliseconds(sleep_ms); - else board.sleep(0); + // Timer setup can schedule another task or consume the scan guard. + if (the_mesh.canIdleDualRadio()) { + if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis()); + if (noise_floor_wait) board.sleepMilliseconds(sleep_ms); + else board.sleep(0); + } } else { if (mesh::powerdiag::enabled()) diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index e1d9d9cf8..ed3fb145a 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -555,13 +555,19 @@ bool Dispatcher::getNextNoiseFloorWakeDelay(uint32_t& delay_millis) const { } bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { - if (isDualRadioActive()) { delay_millis = 0; return true; } + const bool dual_profile = isDualRadioActive(); + uint32_t profile_delay = 0; + if (dual_profile && (!radio_available + || !_radio->getProfileIdleDelay(profile_delay) || profile_delay == 0)) { + delay_millis = 0; + return true; + } #if MESH_ENABLE_FOREIGN_RADIO if (hasForeignRadioFrame()) { delay_millis = 0; return true; } #endif const uint32_t now = _ms->getMillis(); - bool found = false; - uint32_t shortest_delay = 0; + bool found = dual_profile; + uint32_t shortest_delay = profile_delay; #if MESH_ENABLE_TX_BEACONS if (radio_available && hasBeaconRadioFrame()) { // TX completion and restoration need the fast lifecycle, not timed sleep. @@ -570,7 +576,8 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { } if (radio_available && beacon_wake_enabled) { const int32_t wait = int32_t(beacon_wake_at - now); - shortest_delay = wait > 0 ? uint32_t(wait) : 0; + const uint32_t beacon_delay = wait > 0 ? uint32_t(wait) : 0; + if (!found || beacon_delay < shortest_delay) shortest_delay = beacon_delay; found = true; } #endif @@ -601,6 +608,9 @@ bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const { if (receive_retry && receive_retry_delay < delay_millis) { delay_millis = receive_retry_delay; } + if (dual_profile && profile_delay < delay_millis) { + delay_millis = profile_delay; + } } else { // TX completion/timeout still needs the normal fast lifecycle path. delay_millis = 0; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index c61071d4e..f0f069cec 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -229,6 +229,14 @@ public: virtual bool getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const { (void)now; (void)delay_ms; return false; } + // A supported profile scanner may idle the MCU while RF keeps receiving. + // Return a conservative whole-millisecond service budget, not an RF sleep + // permission. False (unsupported/not ready) or zero retains fast polling. + // The caller must preserve every other work deadline and the validated + // board wake contract, then recheck admission immediately before waiting. + virtual bool getProfileIdleDelay(uint32_t& delay_millis) const { + (void)delay_millis; return false; + } virtual void triggerNoiseFloorCalibrate(int threshold) { } @@ -642,6 +650,12 @@ public: #endif Radio* getProfileRadio() { return _radio; } bool isDualRadioActive() const { return _radio->profiles() && _radio->profiles()->enabled(); } + bool canIdleDualRadio() const { + if (!isDualRadioActive()) return true; + uint32_t delay_millis = 0; + return radio_available && _radio->getProfileIdleDelay(delay_millis) + && delay_millis > 0; + } Packet* obtainNewPacket(); void releasePacket(Packet* packet); diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 7c5c0d2d4..e6e2121cc 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -542,12 +542,49 @@ void NRF52Board::prepareSleepCycle() { state.publish(token, sd_enabled != 0, usb_status, error); // FreeRTOS yields and the state-query SVC can both leave a stale event. - // Clear it ONLY at loop entry, before servicing/checking application work. - // A radio interrupt after this point must remain visible to the late wait. + // Clear it only before a complete work/guard pass: at loop entry, or during + // explicit profile-idle preparation before all role sleep predicates. A radio + // interrupt after that pass begins must remain visible to the late wait. __SEV(); __WFE(); } +bool NRF52Board::canIdleRadioProfile() { +#if defined(configTICK_RATE_HZ) && configTICK_RATE_HZ == 1024 + // RTC1 belongs to FreeRTOS. Only observe its periodic wake; never alter its + // counter, compare channels, IRQ ownership, or tickless-idle bookkeeping. + return __get_IPSR() == 0 && __get_PRIMASK() == 0 + && __get_BASEPRI() == 0 && __get_FAULTMASK() == 0 + && nrf_nvic_state.__cr_flag == 0 + && xTaskGetSchedulerState() == taskSCHEDULER_RUNNING + && !(SCB->ICSR & SCB_ICSR_PENDSVSET_Msk) + && NRF_RTC1->PRESCALER == 31 + && (NRF_RTC1->INTENSET & RTC_INTENSET_TICK_Msk) != 0 + && NVIC_GetEnableIRQ(RTC1_IRQn) != 0; +#else + return false; +#endif +} + +void NRF52Board::prepareProfileIdleCycle() { + if (canIdleRadioProfile()) { +#if (__FPU_USED == 1) + // The disabled FPU IRQ can retain an inexact exception from profile math. + // Clear that known source before refreshing the event baseline. An enabled + // owner's flags/IRQ belong to its handler, not this polling cleanup. + if (NVIC_GetEnableIRQ(FPU_IRQn) == 0) { + __set_FPSCR(__get_FPSCR() & ~0x0000009FUL); + (void)__get_FPSCR(); + NVIC_ClearPendingIRQ(FPU_IRQn); + } +#endif + } + // This also revalidates context and SDK state, acknowledges a real PendSV + // when SoftDevice is on, and clears the old ARM event. All role predicates + // must run afterward; an interrupt during those checks is never erased. + prepareSleepCycle(); +} + bool NRF52Board::isUsbDataConnected() { #if defined(USE_TINYUSB) #if defined(CFG_TUD_CDC) && CFG_TUD_CDC diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 06b6ec548..ca950187b 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -73,9 +73,20 @@ public: virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[], bool force_ap = false) override; virtual bool stopOTAUpdate(char reply[]) override; - // Must precede all work servicing/checks in each application loop. The - // authoritative SDK queries and stale-event clear are never done at sleep. + // Normal loop-entry preparation must precede all work servicing/checks. The + // profile-only second preparation below precedes a complete new guard pass; + // authoritative SDK queries and event clearing never belong in sleep(). void prepareSleepCycle(); + // Radio-profile service can set an FPU event after loop entry. This second + // preparation belongs BEFORE the complete role sleep predicate, never after + // any work/USB/radio checks whose interrupt wake must remain visible. + void prepareProfileIdleCycle(); + // Pure readiness check for the pinned 1024 Hz periodic RTC1 wake contract. + // The sketch task stays ready while waiting, so the idle task cannot suppress + // this tick. A profile must still reserve this interval plus its own guard. + static constexpr uint32_t ProfileIdleTickUs = 977; + static constexpr uint32_t ProfileIdleRtcTicks = 32; + static bool canIdleRadioProfile(); virtual void sleep(uint32_t secs) override; bool isUserGpioAvailable(uint8_t pin) const override; bool isExternalPowered() override; diff --git a/src/helpers/nrf52/SleepCycleState.h b/src/helpers/nrf52/SleepCycleState.h index 414bd4736..8e1526206 100644 --- a/src/helpers/nrf52/SleepCycleState.h +++ b/src/helpers/nrf52/SleepCycleState.h @@ -11,7 +11,8 @@ struct SleepCycleSnapshot { bool softdevice_enabled = false; }; -// The application task publishes once at loop entry, before checking work. +// The application task publishes before checking work. Profile idle can +// re-baseline after servicing, but only before a complete new guard pass. // A SoftDevice startup attempt can invalidate from another task without // racing the publication. No SDK state is inferred from NVIC/vector state. class SleepCycleState { diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index a32bcf9a8..021a561d2 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,7 @@ #include #elif defined(NRF52_PLATFORM) #include +#include "../NRF52Board.h" #elif defined(STM32_PLATFORM) #include extern "C" caddr_t _sbrk(int increment); @@ -768,6 +769,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::tryRestoreCodingRate(uint8_t cr) { } void RadioLibWrapper::serviceProfileScan() { +#if defined(NRF52_PLATFORM) + _profile_idle_visit_ticks = 0; // Every deferred/restarted scan fails closed. +#endif syncProfileClock(_profiles.enabled()); if (_cw_active) return; if (!_params_valid || (_profile_retry_at && (int32_t)(millis() - _profile_retry_at) < 0)) return; @@ -816,6 +820,10 @@ void RadioLibWrapper::serviceProfileScan() { if (!_profiles.switchTestReady()) return; } uint8_t target = _active_profile; +#if defined(NRF52_PLATFORM) + const uint8_t visited_profile = _active_profile; +#endif + uint32_t visit_us = 0; const bool restart_scan = _profiles.enabled() && (_profile_scan_generation[0] != _profiles.generation[0] || _profile_scan_generation[1] != _profiles.generation[1]); @@ -834,7 +842,7 @@ void RadioLibWrapper::serviceProfileScan() { if (!_profiles.enabled()) target = 0; else if (restart_scan) target = _profiles.slowerProfile(); else { - uint32_t visit_us = _profiles.listenUs( + visit_us = _profiles.listenUs( _active_profile, profilePreamble(_profiles.slowerProfile())); // A normal visit can be shorter than the RSSI settle interval on some // chips. Hold only a visit that is actually due to contribute its next @@ -869,6 +877,55 @@ void RadioLibWrapper::serviceProfileScan() { recalibrateNoiseFloor(); endReconfigure(resume); } +#if defined(NRF52_PLATFORM) + // A real hop gets a fresh budget on the next pass. Never use the old + // profile's visit duration with the new profile's RX timestamp. + if (_profiles.enabled() && !restart_scan && _active_profile == visited_profile + && result == mesh::RadioParamApplyResult::APPLIED + && !isChipBusy() && !isPacketPendingOrReceiving()) { + _profile_idle_visit_ticks = uint64_t(visit_us) * 512 / 15625; + _profile_idle_stamp = _profile_visit_stamp; + _profile_idle_switch_us = _profiles.switchBudgetUs(); + _profile_idle_preamble = _physical_preamble; + } +#endif +} + +bool RadioLibWrapper::getProfileIdleDelay(uint32_t& delay_ms) const { + delay_ms = 0; +#if defined(NRF52_PLATFORM) + if (!_profiles.enabled() || !NRF52Board::canIdleRadioProfile() + || !_profile_idle_visit_ticks || !_params_valid || state != STATE_RX + || !_profile_rxps_suspended || _rx_ps_enabled || _rx_ps_armed + || _rx_hold_continuous || _rx_retry_pending || _profile_retry_at + || _profile_refresh_required || _nf_refresh_requested || _nf_calib_active + || !_noise_floor_valid || !_noise_floor_secondary_valid + || _wd_observe_until || _cw_active || _morse_owned || hasPendingRadioInterrupt() + || !_profiles.switchTestReady() + || _profile_scan_generation[0] != _profiles.generation[0] + || _profile_scan_generation[1] != _profiles.generation[1] + || _active_profile > 1 || _profile_generation != _profiles.generation[_active_profile] + || _profile_idle_stamp != _profile_visit_stamp + || _profile_idle_preamble != _physical_preamble + || _profile_idle_switch_us != _profiles.switchBudgetUs()) return false; +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return false; +#endif +#if MESH_ENABLE_MORSE_RX + if (_morse_rx_owned) return false; +#endif + const uint32_t elapsed = (NRF_RTC2->COUNTER - _profile_idle_stamp) & 0x00ffffffUL; + // RTC1 wakes the ready sketch task at 1024 Hz (32 RTC2 ticks). Retain + // another 16 ticks (488 us) for return-to-loop work, validated on RAK3401. + // Short visits poll; neither a new timer nor RF duty cycling is needed. + constexpr uint32_t guard_ticks = 16; + if (_profile_idle_visit_ticks >= 0x00800000UL || elapsed >= _profile_idle_visit_ticks + || _profile_idle_visit_ticks - elapsed <= 32 + guard_ticks) return false; + delay_ms = uint64_t(_profile_idle_visit_ticks - elapsed - guard_ticks) * 15625 / 512000; + return delay_ms != 0; +#else + return false; // Other platforms retain polling until their wake path is validated. +#endif } uint32_t RadioLibWrapper::getProfileAirtime(uint8_t profile, int len, uint8_t cr) { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index e8a82599d..2afd143e3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -113,6 +113,14 @@ protected: bool _profile_rxps_suspended = false; bool _profile_saved_rxps = false; bool _profile_refresh_required = false; +#if defined(NRF52_PLATFORM) + // Published by the scanner before the final sleep predicates. The late + // query uses integers only: floating-point work can leave a stale FPU event. + uint32_t _profile_idle_visit_ticks = 0; + uint32_t _profile_idle_stamp = 0; + uint32_t _profile_idle_switch_us = 0; + uint16_t _profile_idle_preamble = 0; +#endif #if defined(MESH_HIL_TX_FREQ_MHZ) bool hilPacketTransmitAllowed() const; #endif @@ -547,6 +555,7 @@ public: } bool getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const override; bool getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) const override; + bool getProfileIdleDelay(uint32_t& delay_ms) const override; void resetStats() { n_recv = n_sent = n_recv_errors = 0; } uint8_t getRadioState() const override; diff --git a/test/fixtures/advert_cache/test.cpp b/test/fixtures/advert_cache/test.cpp index 727d469a6..8d7867e5a 100644 --- a/test/fixtures/advert_cache/test.cpp +++ b/test/fixtures/advert_cache/test.cpp @@ -89,11 +89,13 @@ public: mesh::powerdiag::Mask pendingWorkReason() const; bool canRecoverUsbLogging() const; bool isDualRadioActive() const{return false;} + bool canIdleDualRadio() const{return true;} bool hasOutbound() const{return false;} bool isAnyTempRadioActive() const{return false;} bool hasPendingOtaApply() const{return false;} bool hasPendingReqs() const{return false;} bool hasQueuedWorkDue() const{return false;} + bool getNextNoiseFloorWakeDelay(uint32_t&) const{return false;} bool hasRetryWorkDue() const{return false;} bool millisHasNowPassed(uint32_t when) const{return _ms->now>=when;} bool isContactWriteDue() const{return _store->contact_pending;} diff --git a/test/test_companion_contact_sleep_contract.py b/test/test_companion_contact_sleep_contract.py index 08896d6cf..805e50f55 100644 --- a/test/test_companion_contact_sleep_contract.py +++ b/test/test_companion_contact_sleep_contract.py @@ -243,7 +243,7 @@ int main() { def test_main_loop_gates_sleep_on_pending_work(self): main = (ROOT / "examples/companion_radio/main.cpp").read_text() sleep_start = main.index(" using mesh::powerdiag::Reason;") - sleep_end = main.index("if (can_sleep)", sleep_start) + sleep_end = main.index("if (can_sleep", sleep_start) sleep_gate = main[sleep_start:sleep_end] self.assertIn("the_mesh.pendingWorkReason()", sleep_gate) @@ -258,7 +258,7 @@ int main() { def test_polled_rak_ethernet_holds_event_sleep_through_pending_off(self): main = (ROOT / "examples/companion_radio/main.cpp").read_text() start = main.index(" using mesh::powerdiag::Reason;") - gate = main[start:main.index("if (can_sleep)", start)] + gate = main[start:main.index("if (can_sleep", start)] fixture = r''' #include #include diff --git a/test/test_companion_radio_settings_transaction.py b/test/test_companion_radio_settings_transaction.py index 3666fdd33..b3a3b4934 100644 --- a/test/test_companion_radio_settings_transaction.py +++ b/test/test_companion_radio_settings_transaction.py @@ -269,6 +269,7 @@ struct MyMesh { #endif bool millisHasNowPassed(uint32_t at) const { return int32_t(now_ms-at)>=0; } bool isDualRadioActive() const { return false; } + bool canIdleDualRadio() const { return true; } bool isContactWriteDue() const { return false; } bool hasPendingOtaApply() const { return false; } bool hasPendingReqs() const { return _delayed_replies.hasRequest(); } diff --git a/test/test_esp32_full_power_lifecycle.py b/test/test_esp32_full_power_lifecycle.py index a35091797..d398a5a8b 100644 --- a/test/test_esp32_full_power_lifecycle.py +++ b/test/test_esp32_full_power_lifecycle.py @@ -230,6 +230,7 @@ struct MyMesh { bool getNextRetryWakeDelay(uint32_t&) const { return false; } bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; } bool isDualRadioActive() const { return false; } + bool canIdleDualRadio() const { return true; } uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; } uint32_t limitSleepToScheduledRadioWork(uint32_t secs) const { return secs; } const AbstractBridge* activeBridge() const { return @WORKER@; } diff --git a/test/test_esp32_full_sleep.py b/test/test_esp32_full_sleep.py index 6def0c9e8..4abb11b06 100644 --- a/test/test_esp32_full_sleep.py +++ b/test/test_esp32_full_sleep.py @@ -145,6 +145,7 @@ struct Mesh { return pending ? mesh::powerdiag::mask(mesh::powerdiag::Reason::Mesh) : 0; } bool isDualRadioActive() const { return dual; } + bool canIdleDualRadio() const { return !dual; } bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; } mesh::Radio* getProfileRadio() { return &timing_radio; } } the_mesh; diff --git a/test/test_logging_sleep_contract.py b/test/test_logging_sleep_contract.py index bfb144586..cbd34bb75 100644 --- a/test/test_logging_sleep_contract.py +++ b/test/test_logging_sleep_contract.py @@ -64,6 +64,7 @@ struct MyMesh { bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; } bool dual_radio = false; bool isDualRadioActive() const { return dual_radio; } + bool canIdleDualRadio() const { return !dual_radio; } uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; } uint32_t limitSleepToScheduledRadioWork(uint32_t secs) const { return secs; } bool hasPendingWork() const; diff --git a/test/test_nrf52_profile_idle.py b/test/test_nrf52_profile_idle.py new file mode 100644 index 000000000..e520a02b9 --- /dev/null +++ b/test/test_nrf52_profile_idle.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Execute the real two-phase nRF52 profile-idle preparation and tick guard.""" + +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + + +class Nrf52ProfileIdleTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temp = tempfile.TemporaryDirectory(prefix="meshcore-profile-idle-") + cls.addClassCleanup(cls.temp.cleanup) + source = (ROOT / "src/helpers/NRF52Board.cpp").read_text(encoding="ascii") + methods = "\n".join(body(source, signature) for signature in ( + "void NRF52Board::prepareSleepCycle()", + "bool NRF52Board::canIdleRadioProfile()", + "void NRF52Board::prepareProfileIdleCycle()", + "void NRF52Board::sleep(uint32_t secs)", + )) + fixture = r''' +#include +#include +#include +#include +#include +#include +#ifndef TEST_TICK_RATE +#define TEST_TICK_RATE 1024 +#endif +#define configTICK_RATE_HZ TEST_TICK_RATE +#define __FPU_USED 1 +#define NRF_CRYPTOCELL 1 +constexpr uint32_t NRF_SUCCESS = 0, NRF_ERROR_INVALID_STATE = 8; +constexpr uint32_t SCB_ICSR_PENDSVSET_Msk = 1UL << 28; +constexpr uint32_t RTC_INTENSET_TICK_Msk = 1; +constexpr int RTC1_IRQn = 17, FPU_IRQn = 38, CRYPTOCELL_IRQn = 42; +constexpr int taskSCHEDULER_RUNNING = 1; +struct SdkNvicState { volatile uint32_t __cr_flag = 0; } nrf_nvic_state; +struct Scb { uint32_t ICSR = 0; } scb; +static Scb* const SCB = &scb; +struct Rtc { uint32_t PRESCALER = 31, INTENSET = RTC_INTENSET_TICK_Msk; } rtc; +static Rtc* const NRF_RTC1 = &rtc; +struct Power { uint32_t USBREGSTATUS = 0; } power; +static Power* const NRF_POWER = &power; +static bool irq_enabled[64] = {}, irq_pending[64] = {}; +static unsigned clears[64] = {}; +static uint32_t ipsr = 0, primask = 0, basepri = 0, faultmask = 0; +static uint32_t fpscr = 0, clock_us = 10000; +static int scheduler_state = taskSCHEDULER_RUNNING; +static bool event = false, softdevice = false, work_pending = false; +static bool inject_during_prepare = false; +static unsigned app_irq = 1, acknowledged_irq = 0; +static unsigned state_queries = 0, sdk_calls = 0, sleeps = 0, delays = 0; +static uint32_t __get_IPSR() { return ipsr; } +static uint32_t __get_PRIMASK() { return primask; } +static uint32_t __get_BASEPRI() { return basepri; } +static uint32_t __get_FAULTMASK() { return faultmask; } +static int xTaskGetSchedulerState() { return scheduler_state; } +static uint32_t NVIC_GetEnableIRQ(int irq) { return irq_enabled[irq]; } +static void NVIC_ClearPendingIRQ(int irq) { irq_pending[irq] = false; ++clears[irq]; } +static uint32_t __get_FPSCR() { return fpscr; } +static void __set_FPSCR(uint32_t value) { fpscr = value; } +static uint32_t millis() { return clock_us / 1000; } +static uint32_t micros() { return clock_us; } +static void delay(uint32_t ms) { delays += ms; } +static void applicationIrq() { ++app_irq; event = true; work_pending = true; } +static void taskYIELD() { ++app_irq; event = true; } +namespace mesh_nrf52 { +static uint32_t softdeviceIsEnabled(uint8_t& enabled) { + ++state_queries; enabled = softdevice; event = true; return NRF_SUCCESS; +} +} +static uint32_t sd_power_usbregstatus_get(uint32_t* value) { + *value = power.USBREGSTATUS; event = true; return NRF_SUCCESS; +} +static uint32_t sd_app_evt_wait() { + ++sdk_calls; + const bool history = app_irq != acknowledged_irq; + acknowledged_irq = app_irq; + event = false; + if (!history && !irq_pending[FPU_IRQn] && !irq_pending[CRYPTOCELL_IRQn]) { + assert(!work_pending); ++sleeps; clock_us += 500; + ++app_irq; + } + return NRF_SUCCESS; +} +static void __SEV() { event = true; } +static void __WFE() { + if (inject_during_prepare) { inject_during_prepare = false; applicationIrq(); } + if (event) event = false; + else { assert(!work_pending); ++sleeps; clock_us += 500; } +} +struct NRF52Board { + bool radio_test = false; + bool isRadioTestActive() const { return radio_test; } + void prepareSleepCycle(); + static bool canIdleRadioProfile(); + void prepareProfileIdleCycle(); + void sleep(uint32_t secs); +}; +@METHODS@ +static void floatingWork() { + fpscr = 0x60000010UL; irq_pending[FPU_IRQn] = true; + event = true; // SEVONPEND retains the event even after NVIC pending clears. +} +static void ready() { + irq_enabled[RTC1_IRQn] = true; + assert(NRF52Board::canIdleRadioProfile() == (TEST_TICK_RATE == 1024)); +} +static void normal(bool sdk) { + ready(); softdevice = sdk; + NRF52Board board; + board.prepareSleepCycle(); + const auto initial_queries = state_queries; + floatingWork(); + board.prepareProfileIdleCycle(); + assert(state_queries == initial_queries + 1); + assert(!event && !irq_pending[FPU_IRQn] && (fpscr & 0x9f) == 0); + assert((fpscr & ~0x9fUL) == 0x60000000UL); + assert(clears[FPU_IRQn] == 1 && !work_pending); + const auto waits_before = sdk_calls; + assert(NRF52Board::canIdleRadioProfile()); // Final pure recheck creates no event/SVC. + assert(!event && state_queries == initial_queries + 1 && sdk_calls == waits_before); + board.sleep(0); + assert(sleeps == 1 && delays == 0); +} +static void legacy() { + ready(); NRF52Board board; + board.prepareSleepCycle(); floatingWork(); + board.sleep(0); + // The late FPU cleanup cannot remove an already-latched ARM event. This + // negative control reproduces the measured tuple-dependent raw-WFE insomnia. + assert(sleeps == 0 && !irq_pending[FPU_IRQn]); +} +static void vetoes() { + ready(); + if (TEST_TICK_RATE != 1024) return; + for (uint32_t* value : {&ipsr, &primask, &basepri, &faultmask}) { + *value = 1; assert(!NRF52Board::canIdleRadioProfile()); *value = 0; + } + scheduler_state = 0; assert(!NRF52Board::canIdleRadioProfile()); + scheduler_state = taskSCHEDULER_RUNNING; + nrf_nvic_state.__cr_flag = 1; assert(!NRF52Board::canIdleRadioProfile()); + nrf_nvic_state.__cr_flag = 0; + scb.ICSR = SCB_ICSR_PENDSVSET_Msk; assert(!NRF52Board::canIdleRadioProfile()); + scb.ICSR = 0; + rtc.PRESCALER = 0; assert(!NRF52Board::canIdleRadioProfile()); rtc.PRESCALER = 31; + rtc.INTENSET = 0; assert(!NRF52Board::canIdleRadioProfile()); + rtc.INTENSET = RTC_INTENSET_TICK_Msk; + irq_enabled[RTC1_IRQn] = false; assert(!NRF52Board::canIdleRadioProfile()); + irq_enabled[RTC1_IRQn] = true; + assert(NRF52Board::canIdleRadioProfile() && state_queries == 0 && sdk_calls == 0); +} +static void owners() { + ready(); NRF52Board board; + floatingWork(); irq_enabled[FPU_IRQn] = true; + irq_pending[7] = true; + board.prepareProfileIdleCycle(); + assert(clears[FPU_IRQn] == 0 && irq_pending[FPU_IRQn]); + assert(fpscr == 0x60000010UL && irq_pending[7] && clears[7] == 0); + irq_enabled[FPU_IRQn] = false; + nrf_nvic_state.__cr_flag = 1; + const auto before = state_queries; + board.prepareProfileIdleCycle(); + assert(state_queries == before && clears[FPU_IRQn] == 0 && irq_pending[FPU_IRQn]); + assert(mesh_nrf52::sleepCycleState().lastError() == NRF_ERROR_INVALID_STATE); +} +static void arrivals(bool sdk, bool during_prepare) { + ready(); softdevice = sdk; NRF52Board board; + floatingWork(); inject_during_prepare = during_prepare; + board.prepareProfileIdleCycle(); + if (during_prepare) { + // Preparation may consume the event, but the ENTIRE following role + // predicate still observes the ISR-owned work and must decline sleep. + assert(work_pending && sleeps == 0); + return; + } + assert(!work_pending && NRF52Board::canIdleRadioProfile()); + applicationIrq(); // Arrives after the role predicate's final decision. + board.sleep(0); + assert(work_pending && sleeps == 0 && delays == 0); +} +static void lateFloatingWork() { + ready(); NRF52Board board; + board.prepareProfileIdleCycle(); + floatingWork(); // A missed float call must degrade idle, never erase its event. + board.sleep(0); + assert(sleeps == 0 && delays == 0); +} +int main(int argc, char** argv) { + assert(argc == 2); + if (!std::strcmp(argv[1], "raw")) normal(false); + else if (!std::strcmp(argv[1], "sdk")) normal(true); + else if (!std::strcmp(argv[1], "legacy")) legacy(); + else if (!std::strcmp(argv[1], "vetoes")) vetoes(); + else if (!std::strcmp(argv[1], "owners")) owners(); + else if (!std::strcmp(argv[1], "late-raw")) arrivals(false, false); + else if (!std::strcmp(argv[1], "late-sdk")) arrivals(true, false); + else if (!std::strcmp(argv[1], "prepare-raw")) arrivals(false, true); + else if (!std::strcmp(argv[1], "prepare-sdk")) arrivals(true, true); + else if (!std::strcmp(argv[1], "late-fpu")) lateFloatingWork(); + else assert(false); +} +'''.replace("@METHODS@", methods) + cls.binaries = {} + for tick_rate in (1024, 1000): + cpp = Path(cls.temp.name) / ("idle-%d.cpp" % tick_rate) + binary = cpp.with_suffix("") + cpp.write_text(fixture, encoding="ascii") + result = subprocess.run( + shlex.split(os.environ.get("CXX", "g++")) + + ["-std=c++11", "-Wall", "-Wextra", "-Werror", "-pedantic", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", "-DTEST_TICK_RATE=%d" % tick_rate, + "-I", str(ROOT / "src"), str(cpp), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + cls.binaries[tick_rate] = binary + + def run_case(self, case, tick_rate=1024): + result = subprocess.run([str(self.binaries[tick_rate]), case], + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_fpu_event_refresh_restores_raw_and_softdevice_single_wait(self): + self.run_case("raw") + self.run_case("sdk") + + def test_late_existing_fpu_cleanup_reproduces_raw_insomnia(self): + self.run_case("legacy") + + def test_tick_context_and_pending_scheduler_guards_are_pure(self): + self.run_case("vetoes") + self.run_case("vetoes", tick_rate=1000) + + def test_enabled_fpu_owner_unrelated_irq_and_sdk_critical_are_preserved(self): + self.run_case("owners") + + def test_work_during_preparation_survives_for_complete_role_predicate(self): + self.run_case("prepare-raw") + self.run_case("prepare-sdk") + + def test_irq_after_predicate_prevents_sleep_without_late_event_clear(self): + self.run_case("late-raw") + self.run_case("late-sdk") + + def test_unexpected_float_after_preparation_remains_immediate_not_double_wait(self): + self.run_case("late-fpu") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_power_saving_role_diagnostics.py b/test/test_power_saving_role_diagnostics.py index 9b973e3f2..859114a91 100644 --- a/test/test_power_saving_role_diagnostics.py +++ b/test/test_power_saving_role_diagnostics.py @@ -175,7 +175,7 @@ struct AbstractBridge { uint32_t powerWakeDelayMs(uint32_t) const { return wake_ms; } }; struct MyMesh { - bool dual = false, ota = false, queue = false, retry = false, scheduled = false; + bool dual = false, profile_idle = false, ota = false, queue = false, retry = false, scheduled = false; bool bridge_enabled = true; bool queue_delay_set = false, retry_delay_set = false; uint32_t queue_delay = 0, retry_delay = 0, calibration_delay = 0; @@ -195,6 +195,7 @@ struct MyMesh { const AbstractBridge* activeBridge() const { return bridge; } bool rs232BridgeEnabled() const { return bridge_enabled; } bool isDualRadioActive() const { return dual; } + bool canIdleDualRadio() const { return !dual || profile_idle; } bool hasPendingOtaApply() const { return ota; } bool hasQueuedWorkDue() const { return queue; } bool hasRetryWorkDue() const { return retry; } @@ -232,6 +233,11 @@ int main() { flag = false; }; check(node.dual, Reason::DualRadio); check(node.ota, Reason::Ota); + node.dual = true; node.profile_idle = true; + assert(!node.hasPendingWork()); + check(node.ota, Reason::Ota); // An idle scanner does not bypass other role guards. + check(node.queue, Reason::Queue); check(node.retry, Reason::Retry); + node.dual = node.profile_idle = false; check(node._local_cli_output.busy_value, Reason::Serial); check(node.deferred_cli_command.pending, Reason::Scheduled); check(node.uart.running, Reason::Bridge); diff --git a/test/test_profile_idle.py b/test/test_profile_idle.py new file mode 100644 index 000000000..58dab8f91 --- /dev/null +++ b/test/test_profile_idle.py @@ -0,0 +1,222 @@ +"""Exercise production radio2 idle admission and its final role rechecks.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#define STATE_RX 1 +#define MESH_ENABLE_TX_BEACONS 1 +#define MESH_ENABLE_MORSE_RX 1 +struct Rtc { uint32_t COUNTER = 200; } rtc; +#define NRF_RTC2 (&rtc) +struct NRF52Board { + static bool ready; + static bool canIdleRadioProfile() { return ready; } +}; +bool NRF52Board::ready = true; +struct RadioLibWrapper { + mesh::RadioProfiles _profiles; + uint32_t _profile_idle_visit_ticks=1000, _profile_idle_stamp=100; + uint32_t _profile_visit_stamp=100, _profile_idle_switch_us=1100; + uint16_t _profile_idle_preamble=32, _physical_preamble=32; + uint32_t _profile_generation=1, _profile_scan_generation[2]={1,1}; + uint32_t _profile_retry_at=0, _wd_observe_until=0; + uint8_t state=STATE_RX, _active_profile=0; + bool _params_valid=true, _profile_rxps_suspended=true; + bool _rx_ps_enabled=false, _rx_ps_armed=false, _rx_hold_continuous=false; + bool _rx_retry_pending=false, _profile_refresh_required=false; + bool _nf_refresh_requested=false, _nf_calib_active=false; + bool _noise_floor_valid=true, _noise_floor_secondary_valid=true; + bool _cw_active=false, _morse_owned=false, _beacon_owned=false, _morse_rx_owned=false; + bool irq=false; + bool hasPendingRadioInterrupt() const { return irq; } + bool getProfileIdleDelay(uint32_t&) const; + RadioLibWrapper() { + _profiles.secondary.mode=mesh::RadioProfileMode::RxTx; + _profiles.switch_test_samples[0]=_profiles.switch_test_samples[1]=4; + _profiles.switch_test_max_us[0]=_profiles.switch_test_max_us[1]=1000; + } +}; +@METHOD@ +int main() { + uint32_t delay=999; + RadioLibWrapper w; +#if !defined(NRF52_PLATFORM) + assert(!w.getProfileIdleDelay(delay) && delay==0); +#else + assert(w.getProfileIdleDelay(delay) && delay==26); + // Exact early-wake boundary, rounding, already expired, and RTC wrap. + for (uint32_t remaining=0; remaining<=48; ++remaining) { + rtc.COUNTER=100+1000-remaining; + assert(!w.getProfileIdleDelay(delay) && delay==0); + } + rtc.COUNTER=100+1000-49; + assert(w.getProfileIdleDelay(delay) && delay==1); + rtc.COUNTER=1101; + assert(!w.getProfileIdleDelay(delay)); + w._profile_visit_stamp=w._profile_idle_stamp=0x00fffff0; + rtc.COUNTER=0x10; + assert(w.getProfileIdleDelay(delay) && delay==29); + w=RadioLibWrapper(); rtc.COUNTER=200; + w._profile_idle_visit_ticks=0x00800000; + assert(!w.getProfileIdleDelay(delay)); + // Every software owner, cached timing proof, and asynchronous IRQ fails closed. + auto denied=[&](auto mutate) { + w=RadioLibWrapper(); rtc.COUNTER=200; delay=999; + mutate(); assert(!w.getProfileIdleDelay(delay) && delay==0); + }; + denied([&]{NRF52Board::ready=false;}); NRF52Board::ready=true; + denied([&]{w._profiles.secondary.mode=mesh::RadioProfileMode::Off;}); + denied([&]{w._profile_idle_visit_ticks=0;}); + denied([&]{w._params_valid=false;}); + denied([&]{w.state=3;}); + denied([&]{w._profile_rxps_suspended=false;}); + denied([&]{w._rx_ps_enabled=true;}); + denied([&]{w._rx_ps_armed=true;}); + denied([&]{w._rx_hold_continuous=true;}); + denied([&]{w._rx_retry_pending=true;}); + denied([&]{w._profile_retry_at=1;}); + denied([&]{w._profile_refresh_required=true;}); + denied([&]{w._nf_refresh_requested=true;}); + denied([&]{w._nf_calib_active=true;}); + denied([&]{w._noise_floor_valid=false;}); + denied([&]{w._noise_floor_secondary_valid=false;}); + denied([&]{w._wd_observe_until=1;}); + denied([&]{w._cw_active=true;}); + denied([&]{w._morse_owned=true;}); + denied([&]{w._beacon_owned=true;}); + denied([&]{w._morse_rx_owned=true;}); + denied([&]{w.irq=true;}); + denied([&]{w._profiles.resetSwitchTest();}); + denied([&]{++w._profiles.generation[0];}); + denied([&]{++w._profiles.generation[1];}); + denied([&]{w._active_profile=2;}); + denied([&]{++w._profile_generation;}); + denied([&]{++w._profile_visit_stamp;}); + denied([&]{++w._physical_preamble;}); + denied([&]{++w._profiles.switch_test_max_us[1];}); + // A second query must reject an interrupt or elapsed deadline after admission. + w=RadioLibWrapper(); + assert(w.getProfileIdleDelay(delay)); w.irq=true; + assert(!w.getProfileIdleDelay(delay)); + w.irq=false; rtc.COUNTER=1099; + assert(!w.getProfileIdleDelay(delay)); + mesh::RadioProfileParams longfast; + longfast.bw=250; longfast.sf=11; + longfast.protocol=mesh::RadioProfileProtocol::Meshtext; + assert(mesh::RadioProfiles::minimumListenUs(longfast,true)==37684); + w._profiles.secondary.params=longfast; + assert(w._profiles.preamble(1,32)==16); +#endif +} +''' + + +class ProfileIdleTests(unittest.TestCase): + def test_scanner_cache_tracks_visits_and_invalidates_early_returns(self): + from test_radio_profile_scan import HARNESS as scan_harness + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + names = [("uint8_t", "beginReconfigure"), ("void", "endReconfigure"), + ("void", "serviceProfileScan")] + [("mesh::RadioParamApplyResult", name) for name in + ("tuneProfile", "prepareTransmitProfile", "tryRestoreCodingRate", + "trySetParams", "trySetPrimaryParams")] + methods = "\n".join(body(source, f"{kind} RadioLibWrapper::{name}(") for kind, name in names) + methods += "\n" + body(source, + "mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool") + wrapper = (ROOT / "src/helpers/radiolib/CustomSX1262Wrapper.h").read_text() + methods += "\n" + body(wrapper, "void setProfileStandbyWarm(").replace( + "void setProfileStandbyWarm(bool enabled) override", + "void RadioLibWrapper::setProfileStandbyWarm(bool enabled)") + fixture = scan_harness.split("int main()", 1)[0].replace("@METHODS@", methods) + fixture = fixture.replace(" bool _cw_active=false;", """ + uint32_t _profile_idle_visit_ticks=0, _profile_idle_stamp=0, _profile_idle_switch_us=0; + uint16_t _profile_idle_preamble=0; + bool _cw_active=false; +""") + fixture += r''' +int main() { + RadioLibWrapper w; w.enable(); + assert(w._profile_idle_visit_ticks==0); // restart needs a fresh pass + w.serviceProfileScan(); + const auto ticks=w._profile_idle_visit_ticks; + assert(ticks>0 && w._profile_idle_stamp==w._profile_visit_stamp); + assert(ticks==uint64_t(w._profiles.listenUs(w._active_profile, + w.profilePreamble(w._profiles.slowerProfile())))*512/15625); + w._cw_active=true; w.serviceProfileScan(); + assert(w._profile_idle_visit_ticks==0); + w._cw_active=false; w.serviceProfileScan(); assert(w._profile_idle_visit_ticks); + w._profile_retry_at=millis()+1000; w.serviceProfileScan(); + assert(w._profile_idle_visit_ticks==0); + w._profile_retry_at=0; w.serviceProfileScan(); assert(w._profile_idle_visit_ticks); + w.busy=true; w.serviceProfileScan(); assert(w._profile_idle_visit_ticks==0); + w.busy=false; w.packet=true; w.serviceProfileScan(); assert(w._profile_idle_visit_ticks==0); + w.packet=false; w.events.record(); w.serviceProfileScan(); assert(w._profile_idle_visit_ticks==0); + w.events.claim(); w.serviceProfileScan(); assert(w._profile_idle_visit_ticks); + elapsed_us+=1000000; // hop cannot publish old visit with new timestamp + const auto old=w._active_profile; w.serviceProfileScan(); + assert(w._active_profile!=old && w._profile_idle_visit_ticks==0); + w.serviceProfileScan(); assert(w._profile_idle_visit_ticks); + w._profiles.setSecondary({},false); w.serviceProfileScan(); + assert(w._profile_idle_visit_ticks==0); +} +''' + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + (path / "Arduino.h").write_text("#pragma once\n#include \n#include \n") + (path / "test.cpp").write_text(fixture) + result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", + "-DNRF52_PLATFORM=1", "-Wall", "-Wextra", "-I", folder, + "-I", str(ROOT / "src"), str(path / "test.cpp"), "-o", str(path / "test")], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(path / "test")], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_integer_admission_and_unsupported_platform(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + method = body(source, "bool RadioLibWrapper::getProfileIdleDelay(") + # Keep the final admission free of SPI, float work and clock mutation. + for forbidden in ("profilePreamble(", "listenUs(", "profileTimestamp(", + "isChipBusy(", "isPacketPendingOrReceiving("): + self.assertNotIn(forbidden, method) + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + (path / "Arduino.h").write_text("#pragma once\n#include \n#include \n") + (path / "test.cpp").write_text(HARNESS.replace("@METHOD@", method)) + for defines in ([], ["-DNRF52_PLATFORM=1"]): + result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", + "-Wall", "-Wextra", "-Werror", *defines, "-I", folder, + "-I", str(ROOT / "src"), str(path / "test.cpp"), "-o", str(path / "test")], + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(path / "test")], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_late_preparation_precedes_all_guards_and_final_recheck(self): + for role in ("companion_radio", "simple_repeater", "simple_room_server", "simple_sensor"): + source = (ROOT / "examples" / role / "main.cpp").read_text() + loop = body(source, "\nvoid loop()") + prepare = loop.index("board.prepareProfileIdleCycle()") + predicate = loop.index("sleep_reason =" if role == "companion_radio" else "const auto sleep_guard") + self.assertLess(prepare, predicate, role) + final = loop.index("the_mesh.canIdleDualRadio()", predicate) + sleep = loop.index("board.sleep", final) + self.assertLess(final, sleep, role) + self.assertNotIn("prepareProfileIdleCycle", loop[predicate:], role) + if role == "simple_sensor": + arm = loop.index("armTrackerSleepWake(", predicate) + timer_recheck = loop.index("the_mesh.canIdleDualRadio()", arm) + self.assertLess(timer_recheck, loop.index("board.sleep", arm)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_profile_idle_dispatcher.py b/test/test_radio_profile_idle_dispatcher.py new file mode 100644 index 000000000..f21297dd7 --- /dev/null +++ b/test/test_radio_profile_idle_dispatcher.py @@ -0,0 +1,195 @@ +"""Execute production scanner-idle admission and Dispatcher wake deadlines.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#define MESH_ENABLE_FOREIGN_RADIO 1 +#define MESH_ENABLE_TX_BEACONS 1 +struct Profiles { bool active = false; bool enabled() const { return active; } }; +struct Radio { + Profiles profile; + bool receive_retry = false; + uint32_t receive_delay = 0, recovery = 0; + Profiles* profiles() { return &profile; } + bool getReceiveRetryDelay(uint32_t, uint32_t& out) const { + out = receive_delay; return receive_retry; + } + uint32_t getRecoveryCount() const { return recovery; } + @DEFAULT@ +}; +struct Scanner : Radio { + bool supported = false; + uint32_t delay = 0; + mutable unsigned reads = 0; + bool getProfileIdleDelay(uint32_t& out) const override { + ++reads; out = delay; return supported; + } +}; +struct Clock { uint32_t now = 1000; uint32_t getMillis() const { return now; } }; +struct Manager { + bool tx = false, rx = false; + uint32_t tx_at = 0, rx_at = 0; + bool getNextOutboundTime(uint32_t, uint32_t& out) const { out = tx_at; return tx; } + bool getNextInboundTime(uint32_t, uint32_t& out) const { out = rx_at; return rx; } +}; +struct Dispatcher { + Radio* _radio; + Clock* _ms; + Manager* _mgr; + bool radio_available = true, foreign = false, beacon = false; + bool beacon_wake_enabled = false, adaptive = false; + uint32_t beacon_wake_at = 0, next_tx_time = 0; + void* outbound = nullptr; + bool outbound_radio_retry_pending = false; + uint32_t outbound_radio_retry_at = 0, outbound_restore_retry_at = 0; + uint8_t outbound_restore_cr = 0; + mesh::AGCResetScheduler agc_reset; + Dispatcher(Radio* radio, Clock* clock, Manager* manager) + : _radio(radio), _ms(clock), _mgr(manager) {} + bool isDualRadioActive() const { return _radio->profiles()->enabled(); } + bool hasForeignRadioFrame() const { return foreign; } + bool hasBeaconRadioFrame() const { return beacon; } + bool useAdaptiveAGC() const { return adaptive; } + @ADMISSION@ + bool getNextQueueWakeDelay(uint32_t&) const; +}; +@DEADLINES@ +int main() { + Radio unknown; + uint32_t unchanged = 71; + assert(!unknown.getProfileIdleDelay(unchanged) && unchanged == 71); + Clock clock; + Manager manager; + Dispatcher legacy{&unknown, &clock, &manager}; + assert(legacy.canIdleDualRadio()); + unknown.profile.active = true; + assert(!legacy.canIdleDualRadio()); + assert(legacy.getNextQueueWakeDelay(unchanged) && unchanged == 0); + + for (uint32_t now : {1000U, UINT32_MAX - 15U}) { + clock.now = now; + manager = {}; + Scanner radio; + Dispatcher node{&radio, &clock, &manager}; + uint32_t delay = 99; + assert(node.canIdleDualRadio() && radio.reads == 0); + assert(!node.getNextQueueWakeDelay(delay) && radio.reads == 0); + radio.profile.active = true; + radio.delay = 37; + assert(!node.canIdleDualRadio()); // Unknown ports keep their polling contract. + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + radio.supported = true; + radio.delay = 0; + assert(!node.canIdleDualRadio()); // A due/too-short visit is not an idle budget. + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + radio.delay = 37; + assert(node.canIdleDualRadio()); + assert(node.getNextQueueWakeDelay(delay) && delay == 37); + node.radio_available = false; + assert(!node.canIdleDualRadio()); + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + node.radio_available = true; + + // Scanner admission cannot hide any previously actionable owner/deadline. + node.foreign = true; + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + node.foreign = false; + node.beacon = true; + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + node.beacon = false; + node.beacon_wake_enabled = true; + node.beacon_wake_at = now + 50; + assert(node.getNextQueueWakeDelay(delay) && delay == 37); + node.beacon_wake_at = now + 4; + assert(node.getNextQueueWakeDelay(delay) && delay == 4); + node.beacon_wake_at = now; + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + node.beacon_wake_enabled = false; + + radio.receive_retry = true; radio.receive_delay = 7; + assert(node.getNextQueueWakeDelay(delay) && delay == 7); + radio.receive_delay = 50; + assert(node.getNextQueueWakeDelay(delay) && delay == 37); + radio.receive_retry = false; + node.outbound_restore_cr = 5; + node.outbound_restore_retry_at = now + 2; + assert(node.getNextQueueWakeDelay(delay) && delay == 2); + node.outbound_restore_cr = 0; + + manager.tx = true; manager.tx_at = now + 5; + node.next_tx_time = now + 12; + assert(node.getNextQueueWakeDelay(delay) && delay == 12); + manager.tx_at = now + 50; + assert(node.getNextQueueWakeDelay(delay) && delay == 37); + manager.rx = true; manager.rx_at = now + 3; + assert(node.getNextQueueWakeDelay(delay) && delay == 3); + manager.rx_at = now; + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + manager = {}; + + node.adaptive = true; + node.agc_reset.configure(now, 8, true, radio.recovery); + assert(node.getNextQueueWakeDelay(delay) && delay == 8); + node.agc_reset.configure(now, 80, true, radio.recovery); + assert(node.getNextQueueWakeDelay(delay) && delay == 37); + ++radio.recovery; + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + node.adaptive = false; + + node.outbound = &node; + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + node.outbound_radio_retry_pending = true; + node.outbound_radio_retry_at = now + 200; + assert(node.getNextQueueWakeDelay(delay) && delay == 37); + radio.delay = 400; + assert(node.getNextQueueWakeDelay(delay) && delay == 200); + radio.receive_retry = true; radio.receive_delay = 6; + assert(node.getNextQueueWakeDelay(delay) && delay == 6); + radio.delay = 3; + assert(node.getNextQueueWakeDelay(delay) && delay == 3); + node.outbound_radio_retry_at = now; + assert(node.getNextQueueWakeDelay(delay) && delay == 0); + } +} +''' + + +class RadioProfileIdleDispatcherTest(unittest.TestCase): + def test_actual_default_admission_and_all_dispatcher_deadlines(self): + header = (ROOT / 'src/Dispatcher.h').read_text(encoding='ascii') + source = (ROOT / 'src/Dispatcher.cpp').read_text(encoding='ascii') + code = HARNESS.replace('@DEFAULT@', method(header, 'virtual bool getProfileIdleDelay(')) + code = code.replace('@ADMISSION@', method(header, 'bool canIdleDualRadio() const')) + code = code.replace('@DEADLINES@', method(source, 'bool Dispatcher::getNextQueueWakeDelay(')) + with tempfile.TemporaryDirectory(prefix='meshcore-profile-idle-dispatcher-') as directory: + cpp, binary = Path(directory) / 'test.cpp', Path(directory) / 'test' + cpp.write_text(code, encoding='ascii') + flags = ['-std=c++17', '-Wall', '-Wextra', '-Werror'] + if os.name != 'nt': + flags += ['-fsanitize=address,undefined', '-fno-sanitize-recover=all', + '-fno-pie', '-no-pie'] + result = subprocess.run([ + os.environ.get('CXX', 'c++'), *flags, '-I', str(ROOT / 'src'), + str(cpp), '-o', str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_rs232_heartbeat_owner.py b/test/test_rs232_heartbeat_owner.py index e27f84491..edb10efe3 100644 --- a/test/test_rs232_heartbeat_owner.py +++ b/test/test_rs232_heartbeat_owner.py @@ -154,6 +154,7 @@ struct MyMesh : Callbacks { unsigned long dirty_contacts_expiry = 0, next_recent_repeater_sweep = 0; unsigned long next_battery_alert_check = 0; bool isDualRadioActive() const { return false; } + bool canIdleDualRadio() const { return true; } bool hasPendingOtaApply() const { return false; } bool hasQueuedWorkDue() const { return false; } bool hasRetryWorkDue() const { return false; } diff --git a/test/test_sensor_tracker_sleep.py b/test/test_sensor_tracker_sleep.py index 9a9f12855..88e8a3070 100644 --- a/test/test_sensor_tracker_sleep.py +++ b/test/test_sensor_tracker_sleep.py @@ -54,6 +54,7 @@ public: const Clock* getRTCClock()const{return &rtc;} bool millisHasNowPassed(unsigned long time)const{return (int32_t)(millis()-time)>=0;} bool isDualRadioActive()const{return dual;} + bool canIdleDualRadio()const{return !dual;} bool hasPendingOtaApply()const{return ota;} bool isAnyTempRadioActive()const{return temp;} bool hasOutbound()const{return outbound;} @@ -160,6 +161,7 @@ static void trackerWakeCallback(TimerHandle_t){} @ARM@ struct Mesh {bool tracker=true;uint32_t sleep_secs=30; bool isTrackerModeEnabled()const{return tracker;} + bool canIdleDualRadio()const{return true;} uint32_t getPowerSaveSleepMilliseconds(uint32_t max)const{assert(max==30000);return sleep_secs*1000;} bool getNextNoiseFloorWakeDelay(uint32_t&)const{return false;} bool millisHasNowPassed(uint32_t deadline)const{return (int32_t)(millis()-deadline)>=0;}