Fix MQTT startup after ESP32 WebConfig provisioning

This commit is contained in:
mikecarper committed 2026-10-06 19:28:10 -07:00
1 parent 86d74c4a13
commit d4b7217a4b
18 files changed
+1018 -208

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
#include <atomic>
#include <cassert>
#include <cstring>
#include <string>
#include "helpers/esp32/WiFiCredentials.h"
#include "helpers/MQTTPrefsStorage.h"
using mesh::wifi::CredentialState;
using mesh::wifi::Credentials;
namespace mesh { namespace wifi {
constexpr bool kPrimaryEspNowRadio = false;
std::string joined_ssid, joined_password;
int joins = 0;
void beginStation(const char* ssid, const char* password) {
joined_ssid = ssid; joined_password = password; ++joins;
}
}}
uint8_t effectiveWiFiPowerSave(uint8_t value) { return value; }
enum { WIFI_OFF, WIFI_STA, WIFI_PS_NONE, WIFI_PS_MAX_MODEM, WIFI_PS_MIN_MODEM };
struct { int getMode() { return WIFI_STA; } } WiFi;
using wifi_ps_type_t = int;
int esp_wifi_set_ps(int) { return 0; }
size_t strlcpy(char* to, const char* from, size_t size) {
const size_t len = strlen(from);
if (size) { memcpy(to, from, len < size ? len + 1 : size - 1); to[size - 1] = 0; }
return len;
}
struct WebConfigServer {
inline static WebConfigServer* _active = nullptr;
char _wifi_ssid[32] = {}, _wifi_password[65] = {};
uint8_t _wifi_power_save = mesh::wifi::kDefaultPowerSave;
void* _mqtt_prefs = nullptr;
bool _standalone_wifi = true, _standalone_wifi_dirty = false, _batch_all_ok = true;
struct Entry { const char* key; char reply[160] = {}; };
Entry _batch[3] = {{"wifi.ssid"}, {"wifi.pwd"}, {"wifi.powersave"}};
int _batch_count = 3;
bool bluetoothWiFiCoexistenceRequired() { return false; }
bool reloadStandaloneWiFi();
bool acceptWiFi(Entry& e, const char* value) {
@CREDENTIAL_BATCH@
return false;
}
return true;
}
bool commitWiFi() {
@COMMIT_BATCH@
return _batch_all_ok;
}
static CredentialState resolveWiFi(Credentials&, const void* = nullptr);
static bool hasConfiguredWiFi(const void* = nullptr);
static bool loadStandaloneWiFi(char*, size_t, char*, size_t, uint8_t* = nullptr,
const void* = nullptr);
static bool saveStandaloneWiFi(const char*, const char*, uint8_t);
static bool setStandaloneWiFiSSID(const char*, char*, size_t, const void* = nullptr);
static bool setStandaloneWiFiPassword(const char*, char*, size_t, const void* = nullptr);
static bool setStandaloneWiFiPowerSave(const char*, char*, size_t, const void* = nullptr);
static bool formatWiFiPassword(char*, size_t, const void* = nullptr);
};
@WEB_METHODS@
struct MQTTNodeInfo { bool canonical_wifi = false; };
constexpr int RUNTIME_MQTT_SLOTS = 2;
constexpr const char* MQTT_PRESET_NONE = "none";
constexpr const char* MQTT_PRESET_CUSTOM = "custom";
const void* findMQTTPreset(const char* name) {
return strcmp(name, "test") == 0 ? reinterpret_cast<void*>(1) : nullptr;
}
#define MQTT_DEBUG_PRINTLN(...) {}
static bool isWiFiConfigValid(const MQTTPrefs*, bool);
struct MQTTBridge {
MQTTPrefs* _obs;
MQTTNodeInfo _node_info;
bool _manage_wifi = true, _wifi_configured = false, _initialized = false;
char _wifi_ssid[32] = {}, _wifi_password[65] = {};
uint8_t _wifi_power_save = 0;
std::atomic<bool> _stop_requested{false};
explicit MQTTBridge(MQTTPrefs& obs, bool canonical = true, bool managed = true)
: _obs(&obs), _node_info{canonical}, _manage_wifi(managed) {}
bool prepareWiFiCredentials();
void beginWiFiStation();
bool isReady() const;
static bool isConfigValid(const MQTTPrefs*, bool = false);
void begin() {
@BEGIN_GATE@
_initialized = true;
if (_manage_wifi) beginWiFiStation();
}
void reconnect() {
@RECONNECT@
}
};
@MQTT_METHODS@
struct CmdCallbacks {
MQTTPrefs* legacy;
bool canonical = true;
bool usesCanonicalWiFi() const { return canonical; }
bool setWiFiSSID(const char* value, char* reply) { return WebConfigServer::setStandaloneWiFiSSID(value, reply, 160, legacy); }
bool setWiFiPassword(const char* value, char* reply) { return WebConfigServer::setStandaloneWiFiPassword(value, reply, 160, legacy); }
bool setWiFiPowerSave(const char* value, char* reply) { return WebConfigServer::setStandaloneWiFiPowerSave(value, reply, 160, legacy); }
bool getWiFiPassword(char* reply) { return WebConfigServer::formatWiFiPassword(reply, 160, legacy); }
bool getWiFiSSID(char* reply) {
Credentials credentials; WebConfigServer::resolveWiFi(credentials, legacy);
snprintf(reply, 160, "> %s", credentials.ssid); return true;
}
bool getWiFiStatus(char* reply) { strcpy(reply, "status"); return true; }
bool getWiFiPowerSave(char* reply) { strcpy(reply, "power save"); return true; }
};
struct CommonCLI {
CmdCallbacks* _callbacks;
bool handleObserverSetCmd(uint32_t, const char* config, char* reply) {
@CLI_SET@
return false; // untouched observer fallback follows this production block
}
bool handleObserverGetCmd(uint32_t sender_timestamp, const char* config, char* reply) {
@CLI_GET@
return false;
}
};
void assert_selected(MQTTBridge& bridge, const char* ssid, const char* password) {
assert(bridge.isReady());
assert(std::string(bridge._wifi_ssid) == ssid);
assert(std::string(bridge._wifi_password) == password);
const int old_joins = mesh::wifi::joins;
bridge.reconnect();
assert(mesh::wifi::joins == old_joins + 1);
assert(mesh::wifi::joined_ssid == ssid && mesh::wifi::joined_password == password);
const int opens = nvs_opens;
for (int i = 0; i < 100; ++i) assert(bridge.isReady());
assert(nvs_opens == opens); // no NVS/String operations on the hot path
}
int main() {
static_assert(sizeof(MQTTPrefs::wifi_password) == 64, "persistent codec unchanged");
MQTTPrefs obs{};
strcpy(obs.mqtt_slot_preset[0], "test");
const MQTTPrefs untouched = obs;
char reply[160] = {}, ssid[32] = {};
reset_nvs();
// Fresh Full portal-only provisioning; the frozen observer store is empty.
assert(!MQTTBridge::isConfigValid(&obs, true));
MQTTBridge empty(obs); empty.begin(); assert(!empty.isReady());
WebConfigServer fresh; fresh._mqtt_prefs = &obs;
assert(fresh.acceptWiFi(fresh._batch[0], "portal"));
assert(fresh.acceptWiFi(fresh._batch[1], "password"));
assert(fresh.acceptWiFi(fresh._batch[2], "max"));
assert(fresh.commitWiFi());
assert(memcmp(&obs, &untouched, sizeof(obs)) == 0);
assert(WebConfigServer::hasConfiguredWiFi(&obs));
assert(WebConfigServer::loadStandaloneWiFi(ssid, sizeof(ssid), nullptr, 0));
assert(std::string(ssid) == "portal");
assert(MQTTBridge::isConfigValid(&obs, true));
assert(!MQTTBridge::isConfigValid(&obs, false));
MQTTBridge portal(obs); portal.begin();
assert(portal._wifi_power_save == 2); assert_selected(portal, "portal", "password");
// Saved changes affect a new boot/restart, never a live worker snapshot.
assert(WebConfigServer::saveStandaloneWiFi("changed", "new-password", 0));
assert_selected(portal, "portal", "password");
MQTTBridge reboot(obs); reboot.begin();
assert(reboot._wifi_power_save == 0); assert_selected(reboot, "changed", "new-password");
std::string raw(64, 'A');
assert(WebConfigServer::saveStandaloneWiFi("raw", raw.c_str(), 1));
MQTTBridge raw_bridge(obs); raw_bridge.begin(); assert_selected(raw_bridge, "raw", raw.c_str());
assert(WebConfigServer::formatWiFiPassword(reply, sizeof(reply), &obs));
assert(std::string(reply) == "> " + raw);
assert(!WebConfigServer::saveStandaloneWiFi("bad", std::string(64, 'Z').c_str(), 1));
assert(!WebConfigServer::saveStandaloneWiFi("bad", std::string(65, 'A').c_str(), 1));
assert(!WebConfigServer::saveStandaloneWiFi(std::string(32, 'a').c_str(), "", 1));
MQTTBridge unchanged(obs); unchanged.begin(); assert_selected(unchanged, "raw", raw.c_str());
// Production command routing must reach the canonical callbacks before the
// legacy observer handler, retain remote secret masking, and consume errors.
CmdCallbacks callbacks{&obs}; CommonCLI cli{&callbacks};
assert(cli.handleObserverSetCmd(0, "wifi.ssid routed", reply));
const std::string raw_command = "wifi.pwd " + raw;
assert(cli.handleObserverSetCmd(0, raw_command.c_str(), reply));
assert(cli.handleObserverGetCmd(0, "wifi.pwd", reply));
assert(std::string(reply) == "> " + raw);
assert(cli.handleObserverGetCmd(1, "wifi.pwd", reply));
assert(std::string(reply) == "> ******** (local connection only)");
assert(cli.handleObserverSetCmd(0, "wifi.powersave min", reply));
assert(cli.handleObserverSetCmd(0, "wifi.pwd invalid-64", reply));
assert(memcmp(&obs, &untouched, sizeof(obs)) == 0);
callbacks.canonical = false;
assert(!cli.handleObserverSetCmd(0, "wifi.ssid legacy-route", reply));
assert(!cli.handleObserverGetCmd(0, "wifi.pwd", reply));
// Older observer-only installs fall back, and first CLI write promotes a pair.
reset_nvs(); strcpy(obs.wifi_ssid, "legacy"); strcpy(obs.wifi_password, "legacy-password");
obs.wifi_power_save = 2;
MQTTBridge legacy(obs); legacy.begin(); assert_selected(legacy, "legacy", "legacy-password");
const MQTTPrefs legacy_untouched = obs;
assert(WebConfigServer::setStandaloneWiFiSSID("promoted", reply, sizeof(reply), &obs));
assert(memcmp(&obs, &legacy_untouched, sizeof(obs)) == 0);
MQTTBridge promoted(obs); promoted.begin(); assert_selected(promoted, "promoted", "legacy-password");
reset_nvs();
assert(WebConfigServer::setStandaloneWiFiPassword(raw.c_str(), reply, sizeof(reply), &obs));
MQTTBridge password_first(obs); password_first.begin(); assert_selected(password_first, "legacy", raw.c_str());
// Fresh password-first and SSID-first CLI setup, including open networks.
reset_nvs(); obs.wifi_ssid[0] = obs.wifi_password[0] = 0;
assert(WebConfigServer::setStandaloneWiFiPassword("first-password", reply, sizeof(reply), &obs));
assert(!WebConfigServer::hasConfiguredWiFi(&obs));
assert(WebConfigServer::setStandaloneWiFiSSID("first-ssid", reply, sizeof(reply), &obs));
MQTTBridge first(obs); first.begin(); assert_selected(first, "first-ssid", "first-password");
reset_nvs();
assert(WebConfigServer::setStandaloneWiFiSSID("open", reply, sizeof(reply), &obs));
MQTTBridge open(obs); open.begin(); assert_selected(open, "open", "");
reset_nvs(); nvs_values["ssid"] = {PT_STR, "old-open", 0};
MQTTBridge old_open(obs); old_open.begin(); assert_selected(old_open, "old-open", "");
// Clearing MQTT CLI SSID preserves its historical recovery command while
// making the canonical empty value authoritative over old observer settings.
assert(WebConfigServer::setStandaloneWiFiSSID("", reply, sizeof(reply), &obs));
assert(!WebConfigServer::hasConfiguredWiFi(&obs));
assert(!WebConfigServer::setStandaloneWiFiSSID("", reply, sizeof(reply)));
// Canonical empty, malformed, missing SSID, interrupted or inaccessible data
// must never revive valid observer credentials.
strcpy(obs.wifi_ssid, "legacy"); strcpy(obs.wifi_password, "legacy-password");
for (int fault = 0; fault < 7; ++fault) {
reset_nvs();
nvs_values["ssid"] = {PT_STR, fault == 0 ? "" : "canonical", 0};
nvs_values["password"] = {PT_STR, "pwd", 0};
if (fault == 1) nvs_values["password"].string = std::string(64, 'Z');
if (fault == 2) nvs_values["ssid"].string = std::string(32, 'a');
if (fault == 3) nvs_values["ssid"].type = PT_U8;
if (fault == 4) nvs_values.erase("ssid");
if (fault == 5) nvs_values["pending"] = {PT_U8, {}, 1};
if (fault == 6) nvs_available = false;
assert(!MQTTBridge::isConfigValid(&obs, true));
MQTTBridge broken(obs); broken.begin(); assert(!broken.isReady());
assert(!WebConfigServer::hasConfiguredWiFi(&obs));
}
// Companion-managed and default legacy/Pico semantics ignore infrastructure NVS.
reset_nvs(); assert(WebConfigServer::saveStandaloneWiFi("infra", raw.c_str(), 1));
MQTTBridge companion(obs, true, false); companion.begin();
assert(std::string(companion._wifi_ssid) == "legacy");
assert(std::string(companion._wifi_password) == "legacy-password");
MQTTBridge legacy_role(obs, false); legacy_role.begin();
assert_selected(legacy_role, "legacy", "legacy-password");
// Every individual failed NVS write rolls back a committed pair. Persistent
// failures/interrupted saves stay closed; a failed repair cannot clear pending.
for (int failed_write = 1; failed_write <= 5; ++failed_write) {
reset_nvs(); assert(WebConfigServer::saveStandaloneWiFi("before", "before-pwd", 1));
nvs_writes = 0; nvs_fail_write = failed_write;
assert(!WebConfigServer::saveStandaloneWiFi("after", "after-pwd", 2));
nvs_fail_write = -1;
MQTTBridge rolled_back(obs); rolled_back.begin();
assert_selected(rolled_back, "before", "before-pwd");
}
// A failed portal batch reloads the committed cache; it cannot later join
// the edited-but-unsaved tuple after its AP remains open.
reset_nvs(); assert(WebConfigServer::saveStandaloneWiFi("before", "before-pwd", 1));
WebConfigServer failed_portal; failed_portal._mqtt_prefs = &obs;
assert(failed_portal.reloadStandaloneWiFi());
assert(failed_portal.acceptWiFi(failed_portal._batch[0], "unsaved"));
assert(failed_portal.acceptWiFi(failed_portal._batch[1], "unsaved-password"));
nvs_writes = 0; nvs_fail_write = 3;
assert(!failed_portal.commitWiFi());
assert(std::string(failed_portal._wifi_ssid) == "before");
assert(std::string(failed_portal._wifi_password) == "before-pwd");
nvs_fail_write = -1;
// A final marker commit may change the value before returning failure.
// Rollback must re-establish its barrier; if that fails, leave the complete
// candidate unchanged instead of exposing a mixed credential pair.
reset_nvs(); assert(WebConfigServer::saveStandaloneWiFi("before", "before-pwd", 1));
nvs_writes = 0; nvs_fail_write = nvs_side_effect_write = 5;
assert(!WebConfigServer::saveStandaloneWiFi("after", "after-pwd", 2));
nvs_fail_write = nvs_side_effect_write = -1;
MQTTBridge side_effect(obs); side_effect.begin(); assert_selected(side_effect, "before", "before-pwd");
reset_nvs(); assert(WebConfigServer::saveStandaloneWiFi("before", "before-pwd", 1));
nvs_writes = 0; nvs_fail_write = nvs_side_effect_write = 5; nvs_fail_write_again = 6;
assert(!WebConfigServer::saveStandaloneWiFi("after", "after-pwd", 2));
nvs_fail_write = nvs_side_effect_write = nvs_fail_write_again = -1;
MQTTBridge no_barrier(obs); no_barrier.begin(); assert_selected(no_barrier, "after", "after-pwd");
reset_nvs(); assert(WebConfigServer::saveStandaloneWiFi("before", "before-pwd", 1));
nvs_writes = 0; nvs_fail_write = 3; nvs_fail_forever = true;
assert(!WebConfigServer::saveStandaloneWiFi("after", "after-pwd", 2));
nvs_fail_write = -1; nvs_fail_forever = false;
MQTTBridge partial(obs); partial.begin(); assert(!partial.isReady());
nvs_values["pending"] = {PT_U8, {}, 1};
nvs_writes = 0; nvs_fail_write = 3;
assert(!WebConfigServer::saveStandaloneWiFi("repair", "repair-pwd", 1));
nvs_fail_write = -1;
MQTTBridge failed_repair(obs); failed_repair.begin(); assert(!failed_repair.isReady());
assert(WebConfigServer::saveStandaloneWiFi("repair", "repair-pwd", 1));
MQTTBridge repair(obs); repair.begin(); assert_selected(repair, "repair", "repair-pwd");
// No-canonical Companion reads retain compiled/live credentials, whereas
// explicit invalid or empty canonical settings cannot revive those secrets.
reset_nvs();
#ifdef WIFI_PWD
assert(WebConfigServer::formatWiFiPassword(reply, sizeof(reply)));
assert(std::string(reply) == "> compiled-password");
#endif
WebConfigServer live;
strcpy(live._wifi_password, "live-password"); WebConfigServer::_active = &live;
assert(WebConfigServer::formatWiFiPassword(reply, sizeof(reply)));
assert(std::string(reply) == "> live-password");
nvs_values["ssid"] = {PT_STR, "", 0};
nvs_values["password"] = {PT_STR, "", 0};
assert(WebConfigServer::formatWiFiPassword(reply, sizeof(reply)));
assert(std::string(reply) == "> ");
nvs_values["pending"] = {PT_U8, {}, 1};
assert(WebConfigServer::formatWiFiPassword(reply, sizeof(reply)));
assert(std::string(reply) == "> ");
WebConfigServer::_active = nullptr;
}
+11 -2
View File
@@ -103,8 +103,17 @@ public:
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;
static bool loadStandaloneWiFi(char* ssid, size_t, char* pwd, size_t, uint8_t* ps, const void* legacy = nullptr) {
if (!canonical_available) {
#ifdef WITH_MQTT_BRIDGE
if (legacy) {
const auto* obs = static_cast<const MQTTPrefs*>(legacy);
strcpy(ssid, obs->wifi_ssid); strcpy(pwd, obs->wifi_password);
*ps = obs->wifi_power_save; return ssid[0] != 0;
}
#endif
return false;
}
strcpy(ssid, canonical_ssid); strcpy(pwd, canonical_password.c_str()); *ps = 0;
return true;
}
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <cassert>
#include <map>
#include <string>
#include <cstdint>
struct String {
std::string value;
String() = default;
String(const char* s) : value(s) {}
String(const std::string& s) : value(s) {}
size_t length() const { return value.size(); }
const char* c_str() const { return value.c_str(); }
bool operator==(const char* s) const { return value == s; }
bool operator==(const String& s) const { return value == s.value; }
};
enum PreferenceType { PT_INVALID, PT_STR, PT_U8 };
struct NvsValue { PreferenceType type; std::string string; uint8_t byte; };
inline std::map<std::string, NvsValue> nvs_values;
inline int nvs_writes = 0, nvs_fail_write = -1, nvs_opens = 0;
inline int nvs_side_effect_write = -1, nvs_fail_write_again = -1;
inline bool nvs_fail_forever = false, nvs_available = true;
inline bool nvs_write_allowed() {
++nvs_writes;
if (nvs_writes == nvs_fail_write_again) return false;
return nvs_fail_write < 0 || (nvs_fail_forever
? nvs_writes < nvs_fail_write : nvs_writes != nvs_fail_write);
}
struct Preferences {
bool begin(const char* name, bool) {
assert(std::string(name) == "mesh-wifi"); ++nvs_opens; return nvs_available;
}
void end() {}
bool isKey(const char* key) { return nvs_values.count(key) != 0; }
PreferenceType getType(const char* key) {
return isKey(key) ? nvs_values.at(key).type : PT_INVALID;
}
String getString(const char* key, const char* fallback) {
return getType(key) == PT_STR ? String(nvs_values.at(key).string) : String(fallback);
}
uint8_t getUChar(const char* key, uint8_t fallback) {
return getType(key) == PT_U8 ? nvs_values.at(key).byte : fallback;
}
size_t putString(const char* key, const String& value) {
const bool ok = nvs_write_allowed();
if (ok || nvs_writes == nvs_side_effect_write) nvs_values[key] = {PT_STR, value.value, 0};
return ok ? value.length() : 0;
}
size_t putUChar(const char* key, uint8_t value) {
const bool ok = nvs_write_allowed();
if (ok || nvs_writes == nvs_side_effect_write) nvs_values[key] = {PT_U8, {}, value};
return ok ? 1 : 0;
}
bool remove(const char* key) {
if (!nvs_write_allowed()) return false;
nvs_values.erase(key); return true;
}
};
inline void reset_nvs() {
nvs_values.clear(); nvs_writes = nvs_opens = 0; nvs_fail_write = -1;
nvs_fail_forever = false; nvs_available = true;
nvs_side_effect_write = nvs_fail_write_again = -1;
}
+2 -1
View File
@@ -140,7 +140,7 @@ struct ESPNowBridge : AbstractBridge {
};
struct MQTTNodeInfo {
const char* node_name; float* freq; float* bw;
uint8_t* sf; uint8_t* cr; uint8_t* repeat_flag; bool repeat_when_nonzero;
uint8_t* sf; uint8_t* cr; uint8_t* repeat_flag; bool repeat_when_nonzero; bool canonical_wifi = false;
};
struct MQTTBridge : AbstractBridge {
template<class... Args> explicit MQTTBridge(Args...) {}
@@ -161,6 +161,7 @@ struct WebConfigServer {
if (prefs) strcpy(_wifi_ssid, static_cast<ObserverPrefs*>(prefs)->wifi_ssid);
}
static bool loadEnabled(bool) { return enabled; }
static bool hasConfiguredWiFi(const void* prefs) { return static_cast<const ObserverPrefs*>(prefs)->wifi_ssid[0] != 0; }
bool isRunning() const { return running; }
bool isStopping() const { return stopping; }
void updateWiFiOwnership(bool owner) { owns = owner; }
+95 -19
View File
@@ -5,6 +5,9 @@ import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
import test_wifi_ota_start as wifi_start
ROOT = Path(__file__).resolve().parents[1]
PROFILE = ROOT / "variants" / "heltec_v4" / "platformio.ini"
@@ -21,6 +24,7 @@ def ini_section(source: str, name: str) -> str:
class HeltecV4WiFiSetupPageTest(unittest.TestCase):
compile_and_run = wifi_start.WiFiOtaStartTest.compile_and_run
def test_only_main_v4_oled_full_profiles_enable_the_page(self):
profile = PROFILE.read_text(encoding="utf-8")
full_profiles = (
@@ -84,22 +88,93 @@ class HeltecV4WiFiSetupPageTest(unittest.TestCase):
def test_short_click_leaves_page_and_hold_toggles_session_ap(self):
ui = UI.read_text(encoding="utf-8")
home_handler_start = ui.index(" bool handleInput(char c) override {")
home_handler_end = ui.index("\n};", home_handler_start)
handler = ui[home_handler_start:home_handler_end]
next_page = handler.index("if (c == KEY_NEXT || c == KEY_RIGHT)")
setup_action = handler.index(
"if (c == KEY_ENTER && _page == HomePage::WIFI_SETUP)"
)
self.assertLess(next_page, setup_action)
self.assertIn("_page = (_page + 1) % HomePage::Count;", handler)
setup_handler = handler[setup_action:]
self.assertIn("WebConfigServer::getSetupInfo(nullptr, 0, nullptr, 0)", setup_handler)
self.assertIn("requestCompanionWiFiSetupStop();", setup_handler)
self.assertIn("requestCompanionWiFiSetup();", setup_handler)
self.assertNotIn("!isCompanionWiFiConnected()", setup_handler)
home = ui[ui.index("class HomeScreen :") :]
handler = extract_braced(home, " bool handleInput(char c) override")
page_enum = extract_braced(home, " enum HomePage") + ";"
navigation = extract_braced(home, " void movePage(int direction)")
visible = extract_braced(home, " bool isPageVisible(uint8_t page) const")
main = MAIN.read_text()
requests = "\n".join(extract_braced(main, name) for name in (
" void requestCompanionWiFiSetup()", " void requestCompanionWiFiSetupStop()"))
# Execute production signed-char navigation and HOLD setup handling.
# Rendering/radio endpoints are mocked; preferences saves are absent,
# so an accidental permanent toggle cannot compile in this fixture.
source = r'''
#include <cassert>
#include <cstdint>
#include <cstddef>
#include <helpers/WirelessControl.h>
#define UI_WIFI_SETUP_HOME_PAGE 1
#define UI_NO_DISCOVER_SCREEN 1
#define UI_NO_HIBERNATE 1
#define KEY_LEFT 0xB4
#define KEY_RIGHT 0xB7
#define KEY_PREV 0xF2
#define KEY_NEXT 0xF1
#define KEY_ENTER 13
bool companion_wifi_setup_requested = false, companion_wifi_setup_stop_requested = false;
@REQUESTS@
struct WebConfigServer {
static bool active;
static bool getSetupInfo(void*, int, void*, int) { return active; }
};
bool WebConfigServer::active = false;
struct Mesh { bool isDualRadioActive() const { return false; } bool advert() { return true; } } the_mesh;
enum class UIEventType { ack };
struct UITask {
void showAlert(const char*, int) {}
bool isBluetoothEnabled() { return false; }
void disableBluetooth() {} void enableBluetooth() {}
void showMessages() {} void notify(UIEventType) {}
};
struct UIScreen { virtual bool handleInput(char) { return false; } };
struct HomeScreen : UIScreen {
@PAGES@
int _page = WIFI_SETUP, _radio_status_page = 0;
UITask* _task;
explicit HomeScreen(UITask* task) : _task(task) {}
void resetRadioProfileDisplayPage() {}
@VISIBLE@
@NAVIGATION@
@HANDLER@
};
int main() {
UITask task; HomeScreen home(&task);
assert(home.handleInput(static_cast<char>(KEY_NEXT)));
assert(home._page == HomeScreen::FIRST);
assert(!companion_wifi_setup_requested && !companion_wifi_setup_stop_requested);
assert(home.handleInput(static_cast<char>(KEY_PREV)));
assert(home._page == HomeScreen::WIFI_SETUP);
assert(home.handleInput(KEY_ENTER));
assert(companion_wifi_setup_requested && !companion_wifi_setup_stop_requested);
WebConfigServer::active = true;
assert(home.handleInput(KEY_ENTER));
assert(!companion_wifi_setup_requested && companion_wifi_setup_stop_requested);
assert(home.handleInput(static_cast<char>(KEY_RIGHT)));
assert(home._page == HomeScreen::FIRST);
assert(!companion_wifi_setup_requested && companion_wifi_setup_stop_requested);
assert(home.handleInput(static_cast<char>(KEY_LEFT)));
assert(home._page == HomeScreen::WIFI_SETUP);
requestCompanionWiFiSetupStop();
struct Backend : mesh::wireless::Backend {
uint8_t available() const override { return mesh::wireless::WiFi; }
uint8_t enabled() const override { return mesh::wireless::WiFi; }
uint8_t clients() const override { return 0; }
mesh::wireless::Result set(uint8_t, bool) override { return mesh::wireless::Result::Done; }
} backend;
mesh::wireless::control().begin(backend);
char reply[160] = {};
mesh::wireless::control().handle("set wifi off force", reply, sizeof(reply), 0, mesh::wireless::Independent);
mesh::wireless::control().service(250);
requestCompanionWiFiSetup();
assert(!companion_wifi_setup_requested && companion_wifi_setup_stop_requested);
}
'''
source = (source.replace("@REQUESTS@", requests).replace("@PAGES@", page_enum)
.replace("@VISIBLE@", visible).replace("@NAVIGATION@", navigation)
.replace("@HANDLER@", handler))
for char_mode in ("-fsigned-char", "-funsigned-char"):
self.compile_and_run(source, char_mode)
loop = ui[ui.index("void UITask::loop()") : ui.index(
"char UITask::checkDisplayOn", ui.index("void UITask::loop()")
@@ -124,9 +199,10 @@ class HeltecV4WiFiSetupPageTest(unittest.TestCase):
self.assertIn("current boot session", wifi)
main = MAIN.read_text(encoding="utf-8")
requests_start = main.index("void requestCompanionWiFiSetup()")
requests_end = main.index("bool toggleCompanionWiFi()", requests_start)
requests = main[requests_start:requests_end]
requests = "\n".join(extract_braced(main, signature) for signature in (
" void requestCompanionWiFiSetup()", " void requestCompanionWiFiSetupStop()"))
# Inspect the exact session request methods, not the unrelated permanent
# master switch that is now defined between them and toggleCompanionWiFi.
self.assertIn("companion_wifi_setup_requested = true;", requests)
self.assertIn("companion_wifi_setup_stop_requested = true;", requests)
self.assertNotIn("savePrefs", requests)
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Execute the production NVS writer/resolver, CLI setters and MQTT consumer."""
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class FixtureFailure(AssertionError):
pass
class MQTTCanonicalWiFiTest(unittest.TestCase):
def fixture(self):
web = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
mqtt = (ROOT / "src/helpers/bridges/MQTTBridge.cpp").read_text()
fixture = (ROOT / "test/fixtures/mqtt_canonical_wifi.cpp").read_text()
web_methods = "\n".join(extract_braced(web, signature) for signature in (
"mesh::wifi::CredentialState WebConfigServer::resolveWiFi(",
"bool WebConfigServer::hasConfiguredWiFi(",
"bool WebConfigServer::loadStandaloneWiFi(",
"bool WebConfigServer::saveStandaloneWiFi(",
"static bool loadWiFiEdit(",
"bool WebConfigServer::setStandaloneWiFiSSID(",
"bool WebConfigServer::setStandaloneWiFiPassword(",
"bool WebConfigServer::setStandaloneWiFiPowerSave(",
"bool WebConfigServer::formatWiFiPassword(",
"bool WebConfigServer::reloadStandaloneWiFi(",
))
batch_start = web.index(' if (_standalone_wifi && strcmp(e.key, "wifi.ssid")')
batch_end = web.index(" _cb->execCommand(e.cmd, e.reply);", batch_start)
credential_batch = web[batch_start:batch_end]
commit_batch = extract_braced(web, " if (_standalone_wifi_dirty)")
observer = (ROOT / "src/helpers/CommonCLI_Observer.cpp").read_text()
cli_set = extract_braced(observer[observer.index("bool CommonCLI::handleObserverSetCmd"):],
" if (_callbacks->usesCanonicalWiFi())")
cli_get = extract_braced(observer[observer.index("bool CommonCLI::handleObserverGetCmd"):],
" if (_callbacks->usesCanonicalWiFi())")
mqtt_methods = "\n".join(extract_braced(mqtt, signature) for signature in (
"bool MQTTBridge::prepareWiFiCredentials(",
"void MQTTBridge::beginWiFiStation(",
"static bool isWiFiConfigValid(",
"static bool customEndpointComplete(",
"bool MQTTBridge::isConfigValid(",
"bool MQTTBridge::isReady(",
))
begin = mqtt.index(" // Check if WiFi credentials are configured first")
begin_end = mqtt.index(" // These are begin()/end()-scoped", begin)
# These are the exact production early gate and the reconnect call;
# expensive task/TLS allocation below the gate is a hardware endpoint.
gate = mqtt[begin:begin_end]
reconnect_start = mqtt.index(" WiFi.disconnect();", mqtt.index("bool MQTTBridge::handleWiFiConnection"))
reconnect_start = mqtt.index("#ifdef ESP_PLATFORM", reconnect_start)
reconnect_end = mqtt.index("#endif", reconnect_start) + len("#endif")
reconnect = mqtt[reconnect_start:reconnect_end]
return (fixture.replace("@WEB_METHODS@", web_methods)
.replace("@MQTT_METHODS@", mqtt_methods)
.replace("@BEGIN_GATE@", gate).replace("@RECONNECT@", reconnect)
.replace("@CLI_SET@", cli_set).replace("@CLI_GET@", cli_get)
.replace("@CREDENTIAL_BATCH@", credential_batch)
.replace("@COMMIT_BATCH@", commit_batch))
def compile(self, source, *flags):
compiler = os.environ.get("CXX") or shutil.which("g++")
self.assertIsNotNone(compiler)
with tempfile.TemporaryDirectory(prefix="mqtt-canonical-wifi-") as directory:
path = Path(directory)
(path / "Preferences.h").write_text(
(ROOT / "test/fixtures/wifi_credentials_preferences.h").read_text(), encoding="ascii")
(path / "test.cpp").write_text(source, encoding="ascii")
result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
"-DESP_PLATFORM=1", "-DWITH_MQTT_BRIDGE=1", *flags,
"-I", str(path), "-I", str(ROOT / "src"), str(path / "test.cpp"),
"-o", str(path / "test")], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(path / "test")], capture_output=True,
text=True, timeout=15)
if result.returncode:
raise FixtureFailure(result.stdout + result.stderr)
def test_portal_cli_upgrade_reconnect_boundaries_and_write_failures(self):
self.compile(self.fixture())
def test_companion_compiled_password_fallback_is_preserved(self):
self.compile(self.fixture(), '-DWIFI_PWD="compiled-password"')
def test_pre_fix_mqtt_gate_rejects_fresh_portal_credentials(self):
fixture = self.fixture()
stale = fixture.replace("const bool wifi_configured = prepareWiFiCredentials();",
"const bool wifi_configured = isWiFiConfigValid(_obs, false);")
self.assertNotEqual(stale, fixture)
with self.assertRaises(FixtureFailure):
self.compile(stale)
def test_reconnect_cannot_revert_to_legacy_credentials(self):
fixture = self.fixture()
stale = fixture.replace("mesh::wifi::beginStation(_wifi_ssid, _wifi_password);",
"mesh::wifi::beginStation(_obs->wifi_ssid, _obs->wifi_password);")
self.assertNotEqual(stale, fixture)
with self.assertRaises(FixtureFailure):
self.compile(stale)
if __name__ == "__main__":
unittest.main()