From 36d9735e5539656a31b648eff32cae6b99576500 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 22 Oct 2026 13:59:12 -0700 Subject: [PATCH] Resume UDP station ownership after WebConfig fallback --- docs/udp_bridge_setup.md | 8 ++ examples/simple_repeater/MyMesh.cpp | 35 ++++--- examples/simple_repeater/MyMesh.h | 1 + src/helpers/esp32/WebConfigServer.cpp | 46 +++++++++ src/helpers/esp32/WebConfigServer.h | 6 +- test/fixtures/webconfig_station_owner.cpp | 1 + test/fixtures/webconfig_station_recovery.cpp | 98 +++++++++++++++++++- test/test_udp_bridge_integration.py | 60 ++++++++++-- test/test_webconfig_station_ownership.py | 26 ++++-- 9 files changed, 247 insertions(+), 34 deletions(-) diff --git a/docs/udp_bridge_setup.md b/docs/udp_bridge_setup.md index cdf71d8f3..34e2440fc 100644 --- a/docs/udp_bridge_setup.md +++ b/docs/udp_bridge_setup.md @@ -20,6 +20,14 @@ WiFi ownership transitions, then resumes when station operation is available. Opening a captive configuration access point does not create a UDP bridge network. USB/LoRa CLI access remains useful for inspecting a paused bridge. +With saved WiFi credentials, enabling UDP requests a connection to that +network. The web page can share the same station connection. An unused +automatic recovery AP returns to station mode when UDP needs the network; +connected setup clients and unfinished browser/OTA work keep their link. +An explicitly requested `start webconfig ap` stays active until released. +Use `stop webconfig` when finished with that AP, then check `get udp.status`. +Switching WiFi back on does not enable UDP if you saved `udp.enabled off`. + ## Router client isolation Client isolation is an optional access-point or guest-network policy, not a diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index feafa2b17..7998e06b4 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -11486,6 +11486,17 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { return startWebConfigImpl(force_ap, reply, false); } +bool MyMesh::webConfigOwnsWiFi() const { + bool owns_wifi = true; +#ifdef WITH_MQTT_BRIDGE + owns_wifi = !mqtt_bridge || (!mqtt_bridge->isRunning() && !mqtt_bridge->isStopping()); +#endif +#ifdef WITH_UDP_BRIDGE + owns_wifi = owns_wifi && !isUDPBridgeNetworkEnabled(); +#endif + return owns_wifi; +} + 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"); @@ -11518,11 +11529,9 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup #endif if (!_webconfig) { void* mqtt_prefs = nullptr; - bool owns_wifi = true; + const bool owns_wifi = webConfigOwnsWiFi(); #ifdef WITH_MQTT_BRIDGE mqtt_prefs = _cli.getObserverPrefs(); - owns_wifi = !mqtt_bridge - || (!mqtt_bridge->isRunning() && !mqtt_bridge->isStopping()); #endif _webconfig = new WebConfigServer(this, mqtt_prefs, owns_wifi, self_id.pub_key, getFirmwareVer(), getBuildDate(), getRole(), @@ -11542,10 +11551,7 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup // WiFi start (including master-radio restoration) must retain explicit ESP-NOW. if (automatic_setup) suspendUnconfiguredSetupBridges(); -#ifdef WITH_MQTT_BRIDGE - _webconfig->updateWiFiOwnership(!mqtt_bridge - || (!mqtt_bridge->isRunning() && !mqtt_bridge->isStopping())); -#endif + _webconfig->updateWiFiOwnership(webConfigOwnsWiFi()); #ifdef WITH_UDP_BRIDGE // The portal may switch to AP/setup mode synchronously. Close the station @@ -13624,10 +13630,7 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { mesh::usbConsolePort().printf("%s\r\n", wc_reply); } if (_webconfig) { -#ifdef WITH_MQTT_BRIDGE - _webconfig->updateWiFiOwnership(!mqtt_bridge - || (!mqtt_bridge->isRunning() && !mqtt_bridge->isStopping())); -#endif + _webconfig->updateWiFiOwnership(webConfigOwnsWiFi()); _webconfig->tick(millis()); if (!_webconfig->isRunning() && !_webconfig->isStopping()) { delete _webconfig; @@ -14352,13 +14355,15 @@ void MyMesh::serviceUDPBridge() { } // An AP, setup portal, OTA uploader or MQTT worker owns its WiFi lifecycle. // UDP may share its connected station, but never reconnects underneath it. - if (!mesh::wifi::stationMutationAllowed()) return; -#ifdef WITH_WEBCONFIG - if (isWebConfigActive() || isWebConfigStopping()) return; -#endif #ifdef WITH_MQTT_BRIDGE if (mqtt_bridge && (mqtt_bridge->isRunning() || mqtt_bridge->isStopping())) return; #endif +#ifdef WITH_WEBCONFIG + // A LAN/connecting listener follows this station owner. An explicit setup + // AP, active recovery browser or draining request retains its own link. + if (_webconfig && !_webconfig->prepareStationOwner()) return; +#endif + if (!mesh::wifi::stationMutationAllowed()) return; if (!millisHasNowPassed(udp_station_retry_at)) return; udp_station_retry_at = millis() + 15000U; mesh::wifi::Credentials credentials; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 8ef873ab3..f29ea7069 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -733,6 +733,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool _unconfigured_setup_espnow_suspended = false; void suspendUnconfiguredSetupBridges(); bool startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup); + bool webConfigOwnsWiFi() const; bool _wc_batch_active = false; bool _wc_restart_pending = false; uint8_t _wc_slot_restart_mask = 0; diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index cf875f593..82cface24 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -1053,6 +1053,52 @@ bool WebConfigServer::startAutoMode(char reply[]) { return true; } +bool WebConfigServer::prepareStationOwner() { + if (_stopping) return false; + updateWiFiOwnership(false); + if (_mode != MODE_SETUP) return true; + // Only startAutoMode's saved-network fallback is relinquished. A manual + // setup AP never sets this flag, even when saved credentials exist. + if (!_retry_saved_wifi_in_setup || WiFi.softAPgetStationNum() != 0) return false; + const auto busy = [this]() { + WCLock lock(_mux); + return handlerRefCount() != 0 || _batch_state == BATCH_PENDING + || _setup_wifi_handoff_pending || _reboot_at + || (_terminal && (_terminal->running || _terminal->queue.pending)) + || (_room_mailbox && (_room_mailbox->state() == mesh::RoomWebMailbox::State::Pending + || _room_mailbox->state() == mesh::RoomWebMailbox::State::Running)); + }; + if (busy()) return false; + // Prevent an accepted OTA upload from racing the AP-to-STA transition. + mesh::esp32::managementPassword::ChangeGuard handoff; + if (!handoff.acquired()) return false; + detachRoutes(); + if (busy()) { + attachRoutes(); + return false; + } + WiFi.softAPdisconnect(true); + if ((WiFi.getMode() & WIFI_AP) || !WiFi.mode(WIFI_STA) + || mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) { + attachRoutes(); + return false; + } + if (_dns) { + _dns->stop(); + delete _dns; + _dns = NULL; + } + _was_setup_ap = false; + _initial_setup = false; + _setup_provisioning_privilege = false; + _setup_started_at = 0; + _retry_saved_wifi_in_setup = false; + _mode = MODE_CONNECTING; + attachRoutes(); + mesh::usbDebugPort().println("WebConfig: station owner resumed; waiting for saved WiFi"); + return true; +} + void WebConfigServer::createServer() { if (_host == NULL) { _host = new AsyncWebServer(80); diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 61177e7ef..2bb30cad0 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -192,7 +192,7 @@ public: bool standalone_wifi = false); ~WebConfigServer(); - // Infrastructure roles refresh this from the actual MQTT worker state. + // Infrastructure roles refresh this from actual MQTT/UDP station owners. // A compiled-in but stopped/unconfigured bridge cannot reconnect WiFi. // Companion runtimes retain their own station ownership. Loop task only. void updateWiFiOwnership(bool owns_wifi) { @@ -204,6 +204,10 @@ public: _setup_reconnect_in_progress = false; _setup_reconnect_deadline = 0; } + // A station-only owner may reclaim an idle automatic recovery AP. Explicit + // setup APs and active browser/OTA work keep their link until released. + // Returns true when that owner may manage STA. Loop task only. + bool prepareStationOwner(); // For the device display: true while a setup-mode portal is active. // Fills the AP SSID and portal IP; either buffer may be NULL to just poll. diff --git a/test/fixtures/webconfig_station_owner.cpp b/test/fixtures/webconfig_station_owner.cpp index 0888787e1..22c3b2a6c 100644 --- a/test/fixtures/webconfig_station_owner.cpp +++ b/test/fixtures/webconfig_station_owner.cpp @@ -40,6 +40,7 @@ struct MyMesh { const char* getRole() const { return "test"; } bool startWebConfig(bool force_ap, char* reply); bool startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup); + bool webConfigOwnsWiFi() const; void suspendUnconfiguredSetupBridges(); void refresh() { @REFRESH@ diff --git a/test/fixtures/webconfig_station_recovery.cpp b/test/fixtures/webconfig_station_recovery.cpp index 6857ac024..be87e9035 100644 --- a/test/fixtures/webconfig_station_recovery.cpp +++ b/test/fixtures/webconfig_station_recovery.cpp @@ -7,10 +7,12 @@ #include #include #include +#define MESH_MANAGEMENT_PASSWORD_HOST_TEST +#include static uint32_t clock_ms = 1000; uint32_t millis() { return clock_ms; } void delay(unsigned) {} -enum { WL_DISCONNECTED = 6, WL_CONNECTED = 3, WIFI_STA = 1, +enum { WL_DISCONNECTED = 6, WL_CONNECTED = 3, WIFI_STA = 1, WIFI_AP = 2, WIFI_AP_STA = 3, WIFI_IF_STA = 0, ESP_OK = 0, WIFI_PS_NONE = 0, WIFI_PS_MAX_MODEM = 2, WIFI_PS_MIN_MODEM = 1 }; using wifi_ps_type_t = int; @@ -22,12 +24,16 @@ struct IP { }; struct FakeWiFi { int state = WL_DISCONNECTED, begins = 0, disconnects = 0; + int current_mode = WIFI_STA, ap_clients = 0, ap_disconnects = 0; + bool fail_mode = false, fail_ap_stop = false; bool next_begin_connects = false; std::string ssid, password; int status() const { return state; } - bool mode(int) { return true; } + bool mode(int value) { if (fail_mode) return false; current_mode = value; return true; } + int getMode() const { return current_mode; } + int softAPgetStationNum() const { return ap_clients; } void disconnect(bool, bool) { ++disconnects; state = WL_DISCONNECTED; } - void softAPdisconnect(bool) {} + void softAPdisconnect(bool) { ++ap_disconnects; if (!fail_ap_stop) current_mode &= ~WIFI_AP; } IP localIP() const { return {}; } } WiFi; namespace mesh { @@ -39,7 +45,8 @@ Control& control() { static Control c; return c; } namespace wifi { constexpr uint8_t kPowerSaveMax = 2, kDefaultPowerSave = 0, kPowerSaveNone = 1; constexpr bool kPrimaryEspNowRadio = false; -int applyProtocolMask(int) { return ESP_OK; } +bool fail_protocol = false; +int applyProtocolMask(int) { return fail_protocol ? -1 : ESP_OK; } void setStationAutoReconnect(bool) {} int beginStation(const char* ssid, const char* password) { ++WiFi.begins; WiFi.ssid = ssid; WiFi.password = password; @@ -77,6 +84,7 @@ static bool canonical_available = true; static const char* canonical_ssid = "saved-station"; static std::string canonical_password(64, 'A'); struct DNS { void stop() {} }; +struct WCLock { explicit WCLock(void*) {} }; class WebConfigServer { public: enum Mode { MODE_OFF, MODE_SETUP, MODE_CONNECTING, MODE_LAN }; @@ -98,6 +106,13 @@ public: bool _setup_reconnect_in_progress = false, _board_cmds_probed = false; bool _was_setup_ap = false, _initial_setup = false; bool foreground = false; + bool _setup_provisioning_privilege = false, _setup_wifi_handoff_pending = false; + enum { BATCH_IDLE, BATCH_PENDING }; + int _batch_state = BATCH_IDLE; + unsigned _reboot_at = 0, handler_refs = 0; + bool routes_attached = true, handler_arrives_on_detach = false; + struct Terminal { bool running = false; struct { bool pending = false; } queue; }; + Terminal* _terminal = nullptr; uint32_t _setup_reconnect_deadline = 0, _connect_deadline = 0; uint32_t _setup_started_at = 0, _last_activity = 0; WiFiReconnectPolicy::Tracker _wifi_reconnect_tracker; @@ -124,6 +139,7 @@ public: } bool startSetupMode(char* reply) { ++setup_starts; WiFi.disconnect(false, true); _mode = MODE_SETUP; + WiFi.current_mode = WIFI_AP_STA; createServer(); _was_setup_ap = true; _retry_saved_wifi_in_setup = false; strcpy(reply, "setup"); return true; @@ -131,11 +147,15 @@ public: bool startLanMode(char* reply) { _mode = MODE_LAN; strcpy(reply, "LAN"); return true; } bool startAutoMode(char*); bool beginSavedStation(uint32_t); + bool prepareStationOwner(); void tick(uint32_t); void createServer() { ++server_starts; } void probeBoardCommands() { _board_cmds_probed = true; } void serviceTerminal(uint32_t) {} void serviceRoom(uint32_t) {} + unsigned handlerRefCount() const { return handler_refs; } + void detachRoutes() { routes_attached = false; if (handler_arrives_on_detach) ++handler_refs; } + void attachRoutes() { routes_attached = true; } bool isNetworkForegroundActive(uint32_t) const { return foreground || _mode == MODE_SETUP; } bool bluetoothWiFiCoexistenceRequired() { return false; } bool acceptWiFi(Entry& e, const char* value) { @@ -153,6 +173,7 @@ public: }; @CONSTRUCTOR@ @ATTEMPT@ +@PREPARE@ @START@ @RECOVERY@ @@ -164,7 +185,74 @@ int main() { assert(portal._standalone_wifi && portal.rawPskCapability()); assert(strcmp(portal._wifi_ssid, canonical_ssid) == 0); assert(strlen(portal._wifi_password) == 64); -#if defined(TEST_CREDENTIAL_PRIORITY) +#if defined(TEST_UDP_STATION_OWNER) + // UDP takes the station before the portal's first 15-second deadline. + assert(portal.startAutoMode(reply) && WiFi.begins == 1); + assert(portal.prepareStationOwner() && !portal._owns_wifi && !portal._connect_deadline); + portal.tick(16000); + assert(portal.setup_starts == 0 && WiFi.begins == 1); + // With UDP off, ordinary WiFi-on owns its join and may expose recovery AP. + portal.updateWiFiOwnership(true); + portal.tick(16001); + portal.tick(31001); + assert(portal.setup_starts == 1 && portal._retry_saved_wifi_in_setup); + const int begins = WiFi.begins, disconnects = WiFi.ap_disconnects; + // A browser, queued command, handoff, reboot or active HTTP handler must + // finish without its AP being taken away when UDP is enabled later. + WiFi.ap_clients = 1; assert(!portal.prepareStationOwner()); WiFi.ap_clients = 0; + portal._batch_state = WebConfigServer::BATCH_PENDING; + assert(!portal.prepareStationOwner()); portal._batch_state = WebConfigServer::BATCH_IDLE; + portal._setup_wifi_handoff_pending = true; + assert(!portal.prepareStationOwner()); portal._setup_wifi_handoff_pending = false; + portal._reboot_at = 1; assert(!portal.prepareStationOwner()); portal._reboot_at = 0; + portal.handler_refs = 1; assert(!portal.prepareStationOwner()); portal.handler_refs = 0; + portal.handler_arrives_on_detach = true; + assert(!portal.prepareStationOwner()); portal.handler_arrives_on_detach = false; portal.handler_refs = 0; + WebConfigServer::Terminal terminal; + portal._terminal = &terminal; terminal.queue.pending = true; + assert(!portal.prepareStationOwner()); terminal.queue.pending = false; terminal.running = true; + assert(!portal.prepareStationOwner()); portal._terminal = nullptr; + mesh::RoomWebMailbox room; + uint8_t token[32] = {1}, digest[32] = {2}; + assert(room.submit(token, 1, digest, "test", 4, clock_ms) == mesh::RoomWebMailbox::Result::Accepted); + portal._room_mailbox = &room; + assert(!portal.prepareStationOwner()); + assert(room.begin() && !portal.prepareStationOwner()); portal._room_mailbox = nullptr; + assert(portal.routes_attached && WiFi.ap_disconnects == disconnects); + assert(mesh::esp32::managementPassword::initialize("admin-password")); + mesh::esp32::managementPassword::Snapshot credential; + assert(mesh::esp32::managementPassword::snapshot(credential)); + { + mesh::esp32::managementPassword::UploadGuard upload(credential.generation); + assert(upload.acquired() && !portal.prepareStationOwner()); + assert(WiFi.ap_disconnects == disconnects); + } + // An SDK failure must retain the recovery state and attached routes so a + // later ordinary loop can complete the handoff, without a reboot or clear. + WiFi.fail_ap_stop = true; + assert(!portal.prepareStationOwner() && portal.routes_attached); + assert(portal._mode == WebConfigServer::MODE_SETUP && portal._retry_saved_wifi_in_setup); + WiFi.fail_ap_stop = false; + mesh::wifi::fail_protocol = true; + assert(!portal.prepareStationOwner() && portal.routes_attached); + assert(portal._mode == WebConfigServer::MODE_SETUP && portal._retry_saved_wifi_in_setup); + mesh::wifi::fail_protocol = false; + assert(portal.prepareStationOwner() && portal._mode == WebConfigServer::MODE_CONNECTING); + assert(!portal._retry_saved_wifi_in_setup && !portal._was_setup_ap && portal.routes_attached); + assert(WiFi.current_mode == WIFI_STA && WiFi.begins == begins); + // The external owner joins; the same portal follows the resulting LAN. + WiFi.state = WL_CONNECTED; portal.tick(31002); + assert(portal._mode == WebConfigServer::MODE_LAN); + // An explicit setup AP is never reclassified as an automatic fallback. + portal._mode = WebConfigServer::MODE_OFF; + portal.startSetupMode(reply); + assert(!portal._retry_saved_wifi_in_setup); + const int explicit_disconnects = WiFi.ap_disconnects; + assert(!portal.prepareStationOwner() && WiFi.current_mode == WIFI_AP_STA); + assert(WiFi.ap_disconnects == explicit_disconnects); + portal._stopping = true; + assert(!portal.prepareStationOwner()); +#elif defined(TEST_CREDENTIAL_PRIORITY) // Credential saves and async capability stay in canonical storage across // both ownership transitions; raw PSKs never enter MQTTPrefs[64]. strcpy(obs.wifi_ssid, "legacy-station"); strcpy(obs.wifi_password, "legacy-secret"); diff --git a/test/test_udp_bridge_integration.py b/test/test_udp_bridge_integration.py index e4cbc0062..5cd415ecb 100644 --- a/test/test_udp_bridge_integration.py +++ b/test/test_udp_bridge_integration.py @@ -66,6 +66,7 @@ struct Board {bool ota=false;bool isOTAUpdateRunning()const{return ota;}}; struct ObserverPrefs {const char*wifi_ssid="test";const char*wifi_password="";uint8_t wifi_power_save=1;}; struct CLI {Board board;ObserverPrefs observer;Board*getBoard(){return &board;}const ObserverPrefs*getObserverPrefs(){return &observer;}}; struct MQTT {bool running=false,stopping=false;bool isRunning()const{return running;}bool isStopping()const{return stopping;}}; +struct WebConfig {bool allowed=true;int prepares=0;bool prepareStationOwner(){++prepares;return allowed;}}; struct UDPBridge { static int allocations;bool running=false;int loops=0,closes=0; uint32_t address=0,mask=0; @@ -84,6 +85,7 @@ struct MyMesh { NodePrefs _prefs;void* _mgr=nullptr;CLI _cli;FakeSettings udp_bridge_settings; UDPBridge*udp_bridge=nullptr;uint32_t udp_station_retry_at=0;bool udp_network_paused=false; MQTT* mqtt_bridge=nullptr;bool web_active=false,web_stopping=false; + WebConfig* _webconfig=nullptr; ~MyMesh(){delete udp_bridge;} bool isWebConfigActive(){return web_active;} bool isWebConfigStopping(){return web_stopping;} @@ -131,17 +133,20 @@ int main(){ assert(node.setUDPBridgeNetworkState(true)&&node.udp_bridge->running); WiFi.state=WL_IDLE_STATUS;now_ms+=16000;const int before_owner=WiFi.begins; #ifdef WITH_WEBCONFIG - node.web_active=true;node.serviceUDPBridge();assert(WiFi.begins==before_owner&&!node.udp_bridge->running); - node.web_active=false;node.web_stopping=true;node.serviceUDPBridge();assert(WiFi.begins==before_owner); - node.web_stopping=false; + WebConfig portal;node._webconfig=&portal;portal.allowed=false; + node.serviceUDPBridge();assert(WiFi.begins==before_owner&&!node.udp_bridge->running); + assert(portal.prepares==1); + portal.allowed=true;node.serviceUDPBridge();assert(WiFi.begins==before_owner+1); + node._webconfig=nullptr;now_ms+=16000; #endif + const int before_mqtt=WiFi.begins; #ifdef WITH_MQTT_BRIDGE MQTT mqtt;node.mqtt_bridge=&mqtt;mqtt.running=true;node.serviceUDPBridge(); - assert(WiFi.begins==before_owner&&!node.udp_bridge->running); - mqtt.running=false;mqtt.stopping=true;node.serviceUDPBridge();assert(WiFi.begins==before_owner); + assert(WiFi.begins==before_mqtt&&!node.udp_bridge->running); + mqtt.running=false;mqtt.stopping=true;node.serviceUDPBridge();assert(WiFi.begins==before_mqtt); mqtt.stopping=false; #endif - node.serviceUDPBridge();assert(WiFi.begins==before_owner+1); + node.serviceUDPBridge();assert(WiFi.begins==before_mqtt+1); WiFi.state=WL_CONNECTED;node.serviceUDPBridge();assert(node.udp_bridge->running); // A failed MQTT destination must not park a healthy independent LAN bridge. mesh::wifi::NetworkPowerPolicy policy; @@ -174,6 +179,49 @@ int main(){ ''' class UDPBridgeIntegrationTest(unittest.TestCase): + def test_webconfig_station_ownership_uses_live_udp_and_mqtt_intent(self): + method = body((ROOT / 'examples/simple_repeater/MyMesh.cpp').read_text(encoding='ascii'), + 'bool MyMesh::webConfigOwnsWiFi(') + fixture = r''' +#include +struct Worker { + bool running=false,stopping=false; + bool isRunning()const{return running;} + bool isStopping()const{return stopping;} +}; +struct MyMesh { + Worker* mqtt_bridge=nullptr; bool udp=false; + bool isUDPBridgeNetworkEnabled()const{return udp;} + bool webConfigOwnsWiFi()const; +}; +@METHOD@ +int main(){ + MyMesh node;Worker mqtt; + for(int udp=0;udp<2;++udp)for(int state=0;state<4;++state){ + node.udp=udp;node.mqtt_bridge=state?&mqtt:nullptr; + mqtt.running=state==2;mqtt.stopping=state==3; + bool expected=true; +#ifdef WITH_MQTT_BRIDGE + expected=state<2; +#endif +#ifdef WITH_UDP_BRIDGE + expected=expected&&!udp; +#endif + assert(node.webConfigOwnsWiFi()==expected); + } +} +'''.replace('@METHOD@', method) + for flags in ((), ('WITH_MQTT_BRIDGE',), ('WITH_UDP_BRIDGE',), + ('WITH_MQTT_BRIDGE', 'WITH_UDP_BRIDGE')): + with self.subTest(flags=flags), tempfile.TemporaryDirectory(prefix='udp-web-owner-') as tmp: + path = Path(tmp) + (path / 'test.cpp').write_text(fixture, encoding='ascii') + binary = path / 'test' + subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++11', '-Wall', '-Wextra', '-Werror', + '-fsanitize=address,undefined', '-fno-omit-frame-pointer', '-fno-pie', '-no-pie', + *['-D' + flag + '=1' for flag in flags], str(path / 'test.cpp'), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + def test_wireless_backend_can_query_public_espnow_state_in_every_udp_owner_mix(self): header = (ROOT / 'examples/simple_repeater/MyMesh.h').read_text(encoding='ascii') signature = 'bool isEspNowBridgeRunning() override' diff --git a/test/test_webconfig_station_ownership.py b/test/test_webconfig_station_ownership.py index f5cf0ea39..dd3fe62c8 100644 --- a/test/test_webconfig_station_ownership.py +++ b/test/test_webconfig_station_ownership.py @@ -21,11 +21,16 @@ class WebConfigStationOwnershipTest(unittest.TestCase): 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") - self.assertLess(refresh_end, tick) - self.assertFalse(source[refresh_end:tick].strip()) - refresh = source[refresh_start:refresh_end] + if role == "simple_repeater": + start += "\n" + extract_braced(source, "bool MyMesh::webConfigOwnsWiFi(") + refresh_start = source.rfind("_webconfig->updateWiFiOwnership(", 0, tick) + refresh = source[refresh_start:tick].strip() + else: + refresh_start = source.rfind("#ifdef WITH_MQTT_BRIDGE", 0, tick) + refresh_end = source.index("#endif", refresh_start) + len("#endif") + self.assertLess(refresh_end, tick) + self.assertFalse(source[refresh_end:tick].strip()) + refresh = source[refresh_start:refresh_end] self.assertIn("_webconfig->updateWiFiOwnership(", refresh) body = fixture.replace("@WORKER@", worker).replace("@START@", start) body = body.replace("@SUSPEND@", extract_braced(source, @@ -34,8 +39,9 @@ class WebConfigStationOwnershipTest(unittest.TestCase): self.compile_and_run(body, "-DWITH_MQTT_BRIDGE=1") # A stopped/unconfigured MQTT implementation used to keep # ownership forever merely because its code was compiled in. - stale = re.sub(r"owns_wifi = !.*?;", "owns_wifi = false;", body, - count=1, flags=re.DOTALL) + stale = (body.replace("return owns_wifi;", "return false;", 1) + if role == "simple_repeater" else re.sub( + r"owns_wifi = !.*?;", "owns_wifi = false;", body, count=1, flags=re.DOTALL)) self.assertNotEqual(body, stale) with self.assertRaises(AssertionError): self.compile_and_run(stale, "-DWITH_MQTT_BRIDGE=1") @@ -48,6 +54,7 @@ class WebConfigStationOwnershipTest(unittest.TestCase): start = extract_braced(source, "bool WebConfigServer::startAutoMode(") attempt = extract_braced(source, "bool WebConfigServer::beginSavedStation(") setter = extract_braced(header, "void updateWiFiOwnership(") + prepare = extract_braced(source, "bool WebConfigServer::prepareStationOwner(") tick = extract_braced(source, "void WebConfigServer::tick(") # Execute the complete production join/fallback/retry/promotion blocks. # HTTP batching, display, and settings routes below them are irrelevant. @@ -60,6 +67,7 @@ class WebConfigStationOwnershipTest(unittest.TestCase): capability = re.search(r' doc\["wifi_psk64"\] = .*?;', source).group(0) return (fixture.replace("@CONSTRUCTOR@", constructor).replace("@START@", start) .replace("@SETTER@", setter).replace("@RECOVERY@", recovery) + .replace("@PREPARE@", prepare) .replace("@ATTEMPT@", attempt).replace("@CREDENTIAL_BATCH@", batch) .replace("@PSK_CAPABILITY@", capability)) @@ -70,6 +78,10 @@ class WebConfigStationOwnershipTest(unittest.TestCase): self.compile_and_run(self.station_fixture(), "-DWITH_MQTT_BRIDGE=1", "-DTEST_EXTERNAL_OWNER=1") + def test_udp_claims_connecting_or_idle_recovery_ap_and_preserves_explicit_ap(self): + self.compile_and_run(self.station_fixture(), "-DWITH_MQTT_BRIDGE=1", + "-DTEST_UDP_STATION_OWNER=1") + def test_canonical_raw_psk_wins_and_legacy_mqtt_credentials_still_load(self): fixture = self.station_fixture() self.compile_and_run(fixture, "-DWITH_MQTT_BRIDGE=1",