diff --git a/docs/images/wifi/webserver_files.png b/docs/images/wifi/webserver_files.png new file mode 100644 index 0000000..84835d1 Binary files /dev/null and b/docs/images/wifi/webserver_files.png differ diff --git a/docs/images/wifi/webserver_homepage.png b/docs/images/wifi/webserver_homepage.png new file mode 100644 index 0000000..368e681 Binary files /dev/null and b/docs/images/wifi/webserver_homepage.png differ diff --git a/docs/images/wifi/webserver_upload.png b/docs/images/wifi/webserver_upload.png new file mode 100644 index 0000000..d7295c5 Binary files /dev/null and b/docs/images/wifi/webserver_upload.png differ diff --git a/docs/images/wifi/wifi_connected.jpeg b/docs/images/wifi/wifi_connected.jpeg new file mode 100644 index 0000000..a9d74db Binary files /dev/null and b/docs/images/wifi/wifi_connected.jpeg differ diff --git a/docs/images/wifi/wifi_networks.jpeg b/docs/images/wifi/wifi_networks.jpeg new file mode 100644 index 0000000..9c42dc8 Binary files /dev/null and b/docs/images/wifi/wifi_networks.jpeg differ diff --git a/docs/images/wifi/wifi_password.jpeg b/docs/images/wifi/wifi_password.jpeg new file mode 100644 index 0000000..1ca2fed Binary files /dev/null and b/docs/images/wifi/wifi_password.jpeg differ diff --git a/docs/webserver.md b/docs/webserver.md new file mode 100644 index 0000000..2c96b8e --- /dev/null +++ b/docs/webserver.md @@ -0,0 +1,272 @@ +# Web Server Guide + +This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload EPUB files from your computer or phone. + +## Overview + +CrossPoint Reader includes a built-in web server that allows you to: + +- Upload EPUB files wirelessly from any device on the same WiFi network +- Browse and manage files on your device's SD card +- Create folders to organize your ebooks +- Delete files and folders + +## Prerequisites + +- Your CrossPoint Reader device +- A WiFi network +- A computer, phone, or tablet connected to the **same WiFi network** + +--- + +## Step 1: Accessing the WiFi Screen + +1. From the main menu or file browser, navigate to the **Settings** screen +2. Select the **WiFi** option +3. The device will automatically start scanning for available networks + +--- + +## Step 2: Connecting to WiFi + +### Viewing Available Networks + +Once the scan completes, you'll see a list of available WiFi networks with the following indicators: + +- **Signal strength bars** (`||||`, `|||`, `||`, `|`) - Shows connection quality +- **`*` symbol** - Indicates the network is password-protected (encrypted) +- **`+` symbol** - Indicates you have previously saved credentials for this network + + + +### Selecting a Network + +1. Use the **Left/Right** (or **Volume Up/Down**) buttons to navigate through the network list +2. Press **Confirm** to select the highlighted network + +### Entering Password (for encrypted networks) + +If the network requires a password: + +1. An on-screen keyboard will appear +2. Use the navigation buttons to select characters +3. Press **Confirm** to enter each character +4. When complete, select the **Done** option on the keyboard + + + +**Note:** If you've previously connected to this network, the saved password will be used automatically. + +### Connection Process + +The device will display "Connecting..." while establishing the connection. This typically takes 5-10 seconds. + +### Saving Credentials + +If this is a new network, you'll be prompted to save the password: + +- Select **Yes** to save credentials for automatic connection next time (NOTE: These are stored in plaintext on the device's SD card. Do not use this for sensitive networks.) +- Select **No** to connect without saving + +--- + +## Step 3: Connection Success + +Once connected, the screen will display: + +- **Network name** (SSID) +- **IP Address** (e.g., `192.168.1.102`) +- **Web server URL** (e.g., `http://192.168.1.102/`) + + + +**Important:** Make note of the IP address - you'll need this to access the web interface from your computer or phone. + +--- + +## Step 4: Accessing the Web Interface + +### From a Computer + +1. Ensure your computer is connected to the **same WiFi network** as your CrossPoint Reader +2. Open any web browser (Chrome is recommended) +3. Type the IP address shown on your device into the browser's address bar + - Example: `http://192.168.1.102/` +4. Press Enter + +### From a Phone or Tablet + +1. Ensure your phone/tablet is connected to the **same WiFi network** as your CrossPoint Reader +2. Open your mobile browser (Safari, Chrome, etc.) +3. Type the IP address into the address bar + - Example: `http://192.168.1.102/` +4. Tap Go + +--- + +## Step 5: Using the Web Interface + +### Home Page + +The home page displays: + +- Device status and version information +- WiFi connection status +- Current IP address +- Available memory + +Navigation links: + +- **Home** - Returns to the status page +- **File Manager** - Access file management features + + + +### File Manager + +Click **File Manager** to access file management features. + +#### Browsing Files + +- The file manager displays all files and folders on your SD card +- **Folders** are highlighted in yellow with a 📁 icon +- **EPUB files** are highlighted in green with a 📗 icon +- Click on a folder name to navigate into it +- Use the breadcrumb navigation at the top to go back to parent folders + + + +#### Uploading EPUB Files + +1. Click the **+ Add** button in the top-right corner +2. Select **Upload eBook** from the dropdown menu +3. Click **Choose File** and select an `.epub` file from your device +4. Click **Upload** +5. A progress bar will show the upload status +6. The page will automatically refresh when the upload is complete + +**Note:** Only `.epub` files are accepted. Other file types will be rejected. + + + +#### Creating Folders + +1. Click the **+ Add** button in the top-right corner +2. Select **New Folder** from the dropdown menu +3. Enter a folder name (letters, numbers, underscores, and hyphens only) +4. Click **Create Folder** + +This is useful for organizing your ebooks by genre, author, or series. + +#### Deleting Files and Folders + +1. Click the **🗑️** (trash) icon next to any file or folder +2. Confirm the deletion in the popup dialog +3. Click **Delete** to permanently remove the item + +**Warning:** Deletion is permanent and cannot be undone! + +**Note:** Folders must be empty before they can be deleted. + +--- + +## Troubleshooting + +### Cannot See the Device on the Network + +**Problem:** Browser shows "Cannot connect" or "Site can't be reached" + +**Solutions:** + +1. Verify both devices are on the **same WiFi network** + - Check your computer/phone WiFi settings + - Confirm the CrossPoint Reader shows "Connected" status +2. Double-check the IP address + - Make sure you typed it correctly + - Include `http://` at the beginning +3. Try disabling VPN if you're using one +4. Some networks have "client isolation" enabled - check with your network administrator + +### Connection Drops or Times Out + +**Problem:** WiFi connection is unstable + +**Solutions:** + +1. Move closer to the WiFi router +2. Check signal strength on the device (should be at least `||` or better) +3. Avoid interference from other devices +4. Try a different WiFi network if available + +### Upload Fails + +**Problem:** File upload doesn't complete or shows an error + +**Solutions:** + +1. Ensure the file is a valid `.epub` file +2. Check that the SD card has enough free space +3. Try uploading a smaller file first to test +4. Refresh the browser page and try again + +### Saved Password Not Working + +**Problem:** Device fails to connect with saved credentials + +**Solutions:** + +1. When connection fails, you'll be prompted to "Forget Network" +2. Select **Yes** to remove the saved password +3. Reconnect and enter the password again +4. Choose to save the new password + +--- + +## Security Notes + +- The web server runs on port 80 (standard HTTP) +- **No authentication is required** - anyone on the same network can access the interface +- The web server is only accessible while the WiFi screen shows "Connected" +- The web server automatically stops when you exit the WiFi screen +- For security, only use on trusted private networks + +--- + +## Technical Details + +- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n) +- **Web Server Port:** 80 (HTTP) +- **Maximum Upload Size:** Limited by available SD card space +- **Supported File Format:** `.epub` only +- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge) + +--- + +## Tips and Best Practices + +1. **Organize with folders** - Create folders before uploading to keep your library organized +2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads +3. **Upload multiple files** - You can upload files one at a time; the page refreshes after each upload +4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction") +5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future +6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery + +--- + +## Exiting WiFi Mode + +When you're finished uploading files: + +1. Press the **Back** button on your CrossPoint Reader +2. The web server will automatically stop +3. WiFi will disconnect to conserve battery +4. You'll return to the previous screen + +Your uploaded files will be immediately available in the file browser! + +--- + +## Related Documentation + +- [User Guide](../USER_GUIDE.md) - General device operation +- [README](../README.md) - Project overview and features diff --git a/src/CrossPointWebServer.cpp b/src/CrossPointWebServer.cpp new file mode 100644 index 0000000..daa6c30 --- /dev/null +++ b/src/CrossPointWebServer.cpp @@ -0,0 +1,1475 @@ +#include "CrossPointWebServer.h" + +#include +#include + +#include + +#include "config.h" + +// Global instance +CrossPointWebServer crossPointWebServer; + +// Folders/files to hide from the web interface file browser +// Note: Items starting with "." are automatically hidden +static const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; +static const size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); + +// Helper function to escape HTML special characters to prevent XSS +static String escapeHtml(const String& input) { + String output; + output.reserve(input.length() * 1.1); // Pre-allocate with some extra space + + for (size_t i = 0; i < input.length(); i++) { + char c = input.charAt(i); + switch (c) { + case '&': + output += "&"; + break; + case '<': + output += "<"; + break; + case '>': + output += ">"; + break; + case '"': + output += """; + break; + case '\'': + output += "'"; + break; + default: + output += c; + break; + } + } + return output; +} + +// HTML page template +static const char* HTML_PAGE = R"rawliteral( + + + + + + CrossPoint Reader + + + +

