1087 lines
39 KiB
C++
1087 lines
39 KiB
C++
#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 (~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<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();
|
||
}
|