From af69918094b95ba48ab939971e4349cc8f7d50cd Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 25 Oct 2026 09:07:47 -0700 Subject: [PATCH] Honor saved WebUI off during unconfigured boot setup An enabled MQTT bridge without a saved SSID forced WebConfig back on at boot, even after set webui off. Its setup AP also suspended an enabled ESP-NOW bridge. Use first-time MQTT setup as the default only when no WebUI choice is saved. Apply the saved on/off setting afterward in Repeater and Room Server. Preserve first-time provisioning and explicit starts. Add executable boot regressions for both roles with unset, on and off WebUI settings and configured/unconfigured WiFi. Update setup and picker guidance. --- docs/WiFi.md | 5 +- docs/_javascript/firmware_picker.js | 2 +- docs/cli_commands.md | 12 +++-- docs/firmware_picker.md | 4 +- examples/simple_repeater/MyMesh.cpp | 13 ++++-- examples/simple_room_server/MyMesh.cpp | 10 ++-- test/test_esp32_full_power_lifecycle.py | 61 +++++++++++++++++++++++-- 7 files changed, 87 insertions(+), 20 deletions(-) diff --git a/docs/WiFi.md b/docs/WiFi.md index 2ebb4bd61..007840204 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -194,8 +194,9 @@ absolute 30-minute window. Browser activity or a phone left associated with the AP does not extend it. If no SSID has been saved when the window expires, WebConfig closes and the ESP32 WiFi radio remains off automatically for the remainder of that boot. A reboot or power cycle starts a new 30-minute setup -window; the timeout is deliberately not written to preferences. An -administrator can still override the automatic cutoff with an explicit +window unless `set webui off` has been saved. That choice prevents automatic +setup even without WiFi credentials. The timeout itself is not saved. +An administrator can still override the automatic cutoff with an explicit `start webconfig` command. Once an SSID is saved, the provisioning cutoff no longer applies. When WiFi is diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 1fa2fef5d..5ed7166d1 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -2175,7 +2175,7 @@ "Use get logging.output and set logging.output off|usb|wifi|both to choose the saved output mode. Avoid both when two consumers publish the same packets to one broker." ); extra.push( - "With no saved SSID, the setup AP stays available for 30 minutes after each boot, then Wi-Fi powers off automatically until reboot or an explicit start webconfig command. A configured Wi-Fi mode keeps reconnecting instead." + "With no saved SSID, automatic setup offers a 30-minute AP window per boot. Use set webui off to disable automatic setup after reboot. An explicit start webconfig command can reopen it. A configured Wi-Fi mode keeps reconnecting instead." ); } else if (profile.logging === "usb-runtime") { if (!isFullCompanion(profile)) { diff --git a/docs/cli_commands.md b/docs/cli_commands.md index fbcfd5937..15a8295d9 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -326,7 +326,10 @@ folder transport. again after future reboots; `set webui off` closes it and disables that boot start. `get webui` reports the saved on/off state plus whether the portal is inactive, joining WiFi, serving a setup AP, or serving a LAN URL. The default is -`off` on repeater and room-server builds. On display-equipped repeaters, an +`off` on repeater and room-server builds. An unconfigured MQTT node can open +first-time setup while no WebUI choice has been saved. An explicit +`set webui off` prevents that AP from returning after reboot and pausing +ESP-NOW. On display-equipped repeaters, an otherwise-unused triple click toggles the same saved setting. Consequently, `> off, http://192.168.1.130/` means the saved boot setting is off while a temporary WebConfig session is currently active at that URL. @@ -355,9 +358,10 @@ saved `webui on`.) LAN mode otherwise remains active until reboot. On expanded FULL builds, an unconfigured automatic setup AP receives one absolute 30-minute window per boot, then powers WiFi off even if a client remains attached; rebooting starts -a new automatic window. An administrator can explicitly run `start webconfig` -again without rebooting. Once an SSID is saved, the cutoff no longer applies -and the selected WiFi/MQTT mode keeps reconnecting. Other setup sessions retain +a new automatic window unless `webui off` is saved. An administrator can +explicitly run `start webconfig` again without rebooting. Once an SSID is saved, +the cutoff no longer applies and the selected WiFi/MQTT mode keeps reconnecting. +Other setup sessions retain their profile's idle timeout. Full Repeater and Room Server suspend unconfigured network bridges during diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 1a3bd30d6..2b5af2011 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -305,7 +305,9 @@ the no-output and USB settings because `set usb.logging off|on` is persistent. On a fresh unified FULL install with no saved SSID, the setup AP and WiFi radio remain available for 30 minutes per boot, then turn off automatically until the next reboot or power cycle. An explicit administrator `start webconfig` remains -available as an override. A saved SSID switches to the normal indefinite +available as an override. Save `set webui off` to close the portal and prevent +its automatic return after reboot, including on an ESP-NOW node without WiFi +credentials. A saved SSID switches to the normal indefinite reconnect behavior instead. Fresh ESP32 Full infrastructure defaults to USB logging off. Existing saved diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 3d81d174d..0c448dd98 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4160,12 +4160,15 @@ void MyMesh::begin(FILESYSTEM *fs) { mesh::hilStartupTrace("mesh_webconfig_begin"); #if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) - bool start_webui = WebConfigServer::loadEnabled(false); + bool start_webui = false; +#ifdef WITH_MQTT_BRIDGE + // Offer first-boot MQTT setup only as the default. A saved WebUI choice, + // including off on an unconfigured ESP-NOW node, must take precedence. + start_webui = _prefs.bridge_enabled + && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs()); +#endif + start_webui = WebConfigServer::loadEnabled(start_webui); #ifdef WITH_MQTT_BRIDGE - // Preserve the MQTT observer's first-boot setup experience even though the - // persistent WebUI master switch defaults off on infrastructure roles. - start_webui = start_webui || (_prefs.bridge_enabled - && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())); if (start_webui && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())) { #if defined(WITH_ESPNOW_BRIDGE) if (espnow_bridge.isRunning()) espnow_bridge.end(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index de5f71ac3..132a86287 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2828,10 +2828,14 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif #if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP) - bool start_webui = WebConfigServer::loadEnabled(false); + bool start_webui = false; +#ifdef WITH_MQTT_BRIDGE + // First-boot MQTT setup is a default, never an override of saved WebUI off. + start_webui = _prefs.bridge_enabled + && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs()); +#endif + start_webui = WebConfigServer::loadEnabled(start_webui); #ifdef WITH_MQTT_BRIDGE - start_webui = start_webui || (_prefs.bridge_enabled - && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())); if (start_webui && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())) { #if defined(WITH_ESPNOW_BRIDGE) if (espnow_bridge.isRunning()) espnow_bridge.end(); diff --git a/test/test_esp32_full_power_lifecycle.py b/test/test_esp32_full_power_lifecycle.py index fe468c861..4272ada1f 100644 --- a/test/test_esp32_full_power_lifecycle.py +++ b/test/test_esp32_full_power_lifecycle.py @@ -158,7 +158,7 @@ struct MQTTBridge : AbstractBridge { }; @STOP_OWNED_WIFI@ struct WebConfigServer { - static bool enabled; + static int saved_enabled; static bool allow_start; bool running = false, stopping = false, owns; uint32_t _setup_started_at = 0; @@ -170,7 +170,10 @@ struct WebConfigServer { template WebConfigServer(void*, void* prefs, bool owner, Args...) : owns(owner) { if (prefs) strcpy(_wifi_ssid, static_cast(prefs)->wifi_ssid); } - static bool loadEnabled(bool) { return enabled; } + static bool loadEnabled(bool default_value) { + return saved_enabled < 0 ? default_value : saved_enabled != 0; + } + static bool saveEnabled(bool enabled) { saved_enabled = enabled ? 1 : 0; return true; } static bool hasConfiguredWiFi(const void* prefs) { return static_cast(prefs)->wifi_ssid[0] != 0; } bool isRunning() const { return running; } bool isStopping() const { return stopping; } @@ -200,7 +203,7 @@ struct WebConfigServer { @SETUP_WINDOW@ } }; -bool WebConfigServer::enabled = false; +int WebConfigServer::saved_enabled = -1; bool WebConfigServer::allow_start = true; constexpr unsigned OTA_TX_DRAIN_TIMEOUT_MS = 1, OTA_MQTT_STOP_SETTLE_MS = 1; const char* ota_resolve_base(int) { return "test"; } @@ -405,7 +408,7 @@ class ESP32FullPowerLifecycleTest(unittest.TestCase): methods += "\n" + wifi_owner defaults_start = source.index(" // bridge defaults") defaults = source[defaults_start:source.index(" _prefs.bridge_delay", defaults_start)] - startup = source[source.index(" bool start_webui = WebConfigServer::loadEnabled(false);"):] + startup = source[source.index(" bool start_webui ="):] startup = startup[:startup.index("#endif\n\n", startup.index(" if (start_webui)"))] tick = source.index("_webconfig->tick(millis());") portal_start = source.rfind(" if (_webconfig) {", 0, tick) @@ -436,6 +439,56 @@ class ESP32FullPowerLifecycleTest(unittest.TestCase): with self.subTest(role=role): self.compile_and_run(self.fixture(role), *FLAGS) + def test_saved_webui_off_survives_reboot_without_stopping_espnow(self): + checks = r''' +int main() { + for (bool configured : {false, true}) { + for (int saved : {-1, 0, 1}) { + // NVS survives a reboot; each node starts with fresh runtime state. + WebConfigServer::saved_enabled = -1; + if (saved >= 0) assert(WebConfigServer::saveEnabled(saved != 0)); + WiFi.reset(); espnow_owners = 0; now = 0; + MyMesh node; node.defaults(); + if (configured) strcpy(node._cli.prefs.wifi_ssid, "saved-network"); + assert(node._prefs.bridge_enabled && node._prefs.espnow_bridge_enabled); + assert(node.setBridgeState(true)); WiFi.connected = true; + assert(node.startSharedEspNowBridgeIfReady()); + assert(node.isMqttBridgeRunning() && node.espnow_bridge.isRunning()); + const auto mqtt_stops = node.@WORKER@->stops; + const auto espnow_stops = node.espnow_bridge.stops; + node.startupSetup(); + const bool setup_expected = saved == 1 || (saved < 0 && !configured); + assert(node.isWebConfigActive() == setup_expected); + assert(node._prefs.bridge_enabled && node._prefs.espnow_bridge_enabled); + if (setup_expected && !configured) { + assert(node._unconfigured_setup_espnow_suspended); + assert(!node.isMqttBridgeRunning() && !node.espnow_bridge.isRunning()); + } else { + assert(!node._unconfigured_setup_espnow_suspended); + assert(node.isMqttBridgeRunning() && node.espnow_bridge.isRunning()); + assert(node.@WORKER@->stops == mqtt_stops); + assert(node.espnow_bridge.stops == espnow_stops); + } + if (!setup_expected) { + assert(node._webconfig == nullptr && sdk_mode == WIFI_STA); + for (unsigned retry = 0; retry < 3; ++retry) { + now += 10000; + node.servicePortal(); node.serviceIdleWiFi(); + assert(node.startSharedEspNowBridgeIfReady()); + assert(!node.isWebConfigActive() && sdk_mode == WIFI_STA); + assert(sdk_started && node.isMqttBridgeRunning()); + assert(node.espnow_bridge.isRunning()); + } + } + } + } +} +''' + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + body = "#include \n" + self.fixture(role, checks) + self.compile_and_run(body, *FLAGS) + def test_suspended_mqtt_idle_portal_and_foreground_ownership(self): checks = r''' int main() {