📚 CrossPoint Reader

+ + + +
+

Device Status

+
+ Version + %VERSION% +
+
+ WiFi Status + Connected +
+
+ IP Address + %IP_ADDRESS% +
+
+ Free Memory + %FREE_HEAP% bytes +
+
+ +
+

+ CrossPoint E-Reader • Open Source +

+
+ + +)rawliteral"; + +// File listing page template +static const char* FILES_PAGE_HEADER = R"rawliteral( + + + + + + CrossPoint Reader - Files + + + + +)rawliteral"; + +static const char* FILES_PAGE_FOOTER = R"rawliteral( +
+

+ CrossPoint E-Reader • Open Source +

+
+ + + + + + + + + + + + + +)rawliteral"; + +CrossPointWebServer::CrossPointWebServer() {} + +CrossPointWebServer::~CrossPointWebServer() { stop(); } + +void CrossPointWebServer::begin() { + if (running) { + Serial.printf("[%lu] [WEB] Web server already running\n", millis()); + return; + } + + if (WiFi.status() != WL_CONNECTED) { + Serial.printf("[%lu] [WEB] Cannot start webserver - WiFi not connected\n", millis()); + return; + } + + Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); + server = new WebServer(port); + + if (!server) { + Serial.printf("[%lu] [WEB] Failed to create WebServer!\n", millis()); + return; + } + + // Setup routes + Serial.printf("[%lu] [WEB] Setting up routes...\n", millis()); + server->on("/", HTTP_GET, [this]() { handleRoot(); }); + server->on("/status", HTTP_GET, [this]() { handleStatus(); }); + server->on("/files", HTTP_GET, [this]() { handleFileList(); }); + + // Upload endpoint with special handling for multipart form data + server->on("/upload", HTTP_POST, [this]() { handleUploadPost(); }, [this]() { handleUpload(); }); + + // Create folder endpoint + server->on("/mkdir", HTTP_POST, [this]() { handleCreateFolder(); }); + + // Delete file/folder endpoint + server->on("/delete", HTTP_POST, [this]() { handleDelete(); }); + + server->onNotFound([this]() { handleNotFound(); }); + + server->begin(); + running = true; + + Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); + Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), WiFi.localIP().toString().c_str()); +} + +void CrossPointWebServer::stop() { + if (!running || !server) { + return; + } + + server->stop(); + delete server; + server = nullptr; + running = false; + + Serial.printf("[%lu] [WEB] Web server stopped\n", millis()); +} + +void CrossPointWebServer::handleClient() { + static unsigned long lastDebugPrint = 0; + if (running && server) { + // Print debug every 10 seconds to confirm handleClient is being called + if (millis() - lastDebugPrint > 10000) { + Serial.printf("[%lu] [WEB] handleClient active, server running on port %d\n", millis(), port); + lastDebugPrint = millis(); + } + server->handleClient(); + } +} + +void CrossPointWebServer::handleRoot() { + String html = HTML_PAGE; + + // Replace placeholders with actual values + html.replace("%VERSION%", CROSSPOINT_VERSION); + html.replace("%IP_ADDRESS%", WiFi.localIP().toString()); + html.replace("%FREE_HEAP%", String(ESP.getFreeHeap())); + + server->send(200, "text/html", html); + Serial.printf("[%lu] [WEB] Served root page\n", millis()); +} + +void CrossPointWebServer::handleNotFound() { + String message = "404 Not Found\n\n"; + message += "URI: " + server->uri() + "\n"; + server->send(404, "text/plain", message); +} + +void CrossPointWebServer::handleStatus() { + String json = "{"; + json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\","; + json += "\"ip\":\"" + WiFi.localIP().toString() + "\","; + json += "\"rssi\":" + String(WiFi.RSSI()) + ","; + json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ","; + json += "\"uptime\":" + String(millis() / 1000); + json += "}"; + + server->send(200, "application/json", json); +} + +std::vector CrossPointWebServer::scanFiles(const char* path) { + std::vector files; + + File root = SD.open(path); + if (!root) { + Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path); + return files; + } + + if (!root.isDirectory()) { + Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path); + root.close(); + return files; + } + + Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path); + + File file = root.openNextFile(); + while (file) { + String fileName = String(file.name()); + + // Skip hidden items (starting with ".") + bool shouldHide = fileName.startsWith("."); + + // Check against explicitly hidden items list + if (!shouldHide) { + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (fileName.equals(HIDDEN_ITEMS[i])) { + shouldHide = true; + break; + } + } + } + + if (!shouldHide) { + FileInfo info; + info.name = fileName; + info.isDirectory = file.isDirectory(); + + if (info.isDirectory) { + info.size = 0; + info.isEpub = false; + } else { + info.size = file.size(); + info.isEpub = isEpubFile(info.name); + } + + files.push_back(info); + } + + file.close(); + file = root.openNextFile(); + } + root.close(); + + Serial.printf("[%lu] [WEB] Found %d items (files and folders)\n", millis(), files.size()); + return files; +} + +String CrossPointWebServer::formatFileSize(size_t bytes) { + if (bytes < 1024) { + return String(bytes) + " B"; + } else if (bytes < 1024 * 1024) { + return String(bytes / 1024.0, 1) + " KB"; + } else { + return String(bytes / (1024.0 * 1024.0), 1) + " MB"; + } +} + +bool CrossPointWebServer::isEpubFile(const String& filename) { + String lower = filename; + lower.toLowerCase(); + return lower.endsWith(".epub"); +} + +void CrossPointWebServer::handleFileList() { + String html = FILES_PAGE_HEADER; + + // Get current path from query string (default to root) + String currentPath = "/"; + if (server->hasArg("path")) { + currentPath = server->arg("path"); + // Ensure path starts with / + if (!currentPath.startsWith("/")) { + currentPath = "/" + currentPath; + } + // Remove trailing slash unless it's root + if (currentPath.length() > 1 && currentPath.endsWith("/")) { + currentPath = currentPath.substring(0, currentPath.length() - 1); + } + } + + // Get message from query string if present + if (server->hasArg("msg")) { + String msg = escapeHtml(server->arg("msg")); + String msgType = server->hasArg("type") ? escapeHtml(server->arg("type")) : "success"; + html += "
" + msg + "
"; + } + + // Hidden input to store current path for JavaScript + html += ""; + + // Scan files in current path first (we need counts for the header) + std::vector files = scanFiles(currentPath.c_str()); + + // Count items + int epubCount = 0; + int folderCount = 0; + size_t totalSize = 0; + for (const auto& file : files) { + if (file.isDirectory) { + folderCount++; + } else { + if (file.isEpub) epubCount++; + totalSize += file.size; + } + } + + // Page header with inline breadcrumb and +Add dropdown + html += "
"; + html += "
"; + html += "

📁 File Manager

"; + + // Inline breadcrumb + html += "
"; + html += "/"; + + if (currentPath == "/") { + html += "🏠"; + } else { + html += "🏠"; + String pathParts = currentPath.substring(1); // Remove leading / + String buildPath = ""; + int start = 0; + int end = pathParts.indexOf('/'); + + while (start < (int)pathParts.length()) { + String part; + if (end == -1) { + part = pathParts.substring(start); + buildPath += "/" + part; + html += "/" + escapeHtml(part) + ""; + break; + } else { + part = pathParts.substring(start, end); + buildPath += "/" + part; + html += "/" + escapeHtml(part) + ""; + start = end + 1; + end = pathParts.indexOf('/', start); + } + } + } + html += "
"; + html += "
"; + + // +Add dropdown button + html += "
"; + html += ""; + html += "
"; + html += ""; + html += "
"; + html += ""; + html += "
"; + html += "
"; + + html += "
"; // end page-header + + // Contents card with inline summary + html += "
"; + + // Contents header with inline stats + html += "
"; + html += "

Contents

"; + html += ""; + html += String(folderCount) + " folder" + (folderCount != 1 ? "s" : "") + ", "; + html += String(files.size() - folderCount) + " file" + ((files.size() - folderCount) != 1 ? "s" : "") + ", "; + html += formatFileSize(totalSize); + html += ""; + html += "
"; + + if (files.empty()) { + html += "
This folder is empty
"; + } else { + html += ""; + html += ""; + + // Sort files: folders first, then epub files, then other files, alphabetically within each group + std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) { + // Folders come first + if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory; + // Then sort by epub status (epubs first among files) + if (!a.isDirectory && !b.isDirectory) { + if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub; + } + // Then alphabetically + return a.name < b.name; + }); + + for (const auto& file : files) { + String rowClass; + String icon; + String badge; + String typeStr; + String sizeStr; + + if (file.isDirectory) { + rowClass = "folder-row"; + icon = "📁"; + badge = "FOLDER"; + typeStr = "Folder"; + sizeStr = "-"; + + // Build the path to this folder + String folderPath = currentPath; + if (!folderPath.endsWith("/")) folderPath += "/"; + folderPath += file.name; + + html += ""; + html += ""; + html += ""; + html += ""; + // Escape quotes for JavaScript string + String escapedName = file.name; + escapedName.replace("'", "\\'"); + String escapedPath = folderPath; + escapedPath.replace("'", "\\'"); + html += ""; + html += ""; + } else { + rowClass = file.isEpub ? "epub-file" : ""; + icon = file.isEpub ? "📗" : "📄"; + badge = file.isEpub ? "EPUB" : ""; + String ext = file.name.substring(file.name.lastIndexOf('.') + 1); + ext.toUpperCase(); + typeStr = ext; + sizeStr = formatFileSize(file.size); + + // Build file path for delete + String filePath = currentPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += file.name; + + html += ""; + html += ""; + html += ""; + html += ""; + // Escape quotes for JavaScript string + String escapedName = file.name; + escapedName.replace("'", "\\'"); + String escapedPath = filePath; + escapedPath.replace("'", "\\'"); + html += ""; + html += ""; + } + } + + html += "
NameTypeSizeActions
" + icon + ""; + html += "" + escapeHtml(file.name) + "" + + badge + "" + typeStr + "" + sizeStr + "
" + icon + "" + escapeHtml(file.name) + badge + "" + typeStr + "" + sizeStr + "
"; + } + + html += "
"; + + html += FILES_PAGE_FOOTER; + + server->send(200, "text/html", html); + Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); +} + +// Static variables for upload handling +static File uploadFile; +static String uploadFileName; +static String uploadPath = "/"; +static size_t uploadSize = 0; +static bool uploadSuccess = false; +static String uploadError = ""; + +void CrossPointWebServer::handleUpload() { + HTTPUpload& upload = server->upload(); + + if (upload.status == UPLOAD_FILE_START) { + uploadFileName = upload.filename; + uploadSize = 0; + uploadSuccess = false; + uploadError = ""; + + // Get upload path from query parameter (defaults to root if not specified) + // Note: We use query parameter instead of form data because multipart form + // fields aren't available until after file upload completes + if (server->hasArg("path")) { + uploadPath = server->arg("path"); + // Ensure path starts with / + if (!uploadPath.startsWith("/")) { + uploadPath = "/" + uploadPath; + } + // Remove trailing slash unless it's root + if (uploadPath.length() > 1 && uploadPath.endsWith("/")) { + uploadPath = uploadPath.substring(0, uploadPath.length() - 1); + } + } else { + uploadPath = "/"; + } + + Serial.printf("[%lu] [WEB] Upload start: %s to path: %s\n", millis(), uploadFileName.c_str(), uploadPath.c_str()); + + // Validate file extension + if (!isEpubFile(uploadFileName)) { + uploadError = "Only .epub files are allowed"; + Serial.printf("[%lu] [WEB] Upload rejected - not an epub file\n", millis()); + return; + } + + // Create file path + String filePath = uploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += uploadFileName; + + // Check if file already exists + if (SD.exists(filePath.c_str())) { + Serial.printf("[%lu] [WEB] Overwriting existing file: %s\n", millis(), filePath.c_str()); + SD.remove(filePath.c_str()); + } + + // Open file for writing + uploadFile = SD.open(filePath.c_str(), FILE_WRITE); + if (!uploadFile) { + uploadError = "Failed to create file on SD card"; + Serial.printf("[%lu] [WEB] Failed to create file: %s\n", millis(), filePath.c_str()); + return; + } + + Serial.printf("[%lu] [WEB] File created: %s\n", millis(), filePath.c_str()); + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (uploadFile && uploadError.isEmpty()) { + size_t written = uploadFile.write(upload.buf, upload.currentSize); + if (written != upload.currentSize) { + uploadError = "Failed to write to SD card - disk may be full"; + uploadFile.close(); + Serial.printf("[%lu] [WEB] Write error - expected %d, wrote %d\n", millis(), upload.currentSize, written); + } else { + uploadSize += written; + } + } + } else if (upload.status == UPLOAD_FILE_END) { + if (uploadFile) { + uploadFile.close(); + + if (uploadError.isEmpty()) { + uploadSuccess = true; + Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize); + } + } + } else if (upload.status == UPLOAD_FILE_ABORTED) { + if (uploadFile) { + uploadFile.close(); + // Try to delete the incomplete file + String filePath = uploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += uploadFileName; + SD.remove(filePath.c_str()); + } + uploadError = "Upload aborted"; + Serial.printf("[%lu] [WEB] Upload aborted\n", millis()); + } +} + +void CrossPointWebServer::handleUploadPost() { + if (uploadSuccess) { + server->send(200, "text/plain", "File uploaded successfully: " + uploadFileName); + } else { + String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError; + server->send(400, "text/plain", error); + } +} + +void CrossPointWebServer::handleCreateFolder() { + // Get folder name from form data + if (!server->hasArg("name")) { + server->send(400, "text/plain", "Missing folder name"); + return; + } + + String folderName = server->arg("name"); + + // Validate folder name + if (folderName.isEmpty()) { + server->send(400, "text/plain", "Folder name cannot be empty"); + return; + } + + // Get parent path + String parentPath = "/"; + if (server->hasArg("path")) { + parentPath = server->arg("path"); + if (!parentPath.startsWith("/")) { + parentPath = "/" + parentPath; + } + if (parentPath.length() > 1 && parentPath.endsWith("/")) { + parentPath = parentPath.substring(0, parentPath.length() - 1); + } + } + + // Build full folder path + String folderPath = parentPath; + if (!folderPath.endsWith("/")) folderPath += "/"; + folderPath += folderName; + + Serial.printf("[%lu] [WEB] Creating folder: %s\n", millis(), folderPath.c_str()); + + // Check if already exists + if (SD.exists(folderPath.c_str())) { + server->send(400, "text/plain", "Folder already exists"); + return; + } + + // Create the folder + if (SD.mkdir(folderPath.c_str())) { + Serial.printf("[%lu] [WEB] Folder created successfully: %s\n", millis(), folderPath.c_str()); + server->send(200, "text/plain", "Folder created: " + folderName); + } else { + Serial.printf("[%lu] [WEB] Failed to create folder: %s\n", millis(), folderPath.c_str()); + server->send(500, "text/plain", "Failed to create folder"); + } +} + +void CrossPointWebServer::handleDelete() { + // Get path from form data + if (!server->hasArg("path")) { + server->send(400, "text/plain", "Missing path"); + return; + } + + String itemPath = server->arg("path"); + String itemType = server->hasArg("type") ? server->arg("type") : "file"; + + // Validate path + if (itemPath.isEmpty() || itemPath == "/") { + server->send(400, "text/plain", "Cannot delete root directory"); + return; + } + + // Ensure path starts with / + if (!itemPath.startsWith("/")) { + itemPath = "/" + itemPath; + } + + // Security check: prevent deletion of protected items + String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + + // Check if item starts with a dot (hidden/system file) + if (itemName.startsWith(".")) { + Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), itemPath.c_str()); + server->send(403, "text/plain", "Cannot delete system files"); + return; + } + + // Check against explicitly protected items + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (itemName.equals(HIDDEN_ITEMS[i])) { + Serial.printf("[%lu] [WEB] Delete rejected - protected item: %s\n", millis(), itemPath.c_str()); + server->send(403, "text/plain", "Cannot delete protected items"); + return; + } + } + + // Check if item exists + if (!SD.exists(itemPath.c_str())) { + Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str()); + server->send(404, "text/plain", "Item not found"); + return; + } + + Serial.printf("[%lu] [WEB] Attempting to delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str()); + + bool success = false; + + if (itemType == "folder") { + // For folders, try to remove (will fail if not empty) + File dir = SD.open(itemPath.c_str()); + if (dir && dir.isDirectory()) { + // Check if folder is empty + File entry = dir.openNextFile(); + if (entry) { + // Folder is not empty + entry.close(); + dir.close(); + Serial.printf("[%lu] [WEB] Delete failed - folder not empty: %s\n", millis(), itemPath.c_str()); + server->send(400, "text/plain", "Folder is not empty. Delete contents first."); + return; + } + dir.close(); + } + success = SD.rmdir(itemPath.c_str()); + } else { + // For files, use remove + success = SD.remove(itemPath.c_str()); + } + + if (success) { + Serial.printf("[%lu] [WEB] Successfully deleted: %s\n", millis(), itemPath.c_str()); + server->send(200, "text/plain", "Deleted successfully"); + } else { + Serial.printf("[%lu] [WEB] Failed to delete: %s\n", millis(), itemPath.c_str()); + server->send(500, "text/plain", "Failed to delete item"); + } +} diff --git a/src/CrossPointWebServer.h b/src/CrossPointWebServer.h new file mode 100644 index 0000000..73b4e50 --- /dev/null +++ b/src/CrossPointWebServer.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include +#include +#include + +// Structure to hold file information +struct FileInfo { + String name; + size_t size; + bool isEpub; + bool isDirectory; +}; + +class CrossPointWebServer { + public: + CrossPointWebServer(); + ~CrossPointWebServer(); + + // Start the web server (call after WiFi is connected) + void begin(); + + // Stop the web server + void stop(); + + // Call this periodically to handle client requests + void handleClient(); + + // Check if server is running + bool isRunning() const { return running; } + + // Get the port number + uint16_t getPort() const { return port; } + + private: + WebServer* server = nullptr; + bool running = false; + uint16_t port = 80; + + // File scanning + std::vector scanFiles(const char* path = "/"); + String formatFileSize(size_t bytes); + bool isEpubFile(const String& filename); + + // Request handlers + void handleRoot(); + void handleNotFound(); + void handleStatus(); + void handleFileList(); + void handleUpload(); + void handleUploadPost(); + void handleCreateFolder(); + void handleDelete(); +}; + +// Global instance +extern CrossPointWebServer crossPointWebServer; diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp new file mode 100644 index 0000000..8070bc4 --- /dev/null +++ b/src/WifiCredentialStore.cpp @@ -0,0 +1,158 @@ +#include "WifiCredentialStore.h" + +#include +#include +#include + +#include + +// Initialize the static instance +WifiCredentialStore WifiCredentialStore::instance; + +// File format version +constexpr uint8_t WIFI_FILE_VERSION = 1; + +// WiFi credentials file path +constexpr char WIFI_FILE[] = "/sd/.crosspoint/wifi.bin"; + +// Obfuscation key - "CrossPoint" in ASCII +// This is NOT cryptographic security, just prevents casual file reading +constexpr uint8_t OBFUSCATION_KEY[] = {0x43, 0x72, 0x6F, 0x73, 0x73, 0x50, 0x6F, 0x69, 0x6E, 0x74}; +constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY); + +void WifiCredentialStore::obfuscate(std::string& data) const { + Serial.printf("[%lu] [WCS] Obfuscating/deobfuscating %zu bytes\n", millis(), data.size()); + for (size_t i = 0; i < data.size(); i++) { + data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH]; + } +} + +bool WifiCredentialStore::saveToFile() const { + // Make sure the directory exists + SD.mkdir("/.crosspoint"); + + std::ofstream file(WIFI_FILE, std::ios::binary); + if (!file) { + Serial.printf("[%lu] [WCS] Failed to open wifi.bin for writing\n", millis()); + return false; + } + + // Write header + serialization::writePod(file, WIFI_FILE_VERSION); + serialization::writePod(file, static_cast(credentials.size())); + + // Write each credential + for (const auto& cred : credentials) { + // Write SSID (plaintext - not sensitive) + serialization::writeString(file, cred.ssid); + Serial.printf("[%lu] [WCS] Saving SSID: %s, password length: %zu\n", millis(), cred.ssid.c_str(), + cred.password.size()); + + // Write password (obfuscated) + std::string obfuscatedPwd = cred.password; + obfuscate(obfuscatedPwd); + serialization::writeString(file, obfuscatedPwd); + } + + file.close(); + Serial.printf("[%lu] [WCS] Saved %zu WiFi credentials to file\n", millis(), credentials.size()); + return true; +} + +bool WifiCredentialStore::loadFromFile() { + if (!SD.exists(WIFI_FILE + 3)) { // +3 to skip "/sd" prefix + Serial.printf("[%lu] [WCS] WiFi credentials file does not exist\n", millis()); + return false; + } + + std::ifstream file(WIFI_FILE, std::ios::binary); + if (!file) { + Serial.printf("[%lu] [WCS] Failed to open wifi.bin for reading\n", millis()); + return false; + } + + // Read and verify version + uint8_t version; + serialization::readPod(file, version); + if (version != WIFI_FILE_VERSION) { + Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version); + file.close(); + return false; + } + + // Read credential count + uint8_t count; + serialization::readPod(file, count); + + // Read credentials + credentials.clear(); + for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) { + WifiCredential cred; + + // Read SSID + serialization::readString(file, cred.ssid); + + // Read and deobfuscate password + serialization::readString(file, cred.password); + Serial.printf("[%lu] [WCS] Loaded SSID: %s, obfuscated password length: %zu\n", millis(), cred.ssid.c_str(), + cred.password.size()); + obfuscate(cred.password); // XOR is symmetric, so same function deobfuscates + Serial.printf("[%lu] [WCS] After deobfuscation, password length: %zu\n", millis(), cred.password.size()); + + credentials.push_back(cred); + } + + file.close(); + Serial.printf("[%lu] [WCS] Loaded %zu WiFi credentials from file\n", millis(), credentials.size()); + return true; +} + +bool WifiCredentialStore::addCredential(const std::string& ssid, const std::string& password) { + // Check if this SSID already exists and update it + for (auto& cred : credentials) { + if (cred.ssid == ssid) { + cred.password = password; + Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str()); + return saveToFile(); + } + } + + // Check if we've reached the limit + if (credentials.size() >= MAX_NETWORKS) { + Serial.printf("[%lu] [WCS] Cannot add more networks, limit of %zu reached\n", millis(), MAX_NETWORKS); + return false; + } + + // Add new credential + credentials.push_back({ssid, password}); + Serial.printf("[%lu] [WCS] Added credentials for: %s\n", millis(), ssid.c_str()); + return saveToFile(); +} + +bool WifiCredentialStore::removeCredential(const std::string& ssid) { + for (auto it = credentials.begin(); it != credentials.end(); ++it) { + if (it->ssid == ssid) { + credentials.erase(it); + Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); + return saveToFile(); + } + } + return false; // Not found +} + +const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssid) const { + for (const auto& cred : credentials) { + if (cred.ssid == ssid) { + return &cred; + } + } + return nullptr; +} + +bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; } + +void WifiCredentialStore::clearAll() { + credentials.clear(); + saveToFile(); + Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis()); +} diff --git a/src/WifiCredentialStore.h b/src/WifiCredentialStore.h new file mode 100644 index 0000000..0004dc9 --- /dev/null +++ b/src/WifiCredentialStore.h @@ -0,0 +1,56 @@ +#pragma once +#include +#include + +struct WifiCredential { + std::string ssid; + std::string password; // Stored obfuscated in file +}; + +/** + * Singleton class for storing WiFi credentials on the SD card. + * Credentials are stored in /sd/.crosspoint/wifi.bin with basic + * XOR obfuscation to prevent casual reading (not cryptographically secure). + */ +class WifiCredentialStore { + private: + static WifiCredentialStore instance; + std::vector credentials; + + static constexpr size_t MAX_NETWORKS = 8; + + // Private constructor for singleton + WifiCredentialStore() = default; + + // XOR obfuscation (symmetric - same for encode/decode) + void obfuscate(std::string& data) const; + + public: + // Delete copy constructor and assignment + WifiCredentialStore(const WifiCredentialStore&) = delete; + WifiCredentialStore& operator=(const WifiCredentialStore&) = delete; + + // Get singleton instance + static WifiCredentialStore& getInstance() { return instance; } + + // Save/load from SD card + bool saveToFile() const; + bool loadFromFile(); + + // Credential management + bool addCredential(const std::string& ssid, const std::string& password); + bool removeCredential(const std::string& ssid); + const WifiCredential* findCredential(const std::string& ssid) const; + + // Get all stored credentials (for UI display) + const std::vector& getCredentials() const { return credentials; } + + // Check if a network is saved + bool hasSavedCredential(const std::string& ssid) const; + + // Clear all credentials + void clearAll(); +}; + +// Helper macro to access credentials store +#define WIFI_STORE WifiCredentialStore::getInstance() diff --git a/src/main.cpp b/src/main.cpp index eb3bc0b..97b856a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include "Battery.h" #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "CrossPointWebServer.h" #include "config.h" #include "screens/BootLogoScreen.h" #include "screens/EpubReaderScreen.h" @@ -23,6 +25,7 @@ #include "screens/FullScreenMessageScreen.h" #include "screens/SettingsScreen.h" #include "screens/SleepScreen.h" +#include "screens/WifiScreen.h" #define SPI_FQ 40000000 // Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) @@ -169,9 +172,16 @@ void onSelectEpubFile(const std::string& path) { } } +void onGoToSettings(); + +void onGoToWifi() { + exitScreen(); + enterNewScreen(new WifiScreen(renderer, inputManager, onGoToSettings)); +} + void onGoToSettings() { exitScreen(); - enterNewScreen(new SettingsScreen(renderer, inputManager, onGoHome)); + enterNewScreen(new SettingsScreen(renderer, inputManager, onGoHome, onGoToWifi)); } void onGoHome() { @@ -238,6 +248,11 @@ void loop() { lastMemPrint = millis(); } + // Handle web server clients if WiFi is connected + if (WiFi.status() == WL_CONNECTED) { + crossPointWebServer.handleClient(); + } + inputManager.update(); // Check for any user activity (button press or release) diff --git a/src/screens/OnScreenKeyboard.cpp b/src/screens/OnScreenKeyboard.cpp new file mode 100644 index 0000000..fcbbbba --- /dev/null +++ b/src/screens/OnScreenKeyboard.cpp @@ -0,0 +1,304 @@ +#include "OnScreenKeyboard.h" + +#include "config.h" + +// Keyboard layouts - lowercase +const char* const OnScreenKeyboard::keyboard[NUM_ROWS] = { + "`1234567890-=", "qwertyuiop[]\\", "asdfghjkl;'", "zxcvbnm,./", + "^ _____?", "^ _____ 0 && text.length() > maxLength) { + text = text.substr(0, maxLength); + } +} + +void OnScreenKeyboard::reset(const std::string& newTitle, const std::string& newInitialText) { + if (!newTitle.empty()) { + title = newTitle; + } + text = newInitialText; + selectedRow = 0; + selectedCol = 0; + shiftActive = false; + complete = false; + cancelled = false; +} + +int OnScreenKeyboard::getRowLength(int row) const { + if (row < 0 || row >= NUM_ROWS) return 0; + + // Return actual length of each row based on keyboard layout + switch (row) { + case 0: + return 13; // `1234567890-= + case 1: + return 13; // qwertyuiop[]backslash + case 2: + return 11; // asdfghjkl;' + case 3: + return 10; // zxcvbnm,./ + case 4: + return 10; // ^, space (5 wide), backspace, OK (2 wide) + default: + return 0; + } +} + +char OnScreenKeyboard::getSelectedChar() const { + const char* const* layout = shiftActive ? keyboardShift : keyboard; + + if (selectedRow < 0 || selectedRow >= NUM_ROWS) return '\0'; + if (selectedCol < 0 || selectedCol >= getRowLength(selectedRow)) return '\0'; + + return layout[selectedRow][selectedCol]; +} + +void OnScreenKeyboard::handleKeyPress() { + // Handle special row (bottom row with shift, space, backspace, done) + if (selectedRow == SHIFT_ROW) { + if (selectedCol == SHIFT_COL) { + // Shift toggle + shiftActive = !shiftActive; + return; + } + + if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) { + // Space bar + if (maxLength == 0 || text.length() < maxLength) { + text += ' '; + } + return; + } + + if (selectedCol == BACKSPACE_COL) { + // Backspace + if (!text.empty()) { + text.pop_back(); + } + return; + } + + if (selectedCol >= DONE_COL) { + // Done button + complete = true; + if (onComplete) { + onComplete(text); + } + return; + } + } + + // Regular character + char c = getSelectedChar(); + if (c != '\0' && c != '^' && c != '_' && c != '<') { + if (maxLength == 0 || text.length() < maxLength) { + text += c; + // Auto-disable shift after typing a letter + if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { + shiftActive = false; + } + } + } +} + +bool OnScreenKeyboard::handleInput() { + if (complete || cancelled) { + return false; + } + + bool handled = false; + + // Navigation + if (inputManager.wasPressed(InputManager::BTN_UP)) { + if (selectedRow > 0) { + selectedRow--; + // Clamp column to valid range for new row + int maxCol = getRowLength(selectedRow) - 1; + if (selectedCol > maxCol) selectedCol = maxCol; + } + handled = true; + } else if (inputManager.wasPressed(InputManager::BTN_DOWN)) { + if (selectedRow < NUM_ROWS - 1) { + selectedRow++; + int maxCol = getRowLength(selectedRow) - 1; + if (selectedCol > maxCol) selectedCol = maxCol; + } + handled = true; + } else if (inputManager.wasPressed(InputManager::BTN_LEFT)) { + if (selectedCol > 0) { + selectedCol--; + } else if (selectedRow > 0) { + // Wrap to previous row + selectedRow--; + selectedCol = getRowLength(selectedRow) - 1; + } + handled = true; + } else if (inputManager.wasPressed(InputManager::BTN_RIGHT)) { + int maxCol = getRowLength(selectedRow) - 1; + if (selectedCol < maxCol) { + selectedCol++; + } else if (selectedRow < NUM_ROWS - 1) { + // Wrap to next row + selectedRow++; + selectedCol = 0; + } + handled = true; + } + + // Selection + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + handleKeyPress(); + handled = true; + } + + // Cancel + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + cancelled = true; + if (onCancel) { + onCancel(); + } + handled = true; + } + + return handled; +} + +void OnScreenKeyboard::render(int startY) const { + const auto pageWidth = GfxRenderer::getScreenWidth(); + + // Draw title + renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR); + + // Draw input field + int inputY = startY + 22; + renderer.drawText(UI_FONT_ID, 10, inputY, "["); + + std::string displayText; + if (isPassword) { + displayText = std::string(text.length(), '*'); + } else { + displayText = text; + } + + // Show cursor at end + displayText += "_"; + + // Truncate if too long for display - use actual character width from font + int charWidth = renderer.getSpaceWidth(UI_FONT_ID); + if (charWidth < 1) charWidth = 8; // Fallback to approximate width + int maxDisplayLen = (pageWidth - 40) / charWidth; + if (displayText.length() > static_cast(maxDisplayLen)) { + displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3); + } + + renderer.drawText(UI_FONT_ID, 20, inputY, displayText.c_str()); + renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]"); + + // Draw keyboard - use compact spacing to fit 5 rows on screen + int keyboardStartY = inputY + 25; + const int keyWidth = 18; + const int keyHeight = 18; + const int keySpacing = 3; + + const char* const* layout = shiftActive ? keyboardShift : keyboard; + + // Calculate left margin to center the longest row (13 keys) + int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); + int leftMargin = (pageWidth - maxRowWidth) / 2; + + for (int row = 0; row < NUM_ROWS; row++) { + int rowY = keyboardStartY + row * (keyHeight + keySpacing); + + // Left-align all rows for consistent navigation + int startX = leftMargin; + + // Handle bottom row (row 4) specially with proper multi-column keys + if (row == 4) { + // Bottom row layout: CAPS (2 cols) | SPACE (5 cols) | <- (2 cols) | OK (2 cols) + // Total: 11 visual columns, but we use logical positions for selection + + int currentX = startX; + + // CAPS key (logical col 0, spans 2 key widths) + int capsWidth = 2 * keyWidth + keySpacing; + bool capsSelected = (selectedRow == 4 && selectedCol == SHIFT_COL); + if (capsSelected) { + renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); + renderer.drawText(UI_FONT_ID, currentX + capsWidth - 4, rowY, "]"); + } + renderer.drawText(UI_FONT_ID, currentX + 2, rowY, shiftActive ? "CAPS" : "caps"); + currentX += capsWidth + keySpacing; + + // Space bar (logical cols 2-6, spans 5 key widths) + int spaceWidth = 5 * keyWidth + 4 * keySpacing; + bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); + if (spaceSelected) { + renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); + renderer.drawText(UI_FONT_ID, currentX + spaceWidth - 4, rowY, "]"); + } + // Draw centered underscores for space bar + int spaceTextX = currentX + (spaceWidth / 2) - 12; + renderer.drawText(UI_FONT_ID, spaceTextX, rowY, "_____"); + currentX += spaceWidth + keySpacing; + + // Backspace key (logical col 7, spans 2 key widths) + int bsWidth = 2 * keyWidth + keySpacing; + bool bsSelected = (selectedRow == 4 && selectedCol == BACKSPACE_COL); + if (bsSelected) { + renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); + renderer.drawText(UI_FONT_ID, currentX + bsWidth - 4, rowY, "]"); + } + renderer.drawText(UI_FONT_ID, currentX + 6, rowY, "<-"); + currentX += bsWidth + keySpacing; + + // OK button (logical col 9, spans 2 key widths) + int okWidth = 2 * keyWidth + keySpacing; + bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL); + if (okSelected) { + renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); + renderer.drawText(UI_FONT_ID, currentX + okWidth - 4, rowY, "]"); + } + renderer.drawText(UI_FONT_ID, currentX + 8, rowY, "OK"); + + } else { + // Regular rows: render each key individually + for (int col = 0; col < getRowLength(row); col++) { + int keyX = startX + col * (keyWidth + keySpacing); + + // Get the character to display + char c = layout[row][col]; + std::string keyLabel(1, c); + + // Draw selection highlight + bool isSelected = (row == selectedRow && col == selectedCol); + + if (isSelected) { + renderer.drawText(UI_FONT_ID, keyX - 2, rowY, "["); + renderer.drawText(UI_FONT_ID, keyX + keyWidth - 4, rowY, "]"); + } + + renderer.drawText(UI_FONT_ID, keyX + 2, rowY, keyLabel.c_str()); + } + } + } + + // Draw help text at absolute bottom of screen (consistent with other screens) + const auto pageHeight = GfxRenderer::getScreenHeight(); + renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK"); +} diff --git a/src/screens/OnScreenKeyboard.h b/src/screens/OnScreenKeyboard.h new file mode 100644 index 0000000..e1511cd --- /dev/null +++ b/src/screens/OnScreenKeyboard.h @@ -0,0 +1,123 @@ +#pragma once +#include +#include + +#include +#include + +/** + * Reusable on-screen keyboard component for text input. + * Can be embedded in any screen that needs text entry. + * + * Usage: + * 1. Create an OnScreenKeyboard instance + * 2. Call render() to draw the keyboard + * 3. Call handleInput() to process button presses + * 4. When isComplete() returns true, get the result from getText() + * 5. Call isCancelled() to check if user cancelled input + */ +class OnScreenKeyboard { + public: + // Callback types + using OnCompleteCallback = std::function; + using OnCancelCallback = std::function; + + /** + * Constructor + * @param renderer Reference to the GfxRenderer for drawing + * @param inputManager Reference to InputManager for handling input + * @param title Title to display above the keyboard + * @param initialText Initial text to show in the input field + * @param maxLength Maximum length of input text (0 for unlimited) + * @param isPassword If true, display asterisks instead of actual characters + */ + OnScreenKeyboard(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text", + const std::string& initialText = "", size_t maxLength = 0, bool isPassword = false); + + /** + * Handle button input. Call this in your screen's handleInput(). + * @return true if input was handled, false otherwise + */ + bool handleInput(); + + /** + * Render the keyboard at the specified Y position. + * @param startY Y-coordinate where keyboard rendering starts + */ + void render(int startY) const; + + /** + * Get the current text entered by the user. + */ + const std::string& getText() const { return text; } + + /** + * Set the current text. + */ + void setText(const std::string& newText); + + /** + * Check if the user has completed text entry (pressed OK on Done). + */ + bool isComplete() const { return complete; } + + /** + * Check if the user has cancelled text entry. + */ + bool isCancelled() const { return cancelled; } + + /** + * Reset the keyboard state for reuse. + */ + void reset(const std::string& newTitle = "", const std::string& newInitialText = ""); + + /** + * Set callback for when input is complete. + */ + void setOnComplete(OnCompleteCallback callback) { onComplete = callback; } + + /** + * Set callback for when input is cancelled. + */ + void setOnCancel(OnCancelCallback callback) { onCancel = callback; } + + private: + GfxRenderer& renderer; + InputManager& inputManager; + + std::string title; + std::string text; + size_t maxLength; + bool isPassword; + + // Keyboard state + int selectedRow = 0; + int selectedCol = 0; + bool shiftActive = false; + bool complete = false; + bool cancelled = false; + + // Callbacks + OnCompleteCallback onComplete; + OnCancelCallback onCancel; + + // Keyboard layout + static constexpr int NUM_ROWS = 5; + static constexpr int KEYS_PER_ROW = 13; // Max keys per row (rows 0 and 1 have 13 keys) + static const char* const keyboard[NUM_ROWS]; + static const char* const keyboardShift[NUM_ROWS]; + + // Special key positions (bottom row) + static constexpr int SHIFT_ROW = 4; + static constexpr int SHIFT_COL = 0; + static constexpr int SPACE_ROW = 4; + static constexpr int SPACE_COL = 2; + static constexpr int BACKSPACE_ROW = 4; + static constexpr int BACKSPACE_COL = 7; + static constexpr int DONE_ROW = 4; + static constexpr int DONE_COL = 9; + + char getSelectedChar() const; + void handleKeyPress(); + int getRowLength(int row) const; +}; diff --git a/src/screens/SettingsScreen.cpp b/src/screens/SettingsScreen.cpp index e970752..3a8518f 100644 --- a/src/screens/SettingsScreen.cpp +++ b/src/screens/SettingsScreen.cpp @@ -8,8 +8,9 @@ // Define the static settings list const SettingInfo SettingsScreen::settingsList[SettingsScreen::settingsCount] = { - {"White Sleep Screen", &CrossPointSettings::whiteSleepScreen}, - {"Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing}}; + {"White Sleep Screen", SettingType::TOGGLE, &CrossPointSettings::whiteSleepScreen}, + {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing}, + {"WiFi", SettingType::ACTION, nullptr}}; void SettingsScreen::taskTrampoline(void* param) { auto* self = static_cast(param); @@ -47,7 +48,7 @@ void SettingsScreen::onExit() { void SettingsScreen::handleInput() { // Handle actions with early return if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { - toggleCurrentSetting(); + activateCurrentSetting(); updateRequired = true; return; } @@ -64,9 +65,31 @@ void SettingsScreen::handleInput() { selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); updateRequired = true; } else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) { - // Move selection down (with wrap-around) - selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; + // Move selection down + if (selectedSettingIndex < settingsCount - 1) { + selectedSettingIndex++; + updateRequired = true; + } + } +} + +void SettingsScreen::activateCurrentSetting() { + // Validate index + if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { + return; + } + + const auto& setting = settingsList[selectedSettingIndex]; + + if (setting.type == SettingType::TOGGLE) { + toggleCurrentSetting(); + // Trigger a redraw of the entire screen updateRequired = true; + } else if (setting.type == SettingType::ACTION) { + // Handle action settings + if (std::string(setting.name) == "WiFi") { + onGoWifi(); + } } } @@ -76,9 +99,16 @@ void SettingsScreen::toggleCurrentSetting() { return; } + const auto& setting = settingsList[selectedSettingIndex]; + + // Only toggle if it's a toggle type and has a value pointer + if (setting.type != SettingType::TOGGLE || setting.valuePtr == nullptr) { + return; + } + // Toggle the boolean value using the member pointer - bool currentValue = SETTINGS.*(settingsList[selectedSettingIndex].valuePtr); - SETTINGS.*(settingsList[selectedSettingIndex].valuePtr) = !currentValue; + bool currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = !currentValue; // Save settings when they change SETTINGS.saveToFile(); @@ -116,14 +146,20 @@ void SettingsScreen::render() const { renderer.drawText(UI_FONT_ID, 5, settingY, ">"); } - // Draw setting name and value + // Draw setting name renderer.drawText(UI_FONT_ID, 20, settingY, settingsList[i].name); - bool value = SETTINGS.*(settingsList[i].valuePtr); - renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF"); + + // Draw value based on setting type + if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { + bool value = SETTINGS.*(settingsList[i].valuePtr); + renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF"); + } else if (settingsList[i].type == SettingType::ACTION) { + renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, ">"); + } } // Draw help text - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit"); + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to select, BACK to save & exit"); // Always use standard refresh for settings screen renderer.displayBuffer(); diff --git a/src/screens/SettingsScreen.h b/src/screens/SettingsScreen.h index 8de45b8..af66f2b 100644 --- a/src/screens/SettingsScreen.h +++ b/src/screens/SettingsScreen.h @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -11,10 +12,14 @@ class CrossPointSettings; +// Enum to distinguish setting types +enum class SettingType { TOGGLE, ACTION }; + // Structure to hold setting information struct SettingInfo { const char* name; // Display name of the setting - uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings + SettingType type; // Type of setting + uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE) }; class SettingsScreen final : public Screen { @@ -23,19 +28,22 @@ class SettingsScreen final : public Screen { bool updateRequired = false; int selectedSettingIndex = 0; // Currently selected setting const std::function onGoHome; + const std::function onGoWifi; // Static settings list - static constexpr int settingsCount = 2; // Number of settings + static constexpr int settingsCount = 3; // Number of settings static const SettingInfo settingsList[settingsCount]; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; void toggleCurrentSetting(); + void activateCurrentSetting(); public: - explicit SettingsScreen(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoHome) - : Screen(renderer, inputManager), onGoHome(onGoHome) {} + explicit SettingsScreen(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoHome, + const std::function& onGoWifi) + : Screen(renderer, inputManager), onGoHome(onGoHome), onGoWifi(onGoWifi) {} void onEnter() override; void onExit() override; void handleInput() override; diff --git a/src/screens/WifiScreen.cpp b/src/screens/WifiScreen.cpp new file mode 100644 index 0000000..f1ab171 --- /dev/null +++ b/src/screens/WifiScreen.cpp @@ -0,0 +1,668 @@ +#include "WifiScreen.h" + +#include +#include + +#include "CrossPointWebServer.h" +#include "WifiCredentialStore.h" +#include "config.h" + +void WifiScreen::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void WifiScreen::onEnter() { + renderingMutex = xSemaphoreCreateMutex(); + + // Load saved WiFi credentials + WIFI_STORE.loadFromFile(); + + // Reset state + selectedNetworkIndex = 0; + networks.clear(); + state = WifiScreenState::SCANNING; + selectedSSID.clear(); + connectedIP.clear(); + connectionError.clear(); + enteredPassword.clear(); + usedSavedPassword = false; + savePromptSelection = 0; + forgetPromptSelection = 0; + keyboard.reset(); + + // Trigger first update to show scanning message + updateRequired = true; + + xTaskCreate(&WifiScreen::taskTrampoline, "WifiScreenTask", + 4096, // Stack size (larger for WiFi operations) + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // Start WiFi scan + startWifiScan(); +} + +void WifiScreen::onExit() { + // Stop any ongoing WiFi scan + WiFi.scanDelete(); + + // Stop the web server to free memory + crossPointWebServer.stop(); + + // Disconnect WiFi to free memory + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void WifiScreen::startWifiScan() { + state = WifiScreenState::SCANNING; + networks.clear(); + updateRequired = true; + + // Set WiFi mode to station + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + // Start async scan + WiFi.scanNetworks(true); // true = async scan +} + +void WifiScreen::processWifiScanResults() { + int16_t scanResult = WiFi.scanComplete(); + + if (scanResult == WIFI_SCAN_RUNNING) { + // Scan still in progress + return; + } + + if (scanResult == WIFI_SCAN_FAILED) { + state = WifiScreenState::NETWORK_LIST; + updateRequired = true; + return; + } + + // Scan complete, process results + networks.clear(); + for (int i = 0; i < scanResult; i++) { + WifiNetworkInfo network; + network.ssid = WiFi.SSID(i).c_str(); + network.rssi = WiFi.RSSI(i); + network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN); + network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid); + + // Skip hidden networks (empty SSID) + if (!network.ssid.empty()) { + networks.push_back(network); + } + } + + // Sort by signal strength (strongest first) + std::sort(networks.begin(), networks.end(), + [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; }); + + WiFi.scanDelete(); + state = WifiScreenState::NETWORK_LIST; + selectedNetworkIndex = 0; + updateRequired = true; +} + +void WifiScreen::selectNetwork(int index) { + if (index < 0 || index >= static_cast(networks.size())) { + return; + } + + const auto& network = networks[index]; + selectedSSID = network.ssid; + selectedRequiresPassword = network.isEncrypted; + usedSavedPassword = false; + enteredPassword.clear(); + + // 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; + Serial.printf("[%lu] [WiFi] Using saved password for %s, length: %zu\n", millis(), selectedSSID.c_str(), + enteredPassword.size()); + attemptConnection(); + return; + } + + if (selectedRequiresPassword) { + // Show password entry + state = WifiScreenState::PASSWORD_ENTRY; + keyboard.reset(new OnScreenKeyboard(renderer, inputManager, "Enter WiFi Password", + "", // No initial text + 64, // Max password length + false // Show password by default (hard keyboard to use) + )); + updateRequired = true; + } else { + // Connect directly for open networks + attemptConnection(); + } +} + +void WifiScreen::attemptConnection() { + state = WifiScreenState::CONNECTING; + connectionStartTime = millis(); + connectedIP.clear(); + connectionError.clear(); + updateRequired = true; + + WiFi.mode(WIFI_STA); + + // Get password from keyboard if we just entered it + if (keyboard && !usedSavedPassword) { + enteredPassword = keyboard->getText(); + } + + if (selectedRequiresPassword && !enteredPassword.empty()) { + WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str()); + } else { + WiFi.begin(selectedSSID.c_str()); + } +} + +void WifiScreen::checkConnectionStatus() { + if (state != WifiScreenState::CONNECTING) { + return; + } + + 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; + + // Start the web server + crossPointWebServer.begin(); + + // If we used a saved password, go directly to connected screen + // If we entered a new password, ask if user wants to save it + if (usedSavedPassword || enteredPassword.empty()) { + state = WifiScreenState::CONNECTED; + } else { + state = WifiScreenState::SAVE_PROMPT; + savePromptSelection = 0; // Default to "Yes" + } + updateRequired = true; + return; + } + + if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) { + connectionError = "Connection failed"; + if (status == WL_NO_SSID_AVAIL) { + connectionError = "Network not found"; + } + state = WifiScreenState::CONNECTION_FAILED; + updateRequired = true; + return; + } + + // Check for timeout + if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) { + WiFi.disconnect(); + connectionError = "Connection timeout"; + state = WifiScreenState::CONNECTION_FAILED; + updateRequired = true; + return; + } +} + +void WifiScreen::handleInput() { + // Check scan progress + if (state == WifiScreenState::SCANNING) { + processWifiScanResults(); + return; + } + + // Check connection progress + if (state == WifiScreenState::CONNECTING) { + checkConnectionStatus(); + return; + } + + // Handle password entry state + if (state == WifiScreenState::PASSWORD_ENTRY && keyboard) { + keyboard->handleInput(); + + if (keyboard->isComplete()) { + attemptConnection(); + return; + } + + if (keyboard->isCancelled()) { + state = WifiScreenState::NETWORK_LIST; + keyboard.reset(); + updateRequired = true; + return; + } + + updateRequired = true; + return; + } + + // Handle save prompt state + if (state == WifiScreenState::SAVE_PROMPT) { + if (inputManager.wasPressed(InputManager::BTN_LEFT) || inputManager.wasPressed(InputManager::BTN_UP)) { + if (savePromptSelection > 0) { + savePromptSelection--; + updateRequired = true; + } + } else if (inputManager.wasPressed(InputManager::BTN_RIGHT) || inputManager.wasPressed(InputManager::BTN_DOWN)) { + if (savePromptSelection < 1) { + savePromptSelection++; + updateRequired = true; + } + } else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (savePromptSelection == 0) { + // User chose "Yes" - save the password + WIFI_STORE.addCredential(selectedSSID, enteredPassword); + } + // Move to connected screen + state = WifiScreenState::CONNECTED; + updateRequired = true; + } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { + // Skip saving, go to connected screen + state = WifiScreenState::CONNECTED; + updateRequired = true; + } + return; + } + + // Handle forget prompt state (connection failed with saved credentials) + if (state == WifiScreenState::FORGET_PROMPT) { + if (inputManager.wasPressed(InputManager::BTN_LEFT) || inputManager.wasPressed(InputManager::BTN_UP)) { + if (forgetPromptSelection > 0) { + forgetPromptSelection--; + updateRequired = true; + } + } else if (inputManager.wasPressed(InputManager::BTN_RIGHT) || inputManager.wasPressed(InputManager::BTN_DOWN)) { + if (forgetPromptSelection < 1) { + forgetPromptSelection++; + updateRequired = true; + } + } else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (forgetPromptSelection == 0) { + // User chose "Yes" - forget the network + WIFI_STORE.removeCredential(selectedSSID); + // Update the network list to reflect the change + for (auto& network : networks) { + if (network.ssid == selectedSSID) { + network.hasSavedPassword = false; + break; + } + } + } + // Go back to network list + state = WifiScreenState::NETWORK_LIST; + updateRequired = true; + } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { + // Skip forgetting, go back to network list + state = WifiScreenState::NETWORK_LIST; + updateRequired = true; + } + return; + } + + // Handle connected state + if (state == WifiScreenState::CONNECTED) { + if (inputManager.wasPressed(InputManager::BTN_BACK) || inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + // Exit screen on success + onGoBack(); + return; + } + } + + // Handle connection failed state + if (state == WifiScreenState::CONNECTION_FAILED) { + if (inputManager.wasPressed(InputManager::BTN_BACK) || inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + // If we used saved credentials, offer to forget the network + if (usedSavedPassword) { + state = WifiScreenState::FORGET_PROMPT; + forgetPromptSelection = 0; // Default to "Yes" + } else { + // Go back to network list on failure + state = WifiScreenState::NETWORK_LIST; + } + updateRequired = true; + return; + } + } + + // Handle network list state + if (state == WifiScreenState::NETWORK_LIST) { + // Check for Back button to exit + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + onGoBack(); + return; + } + + // Check for Confirm button to select network or rescan + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (!networks.empty()) { + selectNetwork(selectedNetworkIndex); + } else { + startWifiScan(); + } + return; + } + + // Handle UP/DOWN navigation + if (inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT)) { + if (selectedNetworkIndex > 0) { + selectedNetworkIndex--; + updateRequired = true; + } + } else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) { + if (!networks.empty() && selectedNetworkIndex < static_cast(networks.size()) - 1) { + selectedNetworkIndex++; + updateRequired = true; + } + } + } +} + +std::string WifiScreen::getSignalStrengthIndicator(int32_t rssi) const { + // Convert RSSI to signal bars representation + if (rssi >= -50) { + return "||||"; // Excellent + } else if (rssi >= -60) { + return "||| "; // Good + } else if (rssi >= -70) { + return "|| "; // Fair + } else if (rssi >= -80) { + return "| "; // Weak + } + return " "; // Very weak +} + +void WifiScreen::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void WifiScreen::render() const { + renderer.clearScreen(); + + switch (state) { + case WifiScreenState::SCANNING: + renderConnecting(); // Reuse connecting screen with different message + break; + case WifiScreenState::NETWORK_LIST: + renderNetworkList(); + break; + case WifiScreenState::PASSWORD_ENTRY: + renderPasswordEntry(); + break; + case WifiScreenState::CONNECTING: + renderConnecting(); + break; + case WifiScreenState::CONNECTED: + renderConnected(); + break; + case WifiScreenState::SAVE_PROMPT: + renderSavePrompt(); + break; + case WifiScreenState::CONNECTION_FAILED: + renderConnectionFailed(); + break; + case WifiScreenState::FORGET_PROMPT: + renderForgetPrompt(); + break; + } + + renderer.displayBuffer(); +} + +void WifiScreen::renderNetworkList() const { + const auto pageWidth = GfxRenderer::getScreenWidth(); + const auto pageHeight = GfxRenderer::getScreenHeight(); + + // Draw header + renderer.drawCenteredText(READER_FONT_ID, 10, "WiFi Networks", true, BOLD); + + if (networks.empty()) { + // No networks found or scan failed + const auto height = renderer.getLineHeight(UI_FONT_ID); + const auto top = (pageHeight - height) / 2; + renderer.drawCenteredText(UI_FONT_ID, top, "No networks found", true, REGULAR); + renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again", true, REGULAR); + } else { + // Calculate how many networks we can display + const int startY = 60; + const int lineHeight = 25; + const int maxVisibleNetworks = (pageHeight - startY - 40) / lineHeight; + + // Calculate scroll offset to keep selected item visible + int scrollOffset = 0; + if (selectedNetworkIndex >= maxVisibleNetworks) { + scrollOffset = selectedNetworkIndex - maxVisibleNetworks + 1; + } + + // Draw networks + int displayIndex = 0; + for (size_t i = scrollOffset; i < networks.size() && displayIndex < maxVisibleNetworks; i++, displayIndex++) { + const int networkY = startY + displayIndex * lineHeight; + const auto& network = networks[i]; + + // Draw selection indicator + if (static_cast(i) == selectedNetworkIndex) { + renderer.drawText(UI_FONT_ID, 5, networkY, ">"); + } + + // Draw network name (truncate if too long) + std::string displayName = network.ssid; + if (displayName.length() > 16) { + displayName = displayName.substr(0, 13) + "..."; + } + renderer.drawText(UI_FONT_ID, 20, networkY, displayName.c_str()); + + // Draw signal strength indicator + std::string signalStr = getSignalStrengthIndicator(network.rssi); + renderer.drawText(UI_FONT_ID, pageWidth - 90, networkY, signalStr.c_str()); + + // Draw saved indicator (checkmark) for networks with saved passwords + if (network.hasSavedPassword) { + renderer.drawText(UI_FONT_ID, pageWidth - 50, networkY, "+"); + } + + // Draw lock icon for encrypted networks + if (network.isEncrypted) { + renderer.drawText(UI_FONT_ID, pageWidth - 30, networkY, "*"); + } + } + + // Draw scroll indicators if needed + if (scrollOffset > 0) { + renderer.drawText(SMALL_FONT_ID, pageWidth - 15, startY - 10, "^"); + } + if (scrollOffset + maxVisibleNetworks < static_cast(networks.size())) { + renderer.drawText(SMALL_FONT_ID, pageWidth - 15, startY + maxVisibleNetworks * lineHeight, "v"); + } + + // Show network count + char countStr[32]; + snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size()); + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 45, countStr); + } + + // Draw help text + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "OK: Connect | * = Encrypted | + = Saved"); +} + +void WifiScreen::renderPasswordEntry() const { + const auto pageHeight = GfxRenderer::getScreenHeight(); + + // Draw header + renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD); + + // Draw network name with good spacing from header + std::string networkInfo = "Network: " + selectedSSID; + if (networkInfo.length() > 30) { + networkInfo = networkInfo.substr(0, 27) + "..."; + } + renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR); + + // Draw keyboard + if (keyboard) { + keyboard->render(58); + } +} + +void WifiScreen::renderConnecting() const { + const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto height = renderer.getLineHeight(UI_FONT_ID); + const auto top = (pageHeight - height) / 2; + + if (state == WifiScreenState::SCANNING) { + renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR); + } else { + renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connecting...", true, BOLD); + + std::string ssidInfo = "to " + selectedSSID; + if (ssidInfo.length() > 25) { + ssidInfo = ssidInfo.substr(0, 22) + "..."; + } + renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); + } +} + +void WifiScreen::renderConnected() const { + const auto pageWidth = GfxRenderer::getScreenWidth(); + const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto height = renderer.getLineHeight(UI_FONT_ID); + const auto top = (pageHeight - height * 4) / 2; + + renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connected!", true, BOLD); + + std::string ssidInfo = "Network: " + selectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo = ssidInfo.substr(0, 25) + "..."; + } + renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); + + std::string ipInfo = "IP Address: " + connectedIP; + renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR); + + // Show web server info + std::string webInfo = "Web: http://" + connectedIP + "/"; + renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to exit", true, REGULAR); +} + +void WifiScreen::renderSavePrompt() const { + const auto pageWidth = GfxRenderer::getScreenWidth(); + const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto height = renderer.getLineHeight(UI_FONT_ID); + const auto top = (pageHeight - height * 3) / 2; + + renderer.drawCenteredText(READER_FONT_ID, top - 40, "Connected!", true, BOLD); + + std::string ssidInfo = "Network: " + selectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo = ssidInfo.substr(0, 25) + "..."; + } + renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); + + renderer.drawCenteredText(UI_FONT_ID, top + 40, "Save password for next time?", true, REGULAR); + + // Draw Yes/No buttons + const int buttonY = top + 80; + const int buttonWidth = 60; + const int buttonSpacing = 30; + const int totalWidth = buttonWidth * 2 + buttonSpacing; + const int startX = (pageWidth - totalWidth) / 2; + + // Draw "Yes" button + if (savePromptSelection == 0) { + renderer.drawText(UI_FONT_ID, startX, buttonY, "[Yes]"); + } else { + renderer.drawText(UI_FONT_ID, startX + 4, buttonY, "Yes"); + } + + // Draw "No" button + if (savePromptSelection == 1) { + renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]"); + } else { + renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No"); + } + + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm", true, REGULAR); +} + +void WifiScreen::renderConnectionFailed() const { + const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto height = renderer.getLineHeight(UI_FONT_ID); + const auto top = (pageHeight - height * 2) / 2; + + renderer.drawCenteredText(READER_FONT_ID, top - 20, "Connection Failed", true, BOLD); + renderer.drawCenteredText(UI_FONT_ID, top + 20, connectionError.c_str(), true, REGULAR); + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR); +} + +void WifiScreen::renderForgetPrompt() const { + const auto pageWidth = GfxRenderer::getScreenWidth(); + const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto height = renderer.getLineHeight(UI_FONT_ID); + const auto top = (pageHeight - height * 3) / 2; + + renderer.drawCenteredText(READER_FONT_ID, top - 40, "Forget Network?", true, BOLD); + + std::string ssidInfo = "Network: " + selectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo = ssidInfo.substr(0, 25) + "..."; + } + renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); + + renderer.drawCenteredText(UI_FONT_ID, top + 40, "Remove saved password?", true, REGULAR); + + // Draw Yes/No buttons + const int buttonY = top + 80; + const int buttonWidth = 60; + const int buttonSpacing = 30; + const int totalWidth = buttonWidth * 2 + buttonSpacing; + const int startX = (pageWidth - totalWidth) / 2; + + // Draw "Yes" button + if (forgetPromptSelection == 0) { + renderer.drawText(UI_FONT_ID, startX, buttonY, "[Yes]"); + } else { + renderer.drawText(UI_FONT_ID, startX + 4, buttonY, "Yes"); + } + + // Draw "No" button + if (forgetPromptSelection == 1) { + renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]"); + } else { + renderer.drawText(UI_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No"); + } + + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm", true, REGULAR); +} diff --git a/src/screens/WifiScreen.h b/src/screens/WifiScreen.h new file mode 100644 index 0000000..8d267e1 --- /dev/null +++ b/src/screens/WifiScreen.h @@ -0,0 +1,93 @@ +#pragma once +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "OnScreenKeyboard.h" +#include "Screen.h" + +// Structure to hold WiFi network information +struct WifiNetworkInfo { + std::string ssid; + int32_t rssi; + bool isEncrypted; + bool hasSavedPassword; // Whether we have saved credentials for this network +}; + +// WiFi screen states +enum class WifiScreenState { + SCANNING, // Scanning for networks + NETWORK_LIST, // Displaying available networks + PASSWORD_ENTRY, // Entering password for selected network + CONNECTING, // Attempting to connect + CONNECTED, // Successfully connected, showing IP + 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 +}; + +class WifiScreen final : public Screen { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + WifiScreenState state = WifiScreenState::SCANNING; + int selectedNetworkIndex = 0; + std::vector networks; + const std::function onGoBack; + + // Selected network for connection + std::string selectedSSID; + bool selectedRequiresPassword = false; + + // On-screen keyboard for password entry + std::unique_ptr keyboard; + + // Connection result + std::string connectedIP; + std::string connectionError; + + // Password to potentially save (from keyboard or saved credentials) + std::string enteredPassword; + + // Whether network was connected using a saved password (skip save prompt) + bool usedSavedPassword = false; + + // Save/forget prompt selection (0 = Yes, 1 = No) + int savePromptSelection = 0; + int forgetPromptSelection = 0; + + // Connection timeout + static constexpr unsigned long CONNECTION_TIMEOUT_MS = 15000; + unsigned long connectionStartTime = 0; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderNetworkList() const; + void renderPasswordEntry() const; + void renderConnecting() const; + void renderConnected() const; + void renderSavePrompt() const; + void renderConnectionFailed() const; + void renderForgetPrompt() const; + + void startWifiScan(); + void processWifiScanResults(); + void selectNetwork(int index); + void attemptConnection(); + void checkConnectionStatus(); + std::string getSignalStrengthIndicator(int32_t rssi) const; + + public: + explicit WifiScreen(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoBack) + : Screen(renderer, inputManager), onGoBack(onGoBack) {} + void onEnter() override; + void onExit() override; + void handleInput() override; +};