From 1e421ff4fc6a3b56d43db7a18aab94a72129b527 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 6 Oct 2026 19:28:10 -0700 Subject: [PATCH] Fix MQTT startup after ESP32 WebConfig provisioning --- .github/workflows/run-unit-tests.yml | 2 + examples/simple_repeater/MyMesh.cpp | 23 +- examples/simple_repeater/MyMesh.h | 16 +- examples/simple_room_server/MyMesh.cpp | 23 +- examples/simple_room_server/MyMesh.h | 16 +- src/helpers/CommonCLI.h | 1 + src/helpers/CommonCLI_Observer.cpp | 25 +- src/helpers/bridges/MQTTBridge.cpp | 65 +++- src/helpers/bridges/MQTTBridge.h | 13 +- src/helpers/esp32/WebConfigServer.cpp | 264 +++++++--------- src/helpers/esp32/WebConfigServer.h | 23 +- src/helpers/esp32/WiFiCredentials.h | 134 ++++++++ test/fixtures/mqtt_canonical_wifi.cpp | 316 +++++++++++++++++++ test/fixtures/webconfig_station_recovery.cpp | 13 +- test/fixtures/wifi_credentials_preferences.h | 63 ++++ test/test_esp32_full_power_lifecycle.py | 3 +- test/test_heltec_v4_wifi_setup_page.py | 114 +++++-- test/test_mqtt_canonical_wifi.py | 112 +++++++ 18 files changed, 1018 insertions(+), 208 deletions(-) create mode 100644 src/helpers/esp32/WiFiCredentials.h create mode 100644 test/fixtures/mqtt_canonical_wifi.cpp create mode 100644 test/fixtures/wifi_credentials_preferences.h create mode 100644 test/test_mqtt_canonical_wifi.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a3a31986e..9c79d2944 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -480,6 +480,8 @@ jobs: 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_mqtt_canonical_wifi.py -v + python3 -B test/test_heltec_v4_wifi_setup_page.py -v python3 -B test/test_sensor_webconfig_capabilities.py -v python3 -B test/test_webconfig_ui_runtime.py diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index d8bdd8b4d..7f7c41407 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3851,6 +3851,9 @@ void MyMesh::begin(FILESYSTEM *fs) { node_info.cr = &_prefs.cr; node_info.repeat_flag = &_prefs.disable_fwd; node_info.repeat_when_nonzero = false; +#ifdef WITH_WEBCONFIG + node_info.canonical_wifi = true; +#endif mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), getRTCClock(), &self_id); #endif @@ -3939,8 +3942,8 @@ void MyMesh::begin(FILESYSTEM *fs) { // Preserve the MQTT observer's first-boot setup experience even though the // persistent WebUI master switch defaults off on infrastructure roles. start_webui = start_webui || (_prefs.bridge_enabled - && _cli.getObserverPrefs()->wifi_ssid[0] == 0); - if (start_webui && _cli.getObserverPrefs()->wifi_ssid[0] == 0) { + && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())); + if (start_webui && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())) { #if defined(WITH_ESPNOW_BRIDGE) if (espnow_bridge.isRunning()) espnow_bridge.end(); #endif @@ -10931,7 +10934,7 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) { void MyMesh::suspendUnconfiguredSetupBridges() { #if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) #ifdef WITH_MQTT_BRIDGE - if (_cli.getObserverPrefs()->wifi_ssid[0]) return; + if (WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())) return; #else char ssid[33] = {}; WebConfigServer::loadStandaloneWiFi(ssid, sizeof(ssid), nullptr, 0); @@ -10974,7 +10977,7 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup // out. Require an explicit stop before a portal that would take its radio. if ((isMqttBridgeRunning() || isMqttBridgeStopping()) && (force_ap #if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) - || _cli.getObserverPrefs()->wifi_ssid[0] == 0 + || !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs()) #endif )) { strcpy(reply, isMqttBridgeStopping() ? "Err: MQTT bridge is stopping - retry shortly" @@ -11088,15 +11091,15 @@ bool MyMesh::getWebUIStatus(char* reply) const { } bool MyMesh::getWiFiSSID(char* reply) const { - return WebConfigServer::formatWiFiSSID(reply, 160); + return WebConfigServer::formatWiFiSSID(reply, 160, canonicalWiFiLegacyPrefs()); } bool MyMesh::getWiFiStatus(char* reply) const { - return WebConfigServer::formatWiFiStatus(reply, 160); + return WebConfigServer::formatWiFiStatus(reply, 160, nullptr, canonicalWiFiLegacyPrefs()); } bool MyMesh::getWiFiPowerSave(char* reply) const { - return WebConfigServer::formatWiFiPowerSave(reply, 160); + return WebConfigServer::formatWiFiPowerSave(reply, 160, canonicalWiFiLegacyPrefs()); } bool MyMesh::getWiFiCLI(char* reply) const { @@ -11104,7 +11107,7 @@ bool MyMesh::getWiFiCLI(char* reply) const { } bool MyMesh::setWiFiSSID(const char* value, char* reply) { - if (WebConfigServer::setStandaloneWiFiSSID(value, reply, 160)) { + if (WebConfigServer::setStandaloneWiFiSSID(value, reply, 160, canonicalWiFiLegacyPrefs())) { const bool was_running = _webconfig && _webconfig->isRunning(); if (was_running) _webconfig->requestStop(); if (_webconfig) _webconfig->reloadStandaloneWiFi(); @@ -11116,7 +11119,7 @@ bool MyMesh::setWiFiSSID(const char* value, char* reply) { } bool MyMesh::setWiFiPassword(const char* value, char* reply) { - if (WebConfigServer::setStandaloneWiFiPassword(value, reply, 160)) { + if (WebConfigServer::setStandaloneWiFiPassword(value, reply, 160, canonicalWiFiLegacyPrefs())) { const bool was_running = _webconfig && _webconfig->isRunning(); if (was_running) _webconfig->requestStop(); if (_webconfig) _webconfig->reloadStandaloneWiFi(); @@ -11128,7 +11131,7 @@ bool MyMesh::setWiFiPassword(const char* value, char* reply) { } bool MyMesh::setWiFiPowerSave(const char* value, char* reply) { - if (WebConfigServer::setStandaloneWiFiPowerSave(value, reply, 160) + if (WebConfigServer::setStandaloneWiFiPowerSave(value, reply, 160, canonicalWiFiLegacyPrefs()) && _webconfig) { _webconfig->reloadStandaloneWiFi(); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 255368f21..456188ef1 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -1377,6 +1377,9 @@ public: node_info.cr = &_prefs.cr; node_info.repeat_flag = &_prefs.disable_fwd; node_info.repeat_when_nonzero = false; +#ifdef WITH_WEBCONFIG + node_info.canonical_wifi = true; +#endif mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), getRTCClock(), &self_id); if (!mqtt_bridge) return false; @@ -1486,6 +1489,9 @@ public: node_info.cr = &_prefs.cr; node_info.repeat_flag = &_prefs.disable_fwd; node_info.repeat_when_nonzero = false; +#ifdef WITH_WEBCONFIG + node_info.canonical_wifi = true; +#endif mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), getRTCClock(), &self_id); if (!mqtt_bridge) return false; @@ -1743,9 +1749,17 @@ public: } bool setWebUIEnabled(bool enabled, char* reply) override; bool getWebUIStatus(char* reply) const override; + bool usesCanonicalWiFi() const override { return true; } + const void* canonicalWiFiLegacyPrefs() const { +#ifdef WITH_MQTT_BRIDGE + return _cli.getObserverPrefs(); +#else + return nullptr; +#endif + } bool getWiFiSSID(char* reply) const override; bool getWiFiPassword(char* reply) const override { - return WebConfigServer::formatWiFiPassword(reply, 160); + return WebConfigServer::formatWiFiPassword(reply, 160, canonicalWiFiLegacyPrefs()); } bool getWiFiStatus(char* reply) const override; bool getWiFiPowerSave(char* reply) const override; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index b1c63d120..901fd1bb1 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1453,6 +1453,9 @@ void MyMesh::begin(FILESYSTEM *fs) { node_info.cr = &_prefs.cr; node_info.repeat_flag = &_prefs.disable_fwd; node_info.repeat_when_nonzero = false; +#ifdef WITH_WEBCONFIG + node_info.canonical_wifi = true; +#endif bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), getRTCClock(), &self_id); if (bridge) { @@ -1501,8 +1504,8 @@ void MyMesh::begin(FILESYSTEM *fs) { bool start_webui = WebConfigServer::loadEnabled(false); #ifdef WITH_MQTT_BRIDGE start_webui = start_webui || (_prefs.bridge_enabled - && _cli.getObserverPrefs()->wifi_ssid[0] == 0); - if (start_webui && _cli.getObserverPrefs()->wifi_ssid[0] == 0) { + && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())); + if (start_webui && !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())) { #if defined(WITH_ESPNOW_BRIDGE) if (espnow_bridge.isRunning()) espnow_bridge.end(); #endif @@ -1956,7 +1959,7 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) { void MyMesh::suspendUnconfiguredSetupBridges() { #if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) #ifdef WITH_MQTT_BRIDGE - if (_cli.getObserverPrefs()->wifi_ssid[0]) return; + if (WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs())) return; #else char ssid[33] = {}; WebConfigServer::loadStandaloneWiFi(ssid, sizeof(ssid), nullptr, 0); @@ -1993,7 +1996,7 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup #ifdef WITH_MQTT_BRIDGE if ((isMqttBridgeRunning() || isMqttBridgeStopping()) && (force_ap #if defined(MESHCORE_EXPANDED_PARTITION_PROFILE) - || _cli.getObserverPrefs()->wifi_ssid[0] == 0 + || !WebConfigServer::hasConfiguredWiFi(_cli.getObserverPrefs()) #endif )) { strcpy(reply, isMqttBridgeStopping() ? "Err: MQTT bridge is stopping - retry shortly" @@ -2094,15 +2097,15 @@ bool MyMesh::getWebUIStatus(char* reply) const { } bool MyMesh::getWiFiSSID(char* reply) const { - return WebConfigServer::formatWiFiSSID(reply, 160); + return WebConfigServer::formatWiFiSSID(reply, 160, canonicalWiFiLegacyPrefs()); } bool MyMesh::getWiFiStatus(char* reply) const { - return WebConfigServer::formatWiFiStatus(reply, 160); + return WebConfigServer::formatWiFiStatus(reply, 160, nullptr, canonicalWiFiLegacyPrefs()); } bool MyMesh::getWiFiPowerSave(char* reply) const { - return WebConfigServer::formatWiFiPowerSave(reply, 160); + return WebConfigServer::formatWiFiPowerSave(reply, 160, canonicalWiFiLegacyPrefs()); } bool MyMesh::getWiFiCLI(char* reply) const { @@ -2110,7 +2113,7 @@ bool MyMesh::getWiFiCLI(char* reply) const { } bool MyMesh::setWiFiSSID(const char* value, char* reply) { - if (WebConfigServer::setStandaloneWiFiSSID(value, reply, 160)) { + if (WebConfigServer::setStandaloneWiFiSSID(value, reply, 160, canonicalWiFiLegacyPrefs())) { const bool was_running = _webconfig && _webconfig->isRunning(); if (was_running) _webconfig->requestStop(); if (_webconfig) _webconfig->reloadStandaloneWiFi(); @@ -2122,7 +2125,7 @@ bool MyMesh::setWiFiSSID(const char* value, char* reply) { } bool MyMesh::setWiFiPassword(const char* value, char* reply) { - if (WebConfigServer::setStandaloneWiFiPassword(value, reply, 160)) { + if (WebConfigServer::setStandaloneWiFiPassword(value, reply, 160, canonicalWiFiLegacyPrefs())) { const bool was_running = _webconfig && _webconfig->isRunning(); if (was_running) _webconfig->requestStop(); if (_webconfig) _webconfig->reloadStandaloneWiFi(); @@ -2134,7 +2137,7 @@ bool MyMesh::setWiFiPassword(const char* value, char* reply) { } bool MyMesh::setWiFiPowerSave(const char* value, char* reply) { - if (WebConfigServer::setStandaloneWiFiPowerSave(value, reply, 160) + if (WebConfigServer::setStandaloneWiFiPowerSave(value, reply, 160, canonicalWiFiLegacyPrefs()) && _webconfig) { _webconfig->reloadStandaloneWiFi(); } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index b3966c40c..032770f70 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -635,6 +635,9 @@ public: node_info.cr = &_prefs.cr; node_info.repeat_flag = &_prefs.disable_fwd; node_info.repeat_when_nonzero = false; +#ifdef WITH_WEBCONFIG + node_info.canonical_wifi = true; +#endif bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), getRTCClock(), &self_id); if (!bridge) return false; @@ -741,6 +744,9 @@ public: node_info.cr = &_prefs.cr; node_info.repeat_flag = &_prefs.disable_fwd; node_info.repeat_when_nonzero = false; +#ifdef WITH_WEBCONFIG + node_info.canonical_wifi = true; +#endif bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), getRTCClock(), &self_id); #endif @@ -899,9 +905,17 @@ public: } bool setWebUIEnabled(bool enabled, char* reply) override; bool getWebUIStatus(char* reply) const override; + bool usesCanonicalWiFi() const override { return true; } + const void* canonicalWiFiLegacyPrefs() const { +#ifdef WITH_MQTT_BRIDGE + return _cli.getObserverPrefs(); +#else + return nullptr; +#endif + } bool getWiFiSSID(char* reply) const override; bool getWiFiPassword(char* reply) const override { - return WebConfigServer::formatWiFiPassword(reply, 160); + return WebConfigServer::formatWiFiPassword(reply, 160, canonicalWiFiLegacyPrefs()); } bool getWiFiStatus(char* reply) const override; bool getWiFiPowerSave(char* reply) const override; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 7d12e47b1..feedaa649 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -655,6 +655,7 @@ public: (void)reply; return false; }; + virtual bool usesCanonicalWiFi() const { return false; } virtual bool getWiFiSSID(char* reply) const { (void)reply; return false; diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 72464e5da..fba7d11b2 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -183,6 +183,14 @@ static void formatMQTTPresetListReply(char* reply, size_t reply_size, int start) bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* config, char* reply) { #ifdef WITH_MQTT_BRIDGE + if (_callbacks->usesCanonicalWiFi()) { + const char* value = nullptr; + const auto key = mesh::cli::classifyStandaloneWiFiSet(config, &value); + if (key == mesh::cli::StandaloneWiFiKey::SSID) { _callbacks->setWiFiSSID(value, reply); return true; } + if (key == mesh::cli::StandaloneWiFiKey::Password) { _callbacks->setWiFiPassword(value, reply); return true; } + if (key == mesh::cli::StandaloneWiFiKey::PowerSave) { _callbacks->setWiFiPowerSave(value, reply); return true; } + } + bool handled = true; const auto restart_observer_bridge = [this]() { return mesh::cli::restartBridgeIfEnabled( @@ -872,6 +880,21 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* config, char* reply) { #ifdef WITH_MQTT_BRIDGE + if (_callbacks->usesCanonicalWiFi()) { + if (strcmp(config, "wifi.pwd") == 0) { + _callbacks->getWiFiPassword(reply); + if (sender_timestamp) { + const bool has_password = strlen(reply) > 2; + strcpy(reply, has_password ? "> ******** (local connection only)" : "> (not set)"); + } + return true; + } + const auto key = mesh::cli::classifyStandaloneWiFiGet(config); + if (key == mesh::cli::StandaloneWiFiKey::SSID) { _callbacks->getWiFiSSID(reply); return true; } + if (key == mesh::cli::StandaloneWiFiKey::Status) { _callbacks->getWiFiStatus(reply); return true; } + if (key == mesh::cli::StandaloneWiFiKey::PowerSave) { _callbacks->getWiFiPowerSave(reply); return true; } + } + bool handled = true; if (strcmp(config, "mqtt.enabled") == 0) { snprintf(reply, 160, "> %s", _prefs->bridge_enabled ? "on" : "off"); @@ -1127,7 +1150,7 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf strcpy(reply, "> (not set)"); } } else if (memcmp(config, "mqtt.config.valid", 17) == 0) { - bool valid = MQTTBridge::isConfigValid(&_mqtt_prefs); + bool valid = MQTTBridge::isConfigValid(&_mqtt_prefs, _callbacks->usesCanonicalWiFi()); sprintf(reply, "> %s", valid ? "valid" : "invalid"); #endif } else if (memcmp(config, "alert.hashtag", 13) == 0) { diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index d01167539..74707076c 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -1,5 +1,8 @@ #define MQTT_PRESETS_IMPLEMENTATION #include "MQTTBridge.h" +#ifdef ESP_PLATFORM +#include +#endif #include "../MQTTConnectionPolicy.h" #include "../MQTTMessageBuilder.h" #include "../MQTTPacketQueuePolicy.h" @@ -149,8 +152,47 @@ const char* MQTTBridge::repeatStatus() const { return enabled ? "on" : "off"; } +#ifdef ESP_PLATFORM +void MQTTBridge::beginWiFiStation() { + mesh::wifi::beginStation(_wifi_ssid, _wifi_password); +} + +bool MQTTBridge::prepareWiFiCredentials() { + mesh::wifi::Credentials credentials; + if (!_obs) return _wifi_configured = false; + const auto state = _manage_wifi && _node_info.canonical_wifi + ? mesh::wifi::resolveCredentials(credentials, _obs->wifi_ssid, + _obs->wifi_password, _obs->wifi_power_save) + : mesh::wifi::CredentialState::Absent; + if (!_manage_wifi || !_node_info.canonical_wifi) { + // The Companion owns its live station; do not read infrastructure NVS. + if (strnlen(_obs->wifi_ssid, sizeof(_obs->wifi_ssid)) >= sizeof(credentials.ssid) + || strnlen(_obs->wifi_password, sizeof(_obs->wifi_password)) >= sizeof(_obs->wifi_password)) + return _wifi_configured = false; + strcpy(credentials.ssid, _obs->wifi_ssid); + strcpy(credentials.password, _obs->wifi_password); + credentials.power_save = _obs->wifi_power_save; + } else if (state != mesh::wifi::CredentialState::Ready) { + return _wifi_configured = false; + } + memcpy(_wifi_ssid, credentials.ssid, sizeof(_wifi_ssid)); + memcpy(_wifi_password, credentials.password, sizeof(_wifi_password)); + _wifi_power_save = credentials.power_save; + return _wifi_configured = _wifi_ssid[0] != 0; +} +#endif + // Helper function to check if WiFi credentials are valid -static bool isWiFiConfigValid(const MQTTPrefs* obs) { +static bool isWiFiConfigValid(const MQTTPrefs* obs, bool canonical_wifi = false) { +#ifdef ESP_PLATFORM + if (canonical_wifi && obs) { + mesh::wifi::Credentials credentials; + return mesh::wifi::resolveCredentials(credentials, obs->wifi_ssid, obs->wifi_password, + obs->wifi_power_save) == mesh::wifi::CredentialState::Ready; + } +#else + (void)canonical_wifi; +#endif // Check if WiFi SSID is configured (not empty) if (!obs || strlen(obs->wifi_ssid) == 0) { return false; @@ -170,8 +212,8 @@ static bool customEndpointComplete(const char* host, uint16_t port) { return host[0] != '\0' && (port != 0 || strstr(host, "://") != nullptr); } -bool MQTTBridge::isConfigValid(const MQTTPrefs* obs) { - if (!obs || !isWiFiConfigValid(obs)) return false; +bool MQTTBridge::isConfigValid(const MQTTPrefs* obs, bool canonical_wifi) { + if (!obs || !isWiFiConfigValid(obs, canonical_wifi)) return false; for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { const char* preset_name = obs->mqtt_slot_preset[i]; if (preset_name[0] == '\0' || strcmp(preset_name, MQTT_PRESET_NONE) == 0) continue; @@ -969,7 +1011,12 @@ void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots); // Check if WiFi credentials are configured first - if (!isWiFiConfigValid(_obs)) { +#ifdef ESP_PLATFORM + const bool wifi_configured = prepareWiFiCredentials(); +#else + const bool wifi_configured = isWiFiConfigValid(_obs); +#endif + if (!wifi_configured) { MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured"); return; } @@ -1421,7 +1468,7 @@ void MQTTBridge::initializeWiFiInTask() { // because _ntp_synced persists across end() (only _slots_setup_done is reset). if (WiFi.status() != WL_CONNECTED) { if (_manage_wifi) { - mesh::wifi::beginStation(_obs->wifi_ssid, _obs->wifi_password); + beginWiFiStation(); } } else if (!_ntp_synced.load(std::memory_order_acquire) && !_ntp_sync_pending) { @@ -3064,7 +3111,7 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { #ifdef ESP_PLATFORM wifi_ps_type_t ps_mode; uint8_t ps_pref = mesh::wifi::effectivePowerSave( - _obs->wifi_power_save, + _manage_wifi && _node_info.canonical_wifi ? _wifi_power_save : _obs->wifi_power_save, #if defined(COMPANION_EXCLUSIVE_WIFI_BLE) false, #elif defined(BLE_PIN_CODE) && defined(WIFI_SSID) @@ -3122,7 +3169,7 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { MQTTConnectionPolicy::nextWifiBackoffAttempt(_wifi_reconnect_backoff_attempt); WiFi.disconnect(); #ifdef ESP_PLATFORM - mesh::wifi::beginStation(_obs->wifi_ssid, _obs->wifi_password); + beginWiFiStation(); #else WiFi.begin(_obs->wifi_ssid, _obs->wifi_password); #endif @@ -3135,7 +3182,11 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { bool MQTTBridge::isReady() const { return _initialized && !_stop_requested.load(std::memory_order_acquire) +#ifdef ESP_PLATFORM + && _wifi_configured; +#else && isWiFiConfigValid(_obs); +#endif } bool MQTTBridge::isIATAValid() const { diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index ab7c43ade..d79866e16 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -56,6 +56,8 @@ struct MQTTNodeInfo { const uint8_t* cr = nullptr; const uint8_t* repeat_flag = nullptr; bool repeat_when_nonzero = true; + // ESP32 infrastructure only; Companion and Pico retain their existing owner. + bool canonical_wifi = false; }; // Periodic neighbors publication keys off the mesh neighbor cache (sized by @@ -578,6 +580,15 @@ private: MQTTPrefs* _obs = nullptr; MQTTNodeInfo _node_info; bool _manage_wifi; +#ifdef ESP_PLATFORM + // Frozen before Core 0 starts; changed only after its stop barrier. + char _wifi_ssid[32] = {}; + char _wifi_password[65] = {}; + uint8_t _wifi_power_save = 0; + bool _wifi_configured = false; + bool prepareWiFiCredentials(); + void beginWiFiStation(); +#endif const char* repeatStatus() const; @@ -779,7 +790,7 @@ public: }; static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out); /** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */ - static bool isConfigValid(const MQTTPrefs* obs); + static bool isConfigValid(const MQTTPrefs* obs, bool canonical_wifi = false); static void formatSlotDiagReply(char* buf, size_t bufsize, int slot_index); static uint8_t getLastWifiDisconnectReason(); static unsigned long getLastWifiDisconnectTime(); diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index be0b56a15..a7a219c99 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -354,20 +354,9 @@ WebConfigServer::WebConfigServer(Callbacks* callbacks, void* mqtt_prefs, bool ow // Companion and standalone FULL builds keep their canonical connection // credentials in mesh-wifi NVS. Do not round-trip a 64-hex raw PSK through // the fixed-layout MQTTPrefs wifi_password[64] field. - const bool loaded_standalone = loadStandaloneWiFi( - _wifi_ssid, sizeof(_wifi_ssid), - _wifi_password, sizeof(_wifi_password), &_wifi_power_save); -#ifdef WITH_MQTT_BRIDGE - // Preserve the upgrade path from older WiFi-MQTT Companion installs that - // have not written the canonical namespace yet. Once mesh-wifi exists it - // always wins, including when it contains a 64-hex PSK. - if (!loaded_standalone && obs) { - 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 - ? obs->wifi_power_save : mesh::wifi::kDefaultPowerSave; - } -#endif + loadStandaloneWiFi(_wifi_ssid, sizeof(_wifi_ssid), + _wifi_password, sizeof(_wifi_password), &_wifi_power_save, + _mqtt_prefs); } _wifi_power_save = effectiveWiFiPowerSave(_wifi_power_save); } @@ -414,144 +403,128 @@ bool WebConfigServer::saveCliEnabled(bool enabled) { return written == sizeof(uint8_t); } +mesh::wifi::CredentialState WebConfigServer::resolveWiFi( + mesh::wifi::Credentials& out, const void* legacy_prefs) { +#ifdef WITH_MQTT_BRIDGE + const MQTTPrefs* obs = static_cast(legacy_prefs); + return mesh::wifi::resolveCredentials(out, obs ? obs->wifi_ssid : nullptr, + obs ? obs->wifi_password : nullptr, + obs ? obs->wifi_power_save : mesh::wifi::kDefaultPowerSave); +#else + (void)legacy_prefs; + return mesh::wifi::resolveCredentials(out); +#endif +} + +bool WebConfigServer::hasConfiguredWiFi(const void* legacy_prefs) { + mesh::wifi::Credentials credentials; + return resolveWiFi(credentials, legacy_prefs) == mesh::wifi::CredentialState::Ready; +} + bool WebConfigServer::loadStandaloneWiFi(char* ssid, size_t ssid_len, char* password, size_t password_len, - uint8_t* power_save) { - if (!ssid || ssid_len == 0 || !password || password_len == 0) return false; + uint8_t* power_save, const void* legacy_prefs) { + if (!ssid || !ssid_len || (password && !password_len)) return false; ssid[0] = 0; - password[0] = 0; - Preferences nvs; - if (!nvs.begin("mesh-wifi", false)) return false; - // Preferences::getString() logs an error for a missing key even when the - // caller supplied a default. Missing credentials are expected on first boot. - String stored_ssid = nvs.isKey("ssid") - ? nvs.getString("ssid", "") : String(); - String stored_password = nvs.isKey("password") - ? nvs.getString("password", "") : String(); - uint8_t stored_ps = nvs.isKey("powersave") - ? nvs.getUChar("powersave", mesh::wifi::kDefaultPowerSave) - : mesh::wifi::kDefaultPowerSave; - nvs.end(); - if (stored_ssid.length() >= ssid_len - || stored_password.length() >= password_len - || !mesh::cli::standaloneWiFiPasswordValid(stored_password.c_str())) { - return false; - } - strncpy(ssid, stored_ssid.c_str(), ssid_len - 1); - ssid[ssid_len - 1] = 0; - strncpy(password, stored_password.c_str(), password_len - 1); - password[password_len - 1] = 0; - if (power_save) *power_save = effectiveWiFiPowerSave(stored_ps); - return stored_ssid.length() != 0; + if (password) password[0] = 0; + mesh::wifi::Credentials credentials; + const auto state = resolveWiFi(credentials, legacy_prefs); + if (state == mesh::wifi::CredentialState::Invalid + || state == mesh::wifi::CredentialState::Unavailable + || strlen(credentials.ssid) >= ssid_len + || (password && strlen(credentials.password) >= password_len)) return false; + memcpy(ssid, credentials.ssid, strlen(credentials.ssid) + 1); + if (password) memcpy(password, credentials.password, strlen(credentials.password) + 1); + if (power_save) *power_save = effectiveWiFiPowerSave(credentials.power_save); + return state == mesh::wifi::CredentialState::Ready; } bool WebConfigServer::saveStandaloneWiFi(const char* ssid, const char* password, uint8_t power_save) { - power_save = effectiveWiFiPowerSave(power_save); - const char* pwd = password ? password : ""; - if (!ssid || !ssid[0] || strlen(ssid) >= 32 - || !mesh::cli::standaloneWiFiPasswordValid(pwd) - || power_save > mesh::wifi::kPowerSaveMax) { + if (!mesh::cli::standaloneWiFiSSIDValid(ssid) + || !mesh::cli::standaloneWiFiPasswordValid(password ? password : "")) return false; + mesh::wifi::Credentials credentials; + strcpy(credentials.ssid, ssid); + strcpy(credentials.password, password ? password : ""); + credentials.power_save = effectiveWiFiPowerSave(power_save); + return mesh::wifi::writeCredentials(credentials); +} + +static bool loadWiFiEdit(mesh::wifi::Credentials& credentials, + const void* legacy_prefs, char* reply, size_t reply_len) { + const auto state = WebConfigServer::resolveWiFi(credentials, legacy_prefs); + if (state == mesh::wifi::CredentialState::Invalid + || state == mesh::wifi::CredentialState::Unavailable) { + snprintf(reply, reply_len, "Error: WiFi settings unreadable; use WebConfig to save a complete pair"); return false; } - Preferences nvs; - if (!nvs.begin("mesh-wifi", false)) return false; - bool ok = nvs.putString("ssid", ssid) == strlen(ssid); - nvs.putString("password", pwd); // empty String legitimately writes zero bytes - ok = ok && nvs.getString("password", "\x01") == pwd; - ok = ok && nvs.putUChar("powersave", power_save) == sizeof(uint8_t); - nvs.end(); - return ok; + return true; } bool WebConfigServer::setStandaloneWiFiSSID(const char* value, char* reply, - size_t reply_len) { - if (!reply || reply_len == 0) return false; - if (!mesh::cli::standaloneWiFiSSIDValid(value)) { + size_t reply_len, const void* legacy_prefs) { + if (!reply || !reply_len) return false; + // MQTT infrastructure historically allows clearing its SSID from the CLI. + // Persist the complete empty canonical pair; never fall back to the old store. + const bool clearing = legacy_prefs && value && !value[0]; + if (!clearing && !mesh::cli::standaloneWiFiSSIDValid(value)) { snprintf(reply, reply_len, "Error: WiFi SSID must be 1-31 characters"); return false; } - - Preferences nvs; - if (!nvs.begin("mesh-wifi", false)) { - snprintf(reply, reply_len, "Error: failed to open WiFi settings"); - return false; - } - const bool ok = nvs.putString("ssid", value) == strlen(value); - nvs.end(); - snprintf(reply, reply_len, ok ? "OK - WiFi SSID saved" + mesh::wifi::Credentials credentials; + if (!loadWiFiEdit(credentials, legacy_prefs, reply, reply_len)) return false; + strcpy(credentials.ssid, value); + const bool ok = mesh::wifi::writeCredentials(credentials); + snprintf(reply, reply_len, ok ? "OK - WiFi SSID saved; reboot to apply" : "Error: failed to save WiFi SSID"); return ok; } bool WebConfigServer::setStandaloneWiFiPassword(const char* value, char* reply, - size_t reply_len) { - if (!reply || reply_len == 0) return false; + size_t reply_len, const void* legacy_prefs) { + if (!reply || !reply_len) return false; if (!mesh::cli::standaloneWiFiPasswordValid(value)) { - snprintf(reply, reply_len, - "Error: WiFi password must be 0-63 characters or 64 hex characters"); + snprintf(reply, reply_len, "Error: WiFi password must be 0-63 characters or 64 hex characters"); return false; } - - Preferences nvs; - if (!nvs.begin("mesh-wifi", false)) { - snprintf(reply, reply_len, "Error: failed to open WiFi settings"); - return false; - } - nvs.putString("password", value); - const bool ok = nvs.getString("password", "\x01") == value; - nvs.end(); - snprintf(reply, reply_len, ok ? "OK - WiFi password saved" + mesh::wifi::Credentials credentials; + if (!loadWiFiEdit(credentials, legacy_prefs, reply, reply_len)) return false; + strcpy(credentials.password, value); + const bool ok = mesh::wifi::writeCredentials(credentials); + snprintf(reply, reply_len, ok ? "OK - WiFi password saved; reboot to apply" : "Error: failed to save WiFi password"); return ok; } bool WebConfigServer::setStandaloneWiFiPowerSave(const char* value, char* reply, - size_t reply_len) { - if (!reply || reply_len == 0) return false; + size_t reply_len, const void* legacy_prefs) { + if (!reply || !reply_len) return false; uint8_t power_save = mesh::wifi::kDefaultPowerSave; if (!mesh::cli::parseStandaloneWiFiPowerSave(value, power_save)) { snprintf(reply, reply_len, "Error: power save must be none, min, or max"); return false; } - if (mesh::wifi::kPrimaryEspNowRadio - && power_save == mesh::wifi::kPowerSaveMax) { - snprintf(reply, reply_len, - "Error: power save max is unavailable while ESP-NOW is the primary radio"); + if (mesh::wifi::kPrimaryEspNowRadio && power_save == mesh::wifi::kPowerSaveMax) { + snprintf(reply, reply_len, "Error: power save max is unavailable while ESP-NOW is the primary radio"); return false; } if (effectiveWiFiPowerSave(power_save) != power_save) { - snprintf(reply, reply_len, - "Error: power save none is unavailable while Bluetooth is active"); + snprintf(reply, reply_len, "Error: power save none is unavailable while Bluetooth is active"); return false; } - Preferences nvs; - if (!nvs.begin("mesh-wifi", false)) { - snprintf(reply, reply_len, "Error: failed to open WiFi settings"); - return false; - } - const bool ok = - nvs.putUChar("powersave", power_save) == sizeof(uint8_t); - nvs.end(); - if (!ok) { + mesh::wifi::Credentials credentials; + if (!loadWiFiEdit(credentials, legacy_prefs, reply, reply_len)) return false; + credentials.power_save = power_save; + if (!mesh::wifi::writeCredentials(credentials)) { snprintf(reply, reply_len, "Error: failed to save WiFi power save"); return false; } - - esp_err_t apply_result = ESP_OK; if (WiFi.getMode() != WIFI_OFF) { - const wifi_ps_type_t ps_mode = - power_save == mesh::wifi::kPowerSaveNone ? WIFI_PS_NONE - : power_save == mesh::wifi::kPowerSaveMax ? WIFI_PS_MAX_MODEM - : WIFI_PS_MIN_MODEM; - apply_result = esp_wifi_set_ps(ps_mode); - } - if (apply_result == ESP_OK) { - snprintf(reply, reply_len, "OK - WiFi power save set to %s", value); - } else { - snprintf(reply, reply_len, - "OK - saved; WiFi power save applies on next connection"); + const wifi_ps_type_t mode = power_save == mesh::wifi::kPowerSaveNone ? WIFI_PS_NONE + : power_save == mesh::wifi::kPowerSaveMax ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM; + esp_wifi_set_ps(mode); } + snprintf(reply, reply_len, "OK - WiFi power save saved; reboot to apply"); return true; } @@ -578,34 +551,37 @@ bool WebConfigServer::setWiFiCliEnabled(const char* value, char* reply, bool WebConfigServer::reloadStandaloneWiFi() { return loadStandaloneWiFi( _wifi_ssid, sizeof(_wifi_ssid), - _wifi_password, sizeof(_wifi_password), &_wifi_power_save); + _wifi_password, sizeof(_wifi_password), &_wifi_power_save, _mqtt_prefs); } -bool WebConfigServer::formatWiFiSSID(char* reply, size_t reply_len) { +bool WebConfigServer::formatWiFiSSID(char* reply, size_t reply_len, const void* legacy_prefs) { if (!reply || reply_len == 0) return false; char ssid[32] = ""; char password[65] = ""; uint8_t power_save = mesh::wifi::kDefaultPowerSave; bool configured = loadStandaloneWiFi( - ssid, sizeof(ssid), password, sizeof(password), &power_save); - if (_active && _active->_wifi_ssid[0]) { - strncpy(ssid, _active->_wifi_ssid, sizeof(ssid) - 1); - ssid[sizeof(ssid) - 1] = 0; - configured = true; + ssid, sizeof(ssid), password, sizeof(password), &power_save, legacy_prefs); + if (!legacy_prefs && _active && _active->_wifi_ssid[0]) { + mesh::wifi::Credentials canonical; + if (mesh::wifi::readCredentials(canonical) == mesh::wifi::CredentialState::Absent) { + strlcpy(ssid, _active->_wifi_ssid, sizeof(ssid)); + configured = true; + } } snprintf(reply, reply_len, configured ? "> %s" : "> (not configured)", ssid); return true; } -bool WebConfigServer::formatWiFiPassword(char* reply, size_t reply_len) { +bool WebConfigServer::formatWiFiPassword(char* reply, size_t reply_len, const void* legacy_prefs) { if (!reply || !reply_len) return false; - char ssid[33] = {}, password[65] = {}; - if (!loadStandaloneWiFi(ssid, sizeof(ssid), password, sizeof(password))) { - if (_active) { - strlcpy(password, _active->_wifi_password, sizeof(password)); - } + char password[65] = {}; + mesh::wifi::Credentials credentials; + const auto state = resolveWiFi(credentials, legacy_prefs); + memcpy(password, credentials.password, sizeof(password)); + if (!legacy_prefs && state == mesh::wifi::CredentialState::Absent) { + if (_active) strlcpy(password, _active->_wifi_password, sizeof(password)); #ifdef WIFI_PWD else strlcpy(password, WIFI_PWD, sizeof(password)); #endif @@ -615,25 +591,13 @@ bool WebConfigServer::formatWiFiPassword(char* reply, size_t reply_len) { return true; } -bool WebConfigServer::formatWiFiPowerSave(char* reply, size_t reply_len) { - if (!reply || reply_len == 0) return false; - - uint8_t power_save = mesh::wifi::kDefaultPowerSave; - if (_active) { - power_save = _active->_wifi_power_save; - } else { - char ssid[32] = ""; - char password[65] = ""; - loadStandaloneWiFi( - ssid, sizeof(ssid), password, sizeof(password), &power_save); - } - - const char* name = "none"; - if (power_save == mesh::wifi::kPowerSaveMin) { - name = "min"; - } else if (power_save == mesh::wifi::kPowerSaveMax) { - name = "max"; - } +bool WebConfigServer::formatWiFiPowerSave(char* reply, size_t reply_len, const void* legacy_prefs) { + if (!reply || !reply_len) return false; + mesh::wifi::Credentials credentials; + resolveWiFi(credentials, legacy_prefs); + const uint8_t power_save = effectiveWiFiPowerSave(credentials.power_save); + const char* name = power_save == mesh::wifi::kPowerSaveMin ? "min" + : power_save == mesh::wifi::kPowerSaveMax ? "max" : "none"; snprintf(reply, reply_len, "> %s", name); return true; } @@ -657,19 +621,22 @@ bool WebConfigServer::formatWiFiCliStatus(char* reply, size_t reply_len) { bool WebConfigServer::formatWiFiStatus( char* reply, size_t reply_len, - const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime) { + const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime, + const void* legacy_prefs) { if (!reply || reply_len == 0) return false; char ssid[32] = ""; char password[65] = ""; uint8_t power_save = mesh::wifi::kDefaultPowerSave; bool configured = loadStandaloneWiFi( - ssid, sizeof(ssid), password, sizeof(password), &power_save); + ssid, sizeof(ssid), password, sizeof(password), &power_save, legacy_prefs); WebConfigServer* active = _active; - if (active && active->_wifi_ssid[0]) { - strncpy(ssid, active->_wifi_ssid, sizeof(ssid) - 1); - ssid[sizeof(ssid) - 1] = 0; - configured = true; + if (!legacy_prefs && active && active->_wifi_ssid[0]) { + mesh::wifi::Credentials canonical; + if (mesh::wifi::readCredentials(canonical) == mesh::wifi::CredentialState::Absent) { + strlcpy(ssid, active->_wifi_ssid, sizeof(ssid)); + configured = true; + } } if (active && active->_mode == MODE_SETUP) { @@ -1533,6 +1500,9 @@ void WebConfigServer::drainBatch(uint32_t now) { if (_standalone_wifi_dirty) { _standalone_wifi_dirty = false; if (!saveStandaloneWiFi(_wifi_ssid, _wifi_password, _wifi_power_save)) { + // Cached edits must not be used by a later portal station attempt after + // a failed save. Reload the committed pair, or clear a blocked partial one. + reloadStandaloneWiFi(); // Attribute the persistence failure to the last WiFi field so it is // visible beside a concrete input in the UI. for (int i = _batch_count - 1; i >= 0; i--) { diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 3ef52d75d..903918288 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -1,5 +1,6 @@ #pragma once + // Shared browser configuration portal for ESP32 infrastructure and WiFi // companion builds. The UI removes MQTT controls when no bridge is present. // @@ -24,6 +25,8 @@ #define WITH_WEBCONFIG 1 +#include + #include #include #include @@ -201,23 +204,27 @@ public: static bool saveCliEnabled(bool enabled); static bool loadStandaloneWiFi(char* ssid, size_t ssid_len, char* password, size_t password_len, - uint8_t* power_save = NULL); + uint8_t* power_save = NULL, const void* legacy_prefs = NULL); + static bool hasConfiguredWiFi(const void* legacy_prefs = NULL); + static mesh::wifi::CredentialState resolveWiFi(mesh::wifi::Credentials& out, + const void* legacy_prefs = NULL); static bool saveStandaloneWiFi(const char* ssid, const char* password, uint8_t power_save); static bool setStandaloneWiFiSSID(const char* value, char* reply, - size_t reply_len); + size_t reply_len, const void* legacy_prefs = NULL); static bool setStandaloneWiFiPassword(const char* value, char* reply, - size_t reply_len); + size_t reply_len, const void* legacy_prefs = NULL); static bool setStandaloneWiFiPowerSave(const char* value, char* reply, - size_t reply_len); + size_t reply_len, const void* legacy_prefs = NULL); static bool setWiFiCliEnabled(const char* value, char* reply, size_t reply_len); - static bool formatWiFiSSID(char* reply, size_t reply_len); - static bool formatWiFiPassword(char* reply, size_t reply_len); + static bool formatWiFiSSID(char* reply, size_t reply_len, const void* legacy_prefs = NULL); + static bool formatWiFiPassword(char* reply, size_t reply_len, const void* legacy_prefs = NULL); static bool formatWiFiStatus( char* reply, size_t reply_len, - const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime = NULL); - static bool formatWiFiPowerSave(char* reply, size_t reply_len); + const mesh::wifi::CompanionWiFiRuntimeState* companion_runtime = NULL, + const void* legacy_prefs = NULL); + static bool formatWiFiPowerSave(char* reply, size_t reply_len, const void* legacy_prefs = NULL); static bool formatWiFiCliStatus(char* reply, size_t reply_len); // UI tasks use an otherwise-unused multi-click gesture without reaching into diff --git a/src/helpers/esp32/WiFiCredentials.h b/src/helpers/esp32/WiFiCredentials.h new file mode 100644 index 000000000..2e295c41b --- /dev/null +++ b/src/helpers/esp32/WiFiCredentials.h @@ -0,0 +1,134 @@ +#pragma once + +#include +#include +#include + +namespace mesh { +namespace wifi { + +// Read/write/resolve are loop-task-only, like CLI and queued portal writes. +// MQTT Core 0 uses a separate snapshot; it must never call this NVS API. +// Runtime only: the fixed /mqtt_prefs password field must remain 64 bytes. +struct Credentials { + char ssid[32] = {}; + char password[65] = {}; + uint8_t power_save = kDefaultPowerSave; +}; + +enum class CredentialState { Absent, Unconfigured, Ready, Invalid, Unavailable }; + +inline CredentialState readCredentials(Credentials& out) { + out = Credentials{}; + Preferences nvs; + // Missing keys are normal on a fresh device; avoid Preferences NOT_FOUND logs. + if (!nvs.begin("mesh-wifi", false)) return CredentialState::Unavailable; + const bool ssid_present = nvs.isKey("ssid"); + const bool password_present = nvs.isKey("password"); + const bool ps_present = nvs.isKey("powersave"); + const bool pending_present = nvs.isKey("pending"); + bool valid = (!ssid_present || nvs.getType("ssid") == PT_STR) + && (!password_present || nvs.getType("password") == PT_STR) + && (!ps_present || nvs.getType("powersave") == PT_U8) + && (!pending_present || (nvs.getType("pending") == PT_U8 + && nvs.getUChar("pending", 1) == 0)); + const String ssid = ssid_present && valid ? nvs.getString("ssid", "") : String(); + const String password = password_present && valid ? nvs.getString("password", "") : String(); + out.power_save = ps_present && valid + ? nvs.getUChar("powersave", kDefaultPowerSave) : kDefaultPowerSave; + // Readers reject any write still in progress, including one that began + // after the initial marker read. Firmware writers/readers run on the loop; + // the MQTT worker only sees its frozen snapshot. + valid = valid && (!nvs.isKey("pending") + || (nvs.getType("pending") == PT_U8 && nvs.getUChar("pending", 1) == 0)); + nvs.end(); + valid = valid && ssid.length() < sizeof(out.ssid) + && password.length() < sizeof(out.password) + && cli::standaloneWiFiPasswordValid(password.c_str()) + && out.power_save <= kPowerSaveMax; + if (!valid) { out = Credentials{}; return CredentialState::Invalid; } + memcpy(out.ssid, ssid.c_str(), ssid.length() + 1); + memcpy(out.password, password.c_str(), password.length() + 1); + if (!ssid_present && !password_present) return CredentialState::Absent; + // Historical SSID-only saves describe an open network. Password-only/empty + // configurations never revive old credentials; interrupted writes are pending. + return ssid_present && out.ssid[0] + ? CredentialState::Ready : CredentialState::Unconfigured; +} + +inline CredentialState resolveCredentials(Credentials& out, + const char* legacy_ssid = nullptr, const char* legacy_password = nullptr, + uint8_t legacy_power_save = kDefaultPowerSave) { + const CredentialState state = readCredentials(out); + if (state != CredentialState::Absent) return state; + const uint8_t canonical_ps = out.power_save; + Preferences nvs; + if (!nvs.begin("mesh-wifi", false)) return CredentialState::Unavailable; + const bool canonical_power_save = nvs.isKey("powersave"); + nvs.end(); + if (!legacy_ssid || !legacy_password) return state; + const size_t ssid_len = strnlen(legacy_ssid, 32); + const size_t password_len = strnlen(legacy_password, 64); + if (ssid_len >= 32 || password_len >= 64 + || !cli::standaloneWiFiPasswordValid(legacy_password)) { + out = Credentials{}; + return CredentialState::Invalid; + } + memcpy(out.ssid, legacy_ssid, ssid_len + 1); + memcpy(out.password, legacy_password, password_len + 1); + out.power_save = canonical_power_save ? canonical_ps + : (legacy_power_save <= kPowerSaveMax ? legacy_power_save : kDefaultPowerSave); + return ssid_len ? CredentialState::Ready : CredentialState::Unconfigured; +} + +inline bool writeCredentials(const Credentials& candidate) { + if (strnlen(candidate.ssid, sizeof(candidate.ssid)) >= sizeof(candidate.ssid) + || strnlen(candidate.password, sizeof(candidate.password)) >= sizeof(candidate.password) + || !cli::standaloneWiFiPasswordValid(candidate.password) + || candidate.power_save > kPowerSaveMax) return false; + Credentials previous; + const CredentialState previous_state = readCredentials(previous); + if (previous_state == CredentialState::Unavailable) return false; + Preferences nvs; + if (!nvs.begin("mesh-wifi", false)) return false; + const bool had_ssid = nvs.isKey("ssid"), had_password = nvs.isKey("password"); + const bool had_ps = nvs.isKey("powersave"); + // Corrupt/held settings may be repaired by a complete explicit portal save. + const String old_ssid = had_ssid ? nvs.getString("ssid", "") : String(); + const String old_password = had_password ? nvs.getString("password", "") : String(); + const uint8_t old_ps = had_ps ? nvs.getUChar("powersave", kDefaultPowerSave) : kDefaultPowerSave; + // Fail closed if power is lost between independent NVS key writes. + if (nvs.putUChar("pending", 1) != 1) { nvs.end(); return false; } + nvs.putString("ssid", candidate.ssid); + nvs.putString("password", candidate.password); // empty string can return zero + nvs.putUChar("powersave", candidate.power_save); + bool ok = nvs.getType("ssid") == PT_STR && nvs.getType("password") == PT_STR + && nvs.getType("powersave") == PT_U8 + && nvs.getString("ssid", "\x01") == candidate.ssid + && nvs.getString("password", "\x01") == candidate.password + && nvs.getUChar("powersave", 255) == candidate.power_save; + if (ok) ok = nvs.putUChar("pending", 0) == 1 + && nvs.getUChar("pending", 1) == 0; + if (!ok) { + // A failed commit can have changed the visible marker. Restore the barrier + // before touching any rollback fields, or leave the complete candidate. + const bool blocked = nvs.putUChar("pending", 1) == 1 + && nvs.getUChar("pending", 0) == 1; + if (!blocked) { nvs.end(); return false; } + if (had_ssid) nvs.putString("ssid", old_ssid); else nvs.remove("ssid"); + if (had_password) nvs.putString("password", old_password); else nvs.remove("password"); + if (had_ps) nvs.putUChar("powersave", old_ps); else nvs.remove("powersave"); + const bool restored = nvs.isKey("ssid") == had_ssid + && nvs.isKey("password") == had_password && nvs.isKey("powersave") == had_ps + && (!had_ssid || nvs.getString("ssid", "\x01") == old_ssid) + && (!had_password || nvs.getString("password", "\x01") == old_password) + && (!had_ps || nvs.getUChar("powersave", 255) == old_ps); + if (restored && previous_state != CredentialState::Invalid) nvs.putUChar("pending", 0); + // Otherwise leave pending=1: no consumer may use a partially saved pair. + } + nvs.end(); + return ok; +} + +} // namespace wifi +} // namespace mesh diff --git a/test/fixtures/mqtt_canonical_wifi.cpp b/test/fixtures/mqtt_canonical_wifi.cpp new file mode 100644 index 000000000..30236283e --- /dev/null +++ b/test/fixtures/mqtt_canonical_wifi.cpp @@ -0,0 +1,316 @@ +#include +#include +#include +#include +#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(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 _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; + +} diff --git a/test/fixtures/webconfig_station_recovery.cpp b/test/fixtures/webconfig_station_recovery.cpp index 95e11b050..0840aab5f 100644 --- a/test/fixtures/webconfig_station_recovery.cpp +++ b/test/fixtures/webconfig_station_recovery.cpp @@ -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(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; } diff --git a/test/fixtures/wifi_credentials_preferences.h b/test/fixtures/wifi_credentials_preferences.h new file mode 100644 index 000000000..70161a11a --- /dev/null +++ b/test/fixtures/wifi_credentials_preferences.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include +#include + +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 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; +} diff --git a/test/test_esp32_full_power_lifecycle.py b/test/test_esp32_full_power_lifecycle.py index f9e5f6839..2c1f58f52 100644 --- a/test/test_esp32_full_power_lifecycle.py +++ b/test/test_esp32_full_power_lifecycle.py @@ -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 explicit MQTTBridge(Args...) {} @@ -161,6 +161,7 @@ struct WebConfigServer { if (prefs) strcpy(_wifi_ssid, static_cast(prefs)->wifi_ssid); } static bool loadEnabled(bool) { return enabled; } + static bool hasConfiguredWiFi(const void* prefs) { return static_cast(prefs)->wifi_ssid[0] != 0; } bool isRunning() const { return running; } bool isStopping() const { return stopping; } void updateWiFiOwnership(bool owner) { owns = owner; } diff --git a/test/test_heltec_v4_wifi_setup_page.py b/test/test_heltec_v4_wifi_setup_page.py index 414a31af9..41ed9253a 100644 --- a/test/test_heltec_v4_wifi_setup_page.py +++ b/test/test_heltec_v4_wifi_setup_page.py @@ -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 +#include +#include +#include +#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(KEY_NEXT))); + assert(home._page == HomeScreen::FIRST); + assert(!companion_wifi_setup_requested && !companion_wifi_setup_stop_requested); + assert(home.handleInput(static_cast(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(KEY_RIGHT))); + assert(home._page == HomeScreen::FIRST); + assert(!companion_wifi_setup_requested && companion_wifi_setup_stop_requested); + assert(home.handleInput(static_cast(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) diff --git a/test/test_mqtt_canonical_wifi.py b/test/test_mqtt_canonical_wifi.py new file mode 100644 index 000000000..ff54b5c92 --- /dev/null +++ b/test/test_mqtt_canonical_wifi.py @@ -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()