Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 8 additions & 27 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,37 +65,21 @@ jobs:
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high

build:
runs-on: ubuntu-latest
# Never run untrusted fork PR code on the self-hosted runner.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: [self-hosted, linux, crossink-build]
steps:
- uses: actions/checkout@v6
with:
submodules: recursive

- uses: actions/setup-python@v6
with:
python-version: "3.14"

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
enable-cache: true

- name: Cache PlatformIO packages
uses: actions/cache@v4
with:
path: ~/.platformio
key: ${{ runner.os }}-platformio-${{ hashFiles('platformio.ini', 'platformio.local.example.ini') }}
restore-keys: |
${{ runner.os }}-platformio-

- name: Install PlatformIO Core
run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip
- name: Verify PlatformIO Core
run: pio --version

- name: Build CrossInk
- name: Build CrossInk tiny
run: |
set -euo pipefail
pio run | tee pio.log
pio run -e tiny | tee pio.log

- name: Extract firmware stats
run: |
Expand All @@ -108,12 +92,9 @@ jobs:
- name: Upload firmware artifacts
uses: actions/upload-artifact@v7
with:
name: firmware
name: firmware-tiny
path: |
.pio/build/teensy/firmware-teensy.bin
.pio/build/tiny/firmware-tiny.bin
.pio/build/xlarge/firmware-xlarge.bin
.pio/build/no_emoji/firmware-no_emoji.bin
if-no-files-found: error

# This job is used as the PR required actions check, allows for changes to other steps in the future without breaking
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@
## [Unreleased]

### Added
- Added an adjustable reader line-height setting with percent-based spacing for EPUB and TXT books.
- Added nearby Reading Stats sync between CrossInk readers using direct ESP-NOW device-to-device messages.
- Auto Page Turn interval now remembers the last selected interval per book when it is turned on again.

### Fixed
- Fixed Lyra Carousel popup rendering so loading, indexing, and sleep-entry popups appear in the right place again.
- Improved OPDS book download throughput by using a larger transfer buffer while keeping SD-card font downloads on the lower-memory path.
- Fixed OPDS feed errors so low-memory parser-buffer failures show the specific memory message instead of the generic parse error.
- Free the active SD-card reader font before opening OPDS catalogs so WiFi/feed parsing has more memory available.
- Fixed Auto Page Turn so each book remembers the last selected interval when it is turned on again.

### Changed


## [v1.3.0] - 2026-05-21

### Added
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ Some simple per-book reading stats are tracked automatically and displayed in tw
- Average session time
- All time reading stats including total number of books read

To include all-time totals from other CrossInk devices, create or sync a
`.crosspoint/synced_stats/` folder between devices. When that folder exists,
each reader writes its own `device_<mac>.bin` contribution file and ignores that
file while summing the folder, so any device can display the aggregate total
without becoming the main device. If the folder is not present, the reader only
uses its local `global_stats.bin`.

**Home screen book card (Lyra theme only):**

