From dd7a10f9aa561716f83619298c6f48c93aeb00cb Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 10:18:14 -0700 Subject: [PATCH] Sleep between noise-floor samples and reduce idle refreshes --- .github/workflows/run-unit-tests.yml | 2 + docs/power_saving_diagnostics.md | 25 ++- docs/research/companion_contact_cache.md | 6 +- examples/companion_radio/MyMesh.cpp | 4 +- examples/companion_radio/main.cpp | 20 +- examples/simple_repeater/MyMesh.cpp | 4 +- examples/simple_repeater/main.cpp | 14 +- examples/simple_room_server/MyMesh.cpp | 4 +- examples/simple_room_server/main.cpp | 14 +- examples/simple_sensor/SensorMesh.cpp | 4 +- examples/simple_sensor/main.cpp | 17 +- src/Dispatcher.cpp | 5 + src/Dispatcher.h | 12 ++ src/MeshCore.h | 12 ++ src/helpers/ESP32Board.h | 21 +- src/helpers/PowerManagementUtils.h | 5 + src/helpers/radiolib/NoiseFloorEstimator.h | 7 +- src/helpers/radiolib/RadioLibWrappers.cpp | 115 ++++++++--- src/helpers/radiolib/RadioLibWrappers.h | 11 +- test/fixtures/morse_main_loop.cpp | 37 +++- test/fixtures/noise_floor_sleep.cpp | 189 ++++++++++++++++++ test/fixtures/radio_receive_retry.cpp | 31 ++- test/test_companion_contact_sleep_contract.py | 1 + ...st_companion_radio_settings_transaction.py | 1 + test/test_esp32_full_power_lifecycle.py | 1 + test/test_esp32_gpio_hold.py | 5 +- test/test_esp32_usb_sleep.py | 141 ++++++++++++- test/test_logging_sleep_contract.py | 1 + test/test_noise_floor_sleep.py | 104 ++++++++++ test/test_official_web_console.py | 11 +- test/test_power_saving_deadlines.py | 50 +++++ test/test_power_saving_role_diagnostics.py | 86 +++++++- test/test_radio_profile_scan.py | 15 ++ test/test_radio_receive_contract.py | 88 ++++---- test/test_radio_receive_retry.py | 1 + test/test_radio_rxps_rejection.py | 3 + test/test_rs232_heartbeat_owner.py | 1 + test/test_sensor_tracker_sleep.py | 8 +- 38 files changed, 955 insertions(+), 121 deletions(-) create mode 100644 test/fixtures/noise_floor_sleep.cpp create mode 100644 test/test_noise_floor_sleep.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 77f129f6f..0c919f842 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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' diff --git a/docs/power_saving_diagnostics.md b/docs/power_saving_diagnostics.md index a1596d86f..1ba4f9ced 100644 --- a/docs/power_saving_diagnostics.md +++ b/docs/power_saving_diagnostics.md @@ -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. diff --git a/docs/research/companion_contact_cache.md b/docs/research/companion_contact_cache.md index 2e58693a6..0fd7ace85 100644 --- a/docs/research/companion_contact_cache.md +++ b/docs/research/companion_contact_cache.md @@ -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 diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ad004fffc..b68726899 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -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 diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 549e95821..dca2191e2 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -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(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) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 786e712f9..9ba83c860 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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); diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index be72c0ff3..d40e80538 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #if MESH_PACKET_LOGGING #include @@ -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()); } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index c6feb75ff..2ba5b20b9 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 6f6498f87..66d45fb13 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #if MESH_PACKET_LOGGING #include #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()); } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index c441b1b12..363b7cd33 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -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) diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 6db4925b5..c48ea311a 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #if defined(NRF52_PLATFORM) && MESH_ENABLE_SENSOR_TRACKER #include 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()); } diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 0d1c467ad..e1d9d9cf8 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -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 diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 59d7d4140..c61071d4e 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -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 diff --git a/src/MeshCore.h b/src/MeshCore.h index 5ef63efed..4e138b705 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -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; } diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 9858dd86d..febcfe558 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -128,6 +128,15 @@ public: } void sleep(uint32_t secs) override { + sleepMicroseconds(static_cast(secs) * 1000000ULL); + } + + void sleepMilliseconds(uint32_t millisecs) override { + if (millisecs != 0) + sleepMicroseconds(static_cast(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) { diff --git a/src/helpers/PowerManagementUtils.h b/src/helpers/PowerManagementUtils.h index dc647e3e3..8ccdc4c14 100644 --- a/src/helpers/PowerManagementUtils.h +++ b/src/helpers/PowerManagementUtils.h @@ -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(max_seconds) * 1000ULL; + return deadline_ms < maximum_ms ? deadline_ms : static_cast(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; } diff --git a/src/helpers/radiolib/NoiseFloorEstimator.h b/src/helpers/radiolib/NoiseFloorEstimator.h index 10794a39b..78f45d5fe 100644 --- a/src/helpers/radiolib/NoiseFloorEstimator.h +++ b/src/helpers/radiolib/NoiseFloorEstimator.h @@ -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; } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 3a67cf2d6..90c8c04d4 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -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(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(now); if (_nf_calib_active) { if (!_rx_ps_enabled - || ((long)(now - _nf_calib_deadline) >= 0 + || (static_cast(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(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(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(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(now)) - && !(_nf_sample_from != 0 && (long)(now - _nf_sample_from) < 0) - && !isChipBusy() && !isPacketPendingOrReceiving()) { + && static_cast(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(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(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 diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 5f3a47f8c..e90e393b3 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -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; diff --git a/test/fixtures/morse_main_loop.cpp b/test/fixtures/morse_main_loop.cpp index b1b4ce451..148245c6c 100644 --- a/test/fixtures/morse_main_loop.cpp +++ b/test/fixtures/morse_main_loop.cpp @@ -1,5 +1,6 @@ #include #include +#include #include 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); } diff --git a/test/fixtures/noise_floor_sleep.cpp b/test/fixtures/noise_floor_sleep.cpp new file mode 100644 index 000000000..64b43c6ac --- /dev/null +++ b/test/fixtures/noise_floor_sleep.cpp @@ -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); +} diff --git a/test/fixtures/radio_receive_retry.cpp b/test/fixtures/radio_receive_retry.cpp index 8499c9efb..f3adf8164 100644 --- a/test/fixtures/radio_receive_retry.cpp +++ b/test/fixtures/radio_receive_retry.cpp @@ -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"); diff --git a/test/test_companion_contact_sleep_contract.py b/test/test_companion_contact_sleep_contract.py index 6671692cd..08896d6cf 100644 --- a/test/test_companion_contact_sleep_contract.py +++ b/test/test_companion_contact_sleep_contract.py @@ -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; diff --git a/test/test_companion_radio_settings_transaction.py b/test/test_companion_radio_settings_transaction.py index f27b411ab..3666fdd33 100644 --- a/test/test_companion_radio_settings_transaction.py +++ b/test/test_companion_radio_settings_transaction.py @@ -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; diff --git a/test/test_esp32_full_power_lifecycle.py b/test/test_esp32_full_power_lifecycle.py index 9567a308b..280f0c759 100644 --- a/test/test_esp32_full_power_lifecycle.py +++ b/test/test_esp32_full_power_lifecycle.py @@ -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; } diff --git a/test/test_esp32_gpio_hold.py b/test/test_esp32_gpio_hold.py index 13b264b4e..0ca84a755 100644 --- a/test/test_esp32_gpio_hold.py +++ b/test/test_esp32_gpio_hold.py @@ -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('*'): diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py index f037c6aa5..f709408d4 100644 --- a/test/test_esp32_usb_sleep.py +++ b/test/test_esp32_usb_sleep.py @@ -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(-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): diff --git a/test/test_logging_sleep_contract.py b/test/test_logging_sleep_contract.py index fba835db6..9171d2de3 100644 --- a/test/test_logging_sleep_contract.py +++ b/test/test_logging_sleep_contract.py @@ -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; } diff --git a/test/test_noise_floor_sleep.py b/test/test_noise_floor_sleep.py new file mode 100644 index 000000000..e21084e1a --- /dev/null +++ b/test/test_noise_floor_sleep.py @@ -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 ', '#include \n#include ') + 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() diff --git a/test/test_official_web_console.py b/test/test_official_web_console.py index 1f6e2a922..3ef6bc603 100644 --- a/test/test_official_web_console.py +++ b/test/test_official_web_console.py @@ -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(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 \n" return source + morse_radio + (fixture.replace("@SETTER@", setter).replace("@GETTER@", getter) .replace("@COMMAND_PUMP@", pump).replace("@POWER_LOOP@", power_loop)) diff --git a/test/test_power_saving_deadlines.py b/test/test_power_saving_deadlines.py index 85716f8d3..16e3ae4f1 100644 --- a/test/test_power_saving_deadlines.py +++ b/test/test_power_saving_deadlines.py @@ -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 +#include +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 ', '#include \n#include ') + 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) diff --git a/test/test_power_saving_role_diagnostics.py b/test/test_power_saving_role_diagnostics.py index 85a17ba0b..f0063b4f9 100644 --- a/test/test_power_saving_role_diagnostics.py +++ b/test/test_power_saving_role_diagnostics.py @@ -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 +#include +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 #include @@ -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(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()); diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index ab554761e..8d4726ee2 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -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(); diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index 596d91c3a..265b3c5d7 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -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' diff --git a/test/test_radio_receive_retry.py b/test/test_radio_receive_retry.py index 22400210f..5b12c0ad7 100644 --- a/test/test_radio_receive_retry.py +++ b/test/test_radio_receive_retry.py @@ -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}(') diff --git a/test/test_radio_rxps_rejection.py b/test/test_radio_rxps_rejection.py index 4b93643df..9d007656d 100644 --- a/test/test_radio_rxps_rejection.py +++ b/test/test_radio_rxps_rejection.py @@ -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}) { diff --git a/test/test_rs232_heartbeat_owner.py b/test/test_rs232_heartbeat_owner.py index afa45aacf..4878bc6d3 100644 --- a/test/test_rs232_heartbeat_owner.py +++ b/test/test_rs232_heartbeat_owner.py @@ -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; diff --git a/test/test_sensor_tracker_sleep.py b/test/test_sensor_tracker_sleep.py index b1631e174..849418292 100644 --- a/test/test_sensor_tracker_sleep.py +++ b/test/test_sensor_tracker_sleep.py @@ -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 #include #include +#include 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@