Fix MQTT startup after ESP32 WebConfig provisioning
This commit is contained in:
1 parent
86d74c4a13
commit
d4b7217a4b
18 files changed
+1018
-208
No files matched your search
+316
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user