Sleep between noise-floor samples and reduce idle refreshes

This commit is contained in:
mikecarper committed 2026-10-17 10:18:14 -07:00
1 parent 50428f0fb7
commit dd7a10f9aa
38 files changed
+955 -121

No files matched your search

+2
View File
@@ -374,6 +374,7 @@ jobs:
python3 -B test/test_mqtt_prefs_commit.py -v
python3 -B test/test_identity_and_settings_recovery.py -v
python3 -B test/test_contact_cache.py -v
python3 -B test/test_one_key_dm_shared_queue.py -v
- name: Verify Ethernet RF harness framing, TX lock, isolation and safe serial ownership
run: |
@@ -438,6 +439,7 @@ jobs:
run: |
python3 -B test/test_dispatcher_startup_watchdog.py -v
python3 -B test/test_radio_receive_contract.py
python3 -B test/test_noise_floor_sleep.py -v
python3 -B test/test_radio_receive_retry.py -v
python3 -B test/test_adaptive_agc_roles.py -v
python3 - <<'PY'
+24 -1
View File
@@ -31,7 +31,9 @@ after off starts a fresh window. Collection defaults off at every boot.
- `bridge`, `bridge.cleanup`, `bridge.wake`: active UART/network bridge,
shutdown work or an overdue standby wake. A suspended RS232 owner is not
diagnosed as running just because its saved enabled flag is on.
- `calibration`, `radio.watchdog`: radio observation/calibration work.
- `calibration`: noise-floor work is due now. The CPU can idle between sample
deadlines even while the radio remains in continuous RX for calibration.
- `radio.watchdog`: radio observation/recovery work.
- `queue`, `retry`, `scheduled`, `mesh`, `ota`, `radio2`: pending role work.
- `wireless`, `network`, `serial`, `button`, `tracker`, `morse`: a matching
existing service guard is active.
@@ -60,6 +62,27 @@ than the 32-bit `micros()` wrap (about 71 minutes); the normal wait paths here
are much shorter. Keep gaps between policy observations below the `millis()`
wrap (about 49 days).
## Noise-floor sampling and sleep
Startup still collects a complete baseline promptly: 64 accepted samples,
at least 50 ms apart, after the radio frontend has settled. Collection keeps
the radio in continuous RX when needed, but the CPU may sleep between reads.
Pending packet interrupts retain their normal receive owner. Rejected readings
retry at 50 ms intervals, and the existing 30-second collection timeout bounds
an attempt on a busy channel.
After a successful baseline, ordinary single-profile refresh waits five
minutes. A rejected rise or failed attempt retains the last usable floor and
retries after one minute. Gain, tuning and AGC changes still request immediate
recalibration, so a configured short AGC interval can refresh more frequently.
Dual-profile scanning keeps its existing timing and calibration policy.
ESP32 timed sleep uses a millisecond timer capped at the next sample deadline.
nRF52, RP2040 and STM32 retain their interrupt-idle wake paths. Other sleep
guards, including USB/logging, packet queues and bridges, still apply.
These changes have host scheduling tests and firmware build coverage; their
power benefit requires a new matched hardware/current measurement.
See the [CLI reference](cli_commands.md#diagnose-what-prevents-power-saving)
for all page names and commands.
+4 -2
View File
@@ -25,8 +25,10 @@ The ordinary Wio Tracker L1 BLE build reproduced a runtime RAM failure with
48,964 bytes available against 59,968 required. Enabling the existing cache
for high-contact nRF52 profiles recovered 30,200 bytes: the linked build has
79,164 bytes available and a 19,196-byte margin against the unchanged budget.
Its 350 contacts, 40 channels and configured 256-frame offline/held-DM pool
are retained. This is a linked-capacity result, not a physical load/soak test.
It keeps 350 contacts, 40 channels and 256 normal offline frames. As on the
cached RAK profiles, up to 15 held one-key DMs temporarily use queue slots;
their storage is shared instead of reserving a second pool. This is a
linked-capacity result, not a physical load/soak test.
## Paths and persistence
+3 -1
View File
@@ -11602,7 +11602,9 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
#endif
if (isDualRadioActive()) return mask(Reason::DualRadio);
if (_radio_available && radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog);
if (_radio_available && radio_driver.isCalibratingNoiseFloor()) return mask(Reason::Calibration);
uint32_t noise_floor_delay_ms;
if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0)
return mask(Reason::Calibration);
#if defined(NRF52_PLATFORM)
// nRF52 sleep is event-driven, so a future lazy-write deadline has no
// guaranteed interrupt to wake the loop when it becomes due. Keep polling
+14 -6
View File
@@ -4248,11 +4248,18 @@ void loop() {
// retains a two-minute wake interval after button activity.
if (!sleep_reason && user_btn.needsPolling()) sleep_reason = mask(Reason::Button);
#endif
uint32_t noise_floor_delay_ms = 0;
const bool noise_floor_wait = !sleep_reason
&& the_mesh.getNextNoiseFloorWakeDelay(noise_floor_delay_ms);
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) {
const uint32_t idle_slice_ms = noise_floor_wait && noise_floor_delay_ms < 10
? noise_floor_delay_ms : 10;
#if defined(NRF52_PLATFORM)
board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
if (noise_floor_wait) board.sleepMilliseconds(idle_slice_ms);
else board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
#elif defined(ESP32_PLATFORM)
#if defined(ENABLE_USB_INTERFACE) \
&& defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \
@@ -4266,7 +4273,7 @@ void loop() {
if (mesh::powerdiag::enabled()) mesh::powerdiag::boardVeto(Reason::Board, millis());
sleep_failed = true;
} else {
esp_err_t error = esp_sleep_enable_timer_wakeup(10000ULL);
esp_err_t error = esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(idle_slice_ms) * 1000ULL);
if (error == ESP_OK) {
if (mesh::powerdiag::enabled()) mesh::powerdiag::sleepBegin(micros());
error = esp_light_sleep_start();
@@ -4278,19 +4285,20 @@ void loop() {
}
}
if (sleep_failed) {
vTaskDelay(pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(idle_slice_ms));
}
#elif COMPANION_IDF_PM_AVAILABLE
// Yield long enough for ESP-IDF automatic light sleep to enter when no
// driver holds a power-management lock.
vTaskDelay(pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(idle_slice_ms));
#else
// Connected transports need their own modem sleep and must retain the
// normal FreeRTOS idle behavior.
vTaskDelay(pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(idle_slice_ms));
#endif
#elif defined(RP2040_PLATFORM) || defined(STM32_PLATFORM)
board.sleep(0); // event-driven idle; interrupts wake the main loop
if (noise_floor_wait) board.sleepMilliseconds(idle_slice_ms);
else board.sleep(0); // event-driven idle; interrupts wake the main loop
#endif
}
#if defined(ESP32_PLATFORM)
+3 -1
View File
@@ -13976,7 +13976,9 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
#endif
#endif
if (radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog);
if (radio_driver.isCalibratingNoiseFloor()) return mask(Reason::Calibration);
uint32_t noise_floor_delay_ms;
if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0)
return mask(Reason::Calibration);
if (hasQueuedWorkDue()) return mask(Reason::Queue);
if (hasRetryWorkDue()) return mask(Reason::Retry);
if (isMillisTimerDue(next_flood_advert) || isMillisTimerDue(next_local_advert)) return mask(Reason::Scheduled);
+12 -2
View File
@@ -6,6 +6,7 @@
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/PowerManagementUtils.h>
#include <helpers/HilStartupTrace.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
@@ -646,18 +647,27 @@ void loop() {
#endif
if (can_power_save) {
uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30);
uint32_t noise_floor_delay_ms = 0;
const bool noise_floor_wait = the_mesh.getNextNoiseFloorWakeDelay(noise_floor_delay_ms);
if (noise_floor_wait && noise_floor_delay_ms == 0) {
if (mesh::powerdiag::enabled())
mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Calibration), millis());
sleep_secs = 0;
}
#ifdef HAS_EXTERNAL_WATCHDOG
if (sleep_secs > 0) external_watchdog.feed();
#endif
#if defined(NRF52_PLATFORM)
if (sleep_secs > 0) {
if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis());
board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
if (noise_floor_wait) board.sleepMilliseconds(mesh::power::limitSleepMilliseconds(sleep_secs, noise_floor_delay_ms));
else board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
}
#else
if (sleep_secs > 0 && the_mesh.millisHasNowPassed(POWERSAVING_FIRSTSLEEP_SECS * 1000)) { // To check if it is time to sleep
if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis());
board.sleep(sleep_secs); // Sleep. Wake up for scheduled jobs or when receiving a LoRa packet
if (noise_floor_wait) board.sleepMilliseconds(mesh::power::limitSleepMilliseconds(sleep_secs, noise_floor_delay_ms));
else board.sleep(sleep_secs); // Wake for scheduled jobs or a LoRa packet.
} else if (sleep_secs > 0 && mesh::powerdiag::enabled()) {
mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Startup), millis());
}
+3 -1
View File
@@ -4321,7 +4321,9 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const {
#endif
#endif
if (radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog);
if (radio_driver.isCalibratingNoiseFloor()) return mask(Reason::Calibration);
uint32_t noise_floor_delay_ms;
if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0)
return mask(Reason::Calibration);
if (hasQueuedWorkDue()) return mask(Reason::Queue);
if (hasRetryWorkDue()) return mask(Reason::Retry);
if (acl.getNumClients() > 0 && isMillisTimerDue(next_push)) return mask(Reason::Scheduled);
+12 -2
View File
@@ -6,6 +6,7 @@
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/PowerManagementUtils.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#endif
@@ -418,18 +419,27 @@ void loop() {
#endif
if (can_power_save) {
uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30);
uint32_t noise_floor_delay_ms = 0;
const bool noise_floor_wait = the_mesh.getNextNoiseFloorWakeDelay(noise_floor_delay_ms);
if (noise_floor_wait && noise_floor_delay_ms == 0) {
if (mesh::powerdiag::enabled())
mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Calibration), millis());
sleep_secs = 0;
}
#ifdef HAS_EXTERNAL_WATCHDOG
if (sleep_secs > 0) external_watchdog.feed();
#endif
#if defined(NRF52_PLATFORM)
if (sleep_secs > 0) {
if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis());
board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
if (noise_floor_wait) board.sleepMilliseconds(mesh::power::limitSleepMilliseconds(sleep_secs, noise_floor_delay_ms));
else board.sleep(0); // nrf ignores seconds param, sleeps whenever possible
}
#else
if (sleep_secs > 0 && the_mesh.millisHasNowPassed(POWERSAVING_FIRSTSLEEP_SECS * 1000)) { // To check if it is time to sleep
if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis());
board.sleep(sleep_secs); // Sleep. Wake up for scheduled jobs or when receiving a LoRa packet
if (noise_floor_wait) board.sleepMilliseconds(mesh::power::limitSleepMilliseconds(sleep_secs, noise_floor_delay_ms));
else board.sleep(sleep_secs); // Wake for scheduled jobs or a LoRa packet.
} else if (sleep_secs > 0 && mesh::powerdiag::enabled()) {
mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Startup), millis());
}
+3 -1
View File
@@ -1730,7 +1730,9 @@ mesh::powerdiag::Mask SensorMesh::pendingWorkReason() const {
if (hasPendingOtaApply()) return mask(Reason::Ota);
if (_cli.hasActiveUserGpioTimer()) return mask(Reason::Scheduled);
if (radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog);
if (radio_driver.isCalibratingNoiseFloor()) return mask(Reason::Calibration);
uint32_t noise_floor_delay_ms;
if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0)
return mask(Reason::Calibration);
if (hasQueuedWorkDue()) return mask(Reason::Queue);
if (hasRetryWorkDue()) return mask(Reason::Retry);
if (isMillisTimerDue(next_flood_advert)
+14 -3
View File
@@ -11,6 +11,7 @@
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <helpers/MorseLoopPolicy.h>
#include <helpers/PowerManagementUtils.h>
#if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER
#include <utility/SoftwareTimer.h>
static SoftwareTimer tracker_wake_timer;
@@ -384,6 +385,13 @@ void loop() {
#endif
if (can_power_save) {
uint32_t sleep_secs = the_mesh.getPowerSaveSleepSeconds(30);
uint32_t noise_floor_delay_ms = 0;
const bool noise_floor_wait = the_mesh.getNextNoiseFloorWakeDelay(noise_floor_delay_ms);
if (noise_floor_wait && noise_floor_delay_ms == 0) {
if (mesh::powerdiag::enabled())
mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Calibration), millis());
sleep_secs = 0;
}
#ifdef HAS_EXTERNAL_WATCHDOG
if (sleep_secs > 0) external_watchdog.feed();
#endif
@@ -392,7 +400,8 @@ void loop() {
#if MESH_ENABLE_SENSOR_TRACKER
if (!the_mesh.isTrackerModeEnabled() || armTrackerSleepWake(sleep_secs)) {
if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis());
board.sleep(0);
if (noise_floor_wait) board.sleepMilliseconds(mesh::power::limitSleepMilliseconds(sleep_secs, noise_floor_delay_ms));
else board.sleep(0);
}
else {
if (mesh::powerdiag::enabled())
@@ -401,7 +410,8 @@ void loop() {
}
#else
if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis());
board.sleep(0);
if (noise_floor_wait) board.sleepMilliseconds(mesh::power::limitSleepMilliseconds(sleep_secs, noise_floor_delay_ms));
else board.sleep(0);
#endif
}
#else
@@ -409,7 +419,8 @@ void loop() {
&& the_mesh.millisHasNowPassed(
POWERSAVING_FIRST_SLEEP_SECS * 1000UL)) {
if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis());
board.sleep(sleep_secs);
if (noise_floor_wait) board.sleepMilliseconds(mesh::power::limitSleepMilliseconds(sleep_secs, noise_floor_delay_ms));
else board.sleep(sleep_secs);
} else if (sleep_secs > 0 && mesh::powerdiag::enabled()) {
mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Startup), millis());
}
+5
View File
@@ -549,6 +549,11 @@ void Dispatcher::logPacketLine(const char* direction, const Packet* packet,
}
#endif
bool Dispatcher::getNextNoiseFloorWakeDelay(uint32_t& delay_millis) const {
return radio_available
&& _radio->getNoiseFloorWakeDelay(_ms->getMillis(), delay_millis);
}
bool Dispatcher::getNextQueueWakeDelay(uint32_t& delay_millis) const {
if (isDualRadioActive()) { delay_millis = 0; return true; }
#if MESH_ENABLE_FOREIGN_RADIO
+12
View File
@@ -215,6 +215,15 @@ public:
return getNoiseFloorCalibrationSecondsRemaining();
}
virtual bool isCalibratingNoiseFloor() const { return false; }
// MCU service deadline only: RF calibration can continue in continuous RX
// while the CPU idles between readings. Unknown implementations retain the
// conservative whole-batch veto rather than inventing a safe sample gap.
virtual bool getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) const {
(void)now;
if (!isCalibratingNoiseFloor()) return false;
delay_ms = 0;
return true;
}
// A failed receive start may need a later retry without keeping the MCU
// awake. Transports without a deferred receive retry retain their cadence.
virtual bool getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const {
@@ -615,6 +624,9 @@ protected:
uint32_t getTransmitAirtime(const Packet* packet, int wire_length) const;
public:
// Kept separate from whole-second queue sleep policy: a 50 ms calibration
// deadline needs a bounded millisecond wake, not a zero-second queue veto.
bool getNextNoiseFloorWakeDelay(uint32_t& delay_millis) const;
void begin();
void loop();
#if MESH_ENABLE_TX_BEACONS
+12
View File
@@ -179,6 +179,18 @@ public:
#if defined(ARDUINO)
if (powerdiag::enabled()) powerdiag::sleepEnd(micros());
#endif
#endif
}
// Bounded MCU idle without changing the radio's receive mode. Interrupt-idle
// ports keep their existing periodic-clock wake contract; timed sleepers
// override this to provide a real sub-second timer. An unknown timed port
// must not round a short deadline up and sleep past it.
virtual void sleepMilliseconds(uint32_t millisecs) {
if (millisecs == 0) return;
#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) || defined(STM32_PLATFORM)
sleep(0);
#else
if (millisecs >= 1000UL) sleep(millisecs / 1000UL);
#endif
}
virtual uint32_t getGpio() { return 0; }
+18 -3
View File
@@ -128,6 +128,15 @@ public:
}
void sleep(uint32_t secs) override {
sleepMicroseconds(static_cast<uint64_t>(secs) * 1000000ULL);
}
void sleepMilliseconds(uint32_t millisecs) override {
if (millisecs != 0)
sleepMicroseconds(static_cast<uint64_t>(millisecs) * 1000ULL, true);
}
void sleepMicroseconds(uint64_t wake_us, bool require_timer = false) {
using mesh::powerdiag::Reason;
const bool diagnostics = mesh::powerdiag::enabled();
// Native USB loses its connection in light sleep. An enumerated host
@@ -167,8 +176,14 @@ public:
// Otherwise its real elapsed time would be mislabelled as blocked time.
esp_err_t setup_error = ESP_OK;
// Configure timer wakeup
if (secs > 0) {
const esp_err_t error = esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Wake up periodically to do scheduled jobs
if (wake_us > 0) {
const esp_err_t error = esp_sleep_enable_timer_wakeup(wake_us);
// A calibration slice must not inherit an old, longer timer after a
// setup failure. Keep the legacy seconds API's policy unchanged.
if (require_timer && error != ESP_OK) {
if (diagnostics) mesh::powerdiag::boardError(error, millis());
return;
}
if (diagnostics && error != ESP_OK) setup_error = error;
}
@@ -181,7 +196,7 @@ public:
const int button_gpio = -1;
#endif
if (!radio_wakeup && button_gpio < 0) {
if (secs > 0) {
if (wake_us > 0) {
if (diagnostics) mesh::powerdiag::sleepBegin(micros());
const esp_err_t error = esp_light_sleep_start();
if (diagnostics) {
+5
View File
@@ -17,6 +17,11 @@ inline uint32_t powerSaveSleepSeconds(uint32_t delay_ms) {
#endif
}
inline uint32_t limitSleepMilliseconds(uint32_t max_seconds, uint32_t deadline_ms) {
const uint64_t maximum_ms = static_cast<uint64_t>(max_seconds) * 1000ULL;
return deadline_ms < maximum_ms ? deadline_ms : static_cast<uint32_t>(maximum_ms);
}
inline uint16_t medianVoltage(uint16_t a, uint16_t b, uint16_t c) {
if (a > b) { uint16_t t = a; a = b; b = t; }
if (b > c) { uint16_t t = b; b = c; c = t; }
+6 -1
View File
@@ -31,7 +31,12 @@ public:
}
bool complete() const { return _count == SAMPLE_COUNT; }
bool ready(uint32_t now) const {
return !complete() && (_count == 0 || uint32_t(now - _last_sample_at) >= SAMPLE_INTERVAL_MS);
return !complete() && sampleDelay(now) == 0;
}
uint32_t sampleDelay(uint32_t now) const {
if (_count == 0 || complete()) return 0;
const uint32_t elapsed = now - _last_sample_at;
return elapsed >= SAMPLE_INTERVAL_MS ? 0 : SAMPLE_INTERVAL_MS - elapsed;
}
uint8_t heldBlocks() const { return _held_blocks; }
+82 -33
View File
@@ -23,10 +23,11 @@ extern "C" caddr_t _sbrk(int increment);
// Bounded noise-floor calibration in continuous RX, including RXPS refreshes.
// Requests are coalesced so retries cannot repeatedly force continuous RX.
#define NF_CALIB_INTERVAL_MS 2000UL // continuous-RX refresh / forced-window cooldown
// A quiet block needs about 3.2 s of continuous RX. Do not reopen one after
// only 2 s of duty cycling: routine maintenance would defeat RX power saving.
#define NF_CALIB_INTERVAL_MS (60UL * 1000UL)
#define NF_RXPS_REFRESH_INTERVAL_MS (60UL * 1000UL)
// A successful baseline need not hold the radio in continuous RX every minute.
// Rejected rise/failed attempts retain the shorter retry, not this lazy cadence.
#define NF_LAZY_REFRESH_INTERVAL_MS (5UL * 60UL * 1000UL)
#define NF_CALIB_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
#define NF_CONTINUOUS_TIMEOUT_MS NoiseFloorEstimator::WINDOW_TIMEOUT_MS
#define NF_CALIB_SETTLE_MS RadioLibWrapper::NoiseFloorSettleMillis
@@ -232,6 +233,7 @@ void RadioLibWrapper::begin() {
_floor_estimator.reset(true);
_secondary_floor_estimator.reset(true);
_nf_calib_active = false;
_nf_window_armed = _nf_lazy_refresh = _nf_refresh_attempted = false;
_nf_last_calib = 0;
_nf_sample_from = 0;
_nf_refresh_requested = true; // establish one baseline after startup
@@ -784,6 +786,7 @@ void RadioLibWrapper::serviceProfileScan() {
_nf_refresh_requested = true;
_nf_last_calib = 0;
_nf_calib_deadline = 0;
_nf_window_armed = _nf_lazy_refresh = _nf_refresh_attempted = false;
_floor_estimator.reset(true);
_secondary_floor_estimator.reset(true);
_profile_refresh_required = true; // refresh side detectors even if the tuple is unchanged
@@ -824,6 +827,7 @@ void RadioLibWrapper::serviceProfileScan() {
_nf_refresh_requested = true;
_nf_last_calib = 0;
_nf_calib_deadline = 0;
_nf_window_armed = _nf_lazy_refresh = _nf_refresh_attempted = false;
_floor_estimator.reset(true);
_secondary_floor_estimator.reset(true);
}
@@ -970,13 +974,7 @@ void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) {
}
return;
}
if (_rx_ps_enabled && !_rx_ps_continuous_fallback) {
requestPeriodicNoiseFloorRefresh();
} else {
// RX is already continuous. Keep the original maintenance-tick behavior;
// only passive retry probes apply the existing two-second age check.
requestNoiseFloorRefresh();
}
requestPeriodicNoiseFloorRefresh();
}
void RadioLibWrapper::recalibrateNoiseFloor() {
@@ -986,29 +984,34 @@ void RadioLibWrapper::recalibrateNoiseFloor() {
_noise_floor_secondary_valid = false;
_nf_refresh_requested = true;
_nf_last_calib = 0;
_nf_lazy_refresh = _nf_refresh_attempted = false;
_floor_estimator.reset(true);
_secondary_floor_estimator.reset(true);
const unsigned long now = millis();
_nf_sample_from = now + NF_CALIB_SETTLE_MS;
_nf_sample_from = uint32_t(now) + (_rx_ps_armed ? 0 : NF_CALIB_SETTLE_MS);
if (_nf_calib_active) {
// Keep an already-open continuous-RX calibration window active, but restart
// its sample and timeout bounds around the newly selected gain path.
_nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS;
_nf_window_armed = true;
} else {
// Continuous RX starts its deadline in loop(); RX power saving opens a
// bounded continuous-RX window in noiseFloorCalibCheck().
_nf_calib_deadline = 0;
_nf_window_armed = false;
}
}
void RadioLibWrapper::requestPeriodicNoiseFloorRefresh() {
const uint32_t interval = _rx_ps_enabled && !_rx_ps_continuous_fallback
? NF_RXPS_REFRESH_INTERVAL_MS : NF_CALIB_INTERVAL_MS;
const uint32_t interval = _nf_lazy_refresh ? NF_LAZY_REFRESH_INTERVAL_MS
: (_rx_ps_enabled && !_rx_ps_continuous_fallback
? NF_RXPS_REFRESH_INTERVAL_MS : NF_CALIB_INTERVAL_MS);
// Startup must not wait for a maintenance tick. Subsequent attempts, even
// failed ones, need a real rest interval. Forced RF-path/AGC recalibration
// bypasses this routine policy and retains its existing prompt behavior.
if (_profiles.enabled() || (!_noise_floor_valid && _nf_last_calib == 0)
if (_profiles.enabled()
|| (!_noise_floor_valid && _nf_last_calib == 0 && !_nf_refresh_attempted)
|| static_cast<uint32_t>(millis() - _nf_last_calib) >= interval) {
requestNoiseFloorRefresh();
}
@@ -1017,9 +1020,12 @@ void RadioLibWrapper::requestPeriodicNoiseFloorRefresh() {
void RadioLibWrapper::requestNoiseFloorRefresh() {
if (_nf_refresh_requested) return;
_nf_refresh_requested = true;
_nf_lazy_refresh = false;
_floor_estimator.reset();
_secondary_floor_estimator.reset();
_nf_calib_deadline = 0; // starts when continuous RX is actually available
_nf_window_armed = false;
_nf_sample_from = uint32_t(millis()) + (_rx_ps_armed ? 0 : NF_CALIB_SETTLE_MS);
}
void RadioLibWrapper::doResetAGC() {
@@ -1189,27 +1195,32 @@ bool RadioLibWrapper::isPacketPendingOrReceiving() {
}
void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) {
const uint32_t now_ms = static_cast<uint32_t>(now);
if (_nf_calib_active) {
if (!_rx_ps_enabled
|| ((long)(now - _nf_calib_deadline) >= 0
|| (static_cast<int32_t>(now_ms - uint32_t(_nf_calib_deadline)) >= 0
&& !_floor_estimator.complete())) {
// powersaving turned off mid-window, or the batch couldn't complete
// (busy channel) - keep the previous floor
endNoiseFloorCalib(now);
} else if (_rx_ps_armed && !isPacketPendingOrReceiving()) {
} else if (_rx_ps_armed
&& static_cast<int32_t>(now_ms - uint32_t(_nf_sample_from)) >= 0) {
// A packet can delay the transition from RXPS to continuous RX. Retry
// once the radio is idle without discarding a pending interrupt.
requestRestartRecv();
// at a bounded cadence without discarding a pending interrupt.
_nf_sample_from = now_ms + NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
if (!isPacketPendingOrReceiving()) requestRestartRecv();
}
} else if (_nf_refresh_requested && _rx_ps_enabled && _rx_ps_armed && state == STATE_RX
&& ((!_noise_floor_valid && _nf_last_calib == 0)
|| now - _nf_last_calib >= NF_CALIB_INTERVAL_MS)
&& !isPacketPendingOrReceiving()) {
&& static_cast<int32_t>(now_ms - uint32_t(_nf_sample_from)) >= 0) {
// never interrupt an ongoing reception to calibrate (a TX in flight is
// already excluded by state == STATE_RX); retries next loop iteration
// already excluded by state == STATE_RX). A requested forced baseline is
// prompt; routine cooldown was enforced before creating the request.
_nf_sample_from = now_ms + NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
if (isPacketPendingOrReceiving()) return;
_nf_calib_active = true;
_nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS;
_nf_sample_from = now + NF_CALIB_SETTLE_MS;
_nf_calib_deadline = now_ms + NF_CALIB_TIMEOUT_MS;
_nf_window_armed = true;
_nf_sample_from = now_ms + NF_CALIB_SETTLE_MS;
_floor_estimator.reset();
if (!isPacketPendingOrReceiving()) {
requestRestartRecv(); // startReceiveMode() selects continuous RX
@@ -1222,6 +1233,8 @@ void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) {
_nf_refresh_requested = false;
_nf_last_calib = now;
_nf_calib_deadline = 0;
_nf_window_armed = false;
_nf_refresh_attempted = true;
// force a receive re-arm back into duty-cycle mode, but don't clobber a
// completed-but-unread packet or an in-flight TX (recvRaw()/onSendFinished()
// will re-arm right after those anyway; same guard style as setRxPowerSaving)
@@ -1802,14 +1815,16 @@ void RadioLibWrapper::loop() {
}
if (_nf_refresh_requested
&& (!_rx_ps_enabled || _rx_ps_continuous_fallback)
&& _nf_calib_deadline == 0
&& !_nf_window_armed
&& state == STATE_RX) {
// Measure the awake-time bound from actual continuous RX, not from begin()
// or a request made while the radio is idle, transmitting, or starting up.
_nf_calib_deadline = now + NF_CONTINUOUS_TIMEOUT_MS;
_nf_calib_deadline = uint32_t(now) + NF_CONTINUOUS_TIMEOUT_MS;
_nf_window_armed = true;
}
if (_nf_refresh_requested && _floor_estimator.complete()) {
if (_floor_estimator.publish(_noise_floor_centi_dbm, _noise_floor_valid)) {
_nf_lazy_refresh = _floor_estimator.publish(_noise_floor_centi_dbm, _noise_floor_valid);
if (_nf_lazy_refresh) {
_noise_floor_valid = true;
// Retain the whole-dB API/wire format and the fractional display value.
_noise_floor = (_noise_floor_centi_dbm - 50) / 100;
@@ -1817,6 +1832,8 @@ void RadioLibWrapper::loop() {
_nf_refresh_requested = false;
_nf_last_calib = now;
_nf_calib_deadline = 0;
_nf_window_armed = false;
_nf_refresh_attempted = true;
MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor);
@@ -1828,14 +1845,17 @@ void RadioLibWrapper::loop() {
if (_nf_refresh_requested
&& (!_rx_ps_enabled || _rx_ps_continuous_fallback)
&& _nf_calib_deadline != 0
&& (long)(now - _nf_calib_deadline) >= 0) {
&& _nf_window_armed
&& static_cast<int32_t>(uint32_t(now) - uint32_t(_nf_calib_deadline)) >= 0) {
// A continuously busy channel can reject every candidate sample. Do not
// keep the MCU awake indefinitely; retain the previous floor and let the
// next scheduled calibration request retry an invalid startup baseline.
_nf_refresh_requested = false;
_nf_last_calib = now;
_nf_calib_deadline = 0;
_nf_window_armed = false;
_nf_lazy_refresh = false;
_nf_refresh_attempted = true;
_floor_estimator.reset();
}
@@ -1848,12 +1868,12 @@ void RadioLibWrapper::loop() {
// ~-127 dBm garbage).
if (!_rx_ps_armed
&& _floor_estimator.ready(static_cast<uint32_t>(now))
&& !(_nf_sample_from != 0 && (long)(now - _nf_sample_from) < 0)
&& !isChipBusy() && !isPacketPendingOrReceiving()) {
&& static_cast<int32_t>(uint32_t(now) - uint32_t(_nf_sample_from)) >= 0) {
// Silent RX loss can leave software in STATE_RX while standby RSSI is
// meaningless. Reject that observation before it can lower the floor.
// Space rejected attempts too; an empty block must not hammer SPI.
_nf_sample_from = now + NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
_nf_sample_from = uint32_t(now) + NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
if (isChipBusy() || isPacketPendingOrReceiving()) return;
if (readReceiveMode() == 0) {
_floor_estimator.reset(true);
return;
@@ -1882,7 +1902,8 @@ void RadioLibWrapper::startRecv() {
state = STATE_RX;
// RSSI is unsettled after every RX entry, including CAD/TX completion.
// Keep the partial block, but wait for the frontend before its next sample.
_nf_sample_from = millis() + NF_CALIB_SETTLE_MS;
_nf_sample_from = uint32_t(millis())
+ (_rx_ps_armed ? 0 : NF_CALIB_SETTLE_MS);
_startrx_fails = 0;
_rx_retry_pending = false;
_rx_retry_delay_ms = 0;
@@ -1968,6 +1989,34 @@ bool RadioLibWrapper::getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) con
return true;
}
bool RadioLibWrapper::getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) const {
#if MESH_ENABLE_TX_BEACONS
if (_beacon_owned) return false;
#endif
// Dual-profile scanning and foreign owners retain their existing cadence.
// Failed RX startup has its separate retry deadline, not an active batch.
if (_cw_active || _profiles.enabled() || state != STATE_RX
|| (!_nf_refresh_requested && !_nf_calib_active)) return false;
if (_floor_estimator.complete()
|| (!_nf_window_armed && !_rx_ps_armed)) {
delay_ms = 0; // Publish or establish the continuous-RX timeout first.
return true;
}
const int32_t until_attempt = static_cast<int32_t>(uint32_t(_nf_sample_from) - now);
delay_ms = until_attempt > 0 ? uint32_t(until_attempt) : 0;
if (!_rx_ps_armed) {
const uint32_t until_sample = _floor_estimator.sampleDelay(now);
if (until_sample > delay_ms) delay_ms = until_sample;
}
if (_nf_window_armed) {
const int32_t until_timeout = static_cast<int32_t>(uint32_t(_nf_calib_deadline) - now);
const uint32_t timeout = until_timeout > 0 ? uint32_t(until_timeout) : 0;
if (timeout < delay_ms) delay_ms = timeout;
}
return true;
}
// RX PowerSaving
bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) {
#if MESH_ENABLE_TX_BEACONS
+9 -2
View File
@@ -171,6 +171,12 @@ protected:
// refresh briefly drops to continuous RX, publishes an average, then re-arms
// the duty cycle.
bool _nf_calib_active;
// Explicit arming distinguishes a legitimate wrapped deadline of zero.
bool _nf_window_armed = false;
// Only an accepted full block earns the lazy routine cadence. Held rises
// and failed/timeout attempts retain a one-minute retry without hot polling.
bool _nf_lazy_refresh = false;
bool _nf_refresh_attempted = false;
unsigned long _nf_last_calib; // millis of last completed/attempted window
unsigned long _nf_calib_deadline; // abort window if the batch can't complete
unsigned long _nf_sample_from; // no samples before this (RX entry settle)
@@ -532,14 +538,15 @@ public:
// true while the watchdog is actively watching for BUSY transitions; used by
// the app's hasPendingWork() to keep the MCU out of light sleep for the window
bool isWatchdogObserving() const { return _wd_observe_until != 0; }
// true while a noise-floor batch needs prompt loop service; the app's
// hasPendingWork() keeps the MCU awake for the bounded spaced-sample window.
// Full-batch RF ownership for UI and recovery guards. CPU idle between
// readings is governed separately by getNoiseFloorWakeDelay().
bool isCalibratingNoiseFloor() const override {
return isInRecvMode() && !_rx_ps_armed && (_nf_calib_active
|| (_nf_refresh_requested
&& (!_rx_ps_enabled || _rx_ps_continuous_fallback)));
}
bool getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const override;
bool getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) const override;
void resetStats() { n_recv = n_sent = n_recv_errors = 0; }
uint8_t getRadioState() const override;
+35 -2
View File
@@ -1,5 +1,6 @@
#include <helpers/MorseLoopPolicy.h>
#include <helpers/PowerSavingDiagnostics.h>
#include <helpers/PowerManagementUtils.h>
#include <cstdarg>
static unsigned usb_services=0,command_services=0,transport_services=0,delays=0;
static bool usb_watchdog_armed=false,wireless_pending=false;
@@ -30,10 +31,12 @@ Control& control() { return controller; }
}
struct Board {
unsigned calls=0,watchdogs=0,sleeps=0,fans=0,reboots=0,preparations=0;
unsigned millisecond_sleeps=0,last_sleep_ms=0;
void prepareSleepCycle() { ++preparations; }
void loop() { ++calls; }
void feedWatchdog(bool=true) { ++watchdogs; }
void sleep(uint32_t) { ++sleeps; }
void sleepMilliseconds(uint32_t ms) { ++sleeps; ++millisecond_sleeps; last_sleep_ms=ms; }
bool isUsbDataConnected() { return false; }
bool isUsbHostConnected() { return false; }
void reboot() { ++reboots; }
@@ -49,13 +52,20 @@ bool display_ready=true;
struct Mesh {
struct Prefs { bool powersaving_enabled=true,system_watchdog_enabled=true; uint32_t reboot_interval=0; } prefs;
unsigned calls=0;
bool noise_floor_wait=false,work_pending=false;
uint32_t noise_floor_delay_ms=50;
void loop() { ++calls; }
Prefs* getNodePrefs() { return &prefs; }
uint32_t getPowerSaveSleepSeconds(unsigned) { return 1; }
uint32_t getPowerSaveSleepSeconds(unsigned) { return work_pending ? 0 : 1; }
bool getNextNoiseFloorWakeDelay(uint32_t& ms) const {
ms=noise_floor_delay_ms; return noise_floor_wait;
}
bool millisHasNowPassed(uint32_t) { return true; }
bool isTrackerModeEnabled() { return false; }
bool hasPendingWork() { return false; }
mesh::powerdiag::Mask pendingWorkReason() const { return 0; }
mesh::powerdiag::Mask pendingWorkReason() const {
return work_pending ? mesh::powerdiag::mask(mesh::powerdiag::Reason::Queue) : 0;
}
void handleCommand(uint32_t,char*,char* reply) { reply[0]=0; ++command_services; }
void handleUsbCommand(char*,char* reply) { reply[0]=0; ++command_services; }
} the_mesh;
@@ -128,4 +138,27 @@ int main() {
assert(board.sleeps==sleeps_before_hold);
wireless_pending=false;
#endif
// Calibration remains RF work, but its future sample deadline permits
// bounded MCU idle in every actual role loop. Due work and other vetoes win.
the_mesh.noise_floor_wait=true;
auto before_noise_sleep=board.sleeps;
loop();
assert(board.sleeps==before_noise_sleep+1 && board.millisecond_sleeps==1);
#if ROLE_COMPANION
assert(board.last_sleep_ms==10); // Preserve the shorter Companion cadence.
#else
assert(board.last_sleep_ms==50);
#endif
the_mesh.noise_floor_delay_ms=1;
loop();
assert(board.sleeps==before_noise_sleep+2 && board.last_sleep_ms==1);
the_mesh.noise_floor_delay_ms=0;
loop();
assert(board.sleeps==before_noise_sleep+2); // Sample/transition now due.
the_mesh.noise_floor_delay_ms=50; the_mesh.work_pending=true;
loop();
assert(board.sleeps==before_noise_sleep+2); // Never widen another owner's veto.
the_mesh.work_pending=false; usb_watchdog_armed=true;
loop();
assert(board.sleeps==before_noise_sleep+2);
}
+189
View File
@@ -0,0 +1,189 @@
static const char* scenario;
#define CHECK(expression, diagnostic) do { if (!(expression)) { \
std::fprintf(stderr, "FAIL %s: %s (line %d)\n", scenario, diagnostic, __LINE__); \
return 1; } } while (0)
static void prepare(RadioLibWrapper& w, unsigned mode, uint32_t start = 100) {
now_ms = start;
w._rx_ps_enabled = mode != 0;
w._rx_ps_continuous_fallback = mode == 2;
w._rx_ps_armed = mode == 1;
w._noise_floor_valid = false;
w._nf_refresh_requested = true;
w._nf_last_calib = 0;
w.startRecv();
w.loop();
if (w.state == STATE_IDLE) w.startRecv();
}
int main(int argc, char** argv) {
if (argc != 2) return 2;
scenario = argv[1];
if (!std::strcmp(scenario, "quiet")) {
for (unsigned mode : {0U, 1U, 2U}) {
RadioLibWrapper w;
prepare(w, mode);
const uint32_t start = now_ms;
uint32_t eligible_ms = 0;
unsigned iterations = 0;
while (w._nf_refresh_requested || w._nf_calib_active) {
CHECK(++iterations < 200, "deadline service must remain bounded");
uint32_t wait = UINT32_MAX;
CHECK(w.getNoiseFloorWakeDelay(now_ms, wait), "active batch lost wake deadline");
CHECK(wait > 0 || w._floor_estimator.complete(),
"pending batch must permit idle between samples");
CHECK(wait <= NoiseFloorEstimator::SAMPLE_INTERVAL_MS,
"sample deadline exceeded original spacing");
eligible_ms += wait;
now_ms += wait;
w.loop();
if (w.state == STATE_IDLE) w.startRecv();
}
CHECK(w.reads == NoiseFloorEstimator::SAMPLE_COUNT,
"idle scheduling changed accepted sample count");
CHECK(w._noise_floor_valid && w._noise_floor_centi_dbm == -10000,
"idle scheduling changed published estimator value");
CHECK(w._rx_ps_armed == (mode == 1), "calibration did not restore receive policy");
CHECK(eligible_ms >= 3150 && uint32_t(now_ms - start) < 3300,
"quiet collection must retain original quality and bounded cadence");
}
} else if (!std::strcmp(scenario, "rollover")) {
for (uint32_t start : {100U, UINT32_MAX - 3U, uint32_t(0U - NF_CALIB_SETTLE_MS)}) {
RadioLibWrapper w;
prepare(w, 0, start);
uint32_t wait = 0;
CHECK(w.reads == 0, "RX settling must precede every first sample");
CHECK(w.getNoiseFloorWakeDelay(now_ms, wait) && wait == NF_CALIB_SETTLE_MS,
"settle deadline must survive rollover, including timestamp zero");
now_ms += wait - 1;
w.loop();
CHECK(w.reads == 0 && w.getNoiseFloorWakeDelay(now_ms, wait) && wait == 1,
"sample accepted before settle deadline");
++now_ms;
CHECK(w.getNoiseFloorWakeDelay(now_ms, wait) && wait == 0,
"exact sample boundary must request service");
w.loop();
CHECK(w.reads == 1 && w.getNoiseFloorWakeDelay(now_ms, wait) && wait == 50,
"next sample spacing must survive rollover");
now_ms += 49;
w.loop();
CHECK(w.reads == 1, "early sample changed original sample spacing");
++now_ms;
w.loop();
CHECK(w.reads == 2, "next sample deadline not serviced");
}
} else if (!std::strcmp(scenario, "queries")) {
RadioLibWrapper w;
prepare(w, 1);
uint32_t wait = 0;
const unsigned reads = w.reads, modes = w.mode_reads;
const unsigned busy = w.busy_probes, packets = w.packet_probes;
const auto count = w._floor_estimator.count();
for (unsigned i = 0; i < 100; ++i)
CHECK(w.getNoiseFloorWakeDelay(now_ms, wait) && wait > 0,
"query must preserve pending sample deadline");
CHECK(w.reads == reads && w.mode_reads == modes && w.busy_probes == busy
&& w.packet_probes == packets && w._floor_estimator.count() == count,
"deadline query must not access hardware or mutate samples");
w.events.record();
CHECK(w.getNoiseFloorWakeDelay(now_ms, wait) && w.hasPendingRadioInterrupt(),
"deadline query consumed radio IRQ");
now_ms += NF_CALIB_SETTLE_MS;
w.loop();
CHECK(w.hasPendingRadioInterrupt() && w.reads == reads,
"pending packet IRQ must retain ownership during sampling service");
w.claimRadioInterrupt();
now_ms += NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
w.inject_mode_irq = true;
w.loop();
CHECK(w.hasPendingRadioInterrupt() && w.reads == reads,
"IRQ during status probe must prevent RSSI read and remain pending");
} else if (!std::strcmp(scenario, "rejected")) {
for (bool busy : {false, true}) for (uint32_t start : {100U, UINT32_MAX - 17U}) {
RadioLibWrapper w;
prepare(w, 1, start);
w._noise_floor_valid = true;
w.packet = !busy;
w.busy = busy;
unsigned iterations = 0;
while (w._nf_refresh_requested || w._nf_calib_active) {
CHECK(++iterations < 1000, "rejected-sample retries must not hot poll");
uint32_t wait = 0;
CHECK(w.getNoiseFloorWakeDelay(now_ms, wait), "rejected sample lost timeout wake");
CHECK(wait > 0, "rejected sample must permit bounded idle before retry");
now_ms += wait;
w.loop();
if (w.state == STATE_IDLE) w.startRecv();
}
CHECK(w.reads == 0 && w._noise_floor_centi_dbm == -10500,
"rejected reads must retain published floor");
CHECK(uint32_t(now_ms - start) <= NoiseFloorEstimator::WINDOW_TIMEOUT_MS + 50,
"idle must not extend calibration timeout");
}
} else if (!std::strcmp(scenario, "lazy")) {
for (unsigned path : {0U, 1U, 2U}) for (bool accepted : {false, true})
for (uint32_t start : {1000U, UINT32_MAX - 3149U}) {
RadioLibWrapper w;
now_ms = start;
for (unsigned i = 0; i < NoiseFloorEstimator::SAMPLE_COUNT; ++i) {
now_ms = start + i * NoiseFloorEstimator::SAMPLE_INTERVAL_MS;
CHECK(w._floor_estimator.add(accepted ? -105 : -70, now_ms),
"policy fixture must use original complete spaced blocks");
}
w.loop();
CHECK(!w._nf_refresh_requested && w._nf_refresh_attempted,
"complete publish attempt must retire calibration");
CHECK(w._nf_lazy_refresh == accepted && w._noise_floor_valid,
"held rise must not earn slow refresh merely because old floor is valid");
const uint32_t finish = now_ms;
const uint32_t interval = accepted ? NF_LAZY_REFRESH_INTERVAL_MS : NF_CALIB_INTERVAL_MS;
for (uint32_t age : {1U, interval - 1}) {
now_ms = finish + age;
if (path == 0) w.triggerNoiseFloorCalibrate(7);
else if (path == 1) w.isReceivingPassive(7);
else w.requestPeriodicNoiseFloorRefresh();
CHECK(!w._nf_refresh_requested, "routine caller ignored lazy/retry cooldown");
}
now_ms = finish + interval;
if (path == 0) w.triggerNoiseFloorCalibrate(7);
else if (path == 1) w.isReceivingPassive(7);
else w.requestPeriodicNoiseFloorRefresh();
CHECK(w._nf_refresh_requested, "routine refresh missed its selected cadence");
w._nf_refresh_requested = false;
w._nf_lazy_refresh = true;
w._nf_last_calib = now_ms;
w.recalibrateNoiseFloor();
CHECK(w._nf_refresh_requested && !w._nf_lazy_refresh && !w._noise_floor_valid,
"forced tuning/gain baseline must bypass lazy routine cadence");
}
} else if (!std::strcmp(scenario, "timeout-zero")) {
RadioLibWrapper w;
// A legitimate 30-second timeout lands exactly on uint32_t zero.
const uint32_t start = uint32_t(0U - NoiseFloorEstimator::WINDOW_TIMEOUT_MS);
prepare(w, 0, start);
w.packet = true;
w._noise_floor_valid = false;
CHECK(w._nf_window_armed && uint32_t(w._nf_calib_deadline) == 0,
"wrapped timeout must retain explicit armed state");
now_ms = UINT32_MAX;
w.loop();
uint32_t wait = 0;
CHECK(w._nf_refresh_requested && w.getNoiseFloorWakeDelay(now_ms, wait) && wait == 1,
"last timeout millisecond must remain idle eligible");
now_ms = 0;
CHECK(w.getNoiseFloorWakeDelay(now_ms, wait) && wait == 0,
"wrapped timeout must request exact-boundary service");
w.loop();
CHECK(!w._nf_refresh_requested && w._nf_refresh_attempted && w._nf_last_calib == 0,
"timeout at timestamp zero must retire failed attempt");
w.packet = false;
now_ms = NF_CALIB_INTERVAL_MS - 1;
w.requestPeriodicNoiseFloorRefresh();
CHECK(!w._nf_refresh_requested, "timestamp-zero failure must not masquerade as startup");
++now_ms;
w.requestPeriodicNoiseFloorRefresh();
CHECK(w._nf_refresh_requested, "timestamp-zero failure missed bounded retry");
} else return 2;
std::printf("PASS: %s\n", scenario);
}
+30 -1
View File
@@ -20,6 +20,35 @@ static uint32_t retryDelay(const RadioLibWrapper& w) {
return delay;
}
static void noiseFloorDeadlines() {
for (unsigned mode : {0U, 1U, 2U}) {
RadioLibWrapper w; RetryClock clock; StaticPoolPacketManager manager{4};
RetryDispatcher d(w, clock, manager); d.interval = 0;
now_ms = 1000; d.begin(); selectMode(w, mode);
d.loop(); d.loop();
uint32_t wait = 0;
assert(d.getNextNoiseFloorWakeDelay(wait) && wait == NF_CALIB_SETTLE_MS);
// A subsecond sample is not a whole-second queue veto on timed sleepers.
uint32_t queue = UINT32_MAX;
assert(!d.getNextQueueWakeDelay(queue) && !d.hasQueuedWorkDue());
const unsigned reads = w.reads, probes = w.mode_reads;
const unsigned busy = w.busy_probes, packets = w.packet_probes;
for (unsigned i = 0; i < 100; ++i)
assert(d.getNextNoiseFloorWakeDelay(wait) && wait == NF_CALIB_SETTLE_MS);
assert(w.reads == reads && w.mode_reads == probes && w.busy_probes == busy
&& w.packet_probes == packets);
now_ms += wait; d.loop();
assert(w.reads == 1 && d.getNextNoiseFloorWakeDelay(wait) && wait == 50);
w.events.record();
assert(d.getNextNoiseFloorWakeDelay(wait) && w.hasPendingRadioInterrupt());
w.radio.packet_length = 1;
d.loop();
assert(w.n_recv == 1 && !w.hasPendingRadioInterrupt());
assert(!d.hasQueuedWorkDue());
}
std::puts("PASS: actual Dispatcher noise-floor deadlines (3 modes)");
}
static void persistentFailure() {
uint8_t bytes[8];
for (unsigned mode : {0U, 1U, 2U}) {
@@ -327,7 +356,7 @@ int main(int argc, char** argv) {
return 0;
}
assert(argc == 1);
persistentFailure(); rampAndRollover(); ownersAndImmediateRearms();
noiseFloorDeadlines(); persistentFailure(); rampAndRollover(); ownersAndImmediateRearms();
pendingSetupAndTx(); retainedOutboundWake(); busyAfterFailure();
deferredCalibrationRestore();
std::puts("PASS: receive retry lifecycle");
@@ -271,6 +271,7 @@ struct Mesh {
mesh::powerdiag::Mask pendingWorkReason() const {
return pending ? mesh::powerdiag::mask(mesh::powerdiag::Reason::Mesh) : 0;
}
bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; }
} the_mesh;
struct Board { bool usb; bool isUsbHostConnected() const { return usb; } } board;
struct Ethernet { bool active; bool hardwareActive() const { return active; } } ethernet_interface;
@@ -273,6 +273,7 @@ struct MyMesh {
bool hasPendingOtaApply() const { return false; }
bool hasPendingReqs() const { return _delayed_replies.hasRequest(); }
bool hasQueuedWorkDue() const { return false; }
bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; }
bool hasRetryWorkDue() const { return false; }
bool hasPendingWork() const;
mesh::powerdiag::Mask pendingWorkReason() const;
+1
View File
@@ -228,6 +228,7 @@ struct MyMesh {
bool isMillisTimerDue(unsigned long) const { return false; }
bool getNextQueueWakeDelay(uint32_t&) const { return false; }
bool getNextRetryWakeDelay(uint32_t&) const { return false; }
bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; }
bool isDualRadioActive() const { return false; }
uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; }
uint32_t limitSleepToScheduledRadioWork(uint32_t secs) const { return secs; }
+4 -1
View File
@@ -269,7 +269,10 @@ class FEMBoard:public ESP32Board { void shutdownPeripherals() override {
int main(){FEMBoard board; board.enterDeepSleep(5); assert(slept);}
'''.replace('@SLEEP@',sleep),s3=1)
header=(ROOT/'src/helpers/ESP32Board.h').read_text()
light=body(header,'void sleep(uint32_t secs) override')
light='\n'.join(body(header, signature) for signature in (
'void sleep(uint32_t secs) override',
'void sleepMilliseconds(uint32_t millisecs) override',
'void sleepMicroseconds(uint64_t wake_us, bool require_timer = false)'))
self.assertNotIn('commitTerminalDeepSleepHolds',light)
for folder in (ROOT/'src',ROOT/'variants'):
for path in folder.rglob('*'):
+132 -9
View File
@@ -70,6 +70,7 @@ constexpr int GPIO_INTR_POSEDGE = 1;
constexpr int GPIO_INTR_DISABLE = 0;
static unsigned sleep_calls = 0;
static uint64_t timer_us = 0;
static int timer_error = ESP_OK;
static bool radio_irq_high = false;
static int critical_depth = 0;
static int wake_pin = -1;
@@ -80,7 +81,11 @@ static bool button_pressed = false;
static bool press_during_sleep = false;
static unsigned yield_ms = 0;
static void delay(unsigned ms) { yield_ms += ms; }
static int esp_sleep_enable_timer_wakeup(uint64_t us) { timer_us = us; return ESP_OK; }
static int esp_sleep_enable_timer_wakeup(uint64_t us) {
if (timer_error != ESP_OK) return timer_error;
timer_us = us;
return ESP_OK;
}
static int esp_light_sleep_start() {
++sleep_calls;
if (press_during_sleep) {
@@ -116,6 +121,7 @@ struct MainBoard {
bool radio_test_active = false;
bool isRadioTestActive() const { return radio_test_active; }
virtual void sleep(uint32_t) = 0;
virtual void sleepMilliseconds(uint32_t) = 0;
virtual bool isUsbDataConnected() = 0;
virtual bool isUsbHostConnected() = 0;
};
@@ -257,6 +263,13 @@ def board_method(signature: str) -> str:
return source[start:end]
def board_sleep_methods() -> str:
return "\n".join(board_method(signature) for signature in (
"void sleep(uint32_t secs) override",
"void sleepMilliseconds(uint32_t millisecs) override",
"void sleepMicroseconds(uint64_t wake_us, bool require_timer = false)"))
def v4_profile_config():
config = configparser.ConfigParser(
interpolation=None, inline_comment_prefixes=("#", ";"))
@@ -370,6 +383,7 @@ static int esp_light_sleep_start() {
#define portEXIT_CRITICAL(mux) (--critical_depth)
struct MainBoard {
virtual void sleep(uint32_t) = 0;
virtual void sleepMilliseconds(uint32_t) = 0;
virtual bool isUsbDataConnected() = 0;
virtual bool isUsbHostConnected() = 0;
bool isRadioTestActive() const { return false; }
@@ -509,8 +523,7 @@ class Esp32UsbSleepTest(unittest.TestCase):
def test_v4_sleep_button_poller_and_real_ui_wake_the_screen(self):
config = v4_profile_config()
methods = "\n".join(board_method(signature) for signature in (
"void sleep(uint32_t secs) override",
methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in (
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override", "bool isUsbSleepHeld()"))
compiler = os.environ.get("CXX", "c++")
@@ -593,8 +606,8 @@ class Esp32UsbSleepTest(unittest.TestCase):
self.assertIn(expected_error, result.stderr)
def test_raw_uart_driver_preserves_fresh_console_indefinitely_until_end(self):
methods = "\n".join(board_method(signature) for signature in (
"void sleep(uint32_t secs) override", "bool isUsbDataConnected() override",
methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in (
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override", "bool isUsbSleepHeld()"))
harness = HARNESS.split("int main()", 1)[0] + r'''
int main() {
@@ -639,8 +652,7 @@ int main() {
def test_native_host_survives_terminal_close_and_reconnect(self):
compiler = os.environ.get("CXX", "c++")
self.assertIsNotNone(shutil.which(compiler), "a C++17 compiler is required")
methods = "\n".join(board_method(signature) for signature in (
"void sleep(uint32_t secs) override",
methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in (
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override",
"bool isUsbSleepHeld()",
@@ -669,8 +681,7 @@ int main() {
self.assertEqual(result.returncode, 0, result.stderr)
def test_native_host_reboot_grace_without_logging(self):
methods = "\n".join(board_method(signature) for signature in (
"void sleep(uint32_t secs) override",
methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in (
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override",
"bool isUsbSleepHeld()",
@@ -778,6 +789,118 @@ int main() {
result = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_millisecond_sleep_preserves_precision_and_all_common_guards(self):
methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in (
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override", "bool isUsbSleepHeld()"))
harness = HARNESS.split("int main()", 1)[0] + r'''
int main() {
try {
ESP32Board board;
MainBoard& polymorphic = board;
attachHost(true);
const unsigned before_zero = yield_ms;
polymorphic.sleepMilliseconds(0);
require(sleep_calls == 0 && timer_us == 0 && wake_pin == -1
&& critical_depth == 0 && yield_ms == before_zero,
"zero milliseconds entered sleep or touched peripherals");
polymorphic.sleepMilliseconds(125);
require(sleep_calls == 0 && timer_us == 0 && critical_depth == 0,
"millisecond path bypassed the USB/UART console guard");
attachHost(false);
Serial.end();
mock_millis += MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS;
polymorphic.sleepMilliseconds(1);
require(sleep_calls == 1 && timer_us == 1000ULL,
"one millisecond did not arm an exact subsecond wake timer");
polymorphic.sleepMilliseconds(999);
require(sleep_calls == 2 && timer_us == 999000ULL,
"millisecond timer was rounded to seconds");
polymorphic.sleepMilliseconds(UINT32_MAX);
require(sleep_calls == 3 && timer_us == 4294967295000ULL,
"millisecond conversion overflowed before widening");
board.sleep(UINT32_MAX);
require(sleep_calls == 4 && timer_us == 4294967295000000ULL,
"existing seconds API lost precision or overflowed");
timer_error = 17;
polymorphic.sleepMilliseconds(125);
require(sleep_calls == 4 && timer_us == 4294967295000000ULL
&& critical_depth == 0,
"failed millisecond timer setup reused an older long timer");
timer_error = ESP_OK;
const unsigned before_guards = sleep_calls;
board.inhibit_sleep = true;
polymorphic.sleepMilliseconds(125);
require(sleep_calls == before_guards && critical_depth == 0,
"millisecond path bypassed explicit sleep inhibition");
board.inhibit_sleep = false;
board.radio_test_active = true;
polymorphic.sleepMilliseconds(125);
require(sleep_calls == before_guards && critical_depth == 0,
"millisecond path bypassed radio test ownership");
board.radio_test_active = false;
radio_irq_high = true;
polymorphic.sleepMilliseconds(125);
require(sleep_calls == before_guards && critical_depth == 0,
"millisecond path slept over a pending radio IRQ");
radio_irq_high = false;
#if MOMENTARY_BUTTON_WAKE_FROM_SLEEP
button_pressed = true;
polymorphic.sleepMilliseconds(125);
require(sleep_calls == before_guards && critical_depth == 0,
"millisecond path slept over a pending button press");
button_pressed = false;
#endif
mesh::logging_enabled = true;
polymorphic.sleepMilliseconds(125);
#if MESH_USB_LOGGING_AVAILABLE
require(sleep_calls == before_guards,
"millisecond path bypassed the USB logging veto");
#else
require(sleep_calls == before_guards + 1,
"compiled-out logging blocked millisecond sleep");
#endif
mesh::logging_enabled = false;
board.irq = static_cast<uint32_t>(-1);
const unsigned before_timer = sleep_calls;
polymorphic.sleepMilliseconds(125);
require(sleep_calls == before_timer + 1 && timer_us == 125000ULL
&& critical_depth == 0,
"millisecond timer-only wake failed or left interrupts masked");
const unsigned after_timer = sleep_calls;
const uint64_t last_timer = timer_us;
polymorphic.sleepMilliseconds(0);
require(sleep_calls == after_timer && timer_us == last_timer,
"zero milliseconds reused a previously configured wake source");
} catch (const std::exception& error) {
std::cerr << error.what() << '\n'; return 1;
}
}
'''
compiler = os.environ.get("CXX", "c++")
self.assertIsNotNone(shutil.which(compiler), "a C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="meshcore-ms-sleep-") as directory:
cpp = Path(directory) / "sleep.cpp"
cpp.write_text(harness.replace("@METHODS@", methods), encoding="ascii")
for mode, cdc, button, logging in ((0, 1, 1, 1), (1, 1, 0, 1),
(0, 0, 0, 1), (0, 1, 0, 0)):
with self.subTest(usb_mode=mode, cdc=cdc, button=button, logging=logging):
binary = Path(directory) / f"sleep-{mode}-{cdc}-{button}-{logging}"
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra",
f"-DARDUINO_USB_MODE={mode}",
f"-DARDUINO_USB_CDC_ON_BOOT={cdc}",
f"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE={int(bool(cdc))}",
f"-DMOMENTARY_BUTTON_WAKE_FROM_SLEEP={button}",
f"-DMESH_USB_LOGGING_AVAILABLE={logging}", "-DPIN_USER_BTN=38",
"-DCONFIG_TINYUSB_ENABLED=1", "-I", str(ROOT / "src"),
str(cpp), "-o", str(binary)], capture_output=True, text=True,
timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([str(binary)], capture_output=True, text=True,
timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_g3_enables_gesture_polling_after_gpio_wake(self):
profile = (ROOT / "variants/station_g3_esp32/platformio.ini").read_text()
def section(name):
+1
View File
@@ -61,6 +61,7 @@ struct MyMesh {
bool millisHasNowPassed(unsigned long) const { return false; }
bool getNextQueueWakeDelay(uint32_t&) const { return false; }
bool getNextRetryWakeDelay(uint32_t&) const { return false; }
bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; }
bool dual_radio = false;
bool isDualRadioActive() const { return dual_radio; }
uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; }
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Execute real floor deadlines/transitions with counted host I/O adapters.
These tests prove scheduling and IRQ-ownership contracts, not actual current
consumption, RTOS sleep residency, or RF sample quality on untested hardware.
"""
from pathlib import Path
import os
import subprocess
import sys
import tempfile
import unittest
from test_radio_receive_contract import ROOT, method, transition_harness
class NoiseFloorSleepTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.directory = tempfile.TemporaryDirectory(prefix='meshcore-floor-sleep-')
cls.addClassCleanup(cls.directory.cleanup)
work = Path(cls.directory.name)
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
header = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.h').read_text()
getter = method(source, 'bool RadioLibWrapper::getNoiseFloorWakeDelay(')
harness = transition_harness(source).split('struct LegacyRadio {', 1)[0]
harness = harness.replace(getter, '', 1)
harness = harness.replace('#include <cassert>', '#include <cassert>\n#include <cstring>')
harness = harness.replace(' bool tryResetAGC();',
' bool isInRecvMode() const { return state == STATE_RX; }\n'
+ method(header, 'bool isCalibratingNoiseFloor() const').replace(' override', '')
+ '\n bool tryResetAGC();')
harness = harness.replace('bool isChipBusy() { return busy; }',
'unsigned busy_probes = 0, packet_probes = 0;\n'
' bool isChipBusy() { ++busy_probes; return busy; }')
harness = harness.replace('bool isReceivingPacket() { return packet; }',
'bool isReceivingPacket() { ++packet_probes; return packet; }')
fixture = (ROOT / 'test/fixtures/noise_floor_sleep.cpp').read_text()
variants = {
'production': getter,
'old_batch_veto': getter[:getter.index('{') + 1] + '''
(void)now;
if (!isCalibratingNoiseFloor()) return false;
delay_ms = 0;
return true;
}
''',
}
cls.binaries = {}
sanitize = [] if not sys.platform.startswith('linux') else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-omit-frame-pointer', '-fno-pie', '-no-pie']
for family in ('sx1262', 'other'):
for name, implementation in variants.items():
filename = name + '_' + family
cpp, binary = work / (filename + '.cpp'), work / filename
cpp.write_text((harness + implementation + fixture).replace('\u2014', '-'),
encoding='ascii')
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-O1', '-g', '-Wall', '-Wextra', '-Werror',
*(['-DUSE_SX1262=1'] if family == 'sx1262' else []),
*sanitize, '-I', str(ROOT / 'src'), str(cpp), '-o', str(binary)],
capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.binaries[name, family] = binary
def run_case(self, scenario, variant='production', failure=None):
for family in ('sx1262', 'other'):
with self.subTest(family=family):
result = subprocess.run([str(self.binaries[variant, family]), scenario],
capture_output=True, text=True, timeout=20)
if failure:
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(failure, result.stderr)
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('PASS: ' + scenario, result.stdout)
def test_quiet_batches_allow_idle_between_all_original_samples(self):
self.run_case('quiet')
def test_deadlines_and_settling_survive_rollover_including_zero(self):
self.run_case('rollover')
def test_deadline_queries_have_no_io_or_irq_consumption(self):
self.run_case('queries')
def test_rejected_samples_can_idle_and_keep_timeout_bound(self):
self.run_case('rejected')
def test_success_becomes_lazy_but_held_rises_and_timeouts_retry_sooner(self):
self.run_case('lazy')
def test_timeout_timestamp_zero_is_not_an_unarmed_or_startup_sentinel(self):
self.run_case('timeout-zero')
def test_old_whole_batch_sleep_veto_negative_control(self):
self.run_case('quiet', 'old_batch_veto', 'pending batch must permit idle')
if __name__ == '__main__':
unittest.main()
+8 -3
View File
@@ -15,7 +15,7 @@ import tempfile
import unittest
import urllib.request
from test_esp32_usb_sleep import HARNESS, board_method
from test_esp32_usb_sleep import HARNESS, board_method, board_sleep_methods
from test_hwcdc_tx_backport import body
ROOT = Path(__file__).resolve().parents[1]
@@ -56,8 +56,8 @@ def firmware_harness(*, lose_sleep_guard=False, lose_crlf=False,
" millis(), MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS);")
assert guard in sleep_held, "Missing native USB negative-control boundary"
sleep_held = sleep_held.replace(guard, "return false;")
methods = "\n".join(board_method(signature) for signature in (
"void sleep(uint32_t secs) override", "bool isUsbDataConnected() override",
methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in (
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override")) + "\n" + sleep_held
if lose_raw_uart_guard:
expression = "keep_awake = keep_awake || hold(static_cast<bool>(Serial), Reason::Serial);"
@@ -98,6 +98,11 @@ struct Radio {
static mesh::Radio radio_driver;
'''.replace("@MORSE_HELPER@", morse_helper)
fixture = (ROOT / "test/fixtures/official_web_console/firmware.cpp").read_text()
sleep_getter = " unsigned getPowerSaveSleepSeconds(unsigned limit) { return limit; }"
assert fixture.count(sleep_getter) == 1, "Missing sleep-deadline peripheral boundary"
fixture = fixture.replace(sleep_getter, sleep_getter + "\n" +
" bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; }")
source += "\n#include <helpers/PowerManagementUtils.h>\n"
return source + morse_radio + (fixture.replace("@SETTER@", setter).replace("@GETTER@", getter)
.replace("@COMMAND_PUMP@", pump).replace("@POWER_LOOP@", power_loop))
+50
View File
@@ -50,6 +50,12 @@ struct Node {
int main() {
Node node;
assert(mesh::power::powerSaveSleepSeconds(0) == 0);
assert(mesh::power::limitSleepMilliseconds(0, 50) == 0);
assert(mesh::power::limitSleepMilliseconds(30, 0) == 0);
assert(mesh::power::limitSleepMilliseconds(30, 50) == 50);
assert(mesh::power::limitSleepMilliseconds(1, 1001) == 1000);
assert(mesh::power::limitSleepMilliseconds(UINT32_MAX, 50) == 50);
assert(mesh::power::limitSleepMilliseconds(UINT32_MAX, UINT32_MAX) == UINT32_MAX);
#if EXPECT_INTERRUPT_IDLE
assert(mesh::power::powerSaveSleepSeconds(UINT32_MAX) == 4294968U);
#else
@@ -110,6 +116,50 @@ int main() {
class PowerSavingDeadlineTests(unittest.TestCase):
def test_real_board_millisecond_idle_preserves_port_wake_contract(self):
source = (ROOT / 'src/MeshCore.h').read_text(encoding='ascii')
implementation = method(source, 'virtual void sleepMilliseconds(uint32_t millisecs)')
code = r'''
#include <cassert>
#include <cstdint>
struct Board {
unsigned calls = 0;
uint32_t last = 0;
virtual void sleep(uint32_t secs) { ++calls; last = secs; }
@METHOD@
};
int main() {
Board board;
board.sleepMilliseconds(0);
assert(board.calls == 0);
for (uint32_t ms : {1U, 50U, 999U, 1000U, 1001U, UINT32_MAX}) {
const auto before = board.calls;
board.sleepMilliseconds(ms);
#if EXPECT_INTERRUPT_IDLE
assert(board.calls == before + 1 && board.last == 0);
#else
assert(board.calls == before + (ms >= 1000));
if (ms >= 1000) assert(board.last == ms / 1000);
#endif
}
}
'''.replace('@METHOD@', implementation).replace(
'#include <cstdint>', '#include <cstdint>\n#include <initializer_list>')
with tempfile.TemporaryDirectory(prefix='meshcore-board-ms-idle-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test'
cpp.write_text(code, encoding='ascii')
for platform in ('', 'NRF52_PLATFORM', 'RP2040_PLATFORM', 'STM32_PLATFORM'):
with self.subTest(platform=platform or 'unknown-timed'):
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-DEXPECT_INTERRUPT_IDLE=' + ('1' if platform else '0'),
*(['-D' + platform + '=1'] if platform else []),
str(cpp), '-o', str(exe),
], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(exe)], capture_output=True, text=True)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_real_role_deadlines_preserve_timed_and_interrupt_idle(self):
with tempfile.TemporaryDirectory(prefix='meshcore-sleep-deadlines-') as directory:
work = Path(directory)
+81 -5
View File
@@ -28,6 +28,55 @@ class PowerSavingRoleDiagnosticsTests(unittest.TestCase):
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_actual_radio_default_and_public_dispatcher_deadline_forwarder(self):
header = (ROOT / 'src/Dispatcher.h').read_text(encoding='ascii')
source = (ROOT / 'src/Dispatcher.cpp').read_text(encoding='ascii')
conservative = extract_braced(header, 'virtual bool getNoiseFloorWakeDelay(')
forwarder = extract_braced(source, 'bool Dispatcher::getNextNoiseFloorWakeDelay(')
self.execute(r'''
#include <cassert>
#include <cstdint>
struct Radio {
bool calibrating = false;
bool isCalibratingNoiseFloor() const { return calibrating; }
@DEFAULT@
};
struct SpacedRadio : Radio {
mutable unsigned reads = 0;
uint32_t delay = 50;
bool getNoiseFloorWakeDelay(uint32_t now, uint32_t& out) const override {
assert(now == 123); ++reads; out = delay; return true;
}
};
struct Clock {
unsigned reads = 0;
uint32_t getMillis() { ++reads; return 123; }
};
struct Dispatcher {
bool radio_available = true;
Radio* _radio;
Clock* _ms;
bool getNextNoiseFloorWakeDelay(uint32_t&) const;
};
@FORWARDER@
int main() {
Radio legacy;
Clock clock;
Dispatcher node{true, &legacy, &clock};
uint32_t delay = 71;
assert(!node.getNextNoiseFloorWakeDelay(delay) && delay == 71);
legacy.calibrating = true;
assert(node.getNextNoiseFloorWakeDelay(delay) && delay == 0);
SpacedRadio spaced;
node._radio = &spaced;
assert(node.getNextNoiseFloorWakeDelay(delay) && delay == 50 && spaced.reads == 1);
node.radio_available = false;
const auto before = clock.reads;
assert(!node.getNextNoiseFloorWakeDelay(delay));
assert(clock.reads == before && spaced.reads == 1);
}
'''.replace('@DEFAULT@', conservative).replace('@FORWARDER@', forwarder))
def test_actual_role_guards_preserve_short_circuit_and_ignore_charger_only(self):
for role in ("simple_repeater", "simple_room_server", "simple_sensor"):
with self.subTest(role=role):
@@ -128,7 +177,7 @@ struct MyMesh {
bool dual = 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;
uint32_t queue_delay = 0, retry_delay = 0, calibration_delay = 0;
struct { bool busy_value = false; bool busy() const { return busy_value; } } _local_cli_output;
struct { bool pending = false; } deferred_cli_command;
struct { bool gpio = false; bool hasActiveUserGpioTimer() const { return gpio; } } _cli;
@@ -152,6 +201,9 @@ struct MyMesh {
bool isMillisTimerDue(unsigned long value) const { return value && value <= millis(); }
bool getNextQueueWakeDelay(uint32_t& value) const { value = queue_delay; return queue_delay_set; }
bool getNextRetryWakeDelay(uint32_t& value) const { value = retry_delay; return retry_delay_set; }
bool getNextNoiseFloorWakeDelay(uint32_t& value) const {
value = calibration_delay; return radio_driver.calibration;
}
uint32_t limitSleepToMillisTimer(unsigned long value, uint32_t seconds) const {
return value && value <= millis() ? 0 : seconds;
}
@@ -193,6 +245,11 @@ int main() {
node.uart.wake_ms = 60000;
check(node.radio_driver.watchdog, Reason::RadioWatchdog);
check(node.radio_driver.calibration, Reason::Calibration);
node.radio_driver.calibration = true; node.calibration_delay = 50;
assert(!node.hasPendingWork() && node.getPowerSaveSleepSeconds(30) == 30);
node.calibration_delay = 0;
assert(node.pendingWorkReason() == mask(Reason::Calibration));
node.radio_driver.calibration = false;
check(node.queue, Reason::Queue); check(node.retry, Reason::Retry);
check(node.scheduled, Reason::Scheduled);
node.queue_delay_set = true; node.queue_delay = 500;
@@ -278,7 +335,11 @@ int main() {
def test_esp32_real_veto_and_sdk_failure_timing_does_not_invent_blocked_residency(self):
source = (ROOT / "src/helpers/ESP32Board.h").read_text(encoding="ascii")
method = extract_braced(source, "void sleep(uint32_t secs) override")
method = "\n".join(extract_braced(source, signature) for signature in (
"void sleep(uint32_t secs) override",
"void sleepMilliseconds(uint32_t millisecs) override",
"void sleepMicroseconds(uint64_t wake_us, bool require_timer = false)",
))
code = r'''
#include <cassert>
#include <helpers/PowerSavingDiagnostics.h>
@@ -297,7 +358,8 @@ static bool radio_pending = false;
static uint32_t millis() { return now_us / 1000; }
static uint32_t micros() { return now_us; }
static void delay(unsigned ms) { yields += ms; }
static esp_err_t esp_sleep_enable_timer_wakeup(uint64_t) { return timer_error; }
static uint64_t timer_us = 0;
static esp_err_t esp_sleep_enable_timer_wakeup(uint64_t value) { timer_us = value; return timer_error; }
static esp_err_t esp_sleep_enable_gpio_wakeup() { return gpio_error; }
static esp_err_t gpio_wakeup_enable(gpio_num_t, int) { return pin_error; }
static void gpio_wakeup_disable(gpio_num_t) {}
@@ -310,7 +372,10 @@ namespace mesh {
static bool logging = false;
static bool isUsbLoggingEnabled() { return logging; }
}
struct MainBoard { virtual void sleep(uint32_t) = 0; };
struct MainBoard {
virtual void sleep(uint32_t) = 0;
virtual void sleepMilliseconds(uint32_t) = 0;
};
struct ESP32Board : MainBoard {
bool inhibit_sleep = false, usb = false, test = false;
uint32_t irq = 14;
@@ -324,16 +389,27 @@ int main() {
ESP32Board board;
board.sleep(30);
assert(sdk_calls == 1 && snapshot(millis()).calls == 0);
assert(timer_us == 30000000ULL);
board.sleepMilliseconds(50);
assert(sdk_calls == 2 && timer_us == 50000ULL);
board.sleepMilliseconds(0);
assert(sdk_calls == 2 && timer_us == 50000ULL);
setEnabled(true, millis()); reset(millis()); decision(0, millis());
timer_error = 29; gpio_error = 31; pin_error = 33;
board.sleep(30); // Diagnostics may observe failures, never repair/change policy.
auto value = snapshot(millis());
assert(sdk_calls == 2 && value.calls == 1 && value.returns == 1 && value.errors == 1);
assert(sdk_calls == 3 && value.calls == 1 && value.returns == 1 && value.errors == 1);
assert(value.detail == 29); // First setup failure, without changing later SDK calls.
assert(value.last_call_us == 7000 && value.eligible_ms == 7);
assert(value.blocked_ms[static_cast<size_t>(Reason::Board)] == 0);
assert(critical_depth == 0);
reset(millis()); decision(0, millis());
const auto before_failed_slice = sdk_calls;
board.sleepMilliseconds(50);
value = snapshot(millis());
assert(sdk_calls == before_failed_slice && value.calls == 0);
assert(value.errors == 1 && value.detail == 29 && critical_depth == 0);
reset(millis()); decision(0, millis());
timer_error = gpio_error = pin_error = 0; sleep_error = 41;
board.sleep(30);
value = snapshot(millis());
+15
View File
@@ -43,6 +43,7 @@ struct RadioLibWrapper {
bool _profile_saved_rxps=false, _profile_rxps_suspended=false;
bool _profile_standby_held=false, _saved_standby_xosc=false;
bool _nf_calib_active=false, _nf_refresh_requested=false, _noise_floor_valid=true, _noise_floor_secondary_valid=true, _profile_refresh_required=false;
bool _nf_window_armed=false, _nf_lazy_refresh=false, _nf_refresh_attempted=false;
unsigned long _nf_last_calib=0, _nf_calib_deadline=0, _nf_sample_from=0;
NoiseFloorEstimator _floor_estimator;
NoiseFloorEstimator _secondary_floor_estimator;
@@ -105,6 +106,20 @@ struct RadioLibWrapper {
@METHODS@
int main() {
using Result=mesh::RadioParamApplyResult;
{
RadioLibWrapper w;
w._nf_window_armed=w._nf_lazy_refresh=w._nf_refresh_attempted=true;
w.enable();
assert(!w._nf_window_armed && !w._nf_lazy_refresh && !w._nf_refresh_attempted);
w._nf_window_armed=w._nf_lazy_refresh=w._nf_refresh_attempted=true;
mesh::RadioProfileConfig replacement;
replacement.params=w._profiles.primary;
replacement.params.freq=911.5;
replacement.mode=mesh::RadioProfileMode::RxTx;
w._profiles.setSecondary(replacement,false);
w.serviceProfileScan();
assert(!w._nf_window_armed && !w._nf_lazy_refresh && !w._nf_refresh_attempted);
}
{
RadioLibWrapper w;
w.enable();
+48 -40
View File
@@ -78,6 +78,7 @@ struct RadioLibWrapper {
bool _rx_hold_continuous = false;
bool _nf_calib_active = false, _nf_refresh_requested = true, _noise_floor_valid = true,
_noise_floor_secondary_valid = true;
bool _nf_window_armed = false, _nf_lazy_refresh = false, _nf_refresh_attempted = false;
bool _cad_enabled = true, packet = false, busy = false, inject_arm_irq = false;
bool _rx_boosted_gain_valid = false, _cur_rx_boosted_gain = false;
bool _wd_last_busy = false;
@@ -146,6 +147,7 @@ struct RadioLibWrapper {
void recalibrateNoiseFloor();
bool resetAGCIfIdle();
bool tryResetAGC();
bool getNoiseFloorWakeDelay(uint32_t, uint32_t&) const;
void resetAGC();
@RECOVERY_GETTER@
};
@@ -158,9 +160,7 @@ struct LegacyRadio {
@LEGACY_RECOVERY_GETTER@
};
// Exercise both public callers and the shared policy itself. Continuous and
// fallback Dispatcher triggers intentionally retain their unconditional legacy
// request; only passive polling and the helper use the two-second age gate.
// Public Dispatcher/passive callers share the same bounded routine policy.
static void routineRequest(RadioLibWrapper& w, unsigned path, int margin = 7) {
if (path == 0) w.triggerNoiseFloorCalibrate(margin);
else if (path == 1) {
@@ -178,7 +178,8 @@ static void selectMode(RadioLibWrapper& w, unsigned mode) {
static void periodicRefreshContracts() {
static_assert(NF_RXPS_REFRESH_INTERVAL_MS == 60000UL, "Routine RXPS refresh must rest one minute");
static_assert(NF_CALIB_INTERVAL_MS == 2000UL, "Continuous passive refresh retains its two-second cadence");
static_assert(NF_CALIB_INTERVAL_MS == 60000UL, "Failed routine refresh must rest one minute");
static_assert(NF_LAZY_REFRESH_INTERVAL_MS == 300000UL, "Successful floor refresh becomes lazy");
unsigned cases = 0;
for (unsigned path : {0U, 1U, 2U}) for (unsigned mode : {0U, 1U, 2U})
for (uint32_t last : {10000U, 0xfffffff0U}) for (bool valid : {false, true})
@@ -189,7 +190,7 @@ static void periodicRefreshContracts() {
now_ms = last + age;
routineRequest(w, path);
const uint32_t interval = mode == 1 ? NF_RXPS_REFRESH_INTERVAL_MS : NF_CALIB_INTERVAL_MS;
const bool expected = (path == 0 && mode != 1) || age >= interval;
const bool expected = age >= interval;
assert(w._nf_refresh_requested == expected);
assert(w._threshold == (path == 0 ? 7 : 42)); // threshold changes never wait
assert(w.arms == 0 && w.stops == 0 && w.radio.scans == 0);
@@ -213,12 +214,10 @@ static void periodicRefreshContracts() {
RadioLibWrapper w; selectMode(w, from);
w._nf_last_calib = 10000; w._nf_refresh_requested = false;
now_ms = 20000; selectMode(w, to); routineRequest(w, path);
assert(w._nf_refresh_requested == (to != 1));
if (to == 1) {
now_ms = 10000 + NF_RXPS_REFRESH_INTERVAL_MS - 1; routineRequest(w, path);
assert(!w._nf_refresh_requested);
++now_ms; routineRequest(w, path); assert(w._nf_refresh_requested);
}
assert(!w._nf_refresh_requested);
now_ms = 10000 + NF_RXPS_REFRESH_INTERVAL_MS - 1; routineRequest(w, path);
assert(!w._nf_refresh_requested);
++now_ms; routineRequest(w, path); assert(w._nf_refresh_requested);
++cases;
}
// A periodic request may join, but must not reset or extend, a partial block.
@@ -291,7 +290,7 @@ static void periodicRefreshContracts() {
w.loop(); assert(w._nf_calib_active);
++cases;
}
// A failed initial block gets the same one-minute rest as a valid block.
// A failed initial block rests one minute; an accepted baseline rests five.
// Quiet and busy windows are exercised through the actual loop/re-arm path.
for (bool busy : {false, true}) for (unsigned path : {0U, 1U}) {
RadioLibWrapper w; selectMode(w, 1); w._rx_ps_armed = true;
@@ -313,7 +312,8 @@ static void periodicRefreshContracts() {
assert(w._noise_floor_valid == !busy);
if (busy) assert(w.reads == 0 && w._noise_floor_centi_dbm == -10500);
// A quiet passive caller reads RSSI, but those reads do not open windows.
for (; uint32_t(now_ms - finished) < NF_RXPS_REFRESH_INTERVAL_MS; ++now_ms) {
const uint32_t interval = busy ? NF_RXPS_REFRESH_INTERVAL_MS : NF_LAZY_REFRESH_INTERVAL_MS;
for (; uint32_t(now_ms - finished) < interval; ++now_ms) {
if (uint32_t(now_ms - finished) % NF_CALIB_INTERVAL_MS == 0) {
if (path == 0) w.triggerNoiseFloorCalibrate(7);
else routineRequest(w, path);
@@ -421,7 +421,7 @@ static void agcAdmissionContracts() {
assert(w._noise_floor == -105 && w._noise_floor_centi_dbm == -10500);
assert(w._noise_floor_secondary_centi_dbm == -10600);
assert(w._floor_estimator.count() == 0 && w._secondary_floor_estimator.count() == 0);
assert(w._nf_sample_from == now_ms + NF_CALIB_SETTLE_MS);
assert(w._nf_sample_from == now_ms + (w._rx_ps_armed ? 0 : NF_CALIB_SETTLE_MS));
assert(w._nf_calib_active == active_window);
assert(w._nf_calib_deadline == (active_window ? now_ms + NF_CALIB_TIMEOUT_MS : 0));
assert(w.getRecoveryCount() == 5); // AGC attempts do not inflate watchdog recovery counters
@@ -680,6 +680,39 @@ int main() {
}
'''
def transition_harness(source=None):
"""Build the shared actual-method harness, including its counted I/O adapters."""
if source is None:
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
header = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.h').read_text()
dispatcher = (ROOT / 'src/Dispatcher.h').read_text()
rxps = (ROOT / 'src/helpers/radiolib/RXPowerSaving.h').read_text()
names = [('int16_t', 'performChannelScanWithTimeout'), ('bool', 'isPacketPendingOrReceiving'),
('bool', 'isChannelActive'), ('bool', 'isReceivingPassive'),
('bool', 'setRxPowerSaving'), ('bool', 'setRxBoostedGainMode'),
('void', 'startRecv'), ('void', 'checkReceiveMode'),
('void', 'loop'), ('void', 'triggerNoiseFloorCalibrate'),
('void', 'requestRestartRecv'), ('void', 'noiseFloorCalibCheck'),
('void', 'endNoiseFloorCalib'), ('void', 'requestNoiseFloorRefresh'),
('void', 'requestPeriodicNoiseFloorRefresh'), ('void', 'recalibrateNoiseFloor'),
('bool', 'resetAGCIfIdle'), ('bool', 'tryResetAGC'), ('void', 'resetAGC'),
('bool', 'getNoiseFloorWakeDelay')]
methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n'
methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(')
for kind, name in names)
constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE))
constants += '\n' + re.search(
r'^#define RX_POWERSAVING_CONTINUOUS_FALLBACK_US\s+[^\n]+', rxps,
re.MULTILINE).group(0)
settle_start = header.index('#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LLCC68)')
settle = header[settle_start:header.index('#endif', settle_start) + len('#endif')]
return (HARNESS.replace('@METHODS@', methods).replace('@NF_CONSTANTS@', constants)
.replace('@SETTLE_CONSTANT@', settle)
.replace('@RECOVERY_GETTER@', method(header, 'uint32_t getRecoveryCount() const').replace('override', ''))
.replace('@LEGACY_TRY_RESET@', method(dispatcher, 'virtual bool tryResetAGC('))
.replace('@LEGACY_RECOVERY_GETTER@', method(dispatcher, 'virtual uint32_t getRecoveryCount(')))
class RadioReceiveContractTest(unittest.TestCase):
def test_preamble_margin_and_receive_deadlines(self):
radio_source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
@@ -903,32 +936,7 @@ int main() {
self.assertEqual(result.returncode, 0, result.stderr)
def run_production_transitions(self, source=None, expect_failure=False, phase_failure=False):
if source is None:
source = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
header = (ROOT / 'src/helpers/radiolib/RadioLibWrappers.h').read_text()
dispatcher = (ROOT / 'src/Dispatcher.h').read_text()
rxps = (ROOT / 'src/helpers/radiolib/RXPowerSaving.h').read_text()
names = [('int16_t','performChannelScanWithTimeout'), ('bool','isPacketPendingOrReceiving'),
('bool','isChannelActive'), ('bool','isReceivingPassive'),
('bool','setRxPowerSaving'), ('bool','setRxBoostedGainMode'),
('void','startRecv'), ('void','checkReceiveMode'),
('void','loop'), ('void','triggerNoiseFloorCalibrate'), ('void','requestRestartRecv'), ('void','noiseFloorCalibCheck'),
('void','endNoiseFloorCalib'), ('void','requestNoiseFloorRefresh'),
('void','requestPeriodicNoiseFloorRefresh'),
('void','recalibrateNoiseFloor'), ('bool','resetAGCIfIdle'),
('bool','tryResetAGC'), ('void','resetAGC')]
methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n'
methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') for kind,name in names)
constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE))
constants += '\n' + re.search(
r'^#define RX_POWERSAVING_CONTINUOUS_FALLBACK_US\s+[^\n]+', rxps, re.MULTILINE).group(0)
settle_start = header.index('#if defined(USE_SX1262) || defined(USE_SX1268) || defined(USE_LLCC68)')
settle = header[settle_start:header.index('#endif', settle_start) + len('#endif')]
harness = (HARNESS.replace('@METHODS@', methods).replace('@NF_CONSTANTS@', constants)
.replace('@SETTLE_CONSTANT@', settle)
.replace('@RECOVERY_GETTER@', method(header, 'uint32_t getRecoveryCount() const').replace('override', ''))
.replace('@LEGACY_TRY_RESET@', method(dispatcher, 'virtual bool tryResetAGC('))
.replace('@LEGACY_RECOVERY_GETTER@', method(dispatcher, 'virtual uint32_t getRecoveryCount(')))
harness = transition_harness(source)
with tempfile.TemporaryDirectory(prefix='meshcore-radio-transitions-') as tmp:
cpp = Path(tmp) / 'test.cpp'
binary = Path(tmp) / 'test'
+1
View File
@@ -51,6 +51,7 @@ class RadioReceiveRetryTests(unittest.TestCase):
('bool', 'tryResetAGC'), ('void', 'resetAGC'), ('int', 'recvRaw'),
('void', 'finishReceiveProcessing'),
('void', 'onSendFinished'), ('void', 'begin'),
('bool', 'getNoiseFloorWakeDelay'),
('bool', 'getReceiveRetryDelay'), ('void', 'rxPsWatchdogCheck')]
methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n'
methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(')
+3
View File
@@ -128,11 +128,14 @@ int main(int argc, char** argv) {
"accepted deferred timings not retained");
CHECK(w.rx_starts == starts && w.chip.standbys == standbys && w.applies == applies
&& w._nf_refresh_requested == refresh, "deferred setting touched live scan I/O/calibration");
w._nf_window_armed = w._nf_lazy_refresh = w._nf_refresh_attempted = true;
w._profiles.setSecondary({}, false);
w.serviceProfileScan();
CHECK(!w._profile_rxps_suspended && w.state == STATE_RX
&& w._rx_ps_enabled == requested && w._rx_ps_armed == requested,
"accepted deferred policy not restored after scan");
CHECK(!w._nf_window_armed && !w._nf_lazy_refresh && !w._nf_refresh_attempted,
"scan exit must invalidate stale single-profile calibration policy/deadline");
}
} else if (!std::strcmp(scenario, "unsupported-disable")) {
for (bool scan : {false, true}) {
+1
View File
@@ -160,6 +160,7 @@ struct MyMesh : Callbacks {
bool isMillisTimerDue(unsigned long) const { return false; }
bool getNextQueueWakeDelay(uint32_t&) const { return false; }
bool getNextRetryWakeDelay(uint32_t&) const { return false; }
bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; }
uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; }
uint32_t limitSleepToScheduledRadioWork(uint32_t secs) const { return secs; }
bool hasPendingWork() const;
+7 -1
View File
@@ -62,6 +62,7 @@ public:
bool getNextQueueWakeDelay(uint32_t& out)const{out=queue_delay;return queue_delay_set;}
bool hasPendingRadioWork()const{return queue_delay_set;}
bool getNextRetryWakeDelay(uint32_t& out)const{out=retry_delay;return retry_delay_set;}
bool getNextNoiseFloorWakeDelay(uint32_t& out)const{out=0;return radio_driver.calibration;}
bool isMillisTimerDue(unsigned long timestamp)const;
uint32_t limitSleepToMillisTimer(unsigned long timestamp,uint32_t sleep_secs)const;
bool hasPendingWork()const;
@@ -126,6 +127,7 @@ TIMER = r'''
#include <Arduino.h>
#include <cassert>
#include <helpers/PowerSavingDiagnostics.h>
#include <helpers/PowerManagementUtils.h>
using TimerHandle_t=void*;
using TickType_t=uint32_t;
bool allocate=true,queue_ok=true,active=false;
@@ -158,9 +160,13 @@ static void trackerWakeCallback(TimerHandle_t){}
struct Mesh {bool tracker=true;uint32_t sleep_secs=30;
bool isTrackerModeEnabled()const{return tracker;}
uint32_t getPowerSaveSleepSeconds(uint32_t max)const{assert(max==30);return sleep_secs;}
bool getNextNoiseFloorWakeDelay(uint32_t&)const{return false;}
bool millisHasNowPassed(uint32_t deadline)const{return (int32_t)(millis()-deadline)>=0;}
} the_mesh;
struct Board {unsigned sleeps=0,last=0;void sleep(unsigned value){++sleeps;last=value;}} board;
struct Board {unsigned sleeps=0,last=0;
void sleep(unsigned value){++sleeps;last=value;}
void sleepMilliseconds(unsigned value){++sleeps;last=value;}
} board;
static const unsigned long POWERSAVING_FIRST_SLEEP_SECS=120;
void sleepGate(bool can_power_save){
@GATE@