diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 4f655bc9..5cf2efa7 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -407,6 +407,29 @@ bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path JsonObject obj = arr.add(); 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(byte); + } + } + if (parseOk) { + std::memcpy(cred.bssid, parsed, 6); + cred.channel = static_cast(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); } diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index 77e244e8..d2e01239 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -5,6 +5,8 @@ #include #include +#include + // 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; diff --git a/src/WifiCredentialStore.h b/src/WifiCredentialStore.h index 0e935dc0..ee7db0bb 100644 --- a/src/WifiCredentialStore.h +++ b/src/WifiCredentialStore.h @@ -1,10 +1,24 @@ #pragma once +#include #include #include 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& getCredentials() const { return credentials; } diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 4ebe298b..75b9d354 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -2,12 +2,15 @@ #include #include +#include #include #include #include #include #include +#include +#include #include #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 (~50–80 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(now) - static_cast(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(actualChannel), ipBytes, + gwBytes, maskBytes, dnsBytes, + nowEpoch > 0 ? static_cast(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; diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index 62d77a68..6f700d2b 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -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;