From 219e936ef82d16539111f03bc7982dd5654412df Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 6 Oct 2026 11:46:09 -0700 Subject: [PATCH] Preserve requested ESP-NOW across manual setup and master restore --- docs/cli_commands.md | 4 +- docs/firmware_picker.md | 8 +- examples/simple_repeater/MyMesh.cpp | 10 +- examples/simple_repeater/MyMesh.h | 1 + examples/simple_room_server/MyMesh.cpp | 10 +- examples/simple_room_server/MyMesh.h | 1 + test/fixtures/webconfig_station_owner.cpp | 1 + test/test_esp32_full_power_lifecycle.py | 109 ++++++++++++++++++++-- test/test_webconfig_station_ownership.py | 1 + 9 files changed, 128 insertions(+), 17 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 3add0b781..b9e316141 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -297,9 +297,11 @@ and the selected WiFi/MQTT mode keeps reconnecting. Other setup sessions retain their profile's idle timeout. Full Repeater and Room Server suspend unconfigured network bridges during -this setup session. Automatic ESP-NOW retries remain suspended after the AP +automatic first-boot setup. Automatic ESP-NOW retries remain suspended after the AP closes, so they cannot keep an unused WiFi driver running. Saved bridge choices remain intact; explicitly enabling the bridge resumes it for this boot. +Manual WebConfig/WiFi starts preserve an explicitly enabled ESP-NOW bridge, +including when `set 2.4ghz on` restores the previously selected services. Stopping a browser OTA session restores only bridges that session actually paused, rather than starting services from saved intent alone. diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 5b251ac07..cd7cbd36b 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -294,9 +294,11 @@ available as an override. A saved SSID switches to the normal indefinite reconnect behavior instead. Fresh ESP32 Full infrastructure defaults to USB logging off. Existing saved -logging and device power-saving settings survive an update. The unconfigured -setup session also pauses automatic network-bridge retries until an explicit -bridge start, allowing its radio to turn off when the AP closes. Compact OTA +logging and device power-saving settings survive an update. Automatic +unconfigured boot setup also pauses network-bridge retries until an explicit +bridge start, allowing its radio to turn off when the AP closes. Manual +WebConfig/WiFi starts preserve an explicitly enabled ESP-NOW bridge, including +when the master radio switch restores the selected services. Compact OTA images scan before raising a new AP and verify live driver readiness before reporting the update URL. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 5a405baa2..0128b963c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3946,7 +3946,7 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif if (start_webui) { char wc_reply[160]; - startWebConfig(false, wc_reply); + startWebConfigImpl(false, wc_reply, true); mesh::usbConsolePort().printf("%s\r\n", wc_reply); } #endif @@ -10949,6 +10949,10 @@ void MyMesh::suspendUnconfiguredSetupBridges() { } bool MyMesh::startWebConfig(bool force_ap, char* reply) { + return startWebConfigImpl(force_ap, reply, false); +} + +bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup) { if (_cli.getBoard()->isOTAUpdateRunning()) { strcpy(reply, "Err: OTA server is running - 'stop ota' first"); return true; @@ -10996,7 +11000,9 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { #ifdef WITH_ESPNOW_BRIDGE const bool espnow_was_running = isEspNowBridgeRunning(); #endif - suspendUnconfiguredSetupBridges(); + // Only automatic first-boot setup parks the saved default bridges. A manual + // WiFi start (including master-radio restoration) must retain explicit ESP-NOW. + if (automatic_setup) suspendUnconfiguredSetupBridges(); #ifdef WITH_MQTT_BRIDGE _webconfig->updateWiFiOwnership(!mqtt_bridge diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index ad64b867a..85888b53e 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -657,6 +657,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks // Its retry loop must not revive ESP-NOW after the AP window ends. bool _unconfigured_setup_espnow_suspended = false; void suspendUnconfiguredSetupBridges(); + bool startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup); bool _wc_batch_active = false; bool _wc_restart_pending = false; uint8_t _wc_slot_restart_mask = 0; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index b3dbc0abc..b1c63d120 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1511,7 +1511,7 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif if (start_webui) { char wc_reply[160]; - startWebConfig(false, wc_reply); + startWebConfigImpl(false, wc_reply, true); mesh::usbConsolePort().printf("%s\r\n", wc_reply); } #endif @@ -1973,6 +1973,10 @@ void MyMesh::suspendUnconfiguredSetupBridges() { } bool MyMesh::startWebConfig(bool force_ap, char* reply) { + return startWebConfigImpl(force_ap, reply, false); +} + +bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup) { if (_cli.getBoard()->isOTAUpdateRunning()) { strcpy(reply, "Err: OTA server is running - 'stop ota' first"); return true; @@ -2017,7 +2021,9 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { #ifdef WITH_ESPNOW_BRIDGE const bool espnow_was_running = isEspNowBridgeRunning(); #endif - suspendUnconfiguredSetupBridges(); + // Only automatic first-boot setup parks the saved default bridges. A manual + // WiFi start (including master-radio restoration) must retain explicit ESP-NOW. + if (automatic_setup) suspendUnconfiguredSetupBridges(); #ifdef WITH_MQTT_BRIDGE _webconfig->updateWiFiOwnership(!bridge diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 150e86cd6..b3966c40c 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -322,6 +322,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, WebConfigServer* _webconfig = nullptr; bool _unconfigured_setup_espnow_suspended = false; void suspendUnconfiguredSetupBridges(); + bool startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup); bool _wc_batch_active = false; bool _wc_restart_pending = false; uint8_t _wc_slot_restart_mask = 0; diff --git a/test/fixtures/webconfig_station_owner.cpp b/test/fixtures/webconfig_station_owner.cpp index 454a93318..bd96c7fdf 100644 --- a/test/fixtures/webconfig_station_owner.cpp +++ b/test/fixtures/webconfig_station_owner.cpp @@ -38,6 +38,7 @@ struct MyMesh { const char* getBuildDate() const { return "test"; } const char* getRole() const { return "test"; } bool startWebConfig(bool force_ap, char* reply); + bool startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup); void suspendUnconfiguredSetupBridges(); void refresh() { @REFRESH@ diff --git a/test/test_esp32_full_power_lifecycle.py b/test/test_esp32_full_power_lifecycle.py index a7853a80d..f9e5f6839 100644 --- a/test/test_esp32_full_power_lifecycle.py +++ b/test/test_esp32_full_power_lifecycle.py @@ -30,11 +30,12 @@ uint32_t millis() { return now; } bool millisHasNowPassed(uint32_t deadline) { return int32_t(now - deadline) >= 0; } void delay(unsigned) {} using wifi_mode_t = int; -constexpr int WIFI_OFF = 0, WIFI_MODE_NULL = 0, WIFI_STA = 1, WIFI_AP_STA = 3; +constexpr int WIFI_OFF = 0, WIFI_MODE_NULL = 0, WIFI_STA = 1, WIFI_AP_STA = 3, WIFI_IF_STA = 0; constexpr int ESP_OK = 0; constexpr int ESP_ERR_WIFI_NOT_INIT = -1; bool sdk_initialized = false, sdk_started = false; bool fail_sdk_stop = false, fail_sdk_deinit = false; +unsigned espnow_owners = 0; int sdk_mode = WIFI_OFF, sdk_channel = 1; unsigned sdk_stops = 0, sdk_deinits = 0; int esp_wifi_get_mode(wifi_mode_t* mode) { @@ -58,8 +59,9 @@ struct WiFiMock { int channel() const { return sdk_initialized ? sdk_channel : 0; } bool isConnected() const { return connected; } void setAutoReconnect(bool enabled) { auto_reconnect = enabled; } - void disconnect(bool off, bool erase) { - assert(!off && !erase); ++disconnects; connected = false; + void disconnect(bool off, bool erase = false) { + assert(!erase); ++disconnects; connected = false; + if (off) mode(WIFI_OFF); } bool mode(int mode) { if (mode == WIFI_OFF) ++off_modes; @@ -81,6 +83,10 @@ struct WiFiMock { } WiFi; namespace mesh { namespace bridge { constexpr int ESPNOW_FORMAT_WRAPPED = 1; } +namespace wifi { + void applyProtocolMask(int) {} void restoreEspNowChannel() {} + bool espNowChannelConstrained() { return espnow_owners != 0; } +} struct LocalIdentity { unsigned char pub_key[32] = {}; }; struct Utils { static void toHex(char* out, const unsigned char*, int) { strcpy(out, "test"); } }; struct Log { template void printf(const char*, Args...) {} }; @@ -128,7 +134,10 @@ struct AbstractBridge { void begin() { ++starts; running = true; WiFi.mode(WIFI_STA); } void end() { ++stops; running = false; } // MQTT and OTA may retain STA. }; -using ESPNowBridge = AbstractBridge; +struct ESPNowBridge : AbstractBridge { + void begin() { if (!running) ++espnow_owners; AbstractBridge::begin(); } + void end() { if (running) --espnow_owners; AbstractBridge::end(); } +}; struct MQTTNodeInfo { const char* node_name; float* freq; float* bw; uint8_t* sf; uint8_t* cr; uint8_t* repeat_flag; bool repeat_when_nonzero; @@ -139,6 +148,7 @@ struct MQTTBridge : AbstractBridge { void setBoardModel(const char*) {} void setBuildDate(const char*) {} template void setStatsSources(Args...) {} }; +@STOP_OWNED_WIFI@ struct WebConfigServer { static bool enabled; static bool allow_start; @@ -156,7 +166,7 @@ struct WebConfigServer { void updateWiFiOwnership(bool owner) { owns = owner; } bool startSetupMode(char* reply) { if (!allow_start) { - if (owns) WiFi.mode(WIFI_OFF); + if (owns) stopOwnedWiFiRadio(); strcpy(reply, "Err: failed to start AP"); return false; } _mode = MODE_SETUP; _setup_started_at = millis(); running = true; @@ -167,7 +177,7 @@ struct WebConfigServer { void requestStop() { running = false; stopping = true; } bool stopForOTA(char*) { running = stopping = false; WiFi.mode(WIFI_STA); return true; } void tick(uint32_t now) { - if (stopping) { stopping = false; if (owns) WiFi.mode(WIFI_OFF); return; } + if (stopping) { stopping = false; if (owns) stopOwnedWiFiRadio(); return; } @SETUP_WINDOW@ } }; @@ -180,6 +190,7 @@ struct MyMesh { MQTTBridge* @WORKER@ = nullptr; ESPNowBridge espnow_bridge; WebConfigServer* _webconfig = nullptr; + void* _web_terminal = nullptr; bool _unconfigured_setup_espnow_suspended = false; uint32_t shared_espnow_retry_at = 0, _ota_update_at = 0; int _ota_update_channel = 0; @@ -210,6 +221,7 @@ struct MyMesh { mesh::LocalIdentity getSelfId() { return self_id; } void* getRTCClock() { return nullptr; } void configureBridgeFilter(AbstractBridge*) {} void drainOutbound(unsigned) {} void otaAlert(const char*) {} void suspendUnconfiguredSetupBridges(); void serviceIdleWiFi(); + bool startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup); bool startWebConfig(bool force_ap, char* reply); bool stopWebConfig(char* reply); bool hasPendingWork() const; uint32_t getPowerSaveSleepSeconds(uint32_t) const; @INLINE_METHODS@ @@ -220,6 +232,7 @@ struct MyMesh { ~MyMesh() { delete _webconfig; delete @WORKER@; } }; @METHODS@ +@INFRA_BACKEND@ struct BrowserCLI { Board* _board; Prefs* _prefs; MyMesh* _callbacks; @OTA_STATE@ @@ -344,7 +357,7 @@ int main() { class ESP32FullPowerLifecycleTest(unittest.TestCase): compile_and_run = wifi_start.WiFiOtaStartTest.compile_and_run - def fixture(self, role, checks=CHECKS): + def fixture(self, role, checks=CHECKS, infra_backend=""): source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() header = (ROOT / "examples" / role / "MyMesh.h").read_text() worker = "mqtt_bridge" if role == "simple_repeater" else "bridge" @@ -353,10 +366,12 @@ class ESP32FullPowerLifecycleTest(unittest.TestCase): "bool setMqttBridgeState(", "bool setBridgeState(", "bool isEspNowBridgeRunning()", "bool isMqttBridgeRunning()", "bool isMqttBridgeStopping()", - "bool isWebConfigActive() const", "bool stopWebConfigForOTA(")) + "bool isWebConfigActive() const", "bool isWebConfigStopping() const", + "bool hasWirelessNetworkClient() const", "bool stopWebConfigForOTA(")) methods = "\n".join(extract_braced(source, signature) for signature in ( "void MyMesh::suspendUnconfiguredSetupBridges()", "void MyMesh::serviceIdleWiFi()", - "bool MyMesh::startWebConfig(", "bool MyMesh::stopWebConfig(", + "bool MyMesh::startWebConfig(", "bool MyMesh::startWebConfigImpl(", + "bool MyMesh::stopWebConfig(", "bool MyMesh::hasPendingWork() const", "uint32_t MyMesh::getPowerSaveSleepSeconds(")) defaults_start = source.index(" // bridge defaults") defaults = source[defaults_start:source.index(" _prefs.bridge_delay", defaults_start)] @@ -374,11 +389,14 @@ class ESP32FullPowerLifecycleTest(unittest.TestCase): end = cli.index(' } else if (memcmp(command, "clock", 5)', begin) web_source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text() window = extract_braced(web_source, " if (WebConfigBatch::unconfiguredSetupWindowExpired(") + stop_owned = extract_braced(web_source, "static void stopOwnedWiFiRadio()") return (FIXTURE.replace("@INLINE_METHODS@", inline.replace(" override", "")) .replace("@METHODS@", methods).replace("@DEFAULTS@", defaults) .replace("@STARTUP@", startup).replace("@PORTAL_LOOP@", portal_loop) .replace("@MANIFEST@", manifest).replace("@OTA_CLI@", cli[begin:end]) .replace("@OTA_STATE@", state) + .replace("@INFRA_BACKEND@", infra_backend) + .replace("@STOP_OWNED_WIFI@", stop_owned) .replace("@SETUP_WINDOW@", window).replace("@CHECKS@", checks) .replace("@WORKER@", worker)) @@ -416,6 +434,79 @@ int main() { with self.subTest(role=role): self.compile_and_run(self.fixture(role, checks), *flags) + def test_manual_wifi_and_master_restore_preserve_actual_requested_services(self): + backend = extract_braced((ROOT / "examples/InfrastructureWireless.h").read_text(), + "class InfrastructureWirelessBackend") + ";" + backend = "MyMesh the_mesh; Board& board = the_mesh._cli.board;\n" + backend + checks = r''' +void command(const char* text) { + char reply[160] = {}; + auto& control = mesh::wireless::control(); + assert(control.handle(text, reply, sizeof(reply), now, mesh::wireless::Independent)); + assert(strncmp(reply, "OK", 2) == 0); + for (unsigned attempt = 0; attempt < 5 && control.pending(); ++attempt) { + now += 300; the_mesh.servicePortal(); control.service(now); the_mesh.serviceIdleWiFi(); + } + assert(!control.pending()); + control.handle("get 2.4ghz", reply, sizeof(reply), now, mesh::wireless::Independent); + assert(!strstr(reply, "failed")); +} +int main() { + using namespace mesh::wireless; + InfrastructureWirelessBackend backend; + auto& control = mesh::wireless::control(); control.begin(backend); + the_mesh.defaults(); assert(the_mesh._cli.prefs.wifi_ssid[0] == 0); + the_mesh.startupSetup(); + assert(the_mesh._unconfigured_setup_espnow_suspended && !the_mesh.isEspNowBridgeRunning()); + assert(the_mesh._prefs.bridge_enabled && the_mesh._prefs.espnow_bridge_enabled); + char reply[160] = {}; + the_mesh.stopWebConfig(reply); the_mesh.servicePortal(); the_mesh.serviceIdleWiFi(); + assert(backend.enabled() == 0 && !sdk_initialized); + constexpr uint8_t requested = @REQUESTED@; + if (requested & EspNow) command("set espnow on"); + const auto espnow_stops = the_mesh.espnow_bridge.stops; + if (requested & mesh::wireless::WiFi) command("set wifi on"); + // Exact hardware regression: explicit ESP-NOW, then manual WiFi on, no SSID. + assert(backend.enabled() == requested); + assert(the_mesh.espnow_bridge.stops == espnow_stops); + assert(!the_mesh.isMqttBridgeRunning()); + for (unsigned repeat = 0; repeat < 2; ++repeat) { + command("set 2.4ghz off"); + assert(control.masterOff() && backend.enabled() == 0 && !sdk_initialized); + command("set 2.4ghz on"); + assert(!control.masterOff() && backend.enabled() == requested); + assert(the_mesh._prefs.bridge_enabled && the_mesh._prefs.espnow_bridge_enabled); + // Saved default intent must not add ESP-NOW to a WiFi-only restore. + if (!(requested & EspNow)) { + assert(the_mesh._unconfigured_setup_espnow_suspended); + assert(!the_mesh.startSharedEspNowBridgeIfReady()); + } + } + if (requested == (mesh::wireless::WiFi | EspNow)) { + // The direct forced manual portal route preserves the same live bridge. + the_mesh.stopWebConfig(reply); the_mesh.servicePortal(); the_mesh.serviceIdleWiFi(); + assert(the_mesh.isEspNowBridgeRunning() && sdk_started); + const auto stops = the_mesh.espnow_bridge.stops; + the_mesh.startWebConfig(true, reply); + assert(the_mesh.isWebConfigActive() && the_mesh.isEspNowBridgeRunning()); + assert(the_mesh.espnow_bridge.stops == stops && !the_mesh._unconfigured_setup_espnow_suspended); + } +} +''' + for role in ("simple_repeater", "simple_room_server"): + for mask in ("EspNow", "mesh::wireless::WiFi", "(mesh::wireless::WiFi | EspNow)"): + with self.subTest(role=role, requested=mask): + body = self.fixture(role, checks.replace("@REQUESTED@", mask), backend) + self.compile_and_run(body, *FLAGS, "-DESP32=1") + if mask.startswith("("): + # Reintroducing the manual suspension must break the + # exact command/replay regression, not just a source check. + old = body.replace("return startWebConfigImpl(force_ap, reply, false);", + "return startWebConfigImpl(force_ap, reply, true);") + self.assertNotEqual(old, body) + with self.assertRaises(AssertionError): + self.compile_and_run(old, *FLAGS, "-DESP32=1") + def test_failed_portal_preserves_explicit_bridge_and_prior_setup_suspension(self): checks = r''' struct WirelessBackend : mesh::wireless::Backend { diff --git a/test/test_webconfig_station_ownership.py b/test/test_webconfig_station_ownership.py index 8749569d4..ef0125815 100644 --- a/test/test_webconfig_station_ownership.py +++ b/test/test_webconfig_station_ownership.py @@ -19,6 +19,7 @@ class WebConfigStationOwnershipTest(unittest.TestCase): with self.subTest(role=role): source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() start = extract_braced(source, "bool MyMesh::startWebConfig(") + start += "\n" + extract_braced(source, "bool MyMesh::startWebConfigImpl(") tick = source.index("_webconfig->tick(millis());") refresh_start = source.rfind("#ifdef WITH_MQTT_BRIDGE", 0, tick) refresh_end = source.index("#endif", refresh_start) + len("#endif")