Allow safe nRF52 MCU idle between radio2 scan deadlines

This commit is contained in:
mikecarper committed 2026-10-17 13:21:05 -07:00
1 parent 53835c0793
commit f1d281a766
30 files changed
+916 -24

No files matched your search

+2
View File
@@ -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
+5
View File
@@ -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
+34
View File
@@ -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:
+1 -1
View File
@@ -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)
+5 -1
View File
@@ -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)
+1 -1
View File
@@ -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);
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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);
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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);
+11 -4
View File
@@ -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())
+14 -4
View File
@@ -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;
+14
View File
@@ -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);
+39 -2
View File
@@ -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
+13 -2
View File
@@ -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;
+2 -1
View File
@@ -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 {
+58 -1
View File
@@ -11,6 +11,7 @@
#include <esp_heap_caps.h>
#elif defined(NRF52_PLATFORM)
#include <utility/debug.h>
#include "../NRF52Board.h"
#elif defined(STM32_PLATFORM)
#include <sys/types.h>
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) {
+9
View File
@@ -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;
+2
View File
@@ -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;}
@@ -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 <cassert>
#include <helpers/PowerSavingDiagnostics.h>
@@ -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(); }
+1
View File
@@ -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@; }
+1
View File
@@ -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;
+1
View File
@@ -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;
+261
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <helpers/PowerSavingDiagnostics.h>
#include <helpers/nrf52/SleepCycleState.h>
#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()
+7 -1
View File
@@ -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);
+222
View File
@@ -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 <cassert>
#include <cstdint>
#include <RadioProfiles.h>
#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 <cstdint>\n#include <cmath>\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 <cstdint>\n#include <cmath>\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()
+195
View File
@@ -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 <cassert>
#include <cstddef>
#include <cstdint>
#include <initializer_list>
#include <helpers/AGCResetScheduler.h>
#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()
+1
View File
@@ -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; }
+2
View File
@@ -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;}