From 7240cd52a9215e9a83cd691c5d44e7378a2d86a1 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Thu, 8 Jan 2026 22:57:50 +0100 Subject: [PATCH 01/38] Move battery status on home screen to top left (#253) So it doesn't look so lost on a row on its own. Also sligthly (1px) moved symbol in on reader view. --- src/activities/home/HomeActivity.cpp | 3 ++- src/activities/reader/EpubReaderActivity.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index a774780e..f34283d4 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -332,7 +332,8 @@ void HomeActivity::render() const { const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - ScreenComponents::drawBattery(renderer, 20, pageHeight - 70); + const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, "100 %"); + ScreenComponents::drawBattery(renderer, batteryX, 10); renderer.displayBuffer(); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 1233a821..ac0ffd51 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -437,7 +437,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY); } if (showChapterTitle) { From d4ae108d9b8f5c81cb77c8441ef84441bd1c1b18 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:58:58 -0500 Subject: [PATCH 02/38] Docs: Add instructions for file management via curl (#282) Per a [reddit thread](https://www.reddit.com/r/xteinkereader/comments/1q0fk9r/if_using_crosspoint_firmware_you_can_upload_using/), the file manager can be accessed via curl. Given file upload or deletion via curl may be useful for advanced users, I've added instructions. --- USER_GUIDE.md | 3 +++ docs/webserver.md | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1735ff82..1af7e129 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -56,6 +56,9 @@ The File Upload screen allows you to upload new e-books to the device. When you See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files. +> [!TIP] +> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details. + ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: diff --git a/docs/webserver.md b/docs/webserver.md index 2c96b8eb..2285a927 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -170,6 +170,40 @@ 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 From 87287012baf3bcf7b6a9c0a295cd2c7bb39dcadf Mon Sep 17 00:00:00 2001 From: Samuel Carpentier Date: Mon, 12 Jan 2026 17:58:08 +0900 Subject: [PATCH 03/38] Updated user guide (sleep screens list) (#293) ## Summary Updated sleep screens list by adding the newly available "Blank" option --- USER_GUIDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1af7e129..70a765ba 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -67,6 +67,7 @@ The Settings screen allows you to configure the device's behavior. There are a f - "Light" - The same default sleep screen, on a white background - "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information - "Cover" - The book cover image (Note: this is experimental and may not work as expected) + - "Blank" - A blank screen - **Status Bar**: Configure the status bar displayed while reading: - "None" - No status bar - "No Progress" - Show status bar without reading progress From 66811bf50babc726d54509454f836b5c60eddc21 Mon Sep 17 00:00:00 2001 From: Seth <32936504+selunders@users.noreply.github.com> Date: Mon, 12 Jan 2026 00:59:02 -0800 Subject: [PATCH 04/38] Add navigation hints to ChapterSelectionActivities (#294) ## Summary Add navigation hints to Chapter Select - #190 ### Before ![Mi 11X_20260108_214114_lmc_8 4](https://github.com/user-attachments/assets/45031d21-2c6c-4b7d-a5cc-6ad111bf5a70) ### After ![Mi 11X_20260108_213803_lmc_8 4](https://github.com/user-attachments/assets/1fa4ef22-63e4-4adb-8fc5-5fb8c7fa79fa) --- .../reader/EpubReaderChapterSelectionActivity.cpp | 7 ++++++- .../reader/XtcReaderChapterSelectionActivity.cpp | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 63f1e5a7..8f3ecb80 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -16,7 +16,9 @@ int EpubReaderChapterSelectionActivity::getPageItems() const { constexpr int lineHeight = 30; const int screenHeight = renderer.getScreenHeight(); - const int availableHeight = screenHeight - startY; + const int endY = screenHeight - lineHeight; + + const int availableHeight = endY - startY; int items = availableHeight / lineHeight; // Ensure we always have at least one item per page to avoid division by zero @@ -134,5 +136,8 @@ void EpubReaderChapterSelectionActivity::renderScreen() { tocIndex != selectorIndex); } + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); } diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp index fd732924..b2cfecaa 100644 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -14,7 +14,9 @@ int XtcReaderChapterSelectionActivity::getPageItems() const { constexpr int lineHeight = 30; const int screenHeight = renderer.getScreenHeight(); - const int availableHeight = screenHeight - startY; + const int endY = screenHeight - lineHeight; + + const int availableHeight = endY - startY; int items = availableHeight / lineHeight; if (items < 1) { items = 1; @@ -147,5 +149,8 @@ void XtcReaderChapterSelectionActivity::renderScreen() { renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex); } + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); } From 97c4871316a8f6ec8e15ef562bbfed567f2177af Mon Sep 17 00:00:00 2001 From: David Fischer <85546373+fischer-hub@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:07:26 +0100 Subject: [PATCH 05/38] Add page turn on power button press (#286) ## Summary * **What is the goal of this PR?** * This PR adds a setting to (additionally) map the forward page turn onto the powerbutton when in `EPUBReaderActivity` and powerbutton short press is not mapped to sleep mode. I find the powerbutton to be exactly where my thumb is while reading so it is very convenient to map the forwardpage turn to that. Maybe Im not alone with this ^^ * **What changes are included?** ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- src/CrossPointSettings.h | 11 ++++++++--- src/activities/reader/EpubReaderActivity.cpp | 2 ++ src/activities/reader/XtcReaderActivity.cpp | 2 ++ src/activities/settings/SettingsActivity.cpp | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 3a2a3503..ed6ad642 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -52,6 +52,9 @@ class CrossPointSettings { // E-ink refresh frequency (pages between full refreshes) enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; + // Short power button press actions + enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Sleep screen cover mode settings @@ -61,8 +64,8 @@ class CrossPointSettings { // Text rendering settings uint8_t extraParagraphSpacing = 1; uint8_t textAntiAliasing = 1; - // Duration of the power button press - uint8_t shortPwrBtn = 0; + // Short power button click behaviour + uint8_t shortPwrBtn = IGNORE; // EPUB reading orientation settings // 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise uint8_t orientation = PORTRAIT; @@ -88,7 +91,9 @@ class CrossPointSettings { // Get singleton instance static CrossPointSettings& getInstance() { return instance; } - uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 400; } + uint16_t getPowerButtonDuration() const { + return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400; + } int getReaderFontId() const; bool saveToFile() const; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ac0ffd51..819bcc6f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -152,6 +152,8 @@ void EpubReaderActivity::loop() { const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevReleased && !nextReleased) { diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index c0580cf6..9cdf5c97 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -112,6 +112,8 @@ void XtcReaderActivity::loop() { const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevReleased && !nextReleased) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 702db172..1252efbf 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -21,7 +21,7 @@ const SettingInfo settingsList[settingsCount] = { SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), - SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn), + SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"}), SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, From 88d0d904713fff02f20ed345496c102fcdc9d218 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Mon, 12 Jan 2026 10:53:58 +0100 Subject: [PATCH 06/38] Add option to hide battery percentage. (#297) with option to always hide or hide in reader only. Co-authored-by: Dave Allie --- src/CrossPointSettings.cpp | 3 +++ src/CrossPointSettings.h | 5 +++++ src/ScreenComponents.cpp | 5 +++-- src/ScreenComponents.h | 2 +- src/activities/home/HomeActivity.cpp | 10 ++++++++-- src/activities/reader/EpubReaderActivity.cpp | 4 +++- src/activities/settings/SettingsActivity.cpp | 3 ++- 7 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index cd8b56f7..1ca9ea74 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -46,6 +46,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, sleepScreenCoverMode); serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, textAntiAliasing); + serialization::writePod(outputFile, hideBatteryPercentage); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -110,6 +111,8 @@ bool CrossPointSettings::loadFromFile() { } serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, hideBatteryPercentage); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index ed6ad642..d5f91039 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -55,6 +55,9 @@ class CrossPointSettings { // Short power button press actions enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 }; + // Hide battery percentage + enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Sleep screen cover mode settings @@ -85,6 +88,8 @@ class CrossPointSettings { uint8_t screenMargin = 5; // OPDS browser settings char opdsServerUrl[128] = ""; + // Hide battery percentage + uint8_t hideBatteryPercentage = HIDE_NEVER; ~CrossPointSettings() = default; diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp index 3c359c0e..42b6ef7b 100644 --- a/src/ScreenComponents.cpp +++ b/src/ScreenComponents.cpp @@ -8,10 +8,11 @@ #include "Battery.h" #include "fontIds.h" -void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top) { +void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top, + const bool showPercentage) { // Left aligned battery icon and percentage const uint16_t percentage = battery.readPercentage(); - const auto percentageText = std::to_string(percentage) + "%"; + const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); // 1 column on left, 2 columns on right, 5 columns of battery body diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h index d938beea..150fb0c8 100644 --- a/src/ScreenComponents.h +++ b/src/ScreenComponents.h @@ -7,7 +7,7 @@ class GfxRenderer; class ScreenComponents { public: - static void drawBattery(const GfxRenderer& renderer, int left, int top); + static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); /** * Draw a progress bar with percentage text. diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index f34283d4..9c519d76 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -7,6 +7,7 @@ #include #include +#include "Battery.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" @@ -332,8 +333,13 @@ void HomeActivity::render() const { const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, "100 %"); - ScreenComponents::drawBattery(renderer, batteryX, 10); + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; + // get percentage so we can align text properly + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : ""; + const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage); renderer.displayBuffer(); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 819bcc6f..f51cf9bf 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -419,6 +419,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; // Position status bar near the bottom of the logical screen, regardless of orientation const auto screenHeight = renderer.getScreenHeight(); @@ -439,7 +441,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY); + ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); } if (showChapterTitle) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 1252efbf..f22850a9 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -13,12 +13,13 @@ // Define the static settings list namespace { -constexpr int settingsCount = 18; +constexpr int settingsCount = 19; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), + SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"}), From a9242fe61fcee99c92ecc7fe281b26f487d59ef0 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Mon, 12 Jan 2026 10:55:47 +0100 Subject: [PATCH 07/38] Generate different .bmp for cropped covers so settings have effect. (#330) Addresses https://github.com/daveallie/crosspoint-reader/pull/225#issuecomment-3735150337 --- lib/Epub/Epub.cpp | 13 ++++++++----- lib/Epub/Epub.h | 4 ++-- src/activities/boot_sleep/SleepActivity.cpp | 5 +++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 234344d7..9c4b058b 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -345,11 +345,14 @@ const std::string& Epub::getAuthor() const { return bookMetadataCache->coreMetadata.author; } -std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } +std::string Epub::getCoverBmpPath(bool cropped) const { + const auto coverFileName = "cover" + cropped ? "_crop" : ""; + return cachePath + "/" + coverFileName + ".bmp"; +} -bool Epub::generateCoverBmp() const { +bool Epub::generateCoverBmp(bool cropped) const { // Already generated, return true - if (SdMan.exists(getCoverBmpPath().c_str())) { + if (SdMan.exists(getCoverBmpPath(cropped).c_str())) { return true; } @@ -381,7 +384,7 @@ bool Epub::generateCoverBmp() const { } FsFile coverBmp; - if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) { + if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { coverJpg.close(); return false; } @@ -392,7 +395,7 @@ bool Epub::generateCoverBmp() const { if (!success) { Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); - SdMan.remove(getCoverBmpPath().c_str()); + SdMan.remove(getCoverBmpPath(cropped).c_str()); } Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); return success; diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index a6555e7e..047c955a 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -44,8 +44,8 @@ class Epub { const std::string& getPath() const; const std::string& getTitle() const; const std::string& getAuthor() const; - std::string getCoverBmpPath() const; - bool generateCoverBmp() const; + std::string getCoverBmpPath(bool cropped = false) const; + bool generateCoverBmp(bool cropped = false) const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 43e8e60b..7c79fcb1 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -199,6 +199,7 @@ void SleepActivity::renderCoverSleepScreen() const { } std::string coverBmpPath; + bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) { @@ -223,12 +224,12 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - if (!lastEpub.generateCoverBmp()) { + if (!lastEpub.generateCoverBmp(cropped)) { Serial.println("[SLP] Failed to generate cover bmp"); return renderDefaultSleepScreen(); } - coverBmpPath = lastEpub.getCoverBmpPath(); + coverBmpPath = lastEpub.getCoverBmpPath(cropped); } else { return renderDefaultSleepScreen(); } From 82f21f3c1d02862720cf2ad775f1e78fe37b5967 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 12 Jan 2026 21:35:18 +1100 Subject: [PATCH 08/38] Add AI usage question to the PR template --- .github/PULL_REQUEST_TEMPLATE.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c3866016..ee374b73 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,22 @@ ## Summary -* **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Implements the new feature for - file uploading.) +* **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) * **What changes are included?** ## Additional Context -* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). +* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, + specific areas to focus on). + +--- + +### 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? + +- [ ] Yes +- [ ] Partially +- [ ] No From 41bda438997061f1dbf8115893da0ef9cd35eb9a Mon Sep 17 00:00:00 2001 From: Andrew Brandt Date: Mon, 12 Jan 2026 04:37:23 -0600 Subject: [PATCH 09/38] chore: update formatting in github workflows (#320) **Description**: The purpose of this change is to modify the spacing in the `.github/workflow` files to ensure consistency. **Related Issue(s)**: Implements #319 Signed-off-by: Andrew Brandt --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be9a6e59..286f14aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,11 @@ name: CI jobs: build: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 with: submodules: recursive + - uses: actions/setup-python@v6 with: python-version: '3.14' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8d1c830..df8d6679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,17 +7,18 @@ on: jobs: build-release: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 with: submodules: recursive + - uses: actions/cache@v5 with: path: | ~/.cache/pip ~/.platformio/.cache key: ${{ runner.os }}-pio + - uses: actions/setup-python@v6 with: python-version: '3.14' From 66b100c6ca1c9a8545246dd6a03967e468b7d6d5 Mon Sep 17 00:00:00 2001 From: danoob <2942485+danoooob@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:49:42 +0700 Subject: [PATCH 10/38] fix: Wi-Fi Selection on Calibre Library launch (#313) ## Summary * **What is the goal of this PR?** Fixes the Wi-Fi connection issue when launching the Calibre Library (OPDS browser). The previous implementation always attempted to connect using the first saved WiFi credential, which caused connection failures when users were in locations where only other saved networks (not the first one) were available. Now, the activity launches a WiFi selection screen allowing users to choose from available networks. * **What changes are included?** ## Additional Context **Bug Fixed**: Previously, the code used `credentials[0]` (always the first saved WiFi), so users in areas with only their secondary/tertiary saved networks available could never connect. --------- Co-authored-by: danoooob --- .../browser/OpdsBookBrowserActivity.cpp | 74 +++++++++++-------- .../browser/OpdsBookBrowserActivity.h | 20 +++-- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index b9dbac8e..4e0a08d2 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -7,7 +7,7 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "ScreenComponents.h" -#include "WifiCredentialStore.h" +#include "activities/network/WifiSelectionActivity.h" #include "fontIds.h" #include "network/HttpDownloader.h" #include "util/StringUtils.h" @@ -25,7 +25,7 @@ void OpdsBookBrowserActivity::taskTrampoline(void* param) { } void OpdsBookBrowserActivity::onEnter() { - Activity::onEnter(); + ActivityWithSubactivity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); state = BrowserState::CHECK_WIFI; @@ -49,7 +49,7 @@ void OpdsBookBrowserActivity::onEnter() { } void OpdsBookBrowserActivity::onExit() { - Activity::onExit(); + ActivityWithSubactivity::onExit(); // Turn off WiFi when exiting WiFi.mode(WIFI_OFF); @@ -66,13 +66,28 @@ void OpdsBookBrowserActivity::onExit() { } void OpdsBookBrowserActivity::loop() { + // Handle WiFi selection subactivity + if (state == BrowserState::WIFI_SELECTION) { + ActivityWithSubactivity::loop(); + return; + } + // Handle error state - Confirm retries, Back goes back or home if (state == BrowserState::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - state = BrowserState::LOADING; - statusMessage = "Loading..."; - updateRequired = true; - fetchFeed(currentPath); + // Check if WiFi is still connected + if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { + // WiFi connected - just retry fetching the feed + Serial.printf("[%lu] [OPDS] Retry: WiFi connected, retrying fetch\n", millis()); + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + } else { + // WiFi not connected - launch WiFi selection + Serial.printf("[%lu] [OPDS] Retry: WiFi not connected, launching selection\n", millis()); + launchWifiSelection(); + } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } @@ -350,8 +365,8 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { } void OpdsBookBrowserActivity::checkAndConnectWifi() { - // Already connected? - if (WiFi.status() == WL_CONNECTED) { + // Already connected? Verify connection is valid by checking IP + if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { state = BrowserState::LOADING; statusMessage = "Loading..."; updateRequired = true; @@ -359,38 +374,33 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() { return; } - // Try to connect using saved credentials - statusMessage = "Connecting to WiFi..."; + // Not connected - launch WiFi selection screen directly + launchWifiSelection(); +} + +void OpdsBookBrowserActivity::launchWifiSelection() { + state = BrowserState::WIFI_SELECTION; updateRequired = true; - WIFI_STORE.loadFromFile(); - const auto& credentials = WIFI_STORE.getCredentials(); - if (credentials.empty()) { - state = BrowserState::ERROR; - errorMessage = "No WiFi credentials saved"; - updateRequired = true; - return; - } + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); +} - // Use the first saved credential - const auto& cred = credentials[0]; - WiFi.mode(WIFI_STA); - WiFi.begin(cred.ssid.c_str(), cred.password.c_str()); +void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { + exitActivity(); - // Wait for connection with timeout - constexpr int WIFI_TIMEOUT_MS = 10000; - const unsigned long startTime = millis(); - while (WiFi.status() != WL_CONNECTED && millis() - startTime < WIFI_TIMEOUT_MS) { - vTaskDelay(100 / portTICK_PERIOD_MS); - } - - if (WiFi.status() == WL_CONNECTED) { - Serial.printf("[%lu] [OPDS] WiFi connected: %s\n", millis(), WiFi.localIP().toString().c_str()); + if (connected) { + Serial.printf("[%lu] [OPDS] WiFi connected via selection, fetching feed\n", millis()); state = BrowserState::LOADING; statusMessage = "Loading..."; updateRequired = true; fetchFeed(currentPath); } else { + Serial.printf("[%lu] [OPDS] WiFi selection cancelled/failed\n", millis()); + // Force disconnect to ensure clean state for next retry + // This prevents stale connection status from interfering + WiFi.disconnect(); + WiFi.mode(WIFI_OFF); state = BrowserState::ERROR; errorMessage = "WiFi connection failed"; updateRequired = true; diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index efda294c..b08d9c2a 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -8,25 +8,27 @@ #include #include -#include "../Activity.h" +#include "../ActivityWithSubactivity.h" /** * Activity for browsing and downloading books from an OPDS server. * Supports navigation through catalog hierarchy and downloading EPUBs. + * When WiFi connection fails, launches WiFi selection to let user connect. */ -class OpdsBookBrowserActivity final : public Activity { +class OpdsBookBrowserActivity final : public ActivityWithSubactivity { public: enum class BrowserState { - CHECK_WIFI, // Checking WiFi connection - LOADING, // Fetching OPDS feed - BROWSING, // Displaying entries (navigation or books) - DOWNLOADING, // Downloading selected EPUB - ERROR // Error state with message + CHECK_WIFI, // Checking WiFi connection + WIFI_SELECTION, // WiFi selection subactivity is active + LOADING, // Fetching OPDS feed + BROWSING, // Displaying entries (navigation or books) + DOWNLOADING, // Downloading selected EPUB + ERROR // Error state with message }; explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onGoHome) - : Activity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} + : ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; @@ -54,6 +56,8 @@ class OpdsBookBrowserActivity final : public Activity { void render() const; void checkAndConnectWifi(); + void launchWifiSelection(); + void onWifiSelectionComplete(bool connected); void fetchFeed(const std::string& path); void navigateToEntry(const OpdsEntry& entry); void navigateBack(); From 0165fab5816ab37ab3336940768db0089071ca04 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Mon, 12 Jan 2026 12:36:19 +0100 Subject: [PATCH 11/38] Fix BMP rendering gamma/brightness (#302) 1. Refactor Bitmap.cpp/h to expose the options for FloydSteinberg and brightness/gamma correction at runtime 2. Fine-tune the thresholds for Floyd Steiberg and simple quantization to better match the display's colors Turns out that 2 is enough to make the images render properly, so the brightness boost and gamma adjustment doesn't seem necessary currently (at least for my test image). --- lib/GfxRenderer/Bitmap.cpp | 168 ++--------- lib/GfxRenderer/Bitmap.h | 10 +- lib/GfxRenderer/BitmapHelpers.cpp | 90 ++++++ lib/GfxRenderer/BitmapHelpers.h | 233 +++++++++++++++ lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 280 +----------------- src/activities/boot_sleep/SleepActivity.cpp | 4 +- 6 files changed, 371 insertions(+), 414 deletions(-) create mode 100644 lib/GfxRenderer/BitmapHelpers.cpp create mode 100644 lib/GfxRenderer/BitmapHelpers.h diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 8cc8a5f3..1a3b4406 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -8,119 +8,15 @@ // ============================================================================ // Note: For cover images, dithering is done in JpegToBmpConverter.cpp // This file handles BMP reading - use simple quantization to avoid double-dithering -constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion -constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering -// Brightness adjustments: -constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments -constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true -constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true +constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg // ============================================================================ -// Integer approximation of gamma correction (brightens midtones) -static inline int applyGamma(int gray) { - if (!GAMMA_CORRECTION) return gray; - const int product = gray * 255; - int x = gray; - if (x > 0) { - x = (x + product / x) >> 1; - x = (x + product / x) >> 1; - } - return x > 255 ? 255 : x; -} - -// Simple quantization without dithering - just divide into 4 levels -static inline uint8_t quantizeSimple(int gray) { - if (USE_BRIGHTNESS) { - gray += BRIGHTNESS_BOOST; - if (gray > 255) gray = 255; - gray = applyGamma(gray); - } - return static_cast(gray >> 6); -} - -// Hash-based noise dithering - survives downsampling without moiré artifacts -static inline uint8_t quantizeNoise(int gray, int x, int y) { - if (USE_BRIGHTNESS) { - gray += BRIGHTNESS_BOOST; - if (gray > 255) gray = 255; - gray = applyGamma(gray); - } - - uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; - hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(hash >> 24); - - const int scaled = gray * 3; - if (scaled < 255) { - return (scaled + threshold >= 255) ? 1 : 0; - } else if (scaled < 510) { - return ((scaled - 255) + threshold >= 255) ? 2 : 1; - } else { - return ((scaled - 510) + threshold >= 255) ? 3 : 2; - } -} - -// Main quantization function -static inline uint8_t quantize(int gray, int x, int y) { - if (USE_NOISE_DITHERING) { - return quantizeNoise(gray, x, y); - } else { - return quantizeSimple(gray); - } -} - -// Floyd-Steinberg quantization with error diffusion and serpentine scanning -// Returns 2-bit value (0-3) and updates error buffers -static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow, - bool reverseDir) { - // Add accumulated error to this pixel - int adjusted = gray + errorCurRow[x + 1]; - - // Clamp to valid range - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - - // Quantize to 4 levels (0, 85, 170, 255) - uint8_t quantized; - int quantizedValue; - if (adjusted < 43) { - quantized = 0; - quantizedValue = 0; - } else if (adjusted < 128) { - quantized = 1; - quantizedValue = 85; - } else if (adjusted < 213) { - quantized = 2; - quantizedValue = 170; - } else { - quantized = 3; - quantizedValue = 255; - } - - // Calculate error - int error = adjusted - quantizedValue; - - // Distribute error to neighbors (serpentine: direction-aware) - if (!reverseDir) { - // Left to right - errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16 - errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16 - errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 - errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16 - } else { - // Right to left (mirrored) - errorCurRow[x] += (error * 7) >> 4; // Left: 7/16 - errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16 - errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 - errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16 - } - - return quantized; -} - Bitmap::~Bitmap() { delete[] errorCurRow; delete[] errorNextRow; + + delete atkinsonDitherer; + delete fsDitherer; } uint16_t Bitmap::readLE16(FsFile& f) { @@ -244,13 +140,14 @@ BmpReaderError Bitmap::parseHeaders() { return BmpReaderError::SeekPixelDataFailed; } - // Allocate Floyd-Steinberg error buffers if enabled - if (USE_FLOYD_STEINBERG) { - delete[] errorCurRow; - delete[] errorNextRow; - errorCurRow = new int16_t[width + 2](); // +2 for boundary handling - errorNextRow = new int16_t[width + 2](); - prevRowY = -1; + // Create ditherer if enabled (only for 2-bit output) + // Use OUTPUT dimensions for dithering (after prescaling) + if (bpp > 2 && dithering) { + if (USE_ATKINSON) { + atkinsonDitherer = new AtkinsonDitherer(width); + } else { + fsDitherer = new FloydSteinbergDitherer(width); + } } return BmpReaderError::Ok; @@ -261,17 +158,6 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; - // Handle Floyd-Steinberg error buffer progression - const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow; - if (useFS) { - if (prevRowY != -1) { - // Sequential access - swap buffers - int16_t* temp = errorCurRow; - errorCurRow = errorNextRow; - errorNextRow = temp; - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - } - } prevRowY += 1; uint8_t* outPtr = data; @@ -282,12 +168,18 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { // Helper lambda to pack 2bpp color into the output stream auto packPixel = [&](const uint8_t lum) { uint8_t color; - if (useFS) { - // Floyd-Steinberg error diffusion - color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); + if (atkinsonDitherer) { + color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX); + } else if (fsDitherer) { + color = fsDitherer->processPixel(adjustPixel(lum), currentX); } else { - // Simple quantization or noise dithering - color = quantize(lum, currentX, prevRowY); + if (bpp > 2) { + // Simple quantization or noise dithering + color = quantize(adjustPixel(lum), currentX, prevRowY); + } else { + // do not quantize 2bpp image + color = static_cast(lum >> 6); + } } currentOutByte |= (color << bitShift); if (bitShift == 0) { @@ -345,6 +237,11 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { return BmpReaderError::UnsupportedBpp; } + if (atkinsonDitherer) + atkinsonDitherer->nextRow(); + else if (fsDitherer) + fsDitherer->nextRow(); + // Flush remaining bits if width is not a multiple of 4 if (bitShift != 6) *outPtr = currentOutByte; @@ -356,12 +253,9 @@ BmpReaderError Bitmap::rewindToData() const { return BmpReaderError::SeekPixelDataFailed; } - // Reset Floyd-Steinberg error buffers when rewinding - if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) { - memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - prevRowY = -1; - } + // Reset dithering when rewinding + if (fsDitherer) fsDitherer->reset(); + if (atkinsonDitherer) atkinsonDitherer->reset(); return BmpReaderError::Ok; } diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index a3f2e00c..9ac7cfbb 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -2,6 +2,10 @@ #include +#include + +#include "BitmapHelpers.h" + enum class BmpReaderError : uint8_t { Ok = 0, FileInvalid, @@ -28,7 +32,7 @@ class Bitmap { public: static const char* errorToString(BmpReaderError err); - explicit Bitmap(FsFile& file) : file(file) {} + explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {} ~Bitmap(); BmpReaderError parseHeaders(); BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; @@ -44,6 +48,7 @@ class Bitmap { static uint32_t readLE32(FsFile& f); FsFile& file; + bool dithering = false; int width = 0; int height = 0; bool topDown = false; @@ -56,4 +61,7 @@ class Bitmap { mutable int16_t* errorCurRow = nullptr; mutable int16_t* errorNextRow = nullptr; mutable int prevRowY = -1; // Track row progression for error propagation + + mutable AtkinsonDitherer* atkinsonDitherer = nullptr; + mutable FloydSteinbergDitherer* fsDitherer = nullptr; }; diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp new file mode 100644 index 00000000..b0d9dc06 --- /dev/null +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -0,0 +1,90 @@ +#include "BitmapHelpers.h" + +#include + +// Brightness/Contrast adjustments: +constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) +constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) +constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) +constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering + +// Integer approximation of gamma correction (brightens midtones) +// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255) +static inline int applyGamma(int gray) { + if (!GAMMA_CORRECTION) return gray; + // Fast integer square root approximation for gamma ~0.5 (brightening) + // This brightens dark/mid tones while preserving highlights + const int product = gray * 255; + // Newton-Raphson integer sqrt (2 iterations for good accuracy) + int x = gray; + if (x > 0) { + x = (x + product / x) >> 1; + x = (x + product / x) >> 1; + } + return x > 255 ? 255 : x; +} + +// Apply contrast adjustment around midpoint (128) +// factor > 1.0 increases contrast, < 1.0 decreases +static inline int applyContrast(int gray) { + // Integer-based contrast: (gray - 128) * factor + 128 + // Using fixed-point: factor 1.15 ≈ 115/100 + constexpr int factorNum = static_cast(CONTRAST_FACTOR * 100); + int adjusted = ((gray - 128) * factorNum) / 100 + 128; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + return adjusted; +} +// Combined brightness/contrast/gamma adjustment +int adjustPixel(int gray) { + if (!USE_BRIGHTNESS) return gray; + + // Order: contrast first, then brightness, then gamma + gray = applyContrast(gray); + gray += BRIGHTNESS_BOOST; + if (gray > 255) gray = 255; + if (gray < 0) gray = 0; + gray = applyGamma(gray); + + return gray; +} +// Simple quantization without dithering - divide into 4 levels +// The thresholds are fine-tuned to the X4 display +uint8_t quantizeSimple(int gray) { + if (gray < 45) { + return 0; + } else if (gray < 70) { + return 1; + } else if (gray < 140) { + return 2; + } else { + return 3; + } +} + +// Hash-based noise dithering - survives downsampling without moiré artifacts +// Uses integer hash to generate pseudo-random threshold per pixel +static inline uint8_t quantizeNoise(int gray, int x, int y) { + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); + + const int scaled = gray * 3; + if (scaled < 255) { + return (scaled + threshold >= 255) ? 1 : 0; + } else if (scaled < 510) { + return ((scaled - 255) + threshold >= 255) ? 2 : 1; + } else { + return ((scaled - 510) + threshold >= 255) ? 3 : 2; + } +} + +// Main quantization function - selects between methods based on config +uint8_t quantize(int gray, int x, int y) { + if (USE_NOISE_DITHERING) { + return quantizeNoise(gray, x, y); + } else { + return quantizeSimple(gray); + } +} diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h new file mode 100644 index 00000000..300527e0 --- /dev/null +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -0,0 +1,233 @@ +#pragma once + +#include + +// Helper functions +uint8_t quantize(int gray, int x, int y); +uint8_t quantizeSimple(int gray); +int adjustPixel(int gray); + +// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results +// Error distribution pattern: +// X 1/8 1/8 +// 1/8 1/8 1/8 +// 1/8 +// Less error buildup = fewer artifacts than Floyd-Steinberg +class AtkinsonDitherer { + public: + explicit AtkinsonDitherer(int width) : width(width) { + errorRow0 = new int16_t[width + 4](); // Current row + errorRow1 = new int16_t[width + 4](); // Next row + errorRow2 = new int16_t[width + 4](); // Row after next + } + + ~AtkinsonDitherer() { + delete[] errorRow0; + delete[] errorRow1; + delete[] errorRow2; + } + // **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR** + AtkinsonDitherer(const AtkinsonDitherer& other) = delete; + + // **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR** + AtkinsonDitherer& operator=(const AtkinsonDitherer& other) = delete; + + uint8_t processPixel(int gray, int x) { + // Add accumulated error + int adjusted = gray + errorRow0[x + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels + uint8_t quantized; + int quantizedValue; + if (false) { // original thresholds + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + } else { // fine-tuned to X4 eink display + if (adjusted < 30) { + quantized = 0; + quantizedValue = 15; + } else if (adjusted < 50) { + quantized = 1; + quantizedValue = 30; + } else if (adjusted < 140) { + quantized = 2; + quantizedValue = 80; + } else { + quantized = 3; + quantizedValue = 210; + } + } + + // Calculate error (only distribute 6/8 = 75%) + int error = (adjusted - quantizedValue) >> 3; // error/8 + + // Distribute 1/8 to each of 6 neighbors + errorRow0[x + 3] += error; // Right + errorRow0[x + 4] += error; // Right+1 + errorRow1[x + 1] += error; // Bottom-left + errorRow1[x + 2] += error; // Bottom + errorRow1[x + 3] += error; // Bottom-right + errorRow2[x + 2] += error; // Two rows down + + return quantized; + } + + void nextRow() { + int16_t* temp = errorRow0; + errorRow0 = errorRow1; + errorRow1 = errorRow2; + errorRow2 = temp; + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + void reset() { + memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + private: + int width; + int16_t* errorRow0; + int16_t* errorRow1; + int16_t* errorRow2; +}; + +// Floyd-Steinberg error diffusion dithering with serpentine scanning +// Serpentine scanning alternates direction each row to reduce "worm" artifacts +// Error distribution pattern (left-to-right): +// X 7/16 +// 3/16 5/16 1/16 +// Error distribution pattern (right-to-left, mirrored): +// 1/16 5/16 3/16 +// 7/16 X +class FloydSteinbergDitherer { + public: + explicit FloydSteinbergDitherer(int width) : width(width), rowCount(0) { + errorCurRow = new int16_t[width + 2](); // +2 for boundary handling + errorNextRow = new int16_t[width + 2](); + } + + ~FloydSteinbergDitherer() { + delete[] errorCurRow; + delete[] errorNextRow; + } + + // **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR** + FloydSteinbergDitherer(const FloydSteinbergDitherer& other) = delete; + + // **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR** + FloydSteinbergDitherer& operator=(const FloydSteinbergDitherer& other) = delete; + + // Process a single pixel and return quantized 2-bit value + // x is the logical x position (0 to width-1), direction handled internally + uint8_t processPixel(int gray, int x) { + // Add accumulated error to this pixel + int adjusted = gray + errorCurRow[x + 1]; + + // Clamp to valid range + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels (0, 85, 170, 255) + uint8_t quantized; + int quantizedValue; + if (false) { // original thresholds + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + } else { // fine-tuned to X4 eink display + if (adjusted < 30) { + quantized = 0; + quantizedValue = 15; + } else if (adjusted < 50) { + quantized = 1; + quantizedValue = 30; + } else if (adjusted < 140) { + quantized = 2; + quantizedValue = 80; + } else { + quantized = 3; + quantizedValue = 210; + } + } + + // Calculate error + int error = adjusted - quantizedValue; + + // Distribute error to neighbors (serpentine: direction-aware) + if (!isReverseRow()) { + // Left to right: standard distribution + // Right: 7/16 + errorCurRow[x + 2] += (error * 7) >> 4; + // Bottom-left: 3/16 + errorNextRow[x] += (error * 3) >> 4; + // Bottom: 5/16 + errorNextRow[x + 1] += (error * 5) >> 4; + // Bottom-right: 1/16 + errorNextRow[x + 2] += (error) >> 4; + } else { + // Right to left: mirrored distribution + // Left: 7/16 + errorCurRow[x] += (error * 7) >> 4; + // Bottom-right: 3/16 + errorNextRow[x + 2] += (error * 3) >> 4; + // Bottom: 5/16 + errorNextRow[x + 1] += (error * 5) >> 4; + // Bottom-left: 1/16 + errorNextRow[x] += (error) >> 4; + } + + return quantized; + } + + // Call at the end of each row to swap buffers + void nextRow() { + // Swap buffers + int16_t* temp = errorCurRow; + errorCurRow = errorNextRow; + errorNextRow = temp; + // Clear the next row buffer + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + rowCount++; + } + + // Check if current row should be processed in reverse + bool isReverseRow() const { return (rowCount & 1) != 0; } + + // Reset for a new image or MCU block + void reset() { + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + rowCount = 0; + } + + private: + int width; + int rowCount; + int16_t* errorCurRow; + int16_t* errorNextRow; +}; diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 8c8db889..30c1314f 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -7,6 +7,8 @@ #include #include +#include "BitmapHelpers.h" + // Context structure for picojpeg callback struct JpegReadContext { FsFile& file; @@ -23,282 +25,12 @@ constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantizati constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion) constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) -// Brightness/Contrast adjustments: -constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments -constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) -constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones) -constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) // Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width) constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height) // ============================================================================ -// Integer approximation of gamma correction (brightens midtones) -// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255) -static inline int applyGamma(int gray) { - if (!GAMMA_CORRECTION) return gray; - // Fast integer square root approximation for gamma ~0.5 (brightening) - // This brightens dark/mid tones while preserving highlights - const int product = gray * 255; - // Newton-Raphson integer sqrt (2 iterations for good accuracy) - int x = gray; - if (x > 0) { - x = (x + product / x) >> 1; - x = (x + product / x) >> 1; - } - return x > 255 ? 255 : x; -} - -// Apply contrast adjustment around midpoint (128) -// factor > 1.0 increases contrast, < 1.0 decreases -static inline int applyContrast(int gray) { - // Integer-based contrast: (gray - 128) * factor + 128 - // Using fixed-point: factor 1.15 ≈ 115/100 - constexpr int factorNum = static_cast(CONTRAST_FACTOR * 100); - int adjusted = ((gray - 128) * factorNum) / 100 + 128; - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - return adjusted; -} - -// Combined brightness/contrast/gamma adjustment -static inline int adjustPixel(int gray) { - if (!USE_BRIGHTNESS) return gray; - - // Order: contrast first, then brightness, then gamma - gray = applyContrast(gray); - gray += BRIGHTNESS_BOOST; - if (gray > 255) gray = 255; - if (gray < 0) gray = 0; - gray = applyGamma(gray); - - return gray; -} - -// Simple quantization without dithering - just divide into 4 levels -static inline uint8_t quantizeSimple(int gray) { - gray = adjustPixel(gray); - // Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3 - return static_cast(gray >> 6); -} - -// Hash-based noise dithering - survives downsampling without moiré artifacts -// Uses integer hash to generate pseudo-random threshold per pixel -static inline uint8_t quantizeNoise(int gray, int x, int y) { - gray = adjustPixel(gray); - - // Generate noise threshold using integer hash (no regular pattern to alias) - uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; - hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(hash >> 24); // 0-255 - - // Map gray (0-255) to 4 levels with dithering - const int scaled = gray * 3; - - if (scaled < 255) { - return (scaled + threshold >= 255) ? 1 : 0; - } else if (scaled < 510) { - return ((scaled - 255) + threshold >= 255) ? 2 : 1; - } else { - return ((scaled - 510) + threshold >= 255) ? 3 : 2; - } -} - -// Main quantization function - selects between methods based on config -static inline uint8_t quantize(int gray, int x, int y) { - if (USE_NOISE_DITHERING) { - return quantizeNoise(gray, x, y); - } else { - return quantizeSimple(gray); - } -} - -// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results -// Error distribution pattern: -// X 1/8 1/8 -// 1/8 1/8 1/8 -// 1/8 -// Less error buildup = fewer artifacts than Floyd-Steinberg -class AtkinsonDitherer { - public: - AtkinsonDitherer(int width) : width(width) { - errorRow0 = new int16_t[width + 4](); // Current row - errorRow1 = new int16_t[width + 4](); // Next row - errorRow2 = new int16_t[width + 4](); // Row after next - } - - ~AtkinsonDitherer() { - delete[] errorRow0; - delete[] errorRow1; - delete[] errorRow2; - } - - uint8_t processPixel(int gray, int x) { - // Apply brightness/contrast/gamma adjustments - gray = adjustPixel(gray); - - // Add accumulated error - int adjusted = gray + errorRow0[x + 2]; - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - - // Quantize to 4 levels - uint8_t quantized; - int quantizedValue; - if (adjusted < 43) { - quantized = 0; - quantizedValue = 0; - } else if (adjusted < 128) { - quantized = 1; - quantizedValue = 85; - } else if (adjusted < 213) { - quantized = 2; - quantizedValue = 170; - } else { - quantized = 3; - quantizedValue = 255; - } - - // Calculate error (only distribute 6/8 = 75%) - int error = (adjusted - quantizedValue) >> 3; // error/8 - - // Distribute 1/8 to each of 6 neighbors - errorRow0[x + 3] += error; // Right - errorRow0[x + 4] += error; // Right+1 - errorRow1[x + 1] += error; // Bottom-left - errorRow1[x + 2] += error; // Bottom - errorRow1[x + 3] += error; // Bottom-right - errorRow2[x + 2] += error; // Two rows down - - return quantized; - } - - void nextRow() { - int16_t* temp = errorRow0; - errorRow0 = errorRow1; - errorRow1 = errorRow2; - errorRow2 = temp; - memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); - } - - void reset() { - memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); - memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); - memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); - } - - private: - int width; - int16_t* errorRow0; - int16_t* errorRow1; - int16_t* errorRow2; -}; - -// Floyd-Steinberg error diffusion dithering with serpentine scanning -// Serpentine scanning alternates direction each row to reduce "worm" artifacts -// Error distribution pattern (left-to-right): -// X 7/16 -// 3/16 5/16 1/16 -// Error distribution pattern (right-to-left, mirrored): -// 1/16 5/16 3/16 -// 7/16 X -class FloydSteinbergDitherer { - public: - FloydSteinbergDitherer(int width) : width(width), rowCount(0) { - errorCurRow = new int16_t[width + 2](); // +2 for boundary handling - errorNextRow = new int16_t[width + 2](); - } - - ~FloydSteinbergDitherer() { - delete[] errorCurRow; - delete[] errorNextRow; - } - - // Process a single pixel and return quantized 2-bit value - // x is the logical x position (0 to width-1), direction handled internally - uint8_t processPixel(int gray, int x, bool reverseDirection) { - // Add accumulated error to this pixel - int adjusted = gray + errorCurRow[x + 1]; - - // Clamp to valid range - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - - // Quantize to 4 levels (0, 85, 170, 255) - uint8_t quantized; - int quantizedValue; - if (adjusted < 43) { - quantized = 0; - quantizedValue = 0; - } else if (adjusted < 128) { - quantized = 1; - quantizedValue = 85; - } else if (adjusted < 213) { - quantized = 2; - quantizedValue = 170; - } else { - quantized = 3; - quantizedValue = 255; - } - - // Calculate error - int error = adjusted - quantizedValue; - - // Distribute error to neighbors (serpentine: direction-aware) - if (!reverseDirection) { - // Left to right: standard distribution - // Right: 7/16 - errorCurRow[x + 2] += (error * 7) >> 4; - // Bottom-left: 3/16 - errorNextRow[x] += (error * 3) >> 4; - // Bottom: 5/16 - errorNextRow[x + 1] += (error * 5) >> 4; - // Bottom-right: 1/16 - errorNextRow[x + 2] += (error) >> 4; - } else { - // Right to left: mirrored distribution - // Left: 7/16 - errorCurRow[x] += (error * 7) >> 4; - // Bottom-right: 3/16 - errorNextRow[x + 2] += (error * 3) >> 4; - // Bottom: 5/16 - errorNextRow[x + 1] += (error * 5) >> 4; - // Bottom-left: 1/16 - errorNextRow[x] += (error) >> 4; - } - - return quantized; - } - - // Call at the end of each row to swap buffers - void nextRow() { - // Swap buffers - int16_t* temp = errorCurRow; - errorCurRow = errorNextRow; - errorNextRow = temp; - // Clear the next row buffer - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - rowCount++; - } - - // Check if current row should be processed in reverse - bool isReverseRow() const { return (rowCount & 1) != 0; } - - // Reset for a new image or MCU block - void reset() { - memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - rowCount = 0; - } - - private: - int width; - int rowCount; - int16_t* errorCurRow; - int16_t* errorNextRow; -}; - inline void write16(Print& out, const uint16_t value) { out.write(value & 0xFF); out.write((value >> 8) & 0xFF); @@ -623,12 +355,12 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { } } else { for (int x = 0; x < outWidth; x++) { - const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]); uint8_t twoBit; if (atkinsonDitherer) { twoBit = atkinsonDitherer->processPixel(gray, x); } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + twoBit = fsDitherer->processPixel(gray, x); } else { twoBit = quantize(gray, x, y); } @@ -686,12 +418,12 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { } } else { for (int x = 0; x < outWidth; x++) { - const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0); uint8_t twoBit; if (atkinsonDitherer) { twoBit = atkinsonDitherer->processPixel(gray, x); } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + twoBit = fsDitherer->processPixel(gray, x); } else { twoBit = quantize(gray, x, currentOutY); } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 7c79fcb1..3305a16d 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -86,7 +86,7 @@ void SleepActivity::renderCustomSleepScreen() const { if (SdMan.openFileForRead("SLP", filename, file)) { Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); - Bitmap bitmap(file); + Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); dir.close(); @@ -101,7 +101,7 @@ void SleepActivity::renderCustomSleepScreen() const { // render a custom sleep screen instead of the default. FsFile file; if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) { - Bitmap bitmap(file); + Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); renderBitmapSleepScreen(bitmap); From 8f3df7e10ea82f4b22373f27ef05e92074803d71 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 12 Jan 2026 23:57:34 +1000 Subject: [PATCH 12/38] fix: Handle EPUB 3 TOC to spine mapping when nav file in subdirectory (#332) ## Summary - Nav file in EPUB 3 file is a HTML file with relative hrefs - If this file exists anywhere but in the same location as the content.opf file, navigating in the book will fail - Bump the book cache version to rebuild potentially broken books ## Additional Context - Fixes https://github.com/daveallie/crosspoint-reader/issues/264 --- ### 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? - [ ] Yes - [ ] Partially - [x] No --- lib/Epub/Epub.cpp | 5 ++++- lib/Epub/Epub/BookMetadataCache.cpp | 2 +- lib/Epub/Epub/parsers/ContentOpfParser.cpp | 4 ++-- lib/Epub/Epub/parsers/TocNavParser.cpp | 3 ++- lib/Epub/Epub/parsers/TocNcxParser.cpp | 3 ++- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 9c4b058b..64727bca 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -167,7 +167,10 @@ bool Epub::parseTocNavFile() const { } const auto navSize = tempNavFile.size(); - TocNavParser navParser(contentBasePath, navSize, bookMetadataCache.get()); + // Note: We can't use `contentBasePath` here as the nav file may be in a different folder to the content.opf + // and the HTMLX nav file will have hrefs relative to itself + const std::string navContentBasePath = tocNavItem.substr(0, tocNavItem.find_last_of('/') + 1); + TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get()); if (!navParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis()); diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 06b4f458..52e48098 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -9,7 +9,7 @@ #include "FsHelpers.h" namespace { -constexpr uint8_t BOOK_CACHE_VERSION = 3; +constexpr uint8_t BOOK_CACHE_VERSION = 4; constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 2c90d01d..aee7e57b 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -167,7 +167,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (strcmp(atts[i], "id") == 0) { itemId = atts[i + 1]; } else if (strcmp(atts[i], "href") == 0) { - href = self->baseContentPath + atts[i + 1]; + href = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]); } else if (strcmp(atts[i], "media-type") == 0) { mediaType = atts[i + 1]; } else if (strcmp(atts[i], "properties") == 0) { @@ -243,7 +243,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name break; } } else if (strcmp(atts[i], "href") == 0) { - textHref = self->baseContentPath + atts[i + 1]; + textHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]); } } if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) { diff --git a/lib/Epub/Epub/parsers/TocNavParser.cpp b/lib/Epub/Epub/parsers/TocNavParser.cpp index b8a4e7fb..454b2437 100644 --- a/lib/Epub/Epub/parsers/TocNavParser.cpp +++ b/lib/Epub/Epub/parsers/TocNavParser.cpp @@ -1,5 +1,6 @@ #include "TocNavParser.h" +#include #include #include "../BookMetadataCache.h" @@ -140,7 +141,7 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) { if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) { // Create TOC entry when closing anchor tag (we have all data now) if (!self->currentLabel.empty() && !self->currentHref.empty()) { - std::string href = self->baseContentPath + self->currentHref; + std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentHref); std::string anchor; const size_t pos = href.find('#'); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index b1fbb2fe..3e59451e 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -1,5 +1,6 @@ #include "TocNcxParser.h" +#include #include #include "../BookMetadataCache.h" @@ -159,7 +160,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { // This is the safest place to push the data, assuming always comes before . // NCX spec says navLabel comes before content. if (!self->currentLabel.empty() && !self->currentSrc.empty()) { - std::string href = self->baseContentPath + self->currentSrc; + std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentSrc); std::string anchor; const size_t pos = href.find('#'); From 16c760b2d2bb3fa4ac96b61ef659e1f5d787a78f Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 13 Jan 2026 00:58:43 +1100 Subject: [PATCH 13/38] copy: Tweak pull request template wording --- .github/PULL_REQUEST_TEMPLATE.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ee374b73..21b09aaf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,8 +15,4 @@ 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? - -- [ ] Yes -- [ ] Partially -- [ ] No +Did you use AI tools to help write this code? _**< YES | PARTIALLY | NO >**_ From 33b8fa0e19bdd1f4575740160fcdf73156cafde9 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 13 Jan 2026 00:56:21 +1100 Subject: [PATCH 14/38] chore: Cut release 0.13.0 --- platformio.ini | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/platformio.ini b/platformio.ini index 75d1a77b..6419fdac 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,7 +1,9 @@ [platformio] -crosspoint_version = 0.12.0 default_envs = default +[crosspoint] +version = 0.13.0 + [base] platform = espressif32 @ 6.12.0 board = esp32-c3-devkitm-1 @@ -50,10 +52,10 @@ lib_deps = extends = base build_flags = ${base.build_flags} - -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}-dev\" + -DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\" [env:gh_release] extends = base build_flags = ${base.build_flags} - -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}\" + -DCROSSPOINT_VERSION=\"${crosspoint.version}\" From d4f8eda15477ca7381dc50e71f0bee75c6e14568 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 13 Jan 2026 01:09:06 +1000 Subject: [PATCH 15/38] fix: Increase home activity stack size (#333) ## Summary * fix: Increase home activity stack size ## Additional Context * Home activity can crash occasionally depending on book --- ### 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 --- src/activities/home/HomeActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 9c519d76..dc031fde 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -69,7 +69,7 @@ void HomeActivity::onEnter() { updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle From 52995fa7227c669f7b31265c3d9a79ce2d4a793d Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 13 Jan 2026 02:09:39 +1100 Subject: [PATCH 16/38] chore: Cut release 0.13.1 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 6419fdac..703b9348 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,7 +2,7 @@ default_envs = default [crosspoint] -version = 0.13.0 +version = 0.13.1 [base] platform = espressif32 @ 6.12.0 From 65d23910a3b813977dcedfc06871e3c3bff99a98 Mon Sep 17 00:00:00 2001 From: Andrew Brandt Date: Wed, 14 Jan 2026 04:04:02 -0600 Subject: [PATCH 17/38] ci: add PR format check workflow (#328) **Description**: Add a new workflow to check the PR formatting to ensure consistency on PR titles. We can also use this for semantic release versioning later, if we so desire. **Related Issue(s)**: Implements first portion of #327 --------- Signed-off-by: Andrew Brandt --- .github/workflows/pr-formatting-check.yml | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/pr-formatting-check.yml diff --git a/.github/workflows/pr-formatting-check.yml b/.github/workflows/pr-formatting-check.yml new file mode 100644 index 00000000..044b7b64 --- /dev/null +++ b/.github/workflows/pr-formatting-check.yml @@ -0,0 +1,26 @@ +name: "PR Formatting" + +on: + pull_request_target: + types: + - opened + - reopened + - edited + +permissions: + statuses: write + +jobs: + title-check: + name: Title Check + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Check PR Title + uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 2040e088e7b036e192c6ba52eb05f11e0f8406c3 Mon Sep 17 00:00:00 2001 From: Will Morrow Date: Wed, 14 Jan 2026 05:05:08 -0500 Subject: [PATCH 18/38] Ensure new custom sleep image every time (#300) When picking a random sleep image from a set of custom images, compare the randomly chosen index against a cached value in settings. If the value matches, use the next image (rolling over if it's the last image). Cache the chosen image index to settings in either case. ## Summary Implements a tweak on the custom sleep image feature that ensures that the user gets a new image every time the device goes to sleep. This change adds a new setting (perhaps there's a better place to cache this?) that stores the most recently used file index. During picking the random image index, we compare this against the random index and choose the next one (modulo the number of image files) if it matches, ensuring we get a new image. ## Additional Context As mentioned, I used settings to cache this value since it is a persisted store, perhaps that's overkill. Open to suggestions on if there's a better way. --- src/CrossPointState.cpp | 10 ++++++++-- src/CrossPointState.h | 1 + src/activities/boot_sleep/SleepActivity.cpp | 8 +++++++- src/main.cpp | 1 + 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index 31cb2acb..91aa2536 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr uint8_t STATE_FILE_VERSION = 1; +constexpr uint8_t STATE_FILE_VERSION = 2; constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace @@ -19,6 +19,7 @@ bool CrossPointState::saveToFile() const { serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writeString(outputFile, openEpubPath); + serialization::writePod(outputFile, lastSleepImage); outputFile.close(); return true; } @@ -31,13 +32,18 @@ bool CrossPointState::loadFromFile() { uint8_t version; serialization::readPod(inputFile, version); - if (version != STATE_FILE_VERSION) { + if (version > STATE_FILE_VERSION) { Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); inputFile.close(); return false; } serialization::readString(inputFile, openEpubPath); + if (version >= 2) { + serialization::readPod(inputFile, lastSleepImage); + } else { + lastSleepImage = 0; + } inputFile.close(); return true; diff --git a/src/CrossPointState.h b/src/CrossPointState.h index f060a0c6..87ce4e96 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -8,6 +8,7 @@ class CrossPointState { public: std::string openEpubPath; + uint8_t lastSleepImage; ~CrossPointState() = default; // Get singleton instance diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 3305a16d..0d3eab0a 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -80,7 +80,13 @@ void SleepActivity::renderCustomSleepScreen() const { const auto numFiles = files.size(); if (numFiles > 0) { // Generate a random number between 1 and numFiles - const auto randomFileIndex = random(numFiles); + auto randomFileIndex = random(numFiles); + // If we picked the same image as last time, reroll + while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) { + randomFileIndex = random(numFiles); + } + APP_STATE.lastSleepImage = randomFileIndex; + APP_STATE.saveToFile(); const auto filename = "/sleep/" + files[randomFileIndex]; FsFile file; if (SdMan.openFileForRead("SLP", filename, file)) { diff --git a/src/main.cpp b/src/main.cpp index 5261df3d..34e7376b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -303,6 +303,7 @@ void setup() { // Clear app state to avoid getting into a boot loop if the epub doesn't load const auto path = APP_STATE.openEpubPath; APP_STATE.openEpubPath = ""; + APP_STATE.lastSleepImage = 0; APP_STATE.saveToFile(); onGoToReader(path); } From fecd1849b922e4389099c0fae551f846ad07beb2 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Wed, 14 Jan 2026 19:24:02 +0900 Subject: [PATCH 19/38] Add cover image display in *Continue Reading* card with framebuffer caching (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Display the book cover image in the **"Continue Reading"** card on the home screen, with fast navigation using framebuffer caching. * **What changes are included?** - Display book cover image in the "Continue Reading" card on home screen - Load cover from cached BMP (same as sleep screen cover) - Add framebuffer store/restore functions (`copyStoredBwBuffer`, `freeStoredBwBuffer`) for fast navigation after initial render - Fix `drawBitmap` scaling bug: apply scale to offset only, not to base coordinates - Add white text boxes behind title/author/continue reading label for readability on cover - Support both EPUB and XTC file cover images - Increase HomeActivity task stack size from 2048 to 4096 for cover image rendering ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). - Performance: First render loads cover from SD card (~800ms), subsequent navigation uses cached framebuffer (~instant) - Memory: Framebuffer cache uses ~48KB (6 chunks × 8KB) while on home screen, freed on exit - Fallback: If cover image is not available, falls back to standard text-only display - The `drawBitmap` fix corrects a bug where screenY = (y + offset) scale was incorrectly scaling the base coordinates. Now correctly uses screenY = y + (offset scale) --- lib/Epub/Epub.cpp | 64 ++++ lib/Epub/Epub.h | 2 + lib/GfxRenderer/Bitmap.cpp | 5 +- lib/GfxRenderer/Bitmap.h | 2 + lib/GfxRenderer/BitmapHelpers.cpp | 16 + lib/GfxRenderer/BitmapHelpers.h | 81 +++++ lib/GfxRenderer/GfxRenderer.cpp | 149 ++++++++ lib/GfxRenderer/GfxRenderer.h | 6 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 123 ++++++- lib/JpegToBmpConverter/JpegToBmpConverter.h | 8 +- lib/Xtc/Xtc.cpp | 261 ++++++++++++++ lib/Xtc/Xtc.h | 3 + src/activities/home/HomeActivity.cpp | 318 +++++++++++++++--- src/activities/home/HomeActivity.h | 10 +- 14 files changed, 984 insertions(+), 64 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 64727bca..1b337721 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -409,6 +409,70 @@ bool Epub::generateCoverBmp(bool cropped) const { return false; } +std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Epub::generateThumbBmp() const { + // Already generated, return true + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis()); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { + Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); + return false; + } + + if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { + Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + + FsFile coverJpg; + if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + readItemContentsToStream(coverImageHref, coverJpg, 1024); + coverJpg.close(); + + if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + + FsFile thumbBmp; + if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) { + coverJpg.close(); + return false; + } + // Use smaller target size for Continue Reading card (half of screen: 240x400) + // Generate 1-bit BMP for fast home screen rendering (no gray passes needed) + constexpr int THUMB_TARGET_WIDTH = 240; + constexpr int THUMB_TARGET_HEIGHT = 400; + const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, + THUMB_TARGET_HEIGHT); + coverJpg.close(); + thumbBmp.close(); + SdMan.remove(coverJpgTempPath.c_str()); + + if (!success) { + Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); + SdMan.remove(getThumbBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), + success ? "yes" : "no"); + return success; + } else { + Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); + } + + return false; +} + uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { if (itemHref.empty()) { Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis()); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 047c955a..91062aa4 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -46,6 +46,8 @@ class Epub { const std::string& getAuthor() const; std::string getCoverBmpPath(bool cropped = false) const; bool generateCoverBmp(bool cropped = false) const; + std::string getThumbBmpPath() const; + bool generateThumbBmp() const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 1a3b4406..ad25ffcd 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -228,7 +228,10 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { } case 1: { for (int x = 0; x < width; x++) { - lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; + // Get palette index (0 or 1) from bit at position x + const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0; + // Use palette lookup for proper black/white mapping + lum = paletteLum[palIndex]; packPixel(lum); } break; diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 9ac7cfbb..544869c1 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -42,6 +42,8 @@ class Bitmap { bool isTopDown() const { return topDown; } bool hasGreyscale() const { return bpp > 1; } int getRowBytes() const { return rowBytes; } + bool is1Bit() const { return bpp == 1; } + uint16_t getBpp() const { return bpp; } private: static uint16_t readLE16(FsFile& f); diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index b0d9dc06..465593e8 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -88,3 +88,19 @@ uint8_t quantize(int gray, int x, int y) { return quantizeSimple(gray); } } + +// 1-bit noise dithering for fast home screen rendering +// Uses hash-based noise for consistent dithering that works well at small sizes +uint8_t quantize1bit(int gray, int x, int y) { + gray = adjustPixel(gray); + + // Generate noise threshold using integer hash (no regular pattern to alias) + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + + // Simple threshold with noise: gray >= (128 + noise offset) -> white + // The noise adds variation around the 128 midpoint + const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 + return (gray >= adjustedThreshold) ? 1 : 0; +} diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h index 300527e0..791e70b9 100644 --- a/lib/GfxRenderer/BitmapHelpers.h +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -5,8 +5,89 @@ // Helper functions uint8_t quantize(int gray, int x, int y); uint8_t quantizeSimple(int gray); +uint8_t quantize1bit(int gray, int x, int y); int adjustPixel(int gray); +// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails +// Error distribution pattern (same as 2-bit but quantizes to 2 levels): +// X 1/8 1/8 +// 1/8 1/8 1/8 +// 1/8 +class Atkinson1BitDitherer { + public: + explicit Atkinson1BitDitherer(int width) : width(width) { + errorRow0 = new int16_t[width + 4](); // Current row + errorRow1 = new int16_t[width + 4](); // Next row + errorRow2 = new int16_t[width + 4](); // Row after next + } + + ~Atkinson1BitDitherer() { + delete[] errorRow0; + delete[] errorRow1; + delete[] errorRow2; + } + + // EXPLICITLY DELETE THE COPY CONSTRUCTOR + Atkinson1BitDitherer(const Atkinson1BitDitherer& other) = delete; + + // EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR + Atkinson1BitDitherer& operator=(const Atkinson1BitDitherer& other) = delete; + + uint8_t processPixel(int gray, int x) { + // Apply brightness/contrast/gamma adjustments + gray = adjustPixel(gray); + + // Add accumulated error + int adjusted = gray + errorRow0[x + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 2 levels (1-bit): 0 = black, 1 = white + uint8_t quantized; + int quantizedValue; + if (adjusted < 128) { + quantized = 0; + quantizedValue = 0; + } else { + quantized = 1; + quantizedValue = 255; + } + + // Calculate error (only distribute 6/8 = 75%) + int error = (adjusted - quantizedValue) >> 3; // error/8 + + // Distribute 1/8 to each of 6 neighbors + errorRow0[x + 3] += error; // Right + errorRow0[x + 4] += error; // Right+1 + errorRow1[x + 1] += error; // Bottom-left + errorRow1[x + 2] += error; // Bottom + errorRow1[x + 3] += error; // Bottom-right + errorRow2[x + 2] += error; // Two rows down + + return quantized; + } + + void nextRow() { + int16_t* temp = errorRow0; + errorRow0 = errorRow1; + errorRow1 = errorRow2; + errorRow2 = temp; + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + void reset() { + memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + private: + int width; + int16_t* errorRow0; + int16_t* errorRow1; + int16_t* errorRow2; +}; + // Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results // Error distribution pattern: // X 1/8 1/8 diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index cc1288a7..28022e90 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -154,6 +154,12 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, const float cropX, const float cropY) const { + // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit) + if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) { + drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight); + return; + } + float scale = 1.0f; bool isScaled = false; int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f); @@ -195,6 +201,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con if (screenY >= getScreenHeight()) { break; } + if (screenY < 0) { + continue; + } if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); @@ -217,6 +226,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con if (screenX >= getScreenWidth()) { break; } + if (screenX < 0) { + continue; + } const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; @@ -234,6 +246,143 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con free(rowBytes); } +void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, + const int maxHeight) const { + float scale = 1.0f; + bool isScaled = false; + if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { + scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + isScaled = true; + } + if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { + scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); + isScaled = true; + } + + // For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow) + const int outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + + if (!outputRow || !rowBytes) { + Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis()); + free(outputRow); + free(rowBytes); + return; + } + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + // Read rows sequentially using readNextRow + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { + Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY); + free(outputRow); + free(rowBytes); + return; + } + + // Calculate screen Y based on whether BMP is top-down or bottom-up + const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + int screenY = y + (isScaled ? static_cast(std::floor(bmpYOffset * scale)) : bmpYOffset); + if (screenY >= getScreenHeight()) { + continue; // Continue reading to keep row counter in sync + } + if (screenY < 0) { + continue; + } + + for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { + int screenX = x + (isScaled ? static_cast(std::floor(bmpX * scale)) : bmpX); + if (screenX >= getScreenWidth()) { + break; + } + if (screenX < 0) { + continue; + } + + // Get 2-bit value (result of readNextRow quantization) + const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + + // For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3) + // val < 3 means black pixel (draw it) + if (val < 3) { + drawPixel(screenX, screenY, true); + } + // White pixels (val == 3) are not drawn (leave background) + } + } + + free(outputRow); + free(rowBytes); +} + +void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const { + if (numPoints < 3) return; + + // Find bounding box + int minY = yPoints[0], maxY = yPoints[0]; + for (int i = 1; i < numPoints; i++) { + if (yPoints[i] < minY) minY = yPoints[i]; + if (yPoints[i] > maxY) maxY = yPoints[i]; + } + + // Clip to screen + if (minY < 0) minY = 0; + if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1; + + // Allocate node buffer for scanline algorithm + auto* nodeX = static_cast(malloc(numPoints * sizeof(int))); + if (!nodeX) { + Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis()); + return; + } + + // Scanline fill algorithm + for (int scanY = minY; scanY <= maxY; scanY++) { + int nodes = 0; + + // Find all intersection points with edges + int j = numPoints - 1; + for (int i = 0; i < numPoints; i++) { + if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) { + // Calculate X intersection using fixed-point to avoid float + int dy = yPoints[j] - yPoints[i]; + if (dy != 0) { + nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy; + } + } + j = i; + } + + // Sort nodes by X (simple bubble sort, numPoints is small) + for (int i = 0; i < nodes - 1; i++) { + for (int k = i + 1; k < nodes; k++) { + if (nodeX[i] > nodeX[k]) { + int temp = nodeX[i]; + nodeX[i] = nodeX[k]; + nodeX[k] = temp; + } + } + } + + // Fill between pairs of nodes + for (int i = 0; i < nodes - 1; i += 2) { + int startX = nodeX[i]; + int endX = nodeX[i + 1]; + + // Clip to screen + if (startX < 0) startX = 0; + if (endX >= getScreenWidth()) endX = getScreenWidth() - 1; + + // Draw horizontal line + for (int x = startX; x <= endX; x++) { + drawPixel(x, scanY, state); + } + } + } + + free(nodeX); +} + void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); } void GfxRenderer::invertScreen() const { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e3e9558d..9d341bcc 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -68,6 +68,8 @@ class GfxRenderer { void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0) const; + void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; + void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; // Text int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; @@ -97,8 +99,8 @@ class GfxRenderer { void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - bool storeBwBuffer(); // Returns true if buffer was stored successfully - void restoreBwBuffer(); + bool storeBwBuffer(); // Returns true if buffer was stored successfully + void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; // Low level functions diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 30c1314f..01451a05 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -87,8 +87,47 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { } } +// Helper function: Write BMP header with 1-bit color depth (black and white) +static void writeBmpHeader1bit(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width + 31) / 32 * 4; // 1 bit per pixel, round up to 4-byte boundary + const int imageSize = bytesPerRow * height; + const uint32_t fileSize = 62 + imageSize; // 14 (file header) + 40 (DIB header) + 8 (palette) + image + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); // File size + write32(bmpOut, 0); // Reserved + write32(bmpOut, 62); // Offset to pixel data (14 + 40 + 8) + + // DIB Header (BITMAPINFOHEADER - 40 bytes) + write32(bmpOut, 40); + write32Signed(bmpOut, width); + write32Signed(bmpOut, -height); // Negative height = top-down bitmap + write16(bmpOut, 1); // Color planes + write16(bmpOut, 1); // Bits per pixel (1 bit) + write32(bmpOut, 0); // BI_RGB (no compression) + write32(bmpOut, imageSize); + write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) + write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) + write32(bmpOut, 2); // colorsUsed + write32(bmpOut, 2); // colorsImportant + + // Color Palette (2 colors x 4 bytes = 8 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + // Note: In 1-bit BMP, palette index 0 = black, 1 = white + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } +} + // Helper function: Write BMP header with 2-bit color depth -void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { +static void writeBmpHeader2bit(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up const int imageSize = bytesPerRow * height; @@ -159,9 +198,11 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un return 0; // Success } -// Core function: Convert JPEG file to 2-bit BMP -bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { - Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis()); +// Internal implementation with configurable target size and bit depth +bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, + bool oneBit) { + Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", + targetWidth, targetHeight); // Setup context for picojpeg callback JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; @@ -196,10 +237,10 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { uint32_t scaleY_fp = 65536; bool needsScaling = false; - if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) { + if (targetWidth > 0 && targetHeight > 0 && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) { // Calculate scale to fit within target dimensions while maintaining aspect ratio - const float scaleToFitWidth = static_cast(TARGET_MAX_WIDTH) / imageInfo.m_width; - const float scaleToFitHeight = static_cast(TARGET_MAX_HEIGHT) / imageInfo.m_height; + const float scaleToFitWidth = static_cast(targetWidth) / imageInfo.m_width; + const float scaleToFitHeight = static_cast(targetHeight) / imageInfo.m_height; // We scale to the smaller dimension, so we can potentially crop later. // TODO: ideally, we already crop here. const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; @@ -218,16 +259,19 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { needsScaling = true; Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width, - imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); + imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight); } // Write BMP header with output dimensions int bytesPerRow; - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { writeBmpHeader8bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth + 3) / 4 * 4; + } else if (oneBit) { + writeBmpHeader1bit(bmpOut, outWidth, outHeight); + bytesPerRow = (outWidth + 31) / 32 * 4; // 1 bit per pixel } else { - writeBmpHeader(bmpOut, outWidth, outHeight); + writeBmpHeader2bit(bmpOut, outWidth, outHeight); bytesPerRow = (outWidth * 2 + 31) / 32 * 4; } @@ -258,11 +302,16 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { return false; } - // Create ditherer if enabled (only for 2-bit output) + // Create ditherer if enabled // Use OUTPUT dimensions for dithering (after prescaling) AtkinsonDitherer* atkinsonDitherer = nullptr; FloydSteinbergDitherer* fsDitherer = nullptr; - if (!USE_8BIT_OUTPUT) { + Atkinson1BitDitherer* atkinson1BitDitherer = nullptr; + + if (oneBit) { + // For 1-bit output, use Atkinson dithering for better quality + atkinson1BitDitherer = new Atkinson1BitDitherer(outWidth); + } else if (!USE_8BIT_OUTPUT) { if (USE_ATKINSON) { atkinsonDitherer = new AtkinsonDitherer(outWidth); } else if (USE_FLOYD_STEINBERG) { @@ -348,12 +397,25 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { // No scaling - direct output (1:1 mapping) memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; rowBuffer[x] = adjustPixel(gray); } + } else if (oneBit) { + // 1-bit output with Atkinson dithering for better quality + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + const uint8_t bit = + atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) : quantize1bit(gray, x, y); + // Pack 1-bit value: MSB first, 8 pixels per byte + const int byteIndex = x / 8; + const int bitOffset = 7 - (x % 8); + rowBuffer[byteIndex] |= (bit << bitOffset); + } + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); } else { + // 2-bit output for (int x = 0; x < outWidth; x++) { const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]); uint8_t twoBit; @@ -411,12 +473,25 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (USE_8BIT_OUTPUT && !oneBit) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; rowBuffer[x] = adjustPixel(gray); } + } else if (oneBit) { + // 1-bit output with Atkinson dithering for better quality + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + const uint8_t bit = atkinson1BitDitherer ? atkinson1BitDitherer->processPixel(gray, x) + : quantize1bit(gray, x, currentOutY); + // Pack 1-bit value: MSB first, 8 pixels per byte + const int byteIndex = x / 8; + const int bitOffset = 7 - (x % 8); + rowBuffer[byteIndex] |= (bit << bitOffset); + } + if (atkinson1BitDitherer) atkinson1BitDitherer->nextRow(); } else { + // 2-bit output for (int x = 0; x < outWidth; x++) { const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0); uint8_t twoBit; @@ -464,9 +539,29 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { if (fsDitherer) { delete fsDitherer; } + if (atkinson1BitDitherer) { + delete atkinson1BitDitherer; + } free(mcuRowBuffer); free(rowBuffer); Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis()); return true; } + +// Core function: Convert JPEG file to 2-bit BMP (uses default target size) +bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false); +} + +// Convert with custom target size (for thumbnails, 2-bit) +bool JpegToBmpConverter::jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, + int targetMaxHeight) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, false); +} + +// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering +bool JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, + int targetMaxHeight) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, targetMaxWidth, targetMaxHeight, true); +} diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index f61bd8ef..d5e9b950 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -5,11 +5,15 @@ class Print; class ZipFile; class JpegToBmpConverter { - static void writeBmpHeader(Print& bmpOut, int width, int height); - // [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y); static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); + static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, + bool oneBit); public: static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut); + // Convert with custom target size (for thumbnails) + static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); + // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering + static bool jpegFileTo1BitBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); }; diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 8f79c9dd..7205ffb9 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -293,6 +293,267 @@ bool Xtc::generateCoverBmp() const { return true; } +std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Xtc::generateThumbBmp() const { + // Already generated + if (SdMan.exists(getThumbBmpPath().c_str())) { + return true; + } + + if (!loaded || !parser) { + Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis()); + return false; + } + + if (parser->getPageCount() == 0) { + Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); + return false; + } + + // Setup cache directory + setupCacheDir(); + + // Get first page info for cover + xtc::PageInfo pageInfo; + if (!parser->getPageInfo(0, pageInfo)) { + Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); + return false; + } + + // Get bit depth + const uint8_t bitDepth = parser->getBitDepth(); + + // Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card) + constexpr int THUMB_TARGET_WIDTH = 240; + constexpr int THUMB_TARGET_HEIGHT = 400; + + // Calculate scale factor + float scaleX = static_cast(THUMB_TARGET_WIDTH) / pageInfo.width; + float scaleY = static_cast(THUMB_TARGET_HEIGHT) / pageInfo.height; + float scale = (scaleX < scaleY) ? scaleX : scaleY; + + // Only scale down, never up + if (scale >= 1.0f) { + // Page is already small enough, just use cover.bmp + // Copy cover.bmp to thumb.bmp + if (generateCoverBmp()) { + FsFile src, dst; + if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) { + if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) { + uint8_t buffer[512]; + while (src.available()) { + size_t bytesRead = src.read(buffer, sizeof(buffer)); + dst.write(buffer, bytesRead); + } + dst.close(); + } + src.close(); + } + Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis()); + return SdMan.exists(getThumbBmpPath().c_str()); + } + return false; + } + + uint16_t thumbWidth = static_cast(pageInfo.width * scale); + uint16_t thumbHeight = static_cast(pageInfo.height * scale); + + Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, + pageInfo.height, thumbWidth, thumbHeight, scale); + + // Allocate buffer for page data + size_t bitmapSize; + if (bitDepth == 2) { + bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; + } else { + bitmapSize = ((pageInfo.width + 7) / 8) * pageInfo.height; + } + uint8_t* pageBuffer = static_cast(malloc(bitmapSize)); + if (!pageBuffer) { + Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); + return false; + } + + // Load first page (cover) + size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); + if (bytesRead == 0) { + Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis()); + free(pageBuffer); + return false; + } + + // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) + FsFile thumbBmp; + if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { + Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); + free(pageBuffer); + return false; + } + + // Write 1-bit BMP header for fast home screen rendering + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; // 1 bit per pixel, aligned to 4 bytes + const uint32_t imageSize = rowSize * thumbHeight; + const uint32_t fileSize = 14 + 40 + 8 + imageSize; // 8 bytes for 2-color palette + + // File header + thumbBmp.write('B'); + thumbBmp.write('M'); + thumbBmp.write(reinterpret_cast(&fileSize), 4); + uint32_t reserved = 0; + thumbBmp.write(reinterpret_cast(&reserved), 4); + uint32_t dataOffset = 14 + 40 + 8; // 1-bit palette has 2 colors (8 bytes) + thumbBmp.write(reinterpret_cast(&dataOffset), 4); + + // DIB header + uint32_t dibHeaderSize = 40; + thumbBmp.write(reinterpret_cast(&dibHeaderSize), 4); + int32_t widthVal = thumbWidth; + thumbBmp.write(reinterpret_cast(&widthVal), 4); + int32_t heightVal = -static_cast(thumbHeight); // Negative for top-down + thumbBmp.write(reinterpret_cast(&heightVal), 4); + uint16_t planes = 1; + thumbBmp.write(reinterpret_cast(&planes), 2); + uint16_t bitsPerPixel = 1; // 1-bit for black and white + thumbBmp.write(reinterpret_cast(&bitsPerPixel), 2); + uint32_t compression = 0; + thumbBmp.write(reinterpret_cast(&compression), 4); + thumbBmp.write(reinterpret_cast(&imageSize), 4); + int32_t ppmX = 2835; + thumbBmp.write(reinterpret_cast(&ppmX), 4); + int32_t ppmY = 2835; + thumbBmp.write(reinterpret_cast(&ppmY), 4); + uint32_t colorsUsed = 2; + thumbBmp.write(reinterpret_cast(&colorsUsed), 4); + uint32_t colorsImportant = 2; + thumbBmp.write(reinterpret_cast(&colorsImportant), 4); + + // Color palette (2 colors for 1-bit: black and white) + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + thumbBmp.write(palette, 8); + + // Allocate row buffer for 1-bit output + uint8_t* rowBuffer = static_cast(malloc(rowSize)); + if (!rowBuffer) { + free(pageBuffer); + thumbBmp.close(); + return false; + } + + // Fixed-point scale factor (16.16) + uint32_t scaleInv_fp = static_cast(65536.0f / scale); + + // Pre-calculate plane info for 2-bit mode + const size_t planeSize = (bitDepth == 2) ? ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) : 0; + const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr; + const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr; + const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0; + const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; + + for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { + memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1) + + // Calculate source Y range with bounds checking + uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; + uint32_t srcYEnd = (static_cast(dstY + 1) * scaleInv_fp) >> 16; + if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1; + if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + if (srcYEnd <= srcYStart) srcYEnd = srcYStart + 1; + if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; + + for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { + // Calculate source X range with bounds checking + uint32_t srcXStart = (static_cast(dstX) * scaleInv_fp) >> 16; + uint32_t srcXEnd = (static_cast(dstX + 1) * scaleInv_fp) >> 16; + if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1; + if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1; + if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; + + // Area averaging: sum grayscale values (0-255 range) + uint32_t graySum = 0; + uint32_t totalCount = 0; + + for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) { + for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) { + uint8_t grayValue = 255; // Default: white + + if (bitDepth == 2) { + // XTH 2-bit mode: pixel value 0-3 + // Bounds check for column index + if (srcX < pageInfo.width) { + const size_t colIndex = pageInfo.width - 1 - srcX; + const size_t byteInCol = srcY / 8; + const size_t bitInByte = 7 - (srcY % 8); + const size_t byteOffset = colIndex * colBytes + byteInCol; + // Bounds check for buffer access + if (byteOffset < planeSize) { + const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; + const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; + const uint8_t pixelValue = (bit1 << 1) | bit2; + // Convert 2-bit (0-3) to grayscale: 0=black, 3=white + // pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity) + grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0 + } + } + } else { + // 1-bit mode + const size_t byteIdx = srcY * srcRowBytes + srcX / 8; + const size_t bitIdx = 7 - (srcX % 8); + // Bounds check for buffer access + if (byteIdx < bitmapSize) { + const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1; + // XTC polarity: 1=black, 0=white + grayValue = pixelBit ? 0 : 255; + } + } + + graySum += grayValue; + totalCount++; + } + } + + // Calculate average grayscale and quantize to 1-bit with noise dithering + uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; + + // Hash-based noise dithering for 1-bit output + uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 + + // Quantize to 1-bit: 0=black, 1=white + uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; + + // Pack 1-bit value into row buffer (MSB first, 8 pixels per byte) + const size_t byteIndex = dstX / 8; + const size_t bitOffset = 7 - (dstX % 8); + // Bounds check for row buffer access + if (byteIndex < rowSize) { + if (oneBit) { + rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white + } else { + rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black + } + } + } + + // Write row (already padded to 4-byte boundary by rowSize) + thumbBmp.write(rowBuffer, rowSize); + } + + free(rowBuffer); + thumbBmp.close(); + free(pageBuffer); + + Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, + getThumbBmpPath().c_str()); + return true; +} + uint32_t Xtc::getPageCount() const { if (!loaded || !parser) { return 0; diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index e5bce102..7413ef47 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -62,6 +62,9 @@ class Xtc { // Cover image support (for sleep screen) std::string getCoverBmpPath() const; bool generateCoverBmp() const; + // Thumbnail support (for Continue Reading card) + std::string getThumbBmpPath() const; + bool generateThumbBmp() const; // Page access uint32_t getPageCount() const; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index dc031fde..1936d926 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,8 +1,10 @@ #include "HomeActivity.h" +#include #include #include #include +#include #include #include @@ -15,6 +17,29 @@ #include "fontIds.h" #include "util/StringUtils.h" +namespace { +// UTF-8 safe string truncation - removes one character from the end +// Returns the new size after removing one UTF-8 character +size_t utf8RemoveLastChar(std::string& str) { + if (str.empty()) return 0; + size_t pos = str.size() - 1; + // Walk back to find the start of the last UTF-8 character + // UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF) + while (pos > 0 && (static_cast(str[pos]) & 0xC0) == 0x80) { + --pos; + } + str.resize(pos); + return pos; +} + +// Truncate string by removing N UTF-8 characters from the end +void utf8TruncateChars(std::string& str, size_t numChars) { + for (size_t i = 0; i < numChars && !str.empty(); ++i) { + utf8RemoveLastChar(str); + } +} +} // namespace + void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -46,7 +71,7 @@ void HomeActivity::onEnter() { lastBookTitle = lastBookTitle.substr(lastSlash + 1); } - // If epub, try to load the metadata for title/author + // If epub, try to load the metadata for title/author and cover if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); epub.load(false); @@ -56,10 +81,31 @@ void HomeActivity::onEnter() { if (!epub.getAuthor().empty()) { lastBookAuthor = std::string(epub.getAuthor()); } - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { - lastBookTitle.resize(lastBookTitle.length() - 5); - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { - lastBookTitle.resize(lastBookTitle.length() - 4); + // Try to generate thumbnail image for Continue Reading card + if (epub.generateThumbBmp()) { + coverBmpPath = epub.getThumbBmpPath(); + hasCoverImage = true; + } + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || + StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { + // Handle XTC file + Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); + if (xtc.load()) { + if (!xtc.getTitle().empty()) { + lastBookTitle = std::string(xtc.getTitle()); + } + // Try to generate thumbnail image for Continue Reading card + if (xtc.generateThumbBmp()) { + coverBmpPath = xtc.getThumbBmpPath(); + hasCoverImage = true; + } + } + // Remove extension from title if we don't have metadata + if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { + lastBookTitle.resize(lastBookTitle.length() - 5); + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { + lastBookTitle.resize(lastBookTitle.length() - 4); + } } } @@ -69,7 +115,7 @@ void HomeActivity::onEnter() { updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", - 4096, // Stack size + 4096, // Stack size (increased for cover image rendering) this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -87,6 +133,51 @@ void HomeActivity::onExit() { } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; + + // Free the stored cover buffer if any + freeCoverBuffer(); +} + +bool HomeActivity::storeCoverBuffer() { + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + // Free any existing buffer first + freeCoverBuffer(); + + const size_t bufferSize = GfxRenderer::getBufferSize(); + coverBuffer = static_cast(malloc(bufferSize)); + if (!coverBuffer) { + return false; + } + + memcpy(coverBuffer, frameBuffer, bufferSize); + return true; +} + +bool HomeActivity::restoreCoverBuffer() { + if (!coverBuffer) { + return false; + } + + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + const size_t bufferSize = GfxRenderer::getBufferSize(); + memcpy(frameBuffer, coverBuffer, bufferSize); + return true; +} + +void HomeActivity::freeCoverBuffer() { + if (coverBuffer) { + free(coverBuffer); + coverBuffer = nullptr; + } + coverBufferStored = false; } void HomeActivity::loop() { @@ -138,8 +229,12 @@ void HomeActivity::displayTaskLoop() { } } -void HomeActivity::render() const { - renderer.clearScreen(); +void HomeActivity::render() { + // If we have a stored cover buffer, restore it instead of clearing + const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); + if (!bufferRestored) { + renderer.clearScreen(); + } const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -154,34 +249,101 @@ void HomeActivity::render() const { constexpr int bookY = 30; const bool bookSelected = hasContinueReading && selectorIndex == 0; + // Bookmark dimensions (used in multiple places) + const int bookmarkWidth = bookWidth / 8; + const int bookmarkHeight = bookHeight / 5; + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; + const int bookmarkY = bookY + 5; + // Draw book card regardless, fill with message based on `hasContinueReading` { - if (bookSelected) { - renderer.fillRect(bookX, bookY, bookWidth, bookHeight); - } else { - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + // Draw cover image as background if available (inside the box) + // Only load from SD on first render, then use stored buffer + if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { + // First time: load cover from SD and render + FsFile file; + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + // Calculate position to center image within the book card + int coverX, coverY; + + if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { + const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); + + if (imgRatio > boxRatio) { + coverX = bookX; + coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; + } else { + coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; + coverY = bookY; + } + } else { + coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; + coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; + } + + // Draw the cover image centered within the book card + renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); + + // Draw border around the card + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + + // No bookmark ribbon when cover is shown - it would just cover the art + + // Store the buffer with cover image for fast navigation + coverBufferStored = storeCoverBuffer(); + coverRendered = true; + + // First render: if selected, draw selection indicators now + if (bookSelected) { + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } + } + file.close(); + } + } else if (!bufferRestored && !coverRendered) { + // No cover image: draw border or fill, plus bookmark as visual flair + if (bookSelected) { + renderer.fillRect(bookX, bookY, bookWidth, bookHeight); + } else { + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + } + + // Draw bookmark ribbon when no cover image (visual decoration) + if (hasContinueReading) { + const int notchDepth = bookmarkHeight / 3; + const int centerX = bookmarkX + bookmarkWidth / 2; + + const int xPoints[5] = { + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left + }; + const int yPoints[5] = { + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right + bookmarkY + bookmarkHeight - notchDepth, // center notch point + bookmarkY + bookmarkHeight // bottom-left + }; + + // Draw bookmark ribbon (inverted if selected) + renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); + } } - // Bookmark icon in the top-right corner of the card - const int bookmarkWidth = bookWidth / 8; - const int bookmarkHeight = bookHeight / 5; - const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8; - constexpr int bookmarkY = bookY + 1; - - // Main bookmark body (solid) - renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected); - - // Carve out an inverted triangle notch at the bottom center to create angled points - const int notchHeight = bookmarkHeight / 2; // depth of the notch - for (int i = 0; i < notchHeight; ++i) { - const int y = bookmarkY + bookmarkHeight - 1 - i; - const int xStart = bookmarkX + i; - const int width = bookmarkWidth - 2 * i; - if (width <= 0) { - break; - } - // Draw a horizontal strip in the opposite color to "cut" the notch - renderer.fillRect(xStart, y, width, 1, bookSelected); + // If buffer was restored, draw selection indicators if needed + if (bufferRestored && bookSelected && coverRendered) { + // Draw selection border (no bookmark inversion needed since cover has no bookmark) + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } else if (!coverRendered && !bufferRestored) { + // Selection border already handled above in the no-cover case } } @@ -218,18 +380,25 @@ void HomeActivity::render() const { lines.back().append("..."); while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { - lines.back().resize(lines.back().size() - 5); + // Remove "..." first, then remove one UTF-8 char, then add "..." back + lines.back().resize(lines.back().size() - 3); // Remove "..." + utf8RemoveLastChar(lines.back()); lines.back().append("..."); } break; } int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); - while (wordWidth > maxLineWidth && i.size() > 5) { - // Word itself is too long, trim it - i.resize(i.size() - 5); - i.append("..."); - wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + while (wordWidth > maxLineWidth && !i.empty()) { + // Word itself is too long, trim it (UTF-8 safe) + utf8RemoveLastChar(i); + // Check if we have room for ellipsis + std::string withEllipsis = i + "..."; + wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); + if (wordWidth <= maxLineWidth) { + i = withEllipsis; + break; + } } int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); @@ -261,24 +430,85 @@ void HomeActivity::render() const { // Vertically center the title block within the card int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; + // If cover image was rendered, draw white box behind title and author + if (coverRendered) { + constexpr int boxPadding = 8; + // Calculate the max text width for the box + int maxTextWidth = 0; + for (const auto& line : lines) { + const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); + if (lineWidth > maxTextWidth) { + maxTextWidth = lineWidth; + } + } + if (!lastBookAuthor.empty()) { + std::string trimmedAuthor = lastBookAuthor; + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { + utf8RemoveLastChar(trimmedAuthor); + } + if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < + renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { + trimmedAuthor.append("..."); + } + const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); + if (authorWidth > maxTextWidth) { + maxTextWidth = authorWidth; + } + } + + const int boxWidth = maxTextWidth + boxPadding * 2; + const int boxHeight = totalTextHeight + boxPadding * 2; + const int boxX = (pageWidth - boxWidth) / 2; + const int boxY = titleYStart - boxPadding; + + // Draw white filled box + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + // Draw black border around the box + renderer.drawRect(boxX, boxY, boxWidth, boxHeight, true); + } + for (const auto& line : lines) { - renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); + renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected || coverRendered); titleYStart += renderer.getLineHeight(UI_12_FONT_ID); } if (!lastBookAuthor.empty()) { titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; std::string trimmedAuthor = lastBookAuthor; - // Trim author if too long + // Trim author if too long (UTF-8 safe) + bool wasTrimmed = false; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - trimmedAuthor.resize(trimmedAuthor.size() - 5); + utf8RemoveLastChar(trimmedAuthor); + wasTrimmed = true; + } + if (wasTrimmed && !trimmedAuthor.empty()) { + // Make room for ellipsis + while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && + !trimmedAuthor.empty()) { + utf8RemoveLastChar(trimmedAuthor); + } trimmedAuthor.append("..."); } - renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected || coverRendered); } - renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2, - "Continue Reading", !bookSelected); + // "Continue Reading" label at the bottom + const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; + if (coverRendered) { + // Draw white box behind "Continue Reading" text + const char* continueText = "Continue Reading"; + const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); + constexpr int continuePadding = 6; + const int continueBoxWidth = continueTextWidth + continuePadding * 2; + const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; + const int continueBoxX = (pageWidth - continueBoxWidth) / 2; + const int continueBoxY = continueY - continuePadding / 2; + renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, false); + renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, true); + renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, true); + } else { + renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); + } } else { // No book to continue reading const int y = diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 84cb5bfd..68af0591 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -14,8 +14,13 @@ class HomeActivity final : public Activity { bool updateRequired = false; bool hasContinueReading = false; bool hasOpdsUrl = false; + bool hasCoverImage = false; + bool coverRendered = false; // Track if cover has been rendered once + bool coverBufferStored = false; // Track if cover buffer is stored + uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image std::string lastBookTitle; std::string lastBookAuthor; + std::string coverBmpPath; const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; @@ -24,8 +29,11 @@ class HomeActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); - void render() const; + void render(); int getMenuItemCount() const; + bool storeCoverBuffer(); // Store frame buffer for cover image + bool restoreCoverBuffer(); // Restore frame buffer from stored cover + void freeCoverBuffer(); // Free the stored cover buffer public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, From 14643d02253dde96d229fd85b39fd56ea2707aff Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 14 Jan 2026 21:19:12 +1100 Subject: [PATCH 20/38] Move string helpers out of HomeActivity into StringUtils --- src/activities/home/HomeActivity.cpp | 33 +++++----------------------- src/util/StringUtils.cpp | 19 ++++++++++++++++ src/util/StringUtils.h | 6 +++++ 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 1936d926..3a97e132 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -17,29 +17,6 @@ #include "fontIds.h" #include "util/StringUtils.h" -namespace { -// UTF-8 safe string truncation - removes one character from the end -// Returns the new size after removing one UTF-8 character -size_t utf8RemoveLastChar(std::string& str) { - if (str.empty()) return 0; - size_t pos = str.size() - 1; - // Walk back to find the start of the last UTF-8 character - // UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF) - while (pos > 0 && (static_cast(str[pos]) & 0xC0) == 0x80) { - --pos; - } - str.resize(pos); - return pos; -} - -// Truncate string by removing N UTF-8 characters from the end -void utf8TruncateChars(std::string& str, size_t numChars) { - for (size_t i = 0; i < numChars && !str.empty(); ++i) { - utf8RemoveLastChar(str); - } -} -} // namespace - void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -382,7 +359,7 @@ void HomeActivity::render() { while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { // Remove "..." first, then remove one UTF-8 char, then add "..." back lines.back().resize(lines.back().size() - 3); // Remove "..." - utf8RemoveLastChar(lines.back()); + StringUtils::utf8RemoveLastChar(lines.back()); lines.back().append("..."); } break; @@ -391,7 +368,7 @@ void HomeActivity::render() { int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); while (wordWidth > maxLineWidth && !i.empty()) { // Word itself is too long, trim it (UTF-8 safe) - utf8RemoveLastChar(i); + StringUtils::utf8RemoveLastChar(i); // Check if we have room for ellipsis std::string withEllipsis = i + "..."; wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); @@ -444,7 +421,7 @@ void HomeActivity::render() { if (!lastBookAuthor.empty()) { std::string trimmedAuthor = lastBookAuthor; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - utf8RemoveLastChar(trimmedAuthor); + StringUtils::utf8RemoveLastChar(trimmedAuthor); } if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { @@ -478,14 +455,14 @@ void HomeActivity::render() { // Trim author if too long (UTF-8 safe) bool wasTrimmed = false; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - utf8RemoveLastChar(trimmedAuthor); + StringUtils::utf8RemoveLastChar(trimmedAuthor); wasTrimmed = true; } if (wasTrimmed && !trimmedAuthor.empty()) { // Make room for ellipsis while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - utf8RemoveLastChar(trimmedAuthor); + StringUtils::utf8RemoveLastChar(trimmedAuthor); } trimmedAuthor.append("..."); } diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index e296d378..e56bc9df 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -49,4 +49,23 @@ bool checkFileExtension(const std::string& fileName, const char* extension) { return true; } +size_t utf8RemoveLastChar(std::string& str) { + if (str.empty()) return 0; + size_t pos = str.size() - 1; + // Walk back to find the start of the last UTF-8 character + // UTF-8 continuation bytes start with 10xxxxxx (0x80-0xBF) + while (pos > 0 && (static_cast(str[pos]) & 0xC0) == 0x80) { + --pos; + } + str.resize(pos); + return pos; +} + +// Truncate string by removing N UTF-8 characters from the end +void utf8TruncateChars(std::string& str, const size_t numChars) { + for (size_t i = 0; i < numChars && !str.empty(); ++i) { + utf8RemoveLastChar(str); + } +} + } // namespace StringUtils diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index f846cf17..e001d7b3 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -16,4 +16,10 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); */ bool checkFileExtension(const std::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 +size_t utf8RemoveLastChar(std::string& str); + +// Truncate string by removing N UTF-8 characters from the end +void utf8TruncateChars(std::string& str, size_t numChars); } // namespace StringUtils From 49f97b69cabff5522d40bca2a4516a9bfec6b731 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Wed, 14 Jan 2026 19:36:40 +0900 Subject: [PATCH 21/38] Add TXT file reader support (#240) ## Summary * **What is the goal of this PR?** Add support for reading plain text (.txt) files, enabling users to browse, read, and track progress in TXT documents alongside existing EPUB and XTC formats. * **What changes are included?** - New Txt library for loading and parsing plain text files - New TxtReaderActivity with streaming page rendering using 8KB chunks to handle large files without memory issues on ESP32-C3 - Page index caching system (index.bin) for instant re-open after sleep or app restart - Progress bar UI during initial file indexing (matching EPUB style) - Word wrapping with proper UTF-8 support - Cover image support for TXT files: - Primary: image with same filename as TXT (e.g., book.jpg for book.txt) - Fallback: cover.bmp/jpg/jpeg in the same folder - JPG to BMP conversion using existing converter - Sleep screen cover mode now works with TXT files - File browser now shows .txt files ## Additional Context * Add any other information that might be helpful for the reviewer * Memory constraints: The streaming approach was necessary because ESP32-C3 only has 320KB RAM. A 700KB TXT file cannot be loaded entirely into memory, so we read 8KB chunks and build a page offset index instead. * Cache invalidation: The page index cache automatically invalidates when file size, viewport width, or lines per page changes (e.g., font size or orientation change). * Performance: First open requires indexing (with progress bar), subsequent opens load from cache instantly. * Cover image format: PNG is detected but not supported for conversion (no PNG decoder available). Only BMP and JPG/JPEG work. --- lib/Txt/Txt.cpp | 191 +++++ lib/Txt/Txt.h | 33 + src/activities/boot_sleep/SleepActivity.cpp | 16 + .../reader/FileSelectionActivity.cpp | 2 +- src/activities/reader/ReaderActivity.cpp | 51 ++ src/activities/reader/ReaderActivity.h | 4 + src/activities/reader/TxtReaderActivity.cpp | 700 ++++++++++++++++++ src/activities/reader/TxtReaderActivity.h | 60 ++ 8 files changed, 1056 insertions(+), 1 deletion(-) create mode 100644 lib/Txt/Txt.cpp create mode 100644 lib/Txt/Txt.h create mode 100644 src/activities/reader/TxtReaderActivity.cpp create mode 100644 src/activities/reader/TxtReaderActivity.h diff --git a/lib/Txt/Txt.cpp b/lib/Txt/Txt.cpp new file mode 100644 index 00000000..52c75ed7 --- /dev/null +++ b/lib/Txt/Txt.cpp @@ -0,0 +1,191 @@ +#include "Txt.h" + +#include +#include + +Txt::Txt(std::string path, std::string cacheBasePath) + : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { + // Generate cache path from file path hash + const size_t hash = std::hash{}(filepath); + cachePath = this->cacheBasePath + "/txt_" + std::to_string(hash); +} + +bool Txt::load() { + if (loaded) { + return true; + } + + if (!SdMan.exists(filepath.c_str())) { + Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str()); + return false; + } + + FsFile file; + if (!SdMan.openFileForRead("TXT", filepath, file)) { + Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str()); + return false; + } + + fileSize = file.size(); + file.close(); + + loaded = true; + Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize); + return true; +} + +std::string Txt::getTitle() const { + // Extract filename without path and extension + size_t lastSlash = filepath.find_last_of('/'); + std::string filename = (lastSlash != std::string::npos) ? filepath.substr(lastSlash + 1) : filepath; + + // Remove .txt extension + if (filename.length() >= 4 && filename.substr(filename.length() - 4) == ".txt") { + filename = filename.substr(0, filename.length() - 4); + } + + return filename; +} + +void Txt::setupCacheDir() const { + if (!SdMan.exists(cacheBasePath.c_str())) { + SdMan.mkdir(cacheBasePath.c_str()); + } + if (!SdMan.exists(cachePath.c_str())) { + SdMan.mkdir(cachePath.c_str()); + } +} + +std::string Txt::findCoverImage() const { + // Get the folder containing the txt file + size_t lastSlash = filepath.find_last_of('/'); + std::string folder = (lastSlash != std::string::npos) ? filepath.substr(0, lastSlash) : ""; + if (folder.empty()) { + folder = "/"; + } + + // Get the base filename without extension (e.g., "mybook" from "/books/mybook.txt") + std::string baseName = getTitle(); + + // Image extensions to try + const char* extensions[] = {".bmp", ".jpg", ".jpeg", ".png", ".BMP", ".JPG", ".JPEG", ".PNG"}; + + // First priority: look for image with same name as txt file (e.g., mybook.jpg) + for (const auto& ext : extensions) { + std::string coverPath = folder + "/" + baseName + ext; + if (SdMan.exists(coverPath.c_str())) { + Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str()); + return coverPath; + } + } + + // Fallback: look for cover image files + const char* coverNames[] = {"cover", "Cover", "COVER"}; + for (const auto& name : coverNames) { + for (const auto& ext : extensions) { + std::string coverPath = folder + "/" + std::string(name) + ext; + if (SdMan.exists(coverPath.c_str())) { + Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str()); + return coverPath; + } + } + } + + return ""; +} + +std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Txt::generateCoverBmp() const { + // Already generated, return true + if (SdMan.exists(getCoverBmpPath().c_str())) { + return true; + } + + std::string coverImagePath = findCoverImage(); + if (coverImagePath.empty()) { + Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis()); + return false; + } + + // Setup cache directory + setupCacheDir(); + + // Get file extension + const size_t len = coverImagePath.length(); + const bool isJpg = + (len >= 4 && (coverImagePath.substr(len - 4) == ".jpg" || coverImagePath.substr(len - 4) == ".JPG")) || + (len >= 5 && (coverImagePath.substr(len - 5) == ".jpeg" || coverImagePath.substr(len - 5) == ".JPEG")); + const bool isBmp = len >= 4 && (coverImagePath.substr(len - 4) == ".bmp" || coverImagePath.substr(len - 4) == ".BMP"); + + if (isBmp) { + // Copy BMP file to cache + Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis()); + FsFile src, dst; + if (!SdMan.openFileForRead("TXT", coverImagePath, src)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) { + src.close(); + return false; + } + uint8_t buffer[1024]; + while (src.available()) { + size_t bytesRead = src.read(buffer, sizeof(buffer)); + dst.write(buffer, bytesRead); + } + src.close(); + dst.close(); + Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis()); + return true; + } + + if (isJpg) { + // Convert JPG/JPEG to BMP (same approach as Epub) + Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis()); + FsFile coverJpg, coverBmp; + if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) { + return false; + } + if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { + coverJpg.close(); + return false; + } + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + coverJpg.close(); + coverBmp.close(); + + if (!success) { + Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis()); + SdMan.remove(getCoverBmpPath().c_str()); + } else { + Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis()); + } + return success; + } + + // PNG files are not supported (would need a PNG decoder) + Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis()); + return false; +} + +bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const { + if (!loaded) { + return false; + } + + FsFile file; + if (!SdMan.openFileForRead("TXT", filepath, file)) { + return false; + } + + if (!file.seek(offset)) { + file.close(); + return false; + } + + size_t bytesRead = file.read(buffer, length); + file.close(); + + return bytesRead > 0; +} diff --git a/lib/Txt/Txt.h b/lib/Txt/Txt.h new file mode 100644 index 00000000..b75c7738 --- /dev/null +++ b/lib/Txt/Txt.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#include + +class Txt { + std::string filepath; + std::string cacheBasePath; + std::string cachePath; + bool loaded = false; + size_t fileSize = 0; + + public: + explicit Txt(std::string path, std::string cacheBasePath); + + bool load(); + [[nodiscard]] const std::string& getPath() const { return filepath; } + [[nodiscard]] const std::string& getCachePath() const { return cachePath; } + [[nodiscard]] std::string getTitle() const; + [[nodiscard]] size_t getFileSize() const { return fileSize; } + + void setupCacheDir() const; + + // Cover image support - looks for cover.bmp/jpg/jpeg/png in same folder as txt file + [[nodiscard]] std::string getCoverBmpPath() const; + [[nodiscard]] bool generateCoverBmp() const; + [[nodiscard]] std::string findCoverImage() const; + + // Read content from file + [[nodiscard]] bool readContent(uint8_t* buffer, size_t offset, size_t length) const; +}; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 0d3eab0a..bf2b5857 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "CrossPointSettings.h" @@ -207,6 +208,7 @@ void SleepActivity::renderCoverSleepScreen() const { std::string coverBmpPath; bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; + // Check if the current book is XTC, TXT, or EPUB if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) { // Handle XTC file @@ -222,6 +224,20 @@ void SleepActivity::renderCoverSleepScreen() const { } coverBmpPath = lastXtc.getCoverBmpPath(); + } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) { + // Handle TXT file - looks for cover image in the same folder + Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastTxt.load()) { + Serial.println("[SLP] Failed to load last TXT"); + return renderDefaultSleepScreen(); + } + + if (!lastTxt.generateCoverBmp()) { + Serial.println("[SLP] No cover image found for TXT file"); + return renderDefaultSleepScreen(); + } + + coverBmpPath = lastTxt.getCoverBmpPath(); } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 33c2c3e4..3ef42c1c 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -52,7 +52,7 @@ void FileSelectionActivity::loadFiles() { } else { auto filename = std::string(name); if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") || - StringUtils::checkFileExtension(filename, ".xtc")) { + StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt")) { files.emplace_back(filename); } } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index cb123e1c..c00f6236 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -3,6 +3,8 @@ #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" +#include "Txt.h" +#include "TxtReaderActivity.h" #include "Xtc.h" #include "XtcReaderActivity.h" #include "activities/util/FullScreenMessageActivity.h" @@ -20,6 +22,12 @@ bool ReaderActivity::isXtcFile(const std::string& path) { return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch"); } +bool ReaderActivity::isTxtFile(const std::string& path) { + if (path.length() < 4) return false; + std::string ext4 = path.substr(path.length() - 4); + return ext4 == ".txt" || ext4 == ".TXT"; +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!SdMan.exists(path.c_str())) { Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); @@ -50,6 +58,21 @@ std::unique_ptr ReaderActivity::loadXtc(const std::string& path) { return nullptr; } +std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { + if (!SdMan.exists(path.c_str())) { + Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); + return nullptr; + } + + auto txt = std::unique_ptr(new Txt(path, "/.crosspoint")); + if (txt->load()) { + return txt; + } + + Serial.printf("[%lu] [ ] Failed to load TXT\n", millis()); + return nullptr; +} + void ReaderActivity::onSelectBookFile(const std::string& path) { currentBookPath = path; // Track current book path exitActivity(); @@ -67,6 +90,18 @@ void ReaderActivity::onSelectBookFile(const std::string& path) { delay(2000); onGoToFileSelection(); } + } else if (isTxtFile(path)) { + // Load TXT file + auto txt = loadTxt(path); + if (txt) { + onGoToTxtReader(std::move(txt)); + } else { + exitActivity(); + enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load TXT", + EpdFontFamily::REGULAR, EInkDisplay::HALF_REFRESH)); + delay(2000); + onGoToFileSelection(); + } } else { // Load EPUB file auto epub = loadEpub(path); @@ -108,6 +143,15 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr xtc) { [this] { onGoBack(); })); } +void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { + const auto txtPath = txt->getPath(); + currentBookPath = txtPath; + exitActivity(); + enterNewActivity(new TxtReaderActivity( + renderer, mappedInput, std::move(txt), [this, txtPath] { onGoToFileSelection(txtPath); }, + [this] { onGoBack(); })); +} + void ReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); @@ -125,6 +169,13 @@ void ReaderActivity::onEnter() { return; } onGoToXtcReader(std::move(xtc)); + } else if (isTxtFile(initialBookPath)) { + auto txt = loadTxt(initialBookPath); + if (!txt) { + onGoBack(); + return; + } + onGoToTxtReader(std::move(txt)); } else { auto epub = loadEpub(initialBookPath); if (!epub) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index df44afe5..bec2a45b 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -5,6 +5,7 @@ class Epub; class Xtc; +class Txt; class ReaderActivity final : public ActivityWithSubactivity { std::string initialBookPath; @@ -12,13 +13,16 @@ class ReaderActivity final : public ActivityWithSubactivity { const std::function onGoBack; static std::unique_ptr loadEpub(const std::string& path); static std::unique_ptr loadXtc(const std::string& path); + static std::unique_ptr loadTxt(const std::string& path); static bool isXtcFile(const std::string& path); + static bool isTxtFile(const std::string& path); static std::string extractFolderPath(const std::string& filePath); void onSelectBookFile(const std::string& path); void onGoToFileSelection(const std::string& fromBookPath = ""); void onGoToEpubReader(std::unique_ptr epub); void onGoToXtcReader(std::unique_ptr xtc); + void onGoToTxtReader(std::unique_ptr txt); public: explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath, diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp new file mode 100644 index 00000000..db725320 --- /dev/null +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -0,0 +1,700 @@ +#include "TxtReaderActivity.h" + +#include +#include +#include +#include + +#include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" + +namespace { +constexpr unsigned long goHomeMs = 1000; +constexpr int statusBarMargin = 25; +constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading + +// Cache file magic and version +constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" +constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes +} // namespace + +void TxtReaderActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void TxtReaderActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + if (!txt) { + return; + } + + // Configure screen orientation based on settings + switch (SETTINGS.orientation) { + case CrossPointSettings::ORIENTATION::PORTRAIT: + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + break; + case CrossPointSettings::ORIENTATION::LANDSCAPE_CW: + renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise); + break; + case CrossPointSettings::ORIENTATION::INVERTED: + renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted); + break; + case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW: + renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise); + break; + default: + break; + } + + renderingMutex = xSemaphoreCreateMutex(); + + txt->setupCacheDir(); + + // Save current txt as last opened file + APP_STATE.openEpubPath = txt->getPath(); + APP_STATE.saveToFile(); + + // Trigger first update + updateRequired = true; + + xTaskCreate(&TxtReaderActivity::taskTrampoline, "TxtReaderActivityTask", + 6144, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void TxtReaderActivity::onExit() { + ActivityWithSubactivity::onExit(); + + // Reset orientation back to portrait for the rest of the UI + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + pageOffsets.clear(); + currentPageLines.clear(); + txt.reset(); +} + +void TxtReaderActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + // Long press BACK (1s+) goes directly to home + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { + onGoHome(); + return; + } + + // Short press BACK goes to file selection + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { + onGoBack(); + return; + } + + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + + if (!prevReleased && !nextReleased) { + return; + } + + if (prevReleased && currentPage > 0) { + currentPage--; + updateRequired = true; + } else if (nextReleased && currentPage < totalPages - 1) { + currentPage++; + updateRequired = true; + } +} + +void TxtReaderActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void TxtReaderActivity::initializeReader() { + if (initialized) { + return; + } + + // Store current settings for cache validation + cachedFontId = SETTINGS.getReaderFontId(); + cachedScreenMargin = SETTINGS.screenMargin; + cachedParagraphAlignment = SETTINGS.paragraphAlignment; + + // Calculate viewport dimensions + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += cachedScreenMargin; + orientedMarginLeft += cachedScreenMargin; + orientedMarginRight += cachedScreenMargin; + orientedMarginBottom += statusBarMargin; + + viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; + const int viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; + const int lineHeight = renderer.getLineHeight(cachedFontId); + + linesPerPage = viewportHeight / lineHeight; + if (linesPerPage < 1) linesPerPage = 1; + + Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, + linesPerPage); + + // Try to load cached page index first + if (!loadPageIndexCache()) { + // Cache not found, build page index + buildPageIndex(); + // Save to cache for next time + savePageIndexCache(); + } + + // Load saved progress + loadProgress(); + + initialized = true; +} + +void TxtReaderActivity::buildPageIndex() { + pageOffsets.clear(); + pageOffsets.push_back(0); // First page starts at offset 0 + + size_t offset = 0; + const size_t fileSize = txt->getFileSize(); + int lastProgressPercent = -1; + + Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); + + // Progress bar dimensions (matching EpubReaderActivity style) + constexpr int barWidth = 200; + constexpr int barHeight = 10; + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing..."); + const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; + const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; + const int boxX = (renderer.getScreenWidth() - boxWidth) / 2; + constexpr int boxY = 50; + const int barX = boxX + (boxWidth - barWidth) / 2; + const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + + // Draw initial progress box + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); + renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing..."); + renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); + renderer.drawRect(barX, barY, barWidth, barHeight); + renderer.displayBuffer(); + + while (offset < fileSize) { + std::vector tempLines; + size_t nextOffset = offset; + + if (!loadPageAtOffset(offset, tempLines, nextOffset)) { + break; + } + + if (nextOffset <= offset) { + // No progress made, avoid infinite loop + break; + } + + offset = nextOffset; + if (offset < fileSize) { + pageOffsets.push_back(offset); + } + + // Update progress bar every 10% (matching EpubReaderActivity logic) + int progressPercent = (offset * 100) / fileSize; + if (lastProgressPercent / 10 != progressPercent / 10) { + lastProgressPercent = progressPercent; + + // Fill progress bar + const int fillWidth = (barWidth - 2) * progressPercent / 100; + renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + } + + // Yield to other tasks periodically + if (pageOffsets.size() % 20 == 0) { + vTaskDelay(1); + } + } + + totalPages = pageOffsets.size(); + Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages); +} + +bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { + outLines.clear(); + const size_t fileSize = txt->getFileSize(); + + if (offset >= fileSize) { + return false; + } + + // Read a chunk from file + size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); + auto* buffer = static_cast(malloc(chunkSize + 1)); + if (!buffer) { + Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize); + return false; + } + + if (!txt->readContent(buffer, offset, chunkSize)) { + free(buffer); + return false; + } + buffer[chunkSize] = '\0'; + + // Parse lines from buffer + size_t pos = 0; + + while (pos < chunkSize && static_cast(outLines.size()) < linesPerPage) { + // Find end of line + size_t lineEnd = pos; + while (lineEnd < chunkSize && buffer[lineEnd] != '\n') { + lineEnd++; + } + + // Check if we have a complete line + bool lineComplete = (lineEnd < chunkSize) || (offset + lineEnd >= fileSize); + + if (!lineComplete && static_cast(outLines.size()) > 0) { + // Incomplete line and we already have some lines, stop here + break; + } + + // Calculate the actual length of line content in the buffer (excluding newline) + size_t lineContentLen = lineEnd - pos; + + // Check for carriage return + bool hasCR = (lineContentLen > 0 && buffer[pos + lineContentLen - 1] == '\r'); + size_t displayLen = hasCR ? lineContentLen - 1 : lineContentLen; + + // Extract line content for display (without CR/LF) + std::string line(reinterpret_cast(buffer + pos), displayLen); + + // Track position within this source line (in bytes from pos) + size_t lineBytePos = 0; + + // Word wrap if needed + while (!line.empty() && static_cast(outLines.size()) < linesPerPage) { + int lineWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + + if (lineWidth <= viewportWidth) { + outLines.push_back(line); + lineBytePos = displayLen; // Consumed entire display content + line.clear(); + break; + } + + // Find break point + size_t breakPos = line.length(); + while (breakPos > 0 && renderer.getTextWidth(cachedFontId, line.substr(0, breakPos).c_str()) > viewportWidth) { + // Try to break at space + size_t spacePos = line.rfind(' ', breakPos - 1); + if (spacePos != std::string::npos && spacePos > 0) { + breakPos = spacePos; + } else { + // Break at character boundary for UTF-8 + breakPos--; + // Make sure we don't break in the middle of a UTF-8 sequence + while (breakPos > 0 && (line[breakPos] & 0xC0) == 0x80) { + breakPos--; + } + } + } + + if (breakPos == 0) { + breakPos = 1; + } + + outLines.push_back(line.substr(0, breakPos)); + + // Skip space at break point + size_t skipChars = breakPos; + if (breakPos < line.length() && line[breakPos] == ' ') { + skipChars++; + } + lineBytePos += skipChars; + line = line.substr(skipChars); + } + + // Determine how much of the source buffer we consumed + if (line.empty()) { + // Fully consumed this source line, move past the newline + pos = lineEnd + 1; + } else { + // Partially consumed - page is full mid-line + // Move pos to where we stopped in the line (NOT past the line) + pos = pos + lineBytePos; + break; + } + } + + // Ensure we make progress even if calculations go wrong + if (pos == 0 && !outLines.empty()) { + // Fallback: at minimum, consume something to avoid infinite loop + pos = 1; + } + + nextOffset = offset + pos; + + // Make sure we don't go past the file + if (nextOffset > fileSize) { + nextOffset = fileSize; + } + + free(buffer); + + return !outLines.empty(); +} + +void TxtReaderActivity::renderScreen() { + if (!txt) { + return; + } + + // Initialize reader if not done + if (!initialized) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + initializeReader(); + } + + if (pageOffsets.empty()) { + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty file", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + // Bounds check + if (currentPage < 0) currentPage = 0; + if (currentPage >= totalPages) currentPage = totalPages - 1; + + // Load current page content + size_t offset = pageOffsets[currentPage]; + size_t nextOffset; + currentPageLines.clear(); + loadPageAtOffset(offset, currentPageLines, nextOffset); + + renderer.clearScreen(); + renderPage(); + + // Save progress + saveProgress(); +} + +void TxtReaderActivity::renderPage() { + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += cachedScreenMargin; + orientedMarginLeft += cachedScreenMargin; + orientedMarginRight += cachedScreenMargin; + orientedMarginBottom += statusBarMargin; + + const int lineHeight = renderer.getLineHeight(cachedFontId); + const int contentWidth = viewportWidth; + + // Render text lines with alignment + auto renderLines = [&]() { + int y = orientedMarginTop; + for (const auto& line : currentPageLines) { + if (!line.empty()) { + int x = orientedMarginLeft; + + // Apply text alignment + switch (cachedParagraphAlignment) { + case CrossPointSettings::LEFT_ALIGN: + default: + // x already set to left margin + break; + case CrossPointSettings::CENTER_ALIGN: { + int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + x = orientedMarginLeft + (contentWidth - textWidth) / 2; + break; + } + case CrossPointSettings::RIGHT_ALIGN: { + int textWidth = renderer.getTextWidth(cachedFontId, line.c_str()); + x = orientedMarginLeft + contentWidth - textWidth; + break; + } + case CrossPointSettings::JUSTIFIED: + // For plain text, justified is treated as left-aligned + // (true justification would require word spacing adjustments) + break; + } + + renderer.drawText(cachedFontId, x, y, line.c_str()); + } + y += lineHeight; + } + }; + + // First pass: BW rendering + renderLines(); + renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); + + if (pagesUntilFullRefresh <= 1) { + renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); + } else { + renderer.displayBuffer(); + pagesUntilFullRefresh--; + } + + // Grayscale rendering pass (for anti-aliased fonts) + if (SETTINGS.textAntiAliasing) { + // Save BW buffer for restoration after grayscale pass + renderer.storeBwBuffer(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + renderLines(); + renderer.copyGrayscaleLsbBuffers(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + renderLines(); + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + + // Restore BW buffer + renderer.restoreBwBuffer(); + } +} + +void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, + const int orientedMarginLeft) const { + const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + + const auto screenHeight = renderer.getScreenHeight(); + const auto textY = screenHeight - orientedMarginBottom - 4; + int progressTextWidth = 0; + + if (showProgress) { + const int progress = totalPages > 0 ? (currentPage + 1) * 100 / totalPages : 0; + const std::string progressStr = + std::to_string(currentPage + 1) + "/" + std::to_string(totalPages) + " " + std::to_string(progress) + "%"; + progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr.c_str()); + renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, + progressStr.c_str()); + } + + if (showBattery) { + ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + } + + if (showTitle) { + const int titleMarginLeft = 50 + 30 + orientedMarginLeft; + const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight; + const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight; + + std::string title = txt->getTitle(); + int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + while (titleWidth > availableTextWidth && title.length() > 11) { + title.replace(title.length() - 8, 8, "..."); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } + + renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); + } +} + +void TxtReaderActivity::saveProgress() const { + FsFile f; + if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentPage & 0xFF; + data[1] = (currentPage >> 8) & 0xFF; + data[2] = 0; + data[3] = 0; + f.write(data, 4); + f.close(); + } +} + +void TxtReaderActivity::loadProgress() { + FsFile f; + if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + currentPage = data[0] + (data[1] << 8); + if (currentPage >= totalPages) { + currentPage = totalPages - 1; + } + if (currentPage < 0) { + currentPage = 0; + } + Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages); + } + f.close(); + } +} + +bool TxtReaderActivity::loadPageIndexCache() { + // Cache file format (using serialization module): + // - uint32_t: magic "TXTI" + // - uint8_t: cache version + // - uint32_t: file size (to validate cache) + // - int32_t: viewport width + // - int32_t: lines per page + // - int32_t: font ID (to invalidate cache on font change) + // - int32_t: screen margin (to invalidate cache on margin change) + // - uint8_t: paragraph alignment (to invalidate cache on alignment change) + // - uint32_t: total pages count + // - N * uint32_t: page offsets + + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!SdMan.openFileForRead("TRS", cachePath, f)) { + Serial.printf("[%lu] [TRS] No page index cache found\n", millis()); + return false; + } + + // Read and validate header using serialization module + uint32_t magic; + serialization::readPod(f, magic); + if (magic != CACHE_MAGIC) { + Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint8_t version; + serialization::readPod(f, version); + if (version != CACHE_VERSION) { + Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION); + f.close(); + return false; + } + + uint32_t fileSize; + serialization::readPod(f, fileSize); + if (fileSize != txt->getFileSize()) { + Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t cachedWidth; + serialization::readPod(f, cachedWidth); + if (cachedWidth != viewportWidth) { + Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t cachedLines; + serialization::readPod(f, cachedLines); + if (cachedLines != linesPerPage) { + Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + int32_t fontId; + serialization::readPod(f, fontId); + if (fontId != cachedFontId) { + Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId); + f.close(); + return false; + } + + int32_t margin; + serialization::readPod(f, margin); + if (margin != cachedScreenMargin) { + Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint8_t alignment; + serialization::readPod(f, alignment); + if (alignment != cachedParagraphAlignment) { + Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis()); + f.close(); + return false; + } + + uint32_t numPages; + serialization::readPod(f, numPages); + + // Read page offsets + pageOffsets.clear(); + pageOffsets.reserve(numPages); + + for (uint32_t i = 0; i < numPages; i++) { + uint32_t offset; + serialization::readPod(f, offset); + pageOffsets.push_back(offset); + } + + f.close(); + totalPages = pageOffsets.size(); + Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages); + return true; +} + +void TxtReaderActivity::savePageIndexCache() const { + std::string cachePath = txt->getCachePath() + "/index.bin"; + FsFile f; + if (!SdMan.openFileForWrite("TRS", cachePath, f)) { + Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis()); + return; + } + + // Write header using serialization module + serialization::writePod(f, CACHE_MAGIC); + serialization::writePod(f, CACHE_VERSION); + serialization::writePod(f, static_cast(txt->getFileSize())); + serialization::writePod(f, static_cast(viewportWidth)); + serialization::writePod(f, static_cast(linesPerPage)); + serialization::writePod(f, static_cast(cachedFontId)); + serialization::writePod(f, static_cast(cachedScreenMargin)); + serialization::writePod(f, cachedParagraphAlignment); + serialization::writePod(f, static_cast(pageOffsets.size())); + + // Write page offsets + for (size_t offset : pageOffsets) { + serialization::writePod(f, static_cast(offset)); + } + + f.close(); + Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages); +} diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h new file mode 100644 index 00000000..41ccbfbb --- /dev/null +++ b/src/activities/reader/TxtReaderActivity.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "activities/ActivityWithSubactivity.h" + +class TxtReaderActivity final : public ActivityWithSubactivity { + std::unique_ptr txt; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int currentPage = 0; + int totalPages = 1; + int pagesUntilFullRefresh = 0; + bool updateRequired = false; + const std::function onGoBack; + const std::function onGoHome; + + // Streaming text reader - stores file offsets for each page + std::vector pageOffsets; // File offset for start of each page + std::vector currentPageLines; + int linesPerPage = 0; + int viewportWidth = 0; + bool initialized = false; + + // Cached settings for cache validation (different fonts/margins require re-indexing) + int cachedFontId = 0; + int cachedScreenMargin = 0; + uint8_t cachedParagraphAlignment = CrossPointSettings::LEFT_ALIGN; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); + void renderPage(); + void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + + void initializeReader(); + bool loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset); + void buildPageIndex(); + bool loadPageIndexCache(); + void savePageIndexCache() const; + void saveProgress() const; + void loadProgress(); + + public: + explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr txt, + const std::function& onGoBack, const std::function& onGoHome) + : ActivityWithSubactivity("TxtReader", renderer, mappedInput), + txt(std::move(txt)), + onGoBack(onGoBack), + onGoHome(onGoHome) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; From 1c027ce2cd7370d5d02668dda9e956967e99a49f Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Wed, 14 Jan 2026 12:38:30 +0100 Subject: [PATCH 22/38] Skip BOM character (sometimes used in front of em-dashes) (#340) ## Summary Skip BOM character (sometimes used in front of em-dashes) - they are not part of the glyph set and would render `?` otherwise. --- ### AI Usage Did you use AI tools to help write this code? _**YES**_ --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b96d28f8..b9305b1e 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -151,6 +151,20 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char } } + // Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF + const XML_Char FEFF_BYTE_1 = static_cast(0xEF); + const XML_Char FEFF_BYTE_2 = static_cast(0xBB); + const XML_Char FEFF_BYTE_3 = static_cast(0xBF); + + if (s[i] == FEFF_BYTE_1) { + // Check if the next two bytes complete the 3-byte sequence + if ((i + 2 < len) && (s[i + 1] == FEFF_BYTE_2) && (s[i + 2] == FEFF_BYTE_3)) { + // Sequence 0xEF 0xBB 0xBF found! + i += 2; // Skip the next two bytes + continue; // Move to the next iteration + } + } + // If we're about to run out of space, then cut the word off and start a new one if (self->partWordBufferIndex >= MAX_WORD_SIZE) { self->partWordBuffer[self->partWordBufferIndex] = '\0'; From 9a9dc044ce86d738c33dbe58a83798bdd0a80a30 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Wed, 14 Jan 2026 12:40:40 +0100 Subject: [PATCH 23/38] ifdef around optional fonts to reduce flash size/time. (#339) ## Summary Adds define to omit optional fonts from the build. This reduces time to flash from >31s to <13s. Useful for development that doesn't require fonts. Addresses #193 Invoke it like this during development: `PLATFORMIO_BUILD_FLAGS="-D OMIT_FONTS" pio run --target upload && pio device monitor` Changing the define causes `pio` to do a full rebuild (but it will be quick if you keep the define). --- ### 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 --- src/main.cpp | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 34e7376b..8a7c3b91 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -43,18 +43,19 @@ GfxRenderer renderer(einkDisplay); Activity* currentActivity; // Fonts -EpdFont bookerly12RegularFont(&bookerly_12_regular); -EpdFont bookerly12BoldFont(&bookerly_12_bold); -EpdFont bookerly12ItalicFont(&bookerly_12_italic); -EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic); -EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont, - &bookerly12BoldItalicFont); EpdFont bookerly14RegularFont(&bookerly_14_regular); EpdFont bookerly14BoldFont(&bookerly_14_bold); EpdFont bookerly14ItalicFont(&bookerly_14_italic); EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic); EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont, &bookerly14BoldItalicFont); +#ifndef OMIT_FONTS +EpdFont bookerly12RegularFont(&bookerly_12_regular); +EpdFont bookerly12BoldFont(&bookerly_12_bold); +EpdFont bookerly12ItalicFont(&bookerly_12_italic); +EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic); +EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont, + &bookerly12BoldItalicFont); EpdFont bookerly16RegularFont(&bookerly_16_regular); EpdFont bookerly16BoldFont(&bookerly_16_bold); EpdFont bookerly16ItalicFont(&bookerly_16_italic); @@ -117,6 +118,7 @@ EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic); EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic); EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont, &opendyslexic14BoldItalicFont); +#endif // OMIT_FONTS EpdFont smallFont(¬osans_8_regular); EpdFontFamily smallFontFamily(&smallFont); @@ -239,10 +241,12 @@ void onGoHome() { void setupDisplayAndFonts() { einkDisplay.begin(); Serial.printf("[%lu] [ ] Display initialized\n", millis()); - renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); +#ifndef OMIT_FONTS + renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); renderer.insertFont(BOOKERLY_16_FONT_ID, bookerly16FontFamily); renderer.insertFont(BOOKERLY_18_FONT_ID, bookerly18FontFamily); + renderer.insertFont(NOTOSANS_12_FONT_ID, notosans12FontFamily); renderer.insertFont(NOTOSANS_14_FONT_ID, notosans14FontFamily); renderer.insertFont(NOTOSANS_16_FONT_ID, notosans16FontFamily); @@ -251,6 +255,7 @@ void setupDisplayAndFonts() { renderer.insertFont(OPENDYSLEXIC_10_FONT_ID, opendyslexic10FontFamily); renderer.insertFont(OPENDYSLEXIC_12_FONT_ID, opendyslexic12FontFamily); renderer.insertFont(OPENDYSLEXIC_14_FONT_ID, opendyslexic14FontFamily); +#endif // OMIT_FONTS renderer.insertFont(UI_10_FONT_ID, ui10FontFamily); renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); renderer.insertFont(SMALL_FONT_ID, smallFontFamily); From ed05554d74f4eae61a890a5304c87932a8deb046 Mon Sep 17 00:00:00 2001 From: Armando Cerna Date: Wed, 14 Jan 2026 06:47:24 -0500 Subject: [PATCH 24/38] feat: Add setting to toggle long-press chapter skip (#341) ## Summary Adds a new "Long-press Chapter Skip" toggle in Settings to control whether holding the side buttons skips chapters. I kept accidentally triggering chapter skips while reading, which caused me to lose my place in the middle of long chapters. ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### 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 **_ --- src/CrossPointSettings.cpp | 5 ++++- src/CrossPointSettings.h | 2 ++ src/activities/reader/EpubReaderActivity.cpp | 2 +- src/activities/settings/SettingsActivity.cpp | 3 ++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 1ca9ea74..17b5d053 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 17; +constexpr uint8_t SETTINGS_COUNT = 18; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -47,6 +47,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hideBatteryPercentage); + serialization::writePod(outputFile, longPressChapterSkip); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -113,6 +114,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hideBatteryPercentage); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, longPressChapterSkip); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d5f91039..a5641aad 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -90,6 +90,8 @@ class CrossPointSettings { char opdsServerUrl[128] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; + // Long-press chapter skip on side buttons + uint8_t longPressChapterSkip = 1; ~CrossPointSettings() = default; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f51cf9bf..2eeba80f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -168,7 +168,7 @@ void EpubReaderActivity::loop() { return; } - const bool skipChapter = mappedInput.getHeldTime() > skipChapterMs; + const bool skipChapter = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipChapterMs; if (skipChapter) { // We don't want to delete the section mid-render, so grab the semaphore diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index f22850a9..efa0b9e1 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -13,7 +13,7 @@ // Define the static settings list namespace { -constexpr int settingsCount = 19; +constexpr int settingsCount = 20; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -29,6 +29,7 @@ const SettingInfo settingsList[settingsCount] = { {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}), + SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), SettingInfo::Enum("Reader Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}), SettingInfo::Enum("Reader Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), From c2fb8ce55dc3c6d26b94c80880ecf5e4630d0a22 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:48:43 -0500 Subject: [PATCH 25/38] Update User Guide to reflect release 0.13.1 (#337) Please note I have not tested the Calibre features and am not yet in a position to offer detailed documentation of how they work. --- USER_GUIDE.md | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 70a765ba..b411140e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -20,9 +20,10 @@ Button layout can be customized in **[Settings](#35-settings)**. ### Power On / Off -To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure the power button to trigger on a short press instead of a long one. +To turn the device on or off, **press and hold the Power button for approximately half a second**. +In **[Settings](#35-settings)** you can configure the power button to turn the device off with a short press instead of a long one. -To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then hold the Power button for a few seconds. +To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then quickly press and hold the Power button for a few seconds. ### First Launch @@ -63,18 +64,29 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: - **Sleep Screen**: Which sleep screen to display when the device sleeps: - - "Dark" (default) - The default dark sleep screen + - "Dark" (default) - The default dark Crosspoint logo sleep screen - "Light" - The same default sleep screen, on a white background - - "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information + - "Custom" - Custom images from the SD card; see [Sleep Screen](#36-sleep-screen) below for more information - "Cover" - The book cover image (Note: this is experimental and may not work as expected) - - "Blank" - A blank screen + - "None" - A blank screen +- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected: + - "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary + - "Crop" - Scale the image down and crop as necessary to try to to fill the screen (Note: this is experimental and may not work as expected) - **Status Bar**: Configure the status bar displayed while reading: - "None" - No status bar - "No Progress" - Show status bar without reading progress - "Full" - Show status bar with reading progress +- **Hide Battery %**: Configure where to suppress the battery pecentage display in the status bar; the battery icon will still be shown: + - "Never" - Always show battery percentage (default) + - "In Reader" - Show battery percentage everywhere except in reading mode + - "Always" - Always hide battery percentage - **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-line indentation. -- **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. -- **Reading Orientation**: Set the screen orientation for reading: +- **Text Anti-Aliasing**: Whether to show smooth grey edges (anti-aliasing) on text in reading mode. Note this slows down page turns slightly. +- **Short Power Button Click**: Controls the effect of a short click of the power button: + - "Ignore" - Require a long press to turn off the device + - "Sleep" - A short press powers the device off + - "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off +- **Reading Orientation**: Set the screen orientation for reading EPUB files: - "Portrait" (default) - Standard portrait orientation - "Landscape CW" - Landscape, rotated clockwise - "Inverted" - Portrait, upside down @@ -83,16 +95,18 @@ The Settings screen allows you to configure the device's behavior. There are a f - Back, Confirm, Left, Right (default) - Left, Right, Back, Confirm - Left, Back, Confirm, Right -- **Side Button Layout**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. +- **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. - **Reader Font Family**: Choose the font used for reading: - "Bookerly" (default) - Amazon's reading font - "Noto Sans" - Google's sans-serif font - "Open Dyslexic" - Font designed for readers with dyslexia - **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium", "Large", or "X Large". - **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal", or "Wide". +- **Reader Screen Margin**: Controls the screen margins in reader mode between 5 and 40 pixels in 5 pixel increments. - **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right". - **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep. - **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting. +- **Calibre Settings**: Set up integration for accessing a Calibre web library or connecting to Calibre as a wireless device. - **Check for updates**: Check for firmware updates over WiFi. ### 3.6 Sleep Screen @@ -124,6 +138,8 @@ Once you have opened a book, the button layout changes to facilitate reading. The role of the volume (side) buttons can be swapped in **[Settings](#35-settings)**. +If the **Short Power Button Click** setting is set to "Page Turn", you can also turn to the next page by briefly pressing the Power button. + ### Chapter Navigation * **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release. * **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. From 847786e3426d043d6872ea9a254f732aa69a0c0e Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 14 Jan 2026 06:54:14 -0500 Subject: [PATCH 26/38] Fixes issue with Calibre web expecting SSL (#347) http urls now work with Calibre web --------- Co-authored-by: Dave Allie --- src/network/HttpDownloader.cpp | 25 +++++++++++++++++++++---- src/util/UrlUtils.cpp | 2 ++ src/util/UrlUtils.h | 5 +++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 017c6870..c4de3a05 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -2,13 +2,23 @@ #include #include +#include #include #include +#include "util/UrlUtils.h" + bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { - const std::unique_ptr client(new WiFiClientSecure()); - client->setInsecure(); + // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP + std::unique_ptr client; + if (UrlUtils::isHttpsUrl(url)) { + auto* secureClient = new WiFiClientSecure(); + secureClient->setInsecure(); + client.reset(secureClient); + } else { + client.reset(new WiFiClient()); + } HTTPClient http; Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str()); @@ -33,8 +43,15 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, ProgressCallback progress) { - const std::unique_ptr client(new WiFiClientSecure()); - client->setInsecure(); + // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP + std::unique_ptr client; + if (UrlUtils::isHttpsUrl(url)) { + auto* secureClient = new WiFiClientSecure(); + secureClient->setInsecure(); + client.reset(secureClient); + } else { + client.reset(new WiFiClient()); + } HTTPClient http; Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str()); diff --git a/src/util/UrlUtils.cpp b/src/util/UrlUtils.cpp index 0eeeae3a..bbf5ac15 100644 --- a/src/util/UrlUtils.cpp +++ b/src/util/UrlUtils.cpp @@ -2,6 +2,8 @@ namespace UrlUtils { +bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; } + std::string ensureProtocol(const std::string& url) { if (url.find("://") == std::string::npos) { return "http://" + url; diff --git a/src/util/UrlUtils.h b/src/util/UrlUtils.h index d88ca13c..6428161b 100644 --- a/src/util/UrlUtils.h +++ b/src/util/UrlUtils.h @@ -3,6 +3,11 @@ namespace UrlUtils { +/** + * Check if URL uses HTTPS protocol + */ +bool isHttpsUrl(const std::string& url); + /** * Prepend http:// if no protocol specified (server will redirect to https if needed) */ From a946c83a07d4b5e74ebc91e664945be46ee6f6cd Mon Sep 17 00:00:00 2001 From: swwilshub Date: Wed, 14 Jan 2026 12:11:28 +0000 Subject: [PATCH 27/38] Turbocharge WiFi uploads with WebSocket + watchdog stability (#364) ## Summary * **What is the goal of this PR?** Fix WiFi file transfer stability issues (especially crashes during uploads) and improve upload speed via WebSocket binary protocol. File transfers now don't really crash as much, if they do it recovers and speed has gone form 50KB/s to 300+KB/s. * **What changes are included?** - **WebSocket upload support** - Adds WebSocket binary protocol for file uploads, achieving faster speeds 335 KB/s vs HTTP multipart) - **Watchdog stability fixes** - Adds `esp_task_wdt_reset()` calls throughout upload path to prevent watchdog timeouts during: - File creation (FAT allocation can be slow) - SD card write operations - HTTP header parsing - WebSocket chunk processing - **4KB write buffering** - Batches SD card writes to reduce I/O overhead - **WiFi health monitoring** - Detects WiFi disconnection in STA mode and exits gracefully - **Improved handleClient loop** - 500 iterations with periodic watchdog resets and button checks for responsiveness - **Progress bar improvements** - Fixed jumping/inaccurate progress by capping local progress at 95% until server confirms completion - **Exit button responsiveness** - Button now checked inside the handleClient loop every 64 iterations - **Reduced exit delays** - Decreased shutdown delays from ~850ms to ~140ms **Files changed:** - `platformio.ini` - Added WebSockets library dependency - `CrossPointWebServer.cpp/h` - WebSocket server, upload buffering, watchdog resets - `CrossPointWebServerActivity.cpp` - WiFi monitoring, improved loop, button handling - `FilesPage.html` - WebSocket upload JavaScript with HTTP fallback ## Additional Context - WebSocket uses 4KB chunks with backpressure management to prevent ESP32 buffer overflow - Falls back to HTTP automatically if WebSocket connection fails - The main bottleneck now is SD card write speed (~44% of transfer time), not WiFi - STA mode was more prone to crashes than AP mode due to external network factors; WiFi health monitoring helps detect and handle disconnections gracefully --- ### AI Usage Did you use AI tools to help write this code? _**YES**_ Claude did it ALL, I have no idea what I am doing, but my books transfer fast now. --------- Co-authored-by: Claude --- platformio.ini | 1 + .../network/CrossPointWebServerActivity.cpp | 61 +++- src/network/CrossPointWebServer.cpp | 312 ++++++++++++++++-- src/network/CrossPointWebServer.h | 7 + src/network/html/FilesPage.html | 228 ++++++++++--- 5 files changed, 522 insertions(+), 87 deletions(-) diff --git a/platformio.ini b/platformio.ini index 703b9348..17ec6378 100644 --- a/platformio.ini +++ b/platformio.ini @@ -47,6 +47,7 @@ lib_deps = SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager ArduinoJson @ 7.4.2 QRCode @ 0.0.1 + links2004/WebSockets @ ^2.4.1 [env:default] extends = base diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index dde05614..35ad58ba 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -83,9 +84,8 @@ void CrossPointWebServerActivity::onExit() { dnsServer = nullptr; } - // 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); + // Brief wait for LWIP stack to flush pending packets + delay(50); // Disconnect WiFi gracefully if (isApMode) { @@ -95,11 +95,11 @@ void CrossPointWebServerActivity::onExit() { 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 + delay(30); // 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 + delay(30); // Allow WiFi hardware to power down Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -283,8 +283,28 @@ void CrossPointWebServerActivity::loop() { dnsServer->processNextRequest(); } - // Handle web server requests - call handleClient multiple times per loop - // to improve responsiveness and upload throughput + // STA mode: Monitor WiFi connection health + if (!isApMode && webServer && webServer->isRunning()) { + static unsigned long lastWifiCheck = 0; + if (millis() - lastWifiCheck > 2000) { // Check every 2 seconds + lastWifiCheck = millis(); + const wl_status_t wifiStatus = WiFi.status(); + if (wifiStatus != WL_CONNECTED) { + Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus); + // Show error and exit gracefully + state = WebServerActivityState::SHUTTING_DOWN; + updateRequired = true; + return; + } + // Log weak signal warnings + const int rssi = WiFi.RSSI(); + if (rssi < -75) { + Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi); + } + } + } + + // Handle web server requests - maximize throughput with watchdog safety if (webServer && webServer->isRunning()) { const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; @@ -294,17 +314,32 @@ void CrossPointWebServerActivity::loop() { 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++) { + // Reset watchdog BEFORE processing - HTTP header parsing can be slow + esp_task_wdt_reset(); + + // Process HTTP requests in tight loop for maximum throughput + // More iterations = more data processed per main loop cycle + constexpr int MAX_ITERATIONS = 500; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { webServer->handleClient(); + // Reset watchdog every 32 iterations + if ((i & 0x1F) == 0x1F) { + esp_task_wdt_reset(); + } + // Yield and check for exit button every 64 iterations + if ((i & 0x3F) == 0x3F) { + yield(); + // Check for exit button inside loop for responsiveness + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + } } lastHandleClientTime = millis(); } - // Handle exit on Back button + // Handle exit on Back button (also check outside loop) if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); return; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 8703c2ae..23ba36ba 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -15,6 +16,18 @@ namespace { // Note: Items starting with "." are automatically hidden const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); + +// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) +CrossPointWebServer* wsInstance = nullptr; + +// WebSocket upload state +FsFile wsUploadFile; +String wsUploadFileName; +String wsUploadPath; +size_t wsUploadSize = 0; +size_t wsUploadReceived = 0; +unsigned long wsUploadStartTime = 0; +bool wsUploadInProgress = false; } // namespace // File listing page template - now using generated headers: @@ -86,12 +99,22 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); server->begin(); + + // Start WebSocket server for fast binary uploads + Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort); + wsServer.reset(new WebSocketsServer(wsPort)); + wsInstance = const_cast(this); + wsServer->begin(); + wsServer->onEvent(wsEventCallback); + Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); + running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); // Show the correct IP based on network mode const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); + Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort); Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); } @@ -107,16 +130,29 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap()); - // Add delay to allow any in-flight handleClient() calls to complete - delay(100); - Serial.printf("[%lu] [WEB] Waited 100ms for handleClient to finish\n", millis()); + // Close any in-progress WebSocket upload + if (wsUploadInProgress && wsUploadFile) { + wsUploadFile.close(); + wsUploadInProgress = false; + } + + // Stop WebSocket server + if (wsServer) { + Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis()); + wsServer->close(); + wsServer.reset(); + wsInstance = nullptr; + Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); + } + + // Brief delay to allow any in-flight handleClient() calls to complete + delay(20); server->stop(); Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap()); - // Add another delay before deletion to ensure server->stop() completes - delay(50); - Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis()); + // Brief delay before deletion + delay(10); server.reset(); Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); @@ -148,6 +184,11 @@ void CrossPointWebServer::handleClient() const { } server->handleClient(); + + // Handle WebSocket events + if (wsServer) { + wsServer->loop(); + } } void CrossPointWebServer::handleRoot() const { @@ -229,7 +270,8 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function 0 && uploadFile) { + esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write + const unsigned long writeStart = millis(); + const size_t written = uploadFile.write(uploadBuffer, uploadBufferPos); + totalWriteTime += millis() - writeStart; + writeCount++; + esp_task_wdt_reset(); // Reset watchdog after SD write + + if (written != uploadBufferPos) { + Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), uploadBufferPos, + written); + uploadBufferPos = 0; + return false; + } + uploadBufferPos = 0; + } + return true; +} + void CrossPointWebServer::handleUpload() const { - static unsigned long lastWriteTime = 0; - static unsigned long uploadStartTime = 0; static size_t lastLoggedSize = 0; + // Reset watchdog at start of every upload callback - HTTP parsing can be slow + esp_task_wdt_reset(); + // Safety check: ensure server is still valid if (!running || !server) { Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis()); @@ -315,13 +390,18 @@ void CrossPointWebServer::handleUpload() const { const HTTPUpload& upload = server->upload(); if (upload.status == UPLOAD_FILE_START) { + // Reset watchdog - this is the critical 1% crash point + esp_task_wdt_reset(); + uploadFileName = upload.filename; uploadSize = 0; uploadSuccess = false; uploadError = ""; uploadStartTime = millis(); - lastWriteTime = millis(); lastLoggedSize = 0; + uploadBufferPos = 0; + totalWriteTime = 0; + writeCount = 0; // Get upload path from query parameter (defaults to root if not specified) // Note: We use query parameter instead of form data because multipart form @@ -348,60 +428,82 @@ void CrossPointWebServer::handleUpload() const { if (!filePath.endsWith("/")) filePath += "/"; filePath += uploadFileName; - // Check if file already exists + // Check if file already exists - SD operations can be slow + esp_task_wdt_reset(); if (SdMan.exists(filePath.c_str())) { Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str()); + esp_task_wdt_reset(); SdMan.remove(filePath.c_str()); } - // Open file for writing + // Open file for writing - this can be slow due to FAT cluster allocation + esp_task_wdt_reset(); if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) { uploadError = "Failed to create file on SD card"; Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); return; } + esp_task_wdt_reset(); Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); } else if (upload.status == UPLOAD_FILE_WRITE) { if (uploadFile && uploadError.isEmpty()) { - const unsigned long writeStartTime = millis(); - const size_t written = uploadFile.write(upload.buf, upload.currentSize); - const unsigned long writeEndTime = millis(); - const unsigned long writeDuration = writeEndTime - writeStartTime; + // Buffer incoming data and flush when buffer is full + // This reduces SD card write operations and improves throughput + const uint8_t* data = upload.buf; + size_t remaining = upload.currentSize; - if (written != upload.currentSize) { - uploadError = "Failed to write to SD card - disk may be full"; - uploadFile.close(); - Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize, - written); - } else { - uploadSize += written; + while (remaining > 0) { + const size_t space = UPLOAD_BUFFER_SIZE - uploadBufferPos; + const size_t toCopy = (remaining < space) ? remaining : space; - // Log progress every 50KB or if write took >100ms - if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) { - const unsigned long timeSinceStart = millis() - uploadStartTime; - const unsigned long timeSinceLastWrite = millis() - lastWriteTime; - const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); + memcpy(uploadBuffer + uploadBufferPos, data, toCopy); + uploadBufferPos += toCopy; + data += toCopy; + remaining -= toCopy; - Serial.printf( - "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu " - "ms\n", - millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite); - lastLoggedSize = uploadSize; + // Flush buffer when full + if (uploadBufferPos >= UPLOAD_BUFFER_SIZE) { + if (!flushUploadBuffer()) { + uploadError = "Failed to write to SD card - disk may be full"; + uploadFile.close(); + return; + } } - lastWriteTime = millis(); + } + + uploadSize += upload.currentSize; + + // Log progress every 100KB + if (uploadSize - lastLoggedSize >= 102400) { + const unsigned long elapsed = millis() - uploadStartTime; + const float kbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; + Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), uploadSize, + uploadSize / 1024.0, kbps, writeCount); + lastLoggedSize = uploadSize; } } } else if (upload.status == UPLOAD_FILE_END) { if (uploadFile) { + // Flush any remaining buffered data + if (!flushUploadBuffer()) { + uploadError = "Failed to write final data to SD card"; + } uploadFile.close(); if (uploadError.isEmpty()) { uploadSuccess = true; - Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize); + const unsigned long elapsed = millis() - uploadStartTime; + const float avgKbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; + const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0; + Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(), + 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); } } } else if (upload.status == UPLOAD_FILE_ABORTED) { + uploadBufferPos = 0; // Discard buffered data if (uploadFile) { uploadFile.close(); // Try to delete the incomplete file @@ -555,3 +657,143 @@ void CrossPointWebServer::handleDelete() const { server->send(500, "text/plain", "Failed to delete item"); } } + +// WebSocket callback trampoline +void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + if (wsInstance) { + wsInstance->onWebSocketEvent(num, type, payload, length); + } +} + +// WebSocket event handler for fast binary uploads +// Protocol: +// 1. Client sends TEXT message: "START:::" +// 2. Client sends BINARY messages with file data chunks +// 3. Server sends TEXT "PROGRESS::" after each chunk +// 4. Server sends TEXT "DONE" or "ERROR:" when complete +void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { + switch (type) { + case WStype_DISCONNECTED: + Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num); + // Clean up any in-progress upload + if (wsUploadInProgress && wsUploadFile) { + wsUploadFile.close(); + // Delete incomplete file + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + SdMan.remove(filePath.c_str()); + Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str()); + } + wsUploadInProgress = false; + break; + + case WStype_CONNECTED: { + Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num); + break; + } + + case WStype_TEXT: { + // Parse control messages + String msg = String((char*)payload); + Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str()); + + if (msg.startsWith("START:")) { + // Parse: START::: + int firstColon = msg.indexOf(':', 6); + int secondColon = msg.indexOf(':', firstColon + 1); + + if (firstColon > 0 && secondColon > 0) { + wsUploadFileName = msg.substring(6, firstColon); + wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt(); + wsUploadPath = msg.substring(secondColon + 1); + wsUploadReceived = 0; + wsUploadStartTime = millis(); + + // Ensure path is valid + if (!wsUploadPath.startsWith("/")) wsUploadPath = "/" + wsUploadPath; + if (wsUploadPath.length() > 1 && wsUploadPath.endsWith("/")) { + wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1); + } + + // Build file path + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + + Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(), + wsUploadSize, filePath.c_str()); + + // Check if file exists and remove it + esp_task_wdt_reset(); + if (SdMan.exists(filePath.c_str())) { + SdMan.remove(filePath.c_str()); + } + + // Open file for writing + esp_task_wdt_reset(); + if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) { + wsServer->sendTXT(num, "ERROR:Failed to create file"); + wsUploadInProgress = false; + return; + } + esp_task_wdt_reset(); + + wsUploadInProgress = true; + wsServer->sendTXT(num, "READY"); + } else { + wsServer->sendTXT(num, "ERROR:Invalid START format"); + } + } + break; + } + + case WStype_BIN: { + if (!wsUploadInProgress || !wsUploadFile) { + wsServer->sendTXT(num, "ERROR:No upload in progress"); + return; + } + + // Write binary data directly to file + esp_task_wdt_reset(); + size_t written = wsUploadFile.write(payload, length); + esp_task_wdt_reset(); + + if (written != length) { + wsUploadFile.close(); + wsUploadInProgress = false; + wsServer->sendTXT(num, "ERROR:Write failed - disk full?"); + return; + } + + wsUploadReceived += written; + + // Send progress update (every 64KB or at end) + static size_t lastProgressSent = 0; + if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) { + String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize); + wsServer->sendTXT(num, progress); + lastProgressSent = wsUploadReceived; + } + + // Check if upload complete + if (wsUploadReceived >= wsUploadSize) { + wsUploadFile.close(); + wsUploadInProgress = false; + + unsigned long elapsed = millis() - wsUploadStartTime; + float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; + + Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), + wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); + + wsServer->sendTXT(num, "DONE"); + lastProgressSent = 0; + } + break; + } + + default: + break; + } +} diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 1be07b4a..ecc2d3d2 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include @@ -34,9 +35,15 @@ class CrossPointWebServer { private: std::unique_ptr server = nullptr; + std::unique_ptr wsServer = nullptr; bool running = false; bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; + uint16_t wsPort = 81; // WebSocket port + + // WebSocket upload state + void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); + static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length); // File scanning void scanFiles(const char* path, const std::function& callback) const; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index 08c0a0be..bfdbe3cc 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -816,6 +816,151 @@ } let failedUploadsGlobal = []; +let wsConnection = null; +const WS_PORT = 81; +const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability + +// Get WebSocket URL based on current page location +function getWsUrl() { + const host = window.location.hostname; + return `ws://${host}:${WS_PORT}/`; +} + +// Upload file via WebSocket (faster, binary protocol) +function uploadFileWebSocket(file, onProgress, onComplete, onError) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(getWsUrl()); + let uploadStarted = false; + let sendingChunks = false; + + ws.binaryType = 'arraybuffer'; + + ws.onopen = function() { + console.log('[WS] Connected, starting upload:', file.name); + // Send start message: START::: + ws.send(`START:${file.name}:${file.size}:${currentPath}`); + }; + + ws.onmessage = async function(event) { + const msg = event.data; + console.log('[WS] Message:', msg); + + if (msg === 'READY') { + uploadStarted = true; + sendingChunks = true; + + // Small delay to let connection stabilize + await new Promise(r => setTimeout(r, 50)); + + try { + // Send file in chunks + const totalSize = file.size; + let offset = 0; + + while (offset < totalSize && ws.readyState === WebSocket.OPEN) { + const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset); + const chunk = file.slice(offset, offset + chunkSize); + const buffer = await chunk.arrayBuffer(); + + // Wait for buffer to clear - more aggressive backpressure + while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) { + await new Promise(r => setTimeout(r, 5)); + } + + if (ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket closed during upload'); + } + + ws.send(buffer); + offset += chunkSize; + + // Update local progress - cap at 95% since server still needs to write + // Final 100% shown when server confirms DONE + if (onProgress) { + const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95)); + onProgress(cappedOffset, totalSize); + } + } + + sendingChunks = false; + console.log('[WS] All chunks sent, waiting for DONE'); + } catch (err) { + console.error('[WS] Error sending chunks:', err); + sendingChunks = false; + ws.close(); + reject(err); + } + } else if (msg.startsWith('PROGRESS:')) { + // Server confirmed progress - log for debugging but don't update UI + // (local progress is smoother, server progress causes jumping) + console.log('[WS] Server progress:', msg); + } else if (msg === 'DONE') { + // Show 100% when server confirms completion + if (onProgress) onProgress(file.size, file.size); + ws.close(); + if (onComplete) onComplete(); + resolve(); + } else if (msg.startsWith('ERROR:')) { + const error = msg.substring(6); + ws.close(); + if (onError) onError(error); + reject(new Error(error)); + } + }; + + ws.onerror = function(event) { + console.error('[WS] Error:', event); + if (!uploadStarted) { + reject(new Error('WebSocket connection failed')); + } else if (!sendingChunks) { + reject(new Error('WebSocket error during upload')); + } + }; + + ws.onclose = function(event) { + console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason); + if (sendingChunks) { + reject(new Error('WebSocket closed unexpectedly')); + } + }; + }); +} + +// Upload file via HTTP (fallback method) +function uploadFileHTTP(file, onProgress, onComplete, onError) { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); + + xhr.upload.onprogress = function(e) { + if (e.lengthComputable && onProgress) { + onProgress(e.loaded, e.total); + } + }; + + xhr.onload = function() { + if (xhr.status === 200) { + if (onComplete) onComplete(); + resolve(); + } else { + const error = xhr.responseText || 'Upload failed'; + if (onError) onError(error); + reject(new Error(error)); + } + }; + + xhr.onerror = function() { + const error = 'Network error'; + if (onError) onError(error); + reject(new Error(error)); + }; + + xhr.send(formData); + }); +} function uploadFile() { const fileInput = document.getElementById('fileInput'); @@ -836,8 +981,9 @@ function uploadFile() { let currentIndex = 0; const failedFiles = []; + let useWebSocket = true; // Try WebSocket first - function uploadNextFile() { + async function uploadNextFile() { if (currentIndex >= files.length) { // All files processed - show summary if (failedFiles.length === 0) { @@ -845,67 +991,71 @@ function uploadFile() { progressText.textContent = 'All uploads complete!'; setTimeout(() => { closeUploadModal(); - hydrate(); // Refresh file list instead of reloading + hydrate(); }, 1000); } else { progressFill.style.backgroundColor = '#e74c3c'; const failedList = failedFiles.map(f => f.name).join(', '); progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`; - - // Store failed files globally and show banner failedUploadsGlobal = failedFiles; - setTimeout(() => { closeUploadModal(); showFailedUploadsBanner(); - hydrate(); // Refresh file list to show successfully uploaded files + hydrate(); }, 2000); } return; } const file = files[currentIndex]; - const formData = new FormData(); - formData.append('file', file); - - const xhr = new XMLHttpRequest(); - // Include path as query parameter since multipart form data doesn't make - // form fields available until after file upload completes - xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); - progressFill.style.width = '0%'; - progressFill.style.backgroundColor = '#4caf50'; - progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`; + progressFill.style.backgroundColor = '#27ae60'; + const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`; - xhr.upload.onprogress = function (e) { - if (e.lengthComputable) { - const percent = Math.round((e.loaded / e.total) * 100); - progressFill.style.width = percent + '%'; - progressText.textContent = - `Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`; - } + const onProgress = (loaded, total) => { + const percent = Math.round((loaded / total) * 100); + progressFill.style.width = percent + '%'; + const speed = ''; // Could calculate speed here + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`; }; - xhr.onload = function () { - if (xhr.status === 200) { - currentIndex++; - uploadNextFile(); // upload next file - } else { - // Track failure and continue with next file - failedFiles.push({ name: file.name, error: xhr.responseText, file: file }); - currentIndex++; - uploadNextFile(); - } - }; - - xhr.onerror = function () { - // Track network error and continue with next file - failedFiles.push({ name: file.name, error: 'network error', file: file }); + const onComplete = () => { currentIndex++; uploadNextFile(); }; - xhr.send(formData); + const onError = (error) => { + failedFiles.push({ name: file.name, error: error, file: file }); + currentIndex++; + uploadNextFile(); + }; + + try { + if (useWebSocket) { + await uploadFileWebSocket(file, onProgress, null, null); + onComplete(); + } else { + await uploadFileHTTP(file, onProgress, null, null); + onComplete(); + } + } catch (error) { + console.error('Upload error:', error); + if (useWebSocket && error.message === 'WebSocket connection failed') { + // Fall back to HTTP for all subsequent uploads + console.log('WebSocket failed, falling back to HTTP'); + useWebSocket = false; + // Retry this file with HTTP + try { + await uploadFileHTTP(file, onProgress, null, null); + onComplete(); + } catch (httpError) { + onError(httpError.message); + } + } else { + onError(error.message); + } + } } uploadNextFile(); From 3ee10b31ab25875cecab4b852af206aef54d98a7 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 14 Jan 2026 23:14:00 +1100 Subject: [PATCH 28/38] Update OTA updater URL --- src/network/OtaUpdater.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp index 4afd9de4..d831af0a 100644 --- a/src/network/OtaUpdater.cpp +++ b/src/network/OtaUpdater.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest"; +constexpr char latestReleaseUrl[] = "https://api.github.com/repos/crosspoint-reader/crosspoint-reader/releases/latest"; } OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { From 489220832f824f9ef62261c217bd28aedbd3cf7d Mon Sep 17 00:00:00 2001 From: Maeve Andrews <37351465+maeveynot@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:21:48 -0600 Subject: [PATCH 29/38] Only indent paragraphs for justify/left-align (#367) Currently, when Extra Paragraph Spacing is off, an em-space is added to the beginning of each ParsedText even for blocks like headers that are centered. This whitespace makes the centering slightly off. Change the calculation here to only add the em-space for left/justified text. Co-authored-by: Maeve Andrews --- lib/Epub/Epub/ParsedText.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index f9c0326f..3c37e31b 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -43,7 +43,7 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere wordWidths.reserve(totalWordCount); // add em-space at the beginning of first word in paragraph to indent - if (!extraParagraphSpacing) { + if ((style == TextBlock::JUSTIFIED || style == TextBlock::LEFT_ALIGN) && !extraParagraphSpacing) { std::string& first_word = words.front(); first_word.insert(0, "\xe2\x80\x83"); } From e517945aaa3a15ba479ab283457127b1cdbf8775 Mon Sep 17 00:00:00 2001 From: Maeve Andrews <37351465+maeveynot@users.noreply.github.com> Date: Wed, 14 Jan 2026 06:23:03 -0600 Subject: [PATCH 30/38] Add a bullet to the beginning of any
  • (#368) Currently there is no visual indication whatsoever if something is in a list. An `
  • ` is essentially just another paragraph. As a partial remedy for this, add a bullet character to the beginning of `
  • ` text blocks so that the user can see that they're list items. This is incomplete in that an `
      ` should also have a counter so that its list items can get numbers instead of bullets (right now I don't think we track if we're in a `
        ` or an `
          ` at all), but it's strictly better than the current situation. Co-authored-by: Maeve Andrews --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index b9305b1e..e3e08310 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -97,6 +97,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->startNewTextBlock(self->currentTextBlock->getStyle()); } else { self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment); + if (strcmp(name, "li") == 0) { + self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR); + } } } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); From 56ec3dfb6d95dcb7b523478ffc7eac30f3592ad5 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Thu, 15 Jan 2026 01:01:47 +1100 Subject: [PATCH 31/38] chore: Cut release 0.14.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 17ec6378..cbe47fe9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,7 +2,7 @@ default_envs = default [crosspoint] -version = 0.13.1 +version = 0.14.0 [base] platform = espressif32 @ 6.12.0 From d45f355e872bf564485555d4d23ada0c6cb37948 Mon Sep 17 00:00:00 2001 From: efenner Date: Thu, 15 Jan 2026 05:14:59 -0700 Subject: [PATCH 32/38] feat: Add EPUB table omitted placeholder (#372) ## Summary * **What is the goal of this PR?**: Fix the bug I reported in https://github.com/daveallie/crosspoint-reader/issues/292 * **What changes are included?**: Instead of silently dropping table content in EPUBs., replace with an italicized '[Table omitted]' message where tables appear. ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- ### 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: Evan Fenner Co-authored-by: Warp --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index e3e08310..e540abcc 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -25,7 +25,7 @@ constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); const char* IMAGE_TAGS[] = {"img"}; constexpr int NUM_IMAGE_TAGS = sizeof(IMAGE_TAGS) / sizeof(IMAGE_TAGS[0]); -const char* SKIP_TAGS[] = {"head", "table"}; +const char* SKIP_TAGS[] = {"head"}; constexpr int NUM_SKIP_TAGS = sizeof(SKIP_TAGS) / sizeof(SKIP_TAGS[0]); bool isWhitespace(const char c) { return c == ' ' || c == '\r' || c == '\n' || c == '\t'; } @@ -63,6 +63,20 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* return; } + // Special handling for tables - show placeholder text instead of dropping silently + if (strcmp(name, "table") == 0) { + // Add placeholder text + self->startNewTextBlock(TextBlock::CENTER_ALIGN); + if (self->currentTextBlock) { + self->currentTextBlock->addWord("[Table omitted]", EpdFontFamily::ITALIC); + } + + // Skip table contents + self->skipUntilDepth = self->depth; + self->depth += 1; + return; + } + if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { // TODO: Start processing image tags self->skipUntilDepth = self->depth; From eb84bcee7cd9c0347810c46f23b2f143c7106ad3 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Thu, 15 Jan 2026 02:22:38 +1100 Subject: [PATCH 33/38] chore: Pin links2004/WebSockets version --- platformio.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/platformio.ini b/platformio.ini index cbe47fe9..ef27ffd5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,9 +45,9 @@ lib_deps = InputManager=symlink://open-x4-sdk/libs/hardware/InputManager EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager - ArduinoJson @ 7.4.2 - QRCode @ 0.0.1 - links2004/WebSockets @ ^2.4.1 + bblanchon/ArduinoJson @ 7.4.2 + ricmoo/QRCode @ 0.0.1 + links2004/WebSockets @ 2.7.3 [env:default] extends = base From c1c94c0112153ece7c96656eeea145f0419c8d60 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Thu, 15 Jan 2026 13:21:46 +0100 Subject: [PATCH 34/38] Feature: Show img alt text (#168) Let's start small by showing the ALT text of IMG. This is rudimentary, but avoids those otherwise completely blank chapters. I feel we will need this even when we can render images if that rendering takes >1s - I would then prefer rendering optional and showing the ALT text first. --- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index e540abcc..acddd81d 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -79,9 +79,26 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (matches(name, IMAGE_TAGS, NUM_IMAGE_TAGS)) { // TODO: Start processing image tags - self->skipUntilDepth = self->depth; - self->depth += 1; - return; + std::string alt; + if (atts != nullptr) { + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], "alt") == 0) { + alt = "[Image: " + std::string(atts[i + 1]) + "]"; + } + } + Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); + + self->startNewTextBlock(TextBlock::CENTER_ALIGN); + self->italicUntilDepth = min(self->italicUntilDepth, self->depth); + self->depth += 1; + self->characterData(userData, alt.c_str(), alt.length()); + + } else { + // Skip for now + self->skipUntilDepth = self->depth; + self->depth += 1; + return; + } } if (matches(name, SKIP_TAGS, NUM_SKIP_TAGS)) { From c98ba142e8a7028e0d07ef52ba0201c60464c121 Mon Sep 17 00:00:00 2001 From: Maeve Andrews <37351465+maeveynot@users.noreply.github.com> Date: Thu, 15 Jan 2026 06:23:36 -0600 Subject: [PATCH 35/38] fix: draw button hints correctly if orientation is not portrait (#363) ~~Quick~~ fix for https://github.com/daveallie/crosspoint-reader/issues/362 (this just applies to the chapter selection menu:) ~~If the orientation is portrait, hints as we know them make sense to draw. If the orientation is inverted, we'd have to change the order of the labels (along with everything's position), and if it's one of the landscape choices, we'd have to render the text and buttons vertically. All those other cases will be more complicated.~~ ~~Punt on this for now by only rendering if portrait.~~ Update: this now draws the hints at the physical button position no matter what the orientation is, by temporarily changing orientation to portrait. --------- Co-authored-by: Maeve Andrews --- lib/GfxRenderer/GfxRenderer.cpp | 8 +++++++- lib/GfxRenderer/GfxRenderer.h | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 28022e90..7072fed8 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -468,7 +468,10 @@ int GfxRenderer::getLineHeight(const int fontId) const { } void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, - const char* btn4) const { + const char* btn4) { + const Orientation orig_orientation = getOrientation(); + setOrientation(Orientation::Portrait); + const int pageHeight = getScreenHeight(); constexpr int buttonWidth = 106; constexpr int buttonHeight = 40; @@ -481,12 +484,15 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char // Only draw if the label is non-empty if (labels[i] != nullptr && labels[i][0] != '\0') { const int x = buttonPositions[i]; + fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); const int textWidth = getTextWidth(fontId, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]); } } + + setOrientation(orig_orientation); } void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 9d341bcc..b1fea69b 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -84,7 +84,7 @@ class GfxRenderer { EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; // UI Components - void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; + void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4); void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const; private: From 5a55fa1c6e600e72ca6faf6c1d1d759cab10de52 Mon Sep 17 00:00:00 2001 From: Nathan James <64075030+Nathanjms@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:25:18 +0000 Subject: [PATCH 36/38] fix: also apply longPressChapterSkip setting to xtc reader (#378) ## Summary * This builds upon the helpful PR https://github.com/crosspoint-reader/crosspoint-reader/pull/341, and adds support for the setting to also apply to the XTC reader, which I believed has just been missed and was not intentionally left out. * XTC does not have chapter support yet, but it does skip 10 pages when long-pressed, and so I think this is useful. --- ### AI Usage Did you use AI tools to help write this code? No --- src/activities/reader/XtcReaderActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 9cdf5c97..a211e61c 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -127,7 +127,7 @@ void XtcReaderActivity::loop() { return; } - const bool skipPages = mappedInput.getHeldTime() > skipPageMs; + const bool skipPages = SETTINGS.longPressChapterSkip && mappedInput.getHeldTime() > skipPageMs; const int skipAmount = skipPages ? 10 : 1; if (prevReleased) { From 4eef2b57937cba52cbf111cd036303a90a988c70 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Thu, 15 Jan 2026 07:26:39 -0500 Subject: [PATCH 37/38] feat: Add MAC address display to WiFi Networks screen (#381) ## Summary * Implements #380, allowing the user to see the device's MAC address in order to register on wifi networks ## Additional Context * Although @markatlnk suggested showing on the settings screen, I implemented display at the bottom of the WiFi Networks selection screen (accessed via "File Transfer" > "Join a Network") since I think it makes more sense there. * Tested on my own device ![IMG_2873](https://github.com/user-attachments/assets/b82a20dc-41a0-4b21-81f1-20876aa2c6b0) --- ### 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? _**YES**_ --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/activities/network/WifiSelectionActivity.cpp | 11 +++++++++++ src/activities/network/WifiSelectionActivity.h | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index a8653f43..07d44418 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -37,6 +37,14 @@ void WifiSelectionActivity::onEnter() { savePromptSelection = 0; forgetPromptSelection = 0; + // Cache MAC address for display + uint8_t mac[6]; + WiFi.macAddress(mac); + char macStr[32]; + snprintf(macStr, sizeof(macStr), "MAC address: %02x-%02x-%02x-%02x-%02x-%02x", mac[0], mac[1], mac[2], mac[3], mac[4], + mac[5]); + cachedMacAddress = std::string(macStr); + // Trigger first update to show scanning message updateRequired = true; @@ -572,6 +580,9 @@ void WifiSelectionActivity::renderNetworkList() const { renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 90, countStr); } + // Show MAC address above the network count and legend + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 105, cachedMacAddress.c_str()); + // Draw help text renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); const auto labels = mappedInput.mapLabels("« Back", "Connect", "", ""); diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index 33ea26b1..0a7e7166 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -62,6 +62,9 @@ class WifiSelectionActivity final : public ActivityWithSubactivity { // Password to potentially save (from keyboard or saved credentials) std::string enteredPassword; + // Cached MAC address string for display + std::string cachedMacAddress; + // Whether network was connected using a saved password (skip save prompt) bool usedSavedPassword = false; From 21277e03eb09a0fe7f28aae7108c600c39e219c8 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Thu, 15 Jan 2026 07:27:17 -0500 Subject: [PATCH 38/38] docs: Update User Guide to reflect release 0.14.0 (#376) --- USER_GUIDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index b411140e..0c852691 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -96,6 +96,10 @@ The Settings screen allows you to configure the device's behavior. There are a f - Left, Right, Back, Confirm - Left, Back, Confirm, Right - **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. +- **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skip to the next/previous chapter. + - "Chapter Skip" (default) - Long-pressing skips to next/previous chapter + - "Page Scroll" - Long-pressing scrolls a page up/down +- Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. - **Reader Font Family**: Choose the font used for reading: - "Bookerly" (default) - Amazon's reading font - "Noto Sans" - Google's sans-serif font @@ -144,6 +148,9 @@ If the **Short Power Button Click** setting is set to "Page Turn", you can also * **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release. * **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. +This feature can be disabled in **[Settings](#35-settings)** to help avoid changing chapters by mistake. + + ### System Navigation * **Return to Book Selection:** Press **Back** to close the book and return to the **[Book Selection](#32-book-selection)** screen. * **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.