Retry saved WebConfig WiFi when the MQTT worker is absent

This commit is contained in:
mikecarper committed 2026-10-05 22:10:36 -07:00
1 parent b81da36a00
commit 526a3d1aa4
8 files changed
+479 -31

No files matched your search

+1
View File
@@ -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
+12 -2
View File
@@ -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;
+11 -2
View File
@@ -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;
+49 -26
View File
@@ -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<MQTTPrefs*>(_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();
+22 -1
View File
@@ -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);
+63
View File
@@ -0,0 +1,63 @@
#include <cassert>
#include <cstring>
#include <new>
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;
}
+228
View File
@@ -0,0 +1,228 @@
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <helpers/WiFiReconnectPolicy.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,
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<class... T> 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
}
+93
View File
@@ -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()