Compare commits

...

30 Commits

Author SHA1 Message Date
Brendan O'Leary
de3689a7c7
Merge 71fc35845b into 063a1df851 2025-12-17 20:42:45 -05:00
Brendan O'Leary
71fc35845b Clang 2025-12-17 20:42:42 -05:00
Brendan O'Leary
abb3dc3d43 Fix issue with upload loop 2025-12-17 20:41:00 -05:00
Brendan O'Leary
b87a8c5dd5 Memory cleanup 2025-12-17 20:27:38 -05:00
Brendan O'Leary
b46f872e37 Mobile responsive 2025-12-17 20:14:11 -05:00
Brendan O'Leary
f3810f3c69 fix javascript issued 2025-12-17 20:03:20 -05:00
Brendan O'Leary
ba96a26b71 Clang format fix 2025-12-17 19:11:56 -05:00
Brendan O'Leary
21cb8a6731 Refactor HTML to be mostly pre-generated 2025-12-17 19:11:30 -05:00
Brendan O'Leary
421c5cd30b If server is running, call handleClient 2025-12-17 18:57:31 -05:00
Brendan O'Leary
5d935e6719 Restore the wifi screen 2025-12-17 18:47:27 -05:00
Brendan O'Leary
08227a07fa Merge master 2025-12-17 18:44:05 -05:00
Jonas Diemer
063a1df851
Bugfix for #46: don't look at previous chapters if in chapter 0. (#48)
Some checks are pending
CI / build (push) Waiting to run
Fixes #46
2025-12-18 06:28:06 +11:00
Dave Allie
d429966dd4
Rename Screens to Activities and restructure files (#44)
## Summary

* This PR drastically reshapes the structure of the codebase, moving
from the concept of "Screens" to "Activities", restructing the files and
setting up the concept of subactivities.
* This should help with keep the main file clean and containing all
functional logic in the relevant activity.
* CrossPointState is now also a global singleton which should help with
accessing it from within activities.

## Additional Context

* This is probably going to be a bit disruptive for people with open
PRs, sorry 😞
2025-12-17 23:32:18 +11:00
Jonas Diemer
c78f2a9840
Calculate the progress in the book by file sizes of each chapter. (#38)
## Summary

Addresses #35.

Maybe it could be wise to do some caching of the spine sizes (but
performance isn't too bad).
2025-12-17 23:05:24 +11:00
Dave Allie
11f01d3a41
Add home screen (#42)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* Add home screen
* Sits as new root screen, allows for navigation to settings or file
list
2025-12-17 20:47:43 +11:00
Brendan O'Leary
05da79f6ad clang format 2025-12-16 21:42:36 -05:00
Brendan O'Leary
e9e6982eea RCU for fix with merge 2025-12-16 21:37:17 -05:00
Brendan O'Leary
fcee7d519c Merge main 2025-12-16 21:32:39 -05:00
Brendan O'Leary
9c68d80781 Fix issue with uploading to subfolders 2025-12-16 21:20:45 -05:00
Brendan O'Leary
2aa1584582 Add docs page 2025-12-16 21:18:55 -05:00
Brendan O'Leary
2c79ea8705 Add delete key 2025-12-16 20:56:33 -05:00
Brendan O'Leary
78604d3bda Page design update 2025-12-16 20:49:24 -05:00
Brendan O'Leary
225268c09c Put some basic XSS protection in place 2025-12-16 20:32:52 -05:00
Brendan O'Leary
e384bdbfc2 Hide hidden folders 2025-12-16 20:18:08 -05:00
Brendan O'Leary
1bc30fbf2a Fix issue with failed connections 2025-12-16 20:18:08 -05:00
Brendan O'Leary
596e6fad0b
Update platformio.ini 2025-12-16 14:01:03 -05:00
Brendan O'Leary
698ca629b8 Add option to store wifi credentials 2025-12-15 22:02:03 -05:00
Brendan O'Leary
d4299efaed KIll webserver when exiting 2025-12-15 21:43:01 -05:00
Brendan O'Leary
e4f7327719 Add basic webserver 2025-12-15 21:23:21 -05:00
Brendan O'Leary
f365ba6ff0 Connection to WiFi established 2025-12-15 20:56:09 -05:00
54 changed files with 3970 additions and 236 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
.DS_Store
.vscode
lib/EpdFont/fontsrc
*.generated.h

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

View File

@ -148,6 +148,18 @@ bool Epub::load() {
return false;
}
// determine size of spine items
size_t spineItemsCount = getSpineItemsCount();
size_t spineItemsSize = 0;
for (size_t i = 0; i < spineItemsCount; i++) {
std::string spineItem = getSpineItem(i);
size_t s = 0;
getItemSize(spineItem, &s);
spineItemsSize += s;
cumulativeSpineItemSize.emplace_back(spineItemsSize);
}
Serial.printf("[%lu] [EBP] Book size: %u\n", millis(), spineItemsSize);
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str());
return true;
@ -255,6 +267,8 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
int Epub::getSpineItemsCount() const { return spine.size(); }
size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return cumulativeSpineItemSize.at(spineIndex); }
std::string& Epub::getSpineItem(const int spineIndex) {
if (spineIndex < 0 || spineIndex >= spine.size()) {
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex);
@ -302,3 +316,14 @@ int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
Serial.printf("[%lu] [EBP] TOC item not found\n", millis());
return -1;
}
size_t Epub::getBookSize() const { return getCumulativeSpineItemSize(getSpineItemsCount() - 1); }
// Calculate progress in book
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) {
size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
size_t bookSize = getBookSize();
size_t sectionProgSize = currentSpineRead * curChapterSize;
return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0);
}

View File

@ -20,6 +20,8 @@ class Epub {
std::string filepath;
// the spine of the EPUB file
std::vector<std::pair<std::string, std::string>> spine;
// the file size of the spine items (proxy to book progress)
std::vector<size_t> cumulativeSpineItemSize;
// the toc of the EPUB file
std::vector<EpubTocEntry> toc;
// the base path for items in the EPUB file
@ -51,8 +53,12 @@ class Epub {
bool getItemSize(const std::string& itemHref, size_t* size) const;
std::string& getSpineItem(int spineIndex);
int getSpineItemsCount() const;
EpubTocEntry& getTocItem(int tocTndex);
size_t getCumulativeSpineItemSize(const int spineIndex) const;
EpubTocEntry& getTocItem(int tocIndex);
int getTocItemsCount() const;
int getSpineIndexForTocIndex(int tocIndex) const;
int getTocIndexForSpineIndex(int spineIndex) const;
size_t getBookSize() const;
uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead);
};

View File

@ -3,7 +3,9 @@
#include <HardwareSerial.h>
#include <Serialization.h>
namespace {
constexpr uint8_t PAGE_FILE_VERSION = 3;
}
void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); }

View File

