diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index be865b86..86929171 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -3,6 +3,11 @@ #include #include #include +#include +#include + +#include "activities/ActivityWithSubactivity.h" +#include "activities/network/WifiSelectionActivity.h" // Initialize the static instance WifiCredentialStore WifiCredentialStore::instance; @@ -53,6 +58,10 @@ bool WifiCredentialStore::saveToFile() const { serialization::writeString(file, obfuscatedPwd); } + // Write default SSID + serialization::writeString(file, defaultSSID); + Serial.printf("[%lu] [WCS] Saving default SSID: %s\n", millis(), defaultSSID.c_str()); + file.close(); Serial.printf("[%lu] [WCS] Saved %zu WiFi credentials to file\n", millis(), credentials.size()); return true; @@ -95,6 +104,27 @@ bool WifiCredentialStore::loadFromFile() { credentials.push_back(cred); } + // Try to read default SSID if it exists + defaultSSID.clear(); + if (file.available() >= 4) { + const uint32_t posBefore = file.position(); + uint32_t len = 0; + serialization::readPod(file, len); + + if (file.available() >= len && len <= 64) { + defaultSSID.resize(len); + const size_t bytesRead = file.read(reinterpret_cast(&defaultSSID[0]), len); + if (bytesRead == len) { + Serial.printf("[%lu] [WCS] Loaded default SSID: %s\n", millis(), defaultSSID.c_str()); + } else { + file.seek(posBefore); + defaultSSID.clear(); + } + } else { + file.seek(posBefore); + } + } + file.close(); Serial.printf("[%lu] [WCS] Loaded %zu WiFi credentials from file\n", millis(), credentials.size()); return true; @@ -127,6 +157,9 @@ bool WifiCredentialStore::removeCredential(const std::string& ssid) { [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); if (cred != credentials.end()) { credentials.erase(cred); + if (defaultSSID == ssid) { + defaultSSID.clear(); + } Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); return saveToFile(); } @@ -148,6 +181,107 @@ bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { re void WifiCredentialStore::clearAll() { credentials.clear(); + defaultSSID.clear(); saveToFile(); Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis()); } + +void WifiCredentialStore::setDefaultSSID(const std::string& ssid) { + defaultSSID = ssid; + saveToFile(); + Serial.printf("[%lu] [WCS] Set default SSID: %s\n", millis(), ssid.c_str()); +} + +bool WifiCredentialStore::connectToDefaultWifi(int timeoutMs) const { + if (defaultSSID.empty()) { + Serial.printf("[%lu] [WCS] No default SSID set\n", millis()); + return false; + } + + const auto* cred = findCredential(defaultSSID); + if (!cred) { + return false; + } + + // Quick check: scan to see if the SSID is available before attempting connection + WiFi.mode(WIFI_STA); + WiFi.disconnect(false); + delay(100); + + Serial.printf("[%lu] [WCS] Scanning for SSID: %s\n", millis(), defaultSSID.c_str()); + WiFi.scanNetworks(false); + + const unsigned long scanStart = millis(); + int16_t scanResult = WiFi.scanComplete(); + while (scanResult == WIFI_SCAN_RUNNING && millis() - scanStart < 3000) { + delay(100); + scanResult = WiFi.scanComplete(); + } + + if (scanResult > 0) { + bool ssidFound = false; + for (int i = 0; i < scanResult; i++) { + std::string scannedSSID = WiFi.SSID(i).c_str(); + if (scannedSSID == defaultSSID) { + ssidFound = true; + break; + } + } + + WiFi.scanDelete(); + + if (!ssidFound) { + Serial.printf("[%lu] [WCS] SSID not found in scan results, skipping connection attempt\n", millis()); + return false; + } + } else { + WiFi.scanDelete(); + return false; + } + + WiFi.begin(defaultSSID.c_str(), cred->password.c_str()); + + Serial.printf("[%lu] [WCS] Connecting to default WiFi: %s\n", millis(), defaultSSID.c_str()); + const unsigned long startTime = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - startTime < static_cast(timeoutMs)) { + vTaskDelay(100 / portTICK_PERIOD_MS); + } + + if (WiFi.status() == WL_CONNECTED) { + Serial.printf("[%lu] [WCS] Connected to default WiFi: %s (IP: %s)\n", millis(), defaultSSID.c_str(), + WiFi.localIP().toString().c_str()); + return true; + } else { + Serial.printf("[%lu] [WCS] Failed to connect to default WiFi: %s\n", millis(), defaultSSID.c_str()); + return false; + } +} + +void WifiCredentialStore::ensureWifiConnected(ActivityWithSubactivity& activity, GfxRenderer& renderer, + MappedInputManager& mappedInput, const std::function& onSuccess, + const std::function& onCancel, int timeoutMs) { + if (WiFi.status() == WL_CONNECTED) { + onSuccess(); + return; + } + + // Try to connect using default WiFi + WifiCredentialStore::getInstance().loadFromFile(); + if (WifiCredentialStore::getInstance().connectToDefaultWifi(timeoutMs)) { + Serial.printf("[%lu] [WCS] Auto-connected to WiFi\n", millis()); + onSuccess(); + return; + } + + // Auto-connect failed - show WiFi selection list + Serial.printf("[%lu] [WCS] Auto-connect failed, showing WiFi selection\n", millis()); + activity.enterNewActivity( + new WifiSelectionActivity(renderer, mappedInput, [&activity, onSuccess, onCancel](bool connected) { + activity.exitActivity(); + if (connected) { + onSuccess(); + } else { + onCancel(); + } + })); +} diff --git a/src/WifiCredentialStore.h b/src/WifiCredentialStore.h index 0004dc9b..90e6b342 100644 --- a/src/WifiCredentialStore.h +++ b/src/WifiCredentialStore.h @@ -1,7 +1,12 @@ #pragma once +#include #include #include +class ActivityWithSubactivity; +class GfxRenderer; +class MappedInputManager; + struct WifiCredential { std::string ssid; std::string password; // Stored obfuscated in file @@ -16,6 +21,7 @@ class WifiCredentialStore { private: static WifiCredentialStore instance; std::vector credentials; + std::string defaultSSID; static constexpr size_t MAX_NETWORKS = 8; @@ -50,6 +56,16 @@ class WifiCredentialStore { // Clear all credentials void clearAll(); + + // Default network management + void setDefaultSSID(const std::string& ssid); + const std::string& getDefaultSSID() const { return defaultSSID; } + bool connectToDefaultWifi(int timeoutMs = 5000) const; + + // Helper function to try connecting to default WiFi, or show WiFi selection if it fails + static void ensureWifiConnected(ActivityWithSubactivity& activity, GfxRenderer& renderer, + MappedInputManager& mappedInput, const std::function& onSuccess, + const std::function& onCancel, int timeoutMs = 10000); }; // Helper macro to access credentials store diff --git a/src/activities/ActivityWithSubactivity.h b/src/activities/ActivityWithSubactivity.h index 141dbbcb..7450db3d 100644 --- a/src/activities/ActivityWithSubactivity.h +++ b/src/activities/ActivityWithSubactivity.h @@ -6,10 +6,10 @@ class ActivityWithSubactivity : public Activity { protected: std::unique_ptr subActivity = nullptr; - void exitActivity(); - void enterNewActivity(Activity* activity); public: + void exitActivity(); + void enterNewActivity(Activity* activity); explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput) : Activity(std::move(name), renderer, mappedInput) {} void loop() override; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 4e0a08d2..cd355632 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -7,6 +7,7 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "ScreenComponents.h" +#include "WifiCredentialStore.h" #include "activities/network/WifiSelectionActivity.h" #include "fontIds.h" #include "network/HttpDownloader.h" @@ -154,6 +155,12 @@ void OpdsBookBrowserActivity::loop() { void OpdsBookBrowserActivity::displayTaskLoop() { while (true) { + // If a subactivity is active, yield CPU time but don't render + if (subActivity) { + vTaskDelay(10 / portTICK_PERIOD_MS); + continue; + } + if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -374,8 +381,27 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() { return; } - // Not connected - launch WiFi selection screen directly - launchWifiSelection(); + // Try to connect to default WiFi if available + WIFI_STORE.loadFromFile(); + const bool hasDefaultSSID = !WIFI_STORE.getDefaultSSID().empty(); + + if (hasDefaultSSID) { + statusMessage = "Connecting to WiFi..."; + updateRequired = true; + } + + WIFI_STORE.ensureWifiConnected( + *this, renderer, mappedInput, + [this]() { + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + }, + [this]() { + // User cancelled WiFi selection - go home + onGoHome(); + }); } void OpdsBookBrowserActivity::launchWifiSelection() { diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 35ad58ba..53f0a566 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -11,7 +11,7 @@ #include "MappedInputManager.h" #include "NetworkModeSelectionActivity.h" -#include "WifiSelectionActivity.h" +#include "WifiCredentialStore.h" #include "fontIds.h" namespace { @@ -135,14 +135,24 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) exitActivity(); if (mode == NetworkMode::JOIN_NETWORK) { - // STA mode - launch WiFi selection - Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); + // STA mode - use ensureWifiConnected helper + Serial.printf("[%lu] [WEBACT] Checking WiFi connection...\n", millis()); WiFi.mode(WIFI_STA); state = WebServerActivityState::WIFI_SELECTION; - Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); - enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, - [this](const bool connected) { onWifiSelectionComplete(connected); })); + WIFI_STORE.ensureWifiConnected( + *this, renderer, mappedInput, + [this]() { + // WiFi connected - start web server + connectedIP = WiFi.localIP().toString().c_str(); + connectedSSID = WiFi.SSID().c_str(); + isApMode = false; + onWifiSelectionComplete(true); + }, + [this]() { + // WiFi connection cancelled - go back to mode selection + onWifiSelectionComplete(false); + }); } else { // AP mode - start access point state = WebServerActivityState::AP_STARTING; @@ -152,15 +162,15 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) } void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { - Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); + Serial.printf("[%lu] [WEBACT] WiFi connection completed, connected=%d\n", millis(), connected); if (connected) { - // Get connection info before exiting subactivity - connectedIP = static_cast(subActivity.get())->getConnectedIP(); - connectedSSID = WiFi.SSID().c_str(); isApMode = false; - exitActivity(); + // Exit any subactivity if present + if (subActivity) { + exitActivity(); + } // Start mDNS for hostname resolution if (MDNS.begin(AP_HOSTNAME)) { diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 775a2474..386d2a26 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -24,7 +24,7 @@ enum class WebServerActivityState { * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) - * - For STA mode: Launches WifiSelectionActivity to connect to an existing network + * - For STA mode: Uses ensureWifiConnected to auto-connect to default WiFi or show WiFi selection * - For AP mode: Creates an Access Point that clients can connect to * - Starts the CrossPointWebServer when connected * - Handles client requests in its loop() function diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index a8653f43..973a32d4 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -36,6 +36,7 @@ void WifiSelectionActivity::onEnter() { usedSavedPassword = false; savePromptSelection = 0; forgetPromptSelection = 0; + setDefaultPromptSelection = 0; // Trigger first update to show scanning message updateRequired = true; @@ -229,6 +230,28 @@ void WifiSelectionActivity::attemptConnection() { } } +void WifiSelectionActivity::savePassword() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + WIFI_STORE.addCredential(selectedSSID, enteredPassword); + xSemaphoreGive(renderingMutex); +} + +void WifiSelectionActivity::displaySetDefaultPrompt() { + WIFI_STORE.loadFromFile(); + const std::string currentDefault = WIFI_STORE.getDefaultSSID(); + if (selectedSSID != currentDefault) { + state = WifiSelectionState::SET_DEFAULT_PROMPT; + setDefaultPromptSelection = 0; + updateRequired = true; + } +} + +void WifiSelectionActivity::setDefaultNetwork() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + WIFI_STORE.setDefaultSSID(selectedSSID); + xSemaphoreGive(renderingMutex); +} + void WifiSelectionActivity::checkConnectionStatus() { if (state != WifiSelectionState::CONNECTING) { return; @@ -243,16 +266,19 @@ void WifiSelectionActivity::checkConnectionStatus() { snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); connectedIP = ipStr; - // If we entered a new password, ask if user wants to save it - // Otherwise, immediately complete so parent can start web server + // If we entered a new password, save it (with or without prompt based on fromSettingsScreen) if (!usedSavedPassword && !enteredPassword.empty()) { - state = WifiSelectionState::SAVE_PROMPT; - savePromptSelection = 0; // Default to "Yes" - updateRequired = true; - } else { - // Using saved password or open network - complete immediately - Serial.printf("[%lu] [WIFI] Connected with saved/open credentials, completing immediately\n", millis()); - onComplete(true); + if (fromSettingsScreen) { + // Always save without prompting + savePassword(); + displaySetDefaultPrompt(); + + return; + } else { + state = WifiSelectionState::SAVE_PROMPT; + savePromptSelection = 0; + updateRequired = true; + } } return; } @@ -318,11 +344,9 @@ void WifiSelectionActivity::loop() { } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (savePromptSelection == 0) { // User chose "Yes" - save the password - xSemaphoreTake(renderingMutex, portMAX_DELAY); - WIFI_STORE.addCredential(selectedSSID, enteredPassword); - xSemaphoreGive(renderingMutex); + savePassword(); + displaySetDefaultPrompt(); } - // Complete - parent will start web server onComplete(true); } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { // Skip saving, complete anyway @@ -331,6 +355,44 @@ void WifiSelectionActivity::loop() { return; } + // Handle set default prompt state + if (state == WifiSelectionState::SET_DEFAULT_PROMPT) { + if (mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) { + if (setDefaultPromptSelection > 0) { + setDefaultPromptSelection--; + updateRequired = true; + } + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right)) { + if (setDefaultPromptSelection < 1) { + setDefaultPromptSelection++; + updateRequired = true; + } + } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + if (setDefaultPromptSelection == 0) { + // User chose "Yes" - set as default + setDefaultNetwork(); + } + // Disconnect if from settings screen before completing + if (fromSettingsScreen) { + WiFi.disconnect(false); + delay(100); + WiFi.mode(WIFI_OFF); + } + onComplete(true); + } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + // Disconnect if from settings screen before completing + if (fromSettingsScreen) { + WiFi.disconnect(false); + delay(100); + WiFi.mode(WIFI_OFF); + } + onComplete(true); + } + return; + } + // Handle forget prompt state (connection failed with saved credentials) if (state == WifiSelectionState::FORGET_PROMPT) { if (mappedInput.wasPressed(MappedInputManager::Button::Up) || @@ -489,6 +551,9 @@ void WifiSelectionActivity::render() const { case WifiSelectionState::SAVE_PROMPT: renderSavePrompt(); break; + case WifiSelectionState::SET_DEFAULT_PROMPT: + renderSetDefaultPrompt(); + break; case WifiSelectionState::CONNECTION_FAILED: renderConnectionFailed(); break; @@ -655,6 +720,46 @@ void WifiSelectionActivity::renderSavePrompt() const { renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm"); } +void WifiSelectionActivity::renderSetDefaultPrompt() 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, "Connected!", true, EpdFontFamily::BOLD); + + std::string ssidInfo = "Network: " + 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, "Set as default WiFi?"); + + // 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 (setDefaultPromptSelection == 0) { + renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Yes]"); + } else { + renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Yes"); + } + + // Draw "No" button + if (setDefaultPromptSelection == 1) { + renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]"); + } else { + renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No"); + } + + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm"); +} + void WifiSelectionActivity::renderConnectionFailed() const { const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_10_FONT_ID); diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index 33ea26b1..4ed8fdb1 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -21,14 +21,15 @@ struct WifiNetworkInfo { // WiFi selection states enum class WifiSelectionState { - SCANNING, // Scanning for networks - NETWORK_LIST, // Displaying available networks - PASSWORD_ENTRY, // Entering password for selected network - CONNECTING, // Attempting to connect - CONNECTED, // Successfully connected - SAVE_PROMPT, // Asking user if they want to save the password - CONNECTION_FAILED, // Connection failed - FORGET_PROMPT // Asking user if they want to forget the network + SCANNING, // Scanning for networks + NETWORK_LIST, // Displaying available networks + PASSWORD_ENTRY, // Entering password for selected network + CONNECTING, // Attempting to connect + CONNECTED, // Successfully connected + SAVE_PROMPT, // Asking user if they want to save the password + SET_DEFAULT_PROMPT, // Asking user if they want to set as default + CONNECTION_FAILED, // Connection failed + FORGET_PROMPT // Asking user if they want to forget the network }; /** @@ -65,9 +66,13 @@ class WifiSelectionActivity final : public ActivityWithSubactivity { // Whether network was connected using a saved password (skip save prompt) bool usedSavedPassword = false; - // Save/forget prompt selection (0 = Yes, 1 = No) + // Whether launched from settings screen (affects save behavior and disconnection) + bool fromSettingsScreen = false; + + // Save/forget/set default prompt selection (0 = Yes, 1 = No) int savePromptSelection = 0; int forgetPromptSelection = 0; + int setDefaultPromptSelection = 0; // Connection timeout static constexpr unsigned long CONNECTION_TIMEOUT_MS = 15000; @@ -81,6 +86,7 @@ class WifiSelectionActivity final : public ActivityWithSubactivity { void renderConnecting() const; void renderConnected() const; void renderSavePrompt() const; + void renderSetDefaultPrompt() const; void renderConnectionFailed() const; void renderForgetPrompt() const; @@ -91,10 +97,17 @@ class WifiSelectionActivity final : public ActivityWithSubactivity { void checkConnectionStatus(); std::string getSignalStrengthIndicator(int32_t rssi) const; + // Helper methods + void savePassword(); + void displaySetDefaultPrompt(); + void setDefaultNetwork(); + public: explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onComplete) - : ActivityWithSubactivity("WifiSelection", renderer, mappedInput), onComplete(onComplete) {} + const std::function& onComplete, bool fromSettingsScreen = false) + : ActivityWithSubactivity("WifiSelection", renderer, mappedInput), + onComplete(onComplete), + fromSettingsScreen(fromSettingsScreen) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index efa0b9e1..6ecc5f6e 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -9,6 +9,7 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "WifiConnectionsActivity.h" #include "fontIds.h" // Define the static settings list @@ -42,6 +43,7 @@ const SettingInfo settingsList[settingsCount] = { SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), SettingInfo::Action("Calibre Settings"), + SettingInfo::Action("WiFi Connections"), SettingInfo::Action("Check for updates")}; } // namespace @@ -147,6 +149,14 @@ void SettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "WiFi Connections") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new WifiConnectionsActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); } else if (strcmp(setting.name, "Check for updates") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); diff --git a/src/activities/settings/WifiConnectionsActivity.cpp b/src/activities/settings/WifiConnectionsActivity.cpp new file mode 100644 index 00000000..d3fc8fe9 --- /dev/null +++ b/src/activities/settings/WifiConnectionsActivity.cpp @@ -0,0 +1,267 @@ +#include "WifiConnectionsActivity.h" + +#include +#include + +#include "MappedInputManager.h" +#include "WifiCredentialStore.h" +#include "activities/network/WifiSelectionActivity.h" +#include "fontIds.h" + +namespace { +constexpr int PAGE_ITEMS = 23; +constexpr int SKIP_PAGE_MS = 700; +constexpr unsigned long IGNORE_INPUT_MS = 300; +} // namespace + +void WifiConnectionsActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void WifiConnectionsActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + state = State::LIST; + selectorIndex = 0; + settingsSelection = 0; + selectedNetwork.clear(); + enterTime = millis(); + updateRequired = true; + + WIFI_STORE.loadFromFile(); + + xTaskCreate(&WifiConnectionsActivity::taskTrampoline, "WifiConnectionsTask", 4096, this, 1, &displayTaskHandle); +} + +void WifiConnectionsActivity::onExit() { + ActivityWithSubactivity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void WifiConnectionsActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + const unsigned long timeSinceEnter = millis() - enterTime; + if (timeSinceEnter < IGNORE_INPUT_MS) { + return; + } + + if (state == State::SETTINGS_MENU) { + // Check if this network is already the default + WIFI_STORE.loadFromFile(); + const std::string currentDefault = WIFI_STORE.getDefaultSSID(); + const bool isDefault = (selectedNetwork == currentDefault); + + // Handle settings menu + if (mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left)) { + if (settingsSelection > 0) { + settingsSelection--; + updateRequired = true; + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right)) { + if (settingsSelection < 1) { + settingsSelection++; + updateRequired = true; + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (settingsSelection == 0) { + if (isDefault) { + removeDefault(); + } else { + setDefault(); + } + } else { + deleteNetwork(); + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + cancelSettings(); + } + return; + } + + // Handle list navigation + const auto& credentials = WIFI_STORE.getCredentials(); + const size_t totalItems = credentials.size() + 1; // +1 for "Add new connection" + + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (selectorIndex == 0) { + // "Add new connection" selected - launch WiFi selection + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new WifiSelectionActivity( + renderer, mappedInput, + [this](bool connected) { + // Reload credentials after WiFi selection + WIFI_STORE.loadFromFile(); + exitActivity(); + enterTime = millis(); // Reset enter time to ignore input after subactivity exits + updateRequired = true; + }, + true)); // true = fromSettingsScreen (always save password and disconnect after) + xSemaphoreGive(renderingMutex); + } else { + // Regular credential selected + handleSettings(); + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onBack(); + } else if (prevReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + totalItems) % totalItems; + } else { + selectorIndex = (selectorIndex + totalItems - 1) % totalItems; + } + updateRequired = true; + } else if (nextReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % totalItems; + } else { + selectorIndex = (selectorIndex + 1) % totalItems; + } + updateRequired = true; + } +} + +void WifiConnectionsActivity::handleSettings() { + const auto& credentials = WIFI_STORE.getCredentials(); + // selectorIndex 0 is "Add new connection", so credentials start at index 1 + if (selectorIndex == 0 || selectorIndex > credentials.size()) { + return; + } + + selectedNetwork = credentials[selectorIndex - 1].ssid; + state = State::SETTINGS_MENU; + settingsSelection = 0; + updateRequired = true; +} + +void WifiConnectionsActivity::setDefault() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + WIFI_STORE.setDefaultSSID(selectedNetwork); + xSemaphoreGive(renderingMutex); + + state = State::LIST; + selectedNetwork.clear(); + updateRequired = true; +} + +void WifiConnectionsActivity::removeDefault() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + WIFI_STORE.setDefaultSSID(""); + xSemaphoreGive(renderingMutex); + + state = State::LIST; + selectedNetwork.clear(); + updateRequired = true; +} + +void WifiConnectionsActivity::deleteNetwork() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + WIFI_STORE.removeCredential(selectedNetwork); + xSemaphoreGive(renderingMutex); + + // Reload to get updated list + WIFI_STORE.loadFromFile(); + + const auto& credentials = WIFI_STORE.getCredentials(); + const size_t totalItems = credentials.size() + 1; + if (selectorIndex >= totalItems) { + selectorIndex = totalItems - 1; + } + + state = State::LIST; + selectedNetwork.clear(); + updateRequired = true; +} + +void WifiConnectionsActivity::cancelSettings() { + state = State::LIST; + selectedNetwork.clear(); + updateRequired = true; +} + +void WifiConnectionsActivity::displayTaskLoop() { + while (true) { + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void WifiConnectionsActivity::render() { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.drawCenteredText(UI_12_FONT_ID, 15, "WiFi Connections", true, EpdFontFamily::BOLD); + + if (state == State::SETTINGS_MENU) { + const int centerY = pageHeight / 2; + renderer.drawCenteredText(UI_10_FONT_ID, centerY - 40, "Settings", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, centerY - 20, selectedNetwork.c_str()); + + // Check if this network is already the default + const std::string currentDefault = WIFI_STORE.getDefaultSSID(); + const bool isDefault = (selectedNetwork == currentDefault); + + const char* defaultText = settingsSelection == 0 ? (isDefault ? "> Remove Default" : "> Set Default") + : (isDefault ? " Remove Default" : " Set Default"); + const char* deleteText = settingsSelection == 1 ? "> Delete" : " Delete"; + renderer.drawCenteredText(UI_10_FONT_ID, centerY + 20, defaultText); + renderer.drawCenteredText(UI_10_FONT_ID, centerY + 40, deleteText); + + const auto labels = mappedInput.mapLabels("Cancel", "Confirm", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + } else { + const auto& credentials = WIFI_STORE.getCredentials(); + + const auto labels = mappedInput.mapLabels("« Back", selectorIndex == 0 ? "Add" : "Settings", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + const size_t totalItems = credentials.size() + 1; + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + const std::string& defaultSSID = WIFI_STORE.getDefaultSSID(); + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); + + for (size_t i = pageStartIndex; i < totalItems && i < pageStartIndex + PAGE_ITEMS; i++) { + std::string displayText; + if (i == 0) { + displayText = "+ Add new connection"; + } else { + displayText = credentials[i - 1].ssid; + if (credentials[i - 1].ssid == defaultSSID) { + displayText += " [Default]"; + } + } + auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex); + } + } + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/WifiConnectionsActivity.h b/src/activities/settings/WifiConnectionsActivity.h new file mode 100644 index 00000000..ae719734 --- /dev/null +++ b/src/activities/settings/WifiConnectionsActivity.h @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include + +#include +#include + +#include "activities/ActivityWithSubactivity.h" + +/** + * Activity for managing saved WiFi connections. + * Shows a list of saved WiFi networks and allows deletion with confirmation. + */ +class WifiConnectionsActivity final : public ActivityWithSubactivity { + public: + explicit WifiConnectionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onBack) + : ActivityWithSubactivity("WifiConnections", renderer, mappedInput), onBack(onBack) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + enum class State { LIST, SETTINGS_MENU }; + + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + State state = State::LIST; + size_t selectorIndex = 0; + int settingsSelection = 0; + std::string selectedNetwork; + unsigned long enterTime = 0; + const std::function onBack; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + void handleSettings(); + void setDefault(); + void removeDefault(); + void deleteNetwork(); + void cancelSettings(); +};