From 8038715ffe6d3f8ff4cbbaeb0de1ebcdb6e555e0 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 19 Oct 2026 18:53:33 -0700 Subject: [PATCH] Extend nRF52 radio profile timing across RTC wraps --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/radiolib/RadioLibWrappers.cpp | 44 ++++++++++++++--------- test/test_profile_idle.py | 10 +++++- test/test_radio_profile_clock.py | 35 +++++++++++++----- test/test_radio_profile_scan.py | 2 +- 5 files changed, 65 insertions(+), 27 deletions(-) diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 7969b124d..0932e33b9 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -589,6 +589,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_clock.py -v python3 -B test/test_radio_profile_runtime_recommendation.py -v python3 -B test/test_radio_profile_idle_dispatcher.py -v python3 -B test/test_radio_rxps_rejection.py -v diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 175e88fcb..4fc4be3ee 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -56,12 +56,13 @@ static RadioInterruptEvents radio_interrupt_events[RADIO_INTERRUPT_SLOT_COUNT]; static RadioLibWrapper* radio_interrupt_owners[RADIO_INTERRUPT_SLOT_COUNT] = {}; -// The Adafruit nRF52 core's micros() falls back to 1024 Hz RTOS ticks when -// DWT is disabled. A sub-millisecond retune then appears to take exactly -// 977 us (or 1953 us across two ticks), making the learned switch allowance -// and scan dwell inaccurate. On nRF52, RTC2 provides a 32768 Hz profile clock -// without changing micros() globally or holding the high-frequency clock on. -// Keep it stopped in the ordinary single-profile mode. +// Extend the 24-bit RTC counter on each loop poll. A valid explicit preamble +// can require a visit longer than its 512-second hardware wrap period. +#if defined(NRF52_PLATFORM) +static uint32_t profile_clock_raw = 0; +static uint32_t profile_clock_ticks = 0; +#endif + static void syncProfileClock(bool dual_profile) { #if defined(NRF52_PLATFORM) static bool active = false; @@ -69,7 +70,10 @@ static void syncProfileClock(bool dual_profile) { if (dual_profile) { NRF_RTC2->TASKS_STOP = 1; NRF_RTC2->PRESCALER = 0; - NRF_RTC2->TASKS_CLEAR = 1; + // Keep the retained hardware count: an asynchronous clear could otherwise + // look like a wrap before its first poll. + profile_clock_raw = NRF_RTC2->COUNTER & 0x00ffffffUL; + profile_clock_ticks = 0; NRF_RTC2->TASKS_START = 1; } else { NRF_RTC2->TASKS_STOP = 1; @@ -83,7 +87,12 @@ static void syncProfileClock(bool dual_profile) { static uint32_t profileTimestamp(bool dual_profile) { syncProfileClock(dual_profile); #if defined(NRF52_PLATFORM) - if (dual_profile) return NRF_RTC2->COUNTER; + if (dual_profile) { + const uint32_t raw = NRF_RTC2->COUNTER & 0x00ffffffUL; + profile_clock_ticks += (raw - profile_clock_raw) & 0x00ffffffUL; + profile_clock_raw = raw; + return profile_clock_ticks; + } #endif return micros(); } @@ -91,10 +100,9 @@ static uint32_t profileTimestamp(bool dual_profile) { static uint32_t profileElapsedUs(uint32_t since, uint32_t now, bool dual_profile) { #if defined(NRF52_PLATFORM) if (!dual_profile) return now - since; - // RTC2 is 24-bit and wraps every 512 seconds. Active scanning samples it - // many times per second; a freshly configured profile gets a new visit stamp. - const uint32_t ticks = (now - since) & 0x00ffffffUL; - return static_cast((uint64_t(ticks) * 15625 + 511) >> 9); + const uint32_t ticks = now - since; + const uint64_t elapsed = (uint64_t(ticks) * 15625 + 511) >> 9; + return elapsed > UINT32_MAX ? UINT32_MAX : static_cast(elapsed); #else (void) dual_profile; return now - since; @@ -177,7 +185,7 @@ void RadioLibWrapper::begin() { #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return; #endif - syncProfileClock(_profiles.enabled()); + (void)profileTimestamp(_profiles.enabled()); #ifdef USE_CC310_HW_CRYPTO // Initialize CryptoCell once from normal task context. The session helper // still initializes lazily if an earlier crypto operation runs first. @@ -800,7 +808,7 @@ void RadioLibWrapper::serviceProfileScan() { #if defined(NRF52_PLATFORM) _profile_idle_visit_ticks = 0; // Every deferred/restarted scan fails closed. #endif - syncProfileClock(_profiles.enabled()); + (void)profileTimestamp(_profiles.enabled()); if (_cw_active) return; if (!_params_valid || (_profile_retry_at && (int32_t)(millis() - _profile_retry_at) < 0)) return; _profile_retry_at = 0; @@ -942,7 +950,11 @@ bool RadioLibWrapper::getProfileIdleDelay(uint32_t& delay_ms) const { #if MESH_ENABLE_MORSE_RX if (_morse_rx_owned) return false; #endif - const uint32_t elapsed = (NRF_RTC2->COUNTER - _profile_idle_stamp) & 0x00ffffffUL; + // Use the extended domain without mutating the clock in this final sleep + // check. The scanner already sampled it before publishing this idle proof. + const uint32_t stamp = profile_clock_ticks + + ((NRF_RTC2->COUNTER - profile_clock_raw) & 0x00ffffffUL); + const uint32_t elapsed = stamp - _profile_idle_stamp; // 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. @@ -1868,7 +1880,7 @@ void RadioLibWrapper::loop() { #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return; #endif - syncProfileClock(_profiles.enabled()); + (void)profileTimestamp(_profiles.enabled()); #if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX if (serviceCarrierWave()) return; #endif diff --git a/test/test_profile_idle.py b/test/test_profile_idle.py index 58dab8f91..7199c855f 100644 --- a/test/test_profile_idle.py +++ b/test/test_profile_idle.py @@ -18,6 +18,7 @@ HARNESS = r''' #define MESH_ENABLE_MORSE_RX 1 struct Rtc { uint32_t COUNTER = 200; } rtc; #define NRF_RTC2 (&rtc) +uint32_t profile_clock_raw=0, profile_clock_ticks=0; struct NRF52Board { static bool ready; static bool canIdleRadioProfile() { return ready; } @@ -54,6 +55,13 @@ int main() { assert(!w.getProfileIdleDelay(delay) && delay==0); #else assert(w.getProfileIdleDelay(delay) && delay==26); + // Identical raw RTC values after a full hardware wrap must not resurrect + // an old idle proof. Both stamps use the software-extended tick domain. + profile_clock_ticks=0x01000000UL; + assert(!w.getProfileIdleDelay(delay)); + w._profile_visit_stamp=w._profile_idle_stamp=0x01000064UL; + assert(w.getProfileIdleDelay(delay) && delay==26); + profile_clock_ticks=0;w=RadioLibWrapper(); // Exact early-wake boundary, rounding, already expired, and RTC wrap. for (uint32_t remaining=0; remaining<=48; ++remaining) { rtc.COUNTER=100+1000-remaining; @@ -63,7 +71,7 @@ int main() { assert(w.getProfileIdleDelay(delay) && delay==1); rtc.COUNTER=1101; assert(!w.getProfileIdleDelay(delay)); - w._profile_visit_stamp=w._profile_idle_stamp=0x00fffff0; + w._profile_visit_stamp=w._profile_idle_stamp=0xfffffff0; rtc.COUNTER=0x10; assert(w.getProfileIdleDelay(delay) && delay==29); w=RadioLibWrapper(); rtc.COUNTER=200; diff --git a/test/test_radio_profile_clock.py b/test/test_radio_profile_clock.py index 1b14634ba..0c1e5f261 100644 --- a/test/test_radio_profile_clock.py +++ b/test/test_radio_profile_clock.py @@ -8,9 +8,8 @@ import unittest ROOT = Path(__file__).resolve().parents[1] SOURCE = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() -CLOCK = "static void syncProfileClock(bool dual_profile) {" + SOURCE.split( - "static void syncProfileClock(bool dual_profile) {", 1 -)[1].split("\nstatic\n#if defined(ESP8266)", 1)[0] +CLOCK = SOURCE[SOURCE.index("// Extend the 24-bit RTC counter"):].split( + "\nstatic\n#if defined(ESP8266)", 1)[0] HARNESS = r''' #include @@ -28,22 +27,40 @@ int main() { assert(profileElapsedUs(12, 42, false) == 30); #if defined(NRF52_PLATFORM) rtc.COUNTER = 100; - assert(profileTimestamp(true) == 100); + assert(profileTimestamp(true) == 0); assert(rtc.TASKS_STOP == 1 && rtc.PRESCALER == 0); - assert(rtc.TASKS_CLEAR == 1 && rtc.TASKS_START == 1); + assert(rtc.TASKS_CLEAR == 0 && rtc.TASKS_START == 1); rtc.TASKS_CLEAR = rtc.TASKS_START = rtc.TASKS_STOP = 0; - assert(profileTimestamp(true) == 100); + assert(profileTimestamp(true) == 0); assert(rtc.TASKS_CLEAR == 0 && rtc.TASKS_START == 0 && rtc.TASKS_STOP == 0); assert(profileElapsedUs(0, 32768, true) == 1000000); - assert(profileElapsedUs(0x00fffff0, 0x10, true) == 977); + assert(profileElapsedUs(0xfffffff0, 0x10, true) == 977); + uint32_t stamp=0; + for(unsigned second=1;second<=532;++second) { + rtc.COUNTER=(rtc.COUNTER+32768)&0x00ffffffUL; + stamp=profileTimestamp(true); + assert(stamp==second*32768UL); + assert(profileElapsedUs(0,stamp,true)==second*1000000UL); + } + assert(stamp>0x00ffffffUL); // A valid visit can exceed the hardware period. + assert(profileElapsedUs(0,UINT32_MAX,true)==UINT32_MAX); syncProfileClock(false); assert(rtc.TASKS_STOP == 1 && profileTimestamp(false) == 1234); rtc.TASKS_CLEAR = rtc.TASKS_START = rtc.TASKS_STOP = 0; syncProfileClock(false); assert(rtc.TASKS_CLEAR == 0 && rtc.TASKS_START == 0 && rtc.TASKS_STOP == 0); rtc.COUNTER = 200; - assert(profileTimestamp(true) == 200); - assert(rtc.TASKS_CLEAR == 1 && rtc.TASKS_START == 1); + assert(profileTimestamp(true) == 0); + assert(rtc.TASKS_CLEAR == 0 && rtc.TASKS_START == 1); + for(unsigned chunk=0;chunk<511;++chunk) { + rtc.COUNTER=(rtc.COUNTER+256UL*32768)&0x00ffffffUL; + profileTimestamp(true); + } + rtc.COUNTER=(rtc.COUNTER+255UL*32768)&0x00ffffffUL; + const auto before=profileTimestamp(true);assert(before==0xffff8000UL); + rtc.COUNTER=(rtc.COUNTER+32768)&0x00ffffffUL; + const auto wrapped=profileTimestamp(true);assert(wrapped==0); + assert(profileElapsedUs(before,wrapped,true)==1000000UL); #else assert(profileTimestamp(true) == 1234); assert(profileElapsedUs(12, 42, true) == 30); diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index ee1f4a83d..3724ba4a2 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -26,7 +26,7 @@ static uint64_t elapsed_us; uint32_t micros() { return (uint32_t)elapsed_us; } static bool profile_clock_active=false; void syncProfileClock(bool dual_profile) { profile_clock_active=dual_profile; } -uint32_t profileTimestamp(bool) { return micros(); } +uint32_t profileTimestamp(bool dual) { syncProfileClock(dual); return micros(); } uint32_t profileElapsedUs(uint32_t since, uint32_t now, bool) { return now - since; } uint32_t millis() { return (uint32_t)(elapsed_us / 1000); } struct Chip { bool standbyXOSC=false; int standby() { return 0; } };