@ -9,7 +9,9 @@
#include "Page.h"
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 5;
}
void Section::onPageComplete(std::unique_ptr<Page> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin";

View File

@ -31,6 +31,9 @@ board_build.flash_mode = dio
board_build.flash_size = 16MB
board_build.partitions = partitions.csv
extra_scripts =
pre:scripts/build_html.py
; Libraries
lib_deps =
BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor

51
scripts/build_html.py Normal file
View File

@ -0,0 +1,51 @@
import os
import re
SRC_DIR = "src"
def minify_html(html: str) -> str:
# Tags where whitespace should be preserved
preserve_tags = ['pre', 'code', 'textarea', 'script', 'style']
preserve_regex = '|'.join(preserve_tags)
# Protect preserve blocks with placeholders
preserve_blocks = []
def preserve(match):
preserve_blocks.append(match.group(0))
return f"__PRESERVE_BLOCK_{len(preserve_blocks)-1}__"
html = re.sub(rf'<({preserve_regex})[\s\S]*?</\1>', preserve, html, flags=re.IGNORECASE)
# Remove HTML comments
html = re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL)
# Collapse all whitespace between tags
html = re.sub(r'>\s+<', '><', html)
# Collapse multiple spaces inside tags
html = re.sub(r'\s+', ' ', html)
# Restore preserved blocks
for i, block in enumerate(preserve_blocks):
html = html.replace(f"__PRESERVE_BLOCK_{i}__", block)
return html.strip()
for root, _, files in os.walk(SRC_DIR):
for file in files:
if file.endswith(".html"):
html_path = os.path.join(root, file)
with open(html_path, "r", encoding="utf-8") as f:
html_content = f.read()
# minified = regex.sub("\g<1>", html_content)
minified = minify_html(html_content)
base_name = f"{os.path.splitext(file)[0]}Html"
header_path = os.path.join(root, f"{base_name}.generated.h")
with open(header_path, "w", encoding="utf-8") as h:
h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n")
h.write(f"#pragma once\n")
h.write(f'constexpr char {base_name}[] PROGMEM = R"rawliteral({minified})rawliteral";\n')
print(f"Generated: {header_path}")

View File

@ -10,9 +10,11 @@
// Initialize the static instance
CrossPointSettings CrossPointSettings::instance;
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
constexpr uint8_t SETTINGS_COUNT = 2;
constexpr char SETTINGS_FILE[] = "/sd/.crosspoint/settings.bin";
} // namespace
bool CrossPointSettings::saveToFile() const {
// Make sure the directory exists

View File

@ -6,8 +6,12 @@
#include <fstream>
namespace {
constexpr uint8_t STATE_FILE_VERSION = 1;
constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin";
} // namespace
CrossPointState CrossPointState::instance;
bool CrossPointState::saveToFile() const {
std::ofstream outputFile(STATE_FILE);

View File

@ -3,11 +3,20 @@
#include <string>
class CrossPointState {
// Static instance
static CrossPointState instance;
public:
std::string openEpubPath;
~CrossPointState() = default;
// Get singleton instance
static CrossPointState& getInstance() { return instance; }
bool saveToFile() const;
bool loadFromFile();
};
// Helper macro to access settings
#define APP_STATE CrossPointState::getInstance()

706
src/CrossPointWebServer.cpp Normal file
View File

@ -0,0 +1,706 @@
#include "CrossPointWebServer.h"
#include <SD.h>
#include <WiFi.h>
#include <algorithm>
#include "config.h"
#include "html/FilesPageFooterHtml.generated.h"
#include "html/FilesPageHeaderHtml.generated.h"
#include "html/HomePageHtml.generated.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 += "&amp;";
break;
case '<':
output += "&lt;";
break;
case '>':
output += "&gt;";
break;
case '"':
output += "&quot;";
break;
case '\'':
output += "&#39;";
break;
default:
output += c;
break;
}
}
return output;
}
// File listing page template - now using generated headers:
// - HomePageHtml (from html/HomePage.html)
// - FilesPageHeaderHtml (from html/FilesPageHeader.html)
// - FilesPageFooterHtml (from html/FilesPageFooter.html)
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] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap());
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port);
server = new WebServer(port);
Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap());
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(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
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());
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap());
}
void CrossPointWebServer::stop() {
if (!running || !server) {
return;
}
Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap());
server->stop();
Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap());
delete server;
server = nullptr;
running = false;
Serial.printf("[%lu] [WEB] Web server stopped\n", millis());
Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap());
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
// later in the file and will be cleared when they go out of scope or on next upload
Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap());
}
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 = HomePageHtml;
// 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<FileInfo> CrossPointWebServer::scanFiles(const char* path) {
std::vector<FileInfo> 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 = FilesPageHeaderHtml;
// 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 += "<div class=\"message " + msgType + "\">" + msg + "</div>";
}
// Hidden input to store current path for JavaScript
html += "<input type=\"hidden\" id=\"currentPath\" value=\"" + currentPath + "\">";
// Scan files in current path first (we need counts for the header)
std::vector<FileInfo> 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 action buttons
html += "<div class=\"page-header\">";
html += "<div class=\"page-header-left\">";
html += "<h1>📁 File Manager</h1>";
// Inline breadcrumb
html += "<div class=\"breadcrumb-inline\">";
html += "<span class=\"sep\">/</span>";
if (currentPath == "/") {
html += "<span class=\"current\">🏠</span>";
} else {
html += "<a href=\"/files\">🏠</a>";
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 += "<span class=\"sep\">/</span><span class=\"current\">" + escapeHtml(part) + "</span>";
break;
} else {
part = pathParts.substring(start, end);
buildPath += "/" + part;
html += "<span class=\"sep\">/</span><a href=\"/files?path=" + buildPath + "\">" + escapeHtml(part) + "</a>";
start = end + 1;
end = pathParts.indexOf('/', start);
}
}
}
html += "</div>";
html += "</div>";
// Action buttons
html += "<div class=\"action-buttons\">";
html += "<button class=\"action-btn upload-action-btn\" onclick=\"openUploadModal()\">";
html += "📤 Upload";
html += "</button>";
html += "<button class=\"action-btn folder-action-btn\" onclick=\"openFolderModal()\">";
html += "📁 New Folder";
html += "</button>";
html += "</div>";
html += "</div>"; // end page-header
// Contents card with inline summary
html += "<div class=\"card\">";
// Contents header with inline stats
html += "<div class=\"contents-header\">";
html += "<h2 class=\"contents-title\">Contents</h2>";
html += "<span class=\"summary-inline\">";
html += String(folderCount) + " folder" + (folderCount != 1 ? "s" : "") + ", ";
html += String(files.size() - folderCount) + " file" + ((files.size() - folderCount) != 1 ? "s" : "") + ", ";
html += formatFileSize(totalSize);
html += "</span>";
html += "</div>";
if (files.empty()) {
html += "<div class=\"no-files\">This folder is empty</div>";
} else {
html += "<table class=\"file-table\">";
html += "<tr><th>Name</th><th>Type</th><th>Size</th><th class=\"actions-col\">Actions</th></tr>";
// 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 = "<span class=\"folder-badge\">FOLDER</span>";
typeStr = "Folder";
sizeStr = "-";
// Build the path to this folder
String folderPath = currentPath;
if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += file.name;
html += "<tr class=\"" + rowClass + "\">";
html += "<td><span class=\"file-icon\">" + icon + "</span>";
html += "<a href=\"/files?path=" + folderPath + "\" class=\"folder-link\">" + escapeHtml(file.name) + "</a>" +
badge + "</td>";
html += "<td>" + typeStr + "</td>";
html += "<td>" + sizeStr + "</td>";
// Escape quotes for JavaScript string
String escapedName = file.name;
escapedName.replace("'", "\\'");
String escapedPath = folderPath;
escapedPath.replace("'", "\\'");
html += "<td class=\"actions-col\"><button class=\"delete-btn\" onclick=\"openDeleteModal('" + escapedName +
"', '" + escapedPath + "', true)\" title=\"Delete folder\">🗑️</button></td>";
html += "</tr>";
} else {
rowClass = file.isEpub ? "epub-file" : "";
icon = file.isEpub ? "📗" : "📄";
badge = file.isEpub ? "<span class=\"epub-badge\">EPUB</span>" : "";
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 += "<tr class=\"" + rowClass + "\">";
html += "<td><span class=\"file-icon\">" + icon + "</span>" + escapeHtml(file.name) + badge + "</td>";
html += "<td>" + typeStr + "</td>";
html += "<td>" + sizeStr + "</td>";
// Escape quotes for JavaScript string
String escapedName = file.name;
escapedName.replace("'", "\\'");
String escapedPath = filePath;
escapedPath.replace("'", "\\'");
html += "<td class=\"actions-col\"><button class=\"delete-btn\" onclick=\"openDeleteModal('" + escapedName +
"', '" + escapedPath + "', false)\" title=\"Delete file\">🗑️</button></td>";
html += "</tr>";
}
}
html += "</table>";
}
html += "</div>";
html += FilesPageFooterHtml;
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() {
static unsigned long lastWriteTime = 0;
static unsigned long uploadStartTime = 0;
static size_t lastLoggedSize = 0;
HTTPUpload& upload = server->upload();
if (upload.status == UPLOAD_FILE_START) {
uploadFileName = upload.filename;
uploadSize = 0;
uploadSuccess = false;
uploadError = "";
uploadStartTime = millis();
lastWriteTime = millis();
lastLoggedSize = 0;
// 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());
Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap());
// 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] [UPLOAD] 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] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
return;
}
Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str());
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (uploadFile && uploadError.isEmpty()) {
unsigned long writeStartTime = millis();
size_t written = uploadFile.write(upload.buf, upload.currentSize);
unsigned long writeEndTime = millis();
unsigned long writeDuration = writeEndTime - writeStartTime;
if (written != upload.currentSize) {
uploadError = "Failed to write to SD card - disk may be full";
uploadFile.close();
Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize,
written);
} else {
uploadSize += written;
// Log progress every 50KB or if write took >100ms
if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) {
unsigned long timeSinceStart = millis() - uploadStartTime;
unsigned long timeSinceLastWrite = millis() - lastWriteTime;
float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0);
Serial.printf(
"[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu "
"ms\n",
millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite);
lastLoggedSize = uploadSize;
}
lastWriteTime = millis();
}
}
} 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");
}
}

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()

