Speed up wifi reconnect

This commit is contained in:
jpirnay
2026-05-24 16:27:19 +02:00
parent 7459b5572c
commit a9c7894120
5 changed files with 311 additions and 23 deletions
+65
View File
@@ -407,6 +407,29 @@ bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path
JsonObject obj = arr.add<JsonObject>();
obj["ssid"] = cred.ssid;
obj["password_obf"] = obfuscation::obfuscateToBase64(cred.password);
bool hasHint = cred.channel != 0;
for (int i = 0; i < 6 && !hasHint; i++) {
if (cred.bssid[i] != 0) hasHint = true;
}
if (hasHint) {
char bssidHex[13];
snprintf(bssidHex, sizeof(bssidHex), "%02x%02x%02x%02x%02x%02x", cred.bssid[0], cred.bssid[1], cred.bssid[2],
cred.bssid[3], cred.bssid[4], cred.bssid[5]);
obj["bssid"] = bssidHex;
obj["channel"] = cred.channel;
}
if (cred.ip[0] != 0) {
char buf[16];
snprintf(buf, sizeof(buf), "%d.%d.%d.%d", cred.ip[0], cred.ip[1], cred.ip[2], cred.ip[3]);
obj["ip"] = buf;
snprintf(buf, sizeof(buf), "%d.%d.%d.%d", cred.gateway[0], cred.gateway[1], cred.gateway[2], cred.gateway[3]);
obj["gw"] = buf;
snprintf(buf, sizeof(buf), "%d.%d.%d.%d", cred.mask[0], cred.mask[1], cred.mask[2], cred.mask[3]);
obj["mask"] = buf;
snprintf(buf, sizeof(buf), "%d.%d.%d.%d", cred.dns[0], cred.dns[1], cred.dns[2], cred.dns[3]);
obj["dns"] = buf;
obj["ts"] = cred.cacheTimestamp;
}
}
String json;
@@ -464,6 +487,48 @@ bool JsonSettingsIO::loadWifi(WifiCredentialStore& store, const char* json, bool
cred.password = obj["password"] | std::string("");
if (!cred.password.empty() && needsResave) *needsResave = true;
}
const std::string bssidHex = obj["bssid"] | std::string("");
const int channel = obj["channel"] | 0;
if (bssidHex.size() == 12 && channel > 0 && channel <= 14) {
bool parseOk = true;
uint8_t parsed[6] = {0};
for (int i = 0; i < 6 && parseOk; i++) {
unsigned int byte = 0;
if (sscanf(bssidHex.c_str() + i * 2, "%2x", &byte) != 1) {
parseOk = false;
} else {
parsed[i] = static_cast<uint8_t>(byte);
}
}
if (parseOk) {
std::memcpy(cred.bssid, parsed, 6);
cred.channel = static_cast<uint8_t>(channel);
}
}
const auto parseQuad = [](const std::string& s, uint8_t out[4]) -> bool {
unsigned int a = 0, b = 0, c = 0, d = 0;
if (sscanf(s.c_str(), "%u.%u.%u.%u", &a, &b, &c, &d) != 4) return false;
if (a > 255 || b > 255 || c > 255 || d > 255) return false;
out[0] = a;
out[1] = b;
out[2] = c;
out[3] = d;
return true;
};
const std::string ipStr = obj["ip"] | std::string("");
const std::string gwStr = obj["gw"] | std::string("");
const std::string maskStr = obj["mask"] | std::string("");
const std::string dnsStr = obj["dns"] | std::string("");
if (!ipStr.empty()) {
uint8_t ip[4], gw[4], mask[4], dns[4];
if (parseQuad(ipStr, ip) && parseQuad(gwStr, gw) && parseQuad(maskStr, mask) && parseQuad(dnsStr, dns)) {
std::memcpy(cred.ip, ip, 4);
std::memcpy(cred.gateway, gw, 4);
std::memcpy(cred.mask, mask, 4);
std::memcpy(cred.dns, dns, 4);
cred.cacheTimestamp = obj["ts"] | 0u;
}
}
store.credentials.push_back(cred);
}
+50
View File
@@ -5,6 +5,8 @@
#include <Logging.h>
#include <ObfuscationUtils.h>
#include <cstring>
// Initialize the static instance
WifiCredentialStore WifiCredentialStore::instance;
@@ -82,6 +84,54 @@ const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssi
bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; }
bool WifiCredentialStore::updateConnectionCache(const std::string& ssid, const uint8_t bssid[6], uint8_t channel,
const uint8_t ip[4], const uint8_t gateway[4], const uint8_t mask[4],
const uint8_t dns[4], uint32_t cacheTimestamp) {
const auto cred =
find_if(credentials.begin(), credentials.end(), [&ssid](const WifiCredential& c) { return c.ssid == ssid; });
if (cred == credentials.end()) {
return false;
}
const bool sameHint = (channel == cred->channel) && std::memcmp(bssid, cred->bssid, 6) == 0;
const bool sameIp = std::memcmp(ip, cred->ip, 4) == 0 && std::memcmp(gateway, cred->gateway, 4) == 0 &&
std::memcmp(mask, cred->mask, 4) == 0 && std::memcmp(dns, cred->dns, 4) == 0;
// Timestamp updates are not worth a write on their own; only persist when topology changed.
if (sameHint && sameIp) {
return true;
}
std::memcpy(cred->bssid, bssid, 6);
cred->channel = channel;
std::memcpy(cred->ip, ip, 4);
std::memcpy(cred->gateway, gateway, 4);
std::memcpy(cred->mask, mask, 4);
std::memcpy(cred->dns, dns, 4);
cred->cacheTimestamp = cacheTimestamp;
return saveToFile();
}
bool WifiCredentialStore::clearConnectionCache(const std::string& ssid) {
const auto cred =
find_if(credentials.begin(), credentials.end(), [&ssid](const WifiCredential& c) { return c.ssid == ssid; });
if (cred == credentials.end()) {
return false;
}
bool empty = (cred->channel == 0) && (cred->ip[0] == 0);
for (int i = 0; i < 6 && empty; i++) {
if (cred->bssid[i] != 0) empty = false;
}
if (empty) {
return true;
}
std::memset(cred->bssid, 0, 6);
cred->channel = 0;
std::memset(cred->ip, 0, 4);
std::memset(cred->gateway, 0, 4);
std::memset(cred->mask, 0, 4);
std::memset(cred->dns, 0, 4);
cred->cacheTimestamp = 0;
return saveToFile();
}
void WifiCredentialStore::setLastConnectedSsid(const std::string& ssid) {
if (lastConnectedSsid != ssid) {
lastConnectedSsid = ssid;
+24
View File
@@ -1,10 +1,24 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
struct WifiCredential {
std::string ssid;
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
// Connection hint cached on successful connect. All-zero BSSID or channel==0 means
// "no hint, do a full scan". Used to skip channel scanning on reconnect.
uint8_t bssid[6] = {0, 0, 0, 0, 0, 0};
uint8_t channel = 0;
// Cached IP configuration to skip DHCP on reconnect. Valid only when ip[0] != 0 AND
// we connect to the same BSSID (cached above). cacheTimestamp is epoch seconds at
// capture; 0 means "unknown time, no TTL enforced".
uint8_t ip[4] = {0, 0, 0, 0};
uint8_t gateway[4] = {0, 0, 0, 0};
uint8_t mask[4] = {0, 0, 0, 0};
uint8_t dns[4] = {0, 0, 0, 0};
uint32_t cacheTimestamp = 0;
};
class WifiCredentialStore;
@@ -51,6 +65,16 @@ class WifiCredentialStore {
bool removeCredential(const std::string& ssid);
const WifiCredential* findCredential(const std::string& ssid) const;
// Update cached BSSID/channel hint AND IP configuration in one write. ip/gw/mask/dns
// may be all-zero to mean "no IP cache". cacheTimestamp is epoch seconds (0 if unsynced).
// Persists only if anything changed.
bool updateConnectionCache(const std::string& ssid, const uint8_t bssid[6], uint8_t channel, const uint8_t ip[4],
const uint8_t gateway[4], const uint8_t mask[4], const uint8_t dns[4],
uint32_t cacheTimestamp);
// Clear all cached hint + IP for this credential (after a hint-based connect failed
// and we want the next attempt to start fresh with full scan + DHCP).
bool clearConnectionCache(const std::string& ssid);
// Get all stored credentials (for UI display)
const std::vector<WifiCredential>& getCredentials() const { return credentials; }
+149 -23
View File
@@ -2,12 +2,15 @@
#include <GfxRenderer.h>
#include <HTTPClient.h>
#include <HalClock.h>
#include <I18n.h>
#include <Logging.h>
#include <NetworkClient.h>
#include <WiFi.h>
#include <esp_mac.h>
#include <cstring>
#include <ctime>
#include <map>
#include "MappedInputManager.h"
@@ -46,6 +49,20 @@ String formatMacCompact(const uint8_t mac[6]) {
void WifiSelectionActivity::onEnter() {
Activity::onEnter();
// Timing instrumentation: split total connect time into association vs DHCP.
// STA_CONNECTED = association (auth + 4-way handshake done).
// STA_GOT_IP = DHCP done.
evtIdConnected = WiFi.onEvent(
[this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) {
LOG_DBG("WIFI", "EVT associated at %lu ms", millis() - connectionStartTime);
},
ARDUINO_EVENT_WIFI_STA_CONNECTED);
evtIdGotIp = WiFi.onEvent(
[this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) {
LOG_DBG("WIFI", "EVT got_ip at %lu ms", millis() - connectionStartTime);
},
ARDUINO_EVENT_WIFI_STA_GOT_IP);
// Load saved WiFi credentials - SD card operations need lock as we use SPI
// for both
{
@@ -109,6 +126,15 @@ void WifiSelectionActivity::onEnter() {
void WifiSelectionActivity::onExit() {
Activity::onExit();
if (evtIdConnected != 0) {
WiFi.removeEvent(evtIdConnected);
evtIdConnected = 0;
}
if (evtIdGotIp != 0) {
WiFi.removeEvent(evtIdGotIp);
evtIdGotIp = 0;
}
LOG_DBG("WIFI", "Free heap at onExit start: %d bytes", ESP.getFreeHeap());
// Stop any ongoing WiFi scan
@@ -195,23 +221,11 @@ void WifiSelectionActivity::tryNextAutoCycleCandidate() {
connectionStartTime = millis();
connectedIP.clear();
connectionError.clear();
hintFallbackDone = false;
requestUpdate();
WiFi.persistent(false);
WiFi.mode(WIFI_STA);
WiFi.disconnect(true, true);
delay(100);
uint8_t baseMac[6];
readDeviceBaseMac(baseMac);
String hostname = "CrossPoint-Reader-" + formatMacCompact(baseMac);
WiFi.setHostname(hostname.c_str());
if (selectedRequiresPassword && !enteredPassword.empty()) {
WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str());
} else {
WiFi.begin(selectedSSID.c_str());
}
prepareForConnect();
issueWifiBegin(/*useHint=*/true);
}
void WifiSelectionActivity::processWifiScanResults() {
@@ -334,23 +348,96 @@ void WifiSelectionActivity::attemptConnection() {
connectionStartTime = millis();
connectedIP.clear();
connectionError.clear();
hintFallbackDone = false;
requestUpdate();
prepareForConnect();
issueWifiBegin(/*useHint=*/true);
}
void WifiSelectionActivity::prepareForConnect() {
WiFi.persistent(false); // Credentials are managed by WifiCredentialStore; suppress SDK NVS auto-connect
WiFi.mode(WIFI_STA);
WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID
delay(100);
// Only switch mode if we're not already STA — the mode setter touches the netif and
// can take 50+ ms even when "no change" semantically.
if (WiFi.getMode() != WIFI_STA) {
WiFi.mode(WIFI_STA);
}
// Only do the heavy disconnect(true,true) — which erases NVS and tears down the WPA
// state machine — when there's actually something to tear down. From a fresh/idle
// state it's a pure cost (~5080 ms on this SoC).
const wl_status_t status = WiFi.status();
const bool needsReset = (status == WL_CONNECTED) || (status == WL_CONNECT_FAILED) || (status == WL_CONNECTION_LOST) ||
(status == WL_NO_SSID_AVAIL);
if (needsReset) {
WiFi.disconnect(true, true);
}
// Use stable base MAC so hostname suffix is deterministic across WiFi states.
uint8_t baseMac[6];
readDeviceBaseMac(baseMac);
String hostname = "CrossPoint-Reader-" + formatMacCompact(baseMac);
WiFi.setHostname(hostname.c_str());
}
if (selectedRequiresPassword && !enteredPassword.empty()) {
WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str());
void WifiSelectionActivity::issueWifiBegin(bool useHint) {
std::memset(currentAttemptBssid, 0, 6);
currentAttemptChannel = 0;
bool appliedStaticIp = false;
if (useHint) {
const auto* cred = WIFI_STORE.findCredential(selectedSSID);
if (cred && cred->channel != 0) {
std::memcpy(currentAttemptBssid, cred->bssid, 6);
currentAttemptChannel = cred->channel;
// Apply cached static IP only when the IP cache is keyed to this same BSSID and
// (if we have a synced clock) hasn't aged out. cacheTimestamp==0 means it was
// written before clock-sync; we trust it indefinitely in that case.
if (cred->ip[0] != 0) {
constexpr int64_t TTL_SECONDS = 7 * 24 * 60 * 60;
const time_t now = HalClock::now();
// Use signed arithmetic so we don't underflow when ts is in the future (which
// happens when NTP corrects the clock backwards between cache write and read).
// Future timestamps are treated as fresh (negative elapsed clamped to 0).
const int64_t elapsed =
(now == 0) ? 0 : (static_cast<int64_t>(now) - static_cast<int64_t>(cred->cacheTimestamp));
const bool ttlOk = (cred->cacheTimestamp == 0) || (now == 0) || (elapsed < TTL_SECONDS);
if (ttlOk) {
IPAddress ip(cred->ip[0], cred->ip[1], cred->ip[2], cred->ip[3]);
IPAddress gw(cred->gateway[0], cred->gateway[1], cred->gateway[2], cred->gateway[3]);
IPAddress mask(cred->mask[0], cred->mask[1], cred->mask[2], cred->mask[3]);
IPAddress dns(cred->dns[0], cred->dns[1], cred->dns[2], cred->dns[3]);
WiFi.config(ip, gw, mask, dns);
appliedStaticIp = true;
} else {
LOG_DBG("WIFI", "IP cache aged out (ts=%u, now=%ld), using DHCP", cred->cacheTimestamp, (long)now);
}
}
}
}
if (!appliedStaticIp) {
// Reset to DHCP in case a previous attempt left a static config behind.
WiFi.config(IPAddress(), IPAddress(), IPAddress(), IPAddress());
}
const char* pwd = (selectedRequiresPassword && !enteredPassword.empty()) ? enteredPassword.c_str() : nullptr;
const unsigned long preBeginMs = millis() - connectionStartTime;
if (currentAttemptChannel != 0) {
LOG_DBG("WIFI", "WiFi.begin -> %s ch=%d bssid=%02x:%02x:%02x:%02x:%02x:%02x staticIp=%s (pre-begin %lu ms)",
selectedSSID.c_str(), currentAttemptChannel, currentAttemptBssid[0], currentAttemptBssid[1],
currentAttemptBssid[2], currentAttemptBssid[3], currentAttemptBssid[4], currentAttemptBssid[5],
appliedStaticIp ? "yes" : "no", preBeginMs);
WiFi.begin(selectedSSID.c_str(), pwd, currentAttemptChannel, currentAttemptBssid, true);
} else {
WiFi.begin(selectedSSID.c_str());
LOG_DBG("WIFI", "WiFi.begin -> %s (no hint, pre-begin %lu ms)", selectedSSID.c_str(), preBeginMs);
if (pwd) {
WiFi.begin(selectedSSID.c_str(), pwd);
} else {
WiFi.begin(selectedSSID.c_str());
}
}
}
@@ -399,11 +486,31 @@ void WifiSelectionActivity::checkConnectionStatus() {
connectedIP = ipStr;
autoConnecting = false;
// Save this as the last connected network - SD card operations need lock as
// we use SPI for both
LOG_DBG("WIFI", "Connected to %s in %lu ms (rssi=%d ch=%d ip=%s, hint=%s)", selectedSSID.c_str(),
millis() - connectionStartTime, WiFi.RSSI(), WiFi.channel(), ipStr,
currentAttemptChannel != 0 ? "yes" : "no");
// Save this as the last connected network and cache the full connection profile
// (BSSID/channel + IP/gw/mask/dns) so the next reconnect can skip both channel
// scanning and DHCP. SD card operations need lock as we use SPI for both.
{
RenderLock lock(*this);
WIFI_STORE.setLastConnectedSsid(selectedSSID);
const uint8_t* actualBssid = WiFi.BSSID();
const int actualChannel = WiFi.channel();
if (actualBssid && actualChannel > 0 && actualChannel <= 255) {
const IPAddress gw = WiFi.gatewayIP();
const IPAddress mask = WiFi.subnetMask();
const IPAddress dns = WiFi.dnsIP();
const uint8_t ipBytes[4] = {ip[0], ip[1], ip[2], ip[3]};
const uint8_t gwBytes[4] = {gw[0], gw[1], gw[2], gw[3]};
const uint8_t maskBytes[4] = {mask[0], mask[1], mask[2], mask[3]};
const uint8_t dnsBytes[4] = {dns[0], dns[1], dns[2], dns[3]};
const time_t nowEpoch = HalClock::now();
WIFI_STORE.updateConnectionCache(selectedSSID, actualBssid, static_cast<uint8_t>(actualChannel), ipBytes,
gwBytes, maskBytes, dnsBytes,
nowEpoch > 0 ? static_cast<uint32_t>(nowEpoch) : 0u);
}
}
// Check for captive portal before declaring success
@@ -429,6 +536,25 @@ void WifiSelectionActivity::checkConnectionStatus() {
return;
}
// If this attempt used a hint and the hint hasn't paid off (channel may have changed
// because the AP is part of a mesh / roamed), retry once with a full scan before
// surfacing failure or moving to the next candidate. Triggered on either a hard
// failure status or a short timeout — whichever comes first.
const bool usingHint = currentAttemptChannel != 0;
const bool hintHardFail =
usingHint && !hintFallbackDone && (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL);
const bool hintTimedOut =
usingHint && !hintFallbackDone && (millis() - connectionStartTime > HINT_ATTEMPT_TIMEOUT_MS);
if (hintHardFail || hintTimedOut) {
LOG_DBG("WIFI", "Hint attempt did not connect (%s after %lu ms), retrying with full scan",
hintHardFail ? "hard fail" : "timeout", millis() - connectionStartTime);
hintFallbackDone = true;
WiFi.disconnect(true, false);
connectionStartTime = millis();
issueWifiBegin(/*useHint=*/false);
return;
}
const unsigned long timeout =
state == WifiSelectionState::AUTO_CYCLING ? AUTO_CYCLE_TIMEOUT_MS : CONNECTION_TIMEOUT_MS;
@@ -86,8 +86,23 @@ class WifiSelectionActivity final : public Activity {
// Connection timeouts
static constexpr unsigned long CONNECTION_TIMEOUT_MS = 15000;
static constexpr unsigned long AUTO_CYCLE_TIMEOUT_MS = 5000;
// Faster timeout for the first hint-based attempt before falling back to full scan.
// Hint-based connect on the correct channel usually completes in <2 s; if it doesn't,
// the AP has likely moved (mesh roam, channel change) so it's cheaper to bail and rescan.
static constexpr unsigned long HINT_ATTEMPT_TIMEOUT_MS = 3000;
unsigned long connectionStartTime = 0;
// BSSID/channel hint used on the current attempt (channel==0 means no hint).
uint8_t currentAttemptBssid[6] = {0};
uint8_t currentAttemptChannel = 0;
// Whether we've already done the silent fallback retry without the hint for this
// user-initiated connection. Prevents loops if the AP genuinely isn't reachable.
bool hintFallbackDone = false;
// WiFi event handler IDs so we can deregister on exit.
uint16_t evtIdConnected = 0;
uint16_t evtIdGotIp = 0;
void renderNetworkList() const;
void renderPasswordEntry() const;
void renderConnecting() const;
@@ -104,6 +119,14 @@ class WifiSelectionActivity final : public Activity {
void selectNetwork(int index);
void attemptConnection();
void checkConnectionStatus();
// Issues WiFi.begin() either with the cached BSSID/channel hint (fast path) or without
// (full scan fallback). `useHint=false` clears currentAttemptChannel so the success path
// doesn't double-store the same hint.
void issueWifiBegin(bool useHint);
// Prepares the WiFi stack for a connect attempt: ensures STA mode and a clean state,
// sets a deterministic hostname. Skips the expensive disconnect(true,true) when WiFi
// is already idle so the warm reconnect path doesn't pay an NVS-erase cost.
void prepareForConnect();
bool checkCaptivePortal();
std::string getSignalStrengthIndicator(int32_t rssi) const;