Fall back to BLE Wi-Fi provisioning on sustained post-boot link loss

Once StaClient::connect() had already succeeded once, a later link
loss (router rebooted, password changed, device moved) just retried
esp_wifi_connect() forever with no bound and no fallback -- the only
recovery path was a power cycle, since BLE provisioning only ever
started from startSetupMode() (no stored credentials, or the initial
boot-time connect attempt exhausting its own bounded retry count).

connect() now optionally takes the secure store and device identity;
when set, a link lost for longer than ~1 minute starts BLE
provisioning (net::ble_provisioning::start(), same GATT flow as first-
time setup) alongside the still-ongoing STA reconnect attempts -- it
doesn't stop trying Wi-Fi on its own, it just also gives the app a way
in over Bluetooth if Wi-Fi doesn't come back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 01:07:36 +02:00
co-authored by Claude Sonnet 5
parent 36cd85f4d0
commit 5f46486d46
4 changed files with 111 additions and 8 deletions
@@ -17,6 +17,8 @@
*/
#pragma once
#include "core/DeviceIdentity.hpp"
#include "core/ISecureStore.hpp"
#include "core/WifiCredentials.hpp"
#include "net/NetError.hpp"
@@ -94,8 +96,20 @@ public:
* @brief connect — join the network described by creds.
*
* @dname connect
* @param creds Validated domain credentials from ISecureStore.
* @param hostname STA hostname / mDNS label (no .local suffix).
* @param creds Validated domain credentials from
* ISecureStore.
* @param hostname STA hostname / mDNS label (no .local
* suffix).
* @param bleFallbackStore When set alongside bleFallbackIdentity,
* a link lost for over a minute *after*
* this call already returned successfully
* starts BLE provisioning (additive,
* STA reconnect attempts keep running) so
* the app can reconfigure Wi-Fi without a
* power cycle. Null skips this behaviour.
* @param bleFallbackIdentity Device identity for the BLE fallback's
* advertising name; must outlive this
* StaClient when bleFallbackStore is set.
* @return Ok on success, or NetError::StaConnectTimeout /
* NetError::StaConnectFailed.
* @pubstate writes connected_ on success; uses creds via Secret.
@@ -105,7 +119,9 @@ public:
*/
[[nodiscard]] std::expected<void, NetError>
connect(const core::WifiCredentials& creds,
std::string_view hostname = {});
std::string_view hostname = {},
core::ISecureStore* bleFallbackStore = nullptr,
const core::DeviceIdentity* bleFallbackIdentity = nullptr);
private:
bool connected_;
+2 -1
View File
@@ -189,7 +189,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
StaClient sta;
if (auto staResult =
sta.connect(creds, deviceIdentity.hostname());
sta.connect(creds, deviceIdentity.hostname(), &store,
&deviceIdentity);
!staResult) {
ESP_LOGW(kTag, "STA connect failed for SSID %.*s — credentials kept in NVS",
static_cast<int>(creds.ssid().value().size()),
+48 -1
View File
@@ -18,6 +18,8 @@
#include "net/StaClient.hpp"
#include "net/BleProvisioning.hpp"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
@@ -39,10 +41,20 @@ constexpr int kFailedBit = BIT1;
constexpr int kMaxConnectRetries = 10;
constexpr TickType_t kConnectTimeout = pdMS_TO_TICKS(45000);
constexpr TickType_t kRetryDelay = pdMS_TO_TICKS(800);
/** How long a post-boot link loss must persist before BLE fallback starts. */
constexpr TickType_t kBleFallbackThreshold = pdMS_TO_TICKS(60000);
EventGroupHandle_t s_wifiEventGroup = nullptr;
int s_connectRetries = 0;
// Set once by connect() on success when the caller opted in; used only by
// the post-boot branch of wifiEventHandler() below (s_wifiEventGroup is
// null there, since the bounded connect() call has already returned).
core::ISecureStore* s_bleFallbackStore = nullptr;
const core::DeviceIdentity* s_bleFallbackIdentity = nullptr;
TickType_t s_disconnectedSinceTick = 0;
bool s_bleFallbackActive = false;
/**
* @brief disconnectReasonString — map ESP-IDF Wi-Fi disconnect reason codes.
*
@@ -136,6 +148,32 @@ void wifiEventHandler(void* arg,
return;
}
// Post-boot link loss (the bounded connect() call above already
// succeeded once): esp_wifi_connect() below retries forever on its
// own, which used to be the whole story -- if the AP never comes
// back (moved, replaced, password changed), the device retried
// silently forever with no way for the app to reach it and no way
// for the user to reconfigure Wi-Fi short of a power cycle. After
// a sustained ~1 minute outage, start BLE provisioning alongside
// the ongoing reconnect attempts (does not stop them) so the app
// can push new credentials over Bluetooth.
if (s_disconnectedSinceTick == 0) {
s_disconnectedSinceTick = xTaskGetTickCount();
} else if (!s_bleFallbackActive && s_bleFallbackStore != nullptr
&& s_bleFallbackIdentity != nullptr
&& (xTaskGetTickCount() - s_disconnectedSinceTick)
>= kBleFallbackThreshold) {
ESP_LOGW(kTag, "STA link lost for over a minute — starting BLE "
"provisioning fallback");
if (auto bleResult = ble_provisioning::start(
*s_bleFallbackStore, *s_bleFallbackIdentity);
!bleResult) {
ESP_LOGW(kTag, "BLE fallback provisioning failed to start");
} else {
s_bleFallbackActive = true;
}
}
ESP_LOGW(kTag, "STA link lost — reconnecting");
esp_wifi_connect();
} else if (eventBase == IP_EVENT && eventId == IP_EVENT_STA_GOT_IP) {
@@ -145,6 +183,7 @@ void wifiEventHandler(void* arg,
ESP_LOGI(kTag, "STA IP " IPSTR, IP2STR(&event->ip_info.ip));
}
applyStaLinkTuning();
s_disconnectedSinceTick = 0;
if (s_wifiEventGroup != nullptr) {
xEventGroupSetBits(s_wifiEventGroup, kConnectedBit);
}
@@ -212,12 +251,20 @@ StaClient& StaClient::operator=(StaClient&& other) noexcept
}
std::expected<void, NetError>
StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname)
StaClient::connect(const core::WifiCredentials& creds,
std::string_view hostname,
core::ISecureStore* bleFallbackStore,
const core::DeviceIdentity* bleFallbackIdentity)
{
if (connected_) {
return {};
}
s_bleFallbackStore = bleFallbackStore;
s_bleFallbackIdentity = bleFallbackIdentity;
s_disconnectedSinceTick = 0;
s_bleFallbackActive = false;
std::string hostLabel;
if (!hostname.empty()) {
hostLabel.assign(hostname.begin(), hostname.end());