diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index cd5944c1..be11cf5c 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -460,6 +460,7 @@ jobs: python3 -B test/test_webconfig_ui.py python3 -B test/test_webconfig_gzip.py python3 -B test/test_webconfig_secret_masking.py + python3 -B test/test_webconfig_station_ownership.py -v python3 -B test/test_webconfig_ui_runtime.py - name: Upload Test Results diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b5cefdbd..bdbcc9d4 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -10935,17 +10935,23 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { bool owns_wifi = true; #ifdef WITH_MQTT_BRIDGE mqtt_prefs = _cli.getObserverPrefs(); - owns_wifi = false; + 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(), - _cli.getBoard()->getManufacturerName()); + _cli.getBoard()->getManufacturerName(), true); if (!_webconfig) { strcpy(reply, "Err: not enough memory for webconfig"); return true; } } +#ifdef WITH_MQTT_BRIDGE + _webconfig->updateWiFiOwnership(!mqtt_bridge + || (!mqtt_bridge->isRunning() && !mqtt_bridge->isStopping())); +#endif + if (force_ap) { #ifdef WITH_MQTT_BRIDGE if (mqtt_bridge && mqtt_bridge->isRunning()) { @@ -12945,6 +12951,10 @@ 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->tick(millis()); if (!_webconfig->isRunning() && !_webconfig->isStopping()) { delete _webconfig; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index a5390f52..c8e75973 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1951,17 +1951,22 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { bool owns_wifi = true; #ifdef WITH_MQTT_BRIDGE mqtt_prefs = _cli.getObserverPrefs(); - owns_wifi = false; + owns_wifi = !bridge || (!bridge->isRunning() && !bridge->isStopping()); #endif _webconfig = new WebConfigServer(this, mqtt_prefs, owns_wifi, self_id.pub_key, getFirmwareVer(), getBuildDate(), getRole(), - _cli.getBoard()->getManufacturerName()); + _cli.getBoard()->getManufacturerName(), true); if (!_webconfig) { strcpy(reply, "Err: not enough memory for webconfig"); return true; } } +#ifdef WITH_MQTT_BRIDGE + _webconfig->updateWiFiOwnership(!bridge + || (!bridge->isRunning() && !bridge->isStopping())); +#endif + if (force_ap) { #ifdef WITH_MQTT_BRIDGE if (bridge && bridge->isRunning()) { @@ -2742,6 +2747,10 @@ void MyMesh::loop() { #ifdef WITH_WEBCONFIG if (_webconfig) { +#ifdef WITH_MQTT_BRIDGE + _webconfig->updateWiFiOwnership(!bridge + || (!bridge->isRunning() && !bridge->isStopping())); +#endif _webconfig->tick(millis()); if (!_webconfig->isRunning() && !_webconfig->isStopping()) { delete _webconfig; diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 5813e15c..5d0f6d42 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -331,15 +331,17 @@ static portMUX_TYPE s_wc_route_mux = portMUX_INITIALIZER_UNLOCKED; WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi, const uint8_t* pub_key, const char* fw_ver, const char* build_date, - const char* role, const char* board_name) - : _cb(callbacks), _mqtt_prefs(mqtt_prefs), _owns_wifi(owns_wifi), _pub_key(pub_key), + const char* role, const char* board_name, + bool standalone_wifi) + : _cb(callbacks), _mqtt_prefs(mqtt_prefs), _owns_wifi(owns_wifi), + _standalone_wifi(standalone_wifi || mqtt_prefs == nullptr || !owns_wifi), _pub_key(pub_key), _fw_ver(fw_ver), _build_date(build_date), _role(role), _board_name(board_name) { _mux = xSemaphoreCreateMutex(); _cli_enabled = loadCliEnabled(true); #ifdef WITH_MQTT_BRIDGE MQTTPrefs* obs = static_cast(_mqtt_prefs); - if (obs && _owns_wifi) { + if (obs && !_standalone_wifi) { strncpy(_wifi_ssid, obs->wifi_ssid, sizeof(_wifi_ssid) - 1); strncpy(_wifi_password, obs->wifi_password, sizeof(_wifi_password) - 1); _wifi_power_save = obs->wifi_power_save <= mesh::wifi::kPowerSaveMax @@ -897,6 +899,29 @@ bool WebConfigServer::startLanMode(char reply[]) { return true; } +bool WebConfigServer::beginSavedStation(uint32_t now) { + WiFi.mode(WIFI_STA); + if (mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) { + return false; + } + mesh::wifi::setStationAutoReconnect(true); + _retry_saved_wifi_in_setup = false; + _setup_reconnect_in_progress = false; + _setup_reconnect_deadline = 0; + _setup_started_at = 0; + _wifi_reconnect_tracker.noteDisconnected(now); + mesh::wifi::beginStation(_wifi_ssid, _wifi_password); + _wifi_power_save = effectiveWiFiPowerSave(_wifi_power_save); + wifi_ps_type_t ps_mode = + _wifi_power_save == mesh::wifi::kPowerSaveNone ? WIFI_PS_NONE + : _wifi_power_save == mesh::wifi::kPowerSaveMax ? WIFI_PS_MAX_MODEM + : WIFI_PS_MIN_MODEM; + esp_wifi_set_ps(ps_mode); + _connect_deadline = now + 15000; + if (_connect_deadline == 0) _connect_deadline = 1; + return true; +} + bool WebConfigServer::startAutoMode(char reply[]) { if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); @@ -908,28 +933,17 @@ bool WebConfigServer::startAutoMode(char reply[]) { strcpy(reply, "Err: webconfig busy"); return false; } - - WiFi.mode(WIFI_STA); - if (mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) { + _connect_deadline = 0; + const bool manages_station = _owns_wifi || !_runtime_wifi_ownership_managed; + if (manages_station && !beginSavedStation(millis())) { strcpy(reply, "Err: failed to reset WiFi station protocol"); return false; } - mesh::wifi::setStationAutoReconnect(true); - _retry_saved_wifi_in_setup = false; - _setup_reconnect_in_progress = false; - _setup_reconnect_deadline = 0; - _setup_started_at = 0; - _wifi_reconnect_tracker.noteDisconnected(millis()); - mesh::wifi::beginStation(_wifi_ssid, _wifi_password); - _wifi_power_save = effectiveWiFiPowerSave(_wifi_power_save); - wifi_ps_type_t ps_mode = - _wifi_power_save == mesh::wifi::kPowerSaveNone ? WIFI_PS_NONE - : _wifi_power_save == mesh::wifi::kPowerSaveMax ? WIFI_PS_MAX_MODEM - : WIFI_PS_MIN_MODEM; - esp_wifi_set_ps(ps_mode); _mode = MODE_CONNECTING; - _connect_deadline = millis() + 15000; - if (_connect_deadline == 0) _connect_deadline = 1; + if (!manages_station) { + strcpy(reply, "WebConfig waiting for WiFi owner; use 'get webui' for its IP"); + return true; + } snprintf(reply, 160, "WebConfig connecting to '%s'; use 'get webui' for its IP", _wifi_ssid); return true; } @@ -1086,6 +1100,14 @@ void WebConfigServer::tick(uint32_t now) { } if (_mode == MODE_CONNECTING) { + // A previously active MQTT worker may have stopped while the portal was + // waiting for its link. Acquire the station with a fresh bounded attempt. + if (_owns_wifi && !_connect_deadline && WiFi.status() != WL_CONNECTED) { + if (!beginSavedStation(now)) { + _connect_deadline = now + 15000; + if (_connect_deadline == 0) _connect_deadline = 1; + } + } if (WiFi.status() == WL_CONNECTED) { _wifi_reconnect_tracker.noteConnected(); _mode = MODE_LAN; @@ -1095,7 +1117,8 @@ void WebConfigServer::tick(uint32_t now) { mesh::usbDebugPort().printf( "WebConfig ready: http://%s/\n", WiFi.localIP().toString().c_str()); - } else if (_connect_deadline && (int32_t)(now - _connect_deadline) >= 0) { + } else if ((_owns_wifi || !_runtime_wifi_ownership_managed) && _connect_deadline + && (int32_t)(now - _connect_deadline) >= 0) { mesh::usbDebugPort().printf( "WebConfig: WiFi '%s' unavailable; opening setup AP\n", _wifi_ssid); const bool retry_saved_wifi = _wifi_ssid[0] != 0; @@ -1365,7 +1388,7 @@ void WebConfigServer::drainBatch(uint32_t now) { const bool admin_pwd = wcIsAdminPasswordKey(e.key); const char* value = admin_pwd ? e.cmd + strlen("password ") : e.cmd + strlen("set ") + strlen(e.key) + 1; - if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.ssid") == 0) { + if (_standalone_wifi && strcmp(e.key, "wifi.ssid") == 0) { if (!value[0] || strlen(value) >= sizeof(_wifi_ssid)) { strcpy(e.reply, "Error: WiFi SSID must be 1-31 characters"); } else { @@ -1374,7 +1397,7 @@ void WebConfigServer::drainBatch(uint32_t now) { _standalone_wifi_dirty = true; strcpy(e.reply, "OK"); } - } else if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.pwd") == 0) { + } else if (_standalone_wifi && strcmp(e.key, "wifi.pwd") == 0) { if (!mesh::cli::standaloneWiFiPasswordValid(value)) { strcpy(e.reply, "Error: WiFi password must be 0-63 characters or 64 hex characters"); @@ -1384,7 +1407,7 @@ void WebConfigServer::drainBatch(uint32_t now) { _standalone_wifi_dirty = true; strcpy(e.reply, "OK"); } - } else if ((_mqtt_prefs == NULL || !_owns_wifi) && strcmp(e.key, "wifi.powersave") == 0) { + } else if (_standalone_wifi && strcmp(e.key, "wifi.powersave") == 0) { if (strcmp(value, "min") == 0) _wifi_power_save = 0; else if (strcmp(value, "none") == 0 && bluetoothWiFiCoexistenceRequired()) { @@ -1780,7 +1803,7 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) { #endif // The fixed-layout MQTTPrefs path remains limited to 63 characters. // Standalone mesh-wifi NVS can also hold a standards-defined 64-hex PSK. - doc["wifi_psk64"] = (_mqtt_prefs == NULL || !_owns_wifi); + doc["wifi_psk64"] = _standalone_wifi; doc["cli"] = _cli_enabled && _mode == MODE_LAN && WiFi.status() == WL_CONNECTED && _cb->supportsCliTerminal(); doc["terminal_stream"] = _cb->supportsStreamTerminal(); diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 45f7c329..3ef52d75 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -166,9 +166,23 @@ public: WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool owns_wifi, const uint8_t* pub_key, const char* fw_ver, const char* build_date, - const char* role, const char* board_name); + const char* role, const char* board_name, + bool standalone_wifi = false); ~WebConfigServer(); + // Infrastructure roles refresh this from the actual MQTT worker state. + // 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) { + _runtime_wifi_ownership_managed = true; + if (_owns_wifi == owns_wifi) return; + _owns_wifi = owns_wifi; + // Discard only our attempt state. Never disconnect another owner's STA. + _connect_deadline = 0; + _setup_reconnect_in_progress = false; + _setup_reconnect_deadline = 0; + } + // 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. // Call from the loop task only (same task that changes the mode). @@ -259,6 +273,12 @@ private: Callbacks* _cb; void* _mqtt_prefs; bool _owns_wifi; + // Legacy Companion/manual portals preserve their original start behavior; + // infrastructure opts into worker ownership through updateWiFiOwnership. + bool _runtime_wifi_ownership_managed = false; + // Credential storage is fixed at construction, independently of which + // runtime currently owns the station. Async UI handlers read this flag. + const bool _standalone_wifi; const uint8_t* _pub_key; const char* _fw_ver; const char* _build_date; @@ -341,6 +361,7 @@ private: bool _board_cmds_probed = false; void createServer(); + bool beginSavedStation(uint32_t now); void registerRoutes(); typedef void (WebConfigServer::*RequestHandler)(AsyncWebServerRequest*); static void dispatchRequest(AsyncWebServerRequest* req, RequestHandler handler); diff --git a/test/fixtures/webconfig_station_owner.cpp b/test/fixtures/webconfig_station_owner.cpp new file mode 100644 index 00000000..f5b83c60 --- /dev/null +++ b/test/fixtures/webconfig_station_owner.cpp @@ -0,0 +1,63 @@ +#include +#include +#include +struct Worker { + bool running = false, stopping = false; + bool isRunning() const { return running; } + bool isStopping() const { return stopping; } +}; +struct Board { + bool isOTAUpdateRunning() const { return false; } + const char* getManufacturerName() const { return "Station G2"; } +} board; +struct CLI { + Board* getBoard() { return &board; } + void* getObserverPrefs() { return this; } +}; +struct WebConfigServer { + bool owns, constructor_owns; + WebConfigServer(void*, void*, bool owner, const unsigned char*, + const char*, const char*, const char*, const char*, bool canonical) + : owns(owner), constructor_owns(owner) { assert(canonical); } + bool isRunning() const { return false; } + bool isStopping() const { return false; } + void updateWiFiOwnership(bool owner) { owns = owner; } + void startAutoMode(char* reply) { strcpy(reply, "starting"); } + void startSetupMode(char* reply) { strcpy(reply, "setup"); } +}; +struct MyMesh { + Worker* @WORKER@ = nullptr; + CLI _cli; + WebConfigServer* _webconfig = nullptr; + struct { unsigned char pub_key[32] = {}; } self_id; + const char* getFirmwareVer() const { return "test"; } + const char* getBuildDate() const { return "test"; } + const char* getRole() const { return "test"; } + bool startWebConfig(bool force_ap, char* reply); + void refresh() { +@REFRESH@ + } +}; +@START@ +int main() { + MyMesh mesh; + Worker worker; + mesh.@WORKER@ = &worker; + char reply[160] = {}; + mesh.startWebConfig(false, reply); + assert(mesh._webconfig->constructor_owns && mesh._webconfig->owns); + worker.running = true; + mesh.refresh(); + assert(!mesh._webconfig->owns); + worker.running = false; + worker.stopping = true; + mesh.refresh(); + assert(!mesh._webconfig->owns); + worker.stopping = false; + mesh.refresh(); + assert(mesh._webconfig->owns); + mesh.@WORKER@ = nullptr; + mesh.refresh(); + assert(mesh._webconfig->owns); + delete mesh._webconfig; +} diff --git a/test/fixtures/webconfig_station_recovery.cpp b/test/fixtures/webconfig_station_recovery.cpp new file mode 100644 index 00000000..95e11b05 --- /dev/null +++ b/test/fixtures/webconfig_station_recovery.cpp @@ -0,0 +1,228 @@ +#include +#include +#include +#include +#include +#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, + 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; +void esp_wifi_set_ps(int) {} +void* xSemaphoreCreateMutex() { return nullptr; } +struct IP { + struct Text { const char* c_str() const { return "192.168.0.2"; } }; + Text toString() const { return {}; } +}; +struct FakeWiFi { + int state = WL_DISCONNECTED, begins = 0, disconnects = 0; + bool next_begin_connects = false; + std::string ssid, password; + int status() const { return state; } + void mode(int) {} + void disconnect(bool, bool) { ++disconnects; state = WL_DISCONNECTED; } + void softAPdisconnect(bool) {} + IP localIP() const { return {}; } +} WiFi; +namespace mesh { +namespace wireless { +enum { WiFi = 1 }; +struct Control { bool blocked(int) { return false; } }; +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; } +void setStationAutoReconnect(bool) {} +int beginStation(const char* ssid, const char* password) { + ++WiFi.begins; WiFi.ssid = ssid; WiFi.password = password; + if (WiFi.next_begin_connects) WiFi.state = WL_CONNECTED; + return WiFi.state; +} +bool enforceStationChannel() { return true; } +} +struct Debug { + template void printf(const char*, T...) {} + void println(const char*) {} +}; +Debug& usbDebugPort() { static Debug port; return port; } +namespace cli { +bool standaloneWiFiPasswordValid(const char* pwd) { + const size_t len = strlen(pwd); + if (len < 64) return true; + if (len != 64) return false; + for (size_t i = 0; i < len; ++i) { + const char c = pwd[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'))) return false; + } + return true; +} +} +} +struct MQTTPrefs { + char wifi_ssid[32] = {}; + char wifi_password[64] = {}; + uint8_t wifi_power_save = 0; +}; +uint8_t effectiveWiFiPowerSave(uint8_t value) { return value; } +static bool canonical_available = true; +static const char* canonical_ssid = "saved-station"; +static std::string canonical_password(64, 'A'); +struct DNS { void stop() {} }; +class WebConfigServer { +public: + enum Mode { MODE_OFF, MODE_SETUP, MODE_CONNECTING, MODE_LAN }; + struct Callbacks {}; + Callbacks* _cb; + void* _mqtt_prefs; + bool _owns_wifi; + bool _runtime_wifi_ownership_managed = false; + const bool _standalone_wifi; + const uint8_t* _pub_key; + const char *_fw_ver, *_build_date, *_role, *_board_name; + void* _mux = nullptr; + bool _cli_enabled = false; + char _wifi_ssid[32] = {}, _wifi_password[65] = {}; + uint8_t _wifi_power_save = 0; + Mode _mode = MODE_OFF; + bool _stopping = false, _retry_saved_wifi_in_setup = false; + bool _setup_reconnect_in_progress = false, _board_cmds_probed = false; + bool _was_setup_ap = false, _initial_setup = false; + uint32_t _setup_reconnect_deadline = 0, _connect_deadline = 0; + uint32_t _setup_started_at = 0, _last_activity = 0; + WiFiReconnectPolicy::Tracker _wifi_reconnect_tracker; + DNS* _dns = nullptr; + int setup_starts = 0, server_starts = 0; + struct Entry { const char* key; char reply[160] = {}; }; + bool _standalone_wifi_dirty = false; + WebConfigServer(Callbacks*, void*, bool, const uint8_t*, const char*, + const char*, const char*, const char*, bool); + static bool loadCliEnabled(bool value) { return value; } + static bool loadStandaloneWiFi(char* ssid, size_t, char* pwd, size_t, uint8_t* ps) { + if (!canonical_available) return false; + strcpy(ssid, canonical_ssid); strcpy(pwd, canonical_password.c_str()); *ps = 0; + return true; + } + bool startSetupMode(char* reply) { + ++setup_starts; WiFi.disconnect(false, true); _mode = MODE_SETUP; + createServer(); + _was_setup_ap = true; _retry_saved_wifi_in_setup = false; + strcpy(reply, "setup"); return true; + } + bool startLanMode(char* reply) { _mode = MODE_LAN; strcpy(reply, "LAN"); return true; } + bool startAutoMode(char*); + bool beginSavedStation(uint32_t); + void tick(uint32_t); + void createServer() { ++server_starts; } + void probeBoardCommands() { _board_cmds_probed = true; } + void serviceTerminal(uint32_t) {} + bool bluetoothWiFiCoexistenceRequired() { return false; } + bool acceptWiFi(Entry& e, const char* value) { +@CREDENTIAL_BATCH@ + return false; + } + return true; + } + bool rawPskCapability() const { + struct Doc { bool psk = false; bool& operator[](const char*) { return psk; } } doc; +@PSK_CAPABILITY@ + return doc.psk; + } + @SETTER@ +}; +@CONSTRUCTOR@ +@ATTEMPT@ +@START@ +@RECOVERY@ + +int main() { + MQTTPrefs obs; + const uint8_t key[32] = {}; + char reply[160] = {}; + WebConfigServer portal(nullptr, &obs, true, key, "test", "test", "observer", "G2", true); + 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) + // 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"); + for (bool owner : {false, true}) { + portal.updateWiFiOwnership(owner); + assert(portal.rawPskCapability()); + WebConfigServer::Entry entry{"wifi.pwd"}; + assert(portal.acceptWiFi(entry, canonical_password.c_str())); + assert(strcmp(entry.reply, "OK") == 0 && portal._standalone_wifi_dirty); + assert(strlen(portal._wifi_password) == 64); + assert(strcmp(obs.wifi_password, "legacy-secret") == 0); + entry = WebConfigServer::Entry{"wifi.ssid"}; + assert(portal.acceptWiFi(entry, "replacement-station")); + assert(strcmp(portal._wifi_ssid, "replacement-station") == 0); + assert(strcmp(obs.wifi_ssid, "legacy-station") == 0); + entry = WebConfigServer::Entry{"wifi.powersave"}; + assert(portal.acceptWiFi(entry, "none")); + assert(portal._wifi_power_save == 1 && obs.wifi_power_save == 0); + } + WebConfigServer priority(nullptr, &obs, true, key, "t", "t", "observer", "G2", true); + assert(strcmp(priority._wifi_ssid, canonical_ssid) == 0); + canonical_available = false; + WebConfigServer legacy(nullptr, &obs, true, key, "t", "t", "observer", "G2", true); + assert(strcmp(legacy._wifi_ssid, "legacy-station") == 0); + assert(strcmp(legacy._wifi_password, "legacy-secret") == 0); +#elif defined(TEST_LEGACY_COMPANION) + WebConfigServer companion(nullptr, nullptr, false, key, "t", "t", "companion", "G2", false); + assert(!companion._runtime_wifi_ownership_managed); + assert(companion.startAutoMode(reply)); + assert(WiFi.begins == 1); + companion.tick(15999); + assert(companion.setup_starts == 0); + companion.tick(16000); + assert(companion.setup_starts == 1 && companion._retry_saved_wifi_in_setup); + assert(!companion._runtime_wifi_ownership_managed); +#elif defined(TEST_EXTERNAL_OWNER) + portal.updateWiFiOwnership(false); + assert(portal._runtime_wifi_ownership_managed); + assert(portal.startAutoMode(reply)); + assert(WiFi.begins == 0); + portal.tick(301000); + assert(portal.setup_starts == 0 && WiFi.disconnects == 0); + // The worker stops: the waiting portal acquires a fresh 15s attempt. + portal.updateWiFiOwnership(true); + portal.tick(301001); + assert(WiFi.begins == 1 && portal._connect_deadline == 316001); + // A new worker takes over before the old deadline: do not disconnect it. + portal.updateWiFiOwnership(false); + portal.tick(400000); + assert(portal.setup_starts == 0 && WiFi.disconnects == 0); + // If that worker stops too, its expired predecessor deadline cannot cause + // an immediate AP fallback instead of a fresh saved-credential attempt. + portal.updateWiFiOwnership(true); + portal.tick(400001); + assert(WiFi.begins == 2 && portal._connect_deadline == 415001); + portal.updateWiFiOwnership(false); + WiFi.state = WL_CONNECTED; + portal.tick(400002); + assert(portal._mode == WebConfigServer::MODE_LAN && portal.server_starts == 1); +#else + assert(portal.startAutoMode(reply) && WiFi.begins == 1); + portal.tick(15999); + assert(portal.setup_starts == 0); + portal.tick(16000); + assert(portal.setup_starts == 1 && portal._retry_saved_wifi_in_setup); + assert(WiFi.begins == 1); + portal.tick(300999); + assert(WiFi.begins == 1); + WiFi.next_begin_connects = true; + portal.tick(301000); + assert(WiFi.begins == 2); + assert(WiFi.ssid == canonical_ssid && WiFi.password == canonical_password); + portal.tick(301001); + assert(portal._mode == WebConfigServer::MODE_LAN && portal.server_starts == 1); + assert(!portal._retry_saved_wifi_in_setup); +#endif +} diff --git a/test/test_webconfig_station_ownership.py b/test/test_webconfig_station_ownership.py new file mode 100644 index 00000000..b29e66b3 --- /dev/null +++ b/test/test_webconfig_station_ownership.py @@ -0,0 +1,93 @@ +"""Exercise production portal ownership and failed-join recovery without a radio.""" +from pathlib import Path +import re +import unittest + +from test_replay_reset_integration import extract_braced +import test_wifi_ota_start as wifi_start + +ROOT = Path(__file__).resolve().parents[1] + + +class WebConfigStationOwnershipTest(unittest.TestCase): + compile_and_run = wifi_start.WiFiOtaStartTest.compile_and_run + + def test_infrastructure_uses_actual_mqtt_worker_and_refreshes_ownership(self): + fixture = (ROOT / "test/fixtures/webconfig_station_owner.cpp").read_text() + for role, worker in (("simple_repeater", "mqtt_bridge"), + ("simple_room_server", "bridge")): + with self.subTest(role=role): + source = (ROOT / "examples" / role / "MyMesh.cpp").read_text() + start = extract_braced(source, "bool MyMesh::startWebConfig(") + 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] + self.assertIn("_webconfig->updateWiFiOwnership(", refresh) + body = fixture.replace("@WORKER@", worker).replace("@START@", start) + body = body.replace("@REFRESH@", refresh) + 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) + self.assertNotEqual(body, stale) + with self.assertRaises(AssertionError): + self.compile_and_run(stale, "-DWITH_MQTT_BRIDGE=1") + + def station_fixture(self): + source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text() + header = (ROOT / "src/helpers/esp32/WebConfigServer.h").read_text() + fixture = (ROOT / "test/fixtures/webconfig_station_recovery.cpp").read_text() + constructor = extract_braced(source, "WebConfigServer::WebConfigServer(") + start = extract_braced(source, "bool WebConfigServer::startAutoMode(") + attempt = extract_braced(source, "bool WebConfigServer::beginSavedStation(") + setter = extract_braced(header, "void updateWiFiOwnership(") + 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. + begin = tick.index(" if (_mode == MODE_OFF) return;") + end = tick.index(" if (_dns) _dns->processNextRequest();") + recovery = "void WebConfigServer::tick(uint32_t now) {\n" + tick[begin:end] + "\n}" + batch_start = source.index(' if (_standalone_wifi && strcmp(e.key, "wifi.ssid")') + batch_end = source.index(" _cb->execCommand(e.cmd, e.reply);", batch_start) + batch = source[batch_start:batch_end] + 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("@ATTEMPT@", attempt).replace("@CREDENTIAL_BATCH@", batch) + .replace("@PSK_CAPABILITY@", capability)) + + def test_failed_boot_join_retries_saved_credentials_with_no_mqtt_owner(self): + self.compile_and_run(self.station_fixture(), "-DWITH_MQTT_BRIDGE=1") + + def test_external_owner_excluded_and_runtime_ownership_can_transfer(self): + self.compile_and_run(self.station_fixture(), "-DWITH_MQTT_BRIDGE=1", + "-DTEST_EXTERNAL_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", + "-DTEST_CREDENTIAL_PRIORITY=1") + for old in ( + fixture.replace('doc["wifi_psk64"] = _standalone_wifi;', + 'doc["wifi_psk64"] = (_mqtt_prefs == nullptr || !_owns_wifi);'), + fixture.replace('_standalone_wifi && strcmp(e.key,', + '(_mqtt_prefs == nullptr || !_owns_wifi) && strcmp(e.key,')): + self.assertNotEqual(old, fixture) + with self.assertRaises(AssertionError): + self.compile_and_run(old, "-DWITH_MQTT_BRIDGE=1", + "-DTEST_CREDENTIAL_PRIORITY=1") + + def test_non_mqtt_portal_retains_saved_station_recovery(self): + self.compile_and_run(self.station_fixture()) + + def test_manual_companion_start_preserves_legacy_join_and_15s_fallback(self): + self.compile_and_run(self.station_fixture(), "-DWITH_MQTT_BRIDGE=1", + "-DTEST_LEGACY_COMPANION=1") + + +if __name__ == "__main__": + unittest.main()