- Total reading time
Expand Down Expand Up @@ -372,6 +379,7 @@ The structure is roughly:
.crosspoint/
├── global_stats.bin # All-time reading stats, including total books read
├── global_stats.bin.bak # Backup used if the main global stats file is corrupt
├── synced_stats/ # One per-device stats contribution file for aggregate all-time totals
├── settings.bin # Device settings
├── state.bin # Last-opened book and sleep/session state
├── recent.bin # Recent books list
Expand Down
1 change: 1 addition & 0 deletions docs/contributing/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Typical persisted areas on SD:
epub_<hash>/
book.bin
progress.bin
reader_settings.bin
stats.bin
cover.bmp
sections/*.bin
Expand Down
45 changes: 45 additions & 0 deletions docs/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,38 @@ nav_order: 8

# File Formats

## `global_stats.bin`

`/.crosspoint/global_stats.bin` stores this device's all-time reading counters.
If `/.crosspoint/synced_stats/` already exists, saves also mirror the same
counters to `/.crosspoint/synced_stats/device_<mac>.bin`, where `<mac>` is the
device's hardware MAC address without separators. The reader does not create
this folder on its own.

The `/.crosspoint/synced_stats/` directory is designed for peer-to-peer folder
sync: each device owns one contribution file, and display-only Reading Stats
views add every other device's contribution to this device's local
`global_stats.bin`. This device's own contribution file is skipped while
aggregating so mirroring the folder back to the same device does not double
count its local stats.

### Version 2

Adds `completedBooks` after the original counters.

```text
[0] version (= 2)
[1-4] totalSessions uint32 little-endian
[5-8] totalReadingSeconds uint32 little-endian
[9-12] totalPagesTurned uint32 little-endian
[13-16] completedBooks uint32 little-endian
```

### Version 1

Version 1 files are still readable. They are 13 bytes long and do not include
`completedBooks`, so the reader treats that value as zero.

## `book.bin`

### Version 6
Expand Down Expand Up @@ -116,6 +148,19 @@ if (parsedSize != fileSize) {
}
```

## `reader_settings.bin`

### Version 1

Stores per-book reader preferences that should survive reopening a book without changing EPUB layout caches.

Binary layout:

```text
[0] version (= 1)
[1-2] lastAutoPageTurnIntervalSeconds uint16_t LE
```

## `section.bin`

### Version 36
Expand Down
23 changes: 16 additions & 7 deletions docs/webserver.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,24 @@ The web interface includes APIs for managing saved OPDS servers and saved WiFi c

You can manage files directly from a terminal with `curl` while File Transfer is running. See [Webserver Endpoints](./webserver-endpoints.md).

## Security Notes
## Bluetooth Reading Stats Sync

CrossPoint Reader supports reader-to-reader stats sync from **File Transfer > Bluetooth Stats Sync**. Bluetooth is
advertised only while that screen is open. Press **Sync Stats** on one nearby reader; both readers exchange their
`/.crosspoint/global_stats.bin` contribution and save the peer copy under `/.crosspoint/synced_stats/`.

- The web server runs on HTTP port 80.
- The fast upload WebSocket runs on port 81.
- No authentication is required.
- Anyone on the same network, or connected to the CrossInk hotspot, can access the interface while File Transfer is running.
- The web server stops and WiFi disconnects when you exit File Transfer.
The `synced_stats` folder is created automatically when the user starts the Bluetooth stats-sync workflow. Peer files
are named with the durable device MAC fallback, for example `device_aabbccddeeff.bin`.

## Security Notes

Use File Transfer only on trusted networks.
- 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
- Bluetooth stats sync only accepts stats payloads from nearby CrossPoint readers
- Bluetooth stats sync is only available while the Bluetooth Stats Sync screen is open
- For security, only use on trusted private networks

---

Expand Down
10 changes: 10 additions & 0 deletions lib/I18n/translations/english.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ STR_SAVE_PASSWORD: "Save password for next time?"
STR_PRESS_OK_SCAN: "Press OK to scan again"
STR_JOIN_NETWORK: "Join a Network"
STR_CREATE_HOTSPOT: "Create Hotspot"
STR_NEARBY_STATS_SYNC: "Nearby Stats Sync"
STR_JOIN_DESC: "Connect to an existing WiFi network"
STR_HOTSPOT_DESC: "Create a WiFi network others can join"
STR_NEARBY_STATS_SYNC_DESC: "Exchange reading stats with a nearby reader"
STR_STARTING_HOTSPOT: "Starting Hotspot..."
STR_HOTSPOT_MODE: "Hotspot Mode"
STR_CONNECT_WIFI_HINT: "Connect your device to this WiFi network"
Expand Down Expand Up @@ -209,6 +211,7 @@ STR_UNNAMED: "Unnamed"
STR_NO_SERVER_URL: "No server URL configured"
STR_FETCH_FEED_FAILED: "Failed to fetch feed"
STR_PARSE_FEED_FAILED: "Failed to parse feed"
STR_OPDS_FEED_BUFFER_MEMORY_ERROR: "Couldn't allocate memory for buffer"
STR_NEXT_PAGE: "Next Page »"
STR_PREV_PAGE: "« Previous Page"
STR_NETWORK_PREFIX: "Network: "
Expand Down Expand Up @@ -483,3 +486,10 @@ STR_FIRMWARE_WRITE_FAILED: "Firmware write failed"
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Do not power off!"
STR_RECOVERY_MODE: "Recovery Mode"
STR_RECOVERY_MODE_HINT: "Place firmware.bin on SD card root and select it"
STR_NEARBY_STATS_READY: "Ready to sync both readers"
STR_NEARBY_STATS_SYNC_BUTTON: "Sync"
STR_NEARBY_STATS_READY_HINT: "Press Sync on one reader only"
STR_NEARBY_STATS_SCANNING: "Finding reader to exchange stats"
STR_NEARBY_STATS_SYNCING: "Sending and receiving stats"
STR_NEARBY_STATS_SYNCED: "Both readers synced"
STR_NEARBY_STATS_SIMULATOR_UNAVAILABLE: "Nearby stats sync is not available in simulator"
8 changes: 8 additions & 0 deletions lib/OpdsParser/OpdsParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ OpdsParser::OpdsParser(OpdsEntry* entries, const size_t entryCapacity)
: entries(entries), entryCapacity(entryCapacity) {
if (!entries || entryCapacity == 0) {
errorOccured = true;
errorReason = OpdsParserError::NO_ENTRY_BUFFER;
LOG_DBG("OPDS", "No entry buffer supplied");
}

Expand All @@ -27,6 +28,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
}
if (!xmlData && length > 0) {
errorOccured = true;
errorReason = OpdsParserError::INVALID_INPUT;
return length;
}

Expand All @@ -38,6 +40,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
void* const buf = XML_GetBuffer(parser, chunkSize);
if (!buf) {
errorOccured = true;
errorReason = OpdsParserError::BUFFER_MEMORY;
LOG_DBG("OPDS", "Couldn't allocate memory for buffer");
destroyXmlParser(parser);
parser = nullptr;
Expand All @@ -49,6 +52,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {

if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) {
errorOccured = true;
errorReason = OpdsParserError::XML_PARSE;
LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser)));
destroyXmlParser(parser);
Expand All @@ -65,6 +69,7 @@ void OpdsParser::flush() {
if (!parser) return;
if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) {
errorOccured = true;
errorReason = OpdsParserError::XML_PARSE;
destroyXmlParser(parser);
parser = nullptr;
}
Expand All @@ -74,6 +79,7 @@ bool OpdsParser::parse(const uint8_t* xmlData, const size_t length) {
clear();
if (!xmlData && length > 0) {
errorOccured = true;
errorReason = OpdsParserError::INVALID_INPUT;
return false;
}

Expand All @@ -96,6 +102,7 @@ void OpdsParser::clear() {
currentText.clear();
inEntry = inTitle = inAuthor = inAuthorName = inId = false;
errorOccured = !entries || entryCapacity == 0;
errorReason = errorOccured ? OpdsParserError::NO_ENTRY_BUFFER : OpdsParserError::NONE;
resetXmlParser();
}

Expand All @@ -110,6 +117,7 @@ bool OpdsParser::resetXmlParser() {
parser = XML_ParserCreate(nullptr);
if (!parser) {
errorOccured = true;
errorReason = OpdsParserError::PARSER_MEMORY;
LOG_DBG("OPDS", "Couldn't allocate memory for parser");
return false;
}
Expand Down
4 changes: 4 additions & 0 deletions lib/OpdsParser/OpdsParser.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ enum class OpdsEntryType {
BOOK // Downloadable book
};

enum class OpdsParserError { NONE, NO_ENTRY_BUFFER, INVALID_INPUT, PARSER_MEMORY, BUFFER_MEMORY, XML_PARSE };

/**
* Represents an entry from an OPDS feed (either a navigation link or a book).
*/
Expand Down Expand Up @@ -144,6 +146,7 @@ class OpdsParser final : public Print {
bool parse(const char* xmlData, size_t length) { return parse(reinterpret_cast<const uint8_t*>(xmlData), length); }

bool error() const;
OpdsParserError getErrorReason() const { return errorReason; }

operator bool() const { return !error(); }

Expand Down Expand Up @@ -189,5 +192,6 @@ class OpdsParser final : public Print {
bool inId = false;

bool errorOccured = false;
OpdsParserError errorReason = OpdsParserError::NONE;
bool truncated = false;
};
1 change: 1 addition & 0 deletions scripts/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ cairosvg>=2.9.0
matplotlib>=3.10.9
pyserial>=3.5
colorama>=0.4.6
bleak>=1.1.1
Loading
Loading