Compare commits

...

7 Commits

Author SHA1 Message Date
Istiak Tridip
18db6055a8
Merge 28d01a1aff into 3ce11f14ce 2026-01-22 02:20:40 +11:00
Dave Allie
3ce11f14ce
chore: Cut release 0.15.0
Some checks failed
CI / build (push) Has been cancelled
2026-01-22 02:20:22 +11:00
KasyanDiGris
47ef92e8fd
fix: OPDS browser OOM (#403)
## Summary

- Rewrite OpdsParser to stream parsing instead of full content
- Fix OOM due to big http xml response

Closes #385 

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-01-22 01:43:51 +11:00
Juan Biondi
e3d6e32609
docs: Add detailed webserver documentation (#446)
## More detailed documentation

* **What is the goal of this PR?** 
Add more information about the exposed webserver.
* **What changes are included?**
Detailed documentation for the webserver endpoints
(`./docs/webserver-endpoints.md`)
Adding a table of content so it is easier to navigate directly to the
section you're interested on (Almost all `.md` files or at least all
those relevant)

## Additional Context

Not sure if this would get accepted but I thought it might be useful for
those trying to create separate apps that would sync files to the
device. It was at least to me trying to upload files using python as
stated
[here](https://github.com/crosspoint-reader/crosspoint-reader/discussions/434#discussioncomment-15545349)

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_
2026-01-22 00:29:39 +11:00
Logan Garbarini
d399afb53d
feat: invalidate cache on web uploads and opds downloads and add Clear Cache action (#393)
## Summary

When uploading or downloading an updated ebook from SD/WebUI/OPDS with
same the filename the `.crosspoint` cache is not cleared. This can lead
to issues with the Table of Contents and hangs when switching between
chapters.

I encountered this issue in two places:
- When I need to do further ePub cleaning using Calibre after I load an
ePub and find that some of its formatting should be cleaned up. When I
reprocess the same book and want to place it back in the same location I
need a way to invalidate the cache.
- When syncing RSS feed generated epubs. I generate news ePubs with
filenames like `news-outlet.epub` and so every day when I fetch new news
the crosspoint cache needs to be cleared to load that file.

This change offers the following features:
- On web uploads, if the file already exists, the cache for that file is
cleared
- On OPDS downloads, if the file already exists, the cache for that file
is cleared
- There's now an action for `Clear Cache` in the Settings page which can
clear the cache for all books


Addresses
https://github.com/crosspoint-reader/crosspoint-reader/issues/281

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? PARTIALLY

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-01-22 00:06:07 +11:00
Istiak Tridip
28d01a1aff
Merge branch 'master' into feature/rapid-toc-nav 2026-01-20 22:32:29 +06:00
Istiak Tridip
28152b3fee
feat: rapid toc navigation 2026-01-15 02:28:21 +06:00
23 changed files with 864 additions and 151 deletions

View File

@ -2,6 +2,27 @@
Welcome to the **CrossPoint** firmware. This guide outlines the hardware controls, navigation, and reading features of the device.
- [CrossPoint User Guide](#crosspoint-user-guide)
- [1. Hardware Overview](#1-hardware-overview)
- [Button Layout](#button-layout)
- [2. Power \& Startup](#2-power--startup)
- [Power On / Off](#power-on--off)
- [First Launch](#first-launch)
- [3. Screens](#3-screens)
- [3.1 Home Screen](#31-home-screen)
- [3.2 Book Selection](#32-book-selection)
- [3.3 Reading Mode](#33-reading-mode)
- [3.4 File Upload Screen](#34-file-upload-screen)
- [3.5 Settings](#35-settings)
- [3.6 Sleep Screen](#36-sleep-screen)
- [4. Reading Mode](#4-reading-mode)
- [Page Turning](#page-turning)
- [Chapter Navigation](#chapter-navigation)
- [System Navigation](#system-navigation)
- [5. Chapter Selection Screen](#5-chapter-selection-screen)
- [6. Current Limitations \& Roadmap](#6-current-limitations--roadmap)
## 1. Hardware Overview
The device utilises the standard buttons on the Xtink X4 (in the same layout as the manufacturer firmware, by default):

57
docs/troubleshooting.md Normal file
View File

@ -0,0 +1,57 @@
# Troubleshooting
This document show most common issues and possible solutions while using the device features.
- [Troubleshooting](#troubleshooting)
- [Cannot See the Device on the Network](#cannot-see-the-device-on-the-network)
- [Connection Drops or Times Out](#connection-drops-or-times-out)
- [Upload Fails](#upload-fails)
- [Saved Password Not Working](#saved-password-not-working)
### 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

331
docs/webserver-endpoints.md Normal file
View File

@ -0,0 +1,331 @@
# Webserver Endpoints
This document describes all HTTP and WebSocket endpoints available on the CrossPoint Reader webserver.
- [Webserver Endpoints](#webserver-endpoints)
- [Overview](#overview)
- [HTTP Endpoints](#http-endpoints)
- [GET `/` - Home Page](#get----home-page)
- [GET `/files` - File Browser Page](#get-files---file-browser-page)
- [GET `/api/status` - Device Status](#get-apistatus---device-status)
- [GET `/api/files` - List Files](#get-apifiles---list-files)
- [POST `/upload` - Upload File](#post-upload---upload-file)
- [POST `/mkdir` - Create Folder](#post-mkdir---create-folder)
- [POST `/delete` - Delete File or Folder](#post-delete---delete-file-or-folder)
- [WebSocket Endpoint](#websocket-endpoint)
- [Port 81 - Fast Binary Upload](#port-81---fast-binary-upload)
- [Network Modes](#network-modes)
- [Station Mode (STA)](#station-mode-sta)
- [Access Point Mode (AP)](#access-point-mode-ap)
- [Notes](#notes)
## Overview
The CrossPoint Reader exposes a webserver for file management and device monitoring:
- **HTTP Server**: Port 80
- **WebSocket Server**: Port 81 (for fast binary uploads)
---
## HTTP Endpoints
### GET `/` - Home Page
Serves the home page HTML interface.
**Request:**
```bash
curl http://crosspoint.local/
```
**Response:** HTML page (200 OK)
---
### GET `/files` - File Browser Page
Serves the file browser HTML interface.
**Request:**
```bash
curl http://crosspoint.local/files
```
**Response:** HTML page (200 OK)
---
### GET `/api/status` - Device Status
Returns JSON with device status information.
**Request:**
```bash
curl http://crosspoint.local/api/status
```
**Response (200 OK):**
```json
{
"version": "1.0.0",
"ip": "192.168.1.100",
"mode": "STA",
"rssi": -45,
"freeHeap": 123456,
"uptime": 3600
}
```
| Field | Type | Description |
| ---------- | ------ | --------------------------------------------------------- |
| `version` | string | CrossPoint firmware version |
| `ip` | string | Device IP address |
| `mode` | string | `"STA"` (connected to WiFi) or `"AP"` (access point mode) |
| `rssi` | number | WiFi signal strength in dBm (0 in AP mode) |
| `freeHeap` | number | Free heap memory in bytes |
| `uptime` | number | Seconds since device boot |
---
### GET `/api/files` - List Files
Returns a JSON array of files and folders in the specified directory.
**Request:**
```bash
# List root directory
curl http://crosspoint.local/api/files
# List specific directory
curl "http://crosspoint.local/api/files?path=/Books"
```
**Query Parameters:**
| Parameter | Required | Default | Description |
| --------- | -------- | ------- | ---------------------- |
| `path` | No | `/` | Directory path to list |
**Response (200 OK):**
```json
[
{"name": "MyBook.epub", "size": 1234567, "isDirectory": false, "isEpub": true},
{"name": "Notes", "size": 0, "isDirectory": true, "isEpub": false},
{"name": "document.pdf", "size": 54321, "isDirectory": false, "isEpub": false}
]
```
| Field | Type | Description |
| ------------- | ------- | ---------------------------------------- |
| `name` | string | File or folder name |
| `size` | number | Size in bytes (0 for directories) |
| `isDirectory` | boolean | `true` if the item is a folder |
| `isEpub` | boolean | `true` if the file has `.epub` extension |
**Notes:**
- Hidden files (starting with `.`) are automatically filtered out
- System folders (`System Volume Information`, `XTCache`) are hidden
---
### POST `/upload` - Upload File
Uploads a file to the SD card via multipart form data.
**Request:**
```bash
# Upload to root directory
curl -X POST -F "file=@mybook.epub" http://crosspoint.local/upload
# Upload to specific directory
curl -X POST -F "file=@mybook.epub" "http://crosspoint.local/upload?path=/Books"
```
**Query Parameters:**
| Parameter | Required | Default | Description |
| --------- | -------- | ------- | ------------------------------- |
| `path` | No | `/` | Target directory for the upload |
**Response (200 OK):**
```
File uploaded successfully: mybook.epub
```
**Error Responses:**
| Status | Body | Cause |
| ------ | ----------------------------------------------- | --------------------------- |
| 400 | `Failed to create file on SD card` | Cannot create file |
| 400 | `Failed to write to SD card - disk may be full` | Write error during upload |
| 400 | `Failed to write final data to SD card` | Error flushing final buffer |
| 400 | `Upload aborted` | Client aborted the upload |
| 400 | `Unknown error during upload` | Unspecified error |
**Notes:**
- Existing files with the same name will be overwritten
- Uses a 4KB buffer for efficient SD card writes
---
### POST `/mkdir` - Create Folder
Creates a new folder on the SD card.
**Request:**
```bash
curl -X POST -d "name=NewFolder&path=/" http://crosspoint.local/mkdir
```
**Form Parameters:**
| Parameter | Required | Default | Description |
| --------- | -------- | ------- | ---------------------------- |
| `name` | Yes | - | Name of the folder to create |
| `path` | No | `/` | Parent directory path |
**Response (200 OK):**
```
Folder created: NewFolder
```
**Error Responses:**
| Status | Body | Cause |
| ------ | ----------------------------- | ----------------------------- |
| 400 | `Missing folder name` | `name` parameter not provided |
| 400 | `Folder name cannot be empty` | Empty folder name |
| 400 | `Folder already exists` | Folder with same name exists |
| 500 | `Failed to create folder` | SD card error |
---
### POST `/delete` - Delete File or Folder
Deletes a file or folder from the SD card.
**Request:**
```bash
# Delete a file
curl -X POST -d "path=/Books/mybook.epub&type=file" http://crosspoint.local/delete
# Delete an empty folder
curl -X POST -d "path=/OldFolder&type=folder" http://crosspoint.local/delete
```
**Form Parameters:**
| Parameter | Required | Default | Description |
| --------- | -------- | ------- | -------------------------------- |
| `path` | Yes | - | Path to the item to delete |
| `type` | No | `file` | Type of item: `file` or `folder` |
**Response (200 OK):**
```
Deleted successfully
```
**Error Responses:**
| Status | Body | Cause |
| ------ | --------------------------------------------- | ----------------------------- |
| 400 | `Missing path` | `path` parameter not provided |
| 400 | `Cannot delete root directory` | Attempted to delete `/` |
| 400 | `Folder is not empty. Delete contents first.` | Non-empty folder |
| 403 | `Cannot delete system files` | Hidden file (starts with `.`) |
| 403 | `Cannot delete protected items` | Protected system folder |
| 404 | `Item not found` | Path does not exist |
| 500 | `Failed to delete item` | SD card error |
**Protected Items:**
- Files/folders starting with `.`
- `System Volume Information`
- `XTCache`
---
## WebSocket Endpoint
### Port 81 - Fast Binary Upload
A WebSocket endpoint for high-speed binary file uploads. More efficient than HTTP multipart for large files.
**Connection:**
```
ws://crosspoint.local:81/
```
**Protocol:**
1. **Client** sends TEXT message: `START:<filename>:<size>:<path>`
2. **Server** responds with TEXT: `READY`
3. **Client** sends BINARY messages with file data chunks
4. **Server** sends TEXT progress updates: `PROGRESS:<received>:<total>`
5. **Server** sends TEXT when complete: `DONE` or `ERROR:<message>`
**Example Session:**
```
Client -> "START:mybook.epub:1234567:/Books"
Server -> "READY"
Client -> [binary chunk 1]
Client -> [binary chunk 2]
Server -> "PROGRESS:65536:1234567"
Client -> [binary chunk 3]
...
Server -> "PROGRESS:1234567:1234567"
Server -> "DONE"
```
**Error Messages:**
| Message | Cause |
| --------------------------------- | ---------------------------------- |
| `ERROR:Failed to create file` | Cannot create file on SD card |
| `ERROR:Invalid START format` | Malformed START message |
| `ERROR:No upload in progress` | Binary data received without START |
| `ERROR:Write failed - disk full?` | SD card write error |
**Example with `websocat`:**
```bash
# Interactive session
websocat ws://crosspoint.local:81
# Then type:
START:mybook.epub:1234567:/Books
# Wait for READY, then send binary data
```
**Notes:**
- Progress updates are sent every 64KB or at completion
- Disconnection during upload will delete the incomplete file
- Existing files with the same name will be overwritten
---
## Network Modes
The device can operate in two network modes:
### Station Mode (STA)
- Device connects to an existing WiFi network
- IP address assigned by router/DHCP
- `mode` field in `/api/status` returns `"STA"`
- `rssi` field shows signal strength
### Access Point Mode (AP)
- Device creates its own WiFi hotspot
- Default IP is typically `192.168.4.1`
- `mode` field in `/api/status` returns `"AP"`
- `rssi` field returns `0`
---
## Notes
- These examples use `crosspoint.local`. If your network does not support mDNS or the address does not resolve, replace it with the specific **IP Address** displayed on your device screen (e.g., `http://192.168.1.102/`).
- All paths on the SD card start with `/`
- Trailing slashes are automatically stripped (except for root `/`)
- The webserver uses chunked transfer encoding for file listings

View File

@ -172,89 +172,7 @@ This is useful for organizing your ebooks by genre, author, or series.
## Command Line File Management
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode.
### Uploading a File
To upload a file to the root directory, use the following command:
```bash
curl -F "file=@book.epub" "http://crosspoint.local/upload?path=/"
```
* **`-F "file=@filename"`**: Points to the local file on your computer.
* **`path=/`**: The destination folder on the device SD card.
### Deleting a File
To delete a specific file, provide the full path on the SD card:
```bash
curl -F "path=/folder/file.epub" "http://crosspoint.local/delete"
```
### Advanced Flags
For more reliable transfers of large EPUB files, consider adding these flags:
* `-#`: Shows a simple progress bar.
* `--connect-timeout 30`: Limits how long curl waits to establish a connection (in seconds).
* `--max-time 300`: Sets a maximum duration for the entire transfer (5 minutes).
> [!NOTE]
> These examples use `crosspoint.local`. If your network does not support mDNS or the address does not resolve, replace it with the specific **IP Address** displayed on your device screen (e.g., `http://192.168.1.102/`).
---
## 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
---
For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode a detailed documentation can be found [here](./webserver-endpoints.md).
## Security Notes
@ -303,4 +221,5 @@ Your uploaded files will be immediately available in the file browser!
## Related Documentation
- [User Guide](../USER_GUIDE.md) - General device operation
- [Troubleshooting](./troubleshooting.md) - Troubleshooting
- [README](../README.md) - Project overview and features

View File

@ -4,6 +4,14 @@
#include <cstring>
OpdsParser::OpdsParser() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
errorOccured = true;
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis());
}
}
OpdsParser::~OpdsParser() {
if (parser) {
XML_StopParser(parser, XML_FALSE);
@ -14,13 +22,11 @@ OpdsParser::~OpdsParser() {
}
}
bool OpdsParser::parse(const char* xmlData, const size_t length) {
clear();
size_t OpdsParser::write(uint8_t c) { return write(&c, 1); }
parser = XML_ParserCreate(nullptr);
if (!parser) {
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis());
return false;
size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
if (errorOccured) {
return length;
}
XML_SetUserData(parser, this);
@ -28,43 +34,48 @@ bool OpdsParser::parse(const char* xmlData, const size_t length) {
XML_SetCharacterDataHandler(parser, characterData);
// Parse in chunks to avoid large buffer allocations
const char* currentPos = xmlData;
const char* currentPos = reinterpret_cast<const char*>(xmlData);
size_t remaining = length;
constexpr size_t chunkSize = 1024;
while (remaining > 0) {
void* const buf = XML_GetBuffer(parser, chunkSize);
if (!buf) {
errorOccured = true;
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis());
XML_ParserFree(parser);
parser = nullptr;
return false;
return length;
}
const size_t toRead = remaining < chunkSize ? remaining : chunkSize;
memcpy(buf, currentPos, toRead);
const bool isFinal = (remaining == toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), isFinal) == XML_STATUS_ERROR) {
if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) {
errorOccured = true;
Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
XML_ParserFree(parser);
parser = nullptr;
return false;
return length;
}
currentPos += toRead;
remaining -= toRead;
}
// Clean up parser
XML_ParserFree(parser);
parser = nullptr;
Serial.printf("[%lu] [OPDS] Parsed %zu entries\n", millis(), entries.size());
return true;
return length;
}
void OpdsParser::flush() {
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
errorOccured = true;
XML_ParserFree(parser);
parser = nullptr;
}
}
bool OpdsParser::error() const { return errorOccured; }
void OpdsParser::clear() {
entries.clear();
currentEntry = OpdsEntry{};

View File

@ -1,4 +1,5 @@
#pragma once
#include <Print.h>
#include <expat.h>
#include <string>
@ -42,28 +43,30 @@ using OpdsBook = OpdsEntry;
* }
* }
*/
class OpdsParser {
class OpdsParser final : public Print {
public:
OpdsParser() = default;
OpdsParser();
~OpdsParser();
// Disable copy
OpdsParser(const OpdsParser&) = delete;
OpdsParser& operator=(const OpdsParser&) = delete;
/**
* Parse an OPDS XML feed.
* @param xmlData Pointer to the XML data
* @param length Length of the XML data
* @return true if parsing succeeded, false on error
*/
bool parse(const char* xmlData, size_t length);
size_t write(uint8_t) override;
size_t write(const uint8_t*, size_t) override;
void flush() override;
bool error() const;
operator bool() { return !error(); }
/**
* Get the parsed entries (both navigation and book entries).
* @return Vector of OpdsEntry entries
*/
const std::vector<OpdsEntry>& getEntries() const { return entries; }
const std::vector<OpdsEntry>& getEntries() const& { return entries; }
std::vector<OpdsEntry> getEntries() && { return std::move(entries); }
/**
* Get only book entries (legacy compatibility).
@ -96,4 +99,6 @@ class OpdsParser {
bool inAuthor = false;
bool inAuthorName = false;
bool inId = false;
bool errorOccured = false;
};

View File

@ -0,0 +1,15 @@
#include "OpdsStream.h"
OpdsParserStream::OpdsParserStream(OpdsParser& parser) : parser(parser) {}
int OpdsParserStream::available() { return 0; }
int OpdsParserStream::peek() { abort(); }
int OpdsParserStream::read() { abort(); }
size_t OpdsParserStream::write(uint8_t c) { return parser.write(c); }
size_t OpdsParserStream::write(const uint8_t* buffer, size_t size) { return parser.write(buffer, size); }
OpdsParserStream::~OpdsParserStream() { parser.flush(); }

View File

@ -0,0 +1,23 @@
#pragma once
#include <Stream.h>
#include "OpdsParser.h"
class OpdsParserStream : public Stream {
public:
explicit OpdsParserStream(OpdsParser& parser);
// That functions are not implimented for that stream
int available() override;
int peek() override;
int read() override;
virtual size_t write(uint8_t c) override;
virtual size_t write(const uint8_t* buffer, size_t size) override;
~OpdsParserStream() override;
private:
OpdsParser& parser;
};

View File

@ -2,7 +2,7 @@
default_envs = default
[crosspoint]
version = 0.14.0
version = 0.15.0
[base]
platform = espressif32 @ 6.12.0

View File

@ -1,7 +1,9 @@
#include "OpdsBookBrowserActivity.h"
#include <Epub.h>
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <OpdsStream.h>
#include <WiFi.h>
#include "CrossPointSettings.h"
@ -264,23 +266,27 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
std::string url = UrlUtils::buildUrl(serverUrl, path);
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str());
std::string content;
if (!HttpDownloader::fetchUrl(url, content)) {
state = BrowserState::ERROR;
errorMessage = "Failed to fetch feed";
updateRequired = true;
return;
OpdsParser parser;
{
OpdsParserStream stream{parser};
if (!HttpDownloader::fetchUrl(url, stream)) {
state = BrowserState::ERROR;
errorMessage = "Failed to fetch feed";
updateRequired = true;
return;
}
}
OpdsParser parser;
if (!parser.parse(content.c_str(), content.size())) {
if (!parser) {
state = BrowserState::ERROR;
errorMessage = "Failed to parse feed";
updateRequired = true;
return;
}
entries = parser.getEntries();
entries = std::move(parser).getEntries();
Serial.printf("[%lu] [OPDS] Found %d entries\n", millis(), entries.size());
selectorIndex = 0;
if (entries.empty()) {
@ -355,6 +361,12 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
if (result == HttpDownloader::OK) {
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str());
// Invalidate any existing cache for this file to prevent stale metadata issues
Epub epub(filename, "/.crosspoint");
epub.clearCache();
Serial.printf("[%lu] [OPDS] Cleared cache for: %s\n", millis(), filename.c_str());
state = BrowserState::BROWSING;
updateRequired = true;
} else {

View File

@ -9,7 +9,8 @@
namespace {
// Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700;
constexpr int RAPID_NAV_START_MS = 500;
constexpr int RAPID_NAV_DELAY_MS = 700;
} // namespace
bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); }
@ -124,10 +125,20 @@ void EpubReaderChapterSelectionActivity::loop() {
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const bool prevPressed =
mappedInput.isPressed(MappedInputManager::Button::Up) || mappedInput.isPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.isPressed(MappedInputManager::Button::Down) ||
mappedInput.isPressed(MappedInputManager::Button::Right);
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
const int currentTocPage = selectorIndex / pageItems;
const int lastTocPage = (epub->getTocItemsCount() - 1) / pageItems;
const bool shouldNavigateRapidly = mappedInput.getHeldTime() > RAPID_NAV_START_MS;
const bool isRapidNavigationDue = (millis() - lastRapidNavTime) > RAPID_NAV_DELAY_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Check if sync option is selected (first or last item)
if (isSyncItem(selectorIndex)) {
@ -146,19 +157,29 @@ void EpubReaderChapterSelectionActivity::loop() {
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems;
} else {
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
if (lastRapidNavTime != 0) {
lastRapidNavTime = 0;
return;
}
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
updateRequired = true;
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems;
} else {
selectorIndex = (selectorIndex + 1) % totalItems;
if (lastRapidNavTime != 0) {
lastRapidNavTime = 0;
return;
}
selectorIndex = (selectorIndex + 1) % totalItems;
updateRequired = true;
} else if (prevPressed && shouldNavigateRapidly && isRapidNavigationDue) {
selectorIndex = currentTocPage > 0 ? (currentTocPage - 1) * pageItems : lastTocPage * pageItems;
updateRequired = true;
lastRapidNavTime = millis();
} else if (nextPressed && shouldNavigateRapidly && isRapidNavigationDue) {
selectorIndex = currentTocPage < lastTocPage ? (currentTocPage + 1) * pageItems : 0;
updateRequired = true;
lastRapidNavTime = millis();
}
}

View File

@ -18,6 +18,7 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
int totalPagesInSpine = 0;
int selectorIndex = 0;
bool updateRequired = false;
unsigned long lastRapidNavTime = 0;
const std::function<void()> onGoBack;
const std::function<void(int newSpineIndex)> onSelectSpineIndex;
const std::function<void(int newSpineIndex, int newPage)> onSyncPosition;

View File

@ -6,7 +6,8 @@
#include "fontIds.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr int RAPID_NAV_START_MS = 500;
constexpr int RAPID_NAV_DELAY_MS = 700;
} // namespace
int XtcReaderChapterSelectionActivity::getPageItems() const {
@ -80,8 +81,18 @@ void XtcReaderChapterSelectionActivity::loop() {
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const bool prevPressed =
mappedInput.isPressed(MappedInputManager::Button::Up) || mappedInput.isPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.isPressed(MappedInputManager::Button::Down) ||
mappedInput.isPressed(MappedInputManager::Button::Right);
const int pageItems = getPageItems();
const int currentTocPage = selectorIndex / pageItems;
const int total = static_cast<int>(xtc->getChapters().size());
const int lastTocPage = total > 0 ? (total - 1) / pageItems : 0;
const bool shouldNavigateRapidly = mappedInput.getHeldTime() > RAPID_NAV_START_MS;
const bool isRapidNavigationDue = (millis() - lastRapidNavTime) > RAPID_NAV_DELAY_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto& chapters = xtc->getChapters();
@ -91,27 +102,37 @@ void XtcReaderChapterSelectionActivity::loop() {
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack();
} else if (prevReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
if (total == 0 || lastRapidNavTime != 0) {
lastRapidNavTime = 0;
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total;
} else {
selectorIndex = (selectorIndex + total - 1) % total;
}
selectorIndex = (selectorIndex + total - 1) % total;
updateRequired = true;
} else if (nextReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0 || lastRapidNavTime != 0) {
lastRapidNavTime = 0;
return;
}
selectorIndex = (selectorIndex + 1) % total;
updateRequired = true;
} else if (prevPressed && shouldNavigateRapidly && isRapidNavigationDue) {
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
} else {
selectorIndex = (selectorIndex + 1) % total;
}
selectorIndex = currentTocPage > 0 ? (currentTocPage - 1) * pageItems : lastTocPage * pageItems;
updateRequired = true;
lastRapidNavTime = millis();
} else if (nextPressed && shouldNavigateRapidly && isRapidNavigationDue) {
if (total == 0) {
return;
}
selectorIndex = currentTocPage < lastTocPage ? (currentTocPage + 1) * pageItems : 0;
updateRequired = true;
lastRapidNavTime = millis();
}
}

View File

@ -15,6 +15,7 @@ class XtcReaderChapterSelectionActivity final : public Activity {
uint32_t currentPage = 0;
int selectorIndex = 0;
bool updateRequired = false;
unsigned long lastRapidNavTime = 0;
const std::function<void()> onGoBack;
const std::function<void(uint32_t newPage)> onSelectPage;

View File

@ -6,6 +6,7 @@
#include <cstring>
#include "CalibreSettingsActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
#include "KOReaderSettingsActivity.h"
#include "MappedInputManager.h"
@ -110,6 +111,14 @@ void CategorySettingsActivity::toggleCurrentSetting() {
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Clear Cache") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Check for updates") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();

View File

@ -0,0 +1,178 @@
#include "ClearCacheActivity.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include "MappedInputManager.h"
#include "fontIds.h"
void ClearCacheActivity::taskTrampoline(void* param) {
auto* self = static_cast<ClearCacheActivity*>(param);
self->displayTaskLoop();
}
void ClearCacheActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
state = WARNING;
updateRequired = true;
xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask",
4096, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void ClearCacheActivity::onExit() {
ActivityWithSubactivity::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 ClearCacheActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void ClearCacheActivity::render() {
const auto pageHeight = renderer.getScreenHeight();
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Clear Cache", true, EpdFontFamily::BOLD);
if (state == WARNING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 60, "This will clear all cached book data.", true);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 30, "All reading progress will be lost!", true,
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Books will need to be re-indexed", true);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, "when opened again.", true);
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
if (state == CLEARING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Clearing cache...", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
if (state == SUCCESS) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Cache Cleared", true, EpdFontFamily::BOLD);
String resultText = String(clearedCount) + " items removed";
if (failedCount > 0) {
resultText += ", " + String(failedCount) + " failed";
}
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, resultText.c_str());
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
if (state == FAILED) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Failed to clear cache", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Check serial output for details");
const auto labels = mappedInput.mapLabels("« Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
}
void ClearCacheActivity::clearCache() {
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis());
// Open .crosspoint directory
auto root = SdMan.open("/.crosspoint");
if (!root || !root.isDirectory()) {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to open cache directory\n", millis());
if (root) root.close();
state = FAILED;
updateRequired = true;
return;
}
clearedCount = 0;
failedCount = 0;
char name[128];
// Iterate through all entries in the directory
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name));
String itemName(name);
// Only delete directories starting with epub_ or xtc_
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) {
String fullPath = "/.crosspoint/" + itemName;
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str());
file.close(); // Close before attempting to delete
if (SdMan.removeDir(fullPath.c_str())) {
clearedCount++;
} else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str());
failedCount++;
}
} else {
file.close();
}
}
root.close();
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount);
state = SUCCESS;
updateRequired = true;
}
void ClearCacheActivity::loop() {
if (state == WARNING) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CLEARING;
xSemaphoreGive(renderingMutex);
updateRequired = true;
vTaskDelay(10 / portTICK_PERIOD_MS);
clearCache();
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
Serial.printf("[%lu] [CLEAR_CACHE] User cancelled\n", millis());
goBack();
}
return;
}
if (state == SUCCESS || state == FAILED) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
goBack();
}
return;
}
}

View File

@ -0,0 +1,37 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include "activities/ActivityWithSubactivity.h"
class ClearCacheActivity final : public ActivityWithSubactivity {
public:
explicit ClearCacheActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& goBack)
: ActivityWithSubactivity("ClearCache", renderer, mappedInput), goBack(goBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
enum State { WARNING, CLEARING, SUCCESS, FAILED };
State state = WARNING;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
const std::function<void()> goBack;
int clearedCount = 0;
int failedCount = 0;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render();
void clearCache();
};

View File

@ -44,11 +44,11 @@ const SettingInfo controlsSettings[controlsSettingsCount] = {
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})};
constexpr int systemSettingsCount = 4;
constexpr int systemSettingsCount = 5;
const SettingInfo systemSettings[systemSettingsCount] = {
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"),
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Clear Cache"),
SettingInfo::Action("Check for updates")};
} // namespace

View File

@ -1,6 +1,7 @@
#include "CrossPointWebServer.h"
#include <ArduinoJson.h>
#include <Epub.h>
#include <FsHelpers.h>
#include <SDCardManager.h>
#include <WiFi.h>
@ -10,6 +11,7 @@
#include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h"
#include "util/StringUtils.h"
namespace {
// Folders/files to hide from the web interface file browser
@ -28,6 +30,15 @@ size_t wsUploadSize = 0;
size_t wsUploadReceived = 0;
unsigned long wsUploadStartTime = 0;
bool wsUploadInProgress = false;
// Helper function to clear epub cache after upload
void clearEpubCacheIfNeeded(const String& filePath) {
// Only clear cache for .epub files
if (StringUtils::checkFileExtension(filePath, ".epub")) {
Epub(filePath.c_str(), "/.crosspoint").clearCache();
Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str());
}
}
} // namespace
// File listing page template - now using generated headers:
@ -500,6 +511,12 @@ void CrossPointWebServer::handleUpload() const {
uploadFileName.c_str(), uploadSize, elapsed, avgKbps);
Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(),
writeCount, totalWriteTime, writePercent);
// Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = uploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += uploadFileName;
clearEpubCacheIfNeeded(filePath);
}
}
} else if (upload.status == UPLOAD_FILE_ABORTED) {
@ -787,6 +804,12 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(),
wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps);
// Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName;
clearEpubCacheIfNeeded(filePath);
wsServer->sendTXT(num, "DONE");
lastProgressSent = 0;
}

View File

@ -2,6 +2,7 @@
#include <HTTPClient.h>
#include <HardwareSerial.h>
#include <StreamString.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>
@ -9,7 +10,7 @@
#include "util/UrlUtils.h"
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
std::unique_ptr<WiFiClient> client;
if (UrlUtils::isHttpsUrl(url)) {
@ -34,10 +35,20 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
return false;
}
outContent = http.getString().c_str();
http.writeToStream(&outContent);
http.end();
Serial.printf("[%lu] [HTTP] Fetched %zu bytes\n", millis(), outContent.size());
Serial.printf("[%lu] [HTTP] Fetch success\n", millis());
return true;
}
bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
StreamString stream;
if (!fetchUrl(url, stream)) {
return false;
}
outContent = stream.c_str();
return true;
}

View File

@ -27,6 +27,8 @@ class HttpDownloader {
*/
static bool fetchUrl(const std::string& url, std::string& outContent);
static bool fetchUrl(const std::string& url, Stream& stream);
/**
* Download a file to the SD card.
* @param url The URL to download

View File

@ -49,6 +49,18 @@ bool checkFileExtension(const std::string& fileName, const char* extension) {
return true;
}
bool checkFileExtension(const String& fileName, const char* extension) {
if (fileName.length() < strlen(extension)) {
return false;
}
String localFile(fileName);
String localExtension(extension);
localFile.toLowerCase();
localExtension.toLowerCase();
return localFile.endsWith(localExtension);
}
size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0;
size_t pos = str.size() - 1;

View File

@ -1,5 +1,7 @@
#pragma once
#include <WString.h>
#include <string>
namespace StringUtils {
@ -15,6 +17,7 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
* Check if the given filename ends with the specified extension (case-insensitive).
*/
bool checkFileExtension(const std::string& fileName, const char* extension);
bool checkFileExtension(const String& fileName, const char* extension);
// UTF-8 safe string truncation - removes one character from the end
// Returns the new size after removing one UTF-8 character