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);