#include "WifiSelectionActivity.h" #include #include #include #include #include #include #include #include #include #include #include #include "MappedInputManager.h" #include "WifiCredentialStore.h" #include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/QrUtils.h" namespace { void readDeviceBaseMac(uint8_t mac[6]) { esp_efuse_mac_get_default(mac); } std::string formatMacLabel(const uint8_t mac[6]) { char macStr[64]; snprintf(macStr, sizeof(macStr), "%s %02x-%02x-%02x-%02x-%02x-%02x", tr(STR_MAC_ADDRESS), mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); return std::string(macStr); } std::string formatMacDashed(const uint8_t mac[6]) { char persistedMac[18]; snprintf(persistedMac, sizeof(persistedMac), "%02x-%02x-%02x-%02x-%02x-%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); return std::string(persistedMac); } String formatMacCompact(const uint8_t mac[6]) { char compactMac[13]; snprintf(compactMac, sizeof(compactMac), "%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); return String(compactMac); } } // namespace 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 { RenderLock lock(*this); WIFI_STORE.loadFromFile(); } // Use base MAC from eFuse (stable per-device, independent of WiFi init timing). uint8_t mac[6]; readDeviceBaseMac(mac); cachedMacAddress = formatMacLabel(mac); // Reset state selectedNetworkIndex = 0; networks.clear(); state = WifiSelectionState::SCANNING; selectedSSID.clear(); connectedIP.clear(); connectionError.clear(); enteredPassword.clear(); usedSavedPassword = false; savePromptSelection = 0; forgetPromptSelection = 0; autoConnecting = false; autoCycleCandidates.clear(); autoCycleCandidateIndex = 0; autoCycleAfterScan = false; const std::string persistedMac = formatMacDashed(mac); if (WIFI_STORE.getLastKnownMacAddress() != persistedMac) { RenderLock lock(*this); WIFI_STORE.setLastKnownMacAddress(persistedMac); } // Trigger first update to show scanning message requestUpdate(); // Attempt to auto-connect to the last network if (allowAutoConnect) { const std::string lastSsid = WIFI_STORE.getLastConnectedSsid(); if (!lastSsid.empty()) { const auto* cred = WIFI_STORE.findCredential(lastSsid); if (cred) { LOG_DBG("WIFI", "Attempting to auto-connect to %s", lastSsid.c_str()); selectedSSID = cred->ssid; enteredPassword = cred->password; selectedRequiresPassword = !cred->password.empty(); usedSavedPassword = true; autoConnecting = true; attemptConnection(); requestUpdate(); return; } } } // Fallback to scanning startWifiScan(); } 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 LOG_DBG("WIFI", "Deleting WiFi scan..."); WiFi.scanDelete(); LOG_DBG("WIFI", "Free heap after scanDelete: %d bytes", ESP.getFreeHeap()); // Note: We do NOT disconnect WiFi here - the parent activity // (CrossPointWebServerActivity) manages WiFi connection state. We just clean // up the scan and task. LOG_DBG("WIFI", "Free heap at onExit end: %d bytes", ESP.getFreeHeap()); } void WifiSelectionActivity::startWifiScan() { autoConnecting = false; // autoCycleAfterScan intentionally preserved when set by the auto-cycle flow state = WifiSelectionState::SCANNING; networks.clear(); requestUpdate(); // Set WiFi mode to station WiFi.mode(WIFI_STA); WiFi.disconnect(); delay(100); // Start async scan WiFi.scanNetworks(true); // true = async scan } void WifiSelectionActivity::buildAutoCycleCandidates() { autoCycleCandidates.clear(); autoCycleCandidateIndex = 0; const std::string& skipSsid = WIFI_STORE.getLastConnectedSsid(); // already tried struct Candidate { std::string ssid; int32_t rssi; }; std::vector candidates; for (const auto& net : networks) { if (net.ssid == skipSsid) continue; if (!WIFI_STORE.hasSavedCredential(net.ssid)) continue; candidates.push_back({net.ssid, net.rssi}); } std::sort(candidates.begin(), candidates.end(), [](const Candidate& a, const Candidate& b) { return a.rssi > b.rssi; }); std::transform(candidates.begin(), candidates.end(), std::back_inserter(autoCycleCandidates), [](const Candidate& c) { return c.ssid; }); LOG_DBG("WIFI", "Auto-cycle candidates: %zu", autoCycleCandidates.size()); } void WifiSelectionActivity::tryNextAutoCycleCandidate() { if (autoCycleCandidateIndex >= autoCycleCandidates.size()) { // All candidates exhausted — fall through to manual selection LOG_DBG("WIFI", "Auto-cycle exhausted, falling through to network list"); state = WifiSelectionState::NETWORK_LIST; selectedNetworkIndex = 0; requestUpdate(); return; } const std::string& ssid = autoCycleCandidates[autoCycleCandidateIndex++]; const auto* cred = WIFI_STORE.findCredential(ssid); if (!cred) { tryNextAutoCycleCandidate(); // Credential disappeared, skip return; } LOG_DBG("WIFI", "Auto-cycle trying %s (%zu/%zu)", ssid.c_str(), autoCycleCandidateIndex, autoCycleCandidates.size()); selectedSSID = cred->ssid; enteredPassword = cred->password; selectedRequiresPassword = !cred->password.empty(); usedSavedPassword = true; autoConnecting = false; state = WifiSelectionState::AUTO_CYCLING; connectionStartTime = millis(); connectedIP.clear(); connectionError.clear(); hintFallbackDone = false; requestUpdate(); prepareForConnect(); issueWifiBegin(/*useHint=*/true); } void WifiSelectionActivity::processWifiScanResults() { const int16_t scanResult = WiFi.scanComplete(); if (scanResult == WIFI_SCAN_RUNNING) { // Scan still in progress return; } if (scanResult == WIFI_SCAN_FAILED) { autoCycleAfterScan = false; state = WifiSelectionState::NETWORK_LIST; requestUpdate(); return; } // Scan complete, process results // Use a map to deduplicate networks by SSID, keeping the strongest signal std::map uniqueNetworks; for (int i = 0; i < scanResult; i++) { std::string ssid = WiFi.SSID(i).c_str(); const int32_t rssi = WiFi.RSSI(i); // Skip hidden networks (empty SSID) if (ssid.empty()) { continue; } // Check if we've already seen this SSID auto it = uniqueNetworks.find(ssid); if (it == uniqueNetworks.end() || rssi > it->second.rssi) { // New network or stronger signal than existing entry WifiNetworkInfo network; network.ssid = ssid; network.rssi = rssi; network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN); network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid); uniqueNetworks[ssid] = network; } } // Convert map to vector networks.clear(); for (const auto& pair : uniqueNetworks) { // cppcheck-suppress useStlAlgorithm networks.push_back(pair.second); } // Sort: saved-password networks first, then by signal strength (strongest first) std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { if (a.hasSavedPassword != b.hasSavedPassword) { return a.hasSavedPassword; } return a.rssi > b.rssi; }); WiFi.scanDelete(); if (autoCycleAfterScan) { autoCycleAfterScan = false; buildAutoCycleCandidates(); tryNextAutoCycleCandidate(); return; } state = WifiSelectionState::NETWORK_LIST; selectedNetworkIndex = 0; requestUpdate(); } void WifiSelectionActivity::selectNetwork(const int index) { if (index < 0 || index >= static_cast(networks.size())) { return; } const auto& network = networks[index]; selectedSSID = network.ssid; selectedRequiresPassword = network.isEncrypted; usedSavedPassword = false; enteredPassword.clear(); autoConnecting = false; // Check if we have saved credentials for this network const auto* savedCred = WIFI_STORE.findCredential(selectedSSID); if (savedCred && !savedCred->password.empty()) { // Use saved password - connect directly enteredPassword = savedCred->password; usedSavedPassword = true; LOG_DBG("WiFi", "Using saved password for %s, length: %zu", selectedSSID.c_str(), enteredPassword.size()); attemptConnection(); return; } if (selectedRequiresPassword) { // Show password entry state = WifiSelectionState::PASSWORD_ENTRY; // Don't allow screen updates while changing activity startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_ENTER_WIFI_PASSWORD), "", // No initial text 64, // Max password length InputType::Password), [this](const ActivityResult& result) { if (result.isCancelled) { state = WifiSelectionState::NETWORK_LIST; } else { enteredPassword = std::get(result.data).text; // state will be updated in next loop iteration } }); } else { // Connect directly for open networks attemptConnection(); } } void WifiSelectionActivity::attemptConnection() { state = autoConnecting ? WifiSelectionState::AUTO_CONNECTING : WifiSelectionState::CONNECTING; 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 // 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()); } 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 { 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()); } } } bool WifiSelectionActivity::checkCaptivePortal() { // Probe a known HTTP endpoint that returns 204 on open internet. // Captive portals intercept this and return a redirect (3xx) or 200 with a login page. NetworkClient client; HTTPClient http; http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS); http.setTimeout(5000); if (!http.begin(client, "http://connectivitycheck.gstatic.com/generate_204")) { return false; } const int code = http.GET(); String location = http.getLocation(); http.end(); if (code < 0) { LOG_DBG("WIFI", "Captive portal probe failed (connection error %d)", code); return false; } if (code == 204) { return false; // Open internet, no captive portal } // Any redirect or unexpected 200 means a captive portal is intercepting. captivePortalUrl = location.length() > 0 ? location.c_str() : "http://connectivitycheck.gstatic.com/generate_204"; LOG_DBG("WIFI", "Captive portal detected (HTTP %d), URL: %s", code, captivePortalUrl.c_str()); return true; } void WifiSelectionActivity::checkConnectionStatus() { if (state != WifiSelectionState::CONNECTING && state != WifiSelectionState::AUTO_CONNECTING && state != WifiSelectionState::AUTO_CYCLING) { return; } const wl_status_t status = WiFi.status(); if (status == WL_CONNECTED) { // Successfully connected IPAddress ip = WiFi.localIP(); char ipStr[16]; snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); connectedIP = ipStr; autoConnecting = false; 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 if (checkCaptivePortal()) { state = WifiSelectionState::CAPTIVE_PORTAL; requestUpdate(); return; } // If we entered a new password, ask if user wants to save it // Otherwise, immediately complete so parent can start web server if (!usedSavedPassword && !enteredPassword.empty()) { state = WifiSelectionState::SAVE_PROMPT; savePromptSelection = 0; // Default to "Yes" requestUpdate(); } else { // Using saved password or open network - complete immediately LOG_DBG("WIFI", "Connected with saved/open credentials, " "completing immediately"); onComplete(true); } 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; // Wipe the stale cache before retrying. If the fallback succeeds, the success // path writes a fresh cache (one extra SD write). If it fails or is interrupted, // we won't carry a known-bad hint into the next session. { RenderLock lock(*this); WIFI_STORE.clearConnectionCache(selectedSSID); } 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; if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) { if (state == WifiSelectionState::AUTO_CONNECTING) { // Primary SSID failed — scan and try remaining saved credentials autoCycleAfterScan = true; startWifiScan(); return; } if (state == WifiSelectionState::AUTO_CYCLING) { tryNextAutoCycleCandidate(); return; } connectionError = tr(STR_ERROR_GENERAL_FAILURE); if (status == WL_NO_SSID_AVAIL) { connectionError = tr(STR_ERROR_NETWORK_NOT_FOUND); } state = WifiSelectionState::CONNECTION_FAILED; requestUpdate(); return; } // Check for timeout if (millis() - connectionStartTime > timeout) { WiFi.disconnect(); if (state == WifiSelectionState::AUTO_CONNECTING) { autoCycleAfterScan = true; startWifiScan(); return; } if (state == WifiSelectionState::AUTO_CYCLING) { tryNextAutoCycleCandidate(); return; } connectionError = tr(STR_ERROR_CONNECTION_TIMEOUT); state = WifiSelectionState::CONNECTION_FAILED; requestUpdate(); return; } } void WifiSelectionActivity::loop() { // Check scan progress if (state == WifiSelectionState::SCANNING) { processWifiScanResults(); return; } // Check connection progress if (state == WifiSelectionState::CONNECTING || state == WifiSelectionState::AUTO_CONNECTING || state == WifiSelectionState::AUTO_CYCLING) { checkConnectionStatus(); return; } if (state == WifiSelectionState::PASSWORD_ENTRY) { // Reach here once password entry finished in subactivity attemptConnection(); return; } // Handle save prompt state if (state == WifiSelectionState::SAVE_PROMPT) { if (mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left)) { if (savePromptSelection > 0) { savePromptSelection--; requestUpdate(); } } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || mappedInput.wasPressed(MappedInputManager::Button::Right)) { if (savePromptSelection < 1) { savePromptSelection++; requestUpdate(); } } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (savePromptSelection == 0) { // User chose "Yes" - save the password RenderLock lock(*this); WIFI_STORE.addCredential(selectedSSID, enteredPassword); } // Complete - parent will start web server onComplete(true); } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { // Skip saving, complete anyway onComplete(true); } return; } // Handle forget prompt state (connection failed with saved credentials) if (state == WifiSelectionState::FORGET_PROMPT) { if (mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left)) { if (forgetPromptSelection > 0) { forgetPromptSelection--; requestUpdate(); } } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || mappedInput.wasPressed(MappedInputManager::Button::Right)) { if (forgetPromptSelection < 1) { forgetPromptSelection++; requestUpdate(); } } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (forgetPromptSelection == 1) { RenderLock lock(*this); // User chose "Forget network" - forget the network WIFI_STORE.removeCredential(selectedSSID); // Update the network list to reflect the change const auto network = find_if(networks.begin(), networks.end(), [this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; }); if (network != networks.end()) { network->hasSavedPassword = false; } } // Go back to network list (whether Cancel or Forget network was selected) startWifiScan(); } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { // Skip forgetting, go back to network list startWifiScan(); } return; } // Handle captive portal state - user must authorize on another device if (state == WifiSelectionState::CAPTIVE_PORTAL) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { // User says they've completed browser auth - proceed as connected if (!usedSavedPassword && !enteredPassword.empty()) { state = WifiSelectionState::SAVE_PROMPT; savePromptSelection = 0; requestUpdate(); } else { onComplete(true); } } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { WiFi.disconnect(); startWifiScan(); } return; } // Handle connected state (should not normally be reached - connection // completes immediately) if (state == WifiSelectionState::CONNECTED) { // Safety fallback - immediately complete onComplete(true); return; } // Handle connection failed state if (state == WifiSelectionState::CONNECTION_FAILED) { if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { // If we were auto-connecting or using a saved credential, offer to forget // the network if (autoConnecting || usedSavedPassword) { autoConnecting = false; state = WifiSelectionState::FORGET_PROMPT; forgetPromptSelection = 0; // Default to "Cancel" } else { // Go back to network list on failure for non-saved credentials state = WifiSelectionState::NETWORK_LIST; } requestUpdate(); return; } } // Handle network list state if (state == WifiSelectionState::NETWORK_LIST) { // Check for Back button to exit (cancel) if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onComplete(false); return; } // Check for Confirm button to select network or rescan if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (!networks.empty()) { selectNetwork(selectedNetworkIndex); } else { startWifiScan(); } return; } if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { startWifiScan(); return; } const bool leftPressed = mappedInput.wasPressed(MappedInputManager::Button::Left); if (leftPressed) { const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword; if (hasSavedPassword) { selectedSSID = networks[selectedNetworkIndex].ssid; state = WifiSelectionState::FORGET_PROMPT; forgetPromptSelection = 0; // Default to "Cancel" requestUpdate(); return; } } // Handle navigation buttonNavigator.onNextList(selectedNetworkIndex, static_cast(networks.size()), [this] { requestUpdate(); }); buttonNavigator.onPreviousList(selectedNetworkIndex, static_cast(networks.size()), [this] { requestUpdate(); }); } } std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi) const { // Convert RSSI to signal bars representation if (rssi >= -50) { return "||||"; // Excellent } if (rssi >= -60) { return " |||"; // Good } if (rssi >= -70) { return " ||"; // Fair } return " |"; // Very weak } void WifiSelectionActivity::render(RenderLock&&) { // Don't render if we're in PASSWORD_ENTRY state - we're just transitioning // from the keyboard subactivity back to the main activity if (state == WifiSelectionState::PASSWORD_ENTRY) { return; } renderer.clearScreen(); const auto& metrics = UITheme::getInstance().getMetrics(); const Rect contentRect = UITheme::getContentRect(renderer, true, false); // Draw header char countStr[32]; snprintf(countStr, sizeof(countStr), tr(STR_NETWORKS_FOUND), networks.size()); GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight}, tr(STR_WIFI_NETWORKS), countStr); GUI.drawSubHeader( renderer, Rect{contentRect.x, metrics.topPadding + metrics.headerHeight, contentRect.width, metrics.tabBarHeight}, cachedMacAddress.c_str()); switch (state) { case WifiSelectionState::AUTO_CONNECTING: case WifiSelectionState::AUTO_CYCLING: renderConnecting(); break; case WifiSelectionState::SCANNING: renderConnecting(); // Reuse connecting screen with different message break; case WifiSelectionState::NETWORK_LIST: renderNetworkList(); break; case WifiSelectionState::CONNECTING: renderConnecting(); break; case WifiSelectionState::CONNECTED: renderConnected(); break; case WifiSelectionState::SAVE_PROMPT: renderSavePrompt(); break; case WifiSelectionState::CONNECTION_FAILED: renderConnectionFailed(); break; case WifiSelectionState::FORGET_PROMPT: renderForgetPrompt(); break; case WifiSelectionState::CAPTIVE_PORTAL: renderCaptivePortal(); break; } renderer.displayBuffer(); } void WifiSelectionActivity::renderNetworkList() const { const auto& metrics = UITheme::getInstance().getMetrics(); const Rect contentRect = UITheme::getContentRect(renderer, true, false); if (networks.empty()) { // No networks found or scan failed const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (contentRect.y + contentRect.height - height) / 2; renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_NO_NETWORKS)); renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, tr(STR_PRESS_OK_SCAN)); } else { int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing; int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing * 2; GUI.drawList( renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, static_cast(networks.size()), selectedNetworkIndex, [this](int index) { return networks[index].ssid; }, nullptr, nullptr, [this](int index) { auto network = networks[index]; return std::string(network.hasSavedPassword ? "+ " : "") + (network.isEncrypted ? "* " : "") + getSignalStrengthIndicator(network.rssi); }); } GUI.drawHelpText( renderer, Rect{contentRect.x, contentRect.y + contentRect.height - metrics.contentSidePadding - 15, contentRect.width, 20}, tr(STR_NETWORK_LEGEND)); const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword; const char* forgetLabel = hasSavedPassword ? tr(STR_FORGET_BUTTON) : ""; const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CONNECT), forgetLabel, tr(STR_RETRY)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderConnecting() const { const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (pageHeight - height) / 2; if (state == WifiSelectionState::SCANNING) { renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_SCANNING)); } else { renderer.drawCenteredText(UI_12_FONT_ID, top - 40, tr(STR_CONNECTING), true, EpdFontFamily::BOLD); std::string ssidInfo = std::string(tr(STR_TO_PREFIX)) + selectedSSID; if (ssidInfo.length() > 25) { ssidInfo.replace(22, ssidInfo.length() - 22, "..."); } renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str()); } } void WifiSelectionActivity::renderConnected() const { const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (pageHeight - height * 4) / 2; renderer.drawCenteredText(UI_12_FONT_ID, top - 30, tr(STR_CONNECTED), true, EpdFontFamily::BOLD); std::string ssidInfo = std::string(tr(STR_NETWORK_PREFIX)) + selectedSSID; if (ssidInfo.length() > 28) { ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_10_FONT_ID, top + 10, ssidInfo.c_str()); const std::string ipInfo = std::string(tr(STR_IP_ADDRESS_PREFIX)) + connectedIP; renderer.drawCenteredText(UI_10_FONT_ID, top + 40, ipInfo.c_str()); // Use centralized button hints const auto labels = mappedInput.mapLabels("", tr(STR_DONE), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderSavePrompt() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (pageHeight - height * 3) / 2; renderer.drawCenteredText(UI_12_FONT_ID, top - 40, tr(STR_CONNECTED), true, EpdFontFamily::BOLD); std::string ssidInfo = std::string(tr(STR_NETWORK_PREFIX)) + selectedSSID; if (ssidInfo.length() > 28) { ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, top + 40, tr(STR_SAVE_PASSWORD)); // Draw Yes/No buttons const int buttonY = top + 80; constexpr int buttonWidth = 60; constexpr int buttonSpacing = 30; constexpr int totalWidth = buttonWidth * 2 + buttonSpacing; const int startX = (pageWidth - totalWidth) / 2; // Draw "Yes" button if (savePromptSelection == 0) { std::string text = "[" + std::string(tr(STR_YES)) + "]"; renderer.drawText(UI_10_FONT_ID, startX, buttonY, text.c_str()); } else { renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, tr(STR_YES)); } // Draw "No" button if (savePromptSelection == 1) { std::string text = "[" + std::string(tr(STR_NO)) + "]"; renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, text.c_str()); } else { renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, tr(STR_NO)); } // Use centralized button hints const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderConnectionFailed() const { const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (pageHeight - height * 2) / 2; renderer.drawCenteredText(UI_12_FONT_ID, top - 20, tr(STR_CONNECTION_FAILED), true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, top + 20, connectionError.c_str()); // Use centralized button hints const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DONE), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderForgetPrompt() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (pageHeight - height * 3) / 2; renderer.drawCenteredText(UI_12_FONT_ID, top - 40, tr(STR_FORGET_NETWORK), true, EpdFontFamily::BOLD); std::string ssidInfo = std::string(tr(STR_NETWORK_PREFIX)) + selectedSSID; if (ssidInfo.length() > 28) { ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str()); const auto& metrics = UITheme::getInstance().getMetrics(); const int hintWidth = pageWidth - 2 * metrics.contentSidePadding; const auto forgetLines = renderer.wrappedText(UI_10_FONT_ID, tr(STR_FORGET_AND_REMOVE), hintWidth, 3); int forgetY = top + 40; for (const auto& line : forgetLines) { renderer.drawCenteredText(UI_10_FONT_ID, forgetY, line.c_str()); forgetY += height; } // Draw Cancel/Forget network buttons const int buttonY = top + 80; constexpr int buttonWidth = 120; constexpr int buttonSpacing = 30; constexpr int totalWidth = buttonWidth * 2 + buttonSpacing; const int startX = (pageWidth - totalWidth) / 2; // Draw "Cancel" button if (forgetPromptSelection == 0) { std::string text = "[" + std::string(tr(STR_CANCEL)) + "]"; renderer.drawText(UI_10_FONT_ID, startX, buttonY, text.c_str()); } else { renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, tr(STR_CANCEL)); } // Draw "Forget network" button if (forgetPromptSelection == 1) { std::string text = "[" + std::string(tr(STR_FORGET_BUTTON)) + "]"; renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, text.c_str()); } else { renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, tr(STR_FORGET_BUTTON)); } // Use centralized button hints const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderCaptivePortal() const { const auto& metrics = UITheme::getInstance().getMetrics(); const Rect contentRect = UITheme::getContentRect(renderer, true, false); const int pageWidth = renderer.getScreenWidth(); const int maxWidth = pageWidth - metrics.contentSidePadding * 2; const int lh12 = renderer.getLineHeight(UI_12_FONT_ID); const int lh10 = renderer.getLineHeight(UI_10_FONT_ID); const int lhSmall = renderer.getLineHeight(SMALL_FONT_ID); const int sp = metrics.verticalSpacing; constexpr int QR_SIZE = 320; // Pre-compute wrapped hint and URL lines so we can vertically centre everything const std::string hintText = std::string(tr(STR_CAPTIVE_PORTAL_HINT_1)) + " " + tr(STR_CAPTIVE_PORTAL_HINT_2); const auto hintLines = renderer.wrappedText(UI_10_FONT_ID, hintText.c_str(), maxWidth, 4); const auto urlLines = renderer.wrappedText(SMALL_FONT_ID, captivePortalUrl.c_str(), maxWidth, 10); const int totalHeight = lh12 + sp // title + static_cast(hintLines.size()) * lh10 + sp // hint + QR_SIZE + sp // QR code + static_cast(urlLines.size()) * lhSmall; // contentRect covers the full screen minus button hints; subtract the header // and sub-header that render() always draws above us. const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight; const int contentBottom = contentRect.y + contentRect.height; int y = contentTop + (contentBottom - contentTop - totalHeight) / 2; renderer.drawCenteredText(UI_12_FONT_ID, y, tr(STR_CAPTIVE_PORTAL_DETECTED), true, EpdFontFamily::BOLD); y += lh12 + sp; for (const auto& line : hintLines) { renderer.drawCenteredText(UI_10_FONT_ID, y, line.c_str()); y += lh10; } y += sp; const int qrX = contentRect.x + (contentRect.width - QR_SIZE) / 2; QrUtils::drawQrCode(renderer, Rect{qrX, y, QR_SIZE, QR_SIZE}, captivePortalUrl); y += QR_SIZE + sp; for (const auto& line : urlLines) { renderer.drawCenteredText(SMALL_FONT_ID, y, line.c_str()); y += lhSmall; } const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CAPTIVE_PORTAL_DONE), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::onComplete(const bool connected) { ActivityResult result; result.isCancelled = !connected; if (connected) { result.data = WifiResult{true, selectedSSID, connectedIP}; } setResult(std::move(result)); finish(); }