Files
Crosspoint/src/activities/network/WifiSelectionActivity.cpp
T

1087 lines
39 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "WifiSelectionActivity.h"
#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"
#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<Candidate> 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<std::string, WifiNetworkInfo> 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<int>(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<KeyboardEntryActivity>(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<KeyboardResult>(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 (~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());
}
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 {
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<uint8_t>(actualChannel), ipBytes,
gwBytes, maskBytes, dnsBytes,
nowEpoch > 0 ? static_cast<uint32_t>(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<int>(networks.size()), [this] { requestUpdate(); });
buttonNavigator.onPreviousList(selectedNetworkIndex, static_cast<int>(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<int>(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<int>(hintLines.size()) * lh10 + sp // hint
+ QR_SIZE + sp // QR code
+ static_cast<int>(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();
}