From 97f16dedd0ab0fb4a8b80bf04af76ef8fd38212d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 6 Oct 2026 11:18:00 -0700 Subject: [PATCH] Fix ESP32 Full idle radio teardown and compact AP startup --- .github/workflows/run-unit-tests.yml | 2 + docs/cli_commands.md | 13 + docs/firmware_picker.md | 7 + docs/role_feature_switches.md | 5 + examples/companion_radio/main.cpp | 58 ++- examples/simple_repeater/MyMesh.cpp | 146 ++++++- examples/simple_repeater/MyMesh.h | 19 + examples/simple_room_server/MyMesh.cpp | 138 +++++- examples/simple_room_server/MyMesh.h | 12 + src/helpers/CommonCLI.cpp | 71 ++- src/helpers/CommonCLI.h | 7 + src/helpers/ESP32Board.cpp | 60 +-- src/helpers/ESP32Board.h | 24 +- src/helpers/UsbLogging.cpp | 4 +- src/helpers/WiFiSetupPortal.cpp | 22 +- src/helpers/esp32/WebConfigServer.cpp | 23 +- src/helpers/esp32/WiFiAccessPointPolicy.h | 106 +++++ test/fixtures/rs232_mqtt_runtime.cpp | 1 + test/fixtures/webconfig_station_owner.cpp | 10 +- test/fixtures/wifi_ota_start.cpp | 179 +++++++- test/test_esp32_full_power_lifecycle.py | 502 ++++++++++++++++++++++ test/test_esp32_full_sleep.py | 198 +++++++++ test/test_esp32_usb_sleep.py | 6 +- test/test_rs232_mqtt_runtime.py | 8 + test/test_tbeam_charge_target.py | 3 + test/test_webconfig_ap_start.py | 5 + test/test_webconfig_station_ownership.py | 2 + test/test_wifi_ota_lifecycle.py | 36 +- test/test_wifi_ota_start.py | 201 ++++++++- 29 files changed, 1724 insertions(+), 144 deletions(-) create mode 100644 src/helpers/esp32/WiFiAccessPointPolicy.h create mode 100644 test/test_esp32_full_power_lifecycle.py create mode 100644 test/test_esp32_full_sleep.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index acbb8f39..0b4d8950 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -263,6 +263,8 @@ jobs: run: | python3 -B test/test_usb_host_sleep_policy.py 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 - name: Verify official web console framing and power-saving idle sessions run: python3 -B test/test_official_web_console.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index a0bdc91f..3add0b78 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -296,6 +296,13 @@ again without rebooting. Once an SSID is saved, the cutoff no longer applies and the selected WiFi/MQTT mode keeps reconnecting. Other setup sessions retain their profile's idle timeout. +Full Repeater and Room Server suspend unconfigured network bridges during +this setup session. Automatic ESP-NOW retries remain suspended after the AP +closes, so they cannot keep an unused WiFi driver running. Saved bridge choices +remain intact; explicitly enabling the bridge resumes it for this boot. +Stopping a browser OTA session restores only bridges that session actually +paused, rather than starting services from saved intent alone. + Every ESP32 build with WebConfig supports the browser command terminal, including WiFi Companion and Full Companion. Companions use their complete USB/TCP terminal on the trusted LAN, including contact import, chat, recipient @@ -2362,6 +2369,12 @@ binaries. Companion firmware defaults this setting to `on`. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. USB and each active wireless transport remain available; SenseCAP Indicator Full keeps only its selected BLE or infrastructure-WiFi secondary transport active. `set powersaving off` restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (`radio.rxps`) and WiFi modem power save (`wifi.powersave`). Infrastructure uses the same commands; its hardware and active-service sleep guards determine when the node can sleep. +Native USB ESP32 Full Companions can also use short light-sleep intervals when +the BLE/WiFi drivers, ESP-NOW, GPS UART, logging, and USB host sessions are +inactive and no work is pending. Compiling those transports into Full does not +itself prevent sleep. Turning off BLE advertising alone leaves its controller +running and therefore retains idle behavior rather than manual light sleep. + --- #### View or set the reboot interval (Repeater and room server) diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 8a6c6a77..5b251ac0 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -293,6 +293,13 @@ next reboot or power cycle. An explicit administrator `start webconfig` remains available as an override. A saved SSID switches to the normal indefinite reconnect behavior instead. +Fresh ESP32 Full infrastructure defaults to USB logging off. Existing saved +logging and device power-saving settings survive an update. The unconfigured +setup session also pauses automatic network-bridge retries until an explicit +bridge start, allowing its radio to turn off when the AP closes. Compact OTA +images scan before raising a new AP and verify live driver readiness before +reporting the update URL. + Full Companion profiles use one binary for USB, BLE, ordinary Wi-Fi on ESP32, source-only LoRa OTA, Terminal Chat, optional USB packet logging, and any board-qualified serial or Ethernet Companion transport. Bulk builds therefore diff --git a/docs/role_feature_switches.md b/docs/role_feature_switches.md index b2f3f73b..bb3de2ac 100644 --- a/docs/role_feature_switches.md +++ b/docs/role_feature_switches.md @@ -212,6 +212,11 @@ nRF52 Full Companion. `log start/stop` records to storage independently of live USB logging. Use `log erase` to delete that capture. +Fresh ESP32 Full infrastructure installs default to USB logging off. Upgrades +retain the saved choice. With device power saving enabled, an idle node can +sleep after the setup AP closes and all radio services stop; live USB logging, +an attached native USB host, and active MQTT/ESP-NOW still hold it awake. + For quiet packet capture in current firmware, use `set usb.debug off` and `set usb.logging on`. Verbose diagnostics default off for fresh and upgraded preferences without the new field. The debug choice is saved separately; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 426e2ffb..4b256a87 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -25,6 +25,9 @@ void companionPairingUiHilProbe(); #include "esp_pm.h" #include "esp_sleep.h" #include "esp_system.h" +#if defined(CONFIG_BLUEDROID_ENABLED) && !defined(MESH_USE_NIMBLE_ARDUINO) +#include +#endif #if defined(CONFIG_PM_ENABLE) && CONFIG_PM_ENABLE #define COMPANION_IDF_PM_AVAILABLE 1 #else @@ -3001,6 +3004,37 @@ public: }; static CompanionWirelessBackend companion_wireless; +#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE) \ + && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ + && !defined(ETHERNET_ENABLED) && !defined(SERIAL_RX) +static bool companionNativeUsbLightSleepReady() { + // Full compiles every transport, but a compiled driver is not an active + // receiver. Preserve live services and pending replies while permitting + // the same short sleep slice when those services have been turned off. + if (companion_wireless.enabled() != 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 +} +#endif + bool handleCompanionWirelessCommand(const char* command, char* reply, size_t size, CompanionWirelessSource source) { #if defined(BLE_PIN_CODE) @@ -3698,8 +3732,10 @@ void loop() { // The native-USB-only light-sleep path below bypasses ESP32Board::sleep. can_sleep = can_sleep && !mesh::isUsbLoggingEnabled(); #endif -#if defined(NRF52_PLATFORM) \ - || (defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE)) +#if defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE) + const bool usb_sleep_held = board.isUsbSleepHeld(); + can_sleep = can_sleep && !usb_sleep_held; +#elif defined(NRF52_PLATFORM) can_sleep = can_sleep && !board.isUsbHostConnected(); #endif #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ @@ -3714,22 +3750,22 @@ void loop() { #if defined(NRF52_PLATFORM) board.sleep(0); // nrf ignores seconds param, sleeps whenever possible #elif defined(ESP32_PLATFORM) -#if COMPANION_IDF_PM_AVAILABLE - // Yield long enough for ESP-IDF automatic light sleep to enter when no - // driver holds a power-management lock. - vTaskDelay(pdMS_TO_TICKS(10)); -#elif defined(ENABLE_USB_INTERFACE) \ +#if defined(ENABLE_USB_INTERFACE) \ && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ - && !defined(BLE_PIN_CODE) && !defined(WIFI_SSID) \ && !defined(ETHERNET_ENABLED) && !defined(SERIAL_RX) - // The stock Arduino core has no automatic light sleep. A short timer - // slice gives native-USB-only battery builds real light sleep without + // Automatic light sleep is disabled for native USB, even in a PM-enabled + // core. A short timer slice gives idle battery builds real light sleep without // delaying radio, GPS, button, or newly attached USB work by more than the // normal 10 ms loop cadence. can_sleep already proved no USB host is up. - if (esp_sleep_enable_timer_wakeup(10000ULL) != ESP_OK + if (!companionNativeUsbLightSleepReady() + || esp_sleep_enable_timer_wakeup(10000ULL) != ESP_OK || esp_light_sleep_start() != ESP_OK) { vTaskDelay(pdMS_TO_TICKS(10)); } +#elif COMPANION_IDF_PM_AVAILABLE + // Yield long enough for ESP-IDF automatic light sleep to enter when no + // driver holds a power-management lock. + vTaskDelay(pdMS_TO_TICKS(10)); #else // Connected transports need their own modem sleep and must retain the // normal FreeRTOS idle behavior. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index bdbcc9d4..5a405baa 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,6 +1,11 @@ #include "MyMesh.h" #if defined(ESP32_PLATFORM) #include +#if defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \ + || defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)) +#include +#include +#endif #endif #include #include @@ -10920,6 +10925,29 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) { } } +void MyMesh::suspendUnconfiguredSetupBridges() { +#if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) +#ifdef WITH_MQTT_BRIDGE + if (_cli.getObserverPrefs()->wifi_ssid[0]) return; +#else + char ssid[33] = {}; + WebConfigServer::loadStandaloneWiFi(ssid, sizeof(ssid), nullptr, 0); + if (ssid[0]) return; +#endif + _unconfigured_setup_espnow_suspended = true; +#ifdef WITH_ESPNOW_BRIDGE +#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) + if (espnow_bridge.isRunning()) espnow_bridge.end(); +#else + if (bridge.isRunning()) bridge.end(); +#endif +#endif +#ifdef WITH_MQTT_BRIDGE + if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end(); +#endif +#endif +} + bool MyMesh::startWebConfig(bool force_ap, char* reply) { if (_cli.getBoard()->isOTAUpdateRunning()) { strcpy(reply, "Err: OTA server is running - 'stop ota' first"); @@ -10930,6 +10958,23 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { : "Err: webconfig already running"); return true; } + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { + strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); + return true; + } +#ifdef WITH_MQTT_BRIDGE + // A live MQTT worker cannot be rolled back immediately if its stop times + // out. Require an explicit stop before a portal that would take its radio. + if ((isMqttBridgeRunning() || isMqttBridgeStopping()) && (force_ap +#if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) + || _cli.getObserverPrefs()->wifi_ssid[0] == 0 +#endif + )) { + strcpy(reply, isMqttBridgeStopping() ? "Err: MQTT bridge is stopping - retry shortly" + : "Err: MQTT bridge is running - 'set bridge off' first"); + return true; + } +#endif if (!_webconfig) { void* mqtt_prefs = nullptr; bool owns_wifi = true; @@ -10947,21 +10992,38 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { } } + const bool setup_was_suspended = _unconfigured_setup_espnow_suspended; +#ifdef WITH_ESPNOW_BRIDGE + const bool espnow_was_running = isEspNowBridgeRunning(); +#endif + suspendUnconfiguredSetupBridges(); + #ifdef WITH_MQTT_BRIDGE _webconfig->updateWiFiOwnership(!mqtt_bridge || (!mqtt_bridge->isRunning() && !mqtt_bridge->isStopping())); #endif + bool started; if (force_ap) { -#ifdef WITH_MQTT_BRIDGE - if (mqtt_bridge && mqtt_bridge->isRunning()) { - strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first"); - return true; + started = _webconfig->startSetupMode(reply); + } else { + started = _webconfig->startAutoMode(reply); + } + if (!started) { + // A failed portal must not consume a live operator-started transport. + // Restore actual pre-start state, never the saved first-boot defaults. + _unconfigured_setup_espnow_suspended = setup_was_suspended; + bool restored = true; +#ifdef WITH_ESPNOW_BRIDGE + if (espnow_was_running && !isEspNowBridgeRunning()) { + restored = setEspNowBridgeState(true) && restored; } #endif - _webconfig->startSetupMode(reply); - } else { - _webconfig->startAutoMode(reply); + _unconfigured_setup_espnow_suspended = setup_was_suspended; + if (!restored) { + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; bridge resume failed"); + } } return true; } @@ -12918,6 +12980,16 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { // the loop until reboot - otherwise a packet still queued here (busy / // duty-limited channel) is lost when the flash spins the loop and reboots. drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS); + const bool mqtt_was_running = mqtt_bridge && mqtt_bridge->isRunning(); +#ifdef WITH_ESPNOW_BRIDGE + const bool espnow_was_running = espnow_bridge.isRunning(); +#endif + auto resume_paused_bridges = [&]() { + if (mqtt_was_running) setMqttBridgeState(true); +#ifdef WITH_ESPNOW_BRIDGE + if (espnow_was_running) setEspNowBridgeState(true); +#endif + }; setBridgeState(false); // TODO: Replace this timed settle with a task-exit/join barrier once MQTT // teardown can prove that the idle task has reclaimed the worker resources. @@ -12930,13 +13002,13 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { if (mqtt_bridge && !mqtt_bridge->canFlashAfterStop()) { mesh::usbConsolePort().printf("OTA: aborted, MQTT stop did not complete cleanly - resuming bridge\r\n"); otaAlert("OTA aborted: MQTT stop unclean, bridge resumed"); - setBridgeState(true); + resume_paused_bridges(); } else if (!_cli.getBoard()->otaFromManifest(ota_resolve_base(_ota_update_channel), getFirmwareVer(), false, ota_reply)) { mesh::usbConsolePort().printf("OTA: aborted, resuming bridge - %s\r\n", ota_reply); char ota_alert_msg[160]; snprintf(ota_alert_msg, sizeof(ota_alert_msg), "OTA aborted: %s", ota_reply); otaAlert(ota_alert_msg); - setBridgeState(true); + resume_paused_bridges(); } // Success path: otaFromManifest() flashes and reboots into the new image // (never returns), so there is no in-boot "success" alert - the START alert @@ -12964,6 +13036,9 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { #endif // is pending dirty contacts write needed? +#if defined(ESP32_PLATFORM) + serviceIdleWiFi(); +#endif if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { const bool saved = acl.save(_fs); if (saved) { @@ -13546,9 +13621,59 @@ bool MyMesh::startNeighborDiscover(char* reply) { #endif // WITH_MQTT_NEIGHBORS // To check if there is pending work +#if defined(ESP32_PLATFORM) +void MyMesh::serviceIdleWiFi() { +#if (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \ + || defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))) \ + && (!defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW) \ + && (!defined(MESH_ESPNOW_RADIO) || !MESH_ESPNOW_RADIO) + if (_cli.getBoard()->isOTAUpdateRunning()) return; +#ifdef WITH_WEBCONFIG + if (isWebConfigActive()) return; +#endif +#ifdef WITH_MQTT_BRIDGE + if (_ota_update_at) return; + if (mqtt_bridge && (mqtt_bridge->isRunning() || mqtt_bridge->isStopping())) return; +#endif +#ifdef WITH_ESPNOW_BRIDGE + if (isEspNowBridgeRunning()) return; +#endif + // MQTT shutdown and WebConfig-to-OTA handoff retain STA intentionally. + // Reclaim it only after the last live owner is gone, using Arduino first + // so its private initialized/started cache agrees with the SDK driver. + if (WiFi.getMode() == WIFI_OFF && WiFi.channel() > 0 + && !WiFi.mode(WIFI_STA)) return; + wifi_mode_t sdk_mode = WIFI_MODE_NULL; + if (WiFi.getMode() == WIFI_OFF && esp_wifi_get_mode(&sdk_mode) != ESP_OK) return; + WiFi.setAutoReconnect(false); + WiFi.disconnect(false, false); + if (!WiFi.mode(WIFI_OFF)) return; + // A pure IDF ESP-NOW owner never created an Arduino facade, making OFF a + // no-op there. The ownership checks above also protect that startup path. + if (esp_wifi_get_mode(&sdk_mode) == ESP_OK) { + esp_wifi_stop(); + esp_wifi_deinit(); + } +#endif +} +#endif + bool MyMesh::hasPendingWork() const { if (isDualRadioActive()) return true; if (hasPendingOtaApply()) return true; +#ifdef WITH_WEBCONFIG + if (isWebConfigActive()) return true; +#endif +#if defined(ESP32_PLATFORM) \ + && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \ + || defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))) \ + && (!defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW) \ + && (!defined(MESH_ESPNOW_RADIO) || !MESH_ESPNOW_RADIO) + // Cleanup may fail while the Arduino facade already reports OFF. Require + // the live SDK driver to be gone before permitting manual light sleep. + wifi_mode_t sdk_mode = WIFI_MODE_NULL; + if (esp_wifi_get_mode(&sdk_mode) != ESP_ERR_WIFI_NOT_INIT) return true; +#endif #if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED) if (_local_cli_output.busy()) return true; #endif @@ -13556,6 +13681,9 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) const AbstractBridge* active_bridge = activeBridge(); if (active_bridge && active_bridge->isRunning()) return true; +#ifdef WITH_MQTT_BRIDGE + if (mqtt_bridge && mqtt_bridge->isStopping()) return true; +#endif #if defined(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE) if (isRs232BridgeRunning()) return true; #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 88fb0833..ad64b867 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -621,6 +621,9 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks // Retry ESP-NOW without taking over an active OTA session. MQTT creates its // WiFi task asynchronously, so it must first associate on the shared channel. bool startSharedEspNowBridgeIfReady() { +#ifdef WITH_WEBCONFIG + if (_unconfigured_setup_espnow_suspended) return false; +#endif #if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) ESPNowBridge& transport = espnow_bridge; if (!_prefs.espnow_bridge_enabled) return false; @@ -650,10 +653,17 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks #endif #ifdef WITH_WEBCONFIG WebConfigServer* _webconfig = nullptr; + // A first-boot setup session may pause saved bridge intent for this boot. + // Its retry loop must not revive ESP-NOW after the AP window ends. + bool _unconfigured_setup_espnow_suspended = false; + void suspendUnconfiguredSetupBridges(); bool _wc_batch_active = false; bool _wc_restart_pending = false; uint8_t _wc_slot_restart_mask = 0; #endif +#if defined(ESP32_PLATFORM) + void serviceIdleWiFi(); +#endif #if defined(WITH_MQTT_NEIGHBORS) // Neighbor-scope discovery: a snapshot of the neighbor table overlaid with an @@ -1393,6 +1403,9 @@ public: } if (espnow_bridge.isRunning()) return true; if (_cli.getBoard()->isOTAUpdateRunning()) return false; +#ifdef WITH_WEBCONFIG + _unconfigured_setup_espnow_suspended = false; +#endif shared_espnow_retry_at = 0; #ifdef WITH_MQTT_BRIDGE if (mqtt_bridge && mqtt_bridge->isRunning()) { @@ -1450,6 +1463,9 @@ public: // A standalone bridge shares its WiFi driver with the browser uploader. // Preserve saved intent, but do not restart it until OTA has stopped. if (enable && _cli.getBoard()->isOTAUpdateRunning()) return false; +#ifdef WITH_WEBCONFIG + if (enable) _unconfigured_setup_espnow_suspended = false; +#endif shared_espnow_retry_at = 0; #endif // Disabling an already-absent heap-backed bridge is successful and must @@ -1547,6 +1563,9 @@ public: #else #if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_RS232_BRIDGE) if (_cli.getBoard()->isOTAUpdateRunning()) return false; +#ifdef WITH_WEBCONFIG + _unconfigured_setup_espnow_suspended = false; +#endif shared_espnow_retry_at = 0; #endif #if defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE) diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index bc560eec..b3dbc0ab 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,6 +1,11 @@ #include "MyMesh.h" #if defined(ESP32_PLATFORM) #include +#if defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \ + || defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)) +#include +#include +#endif #endif #include #if defined(ENABLE_OTA) @@ -1948,6 +1953,25 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) { } } +void MyMesh::suspendUnconfiguredSetupBridges() { +#if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) +#ifdef WITH_MQTT_BRIDGE + if (_cli.getObserverPrefs()->wifi_ssid[0]) return; +#else + char ssid[33] = {}; + WebConfigServer::loadStandaloneWiFi(ssid, sizeof(ssid), nullptr, 0); + if (ssid[0]) return; +#endif + _unconfigured_setup_espnow_suspended = true; +#ifdef WITH_ESPNOW_BRIDGE + if (espnow_bridge.isRunning()) espnow_bridge.end(); +#endif +#ifdef WITH_MQTT_BRIDGE + if (bridge && bridge->isRunning()) bridge->end(); +#endif +#endif +} + bool MyMesh::startWebConfig(bool force_ap, char* reply) { if (_cli.getBoard()->isOTAUpdateRunning()) { strcpy(reply, "Err: OTA server is running - 'stop ota' first"); @@ -1958,6 +1982,21 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { : "Err: webconfig already running"); return true; } + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { + strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); + return true; + } +#ifdef WITH_MQTT_BRIDGE + if ((isMqttBridgeRunning() || isMqttBridgeStopping()) && (force_ap +#if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) + || _cli.getObserverPrefs()->wifi_ssid[0] == 0 +#endif + )) { + strcpy(reply, isMqttBridgeStopping() ? "Err: MQTT bridge is stopping - retry shortly" + : "Err: MQTT bridge is running - 'set bridge off' first"); + return true; + } +#endif if (!_webconfig) { void* mqtt_prefs = nullptr; bool owns_wifi = true; @@ -1974,21 +2013,36 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { } } + const bool setup_was_suspended = _unconfigured_setup_espnow_suspended; +#ifdef WITH_ESPNOW_BRIDGE + const bool espnow_was_running = isEspNowBridgeRunning(); +#endif + suspendUnconfiguredSetupBridges(); + #ifdef WITH_MQTT_BRIDGE _webconfig->updateWiFiOwnership(!bridge || (!bridge->isRunning() && !bridge->isStopping())); #endif + bool started; if (force_ap) { -#ifdef WITH_MQTT_BRIDGE - if (bridge && bridge->isRunning()) { - strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first"); - return true; + started = _webconfig->startSetupMode(reply); + } else { + started = _webconfig->startAutoMode(reply); + } + if (!started) { + _unconfigured_setup_espnow_suspended = setup_was_suspended; + bool restored = true; +#ifdef WITH_ESPNOW_BRIDGE + if (espnow_was_running && !isEspNowBridgeRunning()) { + restored = setEspNowBridgeState(true) && restored; } #endif - _webconfig->startSetupMode(reply); - } else { - _webconfig->startAutoMode(reply); + _unconfigured_setup_espnow_suspended = setup_was_suspended; + if (!restored) { + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; bridge resume failed"); + } } return true; } @@ -2780,6 +2834,9 @@ void MyMesh::loop() { // had deliberately stopped. mesh::usbConsolePort().printf("OTA: starting update\r\n"); const bool bridge_was_running = bridge && bridge->isRunning(); +#ifdef WITH_ESPNOW_BRIDGE + const bool espnow_was_running = espnow_bridge.isRunning(); +#endif drainOutbound(OTA_TX_DRAIN_TIMEOUT_MS); // A previously timed-out stop leaves the lifecycle dirty even though the @@ -2787,11 +2844,15 @@ void MyMesh::loop() { // later request must not bypass the teardown barrier merely because there // is no active task left to stop this time. bool may_flash = !bridge || bridge->canFlashAfterStop(); - if (bridge_was_running) { + if (bridge_was_running +#ifdef WITH_ESPNOW_BRIDGE + || espnow_was_running +#endif + ) { setBridgeState(false); // OTA must not write after a forced/timed-out MQTT shutdown: its TLS/heap // ownership is uncertain until a subsequent clean start/stop cycle. - may_flash = bridge && bridge->canFlashAfterStop(); + may_flash = !bridge || bridge->canFlashAfterStop(); if (!may_flash) { mesh::usbConsolePort().printf("OTA: aborted, MQTT stop did not complete cleanly\r\n"); } else { @@ -2811,9 +2872,12 @@ void MyMesh::loop() { // Successful otaFromManifest() reboots and never returns. Restore only a // bridge that was running before this attempt; leave an intentionally // stopped bridge stopped after any OTA refusal or download failure. - if (!may_flash && bridge_was_running) { + if (!may_flash) { mesh::usbConsolePort().printf("OTA: resuming bridge\r\n"); - setBridgeState(true); + if (bridge_was_running) setMqttBridgeState(true); +#ifdef WITH_ESPNOW_BRIDGE + if (espnow_was_running) setEspNowBridgeState(true); +#endif } } #endif @@ -2837,6 +2901,10 @@ void MyMesh::loop() { } } +#if defined(ESP32_PLATFORM) + serviceIdleWiFi(); +#endif + // TODO: periodically check for OLD/inactive entries in known_clients[], and evict // update uptime @@ -2981,14 +3049,62 @@ uint32_t MyMesh::getPowerSaveSleepSeconds(uint32_t max_secs) const { } // To check if there is pending work +#if defined(ESP32_PLATFORM) +void MyMesh::serviceIdleWiFi() { +#if (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \ + || defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))) \ + && (!defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW) \ + && (!defined(MESH_ESPNOW_RADIO) || !MESH_ESPNOW_RADIO) + if (_cli.getBoard()->isOTAUpdateRunning()) return; +#ifdef WITH_WEBCONFIG + if (isWebConfigActive()) return; +#endif +#ifdef WITH_MQTT_BRIDGE + if (_ota_update_at) return; + if (bridge && (bridge->isRunning() || bridge->isStopping())) return; +#endif +#ifdef WITH_ESPNOW_BRIDGE + if (isEspNowBridgeRunning()) return; +#endif + if (WiFi.getMode() == WIFI_OFF && WiFi.channel() > 0 + && !WiFi.mode(WIFI_STA)) return; + wifi_mode_t sdk_mode = WIFI_MODE_NULL; + if (WiFi.getMode() == WIFI_OFF && esp_wifi_get_mode(&sdk_mode) != ESP_OK) return; + WiFi.setAutoReconnect(false); + WiFi.disconnect(false, false); + if (!WiFi.mode(WIFI_OFF)) return; + if (esp_wifi_get_mode(&sdk_mode) == ESP_OK) { + esp_wifi_stop(); + esp_wifi_deinit(); + } +#endif +} +#endif + bool MyMesh::hasPendingWork() const { if (isDualRadioActive()) return true; if (hasPendingOtaApply()) return true; +#ifdef WITH_WEBCONFIG + if (isWebConfigActive()) return true; +#endif +#if defined(ESP32_PLATFORM) \ + && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) \ + || defined(LIGHTWEIGHT_WIFI_OTA) || (defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA))) \ + && (!defined(MESH_PRIMARY_ESPNOW) || !MESH_PRIMARY_ESPNOW) \ + && (!defined(MESH_ESPNOW_RADIO) || !MESH_ESPNOW_RADIO) + // Never sleep over an unowned driver whose cleanup failed or whose Arduino + // facade no longer reflects the live SDK state. + wifi_mode_t sdk_mode = WIFI_MODE_NULL; + if (esp_wifi_get_mode(&sdk_mode) != ESP_ERR_WIFI_NOT_INIT) return true; +#endif #if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED) if (_local_cli_output.busy()) return true; #endif #if defined(WITH_BRIDGE) if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep +#ifdef WITH_MQTT_BRIDGE + if (bridge && bridge->isStopping()) return true; +#endif #if defined(WITH_ESPNOW_BRIDGE) if (espnow_bridge.isRunning()) return true; #endif diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 77ce4abf..150e86cd 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -320,10 +320,15 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, #endif #ifdef WITH_WEBCONFIG WebConfigServer* _webconfig = nullptr; + bool _unconfigured_setup_espnow_suspended = false; + void suspendUnconfiguredSetupBridges(); bool _wc_batch_active = false; bool _wc_restart_pending = false; uint8_t _wc_slot_restart_mask = 0; #endif +#if defined(ESP32_PLATFORM) + void serviceIdleWiFi(); +#endif void addPost(ClientInfo* client, const char* postData); bool applySavedRadioParams(); @@ -579,6 +584,9 @@ public: // MQTT's WiFi task associates asynchronously. ESP-NOW must wait for that // station interface so it can share the AP-selected channel safely. bool startSharedEspNowBridgeIfReady() { +#ifdef WITH_WEBCONFIG + if (_unconfigured_setup_espnow_suspended) return false; +#endif if (espnow_bridge.isRunning()) return true; if (!_prefs.espnow_bridge_enabled) return false; // Keep the browser uploader's bridge pause in effect until OTA stops. @@ -661,6 +669,10 @@ public: return !espnow_bridge.isRunning(); } if (espnow_bridge.isRunning()) return true; + if (_cli.getBoard()->isOTAUpdateRunning()) return false; +#ifdef WITH_WEBCONFIG + _unconfigured_setup_espnow_suspended = false; +#endif shared_espnow_retry_at = 0; if (bridge && bridge->isRunning()) { startSharedEspNowBridgeIfReady(); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index e4517f97..a138df00 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -773,7 +773,14 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { // contain its appended byte, so they safely inherit the enabled default. _prefs->system_watchdog_enabled = 1; memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf)); +#if defined(ESP32_PLATFORM) && defined(MESHCORE_EXPANDED_PARTITION_PROFILE) + // Full carries diagnostics as an optional runtime service. Merely adding + // that capability must not hold an otherwise idle battery node awake. + // A saved logging preference below still takes precedence. + _prefs->usb_logging_enabled = 0; +#else _prefs->usb_logging_enabled = 1; +#endif _prefs->usb_debug_enabled = 0; _prefs->trace_when_repeat_off = 0; _prefs->ota_channel = 0; @@ -2941,6 +2948,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, "ERR: usage start ota [ap]"); return; } +#if defined(WITH_ESPNOW_BRIDGE) + const bool ota_was_running = _board->isOTAUpdateRunning(); + // Keep an outstanding pause if an earlier resume failed. Only a + // successful resume or an explicitly disabled saved intent clears it. +#endif bool webconfig_stopped = false; #if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) if (_callbacks->isWebConfigActive()) { @@ -2951,7 +2963,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re webconfig_stopped = true; } #endif -#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE) +#if defined(WITH_ESPNOW_BRIDGE) // Browser OTA owns the shared WiFi driver. An independent UART bridge // can keep running throughout the upload. const bool espnow_paused = _callbacks->isEspNowBridgeRunning(); @@ -2959,21 +2971,28 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re strcpy(reply, "ERR: could not pause ESP-NOW for OTA"); return; } + _wifi_ota_resume_espnow = _wifi_ota_resume_espnow || espnow_paused; #endif reply[0] = 0; if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) { if (!reply[0]) strcpy(reply, "Error"); -#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE) - if (espnow_paused && !_callbacks->setEspNowBridgeState(true)) { - const size_t used = strlen(reply); - snprintf(reply + used, 160 - used, "; ESP-NOW resume failed"); +#if defined(WITH_ESPNOW_BRIDGE) + if (espnow_paused && !ota_was_running) { + if (_callbacks->setEspNowBridgeState(true)) { + _wifi_ota_resume_espnow = false; + } else { + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; ESP-NOW resume failed"); + } } #endif } #if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) else { // Keep WiFi up, but release MQTT/TLS heap while the browser uploader runs. - _callbacks->setBridgeState(false); + const bool mqtt_paused = _callbacks->isMqttBridgeRunning(); + _wifi_ota_resume_mqtt = _wifi_ota_resume_mqtt || mqtt_paused; + if (mqtt_paused) _callbacks->setMqttBridgeState(false); } #endif #if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE) @@ -2987,30 +3006,40 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re snprintf(reply + used, 160 - used, "; WebConfig stopped"); } } else if (memcmp(command, "stop ota", 8) == 0 && (command[8] == 0 || command[8] == ' ')) { - if (!_board->stopOTAUpdate(reply)) { + const bool ota_stopped = _board->stopOTAUpdate(reply); + if (!ota_stopped) { if (!reply[0]) strcpy(reply, "Error"); } #if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) - else if (_prefs->bridge_enabled -#ifdef WITH_ESPNOW_BRIDGE - || _prefs->espnow_bridge_enabled -#endif - ) { - _callbacks->setBridgeState(true); + else { + if (_wifi_ota_resume_mqtt) { + if (!_prefs->bridge_enabled || _callbacks->setMqttBridgeState(true)) { + _wifi_ota_resume_mqtt = false; + } else { + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; MQTT resume failed"); + } + } } #endif -#if defined(WITH_ESPNOW_BRIDGE) && !defined(WITH_MQTT_BRIDGE) - else if ( -#if defined(WITH_RS232_BRIDGE) || defined(ESPNOW_BRIDGE_MERGED) +#if defined(WITH_ESPNOW_BRIDGE) + if (ota_stopped && !_board->isOTAUpdateRunning() && _wifi_ota_resume_espnow) { + const bool still_enabled = +#if defined(WITH_MQTT_BRIDGE) || defined(WITH_RS232_BRIDGE) || defined(ESPNOW_BRIDGE_MERGED) _prefs->espnow_bridge_enabled #else _prefs->bridge_enabled #endif - ) { - const bool resumed = _callbacks->setEspNowBridgeState(true); - const size_t used = strlen(reply); - snprintf(reply + used, 160 - used, resumed - ? "; ESP-NOW resumed" : "; ESP-NOW resume failed"); + != 0; + if (!still_enabled) { + _wifi_ota_resume_espnow = false; + } else { + const bool resumed = _callbacks->setEspNowBridgeState(true); + if (resumed) _wifi_ota_resume_espnow = false; + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, resumed + ? "; ESP-NOW resumed" : "; ESP-NOW resume failed"); + } } #endif } else if (memcmp(command, "clock", 5) == 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index ed67a03a..7d12e47b 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -793,6 +793,13 @@ class CommonCLI { NodePrefs* _prefs; CommonCLICallbacks* _callbacks; mesh::MainBoard* _board; +#if defined(WITH_ESPNOW_BRIDGE) \ + || (defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA)) + // Browser OTA restores only transports it actually paused, not saved + // defaults suspended by an unconfigured first-boot setup session. + bool _wifi_ota_resume_mqtt = false; + bool _wifi_ota_resume_espnow = false; +#endif #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) UserGpio _user_gpio; #endif diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index efb37958..aceaab47 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -60,6 +60,7 @@ bool ESP32Board::isUserGpioAvailable(uint8_t pin) const { (defined(ADMIN_PASSWORD) || defined(COMPANION_RADIO_FULL)) #include #include +#include #include #include #include @@ -298,22 +299,14 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { if (!use_ap) { ip = WiFi.localIP(); } else { - if (!ota_started_ap || !(WiFi.getMode() & WIFI_AP) - || static_cast(WiFi.softAPIP()) == 0) { - const IPAddress ap_ip(192, 168, 4, 1); - const IPAddress ap_mask(255, 255, 255, 0); - ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask) - && WiFi.softAP("MeshCore-OTA", nullptr, - mesh::wifi::accessPointChannel()); - } - // A valid IP does not imply a discoverable hotspot. An LR protocol bit - // left by ESP-NOW makes AP beacons incompatible with ordinary clients. - if (ota_started_ap && mesh::wifi::applyAccessPointProtocolMask() != ESP_OK) { - WiFi.softAPdisconnect(true); - ota_started_ap = false; - } + ota_started_radio = ota_started_radio || WiFi.getMode() == WIFI_OFF; + ota_started_ap = mesh::wifi::startOpenAccessPoint("MeshCore-OTA", ota_started_ap); if (!ota_started_ap) { inhibit_sleep = ota_server != nullptr; + if (!inhibit_sleep) { + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; + } strcpy(reply, "ERR: OTA WiFi failed"); return false; } @@ -324,6 +317,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { if (!lightweight_ota_server.begin(this)) { if (ota_started_ap) WiFi.softAPdisconnect(true); ota_started_ap = false; + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; inhibit_sleep = false; strcpy(reply, "ERR: OTA server failed"); return false; @@ -344,6 +339,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { bool ESP32Board::stopOTAUpdate(char reply[]) { if (ota_server == nullptr) { + if (ota_started_ap) WiFi.softAPdisconnect(true); + ota_started_ap = false; + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; + inhibit_sleep = false; strcpy(reply, "OK - OTA not running"); return true; } @@ -355,6 +355,8 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { ota_server = nullptr; if (ota_started_ap) WiFi.softAPdisconnect(true); ota_started_ap = false; + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; inhibit_sleep = false; strcpy(reply, "OK - OTA stopped"); MESH_DEBUG_PRINTLN("stopOTAUpdate: %s", reply); @@ -364,6 +366,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { #elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only #include #include +#include #include #include #include "../../arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.h" @@ -394,20 +397,14 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { if (!use_ap) { ip = WiFi.localIP(); } else { - if (!ota_started_ap || !(WiFi.getMode() & WIFI_AP) - || static_cast(WiFi.softAPIP()) == 0) { - const IPAddress ap_ip(192, 168, 4, 1); - const IPAddress ap_mask(255, 255, 255, 0); - ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask) - && WiFi.softAP("MeshCore-OTA", NULL, - mesh::wifi::accessPointChannel()); - } - if (ota_started_ap && mesh::wifi::applyAccessPointProtocolMask() != ESP_OK) { - WiFi.softAPdisconnect(true); - ota_started_ap = false; - } + ota_started_radio = ota_started_radio || WiFi.getMode() == WIFI_OFF; + ota_started_ap = mesh::wifi::startOpenAccessPoint("MeshCore-OTA", ota_started_ap); if (!ota_started_ap) { inhibit_sleep = ota_server != nullptr; + if (!inhibit_sleep) { + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; + } strcpy(reply, "ERR: OTA WiFi failed"); return false; } @@ -439,6 +436,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { if (async_ota_host == nullptr) { if (ota_started_ap) WiFi.softAPdisconnect(true); ota_started_ap = false; + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; inhibit_sleep = false; strcpy(reply, "ERR: OTA server allocation failed"); return false; @@ -462,6 +461,8 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { ota_server = nullptr; if (ota_started_ap) WiFi.softAPdisconnect(true); ota_started_ap = false; + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; inhibit_sleep = false; strcpy(reply, "ERR: OTA server failed; retry start ota"); return false; @@ -472,6 +473,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { bool ESP32Board::stopOTAUpdate(char reply[]) { if (ota_server == nullptr) { + if (ota_started_ap) WiFi.softAPdisconnect(true); + ota_started_ap = false; + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; + inhibit_sleep = false; strcpy(reply, "OK - OTA not running"); return true; } @@ -484,6 +490,8 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { ota_server = nullptr; if (ota_started_ap) WiFi.softAPdisconnect(true); ota_started_ap = false; + mesh::wifi::stopTemporaryAccessPointRadio(ota_started_radio); + ota_started_radio = false; inhibit_sleep = false; strcpy(reply, "OK - OTA stopped"); diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index a79c5956..e4e63166 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -40,6 +40,7 @@ protected: uint8_t startup_reason; bool inhibit_sleep = false; bool ota_started_ap = false; + bool ota_started_radio = false; #if MESH_ESP32_USB_CONSOLE_COOPERATIVE mesh::UsbHostSleepPolicy usb_host_sleep_policy; #endif @@ -117,8 +118,8 @@ public: // after a Pi closes its serial port). Guard every caller here. // Sample even when another blocker is active, so an open console/OTA does // not leave the last positive native USB host signal stale. - const bool usb_host_connected = isUsbHostConnected(); - bool keep_awake = inhibit_sleep || usb_host_connected || isRadioTestActive(); + const bool usb_sleep_held = isUsbSleepHeld(); + bool keep_awake = inhibit_sleep || usb_sleep_held || isRadioTestActive(); #if !defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT // External USB-to-UART chips cannot report host attachment here. Light // sleep loses their first input bytes; UART wake loses the wake character @@ -126,13 +127,6 @@ public: // Keep an enabled console available even before its first command. keep_awake = keep_awake || static_cast(Serial); #endif -#if MESH_ESP32_USB_CONSOLE_COOPERATIVE - // HWCDC loses its SOF host signal after about 5 ms. A warm Pi reboot, - // especially on a USB 1.1 bus, must not put us in a 30-second light sleep - // while the host is returning and trying to enumerate this same radio. - keep_awake = keep_awake || usb_host_sleep_policy.shouldKeepAwake( - millis(), MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS); -#endif #if MESH_USB_LOGGING_AVAILABLE // A live logging stream must also remain available before a host opens // it and across host disconnects. Compiled-out logging is not a blocker. @@ -295,6 +289,18 @@ public: inhibit_sleep = inhibit; } + bool isUsbSleepHeld() { + // Share the native host-loss grace with short Companion sleep slices. + // Sample even when another inhibitor is active, preserving reconnects. + const bool host_connected = isUsbHostConnected(); +#if MESH_ESP32_USB_CONSOLE_COOPERATIVE + return host_connected || usb_host_sleep_policy.shouldKeepAwake( + millis(), MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS); +#else + return host_connected; +#endif + } + uint32_t getResetReason() const override { return esp_reset_reason(); } diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index d83e571c..4996bd4b 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -72,10 +72,12 @@ namespace mesh { -#if defined(ENABLE_USB_INTERFACE) +#if defined(ENABLE_USB_INTERFACE) \ + || (defined(ESP32_PLATFORM) && defined(MESHCORE_EXPANDED_PARTITION_PROFILE)) // A USB Companion owns its primary stream for framed traffic until saved // preferences are loaded. Starting disabled prevents early boot diagnostics // from corrupting that stream before single-TTY builds can enter terminal mode. +// Full infrastructure likewise waits for its saved runtime logging preference. static std::atomic usb_logging_enabled{false}; #else static std::atomic usb_logging_enabled{true}; diff --git a/src/helpers/WiFiSetupPortal.cpp b/src/helpers/WiFiSetupPortal.cpp index 9ccf18bc..ca7cb900 100644 --- a/src/helpers/WiFiSetupPortal.cpp +++ b/src/helpers/WiFiSetupPortal.cpp @@ -13,13 +13,13 @@ #include #include #include +#include #include #include namespace { static const IPAddress SETUP_IP(192, 168, 4, 1); -static const IPAddress SETUP_MASK(255, 255, 255, 0); static const uint32_t CONNECT_TIMEOUT_MS = 20000; // The rendered success page remains in the browser after the setup SSID // disappears; allow one second for the HTTP response to flush first. @@ -29,6 +29,7 @@ struct PortalImpl { WiFiServer server{80}; DNSServer dns; TaskHandle_t task = nullptr; + bool started_radio = false; WiFiSetupPortal::SaveCallback save_callback = nullptr; void* callback_context = nullptr; volatile bool* active = nullptr; @@ -302,6 +303,8 @@ static void portalTask(void* arg) { impl->dns.stop(); impl->server.stop(); WiFi.softAPdisconnect(true); + mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio); + impl->started_radio = false; if (impl->active) *impl->active = false; impl->task = nullptr; vTaskDelete(nullptr); @@ -333,20 +336,17 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi sizeof(impl->ap_name) - 1); impl->ap_name[sizeof(impl->ap_name) - 1] = 0; - WiFi.mode(WIFI_AP_STA); - if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK) - || !WiFi.softAP(impl->ap_name, nullptr, - mesh::wifi::accessPointChannel()) - || mesh::wifi::applyAccessPointProtocolMask() != ESP_OK - || esp_wifi_set_protocol( - WIFI_IF_STA, - mesh::wifi::kProtocolMask) - != ESP_OK) { + impl->started_radio = WiFi.getMode() == WIFI_OFF; + if (!mesh::wifi::startOpenAccessPoint(impl->ap_name, false)) { WiFi.softAPdisconnect(true); + mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio); + impl->started_radio = false; return false; } if (!impl->dns.start(53, "*", SETUP_IP)) { WiFi.softAPdisconnect(true); + mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio); + impl->started_radio = false; return false; } impl->server.begin(); @@ -358,6 +358,8 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi impl->dns.stop(); impl->server.stop(); WiFi.softAPdisconnect(true); + mesh::wifi::stopTemporaryAccessPointRadio(impl->started_radio); + impl->started_radio = false; impl->task = nullptr; return false; } diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 6e3e621d..be0b56a1 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -41,6 +41,7 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28, #include "helpers/WebConfigKeys.h" #include "helpers/WiFiPowerSave.h" #include "helpers/esp32/WiFiRadioPolicy.h" +#include "helpers/esp32/WiFiAccessPointPolicy.h" #include "helpers/esp32/WiFiStationPolicy.h" // ESPAsyncWebServer closes every ordinary response, so splitting the UI into @@ -819,28 +820,14 @@ static bool setupAccessPointReady(const char* ssid, int channel) { static bool scanSetupNetworks() { // Populate the picker before the AP starts, while off-channel scanning // cannot interrupt discovery or a client's first connection. - WiFi.scanDelete(); const uint8_t channel = mesh::wifi::stationScanChannel(); - const int16_t scan_result = WiFi.scanNetworks(true, false, false, 200, channel); const uint32_t scan_started = millis(); - while (WiFi.scanComplete() == WIFI_SCAN_RUNNING - && millis() - scan_started < 3600) delay(20); + const bool prepared = mesh::wifi::scanBeforeAccessPoint(); mesh::usbDebugPort().printf( - "WebConfig pre-AP scan: channel=%u start=%d result=%d elapsed=%u\n", - (unsigned)channel, (int)scan_result, (int)WiFi.scanComplete(), + "WebConfig pre-AP scan: channel=%u result=%d elapsed=%u\n", + (unsigned)channel, (int)WiFi.scanComplete(), (unsigned)(millis() - scan_started)); - // Arduino's scanComplete() can clear its running flag on a facade timeout - // before the SDK scanner stops. Also cancel FAILED scans before enabling AP. - if (WiFi.scanComplete() < 0) { - if (esp_wifi_scan_stop() != ESP_OK) return false; - const uint32_t stop_started = millis(); - while (WiFi.scanComplete() == WIFI_SCAN_RUNNING - && millis() - stop_started < 100) delay(20); - WiFi.scanDelete(); - } - // A failed scan still allows manual SSID entry and a browser rescan. An - // active scanner must stop before we enable the AP interface. - return WiFi.scanComplete() != WIFI_SCAN_RUNNING; + return prepared; } bool WebConfigServer::startSetupMode(char reply[]) { diff --git a/src/helpers/esp32/WiFiAccessPointPolicy.h b/src/helpers/esp32/WiFiAccessPointPolicy.h new file mode 100644 index 00000000..f416c5a7 --- /dev/null +++ b/src/helpers/esp32/WiFiAccessPointPolicy.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include + +namespace mesh { +namespace wifi { + +inline bool accessPointStarted() { +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + return WiFi.AP.started(); +#else + return (WiFi.getStatusBits() & AP_STARTED_BIT) != 0; +#endif +} + +// Arduino setters and a configured netif IP can outlive the AP_START event. +// Read the live driver before telling an operator that the hotspot is ready. +inline bool openAccessPointReady(const char* ssid, int channel) { + wifi_mode_t mode = WIFI_MODE_NULL; + wifi_config_t config = {}; + uint8_t protocol = 0; + const size_t length = strlen(ssid); + return accessPointStarted() + && esp_wifi_get_mode(&mode) == ESP_OK && (mode & WIFI_MODE_AP) + && esp_wifi_get_config(WIFI_IF_AP, &config) == ESP_OK + && config.ap.ssid_len == length + && memcmp(config.ap.ssid, ssid, length) == 0 + && config.ap.ssid_hidden == 0 && config.ap.authmode == WIFI_AUTH_OPEN + && config.ap.channel == channel + && esp_wifi_get_protocol(WIFI_IF_AP, &protocol) == ESP_OK + && protocol == kAccessPointProtocolMask + && static_cast(WiFi.softAPIP()) != 0; +} + +inline bool finishAccessPointScan() { + // A failed Arduino scan can still have an active SDK scanner. scanDelete() + // only releases results; cancel that scanner before enabling AP beacons. + if (WiFi.scanComplete() < 0) { + if (esp_wifi_scan_stop() != ESP_OK) return false; + const uint32_t started = millis(); + while (WiFi.scanComplete() == WIFI_SCAN_RUNNING + && millis() - started < 100) delay(20); + WiFi.scanDelete(); + } + return WiFi.scanComplete() != WIFI_SCAN_RUNNING; +} + +inline bool scanBeforeAccessPoint() { + // Use the same STA-only scan-first startup as WebConfig, including compact + // OTA images without WebConfig. Shared ESP-NOW stays on its home channel. + WiFi.scanDelete(); + WiFi.scanNetworks(true, false, false, 200, stationScanChannel()); + const uint32_t started = millis(); + while (WiFi.scanComplete() == WIFI_SCAN_RUNNING + && millis() - started < 3600) delay(20); + return finishAccessPointScan(); +} + +inline bool startOpenAccessPoint(const char* ssid, bool reuse_existing) { + const int channel = accessPointChannel(); + // Repeated OTA commands retain joined clients when the AP is already live. + if (reuse_existing && applyAccessPointProtocolMask() == ESP_OK + && openAccessPointReady(ssid, channel)) return true; + + const bool connected = WiFi.status() == WL_CONNECTED + && static_cast(WiFi.localIP()) != 0; + // Removing only AP keeps a station association and any ESP-NOW owner. It + // also initializes a cold, previously disabled radio through Arduino. + if (!WiFi.mode(WIFI_STA)) return false; + const uint32_t stopped = millis(); + while (accessPointStarted() && millis() - stopped < 300) delay(20); + if (accessPointStarted() || applyProtocolMask(WIFI_IF_STA) != ESP_OK) + return false; + // Do not scan away from a live LAN connection. An offline radio needs the + // scan-first preparation before its first AP, rather than AP-only startup. + if (!(connected ? finishAccessPointScan() : scanBeforeAccessPoint())) + return false; + + const IPAddress ip(192, 168, 4, 1); + const IPAddress mask(255, 255, 255, 0); + bool ready = WiFi.softAPConfig(ip, ip, mask) + && WiFi.softAP(ssid, nullptr, channel) + && applyAccessPointProtocolMask() == ESP_OK; + if (ready) { + ready = false; + const uint32_t started = millis(); + do { + ready = openAccessPointReady(ssid, channel); + if (!ready) delay(20); + } while (!ready && millis() - started < 300); + } + if (!ready) WiFi.softAPdisconnect(true); + return ready; +} + +inline void stopTemporaryAccessPointRadio(bool started_radio) { + // Only restore OFF when this AP initialized the radio. A station inherited + // from WebConfig/MQTT belongs to the caller's service ownership policy. + if (started_radio && !espNowChannelConstrained() + && WiFi.status() != WL_CONNECTED) WiFi.mode(WIFI_OFF); +} + +} // namespace wifi +} // namespace mesh diff --git a/test/fixtures/rs232_mqtt_runtime.cpp b/test/fixtures/rs232_mqtt_runtime.cpp index 0675cc21..af380185 100644 --- a/test/fixtures/rs232_mqtt_runtime.cpp +++ b/test/fixtures/rs232_mqtt_runtime.cpp @@ -233,6 +233,7 @@ public: MyMesh* _callbacks; FakeSensors* _sensors; FakeBoard* _board; + @OTA_STATE@ NodePrefs stored; unsigned saves = 0; bool fail_save = false; diff --git a/test/fixtures/webconfig_station_owner.cpp b/test/fixtures/webconfig_station_owner.cpp index f5b83c60..454a9331 100644 --- a/test/fixtures/webconfig_station_owner.cpp +++ b/test/fixtures/webconfig_station_owner.cpp @@ -1,6 +1,7 @@ #include #include #include +#include struct Worker { bool running = false, stopping = false; bool isRunning() const { return running; } @@ -22,23 +23,28 @@ struct WebConfigServer { bool isRunning() const { return false; } bool isStopping() const { return false; } void updateWiFiOwnership(bool owner) { owns = owner; } - void startAutoMode(char* reply) { strcpy(reply, "starting"); } - void startSetupMode(char* reply) { strcpy(reply, "setup"); } + bool startAutoMode(char* reply) { strcpy(reply, "starting"); return true; } + bool startSetupMode(char* reply) { strcpy(reply, "setup"); return true; } }; struct MyMesh { Worker* @WORKER@ = nullptr; CLI _cli; WebConfigServer* _webconfig = nullptr; + bool _unconfigured_setup_espnow_suspended = false; + bool isMqttBridgeRunning() const { return @WORKER@ && @WORKER@->isRunning(); } + bool isMqttBridgeStopping() const { return @WORKER@ && @WORKER@->isStopping(); } struct { unsigned char pub_key[32] = {}; } self_id; const char* getFirmwareVer() const { return "test"; } const char* getBuildDate() const { return "test"; } const char* getRole() const { return "test"; } bool startWebConfig(bool force_ap, char* reply); + void suspendUnconfiguredSetupBridges(); void refresh() { @REFRESH@ } }; @START@ +@SUSPEND@ int main() { MyMesh mesh; Worker worker; diff --git a/test/fixtures/wifi_ota_start.cpp b/test/fixtures/wifi_ota_start.cpp index 28a977b7..92bfd475 100644 --- a/test/fixtures/wifi_ota_start.cpp +++ b/test/fixtures/wifi_ota_start.cpp @@ -1,17 +1,23 @@ // WiFi and the HTTP server are peripheral fakes; both OTA lifecycle methods // are inserted verbatim from ESP32Board.cpp by test_wifi_ota_start.py. #include +#include #include #include #include +#include #include #include #define MESH_DEBUG_PRINTLN(...) ((void)0) constexpr int WL_CONNECTED = 3; constexpr int HTTP_GET = 0; -constexpr int WIFI_AP = 2; -constexpr int ESP_OK = 0, WIFI_IF_AP = 1; +using wifi_mode_t = int; +constexpr int WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2; +constexpr int WIFI_MODE_NULL = WIFI_OFF, WIFI_MODE_AP = WIFI_AP; +constexpr int ESP_OK = 0, WIFI_IF_AP = 1, WIFI_IF_STA = 0; +constexpr int WIFI_AUTH_OPEN = 0; +constexpr int WIFI_SCAN_RUNNING = -1, WIFI_SCAN_FAILED = -2, AP_STARTED_BIT = 1; constexpr int LISTEN = 10; bool async_bind_allowed = true; constexpr uint8_t WIFI_PROTOCOL_11B = 1, WIFI_PROTOCOL_11G = 2; @@ -19,6 +25,19 @@ constexpr uint8_t WIFI_PROTOCOL_11N = 4, WIFI_PROTOCOL_LR = 8; using esp_err_t = int; bool allow_protocol_reset = true; bool constrain_espnow_channel = true; +struct wifi_config_t { + struct { + uint8_t ssid[32] = {}; + uint8_t ssid_len = 0, ssid_hidden = 0, channel = 1; + int authmode = WIFI_AUTH_OPEN; + } ap; +}; +enum class Fault { None, Ssid, Hidden, Channel, Protocol, Mode, StartEvent, ZeroIp, + DelayedStart, ModeRead, ConfigRead, ProtocolRead }; +enum class ScanFault { None, FailedFacade, StopRejected, Timeout, DelayedStop }; +static unsigned clock_ms = 100; +unsigned millis() { return clock_ms; } +void delay(unsigned); struct IPAddress { std::string value; IPAddress() = default; @@ -30,37 +49,121 @@ struct IPAddress { explicit operator uint32_t() const { return value.empty() || value == "0.0.0.0" ? 0 : 1; } }; struct WiFiFake { - bool connected = true, ap = false, allow_ap = true; - unsigned starts = 0; + bool connected = true, ap = false, allow_ap = true, radio = true, ap_started = false; + bool mode_allowed = true, sdk_scan_active = false; + unsigned starts = 0, sta_preparations = 0, radio_stops = 0, scans = 0, scan_stops = 0; + unsigned pending_start = 0, pending_stop = 0, scan_remaining = 0; + int scan_result = WIFI_SCAN_FAILED; + Fault fault = Fault::None; + ScanFault scan_fault = ScanFault::None; + wifi_config_t live_config; + struct APStatus { bool started() const; } AP; int ap_channel = 0; uint8_t ap_protocol = 15; // Valid driver state, but LR beacons hide the AP from ordinary clients. int status() const { return connected ? WL_CONNECTED : 0; } - int getMode() const { return ap ? WIFI_AP : 0; } + int getMode() const { return (ap ? WIFI_AP : 0) | (radio ? WIFI_STA : 0); } + int getStatusBits() const { return ap_started ? AP_STARTED_BIT : 0; } + bool mode(int requested) { + if (!mode_allowed) return false; + radio = requested != WIFI_OFF; + if (!radio) { ++radio_stops; connected = false; } + if (requested == WIFI_STA) ++sta_preparations; + if (!(requested & WIFI_AP)) { + ap = false; + if (ap_started) pending_stop = 60; + } + return true; + } IPAddress localIP() const { return IPAddress(10, 20, 30, 40); } - IPAddress softAPIP() const { return ap ? IPAddress(192, 168, 4, 1) : IPAddress(0, 0, 0, 0); } + // A configured netif IP is independent from the live SDK AP/event state. + IPAddress softAPIP() const { return fault != Fault::ZeroIp && ap + ? IPAddress(192, 168, 4, 1) : IPAddress(0, 0, 0, 0); } bool softAPConfig(IPAddress ip, IPAddress gateway, IPAddress mask) { assert(ip.value == "192.168.4.1" && gateway.value == ip.value); assert(mask.value == "255.255.255.0"); + assert(!sdk_scan_active); return allow_ap; } bool softAP(const char* ssid, const char* password, int channel = 1) { - assert(strcmp(ssid, "MeshCore-OTA") == 0 && password == nullptr); + assert(ssid && strlen(ssid) <= 32 && password == nullptr); ap_channel = channel; - ++starts; ap = allow_ap; return ap; + ++starts; ap = allow_ap; radio = true; + if (ap) { + live_config = {}; + const char* live_ssid = fault == Fault::Ssid ? "old-hotspot" : ssid; + live_config.ap.ssid_len = uint8_t(strlen(live_ssid)); + memcpy(live_config.ap.ssid, live_ssid, strlen(live_ssid)); + live_config.ap.ssid_hidden = fault == Fault::Hidden; + live_config.ap.channel = fault == Fault::Channel ? 11 : channel; + ap_started = fault != Fault::StartEvent && fault != Fault::DelayedStart; + if (fault == Fault::DelayedStart) pending_start = 60; + } + return ap; + } + void softAPdisconnect(bool) { ap = false; ap_started = false; } + void scanDelete() { if (!sdk_scan_active) scan_result = WIFI_SCAN_FAILED; } + int scanComplete() const { return scan_result; } + int scanNetworks(bool async, bool hidden, bool passive, unsigned maximum, uint8_t channel) { + assert(async && !hidden && !passive && maximum == 200); + assert(channel == (constrain_espnow_channel ? 6 : 0)); + assert(radio && !ap && !ap_started); + ++scans; sdk_scan_active = true; scan_result = WIFI_SCAN_RUNNING; + if (scan_fault == ScanFault::None) scan_remaining = 100; + else if (scan_fault == ScanFault::FailedFacade || scan_fault == ScanFault::StopRejected) + scan_result = WIFI_SCAN_FAILED; + return scan_result; } - void softAPdisconnect(bool) { ap = false; } } WiFi; -esp_err_t esp_wifi_set_protocol(int interface_id, uint8_t protocols) { +bool WiFiFake::APStatus::started() const { return WiFi.ap_started; } +void delay(unsigned amount) { + clock_ms += amount; + auto consume = [amount](unsigned& pending) { + if (!pending) return false; + pending -= std::min(pending, amount); return pending == 0; + }; + if (consume(WiFi.pending_stop)) WiFi.ap_started = false; + if (consume(WiFi.pending_start)) WiFi.ap_started = true; + if (consume(WiFi.scan_remaining)) { + WiFi.sdk_scan_active = false; + WiFi.scan_result = WiFi.scan_fault == ScanFault::None ? 2 : WIFI_SCAN_FAILED; + } +} +int esp_wifi_scan_stop() { + ++WiFi.scan_stops; + if (WiFi.scan_fault == ScanFault::StopRejected) return -1; + if (WiFi.scan_fault == ScanFault::DelayedStop) WiFi.scan_remaining = 60; + else { WiFi.sdk_scan_active = false; WiFi.scan_result = WIFI_SCAN_FAILED; } + return ESP_OK; +} +int esp_wifi_get_mode(wifi_mode_t* value) { + if (WiFi.fault == Fault::ModeRead) return -1; + *value = WiFi.fault == Fault::Mode ? WIFI_STA : WiFi.getMode(); return ESP_OK; +} +int esp_wifi_get_config(int interface_id, wifi_config_t* value) { assert(interface_id == WIFI_IF_AP); + if (WiFi.fault == Fault::ConfigRead) return -1; + *value = WiFi.live_config; return ESP_OK; +} +int esp_wifi_get_protocol(int interface_id, uint8_t* value) { + assert(interface_id == WIFI_IF_AP); + if (WiFi.fault == Fault::ProtocolRead) return -1; + *value = WiFi.fault == Fault::Protocol ? WIFI_PROTOCOL_LR : WiFi.ap_protocol; + return ESP_OK; +} +esp_err_t esp_wifi_set_protocol(int interface_id, uint8_t protocols) { + assert(interface_id == WIFI_IF_AP || interface_id == WIFI_IF_STA); if (!allow_protocol_reset) return -1; - WiFi.ap_protocol = protocols; + if (interface_id == WIFI_IF_AP) WiFi.ap_protocol = protocols; return ESP_OK; } namespace mesh { namespace wifi { bool espNowChannelConstrained() { return constrain_espnow_channel; } uint8_t activeEspNowChannel() { return 6; } +uint8_t stationScanChannel() { return constrain_espnow_channel ? 6 : 0; } +esp_err_t applyProtocolMask(int interface_id) { return esp_wifi_set_protocol(interface_id, 15); } @AP_PROTOCOL_POLICY@ } } +@AP_LIFECYCLE_POLICY@ static unsigned server_starts = 0; struct AsyncWebServerRequest { void send(int, const char*, const char*) {} @@ -85,6 +188,7 @@ static int SPIFFS; struct ESP32Board { bool inhibit_sleep = false; bool ota_started_ap = false; + bool ota_started_radio = false; #ifdef LIGHTWEIGHT_WIFI_OTA void* ota_server = nullptr; #else @@ -180,6 +284,7 @@ int main() { lightweight_ota_server.allow_stop = true; #endif assert(board.stopOTAUpdate(reply.text)); + assert(WiFi.scans == 0); // Raising an AP preserves the live LAN without scanning. assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap && WiFi.connected); constrain_espnow_channel = false; @@ -204,6 +309,58 @@ int main() { assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap); async_bind_allowed = true; #endif + // Cold start/stop must restore OFF without leaking the temporary STA radio. + WiFi.allow_ap = true; + WiFi.radio = false; + WiFi.connected = false; + start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); + assert(WiFi.scans > 0); // Offline OTA uses STA-only scan-first, including minimal. + assert(board.ota_started_radio && WiFi.radio && WiFi.ap_started); + assert(board.stopOTAUpdate(reply.text)); + assert(!WiFi.radio && !board.ota_started_radio); + + // A facade-successful AP cannot report success with any unusable live state. + for (Fault fault : {Fault::Ssid, Fault::Hidden, Fault::Channel, Fault::Protocol, + Fault::Mode, Fault::StartEvent, Fault::ZeroIp, + Fault::ModeRead, Fault::ConfigRead, Fault::ProtocolRead}) { + WiFi.fault = fault; + const unsigned started = millis(); + assert(!board.startOTAUpdate("test", reply.text, true)); + assert(strcmp(reply.text, "ERR: OTA WiFi failed") == 0); + assert(!WiFi.ap && !WiFi.radio && !board.inhibit_sleep && !board.ota_server); + assert(millis() - started < 5000); + } + WiFi.fault = Fault::DelayedStart; + start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); + assert(WiFi.ap_started); + assert(board.stopOTAUpdate(reply.text)); + WiFi.fault = Fault::None; + // Failed/timeout scans must stop in the SDK before a hotspot can start. + for (ScanFault fault : {ScanFault::FailedFacade, ScanFault::Timeout, ScanFault::DelayedStop}) { + WiFi.scan_fault = fault; + const unsigned started = millis(); + start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); + assert(!WiFi.sdk_scan_active && millis() - started < 5000); + assert(board.stopOTAUpdate(reply.text)); + } + WiFi.scan_fault = ScanFault::StopRejected; + const unsigned ap_before_scan_failure = WiFi.starts; + assert(!board.startOTAUpdate("test", reply.text, true)); + assert(WiFi.starts == ap_before_scan_failure && !WiFi.ap && !WiFi.radio); + WiFi.sdk_scan_active = false; + WiFi.scan_fault = ScanFault::None; + // A later ESP-NOW owner or associated station protects its radio on stop. + start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); + constrain_espnow_channel = true; + assert(board.stopOTAUpdate(reply.text)); + assert(WiFi.radio && !WiFi.ap); + constrain_espnow_channel = false; + WiFi.radio = false; + start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); + WiFi.connected = true; + assert(board.stopOTAUpdate(reply.text)); + assert(WiFi.radio && WiFi.connected); + WiFi.connected = false; // A pending wireless shutdown must not race a newly started uploader. struct Backend : mesh::wireless::Backend { uint8_t active = mesh::wireless::All; diff --git a/test/test_esp32_full_power_lifecycle.py b/test/test_esp32_full_power_lifecycle.py new file mode 100644 index 00000000..a7853a80 --- /dev/null +++ b/test/test_esp32_full_power_lifecycle.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +"""Execute infrastructure startup, bridge retries, OTA resume, and idle teardown. + +The role methods, preference defaults, automatic setup block, and deferred OTA +block are extracted from production. Only hardware/server endpoints are mocked. +""" +from pathlib import Path +import unittest + +from test_replay_reset_integration import extract_braced +import test_wifi_ota_start as wifi_start + +ROOT = Path(__file__).resolve().parents[1] +FLAGS = ["-DESP32_PLATFORM=1", "-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1", + "-DWITH_BRIDGE=1", "-DWITH_WEBCONFIG=1", "-DWITH_MQTT_BRIDGE=1", + "-DWITH_ESPNOW_BRIDGE=1", "-DLIGHTWEIGHT_WIFI_OTA=1", + "-DOTA_MANIFEST_BASE=1", "-DMESHCORE_EXPANDED_PARTITION_PROFILE=1", + "-DWEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS=1800000"] + +FIXTURE = r''' +#include +#include +#include +#include +#include +#include +#include +uint32_t now = 0; +uint32_t millis() { return now; } +bool millisHasNowPassed(uint32_t deadline) { return int32_t(now - deadline) >= 0; } +void delay(unsigned) {} +using wifi_mode_t = int; +constexpr int WIFI_OFF = 0, WIFI_MODE_NULL = 0, WIFI_STA = 1, WIFI_AP_STA = 3; +constexpr int ESP_OK = 0; +constexpr int ESP_ERR_WIFI_NOT_INIT = -1; +bool sdk_initialized = false, sdk_started = false; +bool fail_sdk_stop = false, fail_sdk_deinit = false; +int sdk_mode = WIFI_OFF, sdk_channel = 1; +unsigned sdk_stops = 0, sdk_deinits = 0; +int esp_wifi_get_mode(wifi_mode_t* mode) { + if (!sdk_initialized) return -1; + *mode = sdk_mode; return ESP_OK; +} +int esp_wifi_stop() { + ++sdk_stops; if (fail_sdk_stop) return -2; + sdk_started = false; return ESP_OK; +} +int esp_wifi_deinit() { + ++sdk_deinits; if (fail_sdk_deinit || sdk_started) return -2; + sdk_initialized = sdk_started = false; sdk_mode = WIFI_OFF; + return ESP_OK; +} +struct WiFiMock { + bool initialized_cache = false, started_cache = false, connected = false; + bool fail_sta = false, fail_off = false, auto_reconnect = false; + unsigned disconnects = 0, sta_modes = 0, off_modes = 0; + int getMode() const { return initialized_cache && started_cache ? sdk_mode : WIFI_OFF; } + int channel() const { return sdk_initialized ? sdk_channel : 0; } + bool isConnected() const { return connected; } + void setAutoReconnect(bool enabled) { auto_reconnect = enabled; } + void disconnect(bool off, bool erase) { + assert(!off && !erase); ++disconnects; connected = false; + } + bool mode(int mode) { + if (mode == WIFI_OFF) ++off_modes; + if (mode == WIFI_STA) ++sta_modes; + if ((mode == WIFI_STA && fail_sta) || (mode == WIFI_OFF && fail_off)) return false; + if (mode == getMode()) return true; // Arduino OFF is a no-op for a raw IDF driver. + if (mode == WIFI_OFF) { + initialized_cache = started_cache = false; + esp_wifi_stop(); esp_wifi_deinit(); + } else { + initialized_cache = started_cache = true; + sdk_initialized = sdk_started = true; sdk_mode = mode; + } + return true; + } + void reset() { *this = {}; sdk_initialized = sdk_started = false; sdk_mode = WIFI_OFF; + sdk_channel = 1; sdk_stops = sdk_deinits = 0; + fail_sdk_stop = fail_sdk_deinit = false; } +} WiFi; +namespace mesh { +namespace bridge { constexpr int ESPNOW_FORMAT_WRAPPED = 1; } +struct LocalIdentity { unsigned char pub_key[32] = {}; }; +struct Utils { static void toHex(char* out, const unsigned char*, int) { strcpy(out, "test"); } }; +struct Log { template void printf(const char*, Args...) {} }; +Log& usbConsolePort() { static Log log; return log; } +Log& usbDebugPort() { return usbConsolePort(); } +} +constexpr int PUB_KEY_SIZE = 32; +struct Prefs { + int bridge_enabled = 0, espnow_bridge_enabled = 0, rs232_bridge_enabled = 0; + bool battery_alert_enabled = false; + const char* node_name = "test"; + float freq = 915, bw = 125; uint8_t sf = 7, cr = 5, disable_fwd = 0; +}; +struct ObserverPrefs { char wifi_ssid[33] = {}; }; +struct Board { + bool ota = false, allow_start = true, allow_stop = true; + unsigned manifest_calls = 0; + bool isOTAUpdateRunning() const { return ota; } + const char* getManufacturerName() const { return "test"; } + bool startOTAUpdate(const char*, char* reply, bool) { + if (!allow_start) { strcpy(reply, "ERR: OTA WiFi failed"); return false; } + WiFi.mode(WIFI_AP_STA); ota = true; strcpy(reply, "Started: http://192.168.4.1/update"); + return true; + } + bool stopOTAUpdate(char* reply) { + if (!allow_stop) { strcpy(reply, "ERR: OTA upload active"); return false; } + ota = false; WiFi.mode(WIFI_STA); strcpy(reply, "OK - OTA stopped"); return true; + } + bool otaFromManifest(const char*, const char*, bool, char* reply) { + ++manifest_calls; strcpy(reply, "ERR: not connected"); return false; + } +}; +struct RoleCLI { + Board board; ObserverPrefs prefs; + Board* getBoard() { return &board; } + ObserverPrefs* getObserverPrefs() { return &prefs; } + bool hasActiveUserGpioTimer() const { return false; } +}; +struct AbstractBridge { + bool running = false, stopping = false, allow_flash = true; + unsigned starts = 0, stops = 0; + bool isRunning() const { return running; } + bool isStopping() const { return stopping; } + bool canFlashAfterStop() const { return allow_flash; } + void begin() { ++starts; running = true; WiFi.mode(WIFI_STA); } + void end() { ++stops; running = false; } // MQTT and OTA may retain STA. +}; +using ESPNowBridge = AbstractBridge; +struct MQTTNodeInfo { + const char* node_name; float* freq; float* bw; + uint8_t* sf; uint8_t* cr; uint8_t* repeat_flag; bool repeat_when_nonzero; +}; +struct MQTTBridge : AbstractBridge { + template explicit MQTTBridge(Args...) {} + void setDeviceID(const char*) {} void setFirmwareVersion(const char*) {} + void setBoardModel(const char*) {} void setBuildDate(const char*) {} + template void setStatsSources(Args...) {} +}; +struct WebConfigServer { + static bool enabled; + static bool allow_start; + bool running = false, stopping = false, owns; + uint32_t _setup_started_at = 0; + int _mode = 0; + static constexpr int MODE_SETUP = 1; + char _wifi_ssid[33] = {}; + template WebConfigServer(void*, void* prefs, bool owner, Args...) : owns(owner) { + if (prefs) strcpy(_wifi_ssid, static_cast(prefs)->wifi_ssid); + } + static bool loadEnabled(bool) { return enabled; } + bool isRunning() const { return running; } + bool isStopping() const { return stopping; } + void updateWiFiOwnership(bool owner) { owns = owner; } + bool startSetupMode(char* reply) { + if (!allow_start) { + if (owns) WiFi.mode(WIFI_OFF); + strcpy(reply, "Err: failed to start AP"); return false; + } + _mode = MODE_SETUP; _setup_started_at = millis(); running = true; + WiFi.mode(WIFI_AP_STA); strcpy(reply, "WebConfig AP started"); + return true; + } + bool startAutoMode(char* reply) { return startSetupMode(reply); } + void requestStop() { running = false; stopping = true; } + bool stopForOTA(char*) { running = stopping = false; WiFi.mode(WIFI_STA); return true; } + void tick(uint32_t now) { + if (stopping) { stopping = false; if (owns) WiFi.mode(WIFI_OFF); return; } + @SETUP_WINDOW@ + } +}; +bool WebConfigServer::enabled = false; +bool WebConfigServer::allow_start = true; +constexpr unsigned OTA_TX_DRAIN_TIMEOUT_MS = 1, OTA_MQTT_STOP_SETTLE_MS = 1; +const char* ota_resolve_base(int) { return "test"; } +struct MyMesh { + Prefs _prefs; RoleCLI _cli; + MQTTBridge* @WORKER@ = nullptr; + ESPNowBridge espnow_bridge; + WebConfigServer* _webconfig = nullptr; + bool _unconfigured_setup_espnow_suspended = false; + uint32_t shared_espnow_retry_at = 0, _ota_update_at = 0; + int _ota_update_channel = 0; + bool _wc_batch_active = false, _wc_restart_pending = false; + mesh::LocalIdentity self_id; + void* _radio = nullptr; void* _ms = nullptr; + struct { void setBridge(MQTTBridge*) {} } _alerter; + struct { bool busy() const { return false; } } _local_cli_output; + struct { bool pending = false; } deferred_cli_command; + struct { bool isWatchdogObserving() const { return false; } + bool isCalibratingNoiseFloor() const { return false; } } radio_driver; + struct { int getNumClients() const { return 0; } } acl; + bool pending_self_advert = false, saved_radio_apply_pending = false, temp_radio_applied = false; + unsigned long next_flood_advert = 0, next_local_advert = 0, dirty_contacts_expiry = 0; + unsigned long next_recent_repeater_sweep = 0, next_battery_alert_check = 0, next_push = 0; + unsigned long radio_apply_retry_at = 0, set_radio_at = 0, revert_radio_at = 0; + bool hasPendingOtaApply() const { return false; } bool hasQueuedWorkDue() const { return false; } + bool hasRetryWorkDue() const { return false; } bool hasScheduledRadioWorkDue() const { return false; } + bool isMillisTimerDue(unsigned long) const { return false; } + bool getNextQueueWakeDelay(uint32_t&) const { return false; } + bool getNextRetryWakeDelay(uint32_t&) const { return false; } + bool isDualRadioActive() const { return false; } + uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; } + uint32_t limitSleepToScheduledRadioWork(uint32_t secs) const { return secs; } + const AbstractBridge* activeBridge() const { return @WORKER@; } + const char* getFirmwareVer() const { return "test"; } const char* getBuildDate() const { return "test"; } + const char* getRole() const { return "test"; } + mesh::LocalIdentity getSelfId() { return self_id; } void* getRTCClock() { return nullptr; } + void configureBridgeFilter(AbstractBridge*) {} void drainOutbound(unsigned) {} void otaAlert(const char*) {} + void suspendUnconfiguredSetupBridges(); void serviceIdleWiFi(); + bool startWebConfig(bool force_ap, char* reply); bool stopWebConfig(char* reply); + bool hasPendingWork() const; uint32_t getPowerSaveSleepSeconds(uint32_t) const; + @INLINE_METHODS@ + void defaults() { @DEFAULTS@ } + void startupSetup() { @STARTUP@ } + void servicePortal() { @PORTAL_LOOP@ } + void manifestAttempt() { @MANIFEST@ } + ~MyMesh() { delete _webconfig; delete @WORKER@; } +}; +@METHODS@ +struct BrowserCLI { + Board* _board; Prefs* _prefs; MyMesh* _callbacks; + @OTA_STATE@ + explicit BrowserCLI(MyMesh& node) : _board(node._cli.getBoard()), _prefs(&node._prefs), _callbacks(&node) {} + void run(const char* command, char* reply) { if (false) { @OTA_CLI@ } } +}; +@CHECKS@ +''' + +CHECKS = r''' +int main() { + char reply[160] = {}; + MyMesh node; node.defaults(); + assert(node._prefs.bridge_enabled == 1 && node._prefs.espnow_bridge_enabled == 1); + assert(node.setBridgeState(true)); + assert(node.isMqttBridgeRunning()); + WiFi.connected = true; + node.startSharedEspNowBridgeIfReady(); + assert(node.espnow_bridge.isRunning()); + node.startupSetup(); // Execute the actual first-boot setup decision after saved defaults. + assert(node._unconfigured_setup_espnow_suspended && node.isWebConfigActive()); + assert(!node.isMqttBridgeRunning() && !node.espnow_bridge.isRunning()); + assert(node._prefs.bridge_enabled == 1 && node._prefs.espnow_bridge_enabled == 1); + const auto starts = node.espnow_bridge.starts; + for (now = 0; now < 1800000; now += 10000) { + assert(!node.startSharedEspNowBridgeIfReady()); + node.servicePortal(); node.serviceIdleWiFi(); + assert(sdk_started && node.getPowerSaveSleepSeconds(30) == 0); + } + now = 1800000; + node.servicePortal(); // Absolute window requests a stop, even with activity. + assert(node.isWebConfigActive()); + node.serviceIdleWiFi(); assert(sdk_started); // Still stopping: do not tear down its task. + node.servicePortal(); node.serviceIdleWiFi(); + assert(!node.isWebConfigActive() && !sdk_initialized && !sdk_started); + assert(node.getPowerSaveSleepSeconds(30) == 30); + now += 10000; + assert(!node.startSharedEspNowBridgeIfReady() && node.espnow_bridge.starts == starts); + // Manifest failure must not turn preserved defaults into actual running services. + node._ota_update_at = now; + node.manifestAttempt(); node.serviceIdleWiFi(); + assert(node._unconfigured_setup_espnow_suspended && !node.espnow_bridge.isRunning()); + assert(!node.isMqttBridgeRunning() && !sdk_started); + // Explicit setup restart, stop webconfig, browser OTA, stop OTA: remain suspended. + assert(node.startWebConfig(true, reply)); assert(node.isWebConfigActive()); + assert(node.stopWebConfig(reply)); + node.servicePortal(); node.serviceIdleWiFi(); + BrowserCLI cli(node); + cli.run("start ota ap", reply); assert(node._cli.board.ota && sdk_started); + cli.run("start ota ap", reply); // Repeated start does not invent a bridge snapshot. + cli.run("stop ota", reply); node.serviceIdleWiFi(); + assert(node._unconfigured_setup_espnow_suspended && !sdk_started); + assert(!node.isMqttBridgeRunning() && !node.espnow_bridge.isRunning()); + // An explicit ESP-NOW start ends the suspension and owns the shared radio. + assert(node.setEspNowBridgeState(true)); + assert(!node._unconfigured_setup_espnow_suspended && node.espnow_bridge.isRunning()); + node.serviceIdleWiFi(); assert(sdk_started && node.hasPendingWork()); + // Real active ESP-NOW is restored on failed startup and successful shutdown. + node._cli.board.allow_start = false; + cli.run("start ota ap", reply); assert(node.espnow_bridge.isRunning()); + node._cli.board.allow_start = true; + cli.run("start ota ap", reply); assert(!node.espnow_bridge.isRunning()); + node._cli.board.allow_stop = false; + cli.run("stop ota", reply); assert(!node.espnow_bridge.isRunning() && node._cli.board.ota); + node._cli.board.allow_stop = true; + cli.run("stop ota", reply); assert(node.espnow_bridge.isRunning()); + assert(node.setEspNowBridgeState(false)); + node.serviceIdleWiFi(); assert(!sdk_started); + // Closing a normal configured WebUI must not create a setup suspension. + strcpy(node._cli.prefs.wifi_ssid, "saved"); + assert(node.startWebConfig(false, reply)); + assert(!node._unconfigured_setup_espnow_suspended); + node.stopWebConfig(reply); node.servicePortal(); now += 10000; + assert(node.startSharedEspNowBridgeIfReady()); + node.serviceIdleWiFi(); assert(sdk_started && node.espnow_bridge.isRunning()); + node.setEspNowBridgeState(false); node.serviceIdleWiFi(); + // A stopping MQTT task still owns the driver and prevents manual light sleep. + node.@WORKER@->stopping = true; + assert(!sdk_initialized && node.getPowerSaveSleepSeconds(30) == 0); + WiFi.mode(WIFI_STA); + node.serviceIdleWiFi(); assert(sdk_started && node.hasPendingWork()); + node.@WORKER@->stopping = false; + node.serviceIdleWiFi(); assert(!sdk_started && node.getPowerSaveSleepSeconds(30) == 30); + // The raw-IDF and Arduino facade states differ. Adopt stale OFF before teardown. + WiFi.reset(); sdk_initialized = sdk_started = true; sdk_mode = WIFI_STA; + node.serviceIdleWiFi(); + assert(WiFi.sta_modes == 1 && !WiFi.initialized_cache && !sdk_initialized); + const auto stops = sdk_stops, deinits = sdk_deinits, disconnects = WiFi.disconnects; + for (int i = 0; i < 10; ++i) node.serviceIdleWiFi(); + assert(sdk_stops == stops && sdk_deinits == deinits && WiFi.disconnects == disconnects); + // If channel() is unavailable, the SDK-only fallback still reclaims the driver. + WiFi.reset(); sdk_initialized = sdk_started = true; sdk_mode = WIFI_STA; sdk_channel = 0; + node.serviceIdleWiFi(); assert(!sdk_initialized && sdk_deinits == 1); + // A failed Arduino adoption/shutdown is retried, with no raw deinit under stale caches. + WiFi.reset(); sdk_initialized = sdk_started = true; sdk_mode = WIFI_STA; + WiFi.fail_sta = true; node.serviceIdleWiFi(); assert(sdk_started && sdk_deinits == 0); + assert(node.getPowerSaveSleepSeconds(30) == 0); + WiFi.fail_sta = false; WiFi.fail_off = true; + node.serviceIdleWiFi(); assert(sdk_started && WiFi.initialized_cache && sdk_deinits == 0); + assert(node.getPowerSaveSleepSeconds(30) == 0); + WiFi.fail_off = false; node.serviceIdleWiFi(); assert(!sdk_initialized); + assert(node.getPowerSaveSleepSeconds(30) == 30); + WiFi.reset(); sdk_initialized = sdk_started = true; sdk_mode = WIFI_STA; sdk_channel = 0; + fail_sdk_stop = true; node.serviceIdleWiFi(); + assert(sdk_started && node.getPowerSaveSleepSeconds(30) == 0); + fail_sdk_stop = false; fail_sdk_deinit = true; node.serviceIdleWiFi(); + assert(sdk_initialized && !sdk_started && node.getPowerSaveSleepSeconds(30) == 0); + fail_sdk_deinit = false; node.serviceIdleWiFi(); + assert(!sdk_initialized && node.getPowerSaveSleepSeconds(30) == 30); + // Every live owner blocks cleanup, including a scheduled manifest operation. + WiFi.mode(WIFI_STA); node._ota_update_at = now + 1000; + node.serviceIdleWiFi(); assert(sdk_started); + node._ota_update_at = 0; node.@WORKER@->running = true; + node.serviceIdleWiFi(); assert(sdk_started); + node.@WORKER@->running = false; node._cli.board.ota = true; + node.serviceIdleWiFi(); assert(sdk_started); + node._cli.board.ota = false; node.serviceIdleWiFi(); assert(!sdk_started); +} +''' + + +class ESP32FullPowerLifecycleTest(unittest.TestCase): + compile_and_run = wifi_start.WiFiOtaStartTest.compile_and_run + + def fixture(self, role, checks=CHECKS): + source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() + header = (ROOT / "examples" / role / "MyMesh.h").read_text() + worker = "mqtt_bridge" if role == "simple_repeater" else "bridge" + inline = "\n".join(extract_braced(header, signature) for signature in ( + "bool startSharedEspNowBridgeIfReady()", "bool setEspNowBridgeState(", + "bool setMqttBridgeState(", "bool setBridgeState(", + "bool isEspNowBridgeRunning()", "bool isMqttBridgeRunning()", + "bool isMqttBridgeStopping()", + "bool isWebConfigActive() const", "bool stopWebConfigForOTA(")) + methods = "\n".join(extract_braced(source, signature) for signature in ( + "void MyMesh::suspendUnconfiguredSetupBridges()", "void MyMesh::serviceIdleWiFi()", + "bool MyMesh::startWebConfig(", "bool MyMesh::stopWebConfig(", + "bool MyMesh::hasPendingWork() const", "uint32_t MyMesh::getPowerSaveSleepSeconds(")) + defaults_start = source.index(" // bridge defaults") + defaults = source[defaults_start:source.index(" _prefs.bridge_delay", defaults_start)] + startup = source[source.index(" bool start_webui = WebConfigServer::loadEnabled(false);"):] + startup = startup[:startup.index("#endif\n\n", startup.index(" if (start_webui)"))] + tick = source.index("_webconfig->tick(millis());") + portal_start = source.rfind(" if (_webconfig) {", 0, tick) + portal_loop = extract_braced(source[portal_start:], " if (_webconfig) {") + manifest = extract_braced(source, " if (_ota_update_at && millisHasNowPassed(_ota_update_at))") + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + cli_header = (ROOT / "src/helpers/CommonCLI.h").read_text() + state_begin = cli_header.index(" bool _wifi_ota_resume_mqtt = false;") + state = cli_header[state_begin:cli_header.index("#endif", state_begin)] + begin = cli.index(' } else if (memcmp(command, "start ota", 9)') + end = cli.index(' } else if (memcmp(command, "clock", 5)', begin) + web_source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text() + window = extract_braced(web_source, " if (WebConfigBatch::unconfiguredSetupWindowExpired(") + return (FIXTURE.replace("@INLINE_METHODS@", inline.replace(" override", "")) + .replace("@METHODS@", methods).replace("@DEFAULTS@", defaults) + .replace("@STARTUP@", startup).replace("@PORTAL_LOOP@", portal_loop) + .replace("@MANIFEST@", manifest).replace("@OTA_CLI@", cli[begin:end]) + .replace("@OTA_STATE@", state) + .replace("@SETUP_WINDOW@", window).replace("@CHECKS@", checks) + .replace("@WORKER@", worker)) + + def test_actual_full_startup_timeout_retry_ota_and_teardown(self): + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + self.compile_and_run(self.fixture(role), *FLAGS) + + def test_primary_radios_and_wifi_disabled_images_do_not_reclaim_wifi(self): + for role in ("simple_repeater", "simple_room_server"): + source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() + cleanup = extract_braced(source, "void MyMesh::serviceIdleWiFi()") + fixture = "struct MyMesh { void serviceIdleWiFi(); };\n" + cleanup + fixture += "\nint main() { MyMesh node; node.serviceIdleWiFi(); }\n" + # No WiFi declarations at all: these disabled bodies cannot reference it. + for flags in ([], ["-DMESH_PRIMARY_ESPNOW=1", "-DWITH_ESPNOW_BRIDGE=1"], + ["-DMESH_ESPNOW_RADIO=1", "-DWITH_ESPNOW_BRIDGE=1"]): + with self.subTest(role=role, flags=flags): + self.compile_and_run(fixture, *flags) + + def test_non_full_setup_retains_legacy_bridge_retry_policy(self): + checks = r''' +int main() { + MyMesh node; node.defaults(); + node.setBridgeState(true); WiFi.connected = true; + node.startSharedEspNowBridgeIfReady(); node.startupSetup(); + assert(node.isWebConfigActive() && !node._unconfigured_setup_espnow_suspended); + now += 10000; + assert(node.startSharedEspNowBridgeIfReady()); + assert(node.espnow_bridge.isRunning()); +} +''' + flags = [flag for flag in FLAGS if not flag.startswith("-DMESHCORE_EXPANDED_PARTITION_PROFILE=")] + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + self.compile_and_run(self.fixture(role, checks), *flags) + + def test_failed_portal_preserves_explicit_bridge_and_prior_setup_suspension(self): + checks = r''' +struct WirelessBackend : mesh::wireless::Backend { + uint8_t state = mesh::wireless::WiFi; + uint8_t available() const override { return mesh::wireless::WiFi; } + uint8_t enabled() const override { return state; } + uint8_t clients() const override { return mesh::wireless::Independent; } + mesh::wireless::Result set(uint8_t service, bool on) override { + state = on ? state | service : state & ~service; return mesh::wireless::Result::Done; + } +}; +int main() { + MyMesh node; node.defaults(); char reply[160] = {}; + assert(node.setEspNowBridgeState(true)); + WebConfigServer::allow_start = false; + for (bool force_ap : {false, true}) { + assert(node.startWebConfig(force_ap, reply)); + assert(strstr(reply, "failed to start AP")); + assert(node.espnow_bridge.isRunning() && !node._unconfigured_setup_espnow_suspended); + assert(node._prefs.espnow_bridge_enabled == 1 && node._prefs.bridge_enabled == 1); + node.servicePortal(); node.serviceIdleWiFi(); + assert(!node._webconfig && sdk_started && node.espnow_bridge.isRunning()); + } + WirelessBackend backend; + mesh::wireless::control().begin(backend); + assert(mesh::wireless::control().handle("set wifi off force", reply, sizeof(reply), now, + mesh::wireless::Independent)); + now += 300; mesh::wireless::control().service(now); + assert(mesh::wireless::control().blocked(mesh::wireless::WiFi)); + const auto espnow_stops = node.espnow_bridge.stops; + node.startWebConfig(true, reply); + assert(strstr(reply, "WiFi disabled") && !node._webconfig); + assert(node.espnow_bridge.stops == espnow_stops && node.espnow_bridge.isRunning()); + assert(!node._unconfigured_setup_espnow_suspended); + assert(mesh::wireless::control().allowService(mesh::wireless::WiFi)); + // Do not interrupt an active MQTT task that cannot be synchronously restarted. + node.setBridgeState(true); WiFi.connected = true; now += 10000; + node.startSharedEspNowBridgeIfReady(); + const auto mqtt_stops = node.@WORKER@->stops, second_espnow_stops = node.espnow_bridge.stops; + node.startWebConfig(false, reply); + assert(strstr(reply, "MQTT bridge is running") && !node._webconfig); + assert(node.isMqttBridgeRunning() && node.espnow_bridge.isRunning()); + assert(node.@WORKER@->stops == mqtt_stops && node.espnow_bridge.stops == second_espnow_stops); + node.@WORKER@->running = false; node.@WORKER@->stopping = true; + node.startWebConfig(true, reply); + assert(strstr(reply, "MQTT bridge is stopping") && !node._webconfig); + assert(node.@WORKER@->stops == mqtt_stops && node.espnow_bridge.stops == second_espnow_stops); + // A previous successful setup suspension must survive a later AP error. + MyMesh cold; cold.defaults(); WebConfigServer::allow_start = true; + cold.startupSetup(); cold.stopWebConfig(reply); cold.servicePortal(); cold.serviceIdleWiFi(); + assert(cold._unconfigured_setup_espnow_suspended && !cold.espnow_bridge.isRunning()); + WebConfigServer::allow_start = false; + cold.startWebConfig(true, reply); cold.servicePortal(); + assert(cold._unconfigured_setup_espnow_suspended && !cold.espnow_bridge.isRunning()); + assert(!cold.isMqttBridgeRunning() && cold._prefs.espnow_bridge_enabled == 1); +} +''' + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + self.compile_and_run("#include \n" + self.fixture(role, checks), *FLAGS) + + def test_first_boot_lifecycle_rejects_unsuspended_background_retry(self): + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + body = self.fixture(role) + old = body.replace("if (_unconfigured_setup_espnow_suspended) return false;", "") + self.assertNotEqual(old, body) + # The pre-fix periodic retry revives ESP-NOW during setup and + # keeps the radio running after its absolute AP window. + with self.assertRaises(AssertionError): + self.compile_and_run(old, *FLAGS) + + def test_role_loop_calls_cleanup_after_portal_service_and_before_sleep(self): + for role in ("simple_repeater", "simple_room_server"): + source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() + signature = ("void __attribute__((noinline)) MyMesh::servicePostMeshLoop()" + if role == "simple_repeater" else "void MyMesh::loop()") + loop = extract_braced(source, signature) + self.assertEqual(loop.count("serviceIdleWiFi();"), 1) + self.assertLess(loop.index("_webconfig->tick(millis());"), loop.index("serviceIdleWiFi();")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_esp32_full_sleep.py b/test/test_esp32_full_sleep.py new file mode 100644 index 00000000..02711b30 --- /dev/null +++ b/test/test_esp32_full_sleep.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Execute Full defaults and the actual Companion idle branch with host fakes.""" + +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +def function(source, signature): + start = source.index(signature) + opening = source.index("{", start) + depth = 0 + for end in range(opening, len(source)): + depth += (source[end] == "{") - (source[end] == "}") + if depth == 0: + return source[start:end + 1] + raise AssertionError(signature) + + +def run_cpp(source, defines=()): + with tempfile.TemporaryDirectory(prefix="mesh-full-sleep-") as directory: + cpp = Path(directory) / "test.cpp" + exe = Path(directory) / "test" + cpp.write_text(source) + built = subprocess.run( + [os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", + "-I", str(ROOT / "src"), + *("-D" + flag for flag in defines), str(cpp), "-o", str(exe)], + text=True, capture_output=True) + if built.returncode: + raise AssertionError(built.stderr) + return subprocess.run([str(exe)], text=True, capture_output=True) + + +class FullSleepTest(unittest.TestCase): + def test_full_optional_logging_starts_off_and_saved_intent_wins(self): + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + start = cli.index("#if defined(ESP32_PLATFORM) && defined(MESHCORE_EXPANDED_PARTITION_PROFILE)", + cli.index("void CommonCLI::loadPrefs(")) + default = cli[start:cli.index(" _prefs->usb_debug_enabled", start)] + logger = (ROOT / "src/helpers/UsbLogging.cpp").read_text() + start = logger.index("#if defined(ENABLE_USB_INTERFACE)", logger.index("namespace mesh {")) + early = logger[start:logger.index("static std::atomic usb_logging_preference_known", start)] + # Execute the real persisted-byte read; neither a fresh default nor an + # upgrade is allowed to silently discard an explicit logging choice. + persisted = "" + for line in cli.splitlines(): + if "file.read" in line and "usb_logging_enabled" in line: + start = cli.index(line.strip()) + persisted = cli[start:cli.index(";", start) + 1] + break + self.assertTrue(persisted) + source = r''' +#include +#include +#include +@EARLY@ +struct Prefs { uint8_t usb_logging_enabled = 99; } prefs; +struct File { uint8_t value; void read(uint8_t* out, unsigned) { *out = value; } } file; +int main() { + auto* _prefs = &prefs; + @DEFAULT@ + assert(prefs.usb_logging_enabled == EXPECTED_DEFAULT); + assert(usb_logging_enabled.load() == (EXPECTED_DEFAULT != 0)); + file.value = 1; + @PERSISTED@ + assert(prefs.usb_logging_enabled == 1); + file.value = 0; + @PERSISTED@ + assert(prefs.usb_logging_enabled == 0); +} +'''.replace("@EARLY@", early).replace("@DEFAULT@", default).replace("@PERSISTED@", persisted) + for flags in (("ESP32_PLATFORM", "MESHCORE_EXPANDED_PARTITION_PROFILE", "EXPECTED_DEFAULT=0"), + ("ESP32_PLATFORM", "EXPECTED_DEFAULT=1"), + ("MESHCORE_EXPANDED_PARTITION_PROFILE", "EXPECTED_DEFAULT=1")): + with self.subTest(flags=flags): + result = run_cpp(source, flags) + self.assertEqual(result.returncode, 0, result.stderr) + broken = run_cpp(source.replace("_prefs->usb_logging_enabled = 0;", + "_prefs->usb_logging_enabled = 1;"), + ("ESP32_PLATFORM", "MESHCORE_EXPANDED_PARTITION_PROFILE", "EXPECTED_DEFAULT=0")) + self.assertNotEqual(broken.returncode, 0) + + def test_compiled_full_transports_do_not_exclude_runtime_idle_sleep(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + ready = function(main, "static bool companionNativeUsbLightSleepReady()") + usb_held = function((ROOT / "src/helpers/ESP32Board.h").read_text(), + "bool isUsbSleepHeld()") + start = main.index(" // USB power alone") + end = main.index("#if defined(ESP32) && defined(WIFI_SSID)", + main.index("else if (the_mesh.getNodePrefs()->powersaving_enabled)", start)) + branch = main[start:end] + source = r''' +#include +#include +#include +static uint32_t now = 1000; +uint32_t millis() { return now; } +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; +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; +static bool bt_host_active = false; +int esp_bluedroid_get_status() { return bt_host_active ? 1 : 0; } +int esp_wifi_get_mode(wifi_mode_t* mode) { *mode = sdk_mode; return sdk_result; } +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; } +void vTaskDelay(int) { ++yields; } +void delay(int) { ++yields; } +struct Board { + bool host = false, ota = false, test = false; + mesh::UsbHostSleepPolicy usb_host_sleep_policy; + bool isUsbHostConnected() { usb_host_sleep_policy.observe(host, millis()); return host; } + @USB_HELD@ + bool isOTAUpdateRunning() const { return ota; } + bool isRadioTestActive() const { return test; } +} board; +struct Prefs { bool powersaving_enabled = true; } prefs; +struct Mesh { + bool pending = false; + Prefs* getNodePrefs() { return &prefs; } + bool hasPendingWork() const { return pending; } +} the_mesh; +struct Interfaces { bool pending = false; bool hasPendingIO() const { return pending; } } interface_manager; +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; +namespace mesh { +bool logging = false, watchdog = false; +bool isUsbLoggingEnabled() { return logging; } +bool isUsbLoggingWatchdogArmed() { return watchdog; } +namespace wireless { +struct Control { bool transition = false; bool pending() const { return transition; } } controller; +Control& control() { return controller; } +} +} +@READY@ +void idle() { @BRANCH@ } +template void blocks(T& flag) { + flag = true; int before = sleeps; idle(); assert(sleeps == before); flag = false; +} +int main() { + idle(); assert(sleeps == 1); + for (unsigned service : {1u, 2u, 4u}) { + companion_wireless.services = service; int before = sleeps; idle(); assert(sleeps == before); + } + companion_wireless.services = 0; + for (wifi_mode_t mode : {WIFI_MODE_STA, WIFI_MODE_AP, WIFI_MODE_APSTA}) { + sdk_mode = mode; int before = sleeps; idle(); assert(sleeps == before); + } + sdk_mode = WIFI_MODE_NULL; + for (int result : {ESP_ERR_WIFI_NOT_INIT, ESP_ERR_WIFI_NOT_STARTED}) { + sdk_result = result; int before = sleeps; idle(); assert(sleeps == before + 1); + } + sdk_result = 99; int before = sleeps; idle(); assert(sleeps == before); sdk_result = ESP_OK; + 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(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); + // The real board policy must observe USB even while pending work/logging + // inhibit this branch. A rebooting host cannot disappear for one SOF gap + // and cause Full to shut down its console immediately. + the_mesh.pending = true; board.host = true; idle(); + board.host = false; the_mesh.pending = false; now += 6; + before = sleeps; idle(); assert(sleeps == before); + now += 120000; idle(); assert(sleeps == before + 1); +} +'''.replace("@READY@", ready).replace("@BRANCH@", branch).replace("@USB_HELD@", usb_held) + 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", + "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): + with self.subTest(pm=pm): + result = run_cpp(source, (*flags, f"COMPANION_IDF_PM_AVAILABLE={pm}")) + self.assertEqual(result.returncode, 0, result.stderr) + broken = run_cpp(source.replace("companion_wireless.enabled() != 0", "false"), + (*flags, "COMPANION_IDF_PM_AVAILABLE=0")) + self.assertNotEqual(broken.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py index 2be79969..1e3a7e61 100644 --- a/test/test_esp32_usb_sleep.py +++ b/test/test_esp32_usb_sleep.py @@ -478,7 +478,7 @@ class Esp32UsbSleepTest(unittest.TestCase): methods = "\n".join(board_method(signature) for signature in ( "void sleep(uint32_t secs) override", "bool isUsbDataConnected() override", - "bool isUsbHostConnected() override")) + "bool isUsbHostConnected() override", "bool isUsbSleepHeld()")) compiler = os.environ.get("CXX", "c++") self.assertIsNotNone(shutil.which(compiler), "a C++17 compiler is required") with tempfile.TemporaryDirectory(prefix="meshcore-v4-button-wake-") as directory: @@ -552,7 +552,7 @@ class Esp32UsbSleepTest(unittest.TestCase): def test_raw_uart_driver_preserves_fresh_console_indefinitely_until_end(self): methods = "\n".join(board_method(signature) for signature in ( "void sleep(uint32_t secs) override", "bool isUsbDataConnected() override", - "bool isUsbHostConnected() override")) + "bool isUsbHostConnected() override", "bool isUsbSleepHeld()")) harness = HARNESS.split("int main()", 1)[0] + r''' int main() { try { @@ -600,6 +600,7 @@ int main() { "void sleep(uint32_t secs) override", "bool isUsbDataConnected() override", "bool isUsbHostConnected() override", + "bool isUsbSleepHeld()", )) with tempfile.TemporaryDirectory(prefix="meshcore-usb-sleep-") as temp: cpp = Path(temp) / "test.cpp" @@ -629,6 +630,7 @@ int main() { "void sleep(uint32_t secs) override", "bool isUsbDataConnected() override", "bool isUsbHostConnected() override", + "bool isUsbSleepHeld()", )) harness = HARNESS.split("int main()", 1)[0] + r''' int main() { diff --git a/test/test_rs232_mqtt_runtime.py b/test/test_rs232_mqtt_runtime.py index 9eef0e6c..8168a6c9 100644 --- a/test/test_rs232_mqtt_runtime.py +++ b/test/test_rs232_mqtt_runtime.py @@ -12,6 +12,12 @@ from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] +def ota_resume_fields(): + header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii") + start = header.index(" bool _wifi_ota_resume_mqtt = false;") + return header[start:header.index("#endif", start)] + + class RS232MQTTRuntimeTests(unittest.TestCase): def test_independent_transports_route_retry_restart_and_survive_browser_ota(self): compiler = shutil.which("g++") or shutil.which("clang++") @@ -93,6 +99,7 @@ class RS232MQTTRuntimeTests(unittest.TestCase): "ROUTE_TX": routing["logTx"], "SERVICE": implementation[service_start:service_end], "PENDING": implementation[pending_start:pending_end], "SET_BRANCHES": sets, "GET_BRANCHES": gets, "OTA": cli[ota_start:ota_end], + "OTA_STATE": ota_resume_fields(), "PARSERS": "\n".join(extract_braced(cli, signature) for signature in ( "static bool parseOnOffStrict(", "static bool configKeyEquals(")), }.items(): @@ -171,6 +178,7 @@ class RS232MQTTRuntimeTests(unittest.TestCase): "PARSERS": "\n".join(extract_braced(cli, signature) for signature in ( "static bool parseOnOffStrict(", "static bool configKeyEquals(")), "OTA": cli[ota_start:cli.index(' } else if (memcmp(command, "clock", 5)', ota_start)], + "OTA_STATE": ota_resume_fields(), } for key, value in substitutions.items(): code = code.replace("@" + key + "@", value) diff --git a/test/test_tbeam_charge_target.py b/test/test_tbeam_charge_target.py index d5a836f8..a19ce9e7 100644 --- a/test/test_tbeam_charge_target.py +++ b/test/test_tbeam_charge_target.py @@ -543,6 +543,9 @@ class TBeamChargeTargetTest(unittest.TestCase): harness.write_text(source, encoding="ascii") binary = directory / "harness" command = [self.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror", + # Production retains editor folding pragmas that GCC does + # not implement. Keep real compiler warnings fatal. + "-Wno-unknown-pragmas", "-I", str(directory), "-I", str(ROOT / "src"), str(harness), *extra, "-o", str(binary)] built = subprocess.run(command, capture_output=True, text=True) diff --git a/test/test_webconfig_ap_start.py b/test/test_webconfig_ap_start.py index 7ded4f10..77cb5704 100644 --- a/test/test_webconfig_ap_start.py +++ b/test/test_webconfig_ap_start.py @@ -35,6 +35,11 @@ class WebConfigApStartTest(unittest.TestCase): mask = policy[policy.index("static constexpr uint8_t kAccessPointProtocolMask ="):] mask = mask[:mask.index(";") + 1] fixture = FIXTURE.read_text(encoding="utf-8").replace("@AP_MASK@", mask) + ap_policy = (ROOT / "src/helpers/esp32/WiFiAccessPointPolicy.h").read_text(encoding="utf-8") + scanner = "namespace mesh { namespace wifi {\n" + "\n".join( + extract_braced(ap_policy, signature) for signature in ( + "inline bool finishAccessPointScan(", "inline bool scanBeforeAccessPoint(")) + "\n} }\n" + helper = scanner + helper fixture = fixture.replace("@METHODS@", helper + "\n" + method + "\n" + scan_handler) with tempfile.TemporaryDirectory(prefix="webconfig-ap-start-") as directory: work = Path(directory) diff --git a/test/test_webconfig_station_ownership.py b/test/test_webconfig_station_ownership.py index b29e66b3..8749569d 100644 --- a/test/test_webconfig_station_ownership.py +++ b/test/test_webconfig_station_ownership.py @@ -27,6 +27,8 @@ class WebConfigStationOwnershipTest(unittest.TestCase): refresh = source[refresh_start:refresh_end] self.assertIn("_webconfig->updateWiFiOwnership(", refresh) body = fixture.replace("@WORKER@", worker).replace("@START@", start) + body = body.replace("@SUSPEND@", extract_braced(source, + "void MyMesh::suspendUnconfiguredSetupBridges()")) body = body.replace("@REFRESH@", refresh) self.compile_and_run(body, "-DWITH_MQTT_BRIDGE=1") # A stopped/unconfigured MQTT implementation used to keep diff --git a/test/test_wifi_ota_lifecycle.py b/test/test_wifi_ota_lifecycle.py index a92161f0..943b521e 100644 --- a/test/test_wifi_ota_lifecycle.py +++ b/test/test_wifi_ota_lifecycle.py @@ -39,6 +39,19 @@ STUBS = r''' constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6, HTTP_GET = 0; enum wifi_mode_t { WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2 }; constexpr wifi_mode_t WIFI_MODE_NULL = WIFI_OFF; +constexpr wifi_mode_t WIFI_MODE_AP = WIFI_AP; +constexpr int WIFI_IF_STA = 0, WIFI_IF_AP = 1, WIFI_AUTH_OPEN = 0; +constexpr int WIFI_SCAN_RUNNING = -1, AP_STARTED_BIT = 1; +struct wifi_config_t { + struct { + uint8_t ssid[32] = {}; + uint8_t ssid_len = 0, ssid_hidden = 0, channel = 1; + int authmode = WIFI_AUTH_OPEN; + } ap; +}; +static unsigned clock_ms = 100; +unsigned millis() { return clock_ms; } +void delay(unsigned amount) { clock_ms += amount; } int esp_wifi_stop(); int esp_wifi_deinit(); constexpr int ESP_OK = 0; @@ -67,6 +80,8 @@ struct MockWiFi { bool initialized_cache = false, started_cache = false; IPAddress station_ip, ap_ip, configured_ap_ip; int ap_protocol = 15; + wifi_config_t ap_config; + int getStatusBits() const { return sdk_ap_active ? AP_STARTED_BIT : 0; } int status() const { return station_status; } wifi_mode_t getMode() const { return started_cache && sdk_started ? (sdk_ap_active ? WIFI_AP : WIFI_STA) : WIFI_OFF; @@ -79,6 +94,7 @@ struct MockWiFi { initialized_cache = started_cache = false; } else { sdk_initialized = sdk_started = initialized_cache = started_cache = true; + if (!(requested & WIFI_AP)) sdk_ap_active = false; } return true; } @@ -88,22 +104,37 @@ struct MockWiFi { configured_ap_ip = ip; return ap_config_ok; } - bool softAP(const char*, const char*, int channel = 1) { + bool softAP(const char* ssid, const char*, int channel = 1) { assert(channel == 1); ++ap_start_calls; if (ap_start_ok) { sdk_initialized = sdk_started = sdk_ap_active = true; initialized_cache = started_cache = true; ap_ip = configured_ap_ip; + ap_config.ap.ssid_len = strlen(ssid); + memcpy(ap_config.ap.ssid, ssid, strlen(ssid)); + ap_config.ap.channel = channel; } return ap_start_ok; } void softAPdisconnect(bool) { sdk_ap_active = false; ap_ip = IPAddress(); } + int scanNetworks(bool, bool, bool, int, uint8_t) { return 0; } + int scanComplete() const { return 0; } + void scanDelete() {} } WiFi; namespace mesh { namespace wifi { +constexpr uint8_t kAccessPointProtocolMask = 7; int applyAccessPointProtocolMask() { WiFi.ap_protocol = 7; return ESP_OK; } +int applyProtocolMask(int) { return ESP_OK; } int accessPointChannel() { return 1; } +uint8_t stationScanChannel() { return 0; } +bool espNowChannelConstrained() { return false; } } } +int esp_wifi_get_mode(wifi_mode_t*); +int esp_wifi_scan_stop() { return ESP_OK; } +int esp_wifi_get_config(int, wifi_config_t* config) { *config = WiFi.ap_config; return ESP_OK; } +int esp_wifi_get_protocol(int, uint8_t* value) { *value = WiFi.ap_protocol; return ESP_OK; } +@AP_LIFECYCLE_POLICY@ int wifi_stop_calls = 0, wifi_deinit_calls = 0; int esp_wifi_stop() { @@ -145,6 +176,7 @@ class ESP32Board { public: bool inhibit_sleep = false; bool ota_started_ap = false; + bool ota_started_radio = false; AsyncWebServer* ota_server = nullptr; ~ESP32Board() { delete ota_server; } const char* getManufacturerName() const { return "test board"; } @@ -275,8 +307,10 @@ def production_harness(): enabled = method(infrastructure, "uint8_t enabled() const override") cleanup = method(bridge, "static void stopBridgeWiFiIfUnused(") ota_running = method(board_header, "bool isOTAUpdateRunning() const override") + ap_policy = (ROOT / "src/helpers/esp32/WiFiAccessPointPolicy.h").read_text() return (STUBS.replace("@ENABLED@", enabled).replace("@CLEANUP@", cleanup) .replace("@START_OTA@", start_ota) + .replace("@AP_LIFECYCLE_POLICY@", ap_policy[ap_policy.index("namespace mesh {"):]) .replace("@OTA_RUNNING@", ota_running.replace(" override", "")) + CHECKS) diff --git a/test/test_wifi_ota_start.py b/test/test_wifi_ota_start.py index 9abb9940..eff9c781 100644 --- a/test/test_wifi_ota_start.py +++ b/test/test_wifi_ota_start.py @@ -31,9 +31,7 @@ class WiFiOtaStartTest(unittest.TestCase): text=True, timeout=15) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - def test_network_instructions_and_switching_on_all_esp32_uploaders(self): - source = (ROOT / "src/helpers/ESP32Board.cpp").read_text() - lightweight, other = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1) + def ap_fixture(self): fixture = (ROOT / "test/fixtures/wifi_ota_start.cpp").read_text() policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text() mask = re.search(r"static constexpr uint8_t kAccessPointProtocolMask =.*?;", @@ -41,6 +39,14 @@ class WiFiOtaStartTest(unittest.TestCase): fixture = fixture.replace("@AP_PROTOCOL_POLICY@", mask + "\n" + extract_braced( policy, "inline esp_err_t applyAccessPointProtocolMask()") + "\n" + extract_braced(policy, "inline int accessPointChannel()")) + lifecycle = (ROOT / "src/helpers/esp32/WiFiAccessPointPolicy.h").read_text() + fixture = fixture.replace("@AP_LIFECYCLE_POLICY@", lifecycle[lifecycle.index("namespace mesh {"):]) + return fixture + + def test_network_instructions_and_switching_on_all_esp32_uploaders(self): + source = (ROOT / "src/helpers/ESP32Board.cpp").read_text() + lightweight, other = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1) + fixture = self.ap_fixture() for name, implementation, flags in ( ("lightweight infrastructure", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]), ("lightweight companion", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1", "-DCOMPANION_RADIO_FULL=1"]), @@ -49,7 +55,137 @@ class WiFiOtaStartTest(unittest.TestCase): with self.subTest(uploader=name): methods = "\n".join(extract_braced(implementation, signature) for signature in ( "bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate(")) - self.compile_and_run(fixture.replace("@METHODS@", methods), *flags) + for major in (2, 3): + with self.subTest(arduino_major=major): + self.compile_and_run(fixture.replace("@METHODS@", methods), + *flags, f"-DESP_ARDUINO_VERSION_MAJOR={major}") + + def test_companion_setup_portal_uses_the_same_live_ap_contract(self): + source = (ROOT / "src/helpers/WiFiSetupPortal.cpp").read_text() + start = extract_braced(source, "bool WiFiSetupPortal::begin(") + task = extract_braced(source, "static void portalTask(") + cleanup = task[task.rindex("\n impl->dns.stop();"):task.rindex("\n}")] + endpoints = r''' +static const IPAddress SETUP_IP(192, 168, 4, 1); +using TaskHandle_t = void*; +constexpr int pdPASS = 1; +bool allow_dns = true, allow_task = true; +unsigned task_starts = 0; +struct WiFiServer { + bool listening = false; + explicit WiFiServer(int) {} + void begin() { assert(WiFi.ap_started); listening = true; } + void stop() { listening = false; } + void setNoDelay(bool) {} +}; +struct DNSServer { + bool running = false; + bool start(int, const char*, IPAddress) { + assert(WiFi.ap_started); running = allow_dns; return running; + } + void stop() { running = false; } +}; +class WiFiSetupPortal { +public: + using SaveCallback = bool (*)(void*, const char*, const char*); + volatile bool _active = false; + void* _impl = nullptr; + bool begin(const char*, SaveCallback, void*); +}; +struct PortalImpl { + WiFiServer server{80}; + DNSServer dns; + TaskHandle_t task = nullptr; + bool started_radio = false; + WiFiSetupPortal::SaveCallback save_callback = nullptr; + void* callback_context = nullptr; + volatile bool* active = nullptr; + uint32_t close_ap_at = 0, recovery_interval_ms = 0; + char recovery_ssid[32] = {}, ap_name[33] = {}; + bool recovery_connecting = false; +}; +void vTaskDelete(void*) {} +void portalTask(void* context) { + auto* impl = static_cast(context); +@CLEANUP@ +} +int xTaskCreatePinnedToCore(void (*)(void*), const char*, int, void*, int, + TaskHandle_t* task, int) { + ++task_starts; + if (!allow_task) return 0; + *task = reinterpret_cast(1); return pdPASS; +} +namespace mesh { +struct Log { template void printf(const char*, Args...) {} }; +Log& usbLoggingPort() { static Log log; return log; } +} +@START@ +int main() { + constrain_espnow_channel = false; + WiFi.connected = WiFi.radio = false; + WiFiSetupPortal portal; + assert(portal.begin("MeshCore-Setup", nullptr, nullptr)); + auto* impl = static_cast(portal._impl); + assert(portal._active && impl->started_radio && impl->dns.running && impl->server.listening); + assert(WiFi.ap_started && WiFi.scans == 1); + const unsigned starts = WiFi.starts; + assert(portal.begin("MeshCore-Setup", nullptr, nullptr) && WiFi.starts == starts); + portalTask(impl); + assert(!portal._active && !impl->task && !impl->started_radio && !WiFi.radio); + for (Fault fault : {Fault::Ssid, Fault::StartEvent, Fault::ModeRead}) { + WiFi.fault = fault; + assert(!portal.begin("MeshCore-Setup", nullptr, nullptr)); + assert(!portal._active && !impl->task && !WiFi.radio && !WiFi.ap); + } + WiFi.fault = Fault::None; + allow_dns = false; + assert(!portal.begin("MeshCore-Setup", nullptr, nullptr) && !WiFi.radio); + allow_dns = true; + allow_task = false; + assert(!portal.begin("MeshCore-Setup", nullptr, nullptr)); + assert(!portal._active && !impl->task && !impl->dns.running && !impl->server.listening && !WiFi.radio); + allow_task = true; + WiFi.connected = WiFi.radio = true; + const unsigned scans = WiFi.scans; + assert(portal.begin("MeshCore-Setup", nullptr, nullptr)); + assert(!impl->started_radio && WiFi.scans == scans && WiFi.connected); + portalTask(impl); + assert(WiFi.radio && WiFi.connected); + WiFi.connected = false; + constrain_espnow_channel = true; + assert(portal.begin("MeshCore-Setup", nullptr, nullptr)); + assert(WiFi.ap_channel == 6); + portalTask(impl); + assert(WiFi.radio && !WiFi.ap); + delete impl; +} +''' + fixture = self.ap_fixture().split("static unsigned server_starts", 1)[0] + fixture += endpoints.replace("@CLEANUP@", cleanup).replace("@START@", start) + for major in (2, 3): + with self.subTest(arduino_major=major): + self.compile_and_run(fixture, f"-DESP_ARDUINO_VERSION_MAJOR={major}") + + def test_minimal_ota_never_reports_a_stale_sdk_hotspot_as_started(self): + implementation = (ROOT / "src/helpers/ESP32Board.cpp").read_text().split( + "#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)[0] + methods = "\n".join(extract_braced(implementation, signature) for signature in ( + "bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate(")) + fixture = self.ap_fixture().split("int main() {", 1)[0] + fixture = fixture.replace("@METHODS@", methods) + r''' +int main() { + ESP32Board board; + char reply[160] = {}; + WiFi.connected = WiFi.radio = false; + constrain_espnow_channel = false; + WiFi.fault = Fault::Ssid; // Setters succeed with an old SSID in the actual SDK. + assert(!board.startOTAUpdate("test", reply, true)); + assert(strcmp(reply, "ERR: OTA WiFi failed") == 0); + assert(!board.ota_server && !board.inhibit_sleep && !WiFi.radio); +} +''' + self.compile_and_run(fixture, "-DLIGHTWEIGHT_WIFI_OTA=1", + "-DESP_ARDUINO_VERSION_MAJOR=2") def test_common_cli_preserves_actionable_ota_errors(self): cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() @@ -103,26 +239,38 @@ int main() { #include #include struct Callbacks { - bool active = true, allow_stop = true; + bool active = true, allow_stop = true, mqtt = true, espnow = true; int stops = 0, bridge_stops = 0, bridge_starts = 0; + int espnow_stops = 0, espnow_starts = 0; bool isWebConfigActive() const { return active; } bool stopWebConfigForOTA(char* reply) { ++stops; if (!allow_stop) { strcpy(reply, "ERR: handoff failed"); return false; } active = false; strcpy(reply, "WebConfig stopped"); return true; } - void setBridgeState(bool enabled) { if (enabled) ++bridge_starts; else ++bridge_stops; } + bool isMqttBridgeRunning() const { return mqtt; } + bool isEspNowBridgeRunning() const { return espnow; } + bool setMqttBridgeState(bool enabled) { + if (enabled) ++bridge_starts; else ++bridge_stops; + mqtt = enabled; return true; + } + bool setEspNowBridgeState(bool enabled) { + if (enabled) ++espnow_starts; else ++espnow_stops; + espnow = enabled; return true; + } } callbacks; struct Board { - bool allow_start = true; + bool allow_start = true, running = false; int starts = 0; + bool isOTAUpdateRunning() const { return running; } bool startOTAUpdate(const char*, char* reply, bool) { - assert(!callbacks.active); ++starts; + assert(!callbacks.active && !callbacks.espnow); ++starts; + if (allow_start) running = true; strcpy(reply, allow_start ? "Started: http://10.20.30.40/update - Use same WiFi/LAN" : "ERR: OTA WiFi failed"); return allow_start; } - bool stopOTAUpdate(char*) { return true; } + bool stopOTAUpdate(char*) { running = false; return true; } } board; struct Prefs { const char* node_name = "test"; @@ -132,6 +280,7 @@ struct CLI { Board* _board = &board; Prefs* _prefs = &prefs; Callbacks* _callbacks = &callbacks; + bool _wifi_ota_resume_mqtt = false, _wifi_ota_resume_espnow = false; void run(const char* command, char* reply) { if (false) { @CLI@ @@ -150,18 +299,36 @@ int main() { cli.run("start ota", reply.text); assert(strstr(reply.text, "http://10.20.30.40/update") && strstr(reply.text, "; WebConfig stopped")); assert(callbacks.stops == 2 && board.starts == 1 && callbacks.bridge_stops == 1); + assert(callbacks.espnow_stops == 1 && !callbacks.mqtt && !callbacks.espnow); cli.run("start ota ap", reply.text); assert(!strstr(reply.text, "WebConfig stopped") && callbacks.stops == 2 && board.starts == 2); callbacks.active = true; board.allow_start = false; cli.run("start ota", reply.text); assert(strcmp(reply.text, "ERR: OTA WiFi failed; WebConfig stopped") == 0); - assert(callbacks.stops == 3 && board.starts == 3 && callbacks.bridge_stops == 2); + assert(callbacks.stops == 3 && board.starts == 3 && callbacks.bridge_stops == 1); cli.run("stop ota", reply.text); - assert(callbacks.bridge_starts == 0); // Do not enable disabled bridges. + assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 0); + // Saving intent after an inactive attempt must not manufacture a resume. + prefs.bridge_enabled = true; prefs.espnow_bridge_enabled = true; cli.run("stop ota", reply.text); - assert(callbacks.bridge_starts == 1); // Restore ESP-NOW even with MQTT off. + assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 0); + callbacks.espnow = true; // Only ESP-NOW was actually running on entry. + board.allow_start = true; + cli.run("start ota ap", reply.text); + cli.run("start ota ap", reply.text); // Repeat start preserves the first snapshot. + prefs.bridge_enabled = false; + cli.run("stop ota", reply.text); + assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 1); + assert(callbacks.espnow && !callbacks.mqtt); + // A stopped setup session with both saved defaults enabled stays stopped. + callbacks.espnow = false; + prefs.bridge_enabled = true; + callbacks.active = true; + cli.run("start ota ap", reply.text); + cli.run("stop ota", reply.text); + assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 1); assert(reply.guard == 123); } ''' @@ -209,6 +376,7 @@ struct Callbacks { struct Board { bool allow_start = true, allow_stop = true; int starts = 0; + bool isOTAUpdateRunning() const { return ota; } bool startOTAUpdate(const char*, char* reply, bool) { assert(!callbacks.espnow && !callbacks.web); ++starts; ota = allow_start; @@ -230,6 +398,7 @@ struct CLI { Board* _board = &board; Prefs* _prefs = &prefs; Callbacks* _callbacks = &callbacks; + bool _wifi_ota_resume_mqtt = false, _wifi_ota_resume_espnow = false; void run(const char* command, char* reply) { if (false) { @CLI@ @@ -269,6 +438,14 @@ int main() { assert(!callbacks.espnow && callbacks.uart); cli.run("stop ota", reply.text); assert(strstr(reply.text, "; ESP-NOW resume failed")); + // A fresh successful OTA must preserve an outstanding failed resume. + board.allow_start = true; + cli.run("start ota", reply.text); + assert(ota && !callbacks.espnow); + callbacks.allow_resume = true; + cli.run("stop ota", reply.text); + assert(!ota && callbacks.espnow && strstr(reply.text, "; ESP-NOW resumed")); + callbacks.espnow = false; // A disabled secondary must stay disabled after the upload stops. prefs.espnow_bridge_enabled = false; #if !defined(WITH_RS232_BRIDGE)