diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 37b67d94b..9f078d6c0 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -701,6 +701,7 @@ jobs: 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_esp32_role_profile_idle_handoff.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 diff --git a/src/helpers/esp32/LightSleepPolicy.h b/src/helpers/esp32/LightSleepPolicy.h index 5c9a684ed..b5e8d8eb4 100644 --- a/src/helpers/esp32/LightSleepPolicy.h +++ b/src/helpers/esp32/LightSleepPolicy.h @@ -1,8 +1,22 @@ #pragma once -#include +#include #include +// ENABLE_OTA alone also describes LoRa OTA on BLE-only images. Only link the +// WiFi SDK check when a compiled service can own that driver. Use build flags, +// rather than WITH_WEBCONFIG, which is defined by header inclusion order. +#if defined(WIFI_SSID) || defined(ENABLE_WIFI_INTERFACE) \ + || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \ + || (defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) \ + || defined(WIFI_OTA_SEEDER) || defined(ADMIN_PASSWORD) \ + || defined(COMPANION_RADIO_FULL) +#define MESH_ESP32_SLEEP_CHECK_WIFI 1 +#include +#else +#define MESH_ESP32_SLEEP_CHECK_WIFI 0 +#endif + #if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED #include #if defined(CONFIG_BLUEDROID_ENABLED) && CONFIG_BLUEDROID_ENABLED \ @@ -32,6 +46,7 @@ inline powerdiag::Reason manualLightSleepBlocker() { return powerdiag::Reason::Wireless; #endif #endif +#if MESH_ESP32_SLEEP_CHECK_WIFI wifi_mode_t mode = WIFI_MODE_NULL; const esp_err_t result = esp_wifi_get_mode(&mode); if (result == ESP_OK) @@ -40,6 +55,9 @@ inline powerdiag::Reason manualLightSleepBlocker() { 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. +#else + return powerdiag::Reason::Count; +#endif } inline bool manualLightSleepReady() { diff --git a/test/test_esp32_full_sleep.py b/test/test_esp32_full_sleep.py index 513bb8451..67d8153ad 100644 --- a/test/test_esp32_full_sleep.py +++ b/test/test_esp32_full_sleep.py @@ -28,7 +28,7 @@ def run_cpp(source, defines=()): 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"): + for header in ("sdkconfig.h", "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( diff --git a/test/test_esp32_light_sleep_policy.py b/test/test_esp32_light_sleep_policy.py index 3fc7b8dfb..5c8d94f61 100644 --- a/test/test_esp32_light_sleep_policy.py +++ b/test/test_esp32_light_sleep_policy.py @@ -44,6 +44,8 @@ int main() { mode = value; assert(manualLightSleepBlocker() == Reason::Network); } + mode = static_cast(99); + assert(manualLightSleepBlocker() == Reason::Network); mode = WIFI_MODE_NULL; for (int error : {ESP_ERR_WIFI_NOT_INIT, ESP_ERR_WIFI_NOT_STARTED}) { wifi_error = error; @@ -76,31 +78,65 @@ int main() { ''' -class LightSleepPolicyTest(unittest.TestCase): - def test_real_guard_across_sdk_and_host_modes(self): - for flags in ( +SDK_MODES = ( (), ("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) + ) + + +class LightSleepPolicyTest(unittest.TestCase): + def test_real_guard_across_sdk_and_host_modes(self): + for flags in SDK_MODES: + with self.subTest(flags=flags): + self.run_guard((*flags, 'WIFI_SSID=""')) + + def test_every_wifi_owner_keeps_physical_driver_fail_closed(self): + for owner in ("WIFI_SSID", "ENABLE_WIFI_INTERFACE", "WITH_MQTT_BRIDGE", + "WITH_ESPNOW_BRIDGE", "MESH_PRIMARY_ESPNOW", "WIFI_OTA_SEEDER", + "ADMIN_PASSWORD", "COMPANION_RADIO_FULL"): + with self.subTest(owner=owner): + self.run_guard((owner, "CONFIG_BT_ENABLED=1", + "CONFIG_BLUEDROID_ENABLED=1")) + + def test_ble_only_links_without_wifi_sdk_or_headers(self): + # No WiFi SDK header or function definition exists. An unconditional + # check fails compilation/linking instead of silently passing a fake. + # ENABLE_OTA is deliberately present: this role still has LoRa OTA. + source = "#include \n" + HARNESS + start = source.index(" for (auto value : {WIFI_MODE_STA") + end = source.index("#if defined(CONFIG_BT_ENABLED)", start) + source = source[:start] + source[end:] + for flags in SDK_MODES: + with self.subTest(flags=flags): + self.run_guard((*flags, "BLE_PIN_CODE=123456", "ENABLE_OTA=1", + "MESH_PRIMARY_ESPNOW=0"), source, wifi=False) + + def run_guard(self, flags, source=None, wifi=True): + with tempfile.TemporaryDirectory(prefix="mesh-esp32-sleep-driver-") as directory: + root = Path(directory) + (root / "sdkconfig.h").write_text("#pragma once\n") + (root / "esp_wifi.h").write_text(WIFI if wifi else '#error "WiFi SDK is absent"\n') + 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(source or "#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 not wifi: + symbols = subprocess.run(["nm", "-C", str(exe)], text=True, + capture_output=True, timeout=10) + self.assertEqual(symbols.returncode, 0, symbols.stderr) + self.assertNotIn("esp_wifi_", symbols.stdout) if __name__ == "__main__": diff --git a/test/test_esp32_role_profile_idle_handoff.py b/test/test_esp32_role_profile_idle_handoff.py new file mode 100644 index 000000000..722668061 --- /dev/null +++ b/test/test_esp32_role_profile_idle_handoff.py @@ -0,0 +1,264 @@ +"""Execute the real ESP32 role sleep branches and their absolute-deadline handoff. + +Uses production role branches and ProfileIdleSleep.h, with host-only clock, +mesh and board fakes. It does not access hardware or invoke PlatformIO. +""" + +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + + +PRELUDE = r''' +#include +#include +#include +#include +#include +#define ESP32_PLATFORM 1 +#define POWERSAVING_FIRSTSLEEP_SECS 1 +#define POWERSAVING_FIRST_SLEEP_SECS 1 +static uint64_t clock_us=100000; +int64_t esp_timer_get_time() { return static_cast(clock_us); } +static uint32_t millis() { return static_cast(clock_us/1000); } +static uint32_t micros() { return static_cast(clock_us); } +#include +struct Mesh { + uint32_t budget=30000, noise_delay=50, profile_ms=10; + uint32_t queue_work_us=0, noise_work_us=0, startup_work_us=0, query_work_us=0; + bool dual=true, noise=false, startup_ready=true, profile_ready=true, late_ready=true; + unsigned queries=0; + uint32_t query_at=0; + uint32_t getPowerSaveSleepMilliseconds(uint32_t limit) const { + assert(limit==30000); clock_us+=queue_work_us; return budget; + } + bool getNextNoiseFloorWakeDelay(uint32_t& out) const { + clock_us+=noise_work_us; out=noise_delay; return noise; + } + bool millisHasNowPassed(uint32_t) const { clock_us+=startup_work_us; return startup_ready; } + bool isDualRadioActive() const { return dual; } + bool getDualRadioIdleDelay(uint32_t& out) { + ++queries; query_at=static_cast(clock_us); + clock_us+=query_work_us; out=profile_ms; + return profile_ready && late_ready; + } +} the_mesh; +struct Board { + unsigned absolute_calls=0, legacy_calls=0, actual_sleeps=0; + uint32_t deadline=0, last_legacy_ms=0, setup_us=0, useful_us=0; + bool ready=true; + void sleep(uint32_t seconds) { ++legacy_calls; last_legacy_ms=seconds*1000; } + void sleepMilliseconds(uint32_t ms) { ++legacy_calls; last_legacy_ms=ms; } + void sleepUntilMicroseconds(uint32_t at) { + ++absolute_calls; deadline=at; clock_us+=setup_us; + const int32_t remaining=static_cast(at-static_cast(clock_us)); + // Models the board API's final deadline check; the production board + // implementation has its own executing test and SDK build coverage. + if (ready && remaining>=2000) { ++actual_sleeps; useful_us=remaining; } + } +} board; +static void reset(uint64_t start=100000) { the_mesh=Mesh{}; board=Board{}; clock_us=start; } +''' + +INFRA_MAIN = r''' +int main() { + for (uint64_t start : {100000ULL, 0xffffff00ULL, 0x100000100ULL}) { + for (uint32_t profile : {2U,3U,4U,5U,6U,7U,8U,9U,10U}) { + reset(start); the_mesh.profile_ms=profile; idle(true); + assert(the_mesh.queries==1 && board.absolute_calls==1 && board.actual_sleeps==1); + assert(board.deadline==static_cast(start+profile*1000)); + assert(board.useful_us==profile*1000 && board.legacy_calls==0); + } + // A due queue/calibration, startup hold, or outer guard never reaches + // the handoff. No branch substitutes a longer legacy board sleep. + reset(start); the_mesh.budget=0; idle(true); + assert(the_mesh.queries==0 && board.absolute_calls==0 && board.legacy_calls==0); + reset(start); the_mesh.noise=true; the_mesh.noise_delay=0; idle(true); + assert(the_mesh.queries==0 && board.absolute_calls==0 && board.legacy_calls==0); + reset(start); the_mesh.startup_ready=false; idle(true); + assert(the_mesh.queries==0 && board.absolute_calls==0 && board.legacy_calls==0); + reset(start); idle(false); + assert(the_mesh.queries==0 && board.absolute_calls==0 && board.legacy_calls==0); + + // Every form of late scanner rejection retains zero-delay polling. + reset(start); the_mesh.profile_ready=false; idle(true); + assert(the_mesh.queries==1 && board.absolute_calls==0 && board.legacy_calls==0); + reset(start); the_mesh.late_ready=false; idle(true); + assert(the_mesh.queries==1 && board.absolute_calls==0 && board.legacy_calls==0); + for (uint32_t bad : {0U,1U,11U,UINT32_MAX}) { + reset(start); the_mesh.profile_ms=bad; idle(true); + assert(the_mesh.queries==1 && board.absolute_calls==0 && board.legacy_calls==0); + } + + // The queue/noise deadline begins before queue calculation and startup + // work. Seven ms of setup must leave only three of its original ten. + reset(start); the_mesh.budget=10; the_mesh.queue_work_us=4000; + the_mesh.noise_work_us=1000; the_mesh.startup_work_us=2000; idle(true); + assert(the_mesh.queries==1 && board.actual_sleeps==1 && board.legacy_calls==0); + assert(board.deadline==static_cast(start+10000) && board.useful_us==3000); + reset(start); the_mesh.noise=true; the_mesh.noise_delay=5; + the_mesh.queue_work_us=2000; idle(true); + assert(board.deadline==static_cast(start+5000) && board.useful_us==3000); + reset(start); the_mesh.budget=2; idle(true); + assert(board.deadline==static_cast(start+2000) && board.actual_sleeps==1); + reset(start); the_mesh.budget=1; idle(true); + assert(board.absolute_calls==0 && board.legacy_calls==0); + + // Expiration while traversing the role gate never restarts that budget. + reset(start); the_mesh.budget=10; the_mesh.queue_work_us=10000; idle(true); + assert(the_mesh.queries==1 && board.absolute_calls==0 && board.legacy_calls==0); + reset(start); the_mesh.noise=true; the_mesh.noise_delay=2; + the_mesh.noise_work_us=2000; idle(true); + assert(board.absolute_calls==0 && board.legacy_calls==0); + + // Query and wake-source setup can consume budget after the helper's + // observation. The absolute deadline survives; the board rejects <2ms. + reset(start); the_mesh.query_work_us=7000; board.setup_us=2000; idle(true); + assert(board.absolute_calls==1 && board.deadline==static_cast(start+10000)); + assert(board.actual_sleeps==0 && board.legacy_calls==0); + reset(start); the_mesh.query_work_us=1000; board.setup_us=1000; idle(true); + assert(board.deadline==static_cast(start+10000) && board.useful_us==8000); + reset(start); board.ready=false; idle(true); + assert(board.absolute_calls==1 && board.actual_sleeps==0 && board.legacy_calls==0); + + // Single-profile behavior remains the original direct milliseconds API, + // including a one-ms deadline that is too short for timed radio2 sleep. + for (uint32_t budget : {0U,1U,2U,50U,999U,1001U,30000U}) { + reset(start); the_mesh.dual=false; the_mesh.budget=budget; idle(true); + assert(the_mesh.queries==0 && board.absolute_calls==0); + assert(board.legacy_calls==(budget!=0)); + if (budget) assert(board.last_legacy_ms==budget); + reset(start); the_mesh.dual=false; the_mesh.budget=budget; + the_mesh.noise=true; the_mesh.noise_delay=3; idle(true); + assert(board.legacy_calls==(budget!=0)); + if (budget) assert(board.last_legacy_ms==(budget<3 ? budget : 3)); + } + } +} +''' + + +COMPANION_STUBS = r''' +#define ENABLE_USB_INTERFACE 1 +#if TEST_NATIVE_USB +#define ARDUINO_USB_CDC_ON_BOOT 1 +#endif +static bool native_ready=true; +static uint32_t native_setup_us=0; +bool companionNativeUsbLightSleepReady() { clock_us+=native_setup_us; return native_ready; } +using esp_err_t=int; +constexpr int ESP_OK=0; +static unsigned sdk_timer_calls=0, sdk_sleep_calls=0, yields=0; +static uint32_t sdk_timer_us=0; +int esp_sleep_enable_timer_wakeup(uint64_t value) { + ++sdk_timer_calls; sdk_timer_us=value; return ESP_OK; +} +int esp_light_sleep_start() { ++sdk_sleep_calls; return ESP_OK; } +int pdMS_TO_TICKS(int ms) { return ms; } +void vTaskDelay(int) { ++yields; } +void delay(int) { ++yields; } +struct Prefs { bool powersaving_enabled=true; } prefs; +namespace mesh { +bool isUsbLoggingWatchdogArmed() { return false; } +namespace wireless { +struct Control { bool pending() const { return false; } } controller; +Control& control() { return controller; } +} +} +''' + +COMPANION_MAIN = r''' +int main() { + for (uint64_t start : {100000ULL, 0xffffff00ULL, 0x100000100ULL}) { + for (uint32_t profile : {2U,3U,4U,5U,6U,7U,8U,9U,10U}) { + reset(start); the_mesh.profile_ms=profile; idle(); + assert(board.absolute_calls==1 && board.actual_sleeps==1); + assert(board.deadline==static_cast(start+profile*1000)); + assert(board.legacy_calls==0 && yields==0 && sdk_sleep_calls==0); + } + reset(start); the_mesh.noise=true; the_mesh.noise_delay=0; idle(); + assert(board.absolute_calls==0 && board.legacy_calls==0 && yields==0); + reset(start); the_mesh.noise=true; the_mesh.noise_delay=3; idle(); + assert(board.deadline==static_cast(start+3000) && board.useful_us==3000); + reset(start); the_mesh.profile_ready=false; idle(); + assert(the_mesh.queries==0 && board.absolute_calls==0 && yields==0); + reset(start); the_mesh.late_ready=false; idle(); + assert(the_mesh.queries==1 && board.absolute_calls==0 && yields==0); + for (uint32_t bad : {0U,1U,11U,UINT32_MAX}) { + reset(start); the_mesh.profile_ms=bad; idle(); + assert(board.absolute_calls==0 && yields==0); + } + reset(start); the_mesh.query_work_us=9000; idle(); + assert(board.deadline==static_cast(start+10000) && board.actual_sleeps==0); + assert(yields==0 && sdk_sleep_calls==0); + reset(start); board.ready=false; idle(); + assert(board.absolute_calls==1 && board.actual_sleeps==0 && yields==0); +#if TEST_NATIVE_USB + reset(start); native_ready=false; idle(); + assert(board.absolute_calls==0 && yields==0); + native_ready=true; native_setup_us=9000; + reset(start); idle(); + assert(board.absolute_calls==0 && yields==0); // readiness consumed the original slice + native_setup_us=0; +#endif + } + reset(); the_mesh.dual=false; idle(); + assert(board.absolute_calls==0 && the_mesh.queries==0); +#if TEST_NATIVE_USB + assert(sdk_timer_calls==1 && sdk_timer_us==10000 && sdk_sleep_calls==1 && yields==0); +#else + assert(sdk_timer_calls==0 && sdk_sleep_calls==0 && yields==1); +#endif +} +''' + + +class ActualEsp32RoleHandoffTest(unittest.TestCase): + def compile_run(self, source, name): + with tempfile.TemporaryDirectory(prefix="meshcore-private-role-handoff-") as tmp: + path=Path(tmp) + (path/"esp_timer.h").write_text("#pragma once\n#include \nint64_t esp_timer_get_time();\n") + (path/"test.cpp").write_text(source, encoding="ascii") + flags=["-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function"] + if os.name!="nt": + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + built=subprocess.run([os.environ.get("CXX","c++"), *flags, "-I",tmp, + "-I",str(ROOT/"src"),str(path/"test.cpp"),"-o",str(path/"test")], + capture_output=True,text=True,timeout=60) + self.assertEqual(built.returncode,0,name+" compile:\n"+built.stdout+built.stderr) + ran=subprocess.run([str(path/"test")],capture_output=True,text=True,timeout=20) + self.assertEqual(ran.returncode,0,name+" execute:\n"+ran.stdout+ran.stderr) + + def test_actual_infrastructure_gates_with_production_helper(self): + for role in ("simple_repeater","simple_room_server","simple_sensor"): + with self.subTest(role=role): + source=(ROOT/"examples"/role/"main.cpp").read_text() + gate=body(source," if (can_power_save) {") + self.compile_run(PRELUDE+"\nvoid idle(bool can_power_save) {\n"+gate+"\n}\n"+INFRA_MAIN,role) + + def test_actual_companion_native_and_uart_gates_with_production_helper(self): + source=(ROOT/"examples/companion_radio/main.cpp").read_text() + start=source.index(" // USB power alone") + end=source.index("#if defined(ESP32) && defined(WIFI_SSID)",start) + branch=source[start:end] + fixture=PRELUDE.replace(" uint32_t budget=30000", " struct Prefs* getNodePrefs();\n" + " bool canIdleDualRadio() const { return !dual || profile_ready; }\n" + " mesh::powerdiag::Mask pendingWorkReason() const { return 0; }\n" + " uint32_t budget=30000") + fixture=fixture.replace(" unsigned absolute_calls=0", " bool isUsbSleepHeld() const { return false; }\n" + " unsigned absolute_calls=0") + fixture+=COMPANION_STUBS+"\nPrefs* Mesh::getNodePrefs() { return &prefs; }\n" + fixture+="void idle() { const bool morse_timing=false;\n"+branch+"\n}\n"+COMPANION_MAIN + for native in (0,1): + with self.subTest(native=native): + self.compile_run(f"#define TEST_NATIVE_USB {native}\n"+fixture,f"companion-native={native}") + + +if __name__ == "__main__": + unittest.main()