From 8fbf948d6886528c2bacb7562aeb70b5e0f84e20 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 5 Oct 2026 11:23:01 -0700 Subject: [PATCH] Restore ordinary WiFi protocols for browser OTA hotspots --- src/helpers/ESP32Board.cpp | 12 ++++++++++++ test/fixtures/wifi_ota_start.cpp | 22 ++++++++++++++++++++++ test/test_wifi_ota_lifecycle.py | 9 ++++++++- test/test_wifi_ota_start.py | 6 ++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index bfcd7503..99336f60 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -59,6 +59,7 @@ bool ESP32Board::isUserGpioAvailable(uint8_t pin) const { #if defined(LIGHTWEIGHT_WIFI_OTA) && \ (defined(ADMIN_PASSWORD) || defined(COMPANION_RADIO_FULL)) #include +#include #include #include #include @@ -304,6 +305,12 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask) && WiFi.softAP("MeshCore-OTA", nullptr); } + // A valid IP does not imply a discoverable hotspot. An LR protocol bit + // left by ESP-NOW makes AP beacons incompatible with ordinary clients. + if (ota_started_ap && mesh::wifi::applyAccessPointProtocolMask() != ESP_OK) { + WiFi.softAPdisconnect(true); + ota_started_ap = false; + } if (!ota_started_ap) { inhibit_sleep = ota_server != nullptr; strcpy(reply, "ERR: OTA WiFi failed"); @@ -355,6 +362,7 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { #elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only #include +#include #include #include #include @@ -387,6 +395,10 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { ota_started_ap = WiFi.softAPConfig(ap_ip, ap_ip, ap_mask) && WiFi.softAP("MeshCore-OTA", NULL); } + if (ota_started_ap && mesh::wifi::applyAccessPointProtocolMask() != ESP_OK) { + WiFi.softAPdisconnect(true); + ota_started_ap = false; + } if (!ota_started_ap) { inhibit_sleep = ota_server != nullptr; strcpy(reply, "ERR: OTA WiFi failed"); diff --git a/test/fixtures/wifi_ota_start.cpp b/test/fixtures/wifi_ota_start.cpp index a72e38fb..11c06af5 100644 --- a/test/fixtures/wifi_ota_start.cpp +++ b/test/fixtures/wifi_ota_start.cpp @@ -11,6 +11,11 @@ constexpr int WL_CONNECTED = 3; constexpr int HTTP_GET = 0; constexpr int WIFI_AP = 2; +constexpr int ESP_OK = 0, WIFI_IF_AP = 1; +constexpr uint8_t WIFI_PROTOCOL_11B = 1, WIFI_PROTOCOL_11G = 2; +constexpr uint8_t WIFI_PROTOCOL_11N = 4, WIFI_PROTOCOL_LR = 8; +using esp_err_t = int; +bool allow_protocol_reset = true; struct IPAddress { std::string value; IPAddress() = default; @@ -24,6 +29,7 @@ struct IPAddress { struct WiFiFake { bool connected = true, ap = false, allow_ap = true; unsigned starts = 0; + uint8_t ap_protocol = 15; // Valid driver state, but LR beacons hide the AP from ordinary clients. int status() const { return connected ? WL_CONNECTED : 0; } int getMode() const { return ap ? WIFI_AP : 0; } IPAddress localIP() const { return IPAddress(10, 20, 30, 40); } @@ -39,6 +45,15 @@ struct WiFiFake { } void softAPdisconnect(bool) { ap = false; } } WiFi; +esp_err_t esp_wifi_set_protocol(int interface_id, uint8_t protocols) { + assert(interface_id == WIFI_IF_AP); + if (!allow_protocol_reset) return -1; + WiFi.ap_protocol = protocols; + return ESP_OK; +} +namespace mesh { namespace wifi { +@AP_PROTOCOL_POLICY@ +} } static unsigned server_starts = 0; struct AsyncWebServerRequest { void send(int, const char*, const char*) {} @@ -101,6 +116,7 @@ int main() { + "/update"; assert(strncmp(reply.text, url.c_str(), url.size()) == 0); assert(strstr(reply.text, instruction)); + if (ap || !WiFi.connected) assert(WiFi.ap_protocol == 7); assert(strlen(reply.text) < 160 && reply.guard == 0x12345678); }; // Normal start reports the actual LAN address and does not disconnect it. @@ -119,6 +135,7 @@ int main() { start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); assert(WiFi.ap && WiFi.connected && server_starts == 1); const auto ap_starts = WiFi.starts; + WiFi.ap_protocol = 15; // Repair an existing AP without dropping its clients. start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); assert(WiFi.starts == ap_starts); // Healthy repeats retain associated clients. WiFi.ap = false; // An external driver stop invalidates cached AP ownership. @@ -136,6 +153,11 @@ int main() { assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap && WiFi.connected); WiFi.connected = false; + allow_protocol_reset = 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 && !WiFi.ap); + allow_protocol_reset = true; start(false, "192.168.4.1", "Join WiFi MeshCore-OTA"); assert(WiFi.ap && !WiFi.connected); assert(board.stopOTAUpdate(reply.text)); diff --git a/test/test_wifi_ota_lifecycle.py b/test/test_wifi_ota_lifecycle.py index 154d0ccc..cdb416f8 100644 --- a/test/test_wifi_ota_lifecycle.py +++ b/test/test_wifi_ota_lifecycle.py @@ -38,6 +38,7 @@ STUBS = r''' #define MESH_DEBUG_PRINTLN(...) ((void)0) constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6, HTTP_GET = 0; constexpr int WIFI_AP = 2; +constexpr int ESP_OK = 0; struct IPAddress { uint32_t value = 0; @@ -61,6 +62,7 @@ struct MockWiFi { bool ap_config_ok = true, ap_start_ok = true; bool initialized_cache = false, started_cache = false; IPAddress station_ip, ap_ip, configured_ap_ip; + int ap_protocol = 15; int status() const { return station_status; } int getMode() const { return sdk_ap_active ? WIFI_AP : 0; } IPAddress localIP() const { return station_ip; } @@ -78,7 +80,11 @@ struct MockWiFi { } return ap_start_ok; } + void softAPdisconnect(bool) { sdk_ap_active = false; ap_ip = IPAddress(); } } WiFi; +namespace mesh { namespace wifi { +int applyAccessPointProtocolMask() { WiFi.ap_protocol = 7; return ESP_OK; } +} } int wifi_stop_calls = 0, wifi_deinit_calls = 0; int esp_wifi_stop() { @@ -148,7 +154,7 @@ void seedStation() { bool endpointUsable() { return sdk_initialized && sdk_started && ((WiFi.station_status == WL_CONNECTED && WiFi.station_ip.value != 0) - || (sdk_ap_active && WiFi.ap_ip.value != 0)); + || (sdk_ap_active && WiFi.ap_ip.value != 0 && WiFi.ap_protocol == 7)); } int main(int argc, char** argv) { assert(argc == 2); @@ -162,6 +168,7 @@ int main(int argc, char** argv) { the_mesh.web = true; const IPAddress ip(192, 168, 4, 1), mask(255, 255, 255, 0); assert(WiFi.softAPConfig(ip, ip, mask) && WiFi.softAP("setup", nullptr)); + mesh::wifi::applyAccessPointProtocolMask(); assert(endpointUsable()); } else if (scenario == "owners_mqtt" || scenario == "cleanup_mqtt") { the_mesh.mqtt = true; diff --git a/test/test_wifi_ota_start.py b/test/test_wifi_ota_start.py index f1eda6d2..b6164e91 100644 --- a/test/test_wifi_ota_start.py +++ b/test/test_wifi_ota_start.py @@ -2,6 +2,7 @@ """Exercise the actual browser OTA start/stop methods with fake WiFi and servers.""" import os from pathlib import Path +import re import shutil import subprocess import sys @@ -34,6 +35,11 @@ class WiFiOtaStartTest(unittest.TestCase): 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() + policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text() + mask = re.search(r"static constexpr uint8_t kAccessPointProtocolMask =.*?;", + policy, re.DOTALL).group(0) + fixture = fixture.replace("@AP_PROTOCOL_POLICY@", mask + "\n" + extract_braced( + policy, "inline esp_err_t applyAccessPointProtocolMask()")) for name, implementation, flags in ( ("lightweight infrastructure", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]), ("lightweight companion", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1", "-DCOMPANION_RADIO_FULL=1"]),