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.
This commit is contained in:
mikecarper committed 2026-10-25 09:07:47 -07:00
1 parent 16399a019d
commit af69918094
7 files changed
+87 -20

No files matched your search

+3 -2
View File
@@ -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
+1 -1
View File
@@ -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)) {
+8 -4
View File
@@ -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
+3 -1
View File
@@ -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
+8 -5
View File
@@ -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();
+7 -3
View File
@@ -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();
+57 -4
View File
@@ -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<class... Args> WebConfigServer(void*, void* prefs, bool owner, Args...) : owns(owner) {
if (prefs) strcpy(_wifi_ssid, static_cast<ObserverPrefs*>(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<const ObserverPrefs*>(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 <initializer_list>\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() {