This commit is contained in:
Brendan O'Leary 2025-12-17 18:50:00 +11:00 committed by GitHub
commit e2a2337239
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 3283 additions and 16 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

272
docs/webserver.md Normal file
View File

@ -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
<img src="./images/wifi/wifi_networks.jpeg" height="500">
### 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
<img src="./images/wifi/wifi_password.jpeg" height="500">
**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/`)
<img src="./images/wifi/wifi_connected.jpeg" height="500">
**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
<img src="./images/wifi/webserver_homepage.png" width="600">
### 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
<img src="./images/wifi/webserver_files.png" width="600">
#### 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.
<img src="./images/wifi/webserver_upload.png" width="600">
#### 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

1475
src/CrossPointWebServer.cpp Normal file

File diff suppressed because it is too large Load Diff

59
src/CrossPointWebServer.h Normal file
View File

@ -0,0 +1,59 @@
#pragma once
#include <WebServer.h>
#include <functional>
#include <string>
#include <vector>
// 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<FileInfo> 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;

158
src/WifiCredentialStore.cpp Normal file
View File

@ -0,0 +1,158 @@
#include "WifiCredentialStore.h"
#include <HardwareSerial.h>
#include <SD.h>
#include <Serialization.h>
#include <fstream>
// 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<uint8_t>(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());
}

56
src/WifiCredentialStore.h Normal file
View File

@ -0,0 +1,56 @@
#pragma once
#include <string>
#include <vector>
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<WifiCredential> 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<WifiCredential>& 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()

View File

@ -5,6 +5,7 @@
#include <InputManager.h> #include <InputManager.h>
#include <SD.h> #include <SD.h>
#include <SPI.h> #include <SPI.h>
#include <WiFi.h>
#include <builtinFonts/bookerly_2b.h> #include <builtinFonts/bookerly_2b.h>
#include <builtinFonts/bookerly_bold_2b.h> #include <builtinFonts/bookerly_bold_2b.h>
#include <builtinFonts/bookerly_bold_italic_2b.h> #include <builtinFonts/bookerly_bold_italic_2b.h>
@ -16,6 +17,7 @@
#include "Battery.h" #include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "CrossPointWebServer.h"
#include "config.h" #include "config.h"
#include "screens/BootLogoScreen.h" #include "screens/BootLogoScreen.h"
#include "screens/EpubReaderScreen.h" #include "screens/EpubReaderScreen.h"
@ -23,6 +25,7 @@
#include "screens/FullScreenMessageScreen.h" #include "screens/FullScreenMessageScreen.h"
#include "screens/SettingsScreen.h" #include "screens/SettingsScreen.h"
#include "screens/SleepScreen.h" #include "screens/SleepScreen.h"
#include "screens/WifiScreen.h"
#define SPI_FQ 40000000 #define SPI_FQ 40000000
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) // 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() { void onGoToSettings() {
exitScreen(); exitScreen();
enterNewScreen(new SettingsScreen(renderer, inputManager, onGoHome)); enterNewScreen(new SettingsScreen(renderer, inputManager, onGoHome, onGoToWifi));
} }
void onGoHome() { void onGoHome() {
@ -238,6 +248,11 @@ void loop() {
lastMemPrint = millis(); lastMemPrint = millis();
} }
// Handle web server clients if WiFi is connected
if (WiFi.status() == WL_CONNECTED) {
crossPointWebServer.handleClient();
}
inputManager.update(); inputManager.update();
// Check for any user activity (button press or release) // Check for any user activity (button press or release)

View File

@ -0,0 +1,304 @@
#include "OnScreenKeyboard.h"
#include "config.h"
// Keyboard layouts - lowercase
const char* const OnScreenKeyboard::keyboard[NUM_ROWS] = {
"`1234567890-=", "qwertyuiop[]\\", "asdfghjkl;'", "zxcvbnm,./",
"^ _____<OK" // ^ = shift, _ = space, < = backspace, OK = done
};
// Keyboard layouts - uppercase/symbols
const char* const OnScreenKeyboard::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"",
"ZXCVBNM<>?", "^ _____<OK"};
OnScreenKeyboard::OnScreenKeyboard(GfxRenderer& renderer, InputManager& inputManager, const std::string& title,
const std::string& initialText, size_t maxLength, bool isPassword)
: renderer(renderer),
inputManager(inputManager),
title(title),
text(initialText),
maxLength(maxLength),
isPassword(isPassword) {}
void OnScreenKeyboard::setText(const std::string& newText) {
text = newText;
if (maxLength > 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<size_t>(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");
}

View File

@ -0,0 +1,123 @@
#pragma once
#include <GfxRenderer.h>
#include <InputManager.h>
#include <functional>
#include <string>
/**
* 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<void(const std::string&)>;
using OnCancelCallback = std::function<void()>;
/**
* 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;
};

View File

@ -8,8 +8,9 @@
// Define the static settings list // Define the static settings list
const SettingInfo SettingsScreen::settingsList[SettingsScreen::settingsCount] = { const SettingInfo SettingsScreen::settingsList[SettingsScreen::settingsCount] = {
{"White Sleep Screen", &CrossPointSettings::whiteSleepScreen}, {"White Sleep Screen", SettingType::TOGGLE, &CrossPointSettings::whiteSleepScreen},
{"Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing}}; {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing},
{"WiFi", SettingType::ACTION, nullptr}};
void SettingsScreen::taskTrampoline(void* param) { void SettingsScreen::taskTrampoline(void* param) {
auto* self = static_cast<SettingsScreen*>(param); auto* self = static_cast<SettingsScreen*>(param);
@ -47,7 +48,7 @@ void SettingsScreen::onExit() {
void SettingsScreen::handleInput() { void SettingsScreen::handleInput() {
// Handle actions with early return // Handle actions with early return
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
toggleCurrentSetting(); activateCurrentSetting();
updateRequired = true; updateRequired = true;
return; return;
} }
@ -64,9 +65,31 @@ void SettingsScreen::handleInput() {
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1);
updateRequired = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) { } else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) {
// Move selection down (with wrap-around) // Move selection down
selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; 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; 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; 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 // Toggle the boolean value using the member pointer
bool currentValue = SETTINGS.*(settingsList[selectedSettingIndex].valuePtr); bool currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(settingsList[selectedSettingIndex].valuePtr) = !currentValue; SETTINGS.*(setting.valuePtr) = !currentValue;
// Save settings when they change // Save settings when they change
SETTINGS.saveToFile(); SETTINGS.saveToFile();
@ -116,14 +146,20 @@ void SettingsScreen::render() const {
renderer.drawText(UI_FONT_ID, 5, settingY, ">"); 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); 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 // 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 // Always use standard refresh for settings screen
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -4,6 +4,7 @@
#include <freertos/task.h> #include <freertos/task.h>
#include <cstdint> #include <cstdint>
#include <functional>
#include <string> #include <string>
#include <vector> #include <vector>
@ -11,10 +12,14 @@
class CrossPointSettings; class CrossPointSettings;
// Enum to distinguish setting types
enum class SettingType { TOGGLE, ACTION };
// Structure to hold setting information // Structure to hold setting information
struct SettingInfo { struct SettingInfo {
const char* name; // Display name of the setting 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 { class SettingsScreen final : public Screen {
@ -23,19 +28,22 @@ class SettingsScreen final : public Screen {
bool updateRequired = false; bool updateRequired = false;
int selectedSettingIndex = 0; // Currently selected setting int selectedSettingIndex = 0; // Currently selected setting
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
const std::function<void()> onGoWifi;
// Static settings list // 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 const SettingInfo settingsList[settingsCount];
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
void toggleCurrentSetting(); void toggleCurrentSetting();
void activateCurrentSetting();
public: public:
explicit SettingsScreen(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome) explicit SettingsScreen(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome,
: Screen(renderer, inputManager), onGoHome(onGoHome) {} const std::function<void()>& onGoWifi)
: Screen(renderer, inputManager), onGoHome(onGoHome), onGoWifi(onGoWifi) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void handleInput() override; void handleInput() override;

668
src/screens/WifiScreen.cpp Normal file
View File

@ -0,0 +1,668 @@
#include "WifiScreen.h"
#include <GfxRenderer.h>
#include <WiFi.h>
#include "CrossPointWebServer.h"
#include "WifiCredentialStore.h"
#include "config.h"
void WifiScreen::taskTrampoline(void* param) {
auto* self = static_cast<WifiScreen*>(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<int>(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<int>(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<int>(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<int>(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);
}

93
src/screens/WifiScreen.h Normal file
View File

@ -0,0 +1,93 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
#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<WifiNetworkInfo> networks;
const std::function<void()> onGoBack;
// Selected network for connection
std::string selectedSSID;
bool selectedRequiresPassword = false;
// On-screen keyboard for password entry
std::unique_ptr<OnScreenKeyboard> 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<void()>& onGoBack)
: Screen(renderer, inputManager), onGoBack(onGoBack) {}
void onEnter() override;
void onExit() override;
void handleInput() override;
};