18
src/activities/Activity.h Normal file
View File

@ -0,0 +1,18 @@
#pragma once
#include <InputManager.h>
class GfxRenderer;
class Activity {
protected:
GfxRenderer& renderer;
InputManager& inputManager;
public:
explicit Activity(GfxRenderer& renderer, InputManager& inputManager)
: renderer(renderer), inputManager(inputManager) {}
virtual ~Activity() = default;
virtual void onEnter() {}
virtual void onExit() {}
virtual void loop() {}
};

View File

@ -0,0 +1,21 @@
#include "ActivityWithSubactivity.h"
void ActivityWithSubactivity::exitActivity() {
if (subActivity) {
subActivity->onExit();
subActivity.reset();
}
}
void ActivityWithSubactivity::enterNewActivity(Activity* activity) {
subActivity.reset(activity);
subActivity->onEnter();
}
void ActivityWithSubactivity::loop() {
if (subActivity) {
subActivity->loop();
}
}
void ActivityWithSubactivity::onExit() { exitActivity(); }

View File

@ -0,0 +1,17 @@
#pragma once
#include <memory>
#include "Activity.h"
class ActivityWithSubactivity : public Activity {
protected:
std::unique_ptr<Activity> subActivity = nullptr;
void exitActivity();
void enterNewActivity(Activity* activity);
public:
explicit ActivityWithSubactivity(GfxRenderer& renderer, InputManager& inputManager)
: Activity(renderer, inputManager) {}
void loop() override;
void onExit() override;
};

View File

@ -1,11 +1,11 @@
#include "BootLogoScreen.h"
#include "BootActivity.h"
#include <GfxRenderer.h>
#include "config.h"
#include "images/CrossLarge.h"
void BootLogoScreen::onEnter() {
void BootActivity::onEnter() {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();

View File

@ -0,0 +1,8 @@
#pragma once
#include "../Activity.h"
class BootActivity final : public Activity {
public:
explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {}
void onEnter() override;
};

View File

@ -1,4 +1,4 @@
#include "SleepScreen.h"
#include "SleepActivity.h"
#include <GfxRenderer.h>
@ -6,7 +6,7 @@
#include "config.h"
#include "images/CrossLarge.h"
void SleepScreen::onEnter() {
void SleepActivity::onEnter() {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();

View File

@ -0,0 +1,8 @@
#pragma once
#include "../Activity.h"
class SleepActivity final : public Activity {
public:
explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {}
void onEnter() override;
};

View File

@ -0,0 +1,103 @@
#include "HomeActivity.h"
#include <GfxRenderer.h>
#include <SD.h>
#include "config.h"
namespace {
constexpr int menuItemCount = 2;
}
void HomeActivity::taskTrampoline(void* param) {
auto* self = static_cast<HomeActivity*>(param);
self->displayTaskLoop();
}
void HomeActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
selectorIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void HomeActivity::onExit() {
// 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 HomeActivity::loop() {
const bool prevPressed =
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT);
const bool nextPressed =
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT);
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
if (selectorIndex == 0) {
onReaderOpen();
} else if (selectorIndex == 1) {
onSettingsOpen();
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuItemCount - 1) % menuItemCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuItemCount;
updateRequired = true;
}
}
void HomeActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void HomeActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Draw selection
renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30);
renderer.drawText(UI_FONT_ID, 20, 60, "Read", selectorIndex != 0);
renderer.drawText(UI_FONT_ID, 20, 90, "Settings", selectorIndex != 1);
renderer.drawRect(25, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back");
renderer.drawRect(130, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Confirm")) / 2, pageHeight - 35,
"Confirm");
renderer.drawRect(245, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 245 + (105 - renderer.getTextWidth(UI_FONT_ID, "Left")) / 2, pageHeight - 35, "Left");
renderer.drawRect(350, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 350 + (105 - renderer.getTextWidth(UI_FONT_ID, "Right")) / 2, pageHeight - 35, "Right");
renderer.displayBuffer();
}

View File

@ -0,0 +1,29 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include "../Activity.h"
class HomeActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void()> onReaderOpen;
const std::function<void()> onSettingsOpen;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen)
: Activity(renderer, inputManager), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

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

