diff --git a/.gitignore b/.gitignore
index 25b36fb..bae255e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
.DS_Store
.vscode
lib/EpdFont/fontsrc
+*.generated.h
diff --git a/README.md b/README.md
index c6e18e0..60a2ed2 100644
--- a/README.md
+++ b/README.md
@@ -23,18 +23,23 @@ CrossPoint Reader aims to:
This project is **not affiliated with Xteink**; it's built as a community project.
-## Features
+## Features & Usage
- [x] EPUB parsing and rendering
+- [ ] Image support within EPUB
- [x] Saved reading position
-- [ ] File explorer with file picker
+- [x] File explorer with file picker
- [x] Basic EPUB picker from root directory
- [x] Support nested folders
- [ ] EPUB picker with cover art
-- [ ] Image support within EPUB
+- [x] Custom sleep screen
+ - [ ] Cover sleep screen
+- [x] Wifi book upload
+- [ ] Wifi OTA updates
- [ ] Configurable font, layout, and display options
-- [ ] WiFi connectivity
-- [ ] BLE connectivity
+- [ ] Screen rotation
+
+See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
## Installing
@@ -59,10 +64,6 @@ back to the other partition using the "Swap boot partition" button here https://
See [Development](#development) below.
-## Usage
-
-See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
-
## Development
### Prerequisites
diff --git a/USER_GUIDE.md b/USER_GUIDE.md
index af69b94..f5a9bd0 100644
--- a/USER_GUIDE.md
+++ b/USER_GUIDE.md
@@ -19,24 +19,51 @@ The device utilises the standard buttons on the Xtink X4 in the same layout:
### Power On / Off
-To turn the device on or off, **press and hold the Power button for 1 full second**.
+To turn the device on or off, **press and hold the Power button for half a second**. In **Settings** you can configure
+the power button to trigger on a short press instead of a long one.
### First Launch
-Upon turning the device on for the first time, you will be placed on the **Book Selection Screen** (File Browser).
+Upon turning the device on for the first time, you will be placed on the **Home** screen.
> **Note:** On subsequent restarts, the firmware will automatically reopen the last book you were reading.
---
-## 3. Book Selection
+## 3. Screens
-The Home Screen acts as a folder and file browser.
+### 3.1 Home Screen
+
+The Home Screen is the main entry point to the firmware. From here you can navigate to the **Book Selection** screen,
+**Settings** screen, or **File Upload** screen.
+
+### 3.2 Book Selection (Read)
+
+The Book Selection acts as a folder and file browser.
* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up
and down through folders and books.
* **Open Selection:** Press **Confirm** to open a folder or read a selected book.
+### 3.3 Reading Screen
+
+See [4. Reading Mode](#4-reading-mode) below for more information.
+
+### 3.4 File Upload Screen
+
+The File Upload screen allows you to upload new e-books to the device. When you enter the screen you'll be prompted with
+a WiFi selection dialog and then your X4 will start hosting a web server.
+
+See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files.
+
+### 3.5 Settings
+
+The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
+- **White Sleep Screen**: Whether to use the white screen or black (inverted) default sleep screen
+- **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled,
+ paragraphs will not have vertical space between them, but will have first word indentation.
+- **Short Power Button Click**: Whether to trigger the power button on a short press or a long press.
+
---
## 4. Reading Mode
@@ -76,3 +103,4 @@ are planned for future updates:
* **Images:** Embedded images in e-books will not render.
* **Text Formatting:** There are currently no settings to adjust font type, size, line spacing, or margins.
+* **Rotation**: Different rotation options are not supported.
diff --git a/docs/images/cover.jpg b/docs/images/cover.jpg
index 588b548..f5d7d96 100644
Binary files a/docs/images/cover.jpg and b/docs/images/cover.jpg differ
diff --git a/docs/images/wifi/webserver_files.png b/docs/images/wifi/webserver_files.png
new file mode 100644
index 0000000..84835d1
Binary files /dev/null and b/docs/images/wifi/webserver_files.png differ
diff --git a/docs/images/wifi/webserver_homepage.png b/docs/images/wifi/webserver_homepage.png
new file mode 100644
index 0000000..368e681
Binary files /dev/null and b/docs/images/wifi/webserver_homepage.png differ
diff --git a/docs/images/wifi/webserver_upload.png b/docs/images/wifi/webserver_upload.png
new file mode 100644
index 0000000..d7295c5
Binary files /dev/null and b/docs/images/wifi/webserver_upload.png differ
diff --git a/docs/images/wifi/wifi_connected.jpeg b/docs/images/wifi/wifi_connected.jpeg
new file mode 100644
index 0000000..a9d74db
Binary files /dev/null and b/docs/images/wifi/wifi_connected.jpeg differ
diff --git a/docs/images/wifi/wifi_networks.jpeg b/docs/images/wifi/wifi_networks.jpeg
new file mode 100644
index 0000000..9c42dc8
Binary files /dev/null and b/docs/images/wifi/wifi_networks.jpeg differ
diff --git a/docs/images/wifi/wifi_password.jpeg b/docs/images/wifi/wifi_password.jpeg
new file mode 100644
index 0000000..1ca2fed
Binary files /dev/null and b/docs/images/wifi/wifi_password.jpeg differ
diff --git a/docs/webserver.md b/docs/webserver.md
new file mode 100644
index 0000000..2c96b8e
--- /dev/null
+++ b/docs/webserver.md
@@ -0,0 +1,272 @@
+# Web Server Guide
+
+This guide explains how to connect your CrossPoint Reader to WiFi and use the built-in web server to upload EPUB files from your computer or phone.
+
+## Overview
+
+CrossPoint Reader includes a built-in web server that allows you to:
+
+- Upload EPUB files wirelessly from any device on the same WiFi network
+- Browse and manage files on your device's SD card
+- Create folders to organize your ebooks
+- Delete files and folders
+
+## Prerequisites
+
+- Your CrossPoint Reader device
+- A WiFi network
+- A computer, phone, or tablet connected to the **same WiFi network**
+
+---
+
+## Step 1: Accessing the WiFi Screen
+
+1. From the main menu or file browser, navigate to the **Settings** screen
+2. Select the **WiFi** option
+3. The device will automatically start scanning for available networks
+
+---
+
+## Step 2: Connecting to WiFi
+
+### Viewing Available Networks
+
+Once the scan completes, you'll see a list of available WiFi networks with the following indicators:
+
+- **Signal strength bars** (`||||`, `|||`, `||`, `|`) - Shows connection quality
+- **`*` symbol** - Indicates the network is password-protected (encrypted)
+- **`+` symbol** - Indicates you have previously saved credentials for this network
+
+
+
+### Selecting a Network
+
+1. Use the **Left/Right** (or **Volume Up/Down**) buttons to navigate through the network list
+2. Press **Confirm** to select the highlighted network
+
+### Entering Password (for encrypted networks)
+
+If the network requires a password:
+
+1. An on-screen keyboard will appear
+2. Use the navigation buttons to select characters
+3. Press **Confirm** to enter each character
+4. When complete, select the **Done** option on the keyboard
+
+
+
+**Note:** If you've previously connected to this network, the saved password will be used automatically.
+
+### Connection Process
+
+The device will display "Connecting..." while establishing the connection. This typically takes 5-10 seconds.
+
+### Saving Credentials
+
+If this is a new network, you'll be prompted to save the password:
+
+- Select **Yes** to save credentials for automatic connection next time (NOTE: These are stored in plaintext on the device's SD card. Do not use this for sensitive networks.)
+- Select **No** to connect without saving
+
+---
+
+## Step 3: Connection Success
+
+Once connected, the screen will display:
+
+- **Network name** (SSID)
+- **IP Address** (e.g., `192.168.1.102`)
+- **Web server URL** (e.g., `http://192.168.1.102/`)
+
+
+
+**Important:** Make note of the IP address - you'll need this to access the web interface from your computer or phone.
+
+---
+
+## Step 4: Accessing the Web Interface
+
+### From a Computer
+
+1. Ensure your computer is connected to the **same WiFi network** as your CrossPoint Reader
+2. Open any web browser (Chrome is recommended)
+3. Type the IP address shown on your device into the browser's address bar
+ - Example: `http://192.168.1.102/`
+4. Press Enter
+
+### From a Phone or Tablet
+
+1. Ensure your phone/tablet is connected to the **same WiFi network** as your CrossPoint Reader
+2. Open your mobile browser (Safari, Chrome, etc.)
+3. Type the IP address into the address bar
+ - Example: `http://192.168.1.102/`
+4. Tap Go
+
+---
+
+## Step 5: Using the Web Interface
+
+### Home Page
+
+The home page displays:
+
+- Device status and version information
+- WiFi connection status
+- Current IP address
+- Available memory
+
+Navigation links:
+
+- **Home** - Returns to the status page
+- **File Manager** - Access file management features
+
+
+
+### File Manager
+
+Click **File Manager** to access file management features.
+
+#### Browsing Files
+
+- The file manager displays all files and folders on your SD card
+- **Folders** are highlighted in yellow with a 📁 icon
+- **EPUB files** are highlighted in green with a 📗 icon
+- Click on a folder name to navigate into it
+- Use the breadcrumb navigation at the top to go back to parent folders
+
+
+
+#### Uploading EPUB Files
+
+1. Click the **+ Add** button in the top-right corner
+2. Select **Upload eBook** from the dropdown menu
+3. Click **Choose File** and select an `.epub` file from your device
+4. Click **Upload**
+5. A progress bar will show the upload status
+6. The page will automatically refresh when the upload is complete
+
+**Note:** Only `.epub` files are accepted. Other file types will be rejected.
+
+
+
+#### Creating Folders
+
+1. Click the **+ Add** button in the top-right corner
+2. Select **New Folder** from the dropdown menu
+3. Enter a folder name (letters, numbers, underscores, and hyphens only)
+4. Click **Create Folder**
+
+This is useful for organizing your ebooks by genre, author, or series.
+
+#### Deleting Files and Folders
+
+1. Click the **🗑️** (trash) icon next to any file or folder
+2. Confirm the deletion in the popup dialog
+3. Click **Delete** to permanently remove the item
+
+**Warning:** Deletion is permanent and cannot be undone!
+
+**Note:** Folders must be empty before they can be deleted.
+
+---
+
+## Troubleshooting
+
+### Cannot See the Device on the Network
+
+**Problem:** Browser shows "Cannot connect" or "Site can't be reached"
+
+**Solutions:**
+
+1. Verify both devices are on the **same WiFi network**
+ - Check your computer/phone WiFi settings
+ - Confirm the CrossPoint Reader shows "Connected" status
+2. Double-check the IP address
+ - Make sure you typed it correctly
+ - Include `http://` at the beginning
+3. Try disabling VPN if you're using one
+4. Some networks have "client isolation" enabled - check with your network administrator
+
+### Connection Drops or Times Out
+
+**Problem:** WiFi connection is unstable
+
+**Solutions:**
+
+1. Move closer to the WiFi router
+2. Check signal strength on the device (should be at least `||` or better)
+3. Avoid interference from other devices
+4. Try a different WiFi network if available
+
+### Upload Fails
+
+**Problem:** File upload doesn't complete or shows an error
+
+**Solutions:**
+
+1. Ensure the file is a valid `.epub` file
+2. Check that the SD card has enough free space
+3. Try uploading a smaller file first to test
+4. Refresh the browser page and try again
+
+### Saved Password Not Working
+
+**Problem:** Device fails to connect with saved credentials
+
+**Solutions:**
+
+1. When connection fails, you'll be prompted to "Forget Network"
+2. Select **Yes** to remove the saved password
+3. Reconnect and enter the password again
+4. Choose to save the new password
+
+---
+
+## Security Notes
+
+- The web server runs on port 80 (standard HTTP)
+- **No authentication is required** - anyone on the same network can access the interface
+- The web server is only accessible while the WiFi screen shows "Connected"
+- The web server automatically stops when you exit the WiFi screen
+- For security, only use on trusted private networks
+
+---
+
+## Technical Details
+
+- **Supported WiFi:** 2.4GHz networks (802.11 b/g/n)
+- **Web Server Port:** 80 (HTTP)
+- **Maximum Upload Size:** Limited by available SD card space
+- **Supported File Format:** `.epub` only
+- **Browser Compatibility:** All modern browsers (Chrome, Firefox, Safari, Edge)
+
+---
+
+## Tips and Best Practices
+
+1. **Organize with folders** - Create folders before uploading to keep your library organized
+2. **Check signal strength** - Stronger signals (`|||` or `||||`) provide faster, more reliable uploads
+3. **Upload multiple files** - You can upload files one at a time; the page refreshes after each upload
+4. **Use descriptive names** - Name your folders clearly (e.g., "SciFi", "Mystery", "Non-Fiction")
+5. **Keep credentials saved** - Save your WiFi password for quick reconnection in the future
+6. **Exit when done** - Press **Back** to exit the WiFi screen and save battery
+
+---
+
+## Exiting WiFi Mode
+
+When you're finished uploading files:
+
+1. Press the **Back** button on your CrossPoint Reader
+2. The web server will automatically stop
+3. WiFi will disconnect to conserve battery
+4. You'll return to the previous screen
+
+Your uploaded files will be immediately available in the file browser!
+
+---
+
+## Related Documentation
+
+- [User Guide](../USER_GUIDE.md) - General device operation
+- [README](../README.md) - Project overview and features
diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp
index 1477d72..a3edac8 100644
--- a/lib/Epub/Epub.cpp
+++ b/lib/Epub/Epub.cpp
@@ -322,6 +322,11 @@ int Epub::getTocItemsCount() const { return toc.size(); }
// work out the section index for a toc index
int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
+ if (tocIndex < 0 || tocIndex >= toc.size()) {
+ Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex);
+ return 0;
+ }
+
// the toc entry should have an href that matches the spine item
// so we can find the spine index by looking for the href
for (int i = 0; i < spine.size(); i++) {
@@ -336,6 +341,11 @@ int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
}
int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
+ if (spineIndex < 0 || spineIndex >= spine.size()) {
+ Serial.printf("[%lu] [EBP] getTocIndexForSpineIndex: spineIndex %d out of range\n", millis(), spineIndex);
+ return -1;
+ }
+
// the toc entry should have an href that matches the spine item
// so we can find the toc index by looking for the href
for (int i = 0; i < toc.size(); i++) {
@@ -348,13 +358,21 @@ int Epub::getTocIndexForSpineIndex(const int spineIndex) const {
return -1;
}
-size_t Epub::getBookSize() const { return getCumulativeSpineItemSize(getSpineItemsCount() - 1); }
+size_t Epub::getBookSize() const {
+ if (spine.empty()) {
+ return 0;
+ }
+ return getCumulativeSpineItemSize(getSpineItemsCount() - 1);
+}
// Calculate progress in book
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) {
+ size_t bookSize = getBookSize();
+ if (bookSize == 0) {
+ return 0;
+ }
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(prevChapterSize + sectionProgSize) / bookSize * 100.0);
}
diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp
index 30b44f8..f55bb85 100644
--- a/lib/ZipFile/ZipFile.cpp
+++ b/lib/ZipFile/ZipFile.cpp
@@ -62,6 +62,10 @@ long ZipFile::getDataOffset(const mz_zip_archive_file_stat& fileStat) const {
const uint64_t fileOffset = fileStat.m_local_header_ofs;
FILE* file = fopen(filePath.c_str(), "r");
+ if (!file) {
+ Serial.printf("[%lu] [ZIP] Failed to open file for reading local header\n", millis());
+ return -1;
+ }
fseek(file, fileOffset, SEEK_SET);
const size_t read = fread(pLocalHeader, 1, localHeaderSize, file);
fclose(file);
@@ -104,6 +108,10 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
}
FILE* file = fopen(filePath.c_str(), "rb");
+ if (!file) {
+ Serial.printf("[%lu] [ZIP] Failed to open file for reading\n", millis());
+ return nullptr;
+ }
fseek(file, fileOffset, SEEK_SET);
const auto deflatedDataSize = static_cast(fileStat.m_comp_size);
@@ -175,6 +183,10 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
}
FILE* file = fopen(filePath.c_str(), "rb");
+ if (!file) {
+ Serial.printf("[%lu] [ZIP] Failed to open file for streaming\n", millis());
+ return false;
+ }
fseek(file, fileOffset, SEEK_SET);
const auto deflatedDataSize = static_cast(fileStat.m_comp_size);
diff --git a/platformio.ini b/platformio.ini
index 5494f86..4f71afe 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -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
diff --git a/scripts/build_html.py b/scripts/build_html.py
new file mode 100644
index 0000000..248aba8
--- /dev/null
+++ b/scripts/build_html.py
@@ -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}")
diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp
index 1d6f5da..51ac88a 100644
--- a/src/CrossPointSettings.cpp
+++ b/src/CrossPointSettings.cpp
@@ -25,6 +25,7 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, SETTINGS_COUNT);
serialization::writePod(outputFile, whiteSleepScreen);
serialization::writePod(outputFile, extraParagraphSpacing);
+ serialization::writePod(outputFile, shortPwrBtn);
serialization::writePod(outputFile, hyphenationEnabled);
outputFile.close();
@@ -51,16 +52,18 @@ bool CrossPointSettings::loadFromFile() {
uint8_t fileSettingsCount = 0;
serialization::readPod(inputFile, fileSettingsCount);
- // load settings that exist in the file (supports backward compatibility)
- if (fileSettingsCount >= 1) {
+ // load settings that exist
+ uint8_t settingsRead = 0;
+ do {
serialization::readPod(inputFile, whiteSleepScreen);
- }
- if (fileSettingsCount >= 2) {
+ if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, extraParagraphSpacing);
- }
- if (fileSettingsCount >= 3) {
+ if (++settingsRead >= fileSettingsCount) break;
+ serialization::readPod(inputFile, shortPwrBtn);
+ if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hyphenationEnabled);
- }
+ if (++settingsRead >= fileSettingsCount) break;
+ } while (false);
inputFile.close();
Serial.printf("[%lu] [CPS] Settings loaded from file\n", millis());
diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h
index 7c8b8d0..0ee815d 100644
--- a/src/CrossPointSettings.h
+++ b/src/CrossPointSettings.h
@@ -17,9 +17,10 @@ class CrossPointSettings {
// Sleep screen settings
uint8_t whiteSleepScreen = 0;
-
// Text rendering settings
uint8_t extraParagraphSpacing = 1;
+ // Duration of the power button press
+ uint8_t shortPwrBtn = 0;
uint8_t hyphenationEnabled = 1;
~CrossPointSettings() = default;
@@ -27,6 +28,8 @@ class CrossPointSettings {
// Get singleton instance
static CrossPointSettings& getInstance() { return instance; }
+ uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 500; }
+
bool saveToFile() const;
bool loadFromFile();
};
diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp
new file mode 100644
index 0000000..ec9d955
--- /dev/null
+++ b/src/WifiCredentialStore.cpp
@@ -0,0 +1,160 @@
+#include "WifiCredentialStore.h"
+
+#include
+#include
+#include
+
+#include
+
+// Initialize the static instance
+WifiCredentialStore WifiCredentialStore::instance;
+
+namespace {
+// 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);
+} // namespace
+
+void WifiCredentialStore::obfuscate(std::string& data) const {
+ Serial.printf("[%lu] [WCS] Obfuscating/deobfuscating %zu bytes\n", millis(), data.size());
+ for (size_t i = 0; i < data.size(); i++) {
+ data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH];
+ }
+}
+
+bool WifiCredentialStore::saveToFile() const {
+ // Make sure the directory exists
+ SD.mkdir("/.crosspoint");
+
+ std::ofstream file(WIFI_FILE, std::ios::binary);
+ if (!file) {
+ Serial.printf("[%lu] [WCS] Failed to open wifi.bin for writing\n", millis());
+ return false;
+ }
+
+ // Write header
+ serialization::writePod(file, WIFI_FILE_VERSION);
+ serialization::writePod(file, static_cast(credentials.size()));
+
+ // Write each credential
+ for (const auto& cred : credentials) {
+ // Write SSID (plaintext - not sensitive)
+ serialization::writeString(file, cred.ssid);
+ Serial.printf("[%lu] [WCS] Saving SSID: %s, password length: %zu\n", millis(), cred.ssid.c_str(),
+ cred.password.size());
+
+ // Write password (obfuscated)
+ std::string obfuscatedPwd = cred.password;
+ obfuscate(obfuscatedPwd);
+ serialization::writeString(file, obfuscatedPwd);
+ }
+
+ file.close();
+ Serial.printf("[%lu] [WCS] Saved %zu WiFi credentials to file\n", millis(), credentials.size());
+ return true;
+}
+
+bool WifiCredentialStore::loadFromFile() {
+ if (!SD.exists(WIFI_FILE + 3)) { // +3 to skip "/sd" prefix
+ Serial.printf("[%lu] [WCS] WiFi credentials file does not exist\n", millis());
+ return false;
+ }
+
+ std::ifstream file(WIFI_FILE, std::ios::binary);
+ if (!file) {
+ Serial.printf("[%lu] [WCS] Failed to open wifi.bin for reading\n", millis());
+ return false;
+ }
+
+ // Read and verify version
+ uint8_t version;
+ serialization::readPod(file, version);
+ if (version != WIFI_FILE_VERSION) {
+ Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version);
+ file.close();
+ return false;
+ }
+
+ // Read credential count
+ uint8_t count;
+ serialization::readPod(file, count);
+
+ // Read credentials
+ credentials.clear();
+ for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
+ WifiCredential cred;
+
+ // Read SSID
+ serialization::readString(file, cred.ssid);
+
+ // Read and deobfuscate password
+ serialization::readString(file, cred.password);
+ Serial.printf("[%lu] [WCS] Loaded SSID: %s, obfuscated password length: %zu\n", millis(), cred.ssid.c_str(),
+ cred.password.size());
+ obfuscate(cred.password); // XOR is symmetric, so same function deobfuscates
+ Serial.printf("[%lu] [WCS] After deobfuscation, password length: %zu\n", millis(), cred.password.size());
+
+ credentials.push_back(cred);
+ }
+
+ file.close();
+ Serial.printf("[%lu] [WCS] Loaded %zu WiFi credentials from file\n", millis(), credentials.size());
+ return true;
+}
+
+bool WifiCredentialStore::addCredential(const std::string& ssid, const std::string& password) {
+ // Check if this SSID already exists and update it
+ for (auto& cred : credentials) {
+ if (cred.ssid == ssid) {
+ cred.password = password;
+ Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str());
+ return saveToFile();
+ }
+ }
+
+ // Check if we've reached the limit
+ if (credentials.size() >= MAX_NETWORKS) {
+ Serial.printf("[%lu] [WCS] Cannot add more networks, limit of %zu reached\n", millis(), MAX_NETWORKS);
+ return false;
+ }
+
+ // Add new credential
+ credentials.push_back({ssid, password});
+ Serial.printf("[%lu] [WCS] Added credentials for: %s\n", millis(), ssid.c_str());
+ return saveToFile();
+}
+
+bool WifiCredentialStore::removeCredential(const std::string& ssid) {
+ for (auto it = credentials.begin(); it != credentials.end(); ++it) {
+ if (it->ssid == ssid) {
+ credentials.erase(it);
+ Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str());
+ return saveToFile();
+ }
+ }
+ return false; // Not found
+}
+
+const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssid) const {
+ for (const auto& cred : credentials) {
+ if (cred.ssid == ssid) {
+ return &cred;
+ }
+ }
+ return nullptr;
+}
+
+bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; }
+
+void WifiCredentialStore::clearAll() {
+ credentials.clear();
+ saveToFile();
+ Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis());
+}
diff --git a/src/WifiCredentialStore.h b/src/WifiCredentialStore.h
new file mode 100644
index 0000000..0004dc9
--- /dev/null
+++ b/src/WifiCredentialStore.h
@@ -0,0 +1,56 @@
+#pragma once
+#include
+#include
+
+struct WifiCredential {
+ std::string ssid;
+ std::string password; // Stored obfuscated in file
+};
+
+/**
+ * Singleton class for storing WiFi credentials on the SD card.
+ * Credentials are stored in /sd/.crosspoint/wifi.bin with basic
+ * XOR obfuscation to prevent casual reading (not cryptographically secure).
+ */
+class WifiCredentialStore {
+ private:
+ static WifiCredentialStore instance;
+ std::vector credentials;
+
+ static constexpr size_t MAX_NETWORKS = 8;
+
+ // Private constructor for singleton
+ WifiCredentialStore() = default;
+
+ // XOR obfuscation (symmetric - same for encode/decode)
+ void obfuscate(std::string& data) const;
+
+ public:
+ // Delete copy constructor and assignment
+ WifiCredentialStore(const WifiCredentialStore&) = delete;
+ WifiCredentialStore& operator=(const WifiCredentialStore&) = delete;
+
+ // Get singleton instance
+ static WifiCredentialStore& getInstance() { return instance; }
+
+ // Save/load from SD card
+ bool saveToFile() const;
+ bool loadFromFile();
+
+ // Credential management
+ bool addCredential(const std::string& ssid, const std::string& password);
+ bool removeCredential(const std::string& ssid);
+ const WifiCredential* findCredential(const std::string& ssid) const;
+
+ // Get all stored credentials (for UI display)
+ const std::vector& getCredentials() const { return credentials; }
+
+ // Check if a network is saved
+ bool hasSavedCredential(const std::string& ssid) const;
+
+ // Clear all credentials
+ void clearAll();
+};
+
+// Helper macro to access credentials store
+#define WIFI_STORE WifiCredentialStore::getInstance()
diff --git a/src/activities/Activity.h b/src/activities/Activity.h
index 28017f7..dfe6714 100644
--- a/src/activities/Activity.h
+++ b/src/activities/Activity.h
@@ -15,4 +15,5 @@ class Activity {
virtual void onEnter() {}
virtual void onExit() {}
virtual void loop() {}
+ virtual bool skipLoopDelay() { return false; }
};
diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp
index 417a662..3b36990 100644
--- a/src/activities/boot_sleep/SleepActivity.cpp
+++ b/src/activities/boot_sleep/SleepActivity.cpp
@@ -2,18 +2,72 @@
#include
+#include
+
#include "CrossPointSettings.h"
#include "SD.h"
#include "config.h"
#include "images/CrossLarge.h"
void SleepActivity::onEnter() {
+ renderPopup("Entering Sleep...");
+ // Check if we have a /sleep directory
+ auto dir = SD.open("/sleep");
+ if (dir && dir.isDirectory()) {
+ std::vector files;
+ // collect all valid BMP files
+ for (File file = dir.openNextFile(); file; file = dir.openNextFile()) {
+ if (file.isDirectory()) {
+ file.close();
+ continue;
+ }
+ auto filename = std::string(file.name());
+ if (filename[0] == '.') {
+ file.close();
+ continue;
+ }
+
+ if (filename.substr(filename.length() - 4) != ".bmp") {
+ Serial.printf("[%lu] [Slp] Skipping non-.bmp file name: %s\n", millis(), file.name());
+ file.close();
+ continue;
+ }
+ Bitmap bitmap(file);
+ if (bitmap.parseHeaders() != BmpReaderError::Ok) {
+ Serial.printf("[%lu] [Slp] Skipping invalid BMP file: %s\n", millis(), file.name());
+ file.close();
+ continue;
+ }
+ files.emplace_back(filename);
+ file.close();
+ }
+ int numFiles = files.size();
+ if (numFiles > 0) {
+ // Generate a random number between 1 and numFiles
+ int randomFileIndex = random(numFiles);
+ auto filename = "/sleep/" + files[randomFileIndex];
+ auto file = SD.open(filename.c_str());
+ if (file) {
+ Serial.printf("[%lu] [Slp] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
+ delay(100);
+ Bitmap bitmap(file);
+ if (bitmap.parseHeaders() == BmpReaderError::Ok) {
+ renderCustomSleepScreen(bitmap);
+ dir.close();
+ return;
+ }
+ }
+ }
+ }
+ if (dir) dir.close();
+
// Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default.
auto file = SD.open("/sleep.bmp");
if (file) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
+ Serial.printf("[%lu] [Slp] Loading: /sleep.bmp\n", millis());
renderCustomSleepScreen(bitmap);
return;
}
@@ -22,6 +76,20 @@ void SleepActivity::onEnter() {
renderDefaultSleepScreen();
}
+void SleepActivity::renderPopup(const char* message) const {
+ const int textWidth = renderer.getTextWidth(READER_FONT_ID, message);
+ constexpr int margin = 20;
+ const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2;
+ constexpr int y = 117;
+ const int w = textWidth + margin * 2;
+ const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
+ // renderer.clearScreen();
+ renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
+ renderer.drawText(READER_FONT_ID, x + margin, y + margin, message);
+ renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
+ renderer.displayBuffer();
+}
+
void SleepActivity::renderDefaultSleepScreen() const {
const auto pageWidth = GfxRenderer::getScreenWidth();
const auto pageHeight = GfxRenderer::getScreenHeight();
diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h
index 9d4a7c4..defc1d5 100644
--- a/src/activities/boot_sleep/SleepActivity.h
+++ b/src/activities/boot_sleep/SleepActivity.h
@@ -11,4 +11,5 @@ class SleepActivity final : public Activity {
private:
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen(const Bitmap& bitmap) const;
+ void renderPopup(const char* message) const;
};
diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp
index 19b30c0..82d57cc 100644
--- a/src/activities/home/HomeActivity.cpp
+++ b/src/activities/home/HomeActivity.cpp
@@ -6,7 +6,7 @@
#include "config.h"
namespace {
-constexpr int menuItemCount = 2;
+constexpr int menuItemCount = 3;
}
void HomeActivity::taskTrampoline(void* param) {
@@ -51,6 +51,8 @@ void HomeActivity::loop() {
if (selectorIndex == 0) {
onReaderOpen();
} else if (selectorIndex == 1) {
+ onFileTransferOpen();
+ } else if (selectorIndex == 2) {
onSettingsOpen();
}
} else if (prevPressed) {
@@ -84,7 +86,8 @@ void HomeActivity::render() const {
// 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.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1);
+ renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2);
renderer.drawRect(25, pageHeight - 40, 106, 40);
renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back");
diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h
index 7f6ac4d..5dd26ec 100644
--- a/src/activities/home/HomeActivity.h
+++ b/src/activities/home/HomeActivity.h
@@ -14,6 +14,7 @@ class HomeActivity final : public Activity {
bool updateRequired = false;
const std::function onReaderOpen;
const std::function onSettingsOpen;
+ const std::function onFileTransferOpen;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
@@ -21,8 +22,11 @@ class HomeActivity final : public Activity {
public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onReaderOpen,
- const std::function& onSettingsOpen)
- : Activity(renderer, inputManager), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen) {}
+ const std::function& onSettingsOpen, const std::function& onFileTransferOpen)
+ : Activity(renderer, inputManager),
+ onReaderOpen(onReaderOpen),
+ onSettingsOpen(onSettingsOpen),
+ onFileTransferOpen(onFileTransferOpen) {}
void onEnter() override;
void onExit() override;
void loop() override;
diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp
new file mode 100644
index 0000000..82f6329
--- /dev/null
+++ b/src/activities/network/CrossPointWebServerActivity.cpp
@@ -0,0 +1,243 @@
+#include "CrossPointWebServerActivity.h"
+
+#include
+#include
+
+#include "config.h"
+
+void CrossPointWebServerActivity::taskTrampoline(void* param) {
+ auto* self = static_cast(param);
+ self->displayTaskLoop();
+}
+
+void CrossPointWebServerActivity::onEnter() {
+ Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onEnter ==========\n", millis());
+ Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap());
+
+ renderingMutex = xSemaphoreCreateMutex();
+
+ // Reset state
+ state = WebServerActivityState::WIFI_SELECTION;
+ connectedIP.clear();
+ connectedSSID.clear();
+ lastHandleClientTime = 0;
+ updateRequired = true;
+
+ xTaskCreate(&CrossPointWebServerActivity::taskTrampoline, "WebServerActivityTask",
+ 2048, // Stack size
+ this, // Parameters
+ 1, // Priority
+ &displayTaskHandle // Task handle
+ );
+
+ // Turn on WiFi immediately
+ Serial.printf("[%lu] [WEBACT] Turning on WiFi...\n", millis());
+ WiFi.mode(WIFI_STA);
+
+ // Launch WiFi selection subactivity
+ Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
+ wifiSelection.reset(new WifiSelectionActivity(renderer, inputManager,
+ [this](bool connected) { onWifiSelectionComplete(connected); }));
+ wifiSelection->onEnter();
+}
+
+void CrossPointWebServerActivity::onExit() {
+ Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit START ==========\n", millis());
+ Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap());
+
+ state = WebServerActivityState::SHUTTING_DOWN;
+
+ // Stop the web server first (before disconnecting WiFi)
+ stopWebServer();
+
+ // Exit WiFi selection subactivity if still active
+ if (wifiSelection) {
+ Serial.printf("[%lu] [WEBACT] Exiting WifiSelectionActivity...\n", millis());
+ wifiSelection->onExit();
+ wifiSelection.reset();
+ Serial.printf("[%lu] [WEBACT] WifiSelectionActivity exited\n", millis());
+ }
+
+ // CRITICAL: Wait for LWIP stack to flush any pending packets
+ Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis());
+ delay(500);
+
+ // Disconnect WiFi gracefully
+ Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
+ WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
+ delay(100); // Allow disconnect frame to be sent
+
+ Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis());
+ WiFi.mode(WIFI_OFF);
+ delay(100); // Allow WiFi hardware to fully power down
+
+ Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap());
+
+ // Acquire mutex before deleting task
+ Serial.printf("[%lu] [WEBACT] Acquiring rendering mutex before task deletion...\n", millis());
+ xSemaphoreTake(renderingMutex, portMAX_DELAY);
+
+ // Delete the display task
+ Serial.printf("[%lu] [WEBACT] Deleting display task...\n", millis());
+ if (displayTaskHandle) {
+ vTaskDelete(displayTaskHandle);
+ displayTaskHandle = nullptr;
+ Serial.printf("[%lu] [WEBACT] Display task deleted\n", millis());
+ }
+
+ // Delete the mutex
+ Serial.printf("[%lu] [WEBACT] Deleting mutex...\n", millis());
+ vSemaphoreDelete(renderingMutex);
+ renderingMutex = nullptr;
+ Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis());
+
+ Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap());
+ Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit COMPLETE ==========\n", millis());
+}
+
+void CrossPointWebServerActivity::onWifiSelectionComplete(bool connected) {
+ Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected);
+
+ if (connected) {
+ // Get connection info before exiting subactivity
+ connectedIP = wifiSelection->getConnectedIP();
+ connectedSSID = WiFi.SSID().c_str();
+
+ // Exit the wifi selection subactivity
+ wifiSelection->onExit();
+ wifiSelection.reset();
+
+ // Start the web server
+ startWebServer();
+ } else {
+ // User cancelled - go back
+ onGoBack();
+ }
+}
+
+void CrossPointWebServerActivity::startWebServer() {
+ Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
+
+ // Create the web server instance
+ webServer.reset(new CrossPointWebServer());
+ webServer->begin();
+
+ if (webServer->isRunning()) {
+ state = WebServerActivityState::SERVER_RUNNING;
+ Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
+
+ // Force an immediate render since we're transitioning from a subactivity
+ // that had its own rendering task. We need to make sure our display is shown.
+ xSemaphoreTake(renderingMutex, portMAX_DELAY);
+ render();
+ xSemaphoreGive(renderingMutex);
+ Serial.printf("[%lu] [WEBACT] Rendered File Transfer screen\n", millis());
+ } else {
+ Serial.printf("[%lu] [WEBACT] ERROR: Failed to start web server!\n", millis());
+ webServer.reset();
+ // Go back on error
+ onGoBack();
+ }
+}
+
+void CrossPointWebServerActivity::stopWebServer() {
+ if (webServer && webServer->isRunning()) {
+ Serial.printf("[%lu] [WEBACT] Stopping web server...\n", millis());
+ webServer->stop();
+ Serial.printf("[%lu] [WEBACT] Web server stopped\n", millis());
+ }
+ webServer.reset();
+}
+
+void CrossPointWebServerActivity::loop() {
+ // Handle different states
+ switch (state) {
+ case WebServerActivityState::WIFI_SELECTION:
+ // Forward loop to WiFi selection subactivity
+ if (wifiSelection) {
+ wifiSelection->loop();
+ }
+ break;
+
+ case WebServerActivityState::SERVER_RUNNING:
+ // Handle web server requests - call handleClient multiple times per loop
+ // to improve responsiveness and upload throughput
+ if (webServer && webServer->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] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(),
+ timeSinceLastHandleClient);
+ }
+
+ // Call handleClient multiple times to process pending requests faster
+ // This is critical for upload performance - HTTP file uploads send data
+ // in chunks and each handleClient() call processes incoming data
+ constexpr int HANDLE_CLIENT_ITERATIONS = 10;
+ for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) {
+ webServer->handleClient();
+ }
+ lastHandleClientTime = millis();
+ }
+
+ // Handle exit on Back button
+ if (inputManager.wasPressed(InputManager::BTN_BACK)) {
+ onGoBack();
+ return;
+ }
+ break;
+
+ case WebServerActivityState::SHUTTING_DOWN:
+ // Do nothing - waiting for cleanup
+ break;
+ }
+}
+
+void CrossPointWebServerActivity::displayTaskLoop() {
+ while (true) {
+ if (updateRequired) {
+ updateRequired = false;
+ xSemaphoreTake(renderingMutex, portMAX_DELAY);
+ render();
+ xSemaphoreGive(renderingMutex);
+ }
+ vTaskDelay(10 / portTICK_PERIOD_MS);
+ }
+}
+
+void CrossPointWebServerActivity::render() const {
+ // Only render our own UI when server is running
+ // WiFi selection handles its own rendering
+ if (state == WebServerActivityState::SERVER_RUNNING) {
+ renderer.clearScreen();
+ renderServerRunning();
+ renderer.displayBuffer();
+ }
+}
+
+void CrossPointWebServerActivity::renderServerRunning() const {
+ const auto pageWidth = GfxRenderer::getScreenWidth();
+ const auto pageHeight = GfxRenderer::getScreenHeight();
+ const auto height = renderer.getLineHeight(UI_FONT_ID);
+ const auto top = (pageHeight - height * 5) / 2;
+
+ renderer.drawCenteredText(READER_FONT_ID, top - 30, "File Transfer", true, BOLD);
+
+ std::string ssidInfo = "Network: " + connectedSSID;
+ 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 URL prominently
+ std::string webInfo = "http://" + connectedIP + "/";
+ renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD);
+
+ renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR);
+
+ renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR);
+}
diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h
new file mode 100644
index 0000000..ad41dcd
--- /dev/null
+++ b/src/activities/network/CrossPointWebServerActivity.h
@@ -0,0 +1,66 @@
+#pragma once
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include "../Activity.h"
+#include "WifiSelectionActivity.h"
+#include "server/CrossPointWebServer.h"
+
+// Web server activity states
+enum class WebServerActivityState {
+ WIFI_SELECTION, // WiFi selection subactivity is active
+ SERVER_RUNNING, // Web server is running and handling requests
+ SHUTTING_DOWN // Shutting down server and WiFi
+};
+
+/**
+ * CrossPointWebServerActivity is the entry point for file transfer functionality.
+ * It:
+ * - Immediately turns on WiFi and launches WifiSelectionActivity on enter
+ * - When WifiSelectionActivity completes successfully, starts the CrossPointWebServer
+ * - Handles client requests in its loop() function
+ * - Cleans up the server and shuts down WiFi on exit
+ */
+class CrossPointWebServerActivity final : public Activity {
+ TaskHandle_t displayTaskHandle = nullptr;
+ SemaphoreHandle_t renderingMutex = nullptr;
+ bool updateRequired = false;
+ WebServerActivityState state = WebServerActivityState::WIFI_SELECTION;
+ const std::function onGoBack;
+
+ // WiFi selection subactivity
+ std::unique_ptr wifiSelection;
+
+ // Web server - owned by this activity
+ std::unique_ptr webServer;
+
+ // Server status
+ std::string connectedIP;
+ std::string connectedSSID;
+
+ // Performance monitoring
+ unsigned long lastHandleClientTime = 0;
+
+ static void taskTrampoline(void* param);
+ [[noreturn]] void displayTaskLoop();
+ void render() const;
+ void renderServerRunning() const;
+
+ void onWifiSelectionComplete(bool connected);
+ void startWebServer();
+ void stopWebServer();
+
+ public:
+ explicit CrossPointWebServerActivity(GfxRenderer& renderer, InputManager& inputManager,
+ const std::function& onGoBack)
+ : Activity(renderer, inputManager), onGoBack(onGoBack) {}
+ void onEnter() override;
+ void onExit() override;
+ void loop() override;
+ bool skipLoopDelay() override { return webServer && webServer->isRunning(); }
+};
diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp
new file mode 100644
index 0000000..a48891e
--- /dev/null
+++ b/src/activities/network/WifiSelectionActivity.cpp
@@ -0,0 +1,691 @@
+#include "WifiSelectionActivity.h"
+
+#include
+#include
+
+#include