Retry saved WebConfig WiFi when the MQTT worker is absent
This commit is contained in:
1 parent
b81da36a00
commit
526a3d1aa4
8 files changed
+479
-31
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user