@ -0,0 +1,700 @@
#include "WifiScreen.h"
#include <GfxRenderer.h>
#include <WiFi.h>
#include <map>
#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() {
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap());
// Stop any ongoing WiFi scan
WiFi.scanDelete();
Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap());
// Stop the web server to free memory
crossPointWebServer.stop();
Serial.printf("[%lu] [WIFI] [MEM] Free heap after webserver stop: %d bytes\n", millis(), ESP.getFreeHeap());
// Disconnect WiFi to free memory
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
Serial.printf("[%lu] [WIFI] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap());
// Delete the display task
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
// Small delay to ensure task is fully deleted before cleaning up mutex
vTaskDelay(10 / portTICK_PERIOD_MS);
// Now safe to delete the mutex
if (renderingMutex) {
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
}
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
// Use a map to deduplicate networks by SSID, keeping the strongest signal
std::map<std::string, WifiNetworkInfo> uniqueNetworks;
for (int i = 0; i < scanResult; i++) {
std::string ssid = WiFi.SSID(i).c_str();
int32_t rssi = WiFi.RSSI(i);
// Skip hidden networks (empty SSID)
if (ssid.empty()) {
continue;
}
// Check if we've already seen this SSID
auto it = uniqueNetworks.find(ssid);
if (it == uniqueNetworks.end() || rssi > it->second.rssi) {
// New network or stronger signal than existing entry
WifiNetworkInfo network;
network.ssid = ssid;
network.rssi = rssi;
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
uniqueNetworks[ssid] = network;
}
}
// Convert map to vector
networks.clear();
for (const auto& pair : uniqueNetworks) {
networks.push_back(pair.second);
}
// 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::loop() {
// 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);
}

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 "../Activity.h"
#include "OnScreenKeyboard.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 Activity {
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)
: Activity(renderer, inputManager), onGoBack(onGoBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -1,4 +1,4 @@
#include "EpubReaderScreen.h"
#include "EpubReaderActivity.h"
#include <Epub/Page.h>
#include <GfxRenderer.h>
@ -6,23 +6,25 @@
#include "Battery.h"
#include "CrossPointSettings.h"
#include "EpubReaderChapterSelectionScreen.h"
#include "EpubReaderChapterSelectionActivity.h"
#include "config.h"
constexpr int PAGES_PER_REFRESH = 15;
constexpr unsigned long SKIP_CHAPTER_MS = 700;
namespace {
constexpr int pagesPerRefresh = 15;
constexpr unsigned long skipChapterMs = 700;
constexpr float lineCompression = 0.95f;
constexpr int marginTop = 8;
constexpr int marginRight = 10;
constexpr int marginBottom = 22;
constexpr int marginLeft = 10;
} // namespace
void EpubReaderScreen::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderScreen*>(param);
void EpubReaderActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderScreen::onEnter() {
void EpubReaderActivity::onEnter() {
if (!epub) {
return;
}
@ -44,7 +46,7 @@ void EpubReaderScreen::onEnter() {
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderScreen::taskTrampoline, "EpubReaderScreenTask",
xTaskCreate(&EpubReaderActivity::taskTrampoline, "EpubReaderActivityTask",
8192, // Stack size
this, // Parameters
1, // Priority
@ -52,7 +54,7 @@ void EpubReaderScreen::onEnter() {
);
}
void EpubReaderScreen::onExit() {
void EpubReaderActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
@ -65,22 +67,22 @@ void EpubReaderScreen::onExit() {
epub.reset();
}
void EpubReaderScreen::handleInput() {
// Pass input responsibility to sub screen if exists
if (subScreen) {
subScreen->handleInput();
void EpubReaderActivity::loop() {
// Pass input responsibility to sub activity if exists
if (subAcitivity) {
subAcitivity->loop();
return;
}
// Enter chapter selection screen
// Enter chapter selection activity
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
// Don't start screen transition while rendering
// Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY);
subScreen.reset(new EpubReaderChapterSelectionScreen(
subAcitivity.reset(new EpubReaderChapterSelectionActivity(
this->renderer, this->inputManager, epub, currentSpineIndex,
[this] {
subScreen->onExit();
subScreen.reset();
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
},
[this](const int newSpineIndex) {
@ -89,16 +91,16 @@ void EpubReaderScreen::handleInput() {
nextPageNumber = 0;
section.reset();
}
subScreen->onExit();
subScreen.reset();
subAcitivity->onExit();
subAcitivity.reset();
updateRequired = true;
}));
subScreen->onEnter();
subAcitivity->onEnter();
xSemaphoreGive(renderingMutex);
}
if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoHome();
onGoBack();
return;
}
@ -119,7 +121,7 @@ void EpubReaderScreen::handleInput() {
return;
}
const bool skipChapter = inputManager.getHeldTime() > SKIP_CHAPTER_MS;
const bool skipChapter = inputManager.getHeldTime() > skipChapterMs;
if (skipChapter) {
// We don't want to delete the section mid-render, so grab the semaphore
@ -165,7 +167,7 @@ void EpubReaderScreen::handleInput() {
}
}
void EpubReaderScreen::displayTaskLoop() {
void EpubReaderActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
@ -178,7 +180,7 @@ void EpubReaderScreen::displayTaskLoop() {
}
// TODO: Failure handling
void EpubReaderScreen::renderScreen() {
void EpubReaderActivity::renderScreen() {
if (!epub) {
return;
}
@ -285,12 +287,12 @@ void EpubReaderScreen::renderScreen() {
f.close();
}
void EpubReaderScreen::renderContents(std::unique_ptr<Page> page) {
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page) {
page->render(renderer, READER_FONT_ID);
renderStatusBar();
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
pagesUntilFullRefresh = PAGES_PER_REFRESH;
pagesUntilFullRefresh = pagesPerRefresh;
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
@ -322,10 +324,16 @@ void EpubReaderScreen::renderContents(std::unique_ptr<Page> page) {
renderer.restoreBwBuffer();
}
void EpubReaderScreen::renderStatusBar() const {
void EpubReaderActivity::renderStatusBar() const {
constexpr auto textY = 776;
// Calculate progress in book
float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
// Right aligned text for progress counter
const std::string progress = std::to_string(section->currentPage + 1) + " / " + std::to_string(section->pageCount);
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
" " + std::to_string(bookProgress) + "%";
const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY,
progress.c_str());

View File

@ -5,19 +5,19 @@
#include <freertos/semphr.h>
#include <freertos/task.h>
#include "Screen.h"
#include "../Activity.h"
class EpubReaderScreen final : public Screen {
class EpubReaderActivity final : public Activity {
std::shared_ptr<Epub> epub;
std::unique_ptr<Section> section = nullptr;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::unique_ptr<Screen> subScreen = nullptr;
std::unique_ptr<Activity> subAcitivity = nullptr;
int currentSpineIndex = 0;
int nextPageNumber = 0;
int pagesUntilFullRefresh = 0;
bool updateRequired = false;
const std::function<void()> onGoHome;
const std::function<void()> onGoBack;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
@ -26,10 +26,10 @@ class EpubReaderScreen final : public Screen {
void renderStatusBar() const;
public:
explicit EpubReaderScreen(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoHome)
: Screen(renderer, inputManager), epub(std::move(epub)), onGoHome(onGoHome) {}
explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack)
: Activity(renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {}
void onEnter() override;
void onExit() override;
void handleInput() override;
void loop() override;
};

View File

@ -1,4 +1,4 @@
#include "EpubReaderChapterSelectionScreen.h"
#include "EpubReaderChapterSelectionActivity.h"
#include <GfxRenderer.h>
#include <SD.h>
@ -8,12 +8,12 @@
constexpr int PAGE_ITEMS = 24;
constexpr int SKIP_PAGE_MS = 700;
void EpubReaderChapterSelectionScreen::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderChapterSelectionScreen*>(param);
void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderChapterSelectionActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderChapterSelectionScreen::onEnter() {
void EpubReaderChapterSelectionActivity::onEnter() {
if (!epub) {
return;
}
@ -23,7 +23,7 @@ void EpubReaderChapterSelectionScreen::onEnter() {
// Trigger first update
updateRequired = true;
xTaskCreate(&EpubReaderChapterSelectionScreen::taskTrampoline, "EpubReaderChapterSelectionScreenTask",
xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
@ -31,7 +31,7 @@ void EpubReaderChapterSelectionScreen::onEnter() {
);
}
void EpubReaderChapterSelectionScreen::onExit() {
void EpubReaderChapterSelectionActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
@ -42,7 +42,7 @@ void EpubReaderChapterSelectionScreen::onExit() {
renderingMutex = nullptr;
}
void EpubReaderChapterSelectionScreen::handleInput() {
void EpubReaderChapterSelectionActivity::loop() {
const bool prevReleased =
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT);
const bool nextReleased =
@ -72,7 +72,7 @@ void EpubReaderChapterSelectionScreen::handleInput() {
}
}
void EpubReaderChapterSelectionScreen::displayTaskLoop() {
void EpubReaderChapterSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
@ -84,7 +84,7 @@ void EpubReaderChapterSelectionScreen::displayTaskLoop() {
}
}
void EpubReaderChapterSelectionScreen::renderScreen() {
void EpubReaderChapterSelectionActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();

View File

@ -6,9 +6,9 @@
#include <memory>
#include "Screen.h"
#include "../Activity.h"
class EpubReaderChapterSelectionScreen final : public Screen {
class EpubReaderChapterSelectionActivity final : public Activity {
std::shared_ptr<Epub> epub;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
@ -23,16 +23,16 @@ class EpubReaderChapterSelectionScreen final : public Screen {
void renderScreen();
public:
explicit EpubReaderChapterSelectionScreen(GfxRenderer& renderer, InputManager& inputManager,
const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex)>& onSelectSpineIndex)
: Screen(renderer, inputManager),
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex)>& onSelectSpineIndex)
: Activity(renderer, inputManager),
epub(epub),
currentSpineIndex(currentSpineIndex),
onGoBack(onGoBack),
onSelectSpineIndex(onSelectSpineIndex) {}
void onEnter() override;
void onExit() override;
void handleInput() override;
void loop() override;
};

View File

@ -1,4 +1,4 @@
#include "FileSelectionScreen.h"
#include "FileSelectionActivity.h"
#include <GfxRenderer.h>
#include <SD.h>
@ -15,12 +15,12 @@ void sortFileList(std::vector<std::string>& strs) {
});
}
void FileSelectionScreen::taskTrampoline(void* param) {
auto* self = static_cast<FileSelectionScreen*>(param);
void FileSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<FileSelectionActivity*>(param);
self->displayTaskLoop();
}
void FileSelectionScreen::loadFiles() {
void FileSelectionActivity::loadFiles() {
files.clear();
selectorIndex = 0;
auto root = SD.open(basepath.c_str());
@ -42,7 +42,7 @@ void FileSelectionScreen::loadFiles() {
sortFileList(files);
}
void FileSelectionScreen::onEnter() {
void FileSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
basepath = "/";
@ -52,7 +52,7 @@ void FileSelectionScreen::onEnter() {
// Trigger first update
updateRequired = true;
xTaskCreate(&FileSelectionScreen::taskTrampoline, "FileSelectionScreenTask",
xTaskCreate(&FileSelectionActivity::taskTrampoline, "FileSelectionActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
@ -60,7 +60,7 @@ void FileSelectionScreen::onEnter() {
);
}
void FileSelectionScreen::onExit() {
void FileSelectionActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
@ -72,7 +72,7 @@ void FileSelectionScreen::onExit() {
files.clear();
}
void FileSelectionScreen::handleInput() {
void FileSelectionActivity::loop() {
const bool prevPressed =
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT);
const bool nextPressed =
@ -98,8 +98,8 @@ void FileSelectionScreen::handleInput() {
loadFiles();
updateRequired = true;
} else {
// At root level, go to settings
onSettingsOpen();
// At root level, go back home
onGoHome();
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + files.size() - 1) % files.size();
@ -110,7 +110,7 @@ void FileSelectionScreen::handleInput() {
}
}
void FileSelectionScreen::displayTaskLoop() {
void FileSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
@ -122,14 +122,14 @@ void FileSelectionScreen::displayTaskLoop() {
}
}
void FileSelectionScreen::render() const {
void FileSelectionActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD);
// Help text
renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Settings");
renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home");
if (files.empty()) {
renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found");

View File

@ -7,9 +7,9 @@
#include <string>
#include <vector>
#include "Screen.h"
#include "../Activity.h"
class FileSelectionScreen final : public Screen {
class FileSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::string basepath = "/";
@ -17,7 +17,7 @@ class FileSelectionScreen final : public Screen {
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void(const std::string&)> onSelect;
const std::function<void()> onSettingsOpen;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
@ -25,11 +25,11 @@ class FileSelectionScreen final : public Screen {
void loadFiles();
public:
explicit FileSelectionScreen(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onSettingsOpen)
: Screen(renderer, inputManager), onSelect(onSelect), onSettingsOpen(onSettingsOpen) {}
explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager,
const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onGoHome)
: Activity(renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void handleInput() override;
void loop() override;
};

View File

@ -0,0 +1,68 @@
#include "ReaderActivity.h"
#include <SD.h>
#include "CrossPointState.h"
#include "Epub.h"
#include "EpubReaderActivity.h"
#include "FileSelectionActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!SD.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
return nullptr;
}
auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint"));
if (epub->load()) {
return epub;
}
Serial.printf("[%lu] [ ] Failed to load epub\n", millis());
return nullptr;
}
void ReaderActivity::onSelectEpubFile(const std::string& path) {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading..."));
auto epub = loadEpub(path);
if (epub) {
APP_STATE.openEpubPath = path;
APP_STATE.saveToFile();
onGoToEpubReader(std::move(epub));
} else {
exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR,
EInkDisplay::HALF_REFRESH));
delay(2000);
onGoToFileSelection();
}
}
void ReaderActivity::onGoToFileSelection() {
exitActivity();
enterNewActivity(new FileSelectionActivity(
renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack));
}
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
exitActivity();
enterNewActivity(new EpubReaderActivity(renderer, inputManager, std::move(epub), [this] { onGoToFileSelection(); }));
}
void ReaderActivity::onEnter() {
if (initialEpubPath.empty()) {
onGoToFileSelection();
return;
}
auto epub = loadEpub(initialEpubPath);
if (!epub) {
onGoBack();
return;
}
onGoToEpubReader(std::move(epub));
}

View File

@ -0,0 +1,24 @@
#pragma once
#include <memory>
#include "../ActivityWithSubactivity.h"
class Epub;
class ReaderActivity final : public ActivityWithSubactivity {
std::string initialEpubPath;
const std::function<void()> onGoBack;
static std::unique_ptr<Epub> loadEpub(const std::string& path);
void onSelectEpubFile(const std::string& path);
void onGoToFileSelection();
void onGoToEpubReader(std::unique_ptr<Epub> epub);
public:
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath,
const std::function<void()>& onGoBack)
: ActivityWithSubactivity(renderer, inputManager),
initialEpubPath(std::move(initialEpubPath)),
onGoBack(onGoBack) {}
void onEnter() override;
};

View File

@ -1,4 +1,4 @@
#include "SettingsScreen.h"
#include "SettingsActivity.h"
#include <GfxRenderer.h>
@ -7,16 +7,17 @@
// Define the static settings list
const SettingInfo SettingsScreen::settingsList[SettingsScreen::settingsCount] = {
{"White Sleep Screen", &CrossPointSettings::whiteSleepScreen},
{"Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing}};
const SettingInfo SettingsActivity::settingsList[settingsCount] = {
{"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<SettingsScreen*>(param);
void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop();
}
void SettingsScreen::onEnter() {
void SettingsActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Reset selection to first item
@ -25,7 +26,7 @@ void SettingsScreen::onEnter() {
// Trigger first update
updateRequired = true;
xTaskCreate(&SettingsScreen::taskTrampoline, "SettingsScreenTask",
xTaskCreate(&SettingsActivity::taskTrampoline, "SettingsActivityTask",
2048, // Stack size
this, // Parameters
1, // Priority
@ -33,7 +34,7 @@ void SettingsScreen::onEnter() {
);
}
void SettingsScreen::onExit() {
void SettingsActivity::onExit() {
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
@ -44,10 +45,10 @@ void SettingsScreen::onExit() {
renderingMutex = nullptr;
}
void SettingsScreen::handleInput() {
void SettingsActivity::loop() {
// Handle actions with early return
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
toggleCurrentSetting();
activateCurrentSetting();
updateRequired = true;
return;
}
@ -64,27 +65,56 @@ 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;
updateRequired = true;
// Move selection down
if (selectedSettingIndex < settingsCount - 1) {
selectedSettingIndex++;
updateRequired = true;
}
}
}
void SettingsScreen::toggleCurrentSetting() {
void SettingsActivity::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();
}
}
}
void SettingsActivity::toggleCurrentSetting() {
// Validate index
if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) {
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();
}
void SettingsScreen::displayTaskLoop() {
void SettingsActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
@ -96,7 +126,7 @@ void SettingsScreen::displayTaskLoop() {
}
}
void SettingsScreen::render() const {
void SettingsActivity::render() const {
renderer.clearScreen();
const auto pageWidth = GfxRenderer::getScreenWidth();
@ -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();

View File

@ -4,39 +4,46 @@
#include <freertos/task.h>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
#include "Screen.h"
#include "../Activity.h"
class CrossPointSettings;
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 {
class SettingsActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
int selectedSettingIndex = 0; // Currently selected setting
const std::function<void()> onGoHome;
const std::function<void()> 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<void()>& onGoHome)
: Screen(renderer, inputManager), onGoHome(onGoHome) {}
explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome,
const std::function<void()>& onGoWifi)
: Activity(renderer, inputManager), onGoHome(onGoHome), onGoWifi(onGoWifi) {}
void onEnter() override;
void onExit() override;
void handleInput() override;
void loop() override;
};

View File

@ -1,10 +1,10 @@
#include "FullScreenMessageScreen.h"
#include "FullScreenMessageActivity.h"
#include <GfxRenderer.h>
#include "config.h"
void FullScreenMessageScreen::onEnter() {
void FullScreenMessageActivity::onEnter() {
const auto height = renderer.getLineHeight(UI_FONT_ID);
const auto top = (GfxRenderer::getScreenHeight() - height) / 2;

View File

@ -0,0 +1,21 @@
#pragma once
#include <EInkDisplay.h>
#include <EpdFontFamily.h>
#include <string>
#include <utility>
#include "../Activity.h"
class FullScreenMessageActivity final : public Activity {
std::string text;
EpdFontStyle style;
EInkDisplay::RefreshMode refreshMode;
public:
explicit FullScreenMessageActivity(GfxRenderer& renderer, InputManager& inputManager, std::string text,
const EpdFontStyle style = REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
: Activity(renderer, inputManager), text(std::move(text)), style(style), refreshMode(refreshMode) {}
void onEnter() override;
};

View File

@ -0,0 +1,251 @@
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0;">
CrossPoint E-Reader • Open Source
</p>
</div>
<!-- Upload Modal -->
<div class="modal-overlay" id="uploadModal">
<div class="modal">
<button class="modal-close" onclick="closeUploadModal()">&times;</button>
<h3>📤 Upload eBook</h3>
<div class="upload-form">
<p class="file-info">Select an .epub file to upload to <strong id="uploadPathDisplay"></strong></p>
<input type="file" id="fileInput" accept=".epub" onchange="validateFile()">
<button id="uploadBtn" class="upload-btn" onclick="uploadFile()" disabled>Upload</button>
<div id="progress-container">
<div id="progress-bar"><div id="progress-fill"></div></div>
<div id="progress-text"></div>
</div>
</div>
</div>
</div>
<!-- New Folder Modal -->
<div class="modal-overlay" id="folderModal">
<div class="modal">
<button class="modal-close" onclick="closeFolderModal()">&times;</button>
<h3>📁 New Folder</h3>
<div class="folder-form">
<p class="file-info">Create a new folder in <strong id="folderPathDisplay"></strong></p>
<input type="text" id="folderName" class="folder-input" placeholder="Folder name...">
<button class="folder-btn" onclick="createFolder()">Create Folder</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="deleteModal">
<div class="modal">
<button class="modal-close" onclick="closeDeleteModal()">&times;</button>
<h3>🗑️ Delete Item</h3>
<div class="folder-form">
<p class="delete-warning">⚠️ This action cannot be undone!</p>
<p class="file-info">Are you sure you want to delete:</p>
<p class="delete-item-name" id="deleteItemName"></p>
<input type="hidden" id="deleteItemPath">
<input type="hidden" id="deleteItemType">
<button class="delete-btn-confirm" onclick="confirmDelete()">Delete</button>
<button class="delete-btn-cancel" onclick="closeDeleteModal()">Cancel</button>
</div>
</div>
</div>
<script>
// Modal functions
function openUploadModal() {
const currentPath = document.getElementById('currentPath').value;
document.getElementById('uploadPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
document.getElementById('uploadModal').classList.add('open');
}
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('open');
document.getElementById('fileInput').value = '';
document.getElementById('uploadBtn').disabled = true;
document.getElementById('progress-container').style.display = 'none';
document.getElementById('progress-fill').style.width = '0%';
document.getElementById('progress-fill').style.backgroundColor = '#27ae60';
}
function openFolderModal() {
const currentPath = document.getElementById('currentPath').value;
document.getElementById('folderPathDisplay').textContent = currentPath === '/' ? '/ 🏠' : currentPath;
document.getElementById('folderModal').classList.add('open');
document.getElementById('folderName').value = '';
}
function closeFolderModal() {
document.getElementById('folderModal').classList.remove('open');
}
// Close modals when clicking overlay
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
overlay.classList.remove('open');
}
});
});
function validateFile() {
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const file = fileInput.files[0];
if (file) {
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.epub')) {
alert('Only .epub files are allowed!');
fileInput.value = '';
uploadBtn.disabled = true;
return;
}
uploadBtn.disabled = false;
} else {
uploadBtn.disabled = true;
}
}
function uploadFile() {
const fileInput = document.getElementById('fileInput');
const file = fileInput.files[0];
const currentPath = document.getElementById('currentPath').value;
if (!file) {
alert('Please select a file first!');
return;
}
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.epub')) {
alert('Only .epub files are allowed!');
return;
}
const formData = new FormData();
formData.append('file', file);
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const uploadBtn = document.getElementById('uploadBtn');
progressContainer.style.display = 'block';
uploadBtn.disabled = true;
const xhr = new XMLHttpRequest();
// Include path as query parameter since multipart form data doesn't make
// form fields available until after file upload completes
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = 'Uploading: ' + percent + '%';
}
};
xhr.onload = function() {
if (xhr.status === 200) {
progressText.textContent = 'Upload complete!';
setTimeout(function() {
window.location.reload();
}, 1000);
} else {
progressText.textContent = 'Upload failed: ' + xhr.responseText;
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
}
};
xhr.onerror = function() {
progressText.textContent = 'Upload failed - network error';
progressFill.style.backgroundColor = '#e74c3c';
uploadBtn.disabled = false;
};
xhr.send(formData);
}
function createFolder() {
const folderName = document.getElementById('folderName').value.trim();
const currentPath = document.getElementById('currentPath').value;
if (!folderName) {
alert('Please enter a folder name!');
return;
}
// Validate folder name (no special characters except underscore and hyphen)
const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName);
if (!validName) {
alert('Folder name can only contain letters, numbers, underscores, and hyphens.');
return;
}
const formData = new FormData();
formData.append('name', folderName);
formData.append('path', currentPath);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/mkdir', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to create folder: ' + xhr.responseText);
}
};
xhr.onerror = function() {
alert('Failed to create folder - network error');
};
xhr.send(formData);
}
// Delete functions
function openDeleteModal(name, path, isFolder) {
document.getElementById('deleteItemName').textContent = (isFolder ? '📁 ' : '📄 ') + name;
document.getElementById('deleteItemPath').value = path;
document.getElementById('deleteItemType').value = isFolder ? 'folder' : 'file';
document.getElementById('deleteModal').classList.add('open');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.remove('open');
}
function confirmDelete() {
const path = document.getElementById('deleteItemPath').value;
const itemType = document.getElementById('deleteItemType').value;
const formData = new FormData();
formData.append('path', path);
formData.append('type', itemType);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/delete', true);
xhr.onload = function() {
if (xhr.status === 200) {
window.location.reload();
} else {
alert('Failed to delete: ' + xhr.responseText);
closeDeleteModal();
}
};
xhr.onerror = function() {
alert('Failed to delete - network error');
closeDeleteModal();
};
xhr.send(formData);
}
</script>
</body>
</html>

View File

@ -0,0 +1,472 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader - Files</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: #2c3e50;
margin-bottom: 5px;
}
h2 {
color: #34495e;
margin-top: 0;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #3498db;
}
.page-header-left {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.breadcrumb-inline {
color: #7f8c8d;
font-size: 1.1em;
}
.breadcrumb-inline a {
color: #3498db;
text-decoration: none;
}
.breadcrumb-inline a:hover {
text-decoration: underline;
}
.breadcrumb-inline .sep {
margin: 0 6px;
color: #bdc3c7;
}
.breadcrumb-inline .current {
color: #2c3e50;
font-weight: 500;
}
.nav-links {
margin: 20px 0;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.nav-links a:hover {
background-color: #2980b9;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 10px;
}
.action-btn {
color: white;
padding: 10px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.95em;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.upload-action-btn {
background-color: #27ae60;
}
.upload-action-btn:hover {
background-color: #219a52;
}
.folder-action-btn {
background-color: #f39c12;
}
.folder-action-btn:hover {
background-color: #d68910;
}
/* Upload modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
justify-content: center;
align-items: center;
}
.modal-overlay.open {
display: flex;
}
.modal {
background: white;
border-radius: 8px;
padding: 25px;
max-width: 450px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal h3 {
margin: 0 0 15px 0;
color: #2c3e50;
}
.modal-close {
float: right;
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: #7f8c8d;
line-height: 1;
}
.modal-close:hover {
color: #2c3e50;
}
.file-table {
width: 100%;
border-collapse: collapse;
}
.file-table th,
.file-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.file-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #7f8c8d;
}
.file-table tr:hover {
background-color: #f8f9fa;
}
.epub-file {
background-color: #e8f6e9 !important;
}
.epub-file:hover {
background-color: #d4edda !important;
}
.folder-row {
background-color: #fff9e6 !important;
}
.folder-row:hover {
background-color: #fff3cd !important;
}
.epub-badge {
display: inline-block;
padding: 2px 8px;
background-color: #27ae60;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.folder-badge {
display: inline-block;
padding: 2px 8px;
background-color: #f39c12;
color: white;
border-radius: 10px;
font-size: 0.75em;
margin-left: 8px;
}
.file-icon {
margin-right: 8px;
}
.folder-link {
color: #2c3e50;
text-decoration: none;
cursor: pointer;
}
.folder-link:hover {
color: #3498db;
text-decoration: underline;
}
.upload-form {
margin-top: 10px;
}
.upload-form input[type="file"] {
margin: 10px 0;
width: 100%;
}
.upload-btn {
background-color: #27ae60;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.upload-btn:hover {
background-color: #219a52;
}
.upload-btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.file-info {
color: #7f8c8d;
font-size: 0.85em;
margin: 8px 0;
}
.no-files {
text-align: center;
color: #95a5a6;
padding: 40px;
font-style: italic;
}
.message {
padding: 15px;
border-radius: 4px;
margin: 15px 0;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.contents-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.contents-title {
font-size: 1.1em;
font-weight: 600;
color: #34495e;
margin: 0;
}
.summary-inline {
color: #7f8c8d;
font-size: 0.9em;
}
#progress-container {
display: none;
margin-top: 10px;
}
#progress-bar {
width: 100%;
height: 20px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}
#progress-fill {
height: 100%;
background-color: #27ae60;
width: 0%;
transition: width 0.3s;
}
#progress-text {
text-align: center;
margin-top: 5px;
font-size: 0.9em;
color: #7f8c8d;
}
.folder-form {
margin-top: 10px;
}
.folder-input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1em;
margin-bottom: 10px;
box-sizing: border-box;
}
.folder-btn {
background-color: #f39c12;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.folder-btn:hover {
background-color: #d68910;
}
/* Delete button styles */
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.1em;
padding: 4px 8px;
border-radius: 4px;
color: #95a5a6;
transition: all 0.15s;
}
.delete-btn:hover {
background-color: #fee;
color: #e74c3c;
}
.actions-col {
width: 60px;
text-align: center;
}
/* Delete modal */
.delete-warning {
color: #e74c3c;
font-weight: 600;
margin: 10px 0;
}
.delete-item-name {
font-weight: 600;
color: #2c3e50;
word-break: break-all;
}
.delete-btn-confirm {
background-color: #e74c3c;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
}
.delete-btn-confirm:hover {
background-color: #c0392b;
}
.delete-btn-cancel {
background-color: #95a5a6;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1em;
width: 100%;
margin-top: 10px;
}
.delete-btn-cancel:hover {
background-color: #7f8c8d;
}
/* Mobile responsive styles */
@media (max-width: 600px) {
body {
padding: 10px;
font-size: 14px;
}
.card {
padding: 12px;
margin: 10px 0;
}
.page-header {
gap: 10px;
margin-bottom: 12px;
padding-bottom: 10px;
}
.page-header-left {
gap: 8px;
}
h1 {
font-size: 1.3em;
}
.breadcrumb-inline {
font-size: 0.95em;
}
.nav-links a {
padding: 8px 12px;
margin-right: 6px;
font-size: 0.9em;
}
.action-buttons {
gap: 6px;
}
.action-btn {
padding: 8px 10px;
font-size: 0.85em;
}
.file-table th,
.file-table td {
padding: 8px 6px;
font-size: 0.9em;
}
.file-table th {
font-size: 0.85em;
}
.file-icon {
margin-right: 4px;
}
.epub-badge,
.folder-badge {
padding: 2px 5px;
font-size: 0.65em;
margin-left: 4px;
}
.contents-header {
margin-bottom: 8px;
flex-wrap: wrap;
gap: 4px;
}
.contents-title {
font-size: 1em;
}
.summary-inline {
font-size: 0.8em;
}
.modal {
padding: 15px;
}
.modal h3 {
font-size: 1.1em;
}
.actions-col {
width: 40px;
}
.delete-btn {
font-size: 1em;
padding: 2px 4px;
}
.no-files {
padding: 20px;
font-size: 0.9em;
}
}
</style>
</head>
<body>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
</div>
</body>
</html>

108
src/html/HomePage.html Normal file
View File

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CrossPoint Reader</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
h2 {
color: #34495e;
margin-top: 0;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.info-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #eee;
}
.info-row:last-child {
border-bottom: none;
}
.label {
font-weight: 600;
color: #7f8c8d;
}
.value {
color: #2c3e50;
}
.status {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
background-color: #27ae60;
color: white;
font-size: 0.9em;
}
.nav-links {
margin: 20px 0;
}
.nav-links a {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
margin-right: 10px;
}
.nav-links a:hover {
background-color: #2980b9;
}
</style>
</head>
<body>
<h1>📚 CrossPoint Reader</h1>
<div class="nav-links">
<a href="/">Home</a>
<a href="/files">File Manager</a>
</div>
<div class="card">
<h2>Device Status</h2>
<div class="info-row">
<span class="label">Version</span>
<span class="value">%VERSION%</span>
</div>
<div class="info-row">
<span class="label">WiFi Status</span>
<span class="status">Connected</span>
</div>
<div class="info-row">
<span class="label">IP Address</span>
<span class="value">%IP_ADDRESS%</span>
</div>
<div class="info-row">
<span class="label">Free Memory</span>
<span class="value">%FREE_HEAP% bytes</span>
</div>
</div>
<div class="card">
<p style="text-align: center; color: #95a5a6; margin: 0">
CrossPoint E-Reader • Open Source
</p>
</div>
</body>
</html>

View File

@ -5,6 +5,7 @@
#include <InputManager.h>
#include <SD.h>
#include <SPI.h>
#include <WiFi.h>
#include <builtinFonts/bookerly_2b.h>
#include <builtinFonts/bookerly_bold_2b.h>
#include <builtinFonts/bookerly_bold_italic_2b.h>
@ -16,13 +17,15 @@
#include "Battery.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "CrossPointWebServer.h"
#include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h"
#include "activities/home/HomeActivity.h"
#include "activities/network/WifiScreen.h"
#include "activities/reader/ReaderActivity.h"
#include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
#include "config.h"
#include "screens/BootLogoScreen.h"
#include "screens/EpubReaderScreen.h"
#include "screens/FileSelectionScreen.h"
#include "screens/FullScreenMessageScreen.h"
#include "screens/SettingsScreen.h"
#include "screens/SleepScreen.h"
#define SPI_FQ 40000000
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
@ -41,8 +44,7 @@
EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
InputManager inputManager;
GfxRenderer renderer(einkDisplay);
Screen* currentScreen;
CrossPointState appState;
Activity* currentActivity;
// Fonts
EpdFont bookerlyFont(&bookerly_2b);
@ -66,31 +68,16 @@ constexpr unsigned long POWER_BUTTON_SLEEP_MS = 500;
// Auto-sleep timeout (10 minutes of inactivity)
constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000;
std::unique_ptr<Epub> loadEpub(const std::string& path) {
if (!SD.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str());
return nullptr;
}
auto epub = std::unique_ptr<Epub>(new Epub(path, "/.crosspoint"));
if (epub->load()) {
return epub;
}
Serial.printf("[%lu] [ ] Failed to load epub\n", millis());
return nullptr;
}
void exitScreen() {
if (currentScreen) {
currentScreen->onExit();
delete currentScreen;
void exitActivity() {
if (currentActivity) {
currentActivity->onExit();
delete currentActivity;
}
}
void enterNewScreen(Screen* screen) {
currentScreen = screen;
currentScreen->onEnter();
void enterNewActivity(Activity* activity) {
currentActivity = activity;
currentActivity->onEnter();
}
// Verify long press on wake-up from deep sleep
@ -134,8 +121,8 @@ void waitForPowerRelease() {
// Enter deep sleep mode
void enterDeepSleep() {
exitScreen();
enterNewScreen(new SleepScreen(renderer, inputManager));
exitActivity();
enterNewActivity(new SleepActivity(renderer, inputManager));
Serial.printf("[%lu] [ ] Power button released after a long press. Entering deep sleep.\n", millis());
delay(1000); // Allow Serial buffer to empty and display to update
@ -150,33 +137,27 @@ void enterDeepSleep() {
}
void onGoHome();
void onSelectEpubFile(const std::string& path) {
exitScreen();
enterNewScreen(new FullScreenMessageScreen(renderer, inputManager, "Loading..."));
void onGoToReader(const std::string& initialEpubPath) {
exitActivity();
enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome));
}
void onGoToReaderHome() { onGoToReader(std::string()); }
auto epub = loadEpub(path);
if (epub) {
appState.openEpubPath = path;
appState.saveToFile();
exitScreen();
enterNewScreen(new EpubReaderScreen(renderer, inputManager, std::move(epub), onGoHome));
} else {
exitScreen();
enterNewScreen(
new FullScreenMessageScreen(renderer, inputManager, "Failed to load epub", REGULAR, EInkDisplay::HALF_REFRESH));
delay(2000);
onGoHome();
}
void onGoToSettings();
void onGoToWifi() {
exitActivity();
enterNewActivity(new WifiScreen(renderer, inputManager, onGoToSettings));
}
void onGoToSettings() {
exitScreen();
enterNewScreen(new SettingsScreen(renderer, inputManager, onGoHome));
exitActivity();
enterNewActivity(new SettingsActivity(renderer, inputManager, onGoHome, onGoToWifi));
}
void onGoHome() {
exitScreen();
enterNewScreen(new FileSelectionScreen(renderer, inputManager, onSelectEpubFile, onGoToSettings));
exitActivity();
enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings));
}
void setup() {
@ -202,34 +183,39 @@ void setup() {
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
exitScreen();
enterNewScreen(new BootLogoScreen(renderer, inputManager));
exitActivity();
enterNewActivity(new BootActivity(renderer, inputManager));
// SD Card Initialization
SD.begin(SD_SPI_CS, SPI, SPI_FQ);
SETTINGS.loadFromFile();
appState.loadFromFile();
if (!appState.openEpubPath.empty()) {
auto epub = loadEpub(appState.openEpubPath);
if (epub) {
exitScreen();
enterNewScreen(new EpubReaderScreen(renderer, inputManager, std::move(epub), onGoHome));
// Ensure we're not still holding the power button before leaving setup
waitForPowerRelease();
return;
}
APP_STATE.loadFromFile();
if (APP_STATE.openEpubPath.empty()) {
onGoHome();
} else {
onGoToReader(APP_STATE.openEpubPath);
}
exitScreen();
enterNewScreen(new FileSelectionScreen(renderer, inputManager, onSelectEpubFile, onGoToSettings));
// Ensure we're not still holding the power button before leaving setup
waitForPowerRelease();
}
void loop() {
delay(10);
static unsigned long lastLoopTime = 0;
static unsigned long maxLoopDuration = 0;
static unsigned long lastHandleClientTime = 0;
unsigned long loopStartTime = millis();
unsigned long timeSinceLastLoop = loopStartTime - lastLoopTime;
// Reduce delay when webserver is running to allow faster handleClient() calls
// This is critical for upload performance and preventing TCP timeouts
if (crossPointWebServer.isRunning()) {
delay(1); // Minimal delay to prevent tight loop
} else {
delay(10); // Normal delay when webserver not active
}
static unsigned long lastMemPrint = 0;
if (Serial && millis() - lastMemPrint >= 10000) {
@ -259,7 +245,34 @@ void loop() {
return;
}
if (currentScreen) {
currentScreen->handleInput();
unsigned long activityStartTime = millis();
if (currentActivity) {
currentActivity->loop();
}
unsigned long activityDuration = millis() - activityStartTime;
// Handle web server requests if running
if (crossPointWebServer.isRunning()) {
unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
// Log if there's a significant gap between handleClient calls (>100ms)
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [LOOP] WARNING: %lu ms gap since last handleClient (activity took %lu ms)\n", millis(),
timeSinceLastHandleClient, activityDuration);
}
crossPointWebServer.handleClient();
lastHandleClientTime = millis();
}
unsigned long loopDuration = millis() - loopStartTime;
if (loopDuration > maxLoopDuration) {
maxLoopDuration = loopDuration;
if (maxLoopDuration > 50) {
Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration,
activityDuration);
}
}
lastLoopTime = loopStartTime;
}

View File

@ -1,8 +0,0 @@
#pragma once
#include "Screen.h"
class BootLogoScreen final : public Screen {
public:
explicit BootLogoScreen(GfxRenderer& renderer, InputManager& inputManager) : Screen(renderer, inputManager) {}
void onEnter() override;
};

View File

@ -1,21 +0,0 @@
#pragma once
#include <EInkDisplay.h>
#include <EpdFontFamily.h>
#include <string>
#include <utility>
#include "Screen.h"
class FullScreenMessageScreen final : public Screen {
std::string text;
EpdFontStyle style;
EInkDisplay::RefreshMode refreshMode;
public:
explicit FullScreenMessageScreen(GfxRenderer& renderer, InputManager& inputManager, std::string text,
const EpdFontStyle style = REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
: Screen(renderer, inputManager), text(std::move(text)), style(style), refreshMode(refreshMode) {}
void onEnter() override;
};

View File

@ -1,17 +0,0 @@
#pragma once
#include <InputManager.h>
class GfxRenderer;
class Screen {
protected:
GfxRenderer& renderer;
InputManager& inputManager;
public:
explicit Screen(GfxRenderer& renderer, InputManager& inputManager) : renderer(renderer), inputManager(inputManager) {}
virtual ~Screen() = default;
virtual void onEnter() {}
virtual void onExit() {}
virtual void handleInput() {}
};

View File

@ -1,8 +0,0 @@
#pragma once
#include "Screen.h"
class SleepScreen final : public Screen {
public:
explicit SleepScreen(GfxRenderer& renderer, InputManager& inputManager) : Screen(renderer, inputManager) {}
void onEnter() override;
};