From 47135e081c76c27f16ccc0e847d1ca47f3d8be65 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 5 Oct 2026 10:31:43 -0700 Subject: [PATCH] Make ESP32 OTA startup handle WiFi and WebConfig automatically Report the upload address and required network, honor AP switches on an existing uploader, and keep delayed WebConfig cleanup from disabling OTA WiFi. Preserve actionable startup errors and account for OTA in wireless state controls. Exercise the production OTA lifecycle and stock website console, including power-saving idle sessions, in host regression tests. --- .github/workflows/run-unit-tests.yml | 6 + docs/cli_commands.md | 17 +- docs/faq.md | 6 +- docs/full_companion_features.md | 5 +- docs/role_feature_switches.md | 2 +- examples/InfrastructureWireless.h | 7 + examples/companion_radio/MyMesh.cpp | 21 +- examples/companion_radio/main.cpp | 9 + examples/simple_repeater/MyMesh.h | 3 + examples/simple_room_server/MyMesh.h | 3 + src/helpers/CommonCLI.cpp | 21 +- src/helpers/CommonCLI.h | 4 + src/helpers/ESP32Board.cpp | 43 ++-- src/helpers/WirelessControl.h | 11 + src/helpers/esp32/WebConfigServer.cpp | 41 +++- src/helpers/esp32/WebConfigServer.h | 4 + .../official_web_console/contract.mjs | 75 ++++++ .../official_web_console/firmware.cpp | 103 ++++++++ test/fixtures/webconfig_ota_handoff.cpp | 105 +++++++++ test/fixtures/wifi_ota_start.cpp | 164 +++++++++++++ test/test_official_web_console.py | 126 ++++++++++ test/test_wifi_ota_start.py | 221 ++++++++++++++++++ test/test_wireless_control.py | 14 +- 23 files changed, 965 insertions(+), 46 deletions(-) create mode 100644 test/fixtures/official_web_console/contract.mjs create mode 100644 test/fixtures/official_web_console/firmware.cpp create mode 100644 test/fixtures/webconfig_ota_handoff.cpp create mode 100644 test/fixtures/wifi_ota_start.cpp create mode 100644 test/test_official_web_console.py create mode 100644 test/test_wifi_ota_start.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index cf09ee298..285e01ef0 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -249,6 +249,12 @@ jobs: python3 -B test/test_usb_host_sleep_policy.py python3 -B test/test_esp32_usb_sleep.py + - name: Verify official web console framing and power-saving idle sessions + run: python3 -B test/test_official_web_console.py -v + + - name: Verify WiFi OTA startup and WebConfig handoff + run: python3 -B test/test_wifi_ota_start.py -v + - name: Verify GPS status distinguishes user settings from receiver sleep run: | python3 -B test/test_gps_status.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 895ccd0d1..c7443f96e 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -223,13 +223,18 @@ See the [OTA guide](ota_user_guide.md#adjust-lora-ota-speed). On nRF52, `start ota` invokes Bluetooth DFU with the matching bootloader and application DFU ZIP. The WiFi/AP instructions below apply to ESP32. -On ESP32, `start ota` serves the web upload page on the station IP when connected to WiFi; -otherwise it raises the `MeshCore-OTA` access point. `start ota ap` always raises -the access point, which is useful when the normal network uses client isolation. +On ESP32, `start ota` enables WiFi as needed and serves the web upload page on +the station IP when connected to WiFi; otherwise it raises the `MeshCore-OTA` +access point. The reply gives the URL to open and tells you which network to +use. `start ota ap` always selects the access point, including when OTA is +already running on a LAN address. This is useful with client-isolated networks. -On an ESP32 build with WebConfig, the manual OTA uploader and WebConfig both use HTTP -port 80 and cannot run together. Stop WebConfig before `start ota`, or stop OTA -before `start webconfig`. +Starting OTA automatically stops WebConfig and reports `WebConfig stopped` in +the reply. The existing LAN connection stays up; a setup hotspot is replaced +by `MeshCore-OTA`. WebConfig's delayed HTTP cleanup cannot stop OTA's WiFi. +Use the USB/Bluetooth or LoRa command line to start the uploader; WebConfig's +own terminal cannot deliver the handoff reply after shutting itself down. +Stop OTA before starting WebConfig again. FULL ESP32 builds also expose the `.mota` folder seeder on TCP port 5001 whenever WiFi is usable. This listener is independent of port 80, so a host can diff --git a/docs/faq.md b/docs/faq.md index e67a7f814..4062ae4d3 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -872,13 +872,13 @@ After this bootloader is flashed onto the device, you can trigger an over-the-ai filename). 2. From the MeshCore app, log in remotely to the repeater you want to update with admin privileges. 3. Go to the Command Line tab, type `start ota` and hit enter. -4. You should see `OK` to confirm the repeater device is now in OTA mode. +4. The reply confirms WiFi is on and gives the OTA upload URL and network to use. 5. If the ESP32 is not already joined to Wi-Fi, `start ota` starts an open hotspot named `MeshCore-OTA` at `192.168.4.1`. If it is already joined, the command reports and uses its router-assigned address instead. 6. From your phone or computer, connect to the `MeshCore-OTA` hotspot when one was started. 7. Open the URL reported by `start ota` and upload the non-merged bin from the flasher. When the device started `MeshCore-OTA`, the URL is . -On an MQTT observer, stop WebConfig before running `start ota`; both servers use -HTTP port 80. Likewise, stop the OTA uploader before running `start webconfig`. +`start ota` automatically stops WebConfig and reports the change; both servers +use HTTP port 80. Stop the OTA uploader before running `start webconfig` again. ### 7.3. Q: Is there a way to lower the chance of a failed OTA device firmware update (DFU)? diff --git a/docs/full_companion_features.md b/docs/full_companion_features.md index f03adb53a..ab4335c7e 100644 --- a/docs/full_companion_features.md +++ b/docs/full_companion_features.md @@ -188,8 +188,9 @@ For ESP32 with two application slots, `start ota` returns the WiFi uploader URL, normally `http://DEVICE_IP:8080/update`. `start ota ap` explicitly opens `MeshCore-OTA`; join it and use the returned URL. Upload the exact board's application `.bin`. The device reboots when the upload succeeds. Use -`stop ota` to close an unused uploader. Port 8080 keeps WebConfig on port 80 -available. Use a trusted local network or a temporary setup network. +`stop ota` to close an unused uploader. Starting OTA enables WiFi as needed, +stops WebConfig automatically, and reports the network and URL to use. Use a +trusted local network or a temporary setup network. The 4 MB Full layouts and T-Beam 1W Full use a single application slot and require USB; the command reports that limitation. The artifact's diff --git a/docs/role_feature_switches.md b/docs/role_feature_switches.md index 8a1436259..93d61b60e 100644 --- a/docs/role_feature_switches.md +++ b/docs/role_feature_switches.md @@ -333,7 +333,7 @@ source commands and the bounded temporary radio setup. | Role / hardware | Start self-update | Stop / requirement | | --- | --- | --- | -| ESP32 Repeater, Room Server, Sensor with WiFi updater | `start ota` or `start ota ap`; open the returned URL (normally port 80, `/update`) | `stop ota`; close WebConfig first if it shares port 80 | +| ESP32 Repeater, Room Server, Sensor with WiFi updater | `start ota` or `start ota ap`; WiFi starts as needed and WebConfig stops automatically; open the returned URL (normally port 80, `/update`) | `stop ota` | | ESP32 Full Companion with two application slots | `start ota` or `start ota ap`; returned URL uses port 8080, `/update` | `stop ota`; single-slot Full builds use USB | | nRF52 infrastructure with Bluetooth DFU | `start ota` enters the Bluetooth update flow | Matching application DFU ZIP and board bootloader required | | Qualified LoRa OTA receiver | Follow [LoRa OTA directions](ota_easy.md) | Exact destination package, storage profile, and overlapping temporary radio windows | diff --git a/examples/InfrastructureWireless.h b/examples/InfrastructureWireless.h index 4d1bf4695..f20f14955 100644 --- a/examples/InfrastructureWireless.h +++ b/examples/InfrastructureWireless.h @@ -22,6 +22,9 @@ public: } uint8_t enabled() const override { uint8_t mask = 0; +#ifdef ESP32 + if (board.isOTAUpdateRunning()) mask |= mesh::wireless::WiFi; +#endif #ifdef WITH_WEBCONFIG if (the_mesh.isWebConfigActive()) mask |= mesh::wireless::WiFi; #endif @@ -56,6 +59,10 @@ public: #if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE)) if (service == mesh::wireless::WiFi) { if (!on) { + if (board.isOTAUpdateRunning()) { + char reply[160]; + if (!board.stopOTAUpdate(reply)) return Result::Failed; + } if (!wifi_saved_) { #ifdef WITH_WEBCONFIG resume_web_ = the_mesh.isWebConfigActive(); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 13097a170..6b7af1028 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3349,13 +3349,26 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, // Board implementations use the common 160-byte CLI reply contract. // Binary Companion callers may supply a shorter buffer. char ota_reply[160] = {0}; + bool webconfig_stopped = false; +#ifdef WITH_WEBCONFIG + if (isWebConfigActiveOrStopping()) { + if (!_webconfig->stopForOTA(ota_reply)) { + snprintf(reply, reply_size, "%s", ota_reply); + return true; + } + webconfig_stopped = true; + ota_reply[0] = 0; + } +#endif if (!board.startOTAUpdate(_prefs.node_name, ota_reply, strcmp(command, "start ota ap") == 0)) { - snprintf(reply, reply_size, "%s", - ota_reply[0] ? ota_reply : "ERR: WiFi OTA unavailable"); - } else { - snprintf(reply, reply_size, "%s", ota_reply); + if (!ota_reply[0]) strcpy(ota_reply, "ERR: WiFi OTA unavailable"); } + if (webconfig_stopped) { + const size_t used = strlen(ota_reply); + snprintf(ota_reply + used, sizeof(ota_reply) - used, "; WebConfig stopped"); + } + snprintf(reply, reply_size, "%s", ota_reply); return true; } if (strcmp(command, "stop ota") == 0) { diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index a60aec0a1..319ad1343 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2337,6 +2337,12 @@ void halt() { } static bool finishStoppingCompanionWiFi() { +#ifdef COMPANION_RADIO_FULL + if (board.isOTAUpdateRunning()) { + char reply[160]; + if (!board.stopOTAUpdate(reply)) return false; + } +#endif cancelCompanionWiFiNtp(false); stopCompanionWiFiServices(); #ifdef WITH_WEBCONFIG @@ -2902,6 +2908,9 @@ public: uint8_t enabled() const override { uint8_t mask = 0; #if defined(ESP32) && defined(WIFI_SSID) +#ifdef COMPANION_RADIO_FULL + if (board.isOTAUpdateRunning()) mask |= mesh::wireless::WiFi; +#endif if (companion_wifi_requested || companion_wifi_active) mask |= mesh::wireless::WiFi; #ifdef WITH_WEBCONFIG if (the_mesh.isWebConfigActiveOrStopping()) mask |= mesh::wireless::WiFi; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index d0e871a2c..455b883b0 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -1608,6 +1608,9 @@ public: bool isWebConfigStopping() const { return _webconfig && _webconfig->isStopping(); } bool hasWirelessNetworkClient() const { return _web_terminal != nullptr; } bool stopWebConfig(char* reply) override; + bool stopWebConfigForOTA(char* reply) override { + return !_webconfig || _webconfig->stopForOTA(reply); + } bool setWebUIEnabled(bool enabled, char* reply) override; bool getWebUIStatus(char* reply) const override; bool getWiFiSSID(char* reply) const override; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index c52149f74..df105a687 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -864,6 +864,9 @@ public: bool isWebConfigStopping() const { return _webconfig && _webconfig->isStopping(); } bool hasWirelessNetworkClient() const { return _web_terminal != nullptr; } bool stopWebConfig(char* reply) override; + bool stopWebConfigForOTA(char* reply) override { + return !_webconfig || _webconfig->stopForOTA(reply); + } bool setWebUIEnabled(bool enabled, char* reply) override; bool getWebUIStatus(char* reply) const override; bool getWiFiSSID(char* reply) const override; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 8501659da..8d3b938ad 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -2749,14 +2749,21 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re const bool force_ap = command[9] == ' ' && strcmp(&command[10], "ap") == 0; if (command[9] == ' ' && !force_ap) { strcpy(reply, "ERR: usage start ota [ap]"); - } else + return; + } + bool webconfig_stopped = false; #if defined(ESP_PLATFORM) && defined(ADMIN_PASSWORD) && !defined(WEBCONFIG_DISABLED) if (_callbacks->isWebConfigActive()) { - strcpy(reply, "ERR: stop webconfig first"); - } else + if (!_callbacks->stopWebConfigForOTA(reply)) { + if (!reply[0]) strcpy(reply, "ERR: could not stop WebConfig for OTA"); + return; + } + webconfig_stopped = true; + } #endif + reply[0] = 0; if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) { - strcpy(reply, "Error"); + if (!reply[0]) strcpy(reply, "Error"); } #if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) else { @@ -2764,9 +2771,13 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _callbacks->setBridgeState(false); } #endif + if (webconfig_stopped) { + const size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; WebConfig stopped"); + } } else if (memcmp(command, "stop ota", 8) == 0 && (command[8] == 0 || command[8] == ' ')) { if (!_board->stopOTAUpdate(reply)) { - strcpy(reply, "Error"); + if (!reply[0]) strcpy(reply, "Error"); } #if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) else if (_prefs->bridge_enabled) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 05ca5ff1b..575206526 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -625,6 +625,10 @@ public: (void)reply; return false; }; + virtual bool stopWebConfigForOTA(char* reply) { + (void)reply; + return false; + }; virtual bool isWebConfigActive() const { return false; }; diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 0f0c2e9ed..1d85846e9 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1,6 +1,7 @@ #ifdef ESP_PLATFORM #include "ESP32Board.h" +#include "WirelessControl.h" #include #include "UsbLogging.h" #include "FileRead.h" @@ -68,8 +69,8 @@ bool ESP32Board::isUserGpioAvailable(uint8_t pin) const { static bool lightweight_ota_started_ap; -// Full Companion keeps WebConfig on port 80 while an explicitly started -// updater uses 8080. Infrastructure retains its established /update URL. +// Full Companion retains its established uploader URL on port 8080. +// Infrastructure uses port 80; CLI startup hands WebConfig's WiFi to OTA. #if defined(COMPANION_RADIO_FULL) static constexpr uint16_t LIGHTWEIGHT_OTA_PORT = 8080; #else @@ -275,10 +276,15 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { return false; } #endif + if (!mesh::wireless::control().allowWiFiForOTA()) { + strcpy(reply, "ERR: wireless change pending; retry start ota"); + return false; + } inhibit_sleep = true; IPAddress ip; - if (!force_ap && WiFi.status() == WL_CONNECTED) { + const bool use_ap = force_ap || WiFi.status() != WL_CONNECTED; + if (!use_ap) { ip = WiFi.localIP(); } else { if (!lightweight_ota_started_ap) { @@ -288,7 +294,7 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { && WiFi.softAP("MeshCore-OTA", nullptr); } if (!lightweight_ota_started_ap) { - inhibit_sleep = false; + inhibit_sleep = ota_server != nullptr; strcpy(reply, "ERR: OTA WiFi failed"); return false; } @@ -306,11 +312,12 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { ota_server = &lightweight_ota_server; } + const char* network = use_ap ? "Join WiFi MeshCore-OTA" : "Use same WiFi/LAN"; if (LIGHTWEIGHT_OTA_PORT == 80) { - snprintf(reply, 160, "Started: http://%s/update", ip.toString().c_str()); + snprintf(reply, 160, "Started: http://%s/update - WiFi on; %s", ip.toString().c_str(), network); } else { - snprintf(reply, 160, "Started: http://%s:%u/update", ip.toString().c_str(), - static_cast(LIGHTWEIGHT_OTA_PORT)); + snprintf(reply, 160, "Started: http://%s:%u/update - WiFi on; %s", ip.toString().c_str(), + static_cast(LIGHTWEIGHT_OTA_PORT), network); } MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply); return true; @@ -341,13 +348,11 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { #include bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { - inhibit_sleep = true; // prevent sleep during OTA - - if (ota_server != nullptr) { // already running (idempotent restart) - IPAddress cur_ip = (WiFi.status() == WL_CONNECTED) ? WiFi.localIP() : WiFi.softAPIP(); - sprintf(reply, "Started: http://%s/update", cur_ip.toString().c_str()); - return true; + if (!mesh::wireless::control().allowWiFiForOTA()) { + strcpy(reply, "ERR: wireless change pending; retry start ota"); + return false; } + inhibit_sleep = true; // prevent sleep during OTA // If the device is already on a WiFi network (e.g. an observer joined in STA // mode), serve ElegantOTA on the station IP so it's reachable from the LAN @@ -356,23 +361,29 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { // reachable even when the joined network applies client isolation and the // station IP can't be reached. IPAddress ip; - if (!force_ap && WiFi.status() == WL_CONNECTED) { + const bool use_ap = force_ap || WiFi.status() != WL_CONNECTED; + if (!use_ap) { ip = WiFi.localIP(); } else { const IPAddress ap_ip(192, 168, 4, 1); const IPAddress ap_mask(255, 255, 255, 0); if (!WiFi.softAPConfig(ap_ip, ap_ip, ap_mask) || !WiFi.softAP("MeshCore-OTA", NULL)) { - inhibit_sleep = false; + inhibit_sleep = ota_server != nullptr; strcpy(reply, "ERR: OTA WiFi failed"); return false; } ip = WiFi.softAPIP(); } - sprintf(reply, "Started: http://%s/update", ip.toString().c_str()); + snprintf(reply, 160, "Started: http://%s/update - WiFi on; %s", ip.toString().c_str(), + use_ap ? "Join WiFi MeshCore-OTA" : "Use same WiFi/LAN"); MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply); + // Select the requested network even when the HTTP listener already exists. + // In particular, `start ota ap` must work after an earlier LAN-mode start. + if (ota_server != nullptr) return true; + static char id_buf[60]; sprintf(id_buf, "%s (%s)", id, getManufacturerName()); static char home_buf[90]; diff --git a/src/helpers/WirelessControl.h b/src/helpers/WirelessControl.h index e91adf818..8fb632791 100644 --- a/src/helpers/WirelessControl.h +++ b/src/helpers/WirelessControl.h @@ -72,6 +72,17 @@ public: blocked_.store(blocked_.load() & ~service); return true; } + // An explicit OTA start owns its WiFi startup, including after a user has + // disabled 2.4 GHz for this boot. Leave the other services disabled. Do not + // race a pending shutdown, which could otherwise turn OTA off afterwards. + bool allowWiFiForOTA() { + if (pending_) return false; + blocked_.store(blocked_.load() & ~WiFi); + master_off_ = false; + saved_valid_ = false; + error_ = nullptr; + return true; + } uint8_t enabled() const { return backend_ ? backend_->enabled() : 0; } bool handle(const char* text, char* reply, size_t size, uint32_t now, uint8_t requester) { diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 65e2cce89..5813e15c8 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -245,12 +245,11 @@ static_assert(WC_BOARD_CMD_COUNT <= 8, "board command mask must fit uint8_t"); // POST so nothing in the sequence runs, rather than failing halfway with a // reply that does not explain itself. Returns the reason, or NULL if fine. static const char* wcCliUnavailable(const char* cmd, bool streamed = false) { - // ESP32Board::startOTAUpdate() does `new AsyncWebServer(80)` with no bind - // check and answers "Started" regardless. The portal already holds port 80, - // so from here it can only leak the allocation, inhibit sleep, and lie. + // OTA stops this portal, including the session carrying this command. It + // needs an independent reply route so the user receives the upload URL. if (strncmp(cmd, "start ota", 9) == 0) { - return "start ota needs port 80, which this portal is using. " - "Run it from the serial console, or use `ota update`."; + return "start ota stops WebConfig. Run it from USB/Bluetooth or LoRa " + "to receive the upload URL, or use `ota update`."; } // `clock sync` sets the clock from the CALLER's timestamp. Web requests carry // none (execCommand passes 0), so CommonCLI always rejects it as moving the @@ -967,6 +966,31 @@ void WebConfigServer::requestStop() { _stop_warned = false; } +bool WebConfigServer::stopForOTA(char reply[]) { + if (!isRunning() && !isStopping()) return true; + // Keep a STA interface alive while removing our setup AP. Switching the + // last interface off and immediately creating the OTA AP races ESP32 netif + // teardown. A connected station keeps its address throughout the handoff. + if (_was_setup_ap && !WiFi.enableSTA(true)) { + strcpy(reply, "ERR: could not prepare WiFi for OTA"); + return false; + } + _keep_wifi_on_stop = true; + requestStop(); // Detaches routes and releases port 80 synchronously. + if (_was_setup_ap) { + WiFi.softAPdisconnect(true); + // Arduino 2.x can return false even when enableAP(false) succeeded. + // Check the resulting mode instead of that return value. + if (WiFi.getMode() & WIFI_AP) { + strcpy(reply, "ERR: WebConfig stopped; WiFi AP handoff failed"); + return false; + } + _was_setup_ap = false; + } + strcpy(reply, "WebConfig stopped"); + return true; +} + void WebConfigServer::finalizeTeardown() { closeTerminal(); // Async requests keep a pointer to their server until disconnect. Retain the @@ -976,7 +1000,7 @@ void WebConfigServer::finalizeTeardown() { delete _dns; _dns = NULL; const bool was_setup_ap = _was_setup_ap; - if (was_setup_ap) { + if (was_setup_ap && !_keep_wifi_on_stop) { WiFi.softAPdisconnect(true); // Nothing else owns WiFi when we raised the AP: either the node is // unconfigured, or `start webconfig ap` required the bridge stopped. @@ -985,11 +1009,12 @@ void WebConfigServer::finalizeTeardown() { } else { WiFi.mode(WIFI_STA); } - _was_setup_ap = false; } - if (_owns_wifi && !was_setup_ap) { + if (_owns_wifi && !was_setup_ap && !_keep_wifi_on_stop) { stopOwnedWiFiRadio(); } + _was_setup_ap = false; + _keep_wifi_on_stop = false; _initial_setup = false; _setup_started_at = 0; _stopping = false; diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 1a9e5dcd0..45f7c329f 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -217,6 +217,9 @@ public: bool startAutoMode(char reply[]); // use saved WiFi, or setup AP when absent bool reloadStandaloneWiFi(); void requestStop(); // stop listening and detach this session + // Release HTTP/DNS for the updater without delayed teardown taking its + // WiFi down. Existing HTTP request objects still drain through tick(). + bool stopForOTA(char reply[]); void tick(uint32_t now); // call every loop iteration Mode mode() const { return _mode; } @@ -267,6 +270,7 @@ private: SemaphoreHandle_t _mux; Mode _mode = MODE_OFF; bool _stopping = false; + bool _keep_wifi_on_stop = false; bool _was_setup_ap = false; bool _initial_setup = false; uint32_t _setup_started_at = 0; diff --git a/test/fixtures/official_web_console/contract.mjs b/test/fixtures/official_web_console/contract.mjs new file mode 100644 index 000000000..702948440 --- /dev/null +++ b/test/fixtures/official_web_console/contract.mjs @@ -0,0 +1,75 @@ +// Only the SerialPort device boundary is simulated. The console, CRLF parser, +// encoder, streams, open/close and command writes come from the pinned website. +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {readFileSync} from 'node:fs'; +import {createInterface} from 'node:readline'; +import {setTimeout as delay} from 'node:timers/promises'; + +const {SerialConsole} = await import('data:text/javascript;base64,' + + readFileSync(process.argv[2]).toString('base64')); +const firmware = spawn(process.argv[3], [], {stdio:['pipe','pipe','inherit']}); +let stream, pending, state; +let writes = 0; +createInterface({input:firmware.stdout}).on('line', line => { + if (line.startsWith('DATA ')) { + const bytes = Buffer.from(line.slice(5), 'hex'); + // Delimiters must work when CR and LF arrive in separate USB transfers. + for (let n=0; n {if(pending) pending.reject(Error('Firmware exited: '+code));}); +function command(text) { + assert(!pending, 'Peripheral calls must be serialized'); + return new Promise((resolve,reject)=>{pending={resolve,reject};firmware.stdin.write(text+'\n');}); +} +class Port { + async open(options) { + assert.deepEqual(options,{baudRate:115200}); + this.readable=new ReadableStream({start(controller){stream=controller;},cancel(){stream=null;}}); + this.writable=new WritableStream({write(bytes){++writes;return command('WRITE '+Buffer.from(bytes).toString('hex'));}}); + } + async close(){await command('CLOSE');} + async setSignals(){throw Error('The stock console must work without a reset or explicit DTR');} +} +let client, lines; +async function connect() { + lines=[];client=new SerialConsole(new Port());client.onOutput=line=>lines.push(line); + client.connect(); await delay(0); assert(client.connected); +} +async function query(text, expected, label) { + const start=lines.length; + await client.sendCommand(text); + // All device work is synchronous at the pipe boundary, but Web Streams + // delivery is asynchronous. Bound it rather than making a wall-time sleep. + for(let n=0;n<50;n++) { + if(lines.slice(start).join('').includes(expected)) return; + await delay(2); + } + assert.fail(label+': stock console did not display the firmware reply'); +} +try { + await connect(); + await query('set powersaving on',' -> OK - powersaving on\n','power-saving setter'); + await query('get powersaving',' -> > on\n','power-saving getter'); + for(const elapsed of [121000,180000,360000,1800000,10800000,0xfffffffe]) { + const before=writes; + await command('TIME '+elapsed); + assert.equal(writes,before,'An idle web console gained an artificial keepalive'); + await query('get powersaving',' -> > on\n','idle query'); + assert.equal(state[1],0,'An open web console entered light sleep'); + } + await client.disconnect();await connect(); + await query('get powersaving',' -> > on\n','reopen query'); + await query('set powersaving off',' -> OK - powersaving off\n','disable power saving'); + await command('HOST_OFF');await command('TIME 121000'); + assert.equal(state[1],0,'Disabled power saving slept'); + await query('set powersaving on',' -> OK - powersaving on\n','enable battery power saving'); + await command('TIME 242000'); + assert.equal(state[1],1,'USB protection permanently disabled battery power saving'); + console.log('Official console: CRLF, no-DTR idle, power saving, reopen and battery sleep passed'); +} finally { + if(client?.connected) await client.disconnect(); + firmware.stdin.end(); +} diff --git a/test/fixtures/official_web_console/firmware.cpp b/test/fixtures/official_web_console/firmware.cpp new file mode 100644 index 000000000..4483e4d8f --- /dev/null +++ b/test/fixtures/official_web_console/firmware.cpp @@ -0,0 +1,103 @@ +// Inserted after the ESP32Board peripheral harness. Peripheral queues/time are +// simulated. @-marked methods below are copied verbatim from production. +#include +#include +#include +#include +#include +#include +#include +#include + +struct Stream { + std::deque input; + std::string output; + int available() { return input.size(); } + int read() { int c = input.front(); input.pop_front(); return c; } + void print(char c) { output += c; } + void print(const char* text) { output += text; } + void println(const char* text) { output += text; output += "\r\n"; } + void printf(const char* format, ...) { + char buffer[256]; va_list args; va_start(args, format); + int n = vsnprintf(buffer, sizeof(buffer), format, args); va_end(args); + assert(n >= 0 && size_t(n) < sizeof(buffer)); output.append(buffer, n); + } +} console; +namespace mesh { +void serviceUsbLoggingPort() {} +void serviceUsbTerminalPort() {} +bool takeUsbTerminalSessionReset() { return false; } +bool tryCompleteUsbTerminalSessionReset() { return true; } +bool canAcceptUsbConsoleCommand() { return true; } +Stream& usbConsolePort() { return console; } +void noteUsbLoggingStatsCommand(const char*) {} +bool isUsbLoggingWatchdogArmed() { return false; } +namespace wireless { +struct Control { bool pending() { return false; } }; +Control& control() { static Control value; return value; } +} +} +struct Prefs { uint8_t powersaving_enabled = 0; } prefs; +struct Sensors { void setPowerSavingEnabled(bool) {} } sensors; +struct MyMesh { + Prefs* _prefs = &prefs; + Sensors* _sensors = &sensors; + unsigned saves = 0; + void savePrefs() { ++saves; } + void set(const char* config, char* reply) { @SETTER@ } + void get(const char* config, char* reply) { @GETTER@ } + void handleUsbCommand(const char* command, char* reply) { + if (!strncmp(command, "set ", 4)) set(command + 4, reply); + else if (!strncmp(command, "get ", 4)) get(command + 4, reply); + else strcpy(reply, "Unknown"); + } + void cancelPendingSerialOutput() {} + bool hasPendingSerialOutput() { return false; } + Prefs* getNodePrefs() { return _prefs; } + unsigned getPowerSaveSleepSeconds(unsigned limit) { return limit; } + bool millisHasNowPassed(unsigned deadline) { return millis() > deadline; } +} the_mesh; +static ESP32Board board; +static constexpr unsigned POWERSAVING_FIRSTSLEEP_SECS = 120; +static constexpr size_t LOCAL_SERIAL_COMMAND_MAX = 159; +static char command[LOCAL_SERIAL_COMMAND_MAX + 2] = {}; +static bool command_overflow = false; +@COMMAND_PUMP@ +static void power_loop() { @POWER_LOOP@ } + +static void emit() { + if (!console.output.empty()) { + std::cout << "DATA "; + for (uint8_t c : console.output) printf("%02x", c); + std::cout << '\n'; console.output.clear(); + } + std::cout << "STATE " << unsigned(prefs.powersaving_enabled) << ' ' + << sleep_calls << ' ' << millis() << '\n'; + std::cout << "DONE\n" << std::flush; +} +int main() { + attachHost(true); // The Web Serial port is open, with no DTR/keepalive. + Serial.terminal_open = false; + mesh::logging_enabled = false; + std::string line; + while (std::getline(std::cin, line)) { + if (line.rfind("WRITE ", 0) == 0) { + // A USB peripheral suspended in light sleep cannot deliver new bytes. + if (sleep_calls == 0) { + for (size_t n = 6; n < line.size(); n += 2) { + unsigned byte; assert(n + 1 < line.size()); + assert(sscanf(line.c_str() + n, "%2x", &byte) == 1); + console.input.push_back(uint8_t(byte)); + } + while (console.available()) serviceCommandInterfaces(); + } + } else if (line.rfind("TIME ", 0) == 0) { + mock_millis = std::stoul(line.substr(5)); power_loop(); + } else if (line == "CLOSE") { + console.input.clear(); console.output.clear(); command[0] = 0; + } else if (line == "HOST_OFF") { + attachHost(false); + } else { assert(false && "Unknown peripheral operation"); } + emit(); + } +} diff --git a/test/fixtures/webconfig_ota_handoff.cpp b/test/fixtures/webconfig_ota_handoff.cpp new file mode 100644 index 000000000..d96fcd644 --- /dev/null +++ b/test/fixtures/webconfig_ota_handoff.cpp @@ -0,0 +1,105 @@ +// Compile the production WebConfig stop/finalize methods with peripheral +// doubles. Delayed HTTP request destruction must never turn OTA WiFi off. +#include +#include +#include +#include + +static uint32_t millis() { return 123; } +struct WebConfigBatch { + static uint32_t scheduleAt(uint32_t now, uint32_t delay) { return now + delay; } +}; +constexpr int WIFI_STA = 1; +constexpr int WIFI_AP = 2; +struct WiFiFake { + bool sta = true, ap = false, connected = true, allow_sta = true, allow_ap_stop = true; + unsigned stops = 0, ap_stops = 0; + bool enableSTA(bool on) { if (!allow_sta) return false; sta = on; return true; } + bool softAPdisconnect(bool) { ++ap_stops; if (allow_ap_stop) ap = false; return false; } + int getMode() const { return (sta ? WIFI_STA : 0) | (ap ? WIFI_AP : 0); } + void mode(int value) { assert(value == WIFI_STA); sta = true; ap = false; } +} WiFi; +void stopOwnedWiFiRadio() { + WiFi.sta = WiFi.ap = WiFi.connected = false; + ++WiFi.stops; +} +struct Server { bool listening = true; void end() { listening = false; } }; +struct DNS { void stop() {} }; +struct Callbacks { unsigned stops = 0; void onWebConfigStopped() { ++stops; } }; +struct WebConfigServer { + enum Mode { MODE_OFF, MODE_LAN, MODE_SETUP }; + enum { STOP_WARN_MS = 1000, BATCH_IDLE = 0, BATCH_CONFIG = 0 }; + Server server; + Server* _server = &server; + DNS* _dns = nullptr; + Callbacks callbacks; + Callbacks* _cb = &callbacks; + Mode _mode = MODE_LAN; + bool _stopping = false, _keep_wifi_on_stop = false, _was_setup_ap = false; + bool _owns_wifi = true, _stop_warned = false, _initial_setup = false; + bool _retry_saved_wifi_in_setup = false, _setup_reconnect_in_progress = false; + bool _batch_reboot_armed = false, _setup_wifi_handoff_pending = false; + bool _admin_pwd_set = false, _board_cmds_probed = false; + uint32_t _stop_warn_at = 0, _setup_started_at = 0, _connect_deadline = 0; + uint32_t _setup_reconnect_deadline = 0, _reboot_at = 0, _batch_state = 0; + uint32_t _batch_next = 0, _setup_wifi_handoff_deadline = 0, _batch_kind = 0; + uint32_t _board_cmds = 0; + char _wifi_ssid[32] = {}, _setup_wifi_handoff_ip[16] = {}; + char _session_token[16] = {}, _stats_json[16] = {}; + bool routes = true; + void detachRoutes() { routes = false; } + void closeTerminal() {} + bool isRunning() const { return _mode != MODE_OFF; } + bool isStopping() const { return _stopping; } + void requestStop(); + bool stopForOTA(char*); + void finalizeTeardown(); +}; +@METHODS@ + +int main() { + for (bool setup : {false, true}) { + for (bool owns_wifi : {false, true}) { + WebConfigServer portal; + portal._owns_wifi = owns_wifi; + portal._was_setup_ap = setup; + portal._mode = setup ? WebConfigServer::MODE_SETUP : WebConfigServer::MODE_LAN; + WiFi = WiFiFake{}; + WiFi.ap = setup; + WiFi.sta = !setup; + WiFi.connected = !setup; + char reply[160] = {}; + assert(portal.stopForOTA(reply)); + assert(strcmp(reply, "WebConfig stopped") == 0); + assert(!portal.routes && !portal.server.listening && portal.isStopping()); + assert(WiFi.sta && !WiFi.ap && WiFi.stops == 0); + assert(WiFi.connected == !setup); + // OTA can now bind port 80 and create its own AP, while HTTP request + // objects on the old server still have outstanding references. + WiFi.ap = true; + portal.requestStop(); // A duplicate stop cannot revoke the handoff. + portal.finalizeTeardown(); // Existing requests have finally drained. + assert(WiFi.ap && WiFi.sta && WiFi.stops == 0); + assert(WiFi.ap_stops == unsigned(setup)); + assert(portal.callbacks.stops == 1 && !portal._keep_wifi_on_stop); + } + } + WebConfigServer ordinary; + WiFi = WiFiFake{}; + ordinary.requestStop(); + ordinary.finalizeTeardown(); + assert(WiFi.stops == 1 && !WiFi.sta); // Normal stop keeps its old behavior. + WebConfigServer failed; + failed._was_setup_ap = true; + WiFi = WiFiFake{}; + WiFi.allow_sta = false; + char reply[160] = {}; + assert(!failed.stopForOTA(reply)); + assert(strstr(reply, "ERR:") && failed.server.listening && !failed.isStopping()); + WiFi.allow_sta = true; + WiFi.ap = true; + WiFi.allow_ap_stop = false; + assert(!failed.stopForOTA(reply)); + assert(strstr(reply, "WebConfig stopped; WiFi AP handoff failed")); + assert(!failed.server.listening); +} diff --git a/test/fixtures/wifi_ota_start.cpp b/test/fixtures/wifi_ota_start.cpp new file mode 100644 index 000000000..6efa78cad --- /dev/null +++ b/test/fixtures/wifi_ota_start.cpp @@ -0,0 +1,164 @@ +// 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 + +#define MESH_DEBUG_PRINTLN(...) ((void)0) +constexpr int WL_CONNECTED = 3; +constexpr int HTTP_GET = 0; +struct IPAddress { + std::string value; + IPAddress() = default; + IPAddress(unsigned a, unsigned b, unsigned c, unsigned d) { + value = std::to_string(a) + "." + std::to_string(b) + "." + + std::to_string(c) + "." + std::to_string(d); + } + std::string toString() const { return value; } +}; +struct WiFiFake { + bool connected = true, ap = false, allow_ap = true; + unsigned starts = 0; + int status() const { return connected ? WL_CONNECTED : 0; } + IPAddress localIP() const { return IPAddress(10, 20, 30, 40); } + IPAddress softAPIP() const { return 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"); + return allow_ap; + } + bool softAP(const char* ssid, const char* password) { + assert(strcmp(ssid, "MeshCore-OTA") == 0 && password == nullptr); + ++starts; ap = allow_ap; return ap; + } + void softAPdisconnect(bool) { ap = false; } +} WiFi; +static unsigned server_starts = 0; +struct AsyncWebServerRequest { + void send(int, const char*, const char*) {} + void send(int, const char*, const char*, const char*) {} +}; +struct AsyncWebServer { + explicit AsyncWebServer(int) {} + template void on(const char*, int, F) {} + void begin() { ++server_starts; } + void end() {} +}; +struct Elegant { + void setID(const char*) {} + void begin(AsyncWebServer*) {} +} AsyncElegantOTA; +static int SPIFFS; +struct ESP32Board { + bool inhibit_sleep = false; +#ifdef LIGHTWEIGHT_WIFI_OTA + void* ota_server = nullptr; +#else + AsyncWebServer* ota_server = nullptr; +#endif + const char* getManufacturerName() const { return "test"; } + bool startOTAUpdate(const char*, char*, bool); + bool stopOTAUpdate(char*); +}; +struct LightweightServer { + bool allow = true; + bool begin(ESP32Board*) { ++server_starts; return allow; } + void end() {} +} lightweight_ota_server; +static bool lightweight_ota_started_ap; +#ifdef COMPANION_RADIO_FULL +static constexpr uint16_t LIGHTWEIGHT_OTA_PORT = 8080; +#else +static constexpr uint16_t LIGHTWEIGHT_OTA_PORT = 80; +#endif +struct esp_partition_t { unsigned address; }; +static bool valid_partitions = true; +const esp_partition_t* esp_ota_get_running_partition() { + static esp_partition_t value{0x10000}; return &value; +} +const esp_partition_t* esp_ota_get_next_update_partition(void*) { + static esp_partition_t value{0x340000}; return valid_partitions ? &value : nullptr; +} +@METHODS@ + +int main() { + ESP32Board board; + struct { char text[160] = {}; uint32_t guard = 0x12345678; } reply; + auto start = [&](bool ap, const char* ip, const char* instruction) { + assert(board.startOTAUpdate("test node", reply.text, ap)); + assert(board.inhibit_sleep && board.ota_server); + const std::string url = std::string("Started: http://") + ip +#ifdef COMPANION_RADIO_FULL + + ":8080" +#endif + + "/update"; + assert(strncmp(reply.text, url.c_str(), url.size()) == 0); + assert(strstr(reply.text, instruction)); + assert(strlen(reply.text) < 160 && reply.guard == 0x12345678); + }; + // Normal start reports the actual LAN address and does not disconnect it. + start(false, "10.20.30.40", "same WiFi/LAN"); + assert(WiFi.connected && !WiFi.ap && WiFi.starts == 0 && server_starts == 1); + start(false, "10.20.30.40", "same WiFi/LAN"); + assert(server_starts == 1); + + // A failed switch must leave the existing uploader protected from sleep. + WiFi.allow_ap = false; + assert(!board.startOTAUpdate("test node", reply.text, true)); + assert(strcmp(reply.text, "ERR: OTA WiFi failed") == 0); + assert(board.inhibit_sleep && board.ota_server && WiFi.connected); + WiFi.allow_ap = true; + // In particular, an already-running LAN server must honor a later `ap`. + start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); + assert(WiFi.ap && WiFi.connected && server_starts == 1); + assert(board.stopOTAUpdate(reply.text)); + assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap && WiFi.connected); + + WiFi.connected = false; + start(false, "192.168.4.1", "Join WiFi MeshCore-OTA"); + assert(WiFi.ap && !WiFi.connected); + assert(board.stopOTAUpdate(reply.text)); + WiFi.allow_ap = false; + assert(!board.startOTAUpdate("test node", reply.text, false)); + assert(strcmp(reply.text, "ERR: OTA WiFi failed") == 0); + assert(!board.inhibit_sleep && !board.ota_server); + // A pending wireless shutdown must not race a newly started uploader. + struct Backend : mesh::wireless::Backend { + uint8_t active = mesh::wireless::All; + uint8_t available() const override { return mesh::wireless::All; } + uint8_t enabled() const override { return active; } + uint8_t clients() const override { return 0; } + mesh::wireless::Result set(uint8_t service, bool on) override { + if (on) active |= service; else active &= ~service; + return mesh::wireless::Result::Done; + } + } backend; + auto& control = mesh::wireless::control(); + control.begin(backend); + assert(control.handle("set 2.4ghz off", reply.text, 160, 100, + mesh::wireless::Independent)); + assert(!board.startOTAUpdate("test node", reply.text, false)); + assert(strstr(reply.text, "wireless change pending")); + control.service(350); + assert(control.masterOff() && control.blocked(mesh::wireless::WiFi)); + WiFi.allow_ap = true; + start(false, "192.168.4.1", "Join WiFi MeshCore-OTA"); + assert(!control.masterOff() && !control.blocked(mesh::wireless::WiFi)); + assert(control.blocked(mesh::wireless::Bluetooth) && control.blocked(mesh::wireless::EspNow)); + assert(board.stopOTAUpdate(reply.text)); +#ifdef LIGHTWEIGHT_WIFI_OTA + WiFi.allow_ap = true; + lightweight_ota_server.allow = false; + assert(!board.startOTAUpdate("test node", reply.text, true)); + assert(strcmp(reply.text, "ERR: OTA server failed") == 0); + assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap); +#ifdef COMPANION_RADIO_FULL + valid_partitions = false; + assert(!board.startOTAUpdate("test node", reply.text, false)); + assert(strstr(reply.text, "partition layout requires a USB firmware update")); +#endif +#endif +} diff --git a/test/test_official_web_console.py b/test/test_official_web_console.py new file mode 100644 index 000000000..e960894b6 --- /dev/null +++ b/test/test_official_web_console.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Run the stock website console against production CLI and sleep code. + +USB/RTOS peripherals and time are simulated; the website's Web Streams client, +repeater command pump, power-saving setters/getters, and ESP32Board methods are +real. Physical browser/HIL testing remains necessary for USB enumeration. +""" +import hashlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import urllib.request + +from test_esp32_usb_sleep import HARNESS, board_method +from test_hwcdc_tx_backport import body + +ROOT = Path(__file__).resolve().parents[1] +COMMIT = "45a5257bf792b11a83f4c51c18d4f312addfb677" +URL = f"https://raw.githubusercontent.com/meshcore-dev/flasher.meshcore.io/{COMMIT}/lib/console.js" +SHA256 = "107d3ad867915aea8b2454c324327397a0f9e2f0469656046054011bc64475f9" +SIZE = 2455 + + +def official_console(): + explicit = os.environ.get("MESHCORE_OFFICIAL_WEB_CONSOLE") + path = Path(explicit) if explicit else Path.home() / ".cache/meshcore-tests" / SHA256 / "console.js" + if not path.exists(): + if explicit: + raise AssertionError(f"Missing explicit web console source: {path}") + with urllib.request.urlopen(URL, timeout=45) as response: + data = response.read(SIZE + 1) + if len(data) != SIZE or hashlib.sha256(data).hexdigest() != SHA256: + raise AssertionError("Pinned official console download failed its source gate") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + data = path.read_bytes() + if len(data) != SIZE or hashlib.sha256(data).hexdigest() != SHA256: + raise AssertionError(f"Official web console SHA/size mismatch: {path}") + return path.resolve() + + +def firmware_harness(*, lose_sleep_guard=False, lose_crlf=False): + # Retain the existing host peripheral boundary; use the production methods + # and a virtual clock to exercise minutes/hours without slowing CI. + source = HARNESS.split("int main()", 1)[0] + methods = "\n".join(board_method(signature) for signature in ( + "void sleep(uint32_t secs) override", "bool isUsbDataConnected() override", + "bool isUsbHostConnected() override")) + if lose_sleep_guard: + # A negative control must fail at the client-visible reply boundary, + # rather than only inspecting whether an implementation string exists. + methods = methods.replace("const bool usb_host_connected = isUsbHostConnected();", + "const bool usb_host_connected = false;") + methods = methods.replace("usb_host_sleep_policy.shouldKeepAwake(\n" + " millis(), MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS)", + "false") + source = source.replace("@METHODS@", methods) + source = source.replace(" uint32_t irq = 48;", " void loop() {}\n uint32_t irq = 48;") + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + setter = body(cli, 'if (strcmp(config, "powersaving on") == 0') + getter = body(cli, 'if (strcmp(config, "powersaving") == 0)') + main = (ROOT / "examples/simple_repeater/main.cpp").read_text() + pump = body(main, "static void __attribute__((noinline)) serviceCommandInterfaces()") + if lose_crlf: + pump = pump.replace('console.printf(" -> %s\\r\\n", reply);', + 'console.printf(" -> %s\\n", reply);') + start = main.index(" bool can_power_save =") + end = main.index(" if (the_mesh.getNodePrefs()->reboot_interval", start) + power_loop = main[start:end] + fixture = (ROOT / "test/fixtures/official_web_console/firmware.cpp").read_text() + return source + (fixture.replace("@SETTER@", setter).replace("@GETTER@", getter) + .replace("@COMMAND_PUMP@", pump).replace("@POWER_LOOP@", power_loop)) + + +class OfficialWebConsoleTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.node = shutil.which("node") + cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if not cls.node or not cls.compiler: + raise unittest.SkipTest("Node 18+ and a host C++17 compiler are required") + cls.console = official_console() + + def run_contract(self, *, lose_sleep_guard=False, lose_crlf=False): + with tempfile.TemporaryDirectory(prefix="official-web-console-") as tmp: + tmp = Path(tmp) + source = tmp / "firmware.cpp" + source.write_text(firmware_harness(lose_sleep_guard=lose_sleep_guard, + lose_crlf=lose_crlf), encoding="ascii") + binary = tmp / "firmware" + sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + result = subprocess.run([self.compiler, "-std=c++17", "-Wall", "-Wextra", + "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DESP32_PLATFORM=1", "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", + "-DMESH_USB_CONSOLE_COOPERATIVE=1", "-DMESH_USB_LOGGING_AVAILABLE=1", + "-DCONFIG_TINYUSB_ENABLED=0", "-I", str(ROOT / "src"), + *sanitizers, str(source), "-o", str(binary)], + text=True, capture_output=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([self.node, + str(ROOT / "test/fixtures/official_web_console/contract.mjs"), + str(self.console), str(binary)], text=True, capture_output=True, timeout=20) + return result + + def test_stock_console_crlf_idle_power_saving_and_reopen(self): + result = self.run_contract() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_missing_usb_sleep_guard_breaks_stock_console(self): + result = self.run_contract(lose_sleep_guard=True) + self.assertNotEqual(result.returncode, 0, "Negative control did not lose the console") + self.assertIn("idle query", result.stderr) + + def test_lf_only_reply_is_not_visible_in_stock_console(self): + result = self.run_contract(lose_crlf=True) + self.assertNotEqual(result.returncode, 0, "Negative control did not detect buffered replies") + self.assertIn("power-saving setter", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_wifi_ota_start.py b/test/test_wifi_ota_start.py new file mode 100644 index 000000000..bb327217e --- /dev/null +++ b/test/test_wifi_ota_start.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Exercise the actual browser OTA start/stop methods with fake WiFi and servers.""" +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +class WiFiOtaStartTest(unittest.TestCase): + def compile_and_run(self, source, *flags): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "A host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="wifi-ota-start-") as directory: + path = Path(directory) + (path / "test.cpp").write_text(source, encoding="ascii") + sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", + *sanitizer, *flags, "-I", str(ROOT / "src"), str(path / "test.cpp"), "-o", str(path / "test")], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(path / "test")], capture_output=True, + 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) + fixture = (ROOT / "test/fixtures/wifi_ota_start.cpp").read_text() + for name, implementation, flags in ( + ("lightweight infrastructure", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]), + ("lightweight companion", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1", "-DCOMPANION_RADIO_FULL=1"]), + ("AsyncElegantOTA infrastructure", other, []), + ): + 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) + + def test_common_cli_preserves_actionable_ota_errors(self): + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + start = cli.index(' } else if (memcmp(command, "start ota", 9)') + end = cli.index(' } else if (memcmp(command, "clock", 5)', start) + fixture = r''' +#include +#include +#include +struct Board { + const char* error = "ERR: OTA WiFi failed"; + bool startOTAUpdate(const char*, char* reply, bool) { + strcpy(reply, error); return false; + } + bool stopOTAUpdate(char* reply) { strcpy(reply, error); return false; } +} board; +struct Prefs { const char* node_name = "test"; } prefs; +struct CLI { + Board* _board = &board; + Prefs* _prefs = &prefs; + void run(const char* command, char* reply) { + if (false) { +@CLI@ + } + } +}; +int main() { + CLI cli; + char reply[160] = {}; + for (const char* command : {"start ota", "start ota ap", "stop ota"}) { + cli.run(command, reply); + assert(strcmp(reply, "ERR: OTA WiFi failed") == 0); + board.error = "ERR: OTA server failed"; + cli.run(command, reply); + assert(strcmp(reply, "ERR: OTA server failed") == 0); + board.error = ""; + cli.run(command, reply); + assert(strcmp(reply, "Error") == 0); + board.error = "ERR: OTA WiFi failed"; + } +} +''' + self.compile_and_run('#include \n' + fixture.replace("@CLI@", cli[start:end])) + + def test_common_cli_stops_webconfig_and_reports_the_handoff(self): + cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + start = cli.index(' } else if (memcmp(command, "start ota", 9)') + end = cli.index(' } else if (memcmp(command, "clock", 5)', start) + fixture = r''' +#include +#include +#include +struct Callbacks { + bool active = true, allow_stop = true; + int stops = 0, bridge_stops = 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_stops; } +} callbacks; +struct Board { + bool allow_start = true; + int starts = 0; + bool startOTAUpdate(const char*, char* reply, bool) { + assert(!callbacks.active); ++starts; + 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; } +} board; +struct Prefs { const char* node_name = "test"; bool bridge_enabled = false; } prefs; +struct CLI { + Board* _board = &board; + Prefs* _prefs = &prefs; + Callbacks* _callbacks = &callbacks; + void run(const char* command, char* reply) { + if (false) { +@CLI@ + } + } +}; +int main() { + CLI cli; + struct { char text[160] = {}; unsigned guard = 123; } reply; + cli.run("start ota invalid", reply.text); + assert(strstr(reply.text, "usage") && callbacks.stops == 0 && board.starts == 0); + callbacks.allow_stop = false; + cli.run("start ota", reply.text); + assert(strcmp(reply.text, "ERR: handoff failed") == 0 && board.starts == 0); + callbacks.allow_stop = true; + 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); + 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(reply.guard == 123); +} +''' + self.compile_and_run(fixture.replace("@CLI@", cli[start:end]), + "-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1", + "-DWITH_MQTT_BRIDGE=1", "-DLIGHTWEIGHT_WIFI_OTA=1") + + def test_webconfig_teardown_cannot_turn_ota_wifi_off(self): + source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text() + methods = "\n".join(extract_braced(source, signature) for signature in ( + "void WebConfigServer::requestStop(", "bool WebConfigServer::stopForOTA(", + "void WebConfigServer::finalizeTeardown(")) + fixture = (ROOT / "test/fixtures/webconfig_ota_handoff.cpp").read_text() + self.compile_and_run(fixture.replace("@METHODS@", methods)) + + def test_full_companion_cli_reports_webconfig_stop_and_hardware_errors(self): + source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + command = extract_braced(source, 'if (strcmp(command, "start ota") == 0') + fixture = r''' +#include +#include +#include +struct WebConfig { + bool active = true, allowed = true; + bool stopForOTA(char* reply) { + if (!allowed) { strcpy(reply, "ERR: handoff failed"); return false; } + active = false; return true; + } +} portal; +struct Board { + bool allowed = true; + int starts = 0; + bool startOTAUpdate(const char*, char* reply, bool) { + assert(!portal.active); ++starts; + strcpy(reply, allowed ? "Started: http://192.168.4.1:8080/update - Join WiFi MeshCore-OTA" + : "ERR: this partition layout requires a USB firmware update"); + return allowed; + } +} board; +struct Mesh { + struct { const char* node_name = "test"; } _prefs; + WebConfig* _webconfig = &portal; + bool isWebConfigActiveOrStopping() { return portal.active; } + bool run(const char* command, char* reply, size_t reply_size) { + @COMMAND@ + return false; + } +}; +int main() { + Mesh mesh; + char reply[160] = {}; + portal.allowed = false; + assert(mesh.run("start ota", reply, sizeof(reply))); + assert(strcmp(reply, "ERR: handoff failed") == 0 && board.starts == 0); + portal.allowed = true; + assert(mesh.run("start ota", reply, sizeof(reply))); + assert(strstr(reply, ":8080/update") && strstr(reply, "; WebConfig stopped")); + assert(board.starts == 1); + portal.active = true; + board.allowed = false; + assert(mesh.run("start ota ap", reply, sizeof(reply))); + assert(strstr(reply, "partition layout") && strstr(reply, "; WebConfig stopped")); + struct { char text[20]; unsigned guard = 42; } short_reply; + assert(mesh.run("start ota", short_reply.text, sizeof(short_reply.text))); + assert(short_reply.guard == 42 && short_reply.text[19] == 0); +} +''' + self.compile_and_run(fixture.replace("@COMMAND@", command), "-DWITH_WEBCONFIG=1") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_wireless_control.py b/test/test_wireless_control.py index 3d2288308..32288947d 100644 --- a/test/test_wireless_control.py +++ b/test/test_wireless_control.py @@ -319,7 +319,12 @@ int main(){ #include #include enum {WIFI_STA=1,WIFI_OFF=0,WIFI_IF_STA=0}; -struct {bool usb=false;bool isUsbDataConnected(){return usb;}} board; +struct { + bool usb=false, ota=false; + bool isUsbDataConnected(){return usb;} + bool isOTAUpdateRunning(){return ota;} + bool stopOTAUpdate(char*){ota=false;return true;} +} board; struct { int mode_value=WIFI_STA;bool autoreconnect=true; void setAutoReconnect(bool value){autoreconnect=value;} @@ -360,6 +365,13 @@ int main(){ assert(!c.pending()&&!radio_driver.active&&::WiFi.mode_value==WIFI_OFF); c.handle("set 2.4ghz on",reply,sizeof(reply),300,Independent);c.service(550); assert(the_mesh.bridge&&the_mesh.web&&radio_driver.active); + // OTA alone remains visible as an active WiFi owner after WebConfig drains. + the_mesh.web=the_mesh.bridge=false;board.ota=true; + assert(b.enabled()&mesh::wireless::WiFi); + InfrastructureWirelessBackend ota_backend; + assert(ota_backend.set(mesh::wireless::WiFi,false)==Result::Done); + assert(!board.ota); + the_mesh.web=the_mesh.bridge=true; // Individual WiFi off retains the ESP-NOW driver and stops both its own services. c.handle("set wifi off",reply,sizeof(reply),600,Independent);c.service(850); the_mesh.stopping=false;c.service(851);