Resume UDP station ownership after WebConfig fallback

This commit is contained in:
mikecarper committed 2026-10-22 13:59:12 -07:00
1 parent 9b80ee63ee
commit 36d9735e55
9 files changed
+247 -34

No files matched your search

+8
View File
@@ -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
+20 -15
View File
@@ -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;
+1
View File
@@ -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;
+46
View File
@@ -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);
+5 -1
View File
@@ -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.
+1
View File
@@ -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@
+93 -5
View File
@@ -7,10 +7,12 @@
#include <helpers/RoomWebMailbox.h>
#include <helpers/WiFiReconnectPolicy.h>
#include <helpers/WiFiNetworkPowerPolicy.h>
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST
#include <helpers/esp32/ManagementPassword.h>
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");
+54 -6
View File
@@ -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 <cassert>
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'
+19 -7
View File
@@ -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",