From cc74039caba28f8a06e17988bb2d967343fd0fe1 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Wed, 21 Jan 2026 13:27:41 +0100 Subject: [PATCH 1/3] fix: Skip negative screen coordinates only after we read the bitmap row. (#431) Otherwise, we don't crop properly. Fixes #430 ### AI Usage Did you use AI tools to help write this code? _**< NO >**_ --- lib/GfxRenderer/GfxRenderer.cpp | 7 ++++--- src/activities/boot_sleep/SleepActivity.cpp | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index e5b25be..08420bf 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -201,9 +201,6 @@ 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); @@ -212,6 +209,10 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con return; } + if (screenY < 0) { + continue; + } + if (bmpY < cropPixY) { // Skip the row if it's outside the crop area continue; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index bf2b585..c0d6844 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -167,7 +167,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY); ratio = static_cast(bitmap.getWidth()) / ((1.0f - cropY) * static_cast(bitmap.getHeight())); } - x = std::round((pageWidth - pageHeight * ratio) / 2); + x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); y = 0; Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x); } From 838993259d1326b580a72434af516efa1a8a68ed Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Wed, 21 Jan 2026 17:35:23 +0500 Subject: [PATCH 2/3] fix: hard reset via RTS pin after flashing firmware (#437) ## Summary * Disables going to sleep after uploading new firmware * Makes developer experience easier --- ### 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 >**_ --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main.cpp | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 1a3ee60..c0222e0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -276,13 +276,30 @@ void setupDisplayAndFonts() { Serial.printf("[%lu] [ ] Fonts setup\n", millis()); } +bool isUsbConnected() { + // U0RXD/GPIO20 reads HIGH when USB is connected + return digitalRead(UART0_RXD) == HIGH; +} + +bool isWakeupAfterFlashing() { + const auto wakeupCause = esp_sleep_get_wakeup_cause(); + const auto resetReason = esp_reset_reason(); + + return isUsbConnected() && (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_UNKNOWN); +} + void setup() { t1 = millis(); // Only start serial if USB connected pinMode(UART0_RXD, INPUT); - if (digitalRead(UART0_RXD) == HIGH) { + if (isUsbConnected()) { Serial.begin(115200); + // Wait up to 3 seconds for Serial to be ready to catch early logs + unsigned long start = millis(); + while (!Serial && (millis() - start) < 3000) { + delay(10); + } } inputManager.begin(); @@ -305,8 +322,10 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); - // verify power button press duration after we've read settings. - verifyWakeupLongPress(); + if (!isWakeupAfterFlashing()) { + // For normal wakeups (not immediately after flashing), verify long press + verifyWakeupLongPress(); + } // First serial output only here to avoid timing inconsistencies for power button press duration verification Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); From d399afb53d790548be89fb45b0dc2f686a1bba0f Mon Sep 17 00:00:00 2001 From: Logan Garbarini Date: Wed, 21 Jan 2026 05:06:07 -0800 Subject: [PATCH 3/3] feat: invalidate cache on web uploads and opds downloads and add Clear Cache action (#393) ## Summary When uploading or downloading an updated ebook from SD/WebUI/OPDS with same the filename the `.crosspoint` cache is not cleared. This can lead to issues with the Table of Contents and hangs when switching between chapters. I encountered this issue in two places: - When I need to do further ePub cleaning using Calibre after I load an ePub and find that some of its formatting should be cleaned up. When I reprocess the same book and want to place it back in the same location I need a way to invalidate the cache. - When syncing RSS feed generated epubs. I generate news ePubs with filenames like `news-outlet.epub` and so every day when I fetch new news the crosspoint cache needs to be cleared to load that file. This change offers the following features: - On web uploads, if the file already exists, the cache for that file is cleared - On OPDS downloads, if the file already exists, the cache for that file is cleared - There's now an action for `Clear Cache` in the Settings page which can clear the cache for all books Addresses https://github.com/crosspoint-reader/crosspoint-reader/issues/281 --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? PARTIALLY --------- Co-authored-by: Dave Allie --- .../browser/OpdsBookBrowserActivity.cpp | 7 + .../settings/CategorySettingsActivity.cpp | 9 + .../settings/ClearCacheActivity.cpp | 178 ++++++++++++++++++ src/activities/settings/ClearCacheActivity.h | 37 ++++ src/activities/settings/SettingsActivity.cpp | 4 +- src/network/CrossPointWebServer.cpp | 23 +++ src/util/StringUtils.cpp | 12 ++ src/util/StringUtils.h | 3 + 8 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/activities/settings/ClearCacheActivity.cpp create mode 100644 src/activities/settings/ClearCacheActivity.h diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 4e0a08d..677f9ca 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -1,5 +1,6 @@ #include "OpdsBookBrowserActivity.h" +#include #include #include #include @@ -355,6 +356,12 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { if (result == HttpDownloader::OK) { Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str()); + + // Invalidate any existing cache for this file to prevent stale metadata issues + Epub epub(filename, "/.crosspoint"); + epub.clearCache(); + Serial.printf("[%lu] [OPDS] Cleared cache for: %s\n", millis(), filename.c_str()); + state = BrowserState::BROWSING; updateRequired = true; } else { diff --git a/src/activities/settings/CategorySettingsActivity.cpp b/src/activities/settings/CategorySettingsActivity.cpp index 77ff0b0..a6182b5 100644 --- a/src/activities/settings/CategorySettingsActivity.cpp +++ b/src/activities/settings/CategorySettingsActivity.cpp @@ -6,6 +6,7 @@ #include #include "CalibreSettingsActivity.h" +#include "ClearCacheActivity.h" #include "CrossPointSettings.h" #include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" @@ -110,6 +111,14 @@ void CategorySettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Clear Cache") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); } else if (strcmp(setting.name, "Check for updates") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); diff --git a/src/activities/settings/ClearCacheActivity.cpp b/src/activities/settings/ClearCacheActivity.cpp new file mode 100644 index 0000000..1e10c14 --- /dev/null +++ b/src/activities/settings/ClearCacheActivity.cpp @@ -0,0 +1,178 @@ +#include "ClearCacheActivity.h" + +#include +#include +#include + +#include "MappedInputManager.h" +#include "fontIds.h" + +void ClearCacheActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void ClearCacheActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + state = WARNING; + updateRequired = true; + + xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void ClearCacheActivity::onExit() { + ActivityWithSubactivity::onExit(); + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void ClearCacheActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void ClearCacheActivity::render() { + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Clear Cache", true, EpdFontFamily::BOLD); + + if (state == WARNING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 60, "This will clear all cached book data.", true); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 30, "All reading progress will be lost!", true, + EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Books will need to be re-indexed", true); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, "when opened again.", true); + + const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == CLEARING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Clearing cache...", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (state == SUCCESS) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Cache Cleared", true, EpdFontFamily::BOLD); + String resultText = String(clearedCount) + " items removed"; + if (failedCount > 0) { + resultText += ", " + String(failedCount) + " failed"; + } + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, resultText.c_str()); + + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == FAILED) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Failed to clear cache", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Check serial output for details"); + + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } +} + +void ClearCacheActivity::clearCache() { + Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis()); + + // Open .crosspoint directory + auto root = SdMan.open("/.crosspoint"); + if (!root || !root.isDirectory()) { + Serial.printf("[%lu] [CLEAR_CACHE] Failed to open cache directory\n", millis()); + if (root) root.close(); + state = FAILED; + updateRequired = true; + return; + } + + clearedCount = 0; + failedCount = 0; + char name[128]; + + // Iterate through all entries in the directory + for (auto file = root.openNextFile(); file; file = root.openNextFile()) { + file.getName(name, sizeof(name)); + String itemName(name); + + // Only delete directories starting with epub_ or xtc_ + if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) { + String fullPath = "/.crosspoint/" + itemName; + Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str()); + + file.close(); // Close before attempting to delete + + if (SdMan.removeDir(fullPath.c_str())) { + clearedCount++; + } else { + Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str()); + failedCount++; + } + } else { + file.close(); + } + } + root.close(); + + Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount); + + state = SUCCESS; + updateRequired = true; +} + +void ClearCacheActivity::loop() { + if (state == WARNING) { + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = CLEARING; + xSemaphoreGive(renderingMutex); + updateRequired = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + + clearCache(); + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + Serial.printf("[%lu] [CLEAR_CACHE] User cancelled\n", millis()); + goBack(); + } + return; + } + + if (state == SUCCESS || state == FAILED) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + goBack(); + } + return; + } +} diff --git a/src/activities/settings/ClearCacheActivity.h b/src/activities/settings/ClearCacheActivity.h new file mode 100644 index 0000000..31795a9 --- /dev/null +++ b/src/activities/settings/ClearCacheActivity.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#include + +#include "activities/ActivityWithSubactivity.h" + +class ClearCacheActivity final : public ActivityWithSubactivity { + public: + explicit ClearCacheActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& goBack) + : ActivityWithSubactivity("ClearCache", renderer, mappedInput), goBack(goBack) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + enum State { WARNING, CLEARING, SUCCESS, FAILED }; + + State state = WARNING; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + const std::function goBack; + + int clearedCount = 0; + int failedCount = 0; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + void clearCache(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 2b58932..943fdb4 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -44,11 +44,11 @@ const SettingInfo controlsSettings[controlsSettingsCount] = { SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})}; -constexpr int systemSettingsCount = 4; +constexpr int systemSettingsCount = 5; const SettingInfo systemSettings[systemSettingsCount] = { SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, {"1 min", "5 min", "10 min", "15 min", "30 min"}), - SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), + SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Clear Cache"), SettingInfo::Action("Check for updates")}; } // namespace diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 23ba36b..90dfed7 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1,6 +1,7 @@ #include "CrossPointWebServer.h" #include +#include #include #include #include @@ -10,6 +11,7 @@ #include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" +#include "util/StringUtils.h" namespace { // Folders/files to hide from the web interface file browser @@ -28,6 +30,15 @@ size_t wsUploadSize = 0; size_t wsUploadReceived = 0; unsigned long wsUploadStartTime = 0; bool wsUploadInProgress = false; + +// Helper function to clear epub cache after upload +void clearEpubCacheIfNeeded(const String& filePath) { + // Only clear cache for .epub files + if (StringUtils::checkFileExtension(filePath, ".epub")) { + Epub(filePath.c_str(), "/.crosspoint").clearCache(); + Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str()); + } +} } // namespace // File listing page template - now using generated headers: @@ -500,6 +511,12 @@ void CrossPointWebServer::handleUpload() const { uploadFileName.c_str(), uploadSize, elapsed, avgKbps); Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), writeCount, totalWriteTime, writePercent); + + // Clear epub cache to prevent stale metadata issues when overwriting files + String filePath = uploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += uploadFileName; + clearEpubCacheIfNeeded(filePath); } } } else if (upload.status == UPLOAD_FILE_ABORTED) { @@ -787,6 +804,12 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); + // Clear epub cache to prevent stale metadata issues when overwriting files + String filePath = wsUploadPath; + if (!filePath.endsWith("/")) filePath += "/"; + filePath += wsUploadFileName; + clearEpubCacheIfNeeded(filePath); + wsServer->sendTXT(num, "DONE"); lastProgressSent = 0; } diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index e56bc9d..2426b68 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -49,6 +49,18 @@ bool checkFileExtension(const std::string& fileName, const char* extension) { return true; } +bool checkFileExtension(const String& fileName, const char* extension) { + if (fileName.length() < strlen(extension)) { + return false; + } + + String localFile(fileName); + String localExtension(extension); + localFile.toLowerCase(); + localExtension.toLowerCase(); + return localFile.endsWith(localExtension); +} + size_t utf8RemoveLastChar(std::string& str) { if (str.empty()) return 0; size_t pos = str.size() - 1; diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index e001d7b..5c8332f 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -1,5 +1,7 @@ #pragma once +#include + #include namespace StringUtils { @@ -15,6 +17,7 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); * Check if the given filename ends with the specified extension (case-insensitive). */ bool checkFileExtension(const std::string& fileName, const char* extension); +bool checkFileExtension(const String& fileName, const char* extension); // UTF-8 safe string truncation - removes one character from the end // Returns the new size after removing one UTF-8 character