From 61eecbd971559f1b8e8f47a1a20d25a95f0a0aff Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 21 Oct 2026 17:34:37 -0700 Subject: [PATCH] Enable reversible ESP32 BLE shutdown and bounded radio2 CPU sleep --- .github/workflows/run-unit-tests.yml | 5 + docs/power_saving_diagnostics.md | 21 +- docs/radio_profiles.md | 11 +- examples/companion_radio/main.cpp | 69 ++-- examples/simple_repeater/main.cpp | 11 + examples/simple_room_server/main.cpp | 11 + examples/simple_sensor/main.cpp | 11 + src/Dispatcher.h | 12 + src/helpers/ESP32Board.h | 68 +++- src/helpers/esp32/LightSleepPolicy.h | 50 +++ src/helpers/esp32/ProfileIdleSleep.h | 35 ++ src/helpers/esp32/SerialBLEInterface.cpp | 298 +++++++++++++++--- src/helpers/esp32/SerialBLEInterface.h | 28 ++ src/helpers/radiolib/RadioLibWrappers.cpp | 93 +++++- src/helpers/radiolib/RadioLibWrappers.h | 12 + test/fixtures/esp32_ble_power/mocks/BLE2902.h | 2 + .../esp32_ble_power/mocks/BLEDevice.h | 187 +++++++++++ .../esp32_ble_power/mocks/BLEServer.h | 2 + .../fixtures/esp32_ble_power/mocks/BLEUtils.h | 2 + test/fixtures/esp32_ble_power/mocks/esp_bt.h | 2 + .../esp32_ble_power/mocks/esp_bt_main.h | 2 + .../esp32_ble_power/mocks/esp_gap_ble_api.h | 2 + .../esp32_ble_power/test_bluedroid_power.cpp | 81 +++++ .../esp32_ble_power/test_nimble_power.cpp | 176 +++++++++++ .../nimble_companion/mocks/NimBLEDevice.h | 34 +- test/fixtures/nimble_companion/mocks/esp_bt.h | 22 ++ test/test_esp32_ble_power.py | 44 +++ test/test_esp32_full_sleep.py | 25 +- test/test_esp32_gpio_hold.py | 4 +- test/test_esp32_light_sleep_policy.py | 107 +++++++ test/test_esp32_profile_idle_sleep.py | 97 ++++++ test/test_esp32_usb_sleep.py | 117 ++++++- test/test_official_web_console.py | 9 +- test/test_power_saving_role_diagnostics.py | 21 +- test/test_profile_idle_esp32.py | 249 +++++++++++++++ test/test_profile_switch_fast_policy.py | 1 + test/test_radio_profile_clock.py | 15 +- test/test_radio_profile_idle_dispatcher.py | 10 +- 38 files changed, 1829 insertions(+), 117 deletions(-) create mode 100644 src/helpers/esp32/LightSleepPolicy.h create mode 100644 src/helpers/esp32/ProfileIdleSleep.h create mode 100644 test/fixtures/esp32_ble_power/mocks/BLE2902.h create mode 100644 test/fixtures/esp32_ble_power/mocks/BLEDevice.h create mode 100644 test/fixtures/esp32_ble_power/mocks/BLEServer.h create mode 100644 test/fixtures/esp32_ble_power/mocks/BLEUtils.h create mode 100644 test/fixtures/esp32_ble_power/mocks/esp_bt.h create mode 100644 test/fixtures/esp32_ble_power/mocks/esp_bt_main.h create mode 100644 test/fixtures/esp32_ble_power/mocks/esp_gap_ble_api.h create mode 100644 test/fixtures/esp32_ble_power/test_bluedroid_power.cpp create mode 100644 test/fixtures/esp32_ble_power/test_nimble_power.cpp create mode 100644 test/fixtures/nimble_companion/mocks/esp_bt.h create mode 100644 test/test_esp32_ble_power.py create mode 100644 test/test_esp32_light_sleep_policy.py create mode 100644 test/test_esp32_profile_idle_sleep.py create mode 100644 test/test_profile_idle_esp32.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 9c4612c38..37b67d94b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -699,6 +699,10 @@ jobs: - name: Verify ESP32 USB reboot sleep grace and G3 button wake run: | python3 -B test/test_usb_host_sleep_policy.py + python3 -B test/test_esp32_light_sleep_policy.py -v + python3 -B test/test_esp32_profile_idle_sleep.py -v + python3 -B test/test_profile_idle_esp32.py -v + python3 -B test/test_profile_idle.py -v python3 -B test/test_esp32_usb_sleep.py python3 -B test/test_esp32_full_sleep.py python3 -B test/test_esp32_full_power_lifecycle.py @@ -997,6 +1001,7 @@ jobs: python3 -B test/test_voice_prefixes.py -v MESH_VOICE_TEST_SANITIZERS=1 python3 -B test/test_voice_prefixes.py -v python3 -B test/test_nimble_companion.py + python3 -B test/test_esp32_ble_power.py -v python3 -B test/test_bluetooth_pairing_ui.py python3 -B test/test_companion_pairing_display.py python3 -B test/test_companion_identity_startup.py diff --git a/docs/power_saving_diagnostics.md b/docs/power_saving_diagnostics.md index 5ba161be8..6b85555f9 100644 --- a/docs/power_saving_diagnostics.md +++ b/docs/power_saving_diagnostics.md @@ -45,8 +45,10 @@ after off starts a fresh window. Collection defaults off at every boot. `radio2` means the scanner cannot safely idle right now: for example, a short remaining visit, calibration, pending reception or an unvalidated platform. -On nRF52, settled long visits can idle the MCU with power saving enabled; -the RF receiver stays on. See [radio profiles](radio_profiles.md#mcu-power-saving-while-scanning). +On nRF52 and ESP32, settled long visits can idle the MCU with power saving +enabled; the RF receiver stays on. ESP32 requires working radio GPIO wake and +physically stopped WiFi/Bluetooth drivers. Short visits and active transports +retain polling. See [radio profiles](radio_profiles.md#mcu-power-saving-while-scanning). The role reports the first actual short-circuit blocker. It does not query otherwise skipped hardware just to populate an exhaustive list. Board guards @@ -71,6 +73,21 @@ 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). +## ESP32 Bluetooth off and CPU sleep + +Switching Bluetooth off now completes a reversible host/controller shutdown, +including when Bluetooth was saved off at startup. Existing pairings, address, +name and PIN are retained when it is switched back on. A pending disconnect or +failed teardown keeps the CPU awake rather than treating stopped advertising +as a stopped controller. `get bluetooth` reports a pending change until that +transition finishes. + +USB host sessions, logging, active WiFi/ESP-NOW, OTA and serial work keep their +existing sleep guards. This change does not sleep an ESP32 through a live +Bluetooth or WiFi connection. Such connections require SDK-managed modem and +automatic sleep; lowering CPU frequency or yielding alone is not proof of +light sleep. + ## Noise-floor sampling and sleep Startup still collects a complete baseline promptly: 64 accepted samples, diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index a35db553b..148edc646 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -932,8 +932,15 @@ retries, or short visits. All normal USB, logging, bridge, OTA, queue and role sleep guards still apply. An nRF52 periodic interrupt bounds each wait to about 0.977 ms; admission reserves another 0.488 ms before the visit deadline and is rechecked immediately before sleeping. If that wake source is not -available, scanning keeps polling. Other MCU families retain polling until -their timing is validated. +available, scanning keeps polling. + +ESP32 also permits MCU idle during settled long visits. It requires radio GPIO +wake and physically stopped WiFi/Bluetooth drivers. Its timer slices are at +most 10 ms, reserve 4 ms before the visit deadline, and require at least 2 ms +usable time. Absolute deadlines are checked again after wake-source setup; +expired visits, pending interrupts or setup failures retain polling. USB, +logging, active wireless services and the other role guards remain blockers. +Other MCU families retain polling until their timing is validated. The integer admission gate requires at least 49 RTC2 ticks (about 1.495 ms) remaining after the work checks. A configured visit must also cover the loop diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index eaa85937a..7885c14e0 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -32,6 +32,8 @@ void companionPairingUiHilProbe(); #include "esp_pm.h" #include "esp_sleep.h" #include "esp_system.h" +#include +#include #if defined(CONFIG_BLUEDROID_ENABLED) && !defined(MESH_USE_NIMBLE_ARDUINO) #include #endif @@ -3260,6 +3262,9 @@ bool handleCompanionBluetoothCommand(const char* command, char* reply, snprintf(reply, reply_size, "bluetooth %s%s", interface_manager.isBluetoothEnabled() ? "on" : "off", companion_bluetooth_off_at ? " (off pending)" +#if defined(ESP32_PLATFORM) + : bluetooth_interface.isPowerTransitionPending() ? " (change pending)" +#endif : interface_manager.isBluetoothInhibited() ? " (USB data connected)" : ""); return true; @@ -3286,6 +3291,11 @@ bool handleCompanionBluetoothCommand(const char* command, char* reply, companion_bluetooth_off_at = 0; snprintf(reply, reply_size, "OK - Bluetooth on (saved); paused while USB data is connected"); +#if defined(ESP32_PLATFORM) + } else if (bluetooth_interface.isPowerTransitionPending()) { + companion_bluetooth_off_at = 0; + snprintf(reply, reply_size, "OK - Bluetooth on (saved); start pending"); +#endif } else if (interface_manager.isBluetoothEnabled()) { companion_bluetooth_off_at = 0; snprintf(reply, reply_size, "OK - Bluetooth on (saved)"); @@ -3425,6 +3435,9 @@ public: if (on) interface_manager.enableBluetooth(); else disableCompanionBluetoothForCli(); if (on && interface_manager.isBluetoothInhibited()) return Result::Done; +#if defined(ESP32_PLATFORM) + if (bluetooth_interface.isPowerTransitionPending()) return Result::Pending; +#endif return interface_manager.isBluetoothEnabled() == on ? Result::Done : Result::Failed; } #endif @@ -3495,28 +3508,14 @@ static bool companionNativeUsbLightSleepReady() { enabled_services &= static_cast(~mesh::wireless::WiFi); } } +#endif +#if defined(BLE_PIN_CODE) + if (bluetooth_interface.isPowerTransitionPending()) return false; #endif if (enabled_services != 0 || interface_manager.hasPendingIO() || board.isOTAUpdateRunning() || board.isRadioTestActive() || sensors.gpsUsesSerialUart(1)) return false; -#if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 - // Stopped advertising is not a stopped controller. Manual light sleep - // requires the RF driver to be disabled, including during BLE teardown. - if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_ENABLED) - return false; -#if defined(CONFIG_BLUEDROID_ENABLED) && !defined(MESH_USE_NIMBLE_ARDUINO) - if (esp_bluedroid_get_status() == ESP_BLUEDROID_STATUS_ENABLED) return false; -#endif -#endif -#if defined(ESP32) && defined(WIFI_SSID) - // Catch an unfinished teardown even if the logical WiFi owner is off. - wifi_mode_t mode = WIFI_MODE_NULL; - const esp_err_t result = esp_wifi_get_mode(&mode); - if (result == ESP_OK) return mode == WIFI_MODE_NULL; - return result == ESP_ERR_WIFI_NOT_INIT || result == ESP_ERR_WIFI_NOT_STARTED; -#else - return true; -#endif + return mesh::esp32::manualLightSleepReady(); } #endif @@ -4337,6 +4336,7 @@ void loop() { if (noise_floor_wait) board.sleepMilliseconds(idle_slice_ms); else board.sleep(0); // nrf ignores seconds param, sleeps whenever possible #elif defined(ESP32_PLATFORM) + const uint32_t sleep_budget_started_us = static_cast(esp_timer_get_time()); #if defined(ENABLE_USB_INTERFACE) \ && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ && !defined(ETHERNET_ENABLED) && !defined(SERIAL_RX) @@ -4348,6 +4348,13 @@ void loop() { if (!companionNativeUsbLightSleepReady()) { if (mesh::powerdiag::enabled()) mesh::powerdiag::boardVeto(Reason::Board, millis()); sleep_failed = true; + } else if (the_mesh.isDualRadioActive()) { + // The scanner owns a shorter absolute deadline than the normal loop + // cadence. The board also arms a radio GPIO wake and rechecks elapsed + // time after its setup; short/expired visits keep polling. + if (!mesh::esp32::tryProfileIdleSleep(the_mesh, board, sleep_budget_started_us, idle_slice_ms) + && mesh::powerdiag::enabled()) + mesh::powerdiag::decision(mask(Reason::DualRadio), millis()); } else { esp_err_t error = esp_sleep_enable_timer_wakeup(static_cast(idle_slice_ms) * 1000ULL); if (error == ESP_OK) { @@ -4360,17 +4367,22 @@ void loop() { sleep_failed = true; } } - if (sleep_failed) { + if (sleep_failed && !the_mesh.isDualRadioActive()) { 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(idle_slice_ms)); #else - // Connected transports need their own modem sleep and must retain the - // normal FreeRTOS idle behavior. - vTaskDelay(pdMS_TO_TICKS(idle_slice_ms)); + if (the_mesh.isDualRadioActive()) { + // A rounded RTOS delay can outlast a scanner visit. Use the same + // absolute, GPIO-wake deadline as infrastructure, with board USB, + // logging and physical wireless guards. A veto keeps polling. + if (!mesh::esp32::tryProfileIdleSleep(the_mesh, board, sleep_budget_started_us, idle_slice_ms) + && mesh::powerdiag::enabled()) + mesh::powerdiag::decision(mask(Reason::DualRadio), millis()); + } else { + // PM-enabled cores may use automatic sleep; other connected transports + // retain their modem sleep and normal FreeRTOS idle behavior. + vTaskDelay(pdMS_TO_TICKS(idle_slice_ms)); + } #endif #elif defined(RP2040_PLATFORM) || defined(STM32_PLATFORM) if (noise_floor_wait) board.sleepMilliseconds(idle_slice_ms); @@ -4537,6 +4549,11 @@ void loop() { #endif serviceCompanionBluetoothIdentity(); serviceCompanionBluetoothControl(); +#if defined(ESP32_PLATFORM) + // A logical off request must finish physical host/controller teardown even + // while this interface is disabled and no longer polled for frames. + bluetooth_interface.servicePowerState(); +#endif #if defined(NRF52_PLATFORM) serviceCompanionBluetoothName(); #endif diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index e812265ed..cbb6d9c5d 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -24,6 +24,7 @@ #endif #if defined(ESP32_PLATFORM) #include + #include #include #endif #if defined(NRF52_PLATFORM) @@ -674,6 +675,9 @@ void loop() { can_power_save = can_power_save && sleep_guard(!user_btn.needsPolling(), mesh::powerdiag::Reason::Button); #endif if (can_power_save) { +#if defined(ESP32_PLATFORM) + const uint32_t sleep_budget_started_us = static_cast(esp_timer_get_time()); +#endif uint32_t sleep_ms = the_mesh.getPowerSaveSleepMilliseconds(30000); uint32_t noise_floor_delay_ms = 0; const bool noise_floor_wait = the_mesh.getNextNoiseFloorWakeDelay(noise_floor_delay_ms); @@ -695,6 +699,13 @@ void loop() { #else if (sleep_ms > 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()); +#if defined(ESP32_PLATFORM) + if (the_mesh.isDualRadioActive()) { + if (!mesh::esp32::tryProfileIdleSleep(the_mesh, board, sleep_budget_started_us, sleep_ms) + && mesh::powerdiag::enabled()) + mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::DualRadio), millis()); + } else +#endif board.sleepMilliseconds(sleep_ms); // Wake for scheduled jobs or a LoRa packet. } else if (sleep_ms > 0 && mesh::powerdiag::enabled()) { mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Startup), millis()); diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index acc94c02c..cbe186b0b 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -21,6 +21,7 @@ #if defined(ESP32_PLATFORM) #include + #include #include #endif #if defined(NRF52_PLATFORM) @@ -446,6 +447,9 @@ void loop() { can_power_save = can_power_save && sleep_guard(!user_btn.needsPolling(), mesh::powerdiag::Reason::Button); #endif if (can_power_save) { +#if defined(ESP32_PLATFORM) + const uint32_t sleep_budget_started_us = static_cast(esp_timer_get_time()); +#endif uint32_t sleep_ms = the_mesh.getPowerSaveSleepMilliseconds(30000); uint32_t noise_floor_delay_ms = 0; const bool noise_floor_wait = the_mesh.getNextNoiseFloorWakeDelay(noise_floor_delay_ms); @@ -467,6 +471,13 @@ void loop() { #else if (sleep_ms > 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()); +#if defined(ESP32_PLATFORM) + if (the_mesh.isDualRadioActive()) { + if (!mesh::esp32::tryProfileIdleSleep(the_mesh, board, sleep_budget_started_us, sleep_ms) + && mesh::powerdiag::enabled()) + mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::DualRadio), millis()); + } else +#endif board.sleepMilliseconds(sleep_ms); // Wake for scheduled jobs or a LoRa packet. } else if (sleep_ms > 0 && mesh::powerdiag::enabled()) { mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Startup), millis()); diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index c35b8235b..a63aee4a6 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -58,6 +58,7 @@ static bool armTrackerSleepWake(uint32_t seconds) { #if defined(ESP32_PLATFORM) #include + #include #endif #ifdef DISPLAY_CLASS @@ -405,6 +406,9 @@ void loop() { can_power_save = can_power_save && sleep_guard(!user_btn.needsPolling(), mesh::powerdiag::Reason::Button); #endif if (can_power_save) { +#if defined(ESP32_PLATFORM) + const uint32_t sleep_budget_started_us = static_cast(esp_timer_get_time()); +#endif uint32_t sleep_ms = the_mesh.getPowerSaveSleepMilliseconds(30000); uint32_t noise_floor_delay_ms = 0; const bool noise_floor_wait = the_mesh.getNextNoiseFloorWakeDelay(noise_floor_delay_ms); @@ -444,6 +448,13 @@ void loop() { && the_mesh.millisHasNowPassed( POWERSAVING_FIRST_SLEEP_SECS * 1000UL)) { if (mesh::powerdiag::enabled()) mesh::powerdiag::decision(0, millis()); +#if defined(ESP32_PLATFORM) + if (the_mesh.isDualRadioActive()) { + if (!mesh::esp32::tryProfileIdleSleep(the_mesh, board, sleep_budget_started_us, sleep_ms) + && mesh::powerdiag::enabled()) + mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::DualRadio), millis()); + } else +#endif board.sleepMilliseconds(sleep_ms); } else if (sleep_ms > 0 && mesh::powerdiag::enabled()) { mesh::powerdiag::decision(mesh::powerdiag::mask(mesh::powerdiag::Reason::Startup), millis()); diff --git a/src/Dispatcher.h b/src/Dispatcher.h index 930502497..c96914e77 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -663,6 +663,18 @@ public: return radio_available && _radio->getProfileIdleDelay(delay_millis) && delay_millis > 0; } + // A timed-sleep caller must use the remaining visit budget, not just the + // boolean admission. Single-profile radios retain their existing policy. + // Read the sleep clock before calling, cap the caller's absolute deadline, + // and fail closed if setup/preemption consumes that budget before sleeping. + bool getDualRadioIdleDelay(uint32_t& delay_millis) const { + delay_millis = 0; + uint32_t profile_millis = 0; + if (!isDualRadioActive() || !radio_available + || !_radio->getProfileIdleDelay(profile_millis) || !profile_millis) return false; + delay_millis = profile_millis; + return true; + } Packet* obtainNewPacket(); void releasePacket(Packet* packet); diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 8d1bc90b6..fabb62089 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -20,6 +20,7 @@ #include "UsbLogging.h" #include "UsbHostSleepPolicy.h" #include "PowerSavingDiagnostics.h" +#include "esp32/LightSleepPolicy.h" #if defined(STATION_G3_ESP32) #include #endif @@ -137,6 +138,18 @@ public: } void sleepMicroseconds(uint64_t wake_us, bool require_timer = false) { + sleepMicrosecondsImpl(wake_us, require_timer, false, 0); + } + + // Radio-profile visits use the low 32 bits of esp_timer_get_time()/micros(). + // An absolute deadline prevents setup work or a task preemption from making + // the sleep slice outlast the visit which the scanner proved safe. + void sleepUntilMicroseconds(uint32_t deadline_us) { + sleepMicrosecondsImpl(0, true, true, deadline_us); + } + + void sleepMicrosecondsImpl(uint64_t wake_us, bool require_timer, + bool has_deadline, uint32_t deadline_us) { using mesh::powerdiag::Reason; const bool diagnostics = mesh::powerdiag::enabled(); // Native USB loses its connection in light sleep. An enumerated host @@ -166,9 +179,13 @@ public: // it and across host disconnects. Compiled-out logging is not a blocker. keep_awake = keep_awake || hold(mesh::isUsbLoggingEnabled(), Reason::UsbLogging); #endif + if (!keep_awake) { + const Reason wireless = mesh::esp32::manualLightSleepBlocker(); + keep_awake = hold(wireless != Reason::Count, wireless); + } if (keep_awake) { if (diagnostics) mesh::powerdiag::boardVeto(veto_reason, millis(), veto_detail); - delay(1); // Keep USB and OTA tasks running. + if (!has_deadline) delay(1); // A bounded profile visit must retain polling. return; } @@ -176,7 +193,7 @@ public: // Otherwise its real elapsed time would be mislabelled as blocked time. esp_err_t setup_error = ESP_OK; // Configure timer wakeup - if (wake_us > 0) { + if (!has_deadline && 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. @@ -189,6 +206,11 @@ public: const uint32_t irqGpio = getIRQGpio(); const bool radio_wakeup = irqGpio != static_cast(-1); + if (has_deadline && !radio_wakeup) { + // A timer-only wake cannot service a newly arrived profile packet. + if (diagnostics) mesh::powerdiag::boardVeto(Reason::NoWake, millis()); + return; + } #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP && defined(PIN_USER_BTN) const int button_gpio = PIN_USER_BTN; @@ -225,28 +247,43 @@ public: if (diagnostics) mesh::powerdiag::boardVeto( radio_pending ? Reason::Mesh : Reason::Button, millis(), radio_pending ? static_cast(irqGpio) : button_gpio); - delay(1); + if (!has_deadline) delay(1); return; } - // Configure GPIO wakeup - const esp_err_t gpio_error = esp_sleep_enable_gpio_wakeup(); - if (diagnostics && setup_error == ESP_OK && gpio_error != ESP_OK) setup_error = gpio_error; + // Configure each level source before enabling global GPIO wake. Setup + // errors must veto sleep even with diagnostics off: a timer alone cannot + // replace the pending radio/button event which this caller must service. + esp_err_t gpio_error = ESP_OK; if (radio_wakeup) { const esp_err_t error = gpio_wakeup_enable(wakeupPin, GPIO_INTR_HIGH_LEVEL); - if (diagnostics && setup_error == ESP_OK && error != ESP_OK) setup_error = error; + if (error != ESP_OK) gpio_error = error; } if (button_gpio >= 0) { const esp_err_t error = gpio_wakeup_enable((gpio_num_t)button_gpio, USER_BTN_PRESSED == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); - if (diagnostics && setup_error == ESP_OK && error != ESP_OK) setup_error = error; + if (gpio_error == ESP_OK && error != ESP_OK) gpio_error = error; } + if (gpio_error == ESP_OK) gpio_error = esp_sleep_enable_gpio_wakeup(); - // MCU enters light sleep - if (diagnostics) mesh::powerdiag::sleepBegin(micros()); - const esp_err_t sleep_error = esp_light_sleep_start(); - if (diagnostics) { - mesh::powerdiag::sleepEnd(micros()); + // The level source also catches an IRQ asserted while configuring wake. + // Avoid entering the SDK at all if its pending state is already visible. + const bool pending_after_setup = (radio_wakeup && gpio_get_level(wakeupPin) == HIGH) + || (button_gpio >= 0 + && gpio_get_level((gpio_num_t)button_gpio) == USER_BTN_PRESSED); + bool deadline_expired = false; + if (has_deadline && gpio_error == ESP_OK && !pending_after_setup) { + // Keep this immediately before the SDK wait with local interrupts held. + // A short/rejected slice polls instead; it must not reuse an older timer. + const int32_t remaining_us = static_cast(deadline_us - micros()); + if (remaining_us < 2000) deadline_expired = true; + else gpio_error = esp_sleep_enable_timer_wakeup(static_cast(remaining_us)); + } + esp_err_t sleep_error = ESP_OK; + if (gpio_error == ESP_OK && !pending_after_setup && !deadline_expired) { + if (diagnostics) mesh::powerdiag::sleepBegin(micros()); + sleep_error = esp_light_sleep_start(); + if (diagnostics) mesh::powerdiag::sleepEnd(micros()); } // Avoid ISR flood during wakeup due to HIGH LEVEL interrupt @@ -264,7 +301,12 @@ public: portEXIT_CRITICAL(&sleepMux); if (diagnostics) { if (setup_error != ESP_OK) mesh::powerdiag::boardError(setup_error, millis()); + if (gpio_error != ESP_OK) mesh::powerdiag::boardError(gpio_error, millis()); if (sleep_error != ESP_OK) mesh::powerdiag::boardError(sleep_error, millis()); + if (pending_after_setup) mesh::powerdiag::boardVeto( + radio_wakeup && gpio_get_level(wakeupPin) == HIGH ? Reason::Mesh : Reason::Button, + millis()); + if (deadline_expired) mesh::powerdiag::boardVeto(Reason::Scheduled, millis()); } } diff --git a/src/helpers/esp32/LightSleepPolicy.h b/src/helpers/esp32/LightSleepPolicy.h new file mode 100644 index 000000000..5c9a684ed --- /dev/null +++ b/src/helpers/esp32/LightSleepPolicy.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include + +#if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED +#include +#if defined(CONFIG_BLUEDROID_ENABLED) && CONFIG_BLUEDROID_ENABLED \ + && !defined(MESH_USE_NIMBLE_ARDUINO) +#include +#endif +#endif + +namespace mesh { +namespace esp32 { + +// Explicit light sleep powers down the WiFi/Bluetooth peripheral, even when +// its connections are idle. Only automatic SDK sleep can preserve an active +// driver. Read the physical drivers: a logical service-off flag can precede +// asynchronous teardown, and ESP-NOW may own WiFi without Arduino's facade. +inline powerdiag::Reason manualLightSleepBlocker() { +#if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED + const esp_bt_controller_status_t controller = esp_bt_controller_get_status(); + if (controller != ESP_BT_CONTROLLER_STATUS_IDLE + && controller != ESP_BT_CONTROLLER_STATUS_INITED) + return powerdiag::Reason::Wireless; +#if defined(CONFIG_BLUEDROID_ENABLED) && CONFIG_BLUEDROID_ENABLED \ + && !defined(MESH_USE_NIMBLE_ARDUINO) + const esp_bluedroid_status_t host = esp_bluedroid_get_status(); + if (host != ESP_BLUEDROID_STATUS_UNINITIALIZED + && host != ESP_BLUEDROID_STATUS_INITIALIZED) + return powerdiag::Reason::Wireless; +#endif +#endif + wifi_mode_t mode = WIFI_MODE_NULL; + const esp_err_t result = esp_wifi_get_mode(&mode); + if (result == ESP_OK) + return mode == WIFI_MODE_NULL ? powerdiag::Reason::Count + : powerdiag::Reason::Network; + if (result == ESP_ERR_WIFI_NOT_INIT || result == ESP_ERR_WIFI_NOT_STARTED) + return powerdiag::Reason::Count; + return powerdiag::Reason::Network; // An unknown driver state is not off. +} + +inline bool manualLightSleepReady() { + return manualLightSleepBlocker() == powerdiag::Reason::Count; +} + +} // namespace esp32 +} // namespace mesh diff --git a/src/helpers/esp32/ProfileIdleSleep.h b/src/helpers/esp32/ProfileIdleSleep.h new file mode 100644 index 000000000..5df20d2c6 --- /dev/null +++ b/src/helpers/esp32/ProfileIdleSleep.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +namespace mesh { +namespace esp32 { + +// Preserve both the role's original wake deadline and the scanner's late +// admission. The board checks this absolute deadline again after configuring +// wake sources, so preemption cannot turn a short visit into a longer sleep. +// True means a board sleep was attempted, not proof that the SDK slept. +template +bool tryProfileIdleSleep(Mesh& mesh, Board& board, + uint32_t budget_started_us, uint32_t budget_ms) { + if (budget_ms == 0 || budget_ms > 0x7fffffffUL / 1000UL) return false; + + const uint32_t observed_us = static_cast(esp_timer_get_time()); + uint32_t profile_ms = 0; + if (!mesh.getDualRadioIdleDelay(profile_ms) || profile_ms < 2 || profile_ms > 10) + return false; + + const uint32_t role_deadline = budget_started_us + budget_ms * 1000UL; + const int32_t role_remaining_us = static_cast(role_deadline - observed_us); + if (role_remaining_us < 2000) return false; + + const uint32_t profile_us = profile_ms * 1000UL; + const uint32_t remaining_us = static_cast(role_remaining_us) < profile_us + ? static_cast(role_remaining_us) : profile_us; + board.sleepUntilMicroseconds(observed_us + remaining_us); + return true; +} + +} // namespace esp32 +} // namespace mesh diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index 1c19231bf..faba956f4 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -2,12 +2,15 @@ #include "../BluetoothMac.h" #include "../CompanionFrameQueue.h" #include "esp_mac.h" +#include "esp_bt.h" #include #if defined(CONFIG_BLUEDROID_ENABLED) && !defined(MESH_USE_NIMBLE_ARDUINO) #include "esp_gap_ble_api.h" +#include "esp_bt_main.h" #endif #if MESH_BLE_USES_NIMBLE && !defined(MESH_USE_NIMBLE_ARDUINO) #include +#include #endif // Arduino 3.x relies on a library constructor to mark BLE as used before @@ -29,12 +32,83 @@ extern "C" bool bleInUse(void) { #define ADVERT_RESTART_DELAY 1000 // millis #define BLE_BOND_PERSIST_TIMEOUT_MS 15000 -static void cleanupFailedBluetoothStart() { -#if defined(MESH_USE_NIMBLE_ARDUINO) - BLEDevice::deinit(true); -#else - BLEDevice::deinit(false); +#define BLE_POWER_RETRY_MS 1000 + +static bool stopBluetoothController() { + if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_ENABLED + && esp_bt_controller_disable() != ESP_OK) return false; + if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_INITED + && esp_bt_controller_deinit() != ESP_OK) return false; + return esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_IDLE; +} + +#if !defined(MESH_USE_NIMBLE_ARDUINO) +// The pinned 2.0.17/3.1.3 wrappers leave their static server/advertiser alive +// on deinit(false). The newer wrapper exposing getServer() deletes them. +// Keep that ownership distinction independent of the processor model. +template +static auto bluetoothLibraryOwnsServer(int) + -> decltype(Device::getServer(), bool()) { return true; } +template +static bool bluetoothLibraryOwnsServer(...) { return false; } #endif + +void SerialBLEInterface::forgetGattPointers() { + pServer = NULL; + pService = NULL; + pTxCharacteristic = NULL; + pRxCharacteristic = NULL; +#if !defined(MESH_USE_NIMBLE_ARDUINO) + pTxDescriptor = NULL; +#endif +} + +bool SerialBLEInterface::stopBluetoothStack() { + _callbacksEnabled.store(false, std::memory_order_release); +#if defined(MESH_USE_NIMBLE_ARDUINO) + // clearAll is OBJECT ownership, not bond storage. Stop first without + // deleting objects: a failed stop must leave callback targets valid. + if (!BLEDevice::deinit(false)) return false; + if (!stopBluetoothController()) return false; + BLEDevice::deinit(true); + forgetGattPointers(); + _advertisingConfigured = false; +#else +#if defined(CONFIG_BLUEDROID_ENABLED) + // Stop callback dispatch before the newer wrapper deletes its server. + // Never release controller memory: Bluetooth on must work in this boot. + if (esp_bluedroid_get_status() == ESP_BLUEDROID_STATUS_ENABLED + && esp_bluedroid_disable() != ESP_OK) return false; + if (esp_bluedroid_get_status() == ESP_BLUEDROID_STATUS_INITIALIZED + && esp_bluedroid_deinit() != ESP_OK) return false; +#elif defined(CONFIG_NIMBLE_ENABLED) + if (!_nativeHostStopped) { + if (nimble_port_stop() != 0) return false; + _nativeHostStopped = true; + } +#endif +#if defined(CONFIG_BLUEDROID_ENABLED) + if (!stopBluetoothController()) return false; +#endif + const bool owns_server = bluetoothLibraryOwnsServer(0); + BLEDevice::deinit(false); + // Neither pinned Arduino wrapper owns our characteristics/service. The + // legacy server is retained until createServer() replaces its static + // pointer; deleting it sooner would leave a dangling callback target. + delete pTxDescriptor; + delete pRxCharacteristic; + delete pTxCharacteristic; + delete pService; + pTxDescriptor = NULL; + pRxCharacteristic = pTxCharacteristic = NULL; + pService = NULL; + if (owns_server) { + pServer = NULL; + _advertisingConfigured = false; + } + if (!stopBluetoothController()) return false; +#endif + return true; } #if !defined(MESH_USE_NIMBLE_ARDUINO) @@ -276,6 +350,8 @@ bool SerialBLEInterface::configureBondedOnlyAdvertising( advertising->setAdvertisementData(minimal_advertisement); _bonded_only = true; + _bondedPeer = peer; + _hasBondedPeer = true; _stealth_pair_once = false; _advertisingSuppressed.store(false, std::memory_order_release); _adv_restart_pending = false; @@ -310,7 +386,9 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, bool clear_bonds, bool stealth_pair_once, const mesh::companion::BluetoothPeerIdentity* bonded_only_peer) { + if (_stackActive || _shutdownPending) return false; _pin_code = pin_code; + _hasBondedPeer = false; _successfulConnectionPending.store(false, std::memory_order_release); _successfulConnectionStarted.store(0, std::memory_order_relaxed); _bondedOnlyRecoveryPending.store(false, std::memory_order_release); @@ -344,22 +422,58 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, return false; } + memcpy(_deviceName, dev_name, dev_name_len + 1); + _hasCustomAddress = custom_address != nullptr; + if (_hasCustomAddress) { + memcpy(_customAddress, custom_address, sizeof(_customAddress)); + } + if (bonded_only_peer != nullptr) { + _bondedPeer = *bonded_only_peer; + _hasBondedPeer = true; + _bonded_only = true; + } + _configured = true; + if (startBluetoothStack(clear_bonds)) return true; + servicePowerState(); + return false; +} + +bool SerialBLEInterface::startBluetoothStack(bool clear_bonds) { + const char* dev_name = _deviceName; + const uint8_t* custom_address = _hasCustomAddress ? _customAddress : nullptr; + const uint32_t pin_code = _pin_code; + // A failed start can have partially initialized the controller. Keep the + // shutdown gate closed until servicePowerState() has cleaned it up. + _stackActive = true; + _nativeHostStopped = false; + _callbacksEnabled.store(false, std::memory_order_release); // Create the BLE Device #if MESH_BLE_USES_NIMBLE if (!BLEDevice::init(dev_name)) { BLE_DEBUG_PRINTLN("BLEDevice::init failed"); + _isEnabled = _enableRequested = false; + _shutdownPending = true; return false; } #else // Arduino-ESP32 2.x Bluedroid exposes a void init(). Its first observable // failure is a null server below, which is handled without dereferencing it. BLEDevice::init(dev_name); +#if defined(CONFIG_BLUEDROID_ENABLED) + if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED + || esp_bluedroid_get_status() != ESP_BLUEDROID_STATUS_ENABLED) { + _isEnabled = _enableRequested = false; + _shutdownPending = true; + return false; + } +#endif #endif #if defined(MESH_USE_NIMBLE_ARDUINO) if (clear_bonds && !BLEDevice::deleteAllBonds()) { BLE_DEBUG_PRINTLN("Could not clear NimBLE bonds for the new identity"); - cleanupFailedBluetoothStart(); + _isEnabled = _enableRequested = false; + _shutdownPending = true; return false; } #else @@ -376,13 +490,15 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, if (!BLEDevice::setOwnAddr(native_address) || !BLEDevice::setOwnAddrType(BLE_OWN_ADDR_RANDOM)) { BLE_DEBUG_PRINTLN("Custom Bluetooth MAC setup failed"); - cleanupFailedBluetoothStart(); + _isEnabled = _enableRequested = false; + _shutdownPending = true; return false; } } #if defined(MESH_USE_NIMBLE_ARDUINO) else if (!BLEDevice::setOwnAddrType(BLE_OWN_ADDR_PUBLIC)) { - cleanupFailedBluetoothStart(); + _isEnabled = _enableRequested = false; + _shutdownPending = true; return false; } #endif @@ -424,10 +540,19 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, //BLEDevice::setPower(ESP_PWR_LVL_N8); // Create the BLE Server +#if !defined(MESH_USE_NIMBLE_ARDUINO) + BLEServer* retired_server = pServer; +#endif pServer = BLEDevice::createServer(); +#if !defined(MESH_USE_NIMBLE_ARDUINO) + // createServer has replaced the legacy library's static callback pointer. + // Its old server can now be freed, without ever exposing a dangling one. + delete retired_server; +#endif if (pServer == NULL) { BLE_DEBUG_PRINTLN("BLEDevice::createServer failed"); - cleanupFailedBluetoothStart(); + _isEnabled = _enableRequested = false; + _shutdownPending = true; return false; } #if defined(MESH_USE_NIMBLE_ARDUINO) @@ -454,8 +579,8 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, pService = pServer->createService(SERVICE_UUID); if (pService == NULL) { BLE_DEBUG_PRINTLN("BLEServer::createService failed"); - cleanupFailedBluetoothStart(); - pServer = NULL; + _isEnabled = _enableRequested = false; + _shutdownPending = true; return false; } @@ -479,9 +604,8 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, CHARACTERISTIC_UUID_TX, tx_properties); if (pTxCharacteristic == NULL) { BLE_DEBUG_PRINTLN("BLEService::createCharacteristic(TX) failed"); - cleanupFailedBluetoothStart(); - pServer = NULL; - pService = NULL; + _isEnabled = _enableRequested = false; + _shutdownPending = true; return false; } #if !defined(MESH_USE_NIMBLE_ARDUINO) @@ -500,17 +624,12 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, pTxCharacteristic->addDescriptor(pTxDescriptor); #endif - BLECharacteristic * pRxCharacteristic = pService->createCharacteristic( + pRxCharacteristic = pService->createCharacteristic( CHARACTERISTIC_UUID_RX, rx_properties); if (pRxCharacteristic == NULL) { BLE_DEBUG_PRINTLN("BLEService::createCharacteristic(RX) failed"); - cleanupFailedBluetoothStart(); - pServer = NULL; - pService = NULL; - pTxCharacteristic = NULL; -#if !defined(MESH_USE_NIMBLE_ARDUINO) - pTxDescriptor = NULL; -#endif + _isEnabled = _enableRequested = false; + _shutdownPending = true; return false; } #if !defined(MESH_USE_NIMBLE_ARDUINO) @@ -522,10 +641,16 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, pServer->getAdvertising()->setName(dev_name); pServer->getAdvertising()->enableScanResponse(true); #endif - pServer->getAdvertising()->addServiceUUID(SERVICE_UUID); - if (bonded_only_peer != nullptr) { - configureBondedOnlyAdvertising(*bonded_only_peer, true); + if (!_advertisingConfigured) { + pServer->getAdvertising()->addServiceUUID(SERVICE_UUID); + _advertisingConfigured = true; } + if (_bonded_only && _hasBondedPeer) { + const bool suppressed = _advertisingSuppressed.load(std::memory_order_acquire); + configureBondedOnlyAdvertising(_bondedPeer, true); + if (suppressed) _advertisingSuppressed.store(true, std::memory_order_release); + } + _callbacksEnabled.store(true, std::memory_order_release); return true; } @@ -533,11 +658,13 @@ bool SerialBLEInterface::begin(const char* prefix, const char* name, #if defined(MESH_USE_NIMBLE_ARDUINO) uint32_t SerialBLEInterface::onPassKeyDisplay() { + if (!_callbacksEnabled.load(std::memory_order_acquire)) return _pin_code; _pairingRequestPending.store(true, std::memory_order_release); return _pin_code; } void SerialBLEInterface::onAuthenticationComplete(NimBLEConnInfo& info) { + if (!_callbacksEnabled.load(std::memory_order_acquire)) return; if (pServer == NULL || pServer->getConnectedCount() == 0 || info.getConnHandle() != last_conn_id) return; if (info.isEncrypted() && info.isAuthenticated() && info.isBonded()) { @@ -562,17 +689,20 @@ void SerialBLEInterface::onAuthenticationComplete(NimBLEConnInfo& info) { } #else uint32_t SerialBLEInterface::onPassKeyRequest() { + if (!_callbacksEnabled.load(std::memory_order_acquire)) return _pin_code; BLE_DEBUG_PRINTLN("onPassKeyRequest()"); _pairingRequestPending.store(true, std::memory_order_release); return _pin_code; } void SerialBLEInterface::onPassKeyNotify(uint32_t pass_key) { + if (!_callbacksEnabled.load(std::memory_order_acquire)) return; BLE_DEBUG_PRINTLN("onPassKeyNotify(%u)", pass_key); _pairingRequestPending.store(true, std::memory_order_release); } bool SerialBLEInterface::onConfirmPIN(uint32_t pass_key) { + if (!_callbacksEnabled.load(std::memory_order_acquire)) return false; BLE_DEBUG_PRINTLN("onConfirmPIN(%u)", pass_key); _pairingRequestPending.store(true, std::memory_order_release); return true; @@ -580,11 +710,12 @@ bool SerialBLEInterface::onConfirmPIN(uint32_t pass_key) { bool SerialBLEInterface::onSecurityRequest() { BLE_DEBUG_PRINTLN("onSecurityRequest()"); - return true; // allow + return _callbacksEnabled.load(std::memory_order_acquire); } #if MESH_BLE_USES_NIMBLE void SerialBLEInterface::onAuthenticationComplete(ble_gap_conn_desc* desc) { + if (!_callbacksEnabled.load(std::memory_order_acquire)) return; const bool success = desc != NULL && desc->sec_state.encrypted && desc->sec_state.authenticated && desc->sec_state.bonded; @@ -621,6 +752,7 @@ void SerialBLEInterface::onAuthenticationComplete(ble_gap_conn_desc* desc) { } #else void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) { + if (!_callbacksEnabled.load(std::memory_order_acquire)) return; if (cmpl.success) { BLE_DEBUG_PRINTLN(" - SecurityCallback - Authentication Success"); deviceConnected = true; @@ -662,6 +794,11 @@ void SerialBLEInterface::onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) { #if defined(MESH_USE_NIMBLE_ARDUINO) void SerialBLEInterface::onConnect(BLEServer* pServer, NimBLEConnInfo& info) { + if (!_callbacksEnabled.load(std::memory_order_acquire)) { + last_conn_id = info.getConnHandle(); + pServer->disconnect(last_conn_id); + return; + } last_conn_id = info.getConnHandle(); deviceConnected = false; oldDeviceConnected = false; @@ -682,6 +819,11 @@ void SerialBLEInterface::onConnect(BLEServer* pServer) { #if MESH_BLE_USES_NIMBLE void SerialBLEInterface::onConnect(BLEServer* pServer, ble_gap_conn_desc* desc) { + if (!_callbacksEnabled.load(std::memory_order_acquire)) { + last_conn_id = desc->conn_handle; + pServer->disconnect(last_conn_id); + return; + } BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", desc->conn_handle, pServer->getPeerMTU(desc->conn_handle)); last_conn_id = desc->conn_handle; @@ -703,6 +845,11 @@ void SerialBLEInterface::onMtuChanged(BLEServer* pServer, } #else void SerialBLEInterface::onConnect(BLEServer* pServer, esp_ble_gatts_cb_param_t *param) { + if (!_callbacksEnabled.load(std::memory_order_acquire)) { + last_conn_id = param->connect.conn_id; + pServer->disconnect(last_conn_id); + return; + } BLE_DEBUG_PRINTLN("onConnect(), conn_id=%d, mtu=%d", param->connect.conn_id, pServer->getPeerMTU(param->connect.conn_id)); last_conn_id = param->connect.conn_id; deviceConnected = false; // becomes usable only after authentication completes @@ -734,6 +881,7 @@ void SerialBLEInterface::onDisconnect(BLEServer* pServer, NimBLEConnInfo& info, #else void SerialBLEInterface::onDisconnect(BLEServer* pServer) { #endif + if (!_callbacksEnabled.load(std::memory_order_acquire)) return; BLE_DEBUG_PRINTLN("onDisconnect()"); deviceConnected = false; notifySucceeded = false; @@ -763,6 +911,7 @@ void SerialBLEInterface::onWrite(BLECharacteristic* pCharacteristic, esp_ble_gatts_cb_param_t* param) { (void)param; #endif + if (!_callbacksEnabled.load(std::memory_order_acquire)) return; if (_tx_disconnect_recovery.pending()) { BLE_DEBUG_PRINTLN("onWrite(): dropping frame while BLE reconnect is pending"); return; @@ -801,6 +950,7 @@ void SerialBLEInterface::onSubscribe(BLECharacteristic* pCharacteristic, (void)desc; #endif #if MESH_BLE_USES_NIMBLE + if (!_callbacksEnabled.load(std::memory_order_acquire)) return; if (pCharacteristic == pTxCharacteristic) { // NimBLE uses bit 0 for notification subscriptions. notificationsEnabled.store((subValue & 0x0001u) != 0, @@ -812,6 +962,7 @@ void SerialBLEInterface::onSubscribe(BLECharacteristic* pCharacteristic, #if !defined(MESH_USE_NIMBLE_ARDUINO) void SerialBLEInterface::onStatus(BLECharacteristic* pCharacteristic, Status status, uint32_t code) { (void)pCharacteristic; + if (!_callbacksEnabled.load(std::memory_order_acquire)) return; notifySucceeded = status == SUCCESS_NOTIFY; if (!notifySucceeded) { BLE_DEBUG_PRINTLN("notify failed, status=%d, code=%u; retaining frame", @@ -926,46 +1077,89 @@ void SerialBLEInterface::recoverStalledTx(const char* cause) { serviceTxRecovery((uint32_t)millis()); } -void SerialBLEInterface::enable() { - if (_isEnabled) return; - - _isEnabled = true; +void SerialBLEInterface::startServiceAndAdvertising() { + if (pServer == NULL || pService == NULL) return; clearBuffers(); - - // Start the service #if defined(MESH_USE_NIMBLE_ARDUINO) if (!pServer->start()) { - _isEnabled = false; + _isEnabled = _enableRequested = false; + _shutdownPending = true; + _callbacksEnabled.store(false, std::memory_order_release); return; } #else pService->start(); #endif - - // Start advertising - - //pServer->getAdvertising()->setMinInterval(500); - //pServer->getAdvertising()->setMaxInterval(1000); - + _isEnabled = true; if (advertisingAllowed()) { +#if defined(MESH_USE_NIMBLE_ARDUINO) + if (!pServer->getAdvertising()->start()) { + _isEnabled = _enableRequested = false; + _shutdownPending = true; + _callbacksEnabled.store(false, std::memory_order_release); + pServer->getAdvertising()->stop(); + } +#else pServer->getAdvertising()->start(); +#endif } _adv_restart_pending = false; } +void SerialBLEInterface::enable() { + if (_isEnabled || !_configured) return; + _enableRequested = true; + if (_shutdownPending) { + // Finish a previously requested disconnect/stop before rebuilding. A + // second toggle must never revive half of a stopped host. + servicePowerState(); + return; + } + if (!_stackActive && !startBluetoothStack(false)) { + servicePowerState(); + return; + } + startServiceAndAdvertising(); +} + void SerialBLEInterface::disable() { - _isEnabled = false; - - BLE_DEBUG_PRINTLN("SerialBLEInterface::disable"); - - pServer->getAdvertising()->stop(); - pServer->disconnect(last_conn_id); -#if !defined(MESH_USE_NIMBLE_ARDUINO) - pService->stop(); -#endif - oldDeviceConnected = deviceConnected = false; - clearBuffers(); + _isEnabled = _enableRequested = false; _adv_restart_pending = false; + // The caller drains its command reply before this point. Only stop + // advertising/disconnect here; the host is stopped in the loop task after + // its asynchronous disconnect has completed. + servicePowerState(); +} + +void SerialBLEInterface::servicePowerState() { + const uint32_t now = (uint32_t)millis(); + if (!_enableRequested && _stackActive && !_shutdownPending) { + _shutdownPending = true; + _powerAttempted = false; + _callbacksEnabled.store(false, std::memory_order_release); + _adv_restart_pending = false; + if (pServer != NULL) pServer->getAdvertising()->stop(); + oldDeviceConnected = deviceConnected = false; + } + if (!_shutdownPending) return; + if (_powerAttempted + && !mesh::bleElapsedAtLeast(now, _lastPowerAttempt, BLE_POWER_RETRY_MS)) + return; + _powerAttempted = true; + _lastPowerAttempt = now; + if (pServer != NULL && pServer->getConnectedCount() != 0) { + pServer->disconnect(last_conn_id); + return; + } + if (!stopBluetoothStack()) return; + _stackActive = false; + _shutdownPending = false; + _powerAttempted = false; + clearBuffers(); + _pairingRequestPending.store(false, std::memory_order_release); + _successfulConnectionPending.store(false, std::memory_order_release); + _successfulConnectionStarted.store(0, std::memory_order_relaxed); + if (_enableRequested && startBluetoothStack(false)) startServiceAndAdvertising(); } size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) { @@ -1026,6 +1220,7 @@ bool SerialBLEInterface::isWriteBusy() const { size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { const uint32_t now = (uint32_t)millis(); + if (!_isEnabled || !_stackActive || _shutdownPending) return 0; servicePendingTxReset(); if (_tx_disconnect_recovery.pending()) { serviceTxRecovery(now); @@ -1137,5 +1332,6 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { } bool SerialBLEInterface::isConnected() const { - return !_tx_disconnect_recovery.pending() && deviceConnected; + return _isEnabled && _stackActive && !_shutdownPending + && !_tx_disconnect_recovery.pending() && deviceConnected; } diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 669493cb3..18210ae51 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -29,6 +29,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLEServer *pServer; BLEService *pService; BLECharacteristic * pTxCharacteristic; + BLECharacteristic *pRxCharacteristic; #if !defined(MESH_USE_NIMBLE_ARDUINO) BLE2902 *pTxDescriptor; #endif @@ -37,6 +38,22 @@ class SerialBLEInterface : public BaseSerialInterface, bool notifySucceeded; std::atomic notificationsEnabled{false}; bool _isEnabled; + bool _enableRequested = false; + // This interface outlives each host/controller session. Disable callbacks + // before stopping the host, and only then destroy its GATT objects. + std::atomic _callbacksEnabled{false}; + bool _stackActive = false; + bool _shutdownPending = false; + bool _nativeHostStopped = false; + bool _configured = false; + bool _powerAttempted = false; + uint32_t _lastPowerAttempt = 0; + char _deviceName[48] = {}; + uint8_t _customAddress[mesh::companion::BLUETOOTH_MAC_BYTES] = {}; + bool _hasCustomAddress = false; + bool _advertisingConfigured = false; + bool _hasBondedPeer = false; + mesh::companion::BluetoothPeerIdentity _bondedPeer; uint16_t last_conn_id; uint32_t _pin_code; uint32_t _last_write; @@ -67,6 +84,10 @@ class SerialBLEInterface : public BaseSerialInterface, Frame send_queue[FRAME_QUEUE_SIZE]; void clearBuffers(); + bool startBluetoothStack(bool clear_bonds); + bool stopBluetoothStack(); + void forgetGattPointers(); + void startServiceAndAdvertising(); bool hasRequiredReplyCapacity() const; void servicePendingTxReset(); void scheduleAdvertisingRestart(uint32_t now); @@ -131,6 +152,7 @@ public: pServer = NULL; pService = NULL; pTxCharacteristic = NULL; + pRxCharacteristic = NULL; #if !defined(MESH_USE_NIMBLE_ARDUINO) pTxDescriptor = NULL; #endif @@ -171,6 +193,12 @@ public: void enable() override; void disable() override; bool isEnabled() const override { return _isEnabled; } + // Call from the application task even while the logical interface is off. + // Disconnects are asynchronous; never stop a host from its own callback. + void servicePowerState(); + bool isPowerTransitionPending() const { + return _shutdownPending || (_stackActive != _enableRequested); + } bool isConnected() const override; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 360df43e9..555fef136 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -9,6 +9,7 @@ #include #if defined(ESP32_PLATFORM) #include +#include #elif defined(NRF52_PLATFORM) #include #include "../NRF52Board.h" @@ -94,7 +95,12 @@ static uint32_t profileTimestamp(bool dual_profile) { return profile_clock_ticks; } #endif +#if defined(ESP32_PLATFORM) + // Unlike a cycle counter, this domain includes elapsed light-sleep time. + return static_cast(esp_timer_get_time()); +#else return micros(); +#endif } static uint32_t profileElapsedUs(uint32_t since, uint32_t now, bool dual_profile) { @@ -486,7 +492,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, } } +#if defined(ESP32_PLATFORM) + _esp32_profile_visit_started_us = static_cast(esp_timer_get_time()); + _profile_visit_stamp = static_cast(_esp32_profile_visit_started_us); +#else _profile_visit_stamp = profileTimestamp(_profiles.enabled()); +#endif return success ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; } @@ -610,7 +621,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { if (!restored || (resume && !isInRecvMode())) _profile_refresh_required = _profile_repair_required = true; } +#if defined(ESP32_PLATFORM) + _esp32_profile_visit_started_us = static_cast(esp_timer_get_time()); + _profile_visit_stamp = static_cast(_esp32_profile_visit_started_us); +#else _profile_visit_stamp = profileTimestamp(dual_profile); +#endif // A profile retune re-enters RX on a different tuple. Do not read its // instantaneous RSSI until the chip family's settle interval has elapsed. if (applied && _nf_refresh_requested) { @@ -837,6 +853,9 @@ mesh::RadioParamApplyResult RadioLibWrapper::tryRestoreCodingRate(uint8_t cr) { void RadioLibWrapper::serviceProfileScan() { #if defined(NRF52_PLATFORM) _profile_idle_visit_ticks = 0; // Every deferred/restarted scan fails closed. +#endif +#if defined(ESP32_PLATFORM) + _esp32_profile_idle_visit_us = 0; #endif (void)profileTimestamp(_profiles.enabled()); if (_cw_active) return; @@ -886,7 +905,7 @@ void RadioLibWrapper::serviceProfileScan() { if (!_profiles.switchTestReady()) return; } uint8_t target = _active_profile; -#if defined(NRF52_PLATFORM) +#if defined(NRF52_PLATFORM) || defined(ESP32_PLATFORM) const uint8_t visited_profile = _active_profile; #endif uint32_t visit_us = 0; @@ -919,13 +938,25 @@ void RadioLibWrapper::serviceProfileScan() { && visit_us < NF_CALIB_SETTLE_MS * 1000UL) { visit_us = NF_CALIB_SETTLE_MS * 1000UL; } +#if defined(ESP32_PLATFORM) + // Keep long visits and light-sleep elapsed time in the same 64-bit domain. + const uint64_t now_us = static_cast(esp_timer_get_time()); + if (now_us < _esp32_profile_visit_started_us + || now_us - _esp32_profile_visit_started_us >= visit_us) target ^= 1; +#else if (profileElapsedUs(_profile_visit_stamp, profileTimestamp(true), true) >= visit_us) target ^= 1; +#endif } const auto result = tuneProfile(target); if (restart_scan && result == mesh::RadioParamApplyResult::APPLIED) { _profile_scan_generation[0] = _profiles.generation[0]; _profile_scan_generation[1] = _profiles.generation[1]; +#if defined(ESP32_PLATFORM) + _esp32_profile_visit_started_us = static_cast(esp_timer_get_time()); + _profile_visit_stamp = static_cast(_esp32_profile_visit_started_us); +#else _profile_visit_stamp = profileTimestamp(true); +#endif } if (result == mesh::RadioParamApplyResult::FAILED) { // Retry a rejected or failing profile at a bounded rate; retain primary RX. @@ -955,6 +986,19 @@ void RadioLibWrapper::serviceProfileScan() { _profile_idle_preamble = _physical_preamble; } #endif +#if defined(ESP32_PLATFORM) + if (_profiles.enabled() && !restart_scan && _active_profile == visited_profile + && result == mesh::RadioParamApplyResult::APPLIED + && !isChipBusy() && !isPacketPendingOrReceiving()) { + _esp32_profile_idle_visit_us = visit_us; + _esp32_profile_idle_stamp_us = _esp32_profile_visit_started_us; + _esp32_profile_idle_switch_us = _profiles.switchBudgetUs(); + _esp32_profile_idle_preamble = _physical_preamble; + _esp32_profile_idle_profile = _active_profile; + _esp32_profile_idle_generation[0] = _profiles.generation[0]; + _esp32_profile_idle_generation[1] = _profiles.generation[1]; + } +#endif } bool RadioLibWrapper::getProfileIdleDelay(uint32_t& delay_ms) const { @@ -993,6 +1037,48 @@ bool RadioLibWrapper::getProfileIdleDelay(uint32_t& delay_ms) const { || _profile_idle_visit_ticks - elapsed <= 32 + guard_ticks) return false; delay_ms = uint64_t(_profile_idle_visit_ticks - elapsed - guard_ticks) * 15625 / 512000; return delay_ms != 0; +#elif defined(ESP32_PLATFORM) + if (!_profiles.enabled() || !_esp32_profile_idle_visit_us + || !_params_valid || state != STATE_RX || !_profile_rxps_suspended + || _rx_ps_enabled || _rx_ps_armed || _rx_hold_continuous + || _rx_retry_pending || _profile_retry_at || _profile_refresh_required + || _profile_repair_required || _nf_refresh_requested || _nf_calib_active + || !_noise_floor_valid || !_noise_floor_secondary_valid + || _wd_observe_until || _cw_active || _morse_owned || hasPendingRadioInterrupt() + || !_profiles.switchTestReady() || _active_profile > 1 + || _esp32_profile_idle_profile != _active_profile + || _profile_generation != _profiles.generation[_active_profile] + || _profile_scan_generation[0] != _profiles.generation[0] + || _profile_scan_generation[1] != _profiles.generation[1] + || _esp32_profile_idle_generation[0] != _profiles.generation[0] + || _esp32_profile_idle_generation[1] != _profiles.generation[1] + || _esp32_profile_idle_stamp_us != _esp32_profile_visit_started_us + || _esp32_profile_idle_preamble != _physical_preamble + || _esp32_profile_idle_switch_us != _profiles.switchBudgetUs()) return false; +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return false; +#endif +#if MESH_ENABLE_MORSE_RX + if (_morse_rx_owned) return false; +#endif + const uint64_t now_us = static_cast(esp_timer_get_time()); + if (now_us < _esp32_profile_idle_stamp_us) return false; + const uint64_t elapsed_us = now_us - _esp32_profile_idle_stamp_us; + // Keep frontend settling and short visits on their polling path. Only the + // MCU idles; the RF receiver stays in continuous RX on the current profile. + if (elapsed_us < NoiseFloorSettleMillis * 1000UL + || elapsed_us >= _esp32_profile_idle_visit_us) return false; + const uint32_t remaining_us = _esp32_profile_idle_visit_us - static_cast(elapsed_us); + // Reserve return-to-loop/wake latency and require a useful timer interval. + // These bounds are a conservative policy, not a measured latency guarantee. + // The caller anchors this budget to an absolute ESP Timer deadline before + // querying it and checks that deadline again immediately before sleeping. + constexpr uint32_t wake_guard_us = 4000; + constexpr uint32_t minimum_idle_us = 2000; + if (remaining_us < wake_guard_us + minimum_idle_us) return false; + delay_ms = (remaining_us - wake_guard_us) / 1000; + if (delay_ms > 10) delay_ms = 10; + return true; #else return false; // Other platforms retain polling until their wake path is validated. #endif @@ -2070,7 +2156,12 @@ void RadioLibWrapper::startRecv() { int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { // A very short frame may complete while startReceiveMode() returns. +#if defined(ESP32_PLATFORM) + _esp32_profile_visit_started_us = static_cast(esp_timer_get_time()); + _profile_visit_stamp = static_cast(_esp32_profile_visit_started_us); +#else _profile_visit_stamp = profileTimestamp(_profiles.enabled()); +#endif // Retain that RX interrupt instead of overwriting it with software state. state = STATE_RX; // RSSI is unsettled after every RX entry, including CAD/TX completion. diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index e34ba4f49..4e77fd5cd 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -125,6 +125,18 @@ protected: uint32_t _profile_idle_switch_us = 0; uint16_t _profile_idle_preamble = 0; #endif +#if defined(ESP32_PLATFORM) + // ESP Timer advances by the RTC interval after light sleep. Retain its + // full domain as well as the legacy scanner timestamp: a long RX hold or + // delayed loop must not resurrect a visit after micros() wraps. + uint64_t _esp32_profile_visit_started_us = 0; + uint64_t _esp32_profile_idle_stamp_us = 0; + uint32_t _esp32_profile_idle_visit_us = 0; + uint32_t _esp32_profile_idle_switch_us = 0; + uint32_t _esp32_profile_idle_generation[2] = {}; + uint16_t _esp32_profile_idle_preamble = 0; + uint8_t _esp32_profile_idle_profile = 0; +#endif #if defined(MESH_HIL_TX_FREQ_MHZ) bool hilPacketTransmitAllowed() const; #endif diff --git a/test/fixtures/esp32_ble_power/mocks/BLE2902.h b/test/fixtures/esp32_ble_power/mocks/BLE2902.h new file mode 100644 index 000000000..c34334f7c --- /dev/null +++ b/test/fixtures/esp32_ble_power/mocks/BLE2902.h @@ -0,0 +1,2 @@ +#pragma once +#include "BLEDevice.h" diff --git a/test/fixtures/esp32_ble_power/mocks/BLEDevice.h b/test/fixtures/esp32_ble_power/mocks/BLEDevice.h new file mode 100644 index 000000000..ef2d78090 --- /dev/null +++ b/test/fixtures/esp32_ble_power/mocks/BLEDevice.h @@ -0,0 +1,187 @@ +#pragma once +#include +#include +#include +#include +#include +using esp_err_t = int; +constexpr esp_err_t ESP_OK = 0; +enum esp_bt_controller_status_t { ESP_BT_CONTROLLER_STATUS_IDLE, ESP_BT_CONTROLLER_STATUS_INITED, ESP_BT_CONTROLLER_STATUS_ENABLED }; +enum esp_bluedroid_status_t { ESP_BLUEDROID_STATUS_UNINITIALIZED, ESP_BLUEDROID_STATUS_INITIALIZED, ESP_BLUEDROID_STATUS_ENABLED }; +using esp_bd_addr_t = uint8_t[6]; +using esp_ble_wl_addr_type_t = int; +using esp_gatt_perm_t = int; +constexpr int BLE_ADDR_TYPE_PUBLIC=0, BLE_ADDR_TYPE_RANDOM=1, BLE_WL_ADDR_TYPE_PUBLIC=0, BLE_WL_ADDR_TYPE_RANDOM=1; +constexpr int ESP_LE_KEY_PID=1, ESP_BLE_ADV_FLAG_BREDR_NOT_SPT=4; +constexpr int ESP_GATT_PERM_READ_ENC_MITM=1, ESP_GATT_PERM_WRITE_ENC_MITM=2; +constexpr int ESP_IO_CAP_OUT=0, ESP_LE_AUTH_REQ_SC_MITM_BOND=15; +struct esp_ble_auth_cmpl_t { bool success=true; int addr_type=0, fail_reason=0; uint8_t bd_addr[6]={1,2,3,4,5,6}; }; +struct esp_ble_gatts_cb_param_t { struct { uint16_t conn_id=7; } connect, mtu; }; +struct esp_ble_bond_dev_t { + uint8_t bd_addr[6]={1,2,3,4,5,6}; + struct { unsigned key_mask=0; struct { uint8_t static_addr[6]={}; int addr_type=0; } pid_key; } bond_key; +}; +class BLEServer; +class BLECharacteristic; +namespace fake_bt { +inline esp_bt_controller_status_t controller=ESP_BT_CONTROLLER_STATUS_IDLE; +inline esp_bluedroid_status_t host=ESP_BLUEDROID_STATUS_UNINITIALIZED; +inline std::string failure, name; +inline bool initialized=false, persisted=true; +inline unsigned servers=0, services=0, characteristics=0, descriptors=0; +inline unsigned bond_deletions=0, disconnects=0, stops=0, uuid_adds=0; +inline uint8_t address[6]={}; +inline uint32_t pin=0; +inline BLEServer* server=nullptr; +} +inline esp_bt_controller_status_t esp_bt_controller_get_status() { return fake_bt::controller; } +inline esp_bluedroid_status_t esp_bluedroid_get_status() { return fake_bt::host; } +inline int esp_bluedroid_disable() { + ++fake_bt::stops; + if(fake_bt::failure=="stop-host") return 1; + fake_bt::host=ESP_BLUEDROID_STATUS_INITIALIZED; return ESP_OK; +} +inline int esp_bluedroid_deinit() { + if(fake_bt::failure=="deinit-host") return 1; + fake_bt::host=ESP_BLUEDROID_STATUS_UNINITIALIZED; return ESP_OK; +} +inline int esp_bt_controller_disable() { + if(fake_bt::failure=="stop-controller") return 1; + fake_bt::controller=ESP_BT_CONTROLLER_STATUS_INITED; return ESP_OK; +} +inline int esp_bt_controller_deinit() { + if(fake_bt::failure=="deinit-controller") return 1; + fake_bt::controller=ESP_BT_CONTROLLER_STATUS_IDLE; return ESP_OK; +} +inline int esp_ble_get_bond_device_num() { return fake_bt::persisted ? 1 : 0; } +inline int esp_ble_get_bond_device_list(int*, esp_ble_bond_dev_t* dest) { *dest=esp_ble_bond_dev_t{}; return ESP_OK; } +inline int esp_ble_remove_bond_device(uint8_t*) { ++fake_bt::bond_deletions; return ESP_OK; } +inline int esp_ble_gap_clear_whitelist() { return ESP_OK; } +inline int esp_ble_gap_update_whitelist(bool, esp_bd_addr_t, esp_ble_wl_addr_type_t) { return ESP_OK; } +class BLESecurityCallbacks { +public: + virtual ~BLESecurityCallbacks()=default; + virtual uint32_t onPassKeyRequest() { return 0; } + virtual void onPassKeyNotify(uint32_t) {} + virtual bool onConfirmPIN(uint32_t) { return false; } + virtual bool onSecurityRequest() { return false; } + virtual void onAuthenticationComplete(esp_ble_auth_cmpl_t) {} +}; +class BLEServerCallbacks { +public: + virtual ~BLEServerCallbacks()=default; + virtual void onConnect(BLEServer*) {} + virtual void onConnect(BLEServer*, esp_ble_gatts_cb_param_t*) {} + virtual void onDisconnect(BLEServer*) {} + virtual void onMtuChanged(BLEServer*, esp_ble_gatts_cb_param_t*) {} +}; +class BLECharacteristicCallbacks { +public: + enum Status { SUCCESS_NOTIFY, ERROR_GATT }; + virtual ~BLECharacteristicCallbacks()=default; + virtual void onWrite(BLECharacteristic*, esp_ble_gatts_cb_param_t*) {} + virtual void onStatus(BLECharacteristic*, Status, uint32_t) {} +}; +class BLESecurity { +public: + void setStaticPIN(uint32_t pin) { fake_bt::pin=pin; } + void setCapability(int) {} + void setAuthenticationMode(int) {} +}; +class BLE2902 { + bool subscribed=false; +public: + BLE2902() { ++fake_bt::descriptors; } + ~BLE2902() { --fake_bt::descriptors; } + void setNotifications(bool v) { subscribed=v; } + bool getNotifications() const { return subscribed; } + void setAccessPermissions(int) {} +}; +class BLEAdvertisementData { public: void setFlags(int) {} }; +class BLEAdvertising { +public: + bool active=false, filtered=false; + void start() { active=true; } + void stop() { active=false; } + void addServiceUUID(const char*) { ++fake_bt::uuid_adds; } + void setDeviceAddress(esp_bd_addr_t addr, int) { memcpy(fake_bt::address,addr,6); } + void setScanResponse(bool) {} + void setScanFilter(bool a,bool b) { filtered=a&&b; } + void setAdvertisementData(BLEAdvertisementData&) {} +}; +class BLECharacteristic { +public: + static constexpr uint32_t PROPERTY_READ=1, PROPERTY_NOTIFY=2, PROPERTY_WRITE=4; + BLECharacteristicCallbacks* callbacks=nullptr; + std::vector value; + BLECharacteristic() { ++fake_bt::characteristics; } + ~BLECharacteristic() { --fake_bt::characteristics; } + void setCallbacks(BLECharacteristicCallbacks* cb) { callbacks=cb; } + void setAccessPermissions(int) {} + void addDescriptor(BLE2902*) {} + void setValue(uint8_t* data,size_t len) { value.assign(data,data+len); } + uint8_t* getData() { return value.data(); } + size_t getLength() { return value.size(); } + void notify() { callbacks->onStatus(this,BLECharacteristicCallbacks::SUCCESS_NOTIFY,0); } +}; +class BLEService { +public: + BLEService() { ++fake_bt::services; } + ~BLEService() { --fake_bt::services; } + unsigned made=0; + BLECharacteristic* createCharacteristic(const char*,uint32_t) { + const char* phase = made++ ? "rx" : "tx"; + return fake_bt::failure==phase ? nullptr : new BLECharacteristic; + } + void start() {} +}; +inline BLEAdvertising* fake_advertising=nullptr; +class BLEServer { +public: + bool connected=false; + BLEServerCallbacks* callbacks=nullptr; + BLEServer() { ++fake_bt::servers; } + ~BLEServer() { + assert(fake_bt::host!=ESP_BLUEDROID_STATUS_ENABLED || fake_bt::server!=this); + --fake_bt::servers; + } + BLEService* createService(const char*) { return fake_bt::failure=="service" ? nullptr : new BLEService; } + BLEAdvertising* getAdvertising() { if(!fake_advertising) fake_advertising=new BLEAdvertising; return fake_advertising; } + void setCallbacks(BLEServerCallbacks* cb) { callbacks=cb; } + unsigned getConnectedCount() const { return connected; } + void disconnect(uint16_t) { ++fake_bt::disconnects; } + uint16_t getConnId() { return 7; } + uint16_t getPeerMTU(uint16_t) { return 179; } +}; +class BLEDevice { +public: + inline static BLESecurityCallbacks* security=nullptr; + static void init(const std::string& name) { + fake_bt::name=name; + if(fake_bt::failure=="init") return; + fake_bt::initialized=true; + fake_bt::host=ESP_BLUEDROID_STATUS_ENABLED; + fake_bt::controller=ESP_BT_CONTROLLER_STATUS_ENABLED; + } + static BLEServer* createServer() { + fake_bt::server=fake_bt::failure=="server" ? nullptr : new BLEServer; + return fake_bt::server; + } +#ifdef TEST_BLE_OWNED_SERVER + static BLEServer* getServer() { return fake_bt::server; } +#endif + static void deinit(bool release) { + assert(!release); // BLE memory release is irreversible until reboot. + if(!fake_bt::initialized) return; +#ifdef TEST_BLE_OWNED_SERVER + assert(fake_bt::host!=ESP_BLUEDROID_STATUS_ENABLED); + delete fake_bt::server; fake_bt::server=nullptr; + delete fake_advertising; fake_advertising=nullptr; +#endif + fake_bt::host=ESP_BLUEDROID_STATUS_UNINITIALIZED; + fake_bt::controller=ESP_BT_CONTROLLER_STATUS_IDLE; + fake_bt::initialized=false; + } + static void setSecurityCallbacks(BLESecurityCallbacks* cb) { security=cb; } + static void setMTU(uint16_t) {} +}; diff --git a/test/fixtures/esp32_ble_power/mocks/BLEServer.h b/test/fixtures/esp32_ble_power/mocks/BLEServer.h new file mode 100644 index 000000000..c34334f7c --- /dev/null +++ b/test/fixtures/esp32_ble_power/mocks/BLEServer.h @@ -0,0 +1,2 @@ +#pragma once +#include "BLEDevice.h" diff --git a/test/fixtures/esp32_ble_power/mocks/BLEUtils.h b/test/fixtures/esp32_ble_power/mocks/BLEUtils.h new file mode 100644 index 000000000..c34334f7c --- /dev/null +++ b/test/fixtures/esp32_ble_power/mocks/BLEUtils.h @@ -0,0 +1,2 @@ +#pragma once +#include "BLEDevice.h" diff --git a/test/fixtures/esp32_ble_power/mocks/esp_bt.h b/test/fixtures/esp32_ble_power/mocks/esp_bt.h new file mode 100644 index 000000000..c34334f7c --- /dev/null +++ b/test/fixtures/esp32_ble_power/mocks/esp_bt.h @@ -0,0 +1,2 @@ +#pragma once +#include "BLEDevice.h" diff --git a/test/fixtures/esp32_ble_power/mocks/esp_bt_main.h b/test/fixtures/esp32_ble_power/mocks/esp_bt_main.h new file mode 100644 index 000000000..c34334f7c --- /dev/null +++ b/test/fixtures/esp32_ble_power/mocks/esp_bt_main.h @@ -0,0 +1,2 @@ +#pragma once +#include "BLEDevice.h" diff --git a/test/fixtures/esp32_ble_power/mocks/esp_gap_ble_api.h b/test/fixtures/esp32_ble_power/mocks/esp_gap_ble_api.h new file mode 100644 index 000000000..c34334f7c --- /dev/null +++ b/test/fixtures/esp32_ble_power/mocks/esp_gap_ble_api.h @@ -0,0 +1,2 @@ +#pragma once +#include "BLEDevice.h" diff --git a/test/fixtures/esp32_ble_power/test_bluedroid_power.cpp b/test/fixtures/esp32_ble_power/test_bluedroid_power.cpp new file mode 100644 index 000000000..d290f681c --- /dev/null +++ b/test/fixtures/esp32_ble_power/test_bluedroid_power.cpp @@ -0,0 +1,81 @@ +#include +#include +using namespace fake_bt; +static const uint8_t custom[]={0xc2,1,2,3,4,5}; +static unsigned suites; +static void retire_global() { + delete server; server=nullptr; + delete fake_advertising; fake_advertising=nullptr; + uuid_adds=0; +} +static void stopped(SerialBLEInterface& a) { + a.disable(); test_millis+=1000; a.servicePowerState(); + assert(!a.isEnabled() && !a.isPowerTransitionPending()); + assert(controller==ESP_BT_CONTROLLER_STATUS_IDLE); + assert(host==ESP_BLUEDROID_STATUS_UNINITIALIZED); + assert(services==0 && characteristics==0 && descriptors==0); +#ifdef TEST_BLE_OWNED_SERVER + assert(servers==0); +#else + assert(servers==1); // Valid static library callback target, retired on next init. +#endif +} +static void good(SerialBLEInterface& a) { + assert(a.isEnabled() && !a.isPowerTransitionPending()); + assert(servers==1 && services==1 && characteristics==2 && descriptors==1); + assert(name=="MeshCore-legacy" && pin==246810); + assert(memcmp(address,custom,6)==0 && fake_advertising->active); +} +static void repeated_cycles() { + SerialBLEInterface a; assert(a.begin("MeshCore-","legacy",246810,custom)); + // Test initial saved-off as well as all subsequent live toggles. + stopped(a); + for(unsigned i=0;i<100;++i) { + a.enable(); good(a); assert(bond_deletions==0); stopped(a); +#ifndef TEST_BLE_OWNED_SERVER + assert(uuid_adds==1); // Reused static advertiser must not grow forever. +#endif + } + retire_global(); ++suites; +} +static void partial_stop_failures_and_late_callbacks() { + for(const char* fault : {"stop-host","deinit-host","stop-controller","deinit-controller"}) { + SerialBLEInterface a; assert(a.begin("MeshCore-","legacy",246810,custom)); a.enable(); good(a); + auto* original=server; original->connected=true; + esp_ble_gatts_cb_param_t connection; + original->callbacks->onConnect(original,&connection); + BLEDevice::security->onAuthenticationComplete(esp_ble_auth_cmpl_t{}); + assert(a.isConnected()); + a.disable(); assert(!a.isEnabled() && a.isPowerTransitionPending()); + assert(server==original && services==1 && characteristics==2); + original->connected=false; + failure=fault; test_millis+=1000; a.servicePowerState(); + assert(a.isPowerTransitionPending() && server==original && services==1); + esp_ble_auth_cmpl_t failed; failed.success=false; + BLEDevice::security->onAuthenticationComplete(failed); + assert(bond_deletions==0); + a.enable(); assert(!a.isEnabled() && a.isPowerTransitionPending()); + failure.clear(); test_millis+=1000; a.servicePowerState(); good(a); stopped(a); retire_global(); + } + ++suites; +} +static void startup_failures() { + for(const char* fault : {"init","server","service","tx","rx"}) { + SerialBLEInterface a; assert(a.begin("MeshCore-","legacy",246810,custom)); a.enable(); stopped(a); + failure=fault; a.enable(); + assert(!a.isEnabled() && !a.isConnected()); a.servicePowerState(); + assert(!a.isPowerTransitionPending()); + assert(services==0 && characteristics==0 && descriptors==0); + failure.clear(); a.enable(); good(a); stopped(a); retire_global(); + } + ++suites; +} +int main() { + repeated_cycles(); partial_stop_failures_and_late_callbacks(); startup_failures(); + // The legacy library deliberately retains its static server/advertiser + // until replacement. Release the final objects after the host has stopped. + delete server; server=nullptr; + delete fake_advertising; fake_advertising=nullptr; + assert(servers==0 && services==0 && characteristics==0 && descriptors==0); + printf("%u lifecycle suites passed\n",suites); +} diff --git a/test/fixtures/esp32_ble_power/test_nimble_power.cpp b/test/fixtures/esp32_ble_power/test_nimble_power.cpp new file mode 100644 index 000000000..3ba1bb6f0 --- /dev/null +++ b/test/fixtures/esp32_ble_power/test_nimble_power.cpp @@ -0,0 +1,176 @@ +#include +#include +#include +#include +using namespace fake_nimble; +using mesh::companion::BluetoothPeerIdentity; +static const uint8_t custom[] = {0xc2, 1, 2, 3, 4, 5}; +static unsigned suites; + +static void reset() { + failure.clear(); + NimBLEDevice::deinit(true); + calls.clear(); transmitted.clear(); connection_updates.clear(); + stops = deleted_servers = bond_deletions = disconnects = 0; + persisted = accept_notification = true; + test_millis = 100; +} +static void stop(SerialBLEInterface& adapter) { + adapter.disable(); + test_millis += 1000; + adapter.servicePowerState(); + assert(!adapter.isEnabled() && !adapter.isPowerTransitionPending()); + assert(!initialized && !NimBLEDevice::server); +} +static void connect(SerialBLEInterface& adapter, NimBLEConnInfo& info) { + auto* server = NimBLEDevice::server.get(); + server->connected = true; + server->callbacks->onConnect(server, info); + server->callbacks->onAuthenticationComplete(info); + assert(adapter.isConnected()); + uint8_t rx[MAX_FRAME_SIZE]; adapter.checkRecvFrame(rx); +} +static void verify(SerialBLEInterface& adapter) { + auto* server = NimBLEDevice::server.get(); + assert(adapter.isEnabled() && !adapter.isPowerTransitionPending()); + assert(initialized_name == "MeshCore-fixed"); + assert(server && server->advertising.active); + assert(server->advertising.name == "MeshCore-fixed"); + assert(server->advertising.service == "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"); + assert(server->displayPairingPasskey() == 246810); + assert(bonding && mitm && sc && mtu == MAX_FRAME_SIZE + 3); + assert(own_type == BLE_OWN_ADDR_RANDOM); + for (unsigned i=0; i<6; ++i) assert(address[i] == custom[5-i]); + assert(server->service->characteristics.size() == 2); + assert(server->service->characteristics[0]->properties & NIMBLE_PROPERTY::READ_AUTHEN); + assert(server->service->characteristics[1]->properties & NIMBLE_PROPERTY::WRITE_AUTHEN); +} +static void initial_saved_off() { + reset(); SerialBLEInterface a; + char prefix[] = "MeshCore-", name[] = "fixed"; + uint8_t address_copy[6]; memcpy(address_copy, custom, 6); + assert(a.begin(prefix, name, 246810, address_copy)); + assert(!a.isEnabled() && a.isPowerTransitionPending()); + a.servicePowerState(); + assert(!initialized && !a.isPowerTransitionPending()); + memset(prefix, 'x', sizeof(prefix)-1); memset(name, 'z', sizeof(name)-1); + memset(address_copy, 0, sizeof(address_copy)); + a.enable(); verify(a); stop(a); ++suites; +} +static void repeated_cycles_preserve_identity_bonds_and_gatt() { + reset(); SerialBLEInterface a; + assert(a.begin("MeshCore-", "fixed", 246810, custom, true)); + for (unsigned i=0; i<100; ++i) { + a.enable(); verify(a); stop(a); + assert(bond_deletions == 1 && persisted); + assert(deleted_servers == i+1); + } + ++suites; +} +static void deferred_disconnect_and_quick_on() { + reset(); SerialBLEInterface a; + assert(a.begin("MeshCore-", "fixed", 246810, custom)); a.enable(); + NimBLEConnInfo info; connect(a, info); + auto* original = NimBLEDevice::server.get(); + auto* tx = original->service->characteristics[0].get(); + auto* rx = original->service->characteristics[1].get(); + tx->callbacks->onSubscribe(tx, info, 1); + const uint8_t reply[] = {29, 'O', 'K'}; + assert(a.writeFrame(reply, sizeof(reply)) == sizeof(reply)); + test_millis += 61; uint8_t frame[MAX_FRAME_SIZE]; + a.checkRecvFrame(frame); // Existing main drains its off-command reply first. + assert(transmitted.size() == 1 && !a.hasPendingIO()); + a.disable(); + assert(!a.isEnabled() && a.isPowerTransitionPending()); + assert(initialized && NimBLEDevice::server.get() == original && stops == 0); + assert(disconnects == 1 && !original->advertising.active); + // Late authentication failure during intentional disconnect must not erase + // a good saved bond. Late writes/subscriptions cannot repopulate the queue. + info.authenticated = false; + original->callbacks->onAuthenticationComplete(info); + rx->setValue(reply, sizeof(reply)); rx->callbacks->onWrite(rx, info); + tx->callbacks->onSubscribe(tx, info, 1); + original->callbacks->onDisconnect(original, info, 0); + assert(bond_deletions == 0 && !a.hasPendingIO()); + a.enable(); + assert(!a.isEnabled() && a.isPowerTransitionPending()); + original->connected = false; + test_millis += 999; a.servicePowerState(); + assert(NimBLEDevice::server.get() == original && stops == 0); + ++test_millis; a.servicePowerState(); verify(a); + assert(deleted_servers == 1 && bond_deletions == 0); + stop(a); ++suites; +} +static void failed_stop_keeps_callback_objects_alive() { + reset(); SerialBLEInterface a; + assert(a.begin("MeshCore-", "fixed", 246810, custom)); a.enable(); + auto* original = NimBLEDevice::server.get(); + failure = "stop-host"; a.disable(); + assert(!a.isEnabled() && a.isPowerTransitionPending()); + assert(initialized && NimBLEDevice::server.get() == original && deleted_servers == 0); + unsigned attempts = stops; + for (unsigned i=0; i<100; ++i) a.servicePowerState(); + assert(stops == attempts); // No tight retry loop or premature deletion. + a.enable(); assert(!a.isEnabled() && a.isPowerTransitionPending()); + failure.clear(); test_millis += 1000; a.servicePowerState(); verify(a); + assert(bond_deletions == 0); stop(a); ++suites; +} +static void ignored_controller_teardown_failure_is_retried() { + for (const char* fault : {"stop-controller", "deinit-controller"}) { + reset(); SerialBLEInterface a; + assert(a.begin("MeshCore-", "fixed", 246810, custom)); a.enable(); + auto* original = NimBLEDevice::server.get(); + failure = fault; a.disable(); + // Host says it stopped, but its wrapper silently ignored a live + // controller. Objects and the sleep gate must remain intact. + assert(!initialized && controller_state != 0); + assert(!a.isEnabled() && a.isPowerTransitionPending()); + assert(NimBLEDevice::server.get() == original && deleted_servers == 0); + a.enable(); assert(!a.isEnabled() && a.isPowerTransitionPending()); + failure.clear(); test_millis += 1000; a.servicePowerState(); verify(a); + assert(deleted_servers == 1 && bond_deletions == 0); + stop(a); + } + ++suites; +} +static void startup_failures_remain_off_and_recover() { + for (const char* fault : {"init", "address", "address-type", "server", "service", "tx", "rx", "start-service", "advertise"}) { + reset(); SerialBLEInterface a; + assert(a.begin("MeshCore-", "fixed", 246810, custom)); a.enable(); stop(a); + failure = fault; a.enable(); + assert(!a.isEnabled() && !a.isConnected()); + a.servicePowerState(); + assert(!initialized && !a.isPowerTransitionPending()); + assert(bond_deletions == 0); + failure.clear(); a.enable(); verify(a); stop(a); + } + ++suites; +} +static void stealth_and_recovery_never_reopen_general_advertising() { + reset(); SerialBLEInterface a; + BluetoothPeerIdentity peer; peer.type = mesh::companion::BLUETOOTH_PEER_ADDRESS_PUBLIC; + const uint8_t known[] = {1, 2, 3, 4, 5, 6}; memcpy(peer.address, known, 6); + assert(a.begin("MeshCore-", "fixed", 246810, custom, false, false, &peer)); + for (unsigned i=0; i<3; ++i) { + a.enable(); auto& adv = NimBLEDevice::server->advertising; + assert(adv.active && adv.name.empty() && adv.service.empty()); + assert(adv.scan_filter && adv.connect_filter && !adv.scan_response); + for (unsigned j=0; j<6; ++j) assert(allowlisted.val[j] == known[5-j]); + stop(a); + } + // A missing stored bond must remain suppressed, including after the + // application consumes the recovery event and toggles Bluetooth again. + persisted = false; a.enable(); + assert(a.takeBondedOnlyRecovery()); + assert(!NimBLEDevice::server->advertising.active); stop(a); + persisted = true; a.enable(); + assert(!NimBLEDevice::server->advertising.active); stop(a); ++suites; +} +int main() { + initial_saved_off(); repeated_cycles_preserve_identity_bonds_and_gatt(); + deferred_disconnect_and_quick_on(); failed_stop_keeps_callback_objects_alive(); + ignored_controller_teardown_failure_is_retried(); + startup_failures_remain_off_and_recover(); + stealth_and_recovery_never_reopen_general_advertising(); + printf("%u lifecycle suites passed\n", suites); +} diff --git a/test/fixtures/nimble_companion/mocks/NimBLEDevice.h b/test/fixtures/nimble_companion/mocks/NimBLEDevice.h index bc4a72d96..45e6fe4a4 100644 --- a/test/fixtures/nimble_companion/mocks/NimBLEDevice.h +++ b/test/fixtures/nimble_companion/mocks/NimBLEDevice.h @@ -51,8 +51,10 @@ struct NimBLEAdvertisementData { bool setFlags(uint8_t value) { flags = value; return true; } }; namespace fake_nimble { +inline int controller_state = 0; inline bool initialized = false, persisted = true, accept_notification = true; -inline std::string failure; +inline std::string failure, initialized_name; +inline unsigned stops = 0, deleted_servers = 0, bond_deletions = 0; inline std::vector calls; inline uint8_t address[6] = {}, own_type = BLE_OWN_ADDR_PUBLIC, io_cap = 255; inline ble_addr_t allowlisted; @@ -76,7 +78,8 @@ struct NimBLEAdvertising { bool active = false, scan_response = false, scan_filter = false, connect_filter = false; std::string name, service; bool start(uint32_t = 0, const NimBLEAddress* = nullptr) { - fake_nimble::calls.push_back("advertise"); active = true; return true; + fake_nimble::calls.push_back("advertise"); + active = fake_nimble::failure != "advertise"; return active; } bool stop() { active = false; return true; } void enableScanResponse(bool value) { scan_response = value; } @@ -115,7 +118,11 @@ struct NimBLEServer { std::unique_ptr service; NimBLEServerCallbacks* callbacks = nullptr; bool owns_callbacks = true, auto_advertise = true, connected = false; - ~NimBLEServer() { if (owns_callbacks) delete callbacks; } + ~NimBLEServer() { + ++fake_nimble::deleted_servers; + assert(!fake_nimble::initialized); // Stop must precede destruction. + if (owns_callbacks) delete callbacks; + } void setCallbacks(NimBLEServerCallbacks* cb, bool take_ownership = true) { callbacks = cb; owns_callbacks = take_ownership; } @@ -126,7 +133,7 @@ struct NimBLEServer { return fake_nimble::pin == 123456 ? callbacks->onPassKeyDisplay() : fake_nimble::pin; } - bool start() { return true; } + bool start() { return fake_nimble::failure != "start-service"; } uint8_t getConnectedCount() const { return connected; } uint16_t getPeerMTU(uint16_t) const { return fake_nimble::mtu; } void updateConnParams(uint16_t handle, uint16_t minimum, uint16_t maximum, @@ -142,17 +149,28 @@ struct NimBLEServer { }; struct NimBLEDevice { inline static std::unique_ptr server; - static bool init(const std::string&) { + static bool init(const std::string& name) { + fake_nimble::initialized_name = name; fake_nimble::calls.push_back("init"); - return fake_nimble::initialized = fake_nimble::failure != "init"; + fake_nimble::initialized = fake_nimble::failure != "init"; + fake_nimble::controller_state = fake_nimble::initialized ? 2 : 0; + return fake_nimble::initialized; } static bool deinit(bool clear = false) { + ++fake_nimble::stops; + if (fake_nimble::failure == "stop-host") return false; + if (fake_nimble::initialized) { + // The real NimBLE wrapper ignores its port's controller teardown error. + if (fake_nimble::failure == "stop-controller") fake_nimble::controller_state = 2; + else if (fake_nimble::failure == "deinit-controller") fake_nimble::controller_state = 1; + else fake_nimble::controller_state = 0; + } fake_nimble::initialized = false; if (clear) server.reset(); return true; } - static bool deleteAllBonds() { fake_nimble::calls.push_back("clear-bonds"); return fake_nimble::failure != "clear-bonds"; } - static bool deleteBond(const NimBLEAddress&) { return true; } + static bool deleteAllBonds() { ++fake_nimble::bond_deletions; fake_nimble::calls.push_back("clear-bonds"); return fake_nimble::failure != "clear-bonds"; } + static bool deleteBond(const NimBLEAddress&) { ++fake_nimble::bond_deletions; return true; } static bool isBonded(const NimBLEAddress&) { return fake_nimble::persisted; } static bool setOwnAddr(const uint8_t* address) { fake_nimble::calls.push_back("address"); diff --git a/test/fixtures/nimble_companion/mocks/esp_bt.h b/test/fixtures/nimble_companion/mocks/esp_bt.h new file mode 100644 index 000000000..084da060f --- /dev/null +++ b/test/fixtures/nimble_companion/mocks/esp_bt.h @@ -0,0 +1,22 @@ +#pragma once +#include +using esp_err_t = int; +constexpr esp_err_t ESP_OK = 0; +enum esp_bt_controller_status_t { + ESP_BT_CONTROLLER_STATUS_IDLE, + ESP_BT_CONTROLLER_STATUS_INITED, + ESP_BT_CONTROLLER_STATUS_ENABLED +}; +inline esp_bt_controller_status_t esp_bt_controller_get_status() { + return static_cast(fake_nimble::controller_state); +} +inline esp_err_t esp_bt_controller_disable() { + if (fake_nimble::failure == "stop-controller") return 1; + fake_nimble::controller_state = ESP_BT_CONTROLLER_STATUS_INITED; + return ESP_OK; +} +inline esp_err_t esp_bt_controller_deinit() { + if (fake_nimble::failure == "deinit-controller") return 1; + fake_nimble::controller_state = ESP_BT_CONTROLLER_STATUS_IDLE; + return ESP_OK; +} diff --git a/test/test_esp32_ble_power.py b/test/test_esp32_ble_power.py new file mode 100644 index 000000000..dbe0a2723 --- /dev/null +++ b/test/test_esp32_ble_power.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Run the actual ESP32 BLE adapter through reversible stack lifecycles.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class Esp32BlePowerTests(unittest.TestCase): + def test_nimble_reversible_power_faults_identity_bonds_and_callbacks(self): + self.run_adapter("test_nimble_power.cpp", ["-DMESH_USE_NIMBLE_ARDUINO=1"], + ROOT / "test/fixtures/nimble_companion/mocks") + + def test_bluedroid_legacy_static_ownership_and_partial_stop_failures(self): + self.run_adapter("test_bluedroid_power.cpp", ["-DCONFIG_BLUEDROID_ENABLED=1"], + ROOT / "test/fixtures/esp32_ble_power/mocks") + + def test_bluedroid_new_library_ownership_and_partial_stop_failures(self): + self.run_adapter("test_bluedroid_power.cpp", ["-DCONFIG_BLUEDROID_ENABLED=1", + "-DTEST_BLE_OWNED_SERVER=1"], + ROOT / "test/fixtures/esp32_ble_power/mocks") + + def run_adapter(self, fixture, flags, mocks): + with tempfile.TemporaryDirectory(prefix="meshcore-ble-power-") as temp: + binary = Path(temp) / "ble-power" + result = subprocess.run([ + "c++", "-std=c++17", "-g", "-fsanitize=address,undefined", *flags, + "-I", str(mocks), + "-I", str(ROOT / "test/fixtures/nimble_companion/mocks"), + "-I", str(ROOT / "src"), + str(ROOT / "src/helpers/esp32/SerialBLEInterface.cpp"), + str(ROOT / "test/fixtures/esp32_ble_power" / fixture), + "-o", str(binary), + ], text=True, capture_output=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], text=True, capture_output=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("lifecycle suites passed", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_esp32_full_sleep.py b/test/test_esp32_full_sleep.py index 4abb11b06..513bb8451 100644 --- a/test/test_esp32_full_sleep.py +++ b/test/test_esp32_full_sleep.py @@ -27,6 +27,9 @@ def run_cpp(source, defines=()): cpp = Path(directory) / "test.cpp" exe = Path(directory) / "test" (Path(directory) / "Dispatcher.h").write_text(MORSE_RADIO, encoding="ascii") + (Path(directory) / "esp_timer.h").write_text("#pragma once\n#include \nint64_t esp_timer_get_time();\n") + for header in ("esp_wifi.h", "esp_bt.h", "esp_bt_main.h"): + (Path(directory) / header).write_text("#pragma once\n") cpp.write_text(source) built = subprocess.run( [os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", @@ -105,18 +108,25 @@ int main() { static uint32_t now = 1000; uint32_t millis() { return now; } uint32_t micros() { return now * 1000U; } +int64_t esp_timer_get_time() { return int64_t(now) * 1000; } using esp_err_t = int; enum wifi_mode_t { WIFI_MODE_NULL, WIFI_MODE_STA, WIFI_MODE_AP, WIFI_MODE_APSTA }; constexpr int ESP_OK = 0, ESP_ERR_WIFI_NOT_INIT = 1, ESP_ERR_WIFI_NOT_STARTED = 2; static wifi_mode_t sdk_mode = WIFI_MODE_NULL; static int sdk_result = ESP_OK, sleeps = 0, yields = 0; -constexpr int ESP_BT_CONTROLLER_STATUS_ENABLED = 1; +using esp_bt_controller_status_t = int; +constexpr int ESP_BT_CONTROLLER_STATUS_IDLE = 0, ESP_BT_CONTROLLER_STATUS_INITED = 1, + ESP_BT_CONTROLLER_STATUS_ENABLED = 2; static bool bt_controller_active = false; -int esp_bt_controller_get_status() { return bt_controller_active ? 1 : 0; } -constexpr int ESP_BLUEDROID_STATUS_ENABLED = 1; +int esp_bt_controller_get_status() { return bt_controller_active ? 2 : 0; } +using esp_bluedroid_status_t = int; +constexpr int ESP_BLUEDROID_STATUS_UNINITIALIZED = 0, ESP_BLUEDROID_STATUS_INITIALIZED = 1, + ESP_BLUEDROID_STATUS_ENABLED = 2; static bool bt_host_active = false; -int esp_bluedroid_get_status() { return bt_host_active ? 1 : 0; } +int esp_bluedroid_get_status() { return bt_host_active ? 2 : 0; } int esp_wifi_get_mode(wifi_mode_t* mode) { *mode = sdk_mode; return sdk_result; } +#include +#include int esp_sleep_enable_timer_wakeup(uint64_t us) { assert(us == 10000); return ESP_OK; } int esp_light_sleep_start() { ++sleeps; return ESP_OK; } int pdMS_TO_TICKS(int ms) { return ms; } @@ -129,6 +139,7 @@ struct Board { @USB_HELD@ bool isOTAUpdateRunning() const { return ota; } bool isRadioTestActive() const { return test; } + void sleepUntilMicroseconds(uint32_t) { ++sleeps; } } board; struct Prefs { bool powersaving_enabled = true; } prefs; struct TestRadio : mesh::Radio { @@ -146,10 +157,12 @@ struct Mesh { } bool isDualRadioActive() const { return dual; } bool canIdleDualRadio() const { return !dual; } + bool getDualRadioIdleDelay(uint32_t& out) const { out = 0; return false; } bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; } mesh::Radio* getProfileRadio() { return &timing_radio; } } the_mesh; struct Interfaces { bool pending = false; bool hasPendingIO() const { return pending; } } interface_manager; +struct Bluetooth { bool pending = false; bool isPowerTransitionPending() const { return pending; } } bluetooth_interface; struct Sensors { bool gps_uart = false; bool gpsUsesSerialUart(uint8_t uart) const { return uart == 1 && gps_uart; } } sensors; struct Button { bool polling = false; bool needsPolling() const { return polling; } } user_btn; struct Wireless { unsigned services = 0; unsigned enabled() const { return services; } } companion_wireless; @@ -202,7 +215,7 @@ int main() { blocks(board.ota); blocks(board.test); blocks(the_mesh.pending); blocks(interface_manager.pending); blocks(user_btn.polling); blocks(mesh::logging); blocks(bt_controller_active); - blocks(bt_host_active); blocks(sensors.gps_uart); + blocks(bt_host_active); blocks(sensors.gps_uart); blocks(bluetooth_interface.pending); blocks(mesh::watchdog); blocks(mesh::wireless::controller.transition); prefs.powersaving_enabled = false; before = sleeps; idle(); assert(sleeps == before); prefs.powersaving_enabled = true; idle(); assert(sleeps == before + 1); @@ -263,7 +276,7 @@ int main() { source = "#include \n" + source flags = ("ESP32_PLATFORM", "ESP32", "ENABLE_USB_INTERFACE", "ARDUINO_USB_CDC_ON_BOOT=1", 'WIFI_SSID=""', "BLE_PIN_CODE=123456", "MESH_USB_LOGGING_AVAILABLE=1", - "CONFIG_BLUEDROID_ENABLED=1", + "CONFIG_BLUEDROID_ENABLED=1", "CONFIG_BT_ENABLED=1", "MESH_ESP32_USB_CONSOLE_COOPERATIVE=1", "MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS=120000UL", "MOMENTARY_BUTTON_WAKE_FROM_SLEEP=1", "PIN_USER_BTN=0", "DISPLAY_CLASS=Mock") for pm in (0, 1): diff --git a/test/test_esp32_gpio_hold.py b/test/test_esp32_gpio_hold.py index cd1589e71..3ce05ac46 100644 --- a/test/test_esp32_gpio_hold.py +++ b/test/test_esp32_gpio_hold.py @@ -301,7 +301,9 @@ int main(){FEMBoard board; board.enterDeepSleep(5); assert(slept);} 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)')) + 'void sleepMicroseconds(uint64_t wake_us, bool require_timer = false)', + 'void sleepUntilMicroseconds(uint32_t deadline_us)', + 'void sleepMicrosecondsImpl(uint64_t wake_us, bool require_timer,')) self.assertNotIn('commitTerminalDeepSleepHolds',light) for folder in (ROOT/'src',ROOT/'variants'): for path in folder.rglob('*'): diff --git a/test/test_esp32_light_sleep_policy.py b/test/test_esp32_light_sleep_policy.py new file mode 100644 index 000000000..3fc7b8dfb --- /dev/null +++ b/test/test_esp32_light_sleep_policy.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Compile the real manual-sleep driver guard for WiFi/BT SDK configurations.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + +WIFI = r''' +#pragma once +using esp_err_t = int; +constexpr int ESP_OK = 0, ESP_ERR_WIFI_NOT_INIT = 1, ESP_ERR_WIFI_NOT_STARTED = 2; +enum wifi_mode_t { WIFI_MODE_NULL, WIFI_MODE_STA, WIFI_MODE_AP, WIFI_MODE_APSTA }; +static wifi_mode_t mode = WIFI_MODE_NULL; +static int wifi_error = ESP_OK; +inline int esp_wifi_get_mode(wifi_mode_t* out) { *out = mode; return wifi_error; } +''' +CONTROLLER = r''' +#pragma once +enum esp_bt_controller_status_t { ESP_BT_CONTROLLER_STATUS_IDLE, + ESP_BT_CONTROLLER_STATUS_INITED, ESP_BT_CONTROLLER_STATUS_ENABLED }; +static auto controller = ESP_BT_CONTROLLER_STATUS_IDLE; +inline esp_bt_controller_status_t esp_bt_controller_get_status() { return controller; } +''' +HOST = r''' +#pragma once +enum esp_bluedroid_status_t { ESP_BLUEDROID_STATUS_UNINITIALIZED, + ESP_BLUEDROID_STATUS_INITIALIZED, ESP_BLUEDROID_STATUS_ENABLED }; +static auto host = ESP_BLUEDROID_STATUS_UNINITIALIZED; +inline esp_bluedroid_status_t esp_bluedroid_get_status() { return host; } +''' +HARNESS = r''' +#include +#include +using mesh::powerdiag::Reason; +using mesh::esp32::manualLightSleepBlocker; +using mesh::esp32::manualLightSleepReady; +int main() { + assert(manualLightSleepReady()); + for (auto value : {WIFI_MODE_STA, WIFI_MODE_AP, WIFI_MODE_APSTA}) { + mode = value; + assert(manualLightSleepBlocker() == Reason::Network); + } + mode = WIFI_MODE_NULL; + for (int error : {ESP_ERR_WIFI_NOT_INIT, ESP_ERR_WIFI_NOT_STARTED}) { + wifi_error = error; + assert(manualLightSleepReady()); + } + wifi_error = -1; + assert(manualLightSleepBlocker() == Reason::Network); + wifi_error = ESP_OK; +#if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED + controller = ESP_BT_CONTROLLER_STATUS_INITED; + assert(manualLightSleepReady()); + controller = ESP_BT_CONTROLLER_STATUS_ENABLED; + assert(manualLightSleepBlocker() == Reason::Wireless); + controller = static_cast(99); + assert(manualLightSleepBlocker() == Reason::Wireless); + controller = ESP_BT_CONTROLLER_STATUS_IDLE; +#if defined(CONFIG_BLUEDROID_ENABLED) && CONFIG_BLUEDROID_ENABLED \ + && !defined(MESH_USE_NIMBLE_ARDUINO) + host = ESP_BLUEDROID_STATUS_INITIALIZED; + assert(manualLightSleepReady()); + host = ESP_BLUEDROID_STATUS_ENABLED; + assert(manualLightSleepBlocker() == Reason::Wireless); + host = static_cast(99); + assert(manualLightSleepBlocker() == Reason::Wireless); + host = ESP_BLUEDROID_STATUS_UNINITIALIZED; +#endif +#endif + assert(manualLightSleepReady()); +} +''' + + +class LightSleepPolicyTest(unittest.TestCase): + def test_real_guard_across_sdk_and_host_modes(self): + for flags in ( + (), ("CONFIG_BT_ENABLED=0", "CONFIG_IDF_TARGET_ESP32S2=1"), + ("CONFIG_BT_ENABLED=1", "CONFIG_BLUEDROID_ENABLED=1", "CONFIG_IDF_TARGET_ESP32=1"), + ("CONFIG_BT_ENABLED=1", "CONFIG_BLUEDROID_ENABLED=1", "CONFIG_IDF_TARGET_ESP32S3=1"), + ("CONFIG_BT_ENABLED=1", "CONFIG_BLUEDROID_ENABLED=0", "CONFIG_IDF_TARGET_ESP32C3=1"), + ("CONFIG_BT_ENABLED=1", "CONFIG_BLUEDROID_ENABLED=1", "MESH_USE_NIMBLE_ARDUINO=1", "CONFIG_IDF_TARGET_ESP32C6=1"), + ): + with self.subTest(flags=flags), tempfile.TemporaryDirectory(prefix="mesh-esp32-sleep-driver-") as directory: + root = Path(directory) + (root / "esp_wifi.h").write_text(WIFI) + bt = "CONFIG_BT_ENABLED=1" in flags + bluedroid = bt and "CONFIG_BLUEDROID_ENABLED=1" in flags and "MESH_USE_NIMBLE_ARDUINO=1" not in flags + (root / "esp_bt.h").write_text(CONTROLLER if bt else '#error "Bluetooth absent on this target"\n') + (root / "esp_bt_main.h").write_text(HOST if bluedroid else '#error "Bluedroid absent from this host build"\n') + cpp, exe = root / "guard.cpp", root / "guard" + cpp.write_text("#include \n" + HARNESS) + result = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + *("-D" + flag for flag in flags), "-I", str(root), "-I", str(ROOT / "src"), + str(cpp), "-o", str(exe)], text=True, capture_output=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(exe)], text=True, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_esp32_profile_idle_sleep.py b/test/test_esp32_profile_idle_sleep.py new file mode 100644 index 000000000..3e50352ec --- /dev/null +++ b/test/test_esp32_profile_idle_sleep.py @@ -0,0 +1,97 @@ +"""Execute the production profile sleep handoff with elapsed and wrapping clocks.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class Esp32ProfileIdleSleepTest(unittest.TestCase): + def test_absolute_role_and_radio_deadlines_survive_preemption(self): + source = r''' +#include +#include +#include +static uint64_t now_us = 0; +int64_t esp_timer_get_time() { return static_cast(now_us); } +struct Mesh { + bool ready = true; + uint32_t budget_ms = 10, query_cost_us = 0; + unsigned reads = 0; + bool getDualRadioIdleDelay(uint32_t& out) { + ++reads; + out = budget_ms; + now_us += query_cost_us; + return ready; + } +} mesh_node; +struct Board { + unsigned calls = 0; + uint32_t deadline = 0; + void sleepUntilMicroseconds(uint32_t value) { ++calls; deadline = value; } +} board; +int main() { + for (uint64_t start : {100000ULL, 0xfffffc00ULL, 0x1fffffc00ULL}) { + now_us = start; + mesh_node = {}; + board = {}; + assert(mesh::esp32::tryProfileIdleSleep(mesh_node, board, uint32_t(start), 30)); + assert(board.calls == 1 && board.deadline == uint32_t(start + 10000)); + + // A role deadline calculated before other loop work is never restarted. + now_us = start + 4000; + assert(mesh::esp32::tryProfileIdleSleep(mesh_node, board, uint32_t(start), 7)); + assert(board.deadline == uint32_t(start + 7000)); + now_us = start + 5001; + const unsigned before = board.calls; + assert(!mesh::esp32::tryProfileIdleSleep(mesh_node, board, uint32_t(start), 7)); + assert(board.calls == before); + now_us = start + 8000; + assert(!mesh::esp32::tryProfileIdleSleep(mesh_node, board, uint32_t(start), 7)); + + // An IRQ/task delay in the scanner query reduces the board's remaining + // time instead of moving the absolute deadline forward by that delay. + now_us = start; + mesh_node.query_cost_us = 6000; + assert(mesh::esp32::tryProfileIdleSleep(mesh_node, board, uint32_t(start), 30)); + assert(now_us == start + 6000 && board.deadline == uint32_t(start + 10000)); + mesh_node.query_cost_us = 0; + mesh_node.budget_ms = 2; + now_us = start; + assert(mesh::esp32::tryProfileIdleSleep(mesh_node, board, uint32_t(start), 30)); + assert(board.deadline == uint32_t(start + 2000)); + } + now_us = 100000; + for (uint32_t budget : {0U, 1U, 11U, 0xffffffffU}) { + mesh_node = {}; + mesh_node.budget_ms = budget; + const unsigned before = board.calls; + assert(!mesh::esp32::tryProfileIdleSleep(mesh_node, board, 100000, 30)); + assert(board.calls == before); + } + mesh_node = {}; + mesh_node.ready = false; + assert(!mesh::esp32::tryProfileIdleSleep(mesh_node, board, 100000, 30)); + mesh_node = {}; + const unsigned reads = mesh_node.reads; + assert(!mesh::esp32::tryProfileIdleSleep(mesh_node, board, 100000, 0)); + assert(!mesh::esp32::tryProfileIdleSleep(mesh_node, board, 100000, 0xffffffffU)); + assert(mesh_node.reads == reads); +} +''' + with tempfile.TemporaryDirectory(prefix="mesh-esp32-profile-handoff-") as directory: + path = Path(directory) + (path / "esp_timer.h").write_text("#pragma once\n#include \nint64_t esp_timer_get_time();\n") + (path / "test.cpp").write_text("#include \n" + source) + built = subprocess.run([os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-I", str(path), "-I", str(ROOT / "src"), str(path / "test.cpp"), + "-o", str(path / "test")], capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(path / "test")], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py index f709408d4..254197691 100644 --- a/test/test_esp32_usb_sleep.py +++ b/test/test_esp32_usb_sleep.py @@ -58,6 +58,10 @@ static void attachHost(bool attached) { namespace mesh { static bool logging_enabled = false; bool isUsbLoggingEnabled() { return logging_enabled; } +namespace esp32 { +static powerdiag::Reason wireless_blocker = powerdiag::Reason::Count; +powerdiag::Reason manualLightSleepBlocker() { return wireless_blocker; } +} } using gpio_num_t = int; @@ -71,6 +75,9 @@ constexpr int GPIO_INTR_DISABLE = 0; static unsigned sleep_calls = 0; static uint64_t timer_us = 0; static int timer_error = ESP_OK; +static int radio_wake_error = ESP_OK, button_wake_error = ESP_OK, gpio_wake_error = ESP_OK; +static unsigned gpio_setup_us = 0; +static bool irq_during_setup = false; static bool radio_irq_high = false; static int critical_depth = 0; static int wake_pin = -1; @@ -98,11 +105,17 @@ static int esp_light_sleep_start() { } return ESP_OK; } -static int esp_sleep_enable_gpio_wakeup() { return ESP_OK; } +static int esp_sleep_enable_gpio_wakeup() { + mock_millis += gpio_setup_us / 1000U; + if (irq_during_setup) radio_irq_high = true; + return gpio_wake_error; +} static int gpio_get_level(gpio_num_t pin) { return pin == 38 ? (button_pressed ? LOW : HIGH) : (radio_irq_high ? HIGH : LOW); } static int gpio_wakeup_enable(gpio_num_t pin, int level) { + if (pin == 48 && radio_wake_error != ESP_OK) return radio_wake_error; + if (pin == 38 && button_wake_error != ESP_OK) return button_wake_error; wake_pin = pin; wake_levels.at(pin) = level; return ESP_OK; @@ -267,7 +280,9 @@ 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)")) + "void sleepMicroseconds(uint64_t wake_us, bool require_timer = false)", + "void sleepUntilMicroseconds(uint32_t deadline_us)", + "void sleepMicrosecondsImpl(uint64_t wake_us, bool require_timer,")) def v4_profile_config(): @@ -345,6 +360,9 @@ struct SerialPort { } Serial; namespace mesh { bool isUsbLoggingEnabled() { return false; } +namespace esp32 { +powerdiag::Reason manualLightSleepBlocker() { return powerdiag::Reason::Count; } +} struct Console { void printf(const char*) {} }; static Console console; Console& usbConsolePort() { return console; } @@ -502,6 +520,101 @@ int main() { class Esp32UsbSleepTest(unittest.TestCase): + def test_physical_wireless_vetoes_and_deadline_gpio_failures(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; + attachHost(false); + Serial.end(); + for (auto reason : {mesh::powerdiag::Reason::Network, + mesh::powerdiag::Reason::Wireless}) { + mesh::esp32::wireless_blocker = reason; + mesh::powerdiag::setEnabled(true, millis()); + board.sleepMilliseconds(10); + require(sleep_calls == 0 && timer_us == 0 && critical_depth == 0, + "physical wireless veto configured wake or slept"); + require(mesh::powerdiag::snapshot(millis()).current & mesh::powerdiag::mask(reason), + "physical wireless veto lost its actual diagnostic reason"); + const unsigned before_yield = yield_ms; + board.sleepUntilMicroseconds(micros() + 10000U); + require(yield_ms == before_yield && sleep_calls == 0, + "blocked profile sleep yielded beyond the scanner's safe visit"); + mesh::powerdiag::setEnabled(false, millis()); + } + mesh::esp32::wireless_blocker = mesh::powerdiag::Reason::Count; + for (bool diagnostics : {false, true}) { + mesh::powerdiag::setEnabled(diagnostics, millis()); + for (int source : {0, 1, 2, 3}) { + radio_wake_error = source == 0 ? 31 : ESP_OK; + button_wake_error = source == 1 ? 32 : ESP_OK; + gpio_wake_error = source == 2 ? 33 : ESP_OK; + timer_error = source == 3 ? 34 : ESP_OK; + const unsigned before = sleep_calls; + const uint64_t before_timer = timer_us; + board.sleepUntilMicroseconds(micros() + 10000U); + require(sleep_calls == before && timer_us == before_timer && critical_depth == 0, + "failed timed wake setup slept or reused an old timer"); + require(wake_levels[48] == 0 && wake_levels[38] == 0 + && restored_interrupts[48] == GPIO_INTR_POSEDGE + && restored_interrupts[38] == GPIO_INTR_DISABLE, + "failed timed wake setup left level IRQ or wake pin armed"); + } + radio_wake_error = button_wake_error = gpio_wake_error = timer_error = ESP_OK; + gpio_setup_us = 3000; + const unsigned before = sleep_calls; + board.sleepUntilMicroseconds(micros() + 10000U); + require(sleep_calls == before + 1 && timer_us == 7000U, + "absolute timer restarted the full visit after wake setup work"); + gpio_setup_us = 0; + const uint64_t last_timer = timer_us; + for (uint32_t delta : {0U, 1U, 1999U, UINT32_MAX}) { + board.sleepUntilMicroseconds(micros() + delta); + require(sleep_calls == before + 1 && timer_us == last_timer && critical_depth == 0, + "expired or subminimum deadline slept or inherited an old timer"); + } + board.sleepUntilMicroseconds(micros() + 2000U); + require(sleep_calls == before + 2 && timer_us == 2000U, + "minimum useful absolute deadline did not sleep"); + mock_millis = 4294967U; + board.sleepUntilMicroseconds(micros() + 5000U); + require(sleep_calls == before + 3 && timer_us == 5000U, + "microsecond clock wrap corrupted a valid absolute deadline"); + board.irq = UINT32_MAX; + board.sleepUntilMicroseconds(micros() + 10000U); + require(sleep_calls == before + 3 && timer_us == 5000U, + "profile deadline accepted a timer without a radio IRQ wake"); + board.irq = 48; + irq_during_setup = true; + board.sleepUntilMicroseconds(micros() + 10000U); + require(sleep_calls == before + 3 && timer_us == 5000U && critical_depth == 0, + "profile wait slept over an IRQ arriving during wake setup"); + irq_during_setup = radio_irq_high = false; + mesh::powerdiag::setEnabled(false, millis()); + } + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; return 1; + } +} +''' + with tempfile.TemporaryDirectory(prefix="meshcore-esp32-profile-deadline-") as directory: + cpp = Path(directory) / "sleep.cpp" + exe = Path(directory) / "sleep" + cpp.write_text(harness.replace("@METHODS@", methods), encoding="ascii") + result = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", + "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=0", "-DMESH_USB_LOGGING_AVAILABLE=0", + "-DMOMENTARY_BUTTON_WAKE_FROM_SLEEP=1", "-DPIN_USER_BTN=38", + "-I", str(ROOT / "src"), str(cpp), "-o", str(exe)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + def test_v4_profiles_inherit_button_wake_with_the_actual_gpio_pins(self): config = v4_profile_config() # These standalone partition-migration utilities deliberately bypass diff --git a/test/test_official_web_console.py b/test/test_official_web_console.py index 928491efa..bb7cf22c0 100644 --- a/test/test_official_web_console.py +++ b/test/test_official_web_console.py @@ -101,8 +101,15 @@ static mesh::Radio radio_driver; sleep_getter = " unsigned getPowerSaveSleepMilliseconds(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; }") + " bool getNextNoiseFloorWakeDelay(uint32_t&) const { return false; }\n" + + # This RF boundary has no settled scanner proof, so a live radio2 + # must keep polling. Execute the production deadline helper below. + " bool getDualRadioIdleDelay(uint32_t& ms) const { ms = 0; return false; }") source += "\n#include \n" + profile_helper = body((ROOT / "src/helpers/esp32/ProfileIdleSleep.h").read_text(), + "template ") + source += ("\nstatic int64_t esp_timer_get_time() { return micros(); }\n" + "namespace mesh { namespace esp32 {\n" + profile_helper + "\n} }\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_role_diagnostics.py b/test/test_power_saving_role_diagnostics.py index 859114a91..c519911ad 100644 --- a/test/test_power_saving_role_diagnostics.py +++ b/test/test_power_saving_role_diagnostics.py @@ -356,6 +356,8 @@ int main() { "void sleep(uint32_t secs) override", "void sleepMilliseconds(uint32_t millisecs) override", "void sleepMicroseconds(uint64_t wake_us, bool require_timer = false)", + "void sleepUntilMicroseconds(uint32_t deadline_us)", + "void sleepMicrosecondsImpl(uint64_t wake_us, bool require_timer,", )) code = r''' #include @@ -388,6 +390,9 @@ static esp_err_t esp_light_sleep_start() { ++sdk_calls; now_us += sleep_duration namespace mesh { static bool logging = false; static bool isUsbLoggingEnabled() { return logging; } +namespace esp32 { +static powerdiag::Reason manualLightSleepBlocker() { return powerdiag::Reason::Count; } +} } struct MainBoard { virtual void sleep(uint32_t) = 0; @@ -412,8 +417,8 @@ int main() { 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. + timer_error = 29; + board.sleep(30); // Keep the legacy seconds API's timer-error policy. auto value = snapshot(millis()); 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. @@ -426,6 +431,18 @@ int main() { value = snapshot(millis()); assert(sdk_calls == before_failed_slice && value.calls == 0); assert(value.errors == 1 && value.detail == 29 && critical_depth == 0); + const int gpio_errors[] = {31, 33}; + for (int error : gpio_errors) { + reset(millis()); decision(0, millis()); + timer_error = 0; + gpio_error = error == 31 ? error : 0; + pin_error = error == 33 ? error : 0; + board.sleep(30); + value = snapshot(millis()); + assert(sdk_calls == before_failed_slice && value.calls == 0 && value.returns == 0); + assert(value.errors == 1 && value.detail == error && critical_depth == 0); + assert(value.eligible_ms == 0 && value.call_us == 0); + } reset(millis()); decision(0, millis()); timer_error = gpio_error = pin_error = 0; sleep_error = 41; board.sleep(30); diff --git a/test/test_profile_idle_esp32.py b/test/test_profile_idle_esp32.py new file mode 100644 index 000000000..8964b01fb --- /dev/null +++ b/test/test_profile_idle_esp32.py @@ -0,0 +1,249 @@ +"""Execute ESP32 scanner publication and its late, integer-only idle query.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from cpp_source import body +from test_radio_profile_scan import HARNESS as SCAN_HARNESS + + +ROOT = Path(__file__).resolve().parents[1] + +ESP_MEMBERS = r''' + uint64_t _esp32_profile_visit_started_us=0, _esp32_profile_idle_stamp_us=0; + uint32_t _esp32_profile_idle_visit_us=0, _esp32_profile_idle_switch_us=0; + uint32_t _esp32_profile_idle_generation[2]={}; + uint16_t _esp32_profile_idle_preamble=0; + uint8_t _esp32_profile_idle_profile=0; + uint32_t _wd_observe_until=0; + bool _rx_retry_pending=false, _morse_owned=false; + bool _beacon_owned=false, _beacon_restoring=false, _morse_rx_owned=false; + bool getProfileIdleDelay(uint32_t&) const; +''' + +COMMON = r''' +// Complete both independent baselines after startup without faking the idle +// token: its RX stamp, generation, preamble and visit come from the scanner. +static void ready(RadioLibWrapper& w, uint8_t sf=11, float bw=125) { + w._profiles.primary.sf=w._cur_sf=sf; + w._profiles.primary.bw=w._cur_bw=bw; + w.enable(); + assert(w._profiles.switchTestReady()); + w._nf_refresh_requested=w._nf_calib_active=false; + w._noise_floor_valid=w._noise_floor_secondary_valid=true; + w.serviceProfileScan(); + assert(w._esp32_profile_idle_visit_us>0); +} +static bool idle(RadioLibWrapper& w, uint32_t expected) { + uint32_t delay=999; + const bool allowed=w.getProfileIdleDelay(delay); + assert(delay==expected); + return allowed; +} +''' + +ADMISSION = r''' +int main() { + RadioLibWrapper w; ready(w); + const uint64_t start=w._esp32_profile_visit_started_us; + const uint32_t visit=w._esp32_profile_idle_visit_us; + assert(visit>30000); + // Frontend settling keeps polling even when the whole visit is long. + elapsed_us=start+RadioLibWrapper::NoiseFloorSettleMillis*1000-1; + assert(!idle(w,0)); + elapsed_us=start+RadioLibWrapper::NoiseFloorSettleMillis*1000; + assert(idle(w,10)); + // The original RX stamp survives repeated loop passes on the same tuple. + w.serviceProfileScan(); + assert(w._esp32_profile_visit_started_us==start && idle(w,10)); + + // Visit end is strict: require 4 ms guard plus at least 2 ms of useful idle. + for (uint32_t remaining=0; remaining<=17000; ++remaining) { + elapsed_us=start+visit-remaining; + const auto usable=remaining>=6000 ? (remaining-4000)/1000 : 0; + const auto expected=usable>10 ? 10 : usable; + assert(idle(w,expected)==(expected!=0)); + } + elapsed_us=start+visit+1; + assert(!idle(w,0)); + // Stale low-32-bit clock equality after a full wrap is never a new visit. + elapsed_us=start+0x100000000ULL+10000; + assert(!idle(w,0)); + elapsed_us=start-1; + assert(!idle(w,0)); + + // A late IRQ, RX restart, owner or changed tuning proof revokes admission. + auto denied=[&](auto mutate) { + RadioLibWrapper fresh; ready(fresh); + elapsed_us=fresh._esp32_profile_visit_started_us+10000; + assert(idle(fresh,10)); + mutate(fresh); + assert(!idle(fresh,0)); + }; + denied([](auto& x){x._profiles.secondary.mode=mesh::RadioProfileMode::Off;}); + denied([](auto& x){x._esp32_profile_idle_visit_us=0;}); + denied([](auto& x){x._params_valid=false;}); + denied([](auto& x){x.state=STATE_TX_WAIT;}); + denied([](auto& x){x._profile_rxps_suspended=false;}); + denied([](auto& x){x._rx_ps_enabled=true;}); + denied([](auto& x){x._rx_ps_armed=true;}); + denied([](auto& x){x._rx_hold_continuous=true;}); + denied([](auto& x){x._rx_retry_pending=true;}); + denied([](auto& x){x._profile_retry_at=1;}); + denied([](auto& x){x._profile_refresh_required=true;}); + denied([](auto& x){x._profile_repair_required=true;}); + denied([](auto& x){x._nf_refresh_requested=true;}); + denied([](auto& x){x._nf_calib_active=true;}); + denied([](auto& x){x._noise_floor_valid=false;}); + denied([](auto& x){x._noise_floor_secondary_valid=false;}); + denied([](auto& x){x._wd_observe_until=1;}); + denied([](auto& x){x._cw_active=true;}); + denied([](auto& x){x._morse_owned=true;}); + denied([](auto& x){x._beacon_owned=true;}); + denied([](auto& x){x._morse_rx_owned=true;}); + denied([](auto& x){x.events.record();}); + denied([](auto& x){x._profiles.resetSwitchTest();}); + denied([](auto& x){x._active_profile=2;}); + denied([](auto& x){x._active_profile^=1;}); + denied([](auto& x){++x._profile_generation;}); + denied([](auto& x){++x._profiles.generation[0];}); + denied([](auto& x){++x._profiles.generation[1];}); + denied([](auto& x){++x._profile_scan_generation[0];}); + denied([](auto& x){++x._profile_scan_generation[1];}); + denied([](auto& x){++x._esp32_profile_idle_generation[0];}); + denied([](auto& x){++x._esp32_profile_idle_generation[1];}); + denied([](auto& x){++x._esp32_profile_visit_started_us;}); + denied([](auto& x){++x._physical_preamble;}); + denied([](auto& x){++x._profiles.switch_test_max_us[1];}); + + // Both scanner generations matter even if a replacement copied the same + // active tuple and its retune sample budget happened to be unchanged. + denied([](auto& x){ + ++x._profiles.generation[1]; ++x._profile_scan_generation[1]; + }); +} +''' + +SCANNER = r''' +int main() { + RadioLibWrapper w; ready(w); + auto start=w._esp32_profile_visit_started_us; + elapsed_us=start+10000; + assert(idle(w,10)); + w._cw_active=true; w.serviceProfileScan(); + assert(w._esp32_profile_idle_visit_us==0 && !idle(w,0)); + w._cw_active=false; w.serviceProfileScan(); assert(idle(w,10)); + w._profile_retry_at=millis()+1000; w.serviceProfileScan(); + assert(w._esp32_profile_idle_visit_us==0); + w._profile_retry_at=0; w.serviceProfileScan(); assert(idle(w,10)); + w.busy=true; w.serviceProfileScan(); assert(!idle(w,0)); + w.busy=false; w.packet=true; w.serviceProfileScan(); assert(!idle(w,0)); + w.packet=false; w.events.record(); w.serviceProfileScan(); assert(!idle(w,0)); + w.events.claim(); w.serviceProfileScan(); assert(idle(w,10)); + + // A timer wake advances the same clock the scanner uses. It cannot let a + // due scan spend an extra visit on the old profile or reuse its old budget. + auto previous=w._active_profile; + elapsed_us=start+w._esp32_profile_idle_visit_us; + w.serviceProfileScan(); + assert(w._active_profile!=previous && !idle(w,0)); + start=w._esp32_profile_visit_started_us; + w.serviceProfileScan(); + assert(w._esp32_profile_idle_stamp_us==start && !idle(w,0)); + + // The fast visit is sub-ms (SF5/BW500) and must keep polling throughout. + RadioLibWrapper fast; ready(fast,5,500); + assert(fast._esp32_profile_idle_visit_us<1000 && !idle(fast,0)); + elapsed_us+=fast._esp32_profile_idle_visit_us; + fast.serviceProfileScan(); + assert(!idle(fast,0)); + + // A long hold spanning micros() wrap still causes a real hop on release. + RadioLibWrapper wrap; ready(wrap); + const auto old_profile=wrap._active_profile; + elapsed_us=wrap._esp32_profile_visit_started_us+0x100000000ULL+10000; + wrap.serviceProfileScan(); + assert(wrap._active_profile!=old_profile && !idle(wrap,0)); + assert(wrap._esp32_profile_visit_started_us==elapsed_us); + assert(wrap._profile_visit_stamp==uint32_t(elapsed_us)); + + RadioLibWrapper changed; ready(changed); + elapsed_us=changed._esp32_profile_visit_started_us+10000; + assert(idle(changed,10)); + auto replacement=changed._profiles.secondary; + replacement.params.freq+=1; + changed._profiles.setSecondary(replacement,false); + assert(!idle(changed,0)); + changed.serviceProfileScan(); + assert(changed._esp32_profile_idle_visit_us==0 && !idle(changed,0)); + assert(changed._nf_refresh_requested && !changed._noise_floor_secondary_valid); + changed._profiles.setSecondary({},false); + changed.serviceProfileScan(); + assert(changed._esp32_profile_idle_visit_us==0 && !idle(changed,0)); + assert(!changed._profile_rxps_suspended && changed._rx_ps_enabled); +} +''' + + +class Esp32ProfileIdleTest(unittest.TestCase): + @staticmethod + def fixture(): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + names = [("uint8_t", "beginReconfigure"), ("void", "endReconfigure"), + ("void", "serviceProfileScan")] + [("mesh::RadioParamApplyResult", name) for name in + ("tuneProfile", "prepareTransmitProfile", "tryRestoreCodingRate", + "trySetParams", "trySetPrimaryParams")] + methods = "\n".join(body(source, f"{kind} RadioLibWrapper::{name}(") for kind, name in names) + methods += "\n" + body(source, + "mesh::RadioParamApplyResult RadioLibWrapper::prepareTransmitProfile(uint8_t profile, bool") + query = body(source, "bool RadioLibWrapper::getProfileIdleDelay(") + # This final admission must never perform SPI, derive float timing or + # run the scanner. Every such operation precedes the published proof. + for forbidden in ("profilePreamble(", "listenUs(", "profileTimestamp(", + "isChipBusy(", "isPacketPendingOrReceiving(", "tuneProfile("): + assert forbidden not in query, forbidden + methods += "\n" + query + wrapper = (ROOT / "src/helpers/radiolib/CustomSX1262Wrapper.h").read_text() + methods += "\n" + body(wrapper, "void setProfileStandbyWarm(").replace( + "void setProfileStandbyWarm(bool enabled) override", + "void RadioLibWrapper::setProfileStandbyWarm(bool enabled)") + fixture = SCAN_HARNESS.split("int main()", 1)[0] + fixture = fixture.replace(" bool _cw_active=false;", ESP_MEMBERS + "\n bool _cw_active=false;") + fixture = fixture.replace("static uint64_t elapsed_us;", """ +#define MESH_ENABLE_TX_BEACONS 1 +#define MESH_ENABLE_MORSE_RX 1 +static uint64_t elapsed_us; +int64_t esp_timer_get_time() { return static_cast(elapsed_us); } +""") + fixture = fixture.replace("state=STATE_RX; _profile_visit_stamp=profileTimestamp(_profiles.enabled());", + "state=STATE_RX; _esp32_profile_visit_started_us=elapsed_us; " + "_profile_visit_stamp=profileTimestamp(_profiles.enabled());") + return fixture.replace("@METHODS@", methods) + COMMON + + def run_fixture(self, main): + with tempfile.TemporaryDirectory(prefix="meshcore-esp32-profile-idle-") as directory: + folder = Path(directory) + (folder / "Arduino.h").write_text("#pragma once\n#include \n#include \n") + (folder / "test.cpp").write_text(self.fixture() + main) + flags = ["-std=c++17", "-Wall", "-Wextra", "-Werror", "-DESP32_PLATFORM=1"] + if os.name != "nt": + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + result = subprocess.run([os.environ.get("CXX", "c++"), *flags, + "-I", directory, "-I", str(ROOT / "src"), str(folder / "test.cpp"), + "-o", str(folder / "test")], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(folder / "test")], capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_late_integer_admission_boundaries_and_revocation(self): + self.run_fixture(ADMISSION) + + def test_real_scanner_clock_hops_and_failed_publication(self): + self.run_fixture(SCANNER) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_profile_switch_fast_policy.py b/test/test_profile_switch_fast_policy.py index 7c6c8782b..76a5b37a5 100644 --- a/test/test_profile_switch_fast_policy.py +++ b/test/test_profile_switch_fast_policy.py @@ -121,6 +121,7 @@ int main() { #define ESP32_PLATFORM uint32_t clockUs=0; uint32_t micros() { return clockUs; } +int64_t esp_timer_get_time() { return clockUs; } @CLOCK@ struct BenchWrapper { uint32_t _profile_visit_stamp=0; @GETTER@ }; int main() { diff --git a/test/test_radio_profile_clock.py b/test/test_radio_profile_clock.py index 0c1e5f261..591aae1b4 100644 --- a/test/test_radio_profile_clock.py +++ b/test/test_radio_profile_clock.py @@ -20,6 +20,8 @@ struct FakeRtc { } rtc; #define NRF_RTC2 (&rtc) uint32_t micros() { return 1234; } +static uint64_t esp_elapsed_us=1234; +int64_t esp_timer_get_time() { return static_cast(esp_elapsed_us); } @CLOCK@ int main() { assert(profileTimestamp(false) == 1234); @@ -61,6 +63,17 @@ int main() { rtc.COUNTER=(rtc.COUNTER+32768)&0x00ffffffUL; const auto wrapped=profileTimestamp(true);assert(wrapped==0); assert(profileElapsedUs(before,wrapped,true)==1000000UL); +#elif defined(ESP32_PLATFORM) + assert(profileTimestamp(true) == 1234); + // The ESP Timer is the light-sleep-adjusted clock; a frozen cycle/micros + // source must not extend a radio visit while the MCU was asleep. + esp_elapsed_us+=10000; + assert(micros()==1234 && profileTimestamp(true)==11234); + assert(profileTimestamp(false)==11234); + esp_elapsed_us=0x100000010ULL; + assert(profileTimestamp(true)==0x10); + assert(profileElapsedUs(0xfffffff0,profileTimestamp(true),true)==32); + assert(rtc.TASKS_START == 0 && rtc.TASKS_STOP == 0); #else assert(profileTimestamp(true) == 1234); assert(profileElapsedUs(12, 42, true) == 30); @@ -76,7 +89,7 @@ class ProfileClockTest(unittest.TestCase): cpp = Path(folder) / "test.cpp" exe = Path(folder) / "test.exe" cpp.write_text(HARNESS.replace("@CLOCK@", CLOCK)) - for defines in ([], ["-DNRF52_PLATFORM=1"]): + for defines in ([], ["-DNRF52_PLATFORM=1"], ["-DESP32_PLATFORM=1"]): result = subprocess.run( [os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", *defines, str(cpp), "-o", str(exe)], diff --git a/test/test_radio_profile_idle_dispatcher.py b/test/test_radio_profile_idle_dispatcher.py index f21297dd7..23beeaedc 100644 --- a/test/test_radio_profile_idle_dispatcher.py +++ b/test/test_radio_profile_idle_dispatcher.py @@ -76,8 +76,10 @@ int main() { Manager manager; Dispatcher legacy{&unknown, &clock, &manager}; assert(legacy.canIdleDualRadio()); + assert(!legacy.getDualRadioIdleDelay(unchanged) && unchanged == 0); unknown.profile.active = true; assert(!legacy.canIdleDualRadio()); + assert(!legacy.getDualRadioIdleDelay(unchanged) && unchanged == 0); assert(legacy.getNextQueueWakeDelay(unchanged) && unchanged == 0); for (uint32_t now : {1000U, UINT32_MAX - 15U}) { @@ -87,20 +89,25 @@ int main() { Dispatcher node{&radio, &clock, &manager}; uint32_t delay = 99; assert(node.canIdleDualRadio() && radio.reads == 0); + assert(!node.getDualRadioIdleDelay(delay) && delay == 0 && radio.reads == 0); assert(!node.getNextQueueWakeDelay(delay) && radio.reads == 0); radio.profile.active = true; radio.delay = 37; assert(!node.canIdleDualRadio()); // Unknown ports keep their polling contract. + assert(!node.getDualRadioIdleDelay(delay) && delay == 0); // Ignore a false writer's output. assert(node.getNextQueueWakeDelay(delay) && delay == 0); radio.supported = true; radio.delay = 0; assert(!node.canIdleDualRadio()); // A due/too-short visit is not an idle budget. + assert(!node.getDualRadioIdleDelay(delay) && delay == 0); assert(node.getNextQueueWakeDelay(delay) && delay == 0); radio.delay = 37; assert(node.canIdleDualRadio()); + assert(node.getDualRadioIdleDelay(delay) && delay == 37); assert(node.getNextQueueWakeDelay(delay) && delay == 37); node.radio_available = false; assert(!node.canIdleDualRadio()); + assert(!node.getDualRadioIdleDelay(delay) && delay == 0); assert(node.getNextQueueWakeDelay(delay) && delay == 0); node.radio_available = true; @@ -173,7 +180,8 @@ class RadioProfileIdleDispatcherTest(unittest.TestCase): header = (ROOT / 'src/Dispatcher.h').read_text(encoding='ascii') source = (ROOT / 'src/Dispatcher.cpp').read_text(encoding='ascii') code = HARNESS.replace('@DEFAULT@', method(header, 'virtual bool getProfileIdleDelay(')) - code = code.replace('@ADMISSION@', method(header, 'bool canIdleDualRadio() const')) + code = code.replace('@ADMISSION@', method(header, 'bool canIdleDualRadio() const') + '\n' + + method(header, 'bool getDualRadioIdleDelay(')) code = code.replace('@DEADLINES@', method(source, 'bool Dispatcher::getNextQueueWakeDelay(')) with tempfile.TemporaryDirectory(prefix='meshcore-profile-idle-dispatcher-') as directory: cpp, binary = Path(directory) / 'test.cpp', Path(directory) / 'test'