From f935b59a41a1b4e23f735fd4ff263a06770aef98 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Sun, 1 Feb 2026 08:34:30 +0100 Subject: [PATCH 01/11] feat: Add reading menu and delete cache function (#433) ## Summary * Adds a menu in the Epub reader * The Chapter selection is moved there to pos 1 (so it can be reached by double tapping the confirm button) * A Go Home is there, too * Most significantly, a function "Delete Book Cache" is added. This returns to main (to avoid directly rebuilding cached items, eg. if this is used to debug/develop other areas - and it's also easier ;)) Probably, the Sync function could now be moved from the Chapter selection to this menu, too. --- ### 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/activities/reader/EpubReaderActivity.cpp | 126 +++++++++++++----- src/activities/reader/EpubReaderActivity.h | 4 + .../EpubReaderChapterSelectionActivity.cpp | 4 +- .../reader/EpubReaderMenuActivity.cpp | 103 ++++++++++++++ .../reader/EpubReaderMenuActivity.h | 51 +++++++ 5 files changed, 255 insertions(+), 33 deletions(-) create mode 100644 src/activities/reader/EpubReaderMenuActivity.cpp create mode 100644 src/activities/reader/EpubReaderMenuActivity.h diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 58668c68..2d820432 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -130,31 +130,9 @@ void EpubReaderActivity::loop() { const int currentPage = section ? section->currentPage : 0; const int totalPages = section ? section->pageCount : 0; exitActivity(); - enterNewActivity(new EpubReaderChapterSelectionActivity( - this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, - [this] { - exitActivity(); - updateRequired = true; - }, - [this](const int newSpineIndex) { - if (currentSpineIndex != newSpineIndex) { - currentSpineIndex = newSpineIndex; - nextPageNumber = 0; - section.reset(); - } - exitActivity(); - updateRequired = true; - }, - [this](const int newSpineIndex, const int newPage) { - // Handle sync position - if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { - currentSpineIndex = newSpineIndex; - nextPageNumber = newPage; - section.reset(); - } - exitActivity(); - updateRequired = true; - })); + enterNewActivity(new EpubReaderMenuActivity( + this->renderer, this->mappedInput, epub->getTitle(), [this]() { onReaderMenuBack(); }, + [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); })); xSemaphoreGive(renderingMutex); } @@ -242,6 +220,89 @@ void EpubReaderActivity::loop() { } } +void EpubReaderActivity::onReaderMenuBack() { + exitActivity(); + updateRequired = true; +} + +void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) { + switch (action) { + case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { + // Calculate values BEFORE we start destroying things + const int currentP = section ? section->currentPage : 0; + const int totalP = section ? section->pageCount : 0; + const int spineIdx = currentSpineIndex; + const std::string path = epub->getPath(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + + // 1. Close the menu + exitActivity(); + + // 2. Open the Chapter Selector + enterNewActivity(new EpubReaderChapterSelectionActivity( + this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP, + [this] { + exitActivity(); + updateRequired = true; + }, + [this](const int newSpineIndex) { + if (currentSpineIndex != newSpineIndex) { + currentSpineIndex = newSpineIndex; + nextPageNumber = 0; + section.reset(); + } + exitActivity(); + updateRequired = true; + }, + [this](const int newSpineIndex, const int newPage) { + if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { + currentSpineIndex = newSpineIndex; + nextPageNumber = newPage; + section.reset(); + } + exitActivity(); + updateRequired = true; + })); + + xSemaphoreGive(renderingMutex); + break; + } + case EpubReaderMenuActivity::MenuAction::GO_HOME: { + // 2. Trigger the reader's "Go Home" callback + if (onGoHome) { + onGoHome(); + } + + break; + } + case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (epub) { + // 2. BACKUP: Read current progress + // We use the current variables that track our position + uint16_t backupSpine = currentSpineIndex; + uint16_t backupPage = section->currentPage; + uint16_t backupPageCount = section->pageCount; + + section.reset(); + // 3. WIPE: Clear the cache directory + epub->clearCache(); + + // 4. RESTORE: Re-setup the directory and rewrite the progress file + epub->setupCacheDir(); + + saveProgress(backupSpine, backupPage, backupPageCount); + } + exitActivity(); + updateRequired = true; + xSemaphoreGive(renderingMutex); + if (onGoHome) onGoHome(); + break; + } + } +} + void EpubReaderActivity::displayTaskLoop() { while (true) { if (updateRequired) { @@ -407,21 +468,26 @@ void EpubReaderActivity::renderScreen() { renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); } + saveProgress(currentSpineIndex, section->currentPage, section->pageCount); +} +void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { FsFile f; if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[6]; data[0] = currentSpineIndex & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF; - data[2] = section->currentPage & 0xFF; - data[3] = (section->currentPage >> 8) & 0xFF; - data[4] = section->pageCount & 0xFF; - data[5] = (section->pageCount >> 8) & 0xFF; + data[2] = currentPage & 0xFF; + data[3] = (currentPage >> 8) & 0xFF; + data[4] = pageCount & 0xFF; + data[5] = (pageCount >> 8) & 0xFF; f.write(data, 6); f.close(); + Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage); + } else { + Serial.printf("[ERS] Could not save progress!\n"); } } - void EpubReaderActivity::renderContents(std::unique_ptr page, const int orientedMarginTop, const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index ab4aff2d..ca7c0dc9 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,6 +5,7 @@ #include #include +#include "EpubReaderMenuActivity.h" #include "activities/ActivityWithSubactivity.h" class EpubReaderActivity final : public ActivityWithSubactivity { @@ -27,6 +28,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity { void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; + void saveProgress(int spineIndex, int currentPage, int pageCount); + void onReaderMenuBack(); + void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); public: explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr epub, diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 1b35e143..614227de 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -181,9 +181,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() { const int pageItems = getPageItems(); const int totalItems = getTotalItems(); - const std::string title = - renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Go to Chapter", true, EpdFontFamily::BOLD); const auto pageStartIndex = selectorIndex / pageItems * pageItems; renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp new file mode 100644 index 00000000..5ce4881d --- /dev/null +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -0,0 +1,103 @@ +#include "EpubReaderMenuActivity.h" + +#include + +#include "fontIds.h" + +void EpubReaderMenuActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); + updateRequired = true; + + xTaskCreate(&EpubReaderMenuActivity::taskTrampoline, "EpubMenuTask", 4096, this, 1, &displayTaskHandle); +} + +void EpubReaderMenuActivity::onExit() { + ActivityWithSubactivity::onExit(); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void EpubReaderMenuActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void EpubReaderMenuActivity::displayTaskLoop() { + while (true) { + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void EpubReaderMenuActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + // Use local variables for items we need to check after potential deletion + if (mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left)) { + selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size(); + updateRequired = true; + } else if (mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right)) { + selectedIndex = (selectedIndex + 1) % menuItems.size(); + updateRequired = true; + } else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // 1. Capture the callback and action locally + auto actionCallback = onAction; + auto selectedAction = menuItems[selectedIndex].action; + + // 2. Execute the callback + actionCallback(selectedAction); + + // 3. CRITICAL: Return immediately. 'this' is likely deleted now. + return; + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onBack(); + return; // Also return here just in case + } +} + +void EpubReaderMenuActivity::renderScreen() { + renderer.clearScreen(); + const auto pageWidth = renderer.getScreenWidth(); + + // Title + const std::string truncTitle = + renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - 40, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, truncTitle.c_str(), true, EpdFontFamily::BOLD); + + // Menu Items + constexpr int startY = 60; + constexpr int lineHeight = 30; + + for (size_t i = 0; i < menuItems.size(); ++i) { + const int displayY = startY + (i * lineHeight); + const bool isSelected = (static_cast(i) == selectedIndex); + + if (isSelected) { + renderer.fillRect(0, displayY, pageWidth - 1, lineHeight, true); + } + + renderer.drawText(UI_10_FONT_ID, 20, displayY, menuItems[i].label.c_str(), !isSelected); + } + + // Footer / Hints + 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/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h new file mode 100644 index 00000000..bd253f81 --- /dev/null +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include + +#include "../ActivityWithSubactivity.h" +#include "MappedInputManager.h" + +class EpubReaderMenuActivity final : public ActivityWithSubactivity { + public: + enum class MenuAction { SELECT_CHAPTER, GO_HOME, DELETE_CACHE }; + + explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, + const std::function& onBack, const std::function& onAction) + : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), + title(title), + onBack(onBack), + onAction(onAction) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + struct MenuItem { + MenuAction action; + std::string label; + }; + + const std::vector menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, + {MenuAction::GO_HOME, "Go Home"}, + {MenuAction::DELETE_CACHE, "Delete Book Cache"}}; + + int selectedIndex = 0; + bool updateRequired = false; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + std::string title = "Reader Menu"; + + const std::function onBack; + const std::function onAction; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void renderScreen(); +}; From f4df513bf3ee45606d6d0d28552daee2bf8febe2 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Sun, 1 Feb 2026 12:41:24 +0500 Subject: [PATCH 02/11] feat(ui): change popup logic (#442) ## Summary * refactors Indexing popups into ScreenComponents (they had different implementations in different files) * removes Indexing popup for small chapters * only show Indexing popup (without progress bar) for large chapters (using same minimum file size condition as for progress bar before) ## Additional Context * Having to show even single popup message and redraw the screen slows down the flow significantly * Testing results: * Opening large chapter with progress bar - 11 seconds * Same chapter without progress bar, only single Indexing popup - 5 seconds --- ### AI Usage Did you use AI tools to help write this code? _**< PARTIALLY>**_ --- lib/Epub/Epub/Section.cpp | 12 +----- lib/Epub/Epub/Section.h | 3 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 23 +++------- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 6 +-- lib/GfxRenderer/GfxRenderer.h | 2 +- src/ScreenComponents.cpp | 32 ++++++++++++++ src/ScreenComponents.h | 11 +++++ src/activities/boot_sleep/SleepActivity.cpp | 18 ++------ src/activities/boot_sleep/SleepActivity.h | 1 - src/activities/reader/EpubReaderActivity.cpp | 42 +------------------ src/activities/reader/TxtReaderActivity.cpp | 34 +-------------- 11 files changed, 62 insertions(+), 122 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 581a364f..cf67108b 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -123,9 +123,7 @@ bool Section::clearCache() const { bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, - const std::function& progressSetupFn, - const std::function& progressFn) { - constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB + const std::function& popupFn) { const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; @@ -171,11 +169,6 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize); - // Only show progress bar for larger chapters where rendering overhead is worth it - if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) { - progressSetupFn(); - } - if (!SdMan.openFileForWrite("SCT", filePath, file)) { return false; } @@ -186,8 +179,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c ChapterHtmlSlimParser visitor( tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, viewportHeight, hyphenationEnabled, - [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, - progressFn); + [this, &lut](std::unique_ptr page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn); Hyphenator::setPreferredLanguage(epub->getLanguage()); success = visitor.parseAndBuildPages(); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 5b726141..5fdf210a 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -33,7 +33,6 @@ class Section { bool clearCache() const; bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, - const std::function& progressSetupFn = nullptr, - const std::function& progressFn = nullptr); + const std::function& popupFn = nullptr); std::unique_ptr loadPageFromSectionFile(); }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 298c4ec6..ac1f537f 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -10,8 +10,8 @@ const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); -// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it -constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB +// Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it +constexpr size_t MIN_SIZE_FOR_POPUP = 50 * 1024; // 50KB const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); @@ -289,10 +289,10 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - // Get file size for progress calculation - const size_t totalSize = file.size(); - size_t bytesRead = 0; - int lastProgress = -1; + // Get file size to decide whether to show indexing popup. + if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) { + popupFn(); + } XML_SetUserData(parser, this); XML_SetElementHandler(parser, startElement, endElement); @@ -322,17 +322,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - // Update progress (call every 10% change to avoid too frequent updates) - // Only show progress for larger chapters where rendering overhead is worth it - bytesRead += len; - if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) { - const int progress = static_cast((bytesRead * 100) / totalSize); - if (lastProgress / 10 != progress / 10) { - lastProgress = progress; - progressFn(progress); - } - } - done = file.available() == 0; if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 2d8ebe5c..38202e6e 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -18,7 +18,7 @@ class ChapterHtmlSlimParser { const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; - std::function progressFn; // Progress callback (0-100) + std::function popupFn; // Popup callback int depth = 0; int skipUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX; @@ -52,7 +52,7 @@ class ChapterHtmlSlimParser { const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint16_t viewportHeight, const bool hyphenationEnabled, const std::function)>& completePageFn, - const std::function& progressFn = nullptr) + const std::function& popupFn = nullptr) : filepath(filepath), renderer(renderer), fontId(fontId), @@ -63,7 +63,7 @@ class ChapterHtmlSlimParser { viewportHeight(viewportHeight), hyphenationEnabled(hyphenationEnabled), completePageFn(completePageFn), - progressFn(progressFn) {} + popupFn(popupFn) {} ~ChapterHtmlSlimParser() = default; bool parseAndBuildPages(); void addLineToPage(std::shared_ptr line); diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 733975f4..86ddc8fc 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -56,7 +56,7 @@ class GfxRenderer { int getScreenHeight() const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; // EXPERIMENTAL: Windowed update - display only a rectangular region - void displayWindow(int x, int y, int width, int height) const; + // void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; void clearScreen(uint8_t color = 0xFF) const; diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp index ef47dfc5..72f7faf0 100644 --- a/src/ScreenComponents.cpp +++ b/src/ScreenComponents.cpp @@ -42,6 +42,38 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); } +ScreenComponents::PopupLayout ScreenComponents::drawPopup(const GfxRenderer& renderer, const char* message) { + constexpr int margin = 15; + constexpr int y = 60; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); + const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int w = textWidth + margin * 2; + const int h = textHeight + margin * 2; + const int x = (renderer.getScreenWidth() - w) / 2; + + renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2 + renderer.fillRect(x, y, w, h, false); + + const int textX = x + (w - textWidth) / 2; + const int textY = y + margin - 2; + renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return {x, y, w, h}; +} + +void ScreenComponents::fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, const int progress) { + constexpr int barHeight = 4; + const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width + const int barX = layout.x + (layout.width - barWidth) / 2; + const int barY = layout.y + layout.height - 10; + + int fillWidth = barWidth * progress / 100; + + renderer.fillRect(barX, barY, fillWidth, barHeight, true); + + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +} + void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) { int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft; renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom, diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h index 15403f60..78ed5920 100644 --- a/src/ScreenComponents.h +++ b/src/ScreenComponents.h @@ -15,9 +15,20 @@ class ScreenComponents { public: static const int BOOK_PROGRESS_BAR_HEIGHT = 4; + struct PopupLayout { + int x; + int y; + int width; + int height; + }; + static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress); + static PopupLayout drawPopup(const GfxRenderer& renderer, const char* message); + + static void fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, int progress); + // Draw a horizontal tab bar with underline indicator for selected tab // Returns the height of the tab bar (for positioning content below) static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector& tabs); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index aace2095..7ffc5851 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -8,13 +8,15 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" +#include "ScreenComponents.h" #include "fontIds.h" #include "images/CrossLarge.h" #include "util/StringUtils.h" void SleepActivity::onEnter() { Activity::onEnter(); - renderPopup("Entering Sleep..."); + + ScreenComponents::drawPopup(renderer, "Entering Sleep..."); if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { return renderBlankSleepScreen(); @@ -31,20 +33,6 @@ void SleepActivity::onEnter() { renderDefaultSleepScreen(); } -void SleepActivity::renderPopup(const char* message) const { - const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); - constexpr int margin = 20; - const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2; - constexpr int y = 117; - const int w = textWidth + margin * 2; - const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2; - // renderer.clearScreen(); - renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true); - renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); - renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD); - renderer.displayBuffer(); -} - void SleepActivity::renderCustomSleepScreen() const { // Check if we have a /sleep directory auto dir = SdMan.open("/sleep"); diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 283220ce..87df8ba1 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -10,7 +10,6 @@ class SleepActivity final : public Activity { void onEnter() override; private: - void renderPopup(const char* message) const; void renderDefaultSleepScreen() const; void renderCustomSleepScreen() const; void renderCoverSleepScreen() const; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2d820432..17d81632 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -369,49 +369,11 @@ void EpubReaderActivity::renderScreen() { viewportHeight, SETTINGS.hyphenationEnabled)) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); - // Progress bar dimensions - constexpr int barWidth = 200; - constexpr int barHeight = 10; - constexpr int boxMargin = 20; - const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing..."); - const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; - const int boxWidthNoBar = textWidth + boxMargin * 2; - const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; - const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; - const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2; - const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2; - constexpr int boxY = 50; - const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2; - const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; - - // Always show "Indexing..." text first - { - renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false); - renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing..."); - renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10); - renderer.displayBuffer(); - pagesUntilFullRefresh = 0; - } - - // Setup callback - only called for chapters >= 50KB, redraws with progress bar - auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] { - renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false); - renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing..."); - renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10); - renderer.drawRect(barX, barY, barWidth, barHeight); - renderer.displayBuffer(); - }; - - // Progress callback to update progress bar - auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) { - const int fillWidth = (barWidth - 2) * progress / 100; - renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); - }; + const auto popupFn = [this]() { ScreenComponents::drawPopup(renderer, "Indexing..."); }; if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) { + viewportHeight, SETTINGS.hyphenationEnabled, popupFn)) { Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); section.reset(); return; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index e9303de3..8e756278 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -207,28 +207,10 @@ void TxtReaderActivity::buildPageIndex() { 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(); + ScreenComponents::drawPopup(renderer, "Indexing..."); while (offset < fileSize) { std::vector tempLines; @@ -248,17 +230,6 @@ void TxtReaderActivity::buildPageIndex() { 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(HalDisplay::FAST_REFRESH); - } - // Yield to other tasks periodically if (pageOffsets.size() % 20 == 0) { vTaskDelay(1); @@ -402,9 +373,6 @@ void TxtReaderActivity::renderScreen() { // Initialize reader if not done if (!initialized) { - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD); - renderer.displayBuffer(); initializeReader(); } From 6b7065b986fc61ae7b0270284b244acdae474ed9 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Sun, 1 Feb 2026 12:51:31 +0500 Subject: [PATCH 03/11] fix: don't wake up after USB connect (#576) ## Summary * fixes problem that if short power button press is enabled, connecting device to usb leads to waking up --- lib/hal/HalGPIO.cpp | 23 ++++++++++++++++------- lib/hal/HalGPIO.h | 5 +++-- src/main.cpp | 20 ++++++++++++++++---- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 803efba0..89ce13ba 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -24,12 +24,13 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); } unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } void HalGPIO::startDeepSleep() { - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Ensure that the power button has been released to avoid immediately turning back on if you're holding it while (inputMgr.isPressed(BTN_POWER)) { delay(50); inputMgr.update(); } + // Arm the wakeup trigger *after* the button is released + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Enter Deep Sleep esp_deep_sleep_start(); } @@ -44,12 +45,20 @@ bool HalGPIO::isUsbConnected() const { return digitalRead(UART0_RXD) == HIGH; } -bool HalGPIO::isWakeupByPowerButton() const { +HalGPIO::WakeupReason HalGPIO::getWakeupReason() const { + const bool usbConnected = isUsbConnected(); const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto resetReason = esp_reset_reason(); - if (isUsbConnected()) { - return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; - } else { - return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); + + if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) || + (wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) { + return WakeupReason::PowerButton; } -} + if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) { + return WakeupReason::AfterFlash; + } + if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) { + return WakeupReason::AfterUSBPower; + } + return WakeupReason::Other; +} \ No newline at end of file diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 11ffb22e..615a8d63 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -47,8 +47,9 @@ class HalGPIO { // Check if USB is connected bool isUsbConnected() const; - // Check if wakeup was caused by power button press - bool isWakeupByPowerButton() const; + enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other }; + + WakeupReason getWakeupReason() const; // Button indices static constexpr uint8_t BTN_BACK = 0; diff --git a/src/main.cpp b/src/main.cpp index 2308f0a2..f099cba1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -294,10 +294,22 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); - if (gpio.isWakeupByPowerButton()) { - // For normal wakeups, verify power button press duration - Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); - verifyPowerButtonDuration(); + switch (gpio.getWakeupReason()) { + case HalGPIO::WakeupReason::PowerButton: + // For normal wakeups, verify power button press duration + Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); + verifyPowerButtonDuration(); + break; + case HalGPIO::WakeupReason::AfterUSBPower: + // If USB power caused a cold boot, go back to sleep + Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis()); + gpio.startDeepSleep(); + break; + case HalGPIO::WakeupReason::AfterFlash: + // After flashing, just proceed to boot + case HalGPIO::WakeupReason::Other: + default: + break; } // First serial output only here to avoid timing inconsistencies for power button press duration verification From 12c20bb09e68db9f37299d3b13948ac167da8d9a Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Sun, 1 Feb 2026 03:19:23 -0500 Subject: [PATCH 04/11] fix: WiFi error screen text clarifications (#612) ## Summary * Clarify strings on Wifi connection error screens * I have confirmed on device that these are short enough not to overflow screen margins ## Additional Context * Several screens give duplicative text (e.g., header "Connection Failed" with contents text "Connection failed") or slightly confusing text (header "Forget Network?" with text "Remove saved password?") --- ### AI Usage Did you use AI tools to help write this code? **No** --- src/activities/network/WifiSelectionActivity.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 5c45223b..8bf83a93 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -266,9 +266,9 @@ void WifiSelectionActivity::checkConnectionStatus() { } if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) { - connectionError = "Connection failed"; + connectionError = "Error: General failure"; if (status == WL_NO_SSID_AVAIL) { - connectionError = "Network not found"; + connectionError = "Error: Network not found"; } state = WifiSelectionState::CONNECTION_FAILED; updateRequired = true; @@ -278,7 +278,7 @@ void WifiSelectionActivity::checkConnectionStatus() { // Check for timeout if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) { WiFi.disconnect(); - connectionError = "Connection timeout"; + connectionError = "Error: Connection timeout"; state = WifiSelectionState::CONNECTION_FAILED; updateRequired = true; return; @@ -689,7 +689,7 @@ void WifiSelectionActivity::renderForgetPrompt() const { const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (pageHeight - height * 3) / 2; - renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network?", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD); std::string ssidInfo = "Network: " + selectedSSID; if (ssidInfo.length() > 28) { @@ -697,7 +697,7 @@ void WifiSelectionActivity::renderForgetPrompt() const { } renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str()); - renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?"); + renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Forget network and remove saved password?"); // Draw Cancel/Forget network buttons const int buttonY = top + 80; From 11b2a59233fe2c52f245db721d0c4cedfce2737f Mon Sep 17 00:00:00 2001 From: nscheung <6036668+nscheung@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:21:28 -0800 Subject: [PATCH 05/11] fix: Hide button hints in landscape CW mode (#637) ## Summary * This change hides the button hints from overlapping chapter titles when in landscape CW mode. Before ![Before](https://github.com/user-attachments/assets/3015a9b3-3fa5-443b-a641-3e65841a6fbc) After ![After](https://github.com/user-attachments/assets/011de15d-5ae6-429c-8f91-d8f37abe52d9) ## Additional Context * I initially considered implementing an offset fix, but with potential UI changes on the horizon, hiding the button hints appears to be the simplest solution for now. --- ### 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 > --- .../reader/EpubReaderChapterSelectionActivity.cpp | 7 +++++-- .../reader/XtcReaderChapterSelectionActivity.cpp | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 614227de..a77b37d0 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -206,8 +206,11 @@ void EpubReaderChapterSelectionActivity::renderScreen() { } } - const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + // Skip button hints in landscape CW mode (they overlap content) + if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { + 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 b2cfecaa..ad806a30 100644 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -149,8 +149,11 @@ 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); + // Skip button hints in landscape CW mode (they overlap content) + if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { + 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 634f6279cbe2a20421a0c1a245cb6c36fd279f05 Mon Sep 17 00:00:00 2001 From: Thomas Foskolos Date: Sun, 1 Feb 2026 10:21:36 +0200 Subject: [PATCH 06/11] docs: Update USER_GUIDE.md (#625) Add Greek as not supported language atm on the use guide --- USER_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 06973c92..e4d72b0c 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -201,7 +201,7 @@ CrossPoint renders text using the following Unicode character blocks, enabling s * **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others. * **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others. -What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi. +What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic, Greek and Farsi. --- From 4dd73a211a08ef7979fec699c70487f09766f3a3 Mon Sep 17 00:00:00 2001 From: Gaspar Fabrega Ragni Date: Sun, 1 Feb 2026 05:22:52 -0300 Subject: [PATCH 07/11] fix: custom sleep not showing image at index 0 (#639) ## Summary * Fixing custom sleep behaviour where the first image in the /sleep directory is not shown * image at index 0 is not being rendered when more than 1 image is stored in /sleep directory, because `APP_STATE.lastSleepImage` is always 0. ## Additional Context * `APP_STATE.lastSleepImage` is reset to 0 when a epub is open, this value is only used to compare it to the randomly selected one in `renderCustomSleepScreen()` that should always be a valid index, since the list of valid bmp images is colected from scratch. -> no need to reset it and block image @ index 0 from being rendered --- ### 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: Oyster --- src/main.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index f099cba1..89c4e13c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -329,7 +329,6 @@ 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, MyLibraryActivity::Tab::Recent); } From 5a97334aced2af48fc0cbda0c91873f5d8587edd Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 1 Feb 2026 21:35:25 +1100 Subject: [PATCH 08/11] Revert "fix: don't wake up after USB connect" (#643) Reverts crosspoint-reader/crosspoint-reader#576 Causing a boot loop on master --- lib/hal/HalGPIO.cpp | 23 +++++++---------------- lib/hal/HalGPIO.h | 5 ++--- src/main.cpp | 20 ++++---------------- 3 files changed, 13 insertions(+), 35 deletions(-) diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 89ce13ba..803efba0 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -24,13 +24,12 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); } unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } void HalGPIO::startDeepSleep() { + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Ensure that the power button has been released to avoid immediately turning back on if you're holding it while (inputMgr.isPressed(BTN_POWER)) { delay(50); inputMgr.update(); } - // Arm the wakeup trigger *after* the button is released - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Enter Deep Sleep esp_deep_sleep_start(); } @@ -45,20 +44,12 @@ bool HalGPIO::isUsbConnected() const { return digitalRead(UART0_RXD) == HIGH; } -HalGPIO::WakeupReason HalGPIO::getWakeupReason() const { - const bool usbConnected = isUsbConnected(); +bool HalGPIO::isWakeupByPowerButton() const { const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto resetReason = esp_reset_reason(); - - if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) || - (wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) { - return WakeupReason::PowerButton; + if (isUsbConnected()) { + return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; + } else { + return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); } - if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) { - return WakeupReason::AfterFlash; - } - if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) { - return WakeupReason::AfterUSBPower; - } - return WakeupReason::Other; -} \ No newline at end of file +} diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 615a8d63..11ffb22e 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -47,9 +47,8 @@ class HalGPIO { // Check if USB is connected bool isUsbConnected() const; - enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other }; - - WakeupReason getWakeupReason() const; + // Check if wakeup was caused by power button press + bool isWakeupByPowerButton() const; // Button indices static constexpr uint8_t BTN_BACK = 0; diff --git a/src/main.cpp b/src/main.cpp index 89c4e13c..df54fb6d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -294,22 +294,10 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); - switch (gpio.getWakeupReason()) { - case HalGPIO::WakeupReason::PowerButton: - // For normal wakeups, verify power button press duration - Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); - verifyPowerButtonDuration(); - break; - case HalGPIO::WakeupReason::AfterUSBPower: - // If USB power caused a cold boot, go back to sleep - Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis()); - gpio.startDeepSleep(); - break; - case HalGPIO::WakeupReason::AfterFlash: - // After flashing, just proceed to boot - case HalGPIO::WakeupReason::Other: - default: - break; + if (gpio.isWakeupByPowerButton()) { + // For normal wakeups, verify power button press duration + Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); + verifyPowerButtonDuration(); } // First serial output only here to avoid timing inconsistencies for power button press duration verification From 0d82b03981c552306d1ff4550a3691624b704c54 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Sun, 1 Feb 2026 16:19:33 +0500 Subject: [PATCH 09/11] fix: don't wake up after USB connect (#644) ## Summary * fixes problem that if short power button press is enabled, connecting device to usb leads to waking up --- lib/hal/HalGPIO.cpp | 23 ++++++++++++++++------- lib/hal/HalGPIO.h | 5 +++-- src/main.cpp | 20 ++++++++++++++++---- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 803efba0..89ce13ba 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -24,12 +24,13 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); } unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } void HalGPIO::startDeepSleep() { - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Ensure that the power button has been released to avoid immediately turning back on if you're holding it while (inputMgr.isPressed(BTN_POWER)) { delay(50); inputMgr.update(); } + // Arm the wakeup trigger *after* the button is released + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Enter Deep Sleep esp_deep_sleep_start(); } @@ -44,12 +45,20 @@ bool HalGPIO::isUsbConnected() const { return digitalRead(UART0_RXD) == HIGH; } -bool HalGPIO::isWakeupByPowerButton() const { +HalGPIO::WakeupReason HalGPIO::getWakeupReason() const { + const bool usbConnected = isUsbConnected(); const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto resetReason = esp_reset_reason(); - if (isUsbConnected()) { - return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; - } else { - return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); + + if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) || + (wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) { + return WakeupReason::PowerButton; } -} + if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) { + return WakeupReason::AfterFlash; + } + if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) { + return WakeupReason::AfterUSBPower; + } + return WakeupReason::Other; +} \ No newline at end of file diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 11ffb22e..615a8d63 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -47,8 +47,9 @@ class HalGPIO { // Check if USB is connected bool isUsbConnected() const; - // Check if wakeup was caused by power button press - bool isWakeupByPowerButton() const; + enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other }; + + WakeupReason getWakeupReason() const; // Button indices static constexpr uint8_t BTN_BACK = 0; diff --git a/src/main.cpp b/src/main.cpp index df54fb6d..89c4e13c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -294,10 +294,22 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); - if (gpio.isWakeupByPowerButton()) { - // For normal wakeups, verify power button press duration - Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); - verifyPowerButtonDuration(); + switch (gpio.getWakeupReason()) { + case HalGPIO::WakeupReason::PowerButton: + // For normal wakeups, verify power button press duration + Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); + verifyPowerButtonDuration(); + break; + case HalGPIO::WakeupReason::AfterUSBPower: + // If USB power caused a cold boot, go back to sleep + Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis()); + gpio.startDeepSleep(); + break; + case HalGPIO::WakeupReason::AfterFlash: + // After flashing, just proceed to boot + case HalGPIO::WakeupReason::Other: + default: + break; } // First serial output only here to avoid timing inconsistencies for power button press duration verification From b1dcb7733b8a843119f04037079c7521a054ab83 Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Sun, 1 Feb 2026 16:23:48 +0500 Subject: [PATCH 10/11] fix: truncating chapter titles using UTF-8 safe function (#599) ## Summary * Truncating chapter titles using utf8 safe functions (Cyrillic titles were split mid codepoint) * refactoring of lib/Utf8 --- ### 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 >**_ --- lib/GfxRenderer/GfxRenderer.cpp | 18 +++++++++++++----- lib/Utf8/Utf8.cpp | 17 +++++++++++++++++ lib/Utf8/Utf8.h | 6 +++++- src/activities/home/HomeActivity.cpp | 11 ++++++----- src/activities/reader/EpubReaderActivity.cpp | 4 ++-- src/activities/reader/TxtReaderActivity.cpp | 4 ++-- src/util/StringUtils.cpp | 19 ------------------- src/util/StringUtils.h | 6 ------ 8 files changed, 45 insertions(+), 40 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index fa1c61c6..b5aa7710 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -415,13 +415,21 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, const EpdFontFamily::Style style) const { + if (!text || maxWidth <= 0) return ""; + std::string item = text; - int itemWidth = getTextWidth(fontId, item.c_str(), style); - while (itemWidth > maxWidth && item.length() > 8) { - item.replace(item.length() - 5, 5, "..."); - itemWidth = getTextWidth(fontId, item.c_str(), style); + const char* ellipsis = "..."; + int textWidth = getTextWidth(fontId, item.c_str(), style); + if (textWidth <= maxWidth) { + // Text fits, return as is + return item; } - return item; + + while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) { + utf8RemoveLastChar(item); + } + + return item.empty() ? ellipsis : item + ellipsis; } // Note: Internal driver treats screen in command orientation; this library exposes a logical orientation diff --git a/lib/Utf8/Utf8.cpp b/lib/Utf8/Utf8.cpp index d5f7ebce..f77cce55 100644 --- a/lib/Utf8/Utf8.cpp +++ b/lib/Utf8/Utf8.cpp @@ -29,3 +29,20 @@ uint32_t utf8NextCodepoint(const unsigned char** string) { return cp; } + +size_t utf8RemoveLastChar(std::string& str) { + if (str.empty()) return 0; + size_t pos = str.size() - 1; + 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); + } +} diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index 095c1584..23d63a4e 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -1,7 +1,11 @@ #pragma once #include - +#include #define REPLACEMENT_GLYPH 0xFFFD uint32_t utf8NextCodepoint(const unsigned char** string); +// Remove the last UTF-8 codepoint from a std::string and return the new size. +size_t utf8RemoveLastChar(std::string& str); +// Truncate string by removing N UTF-8 codepoints from the end. +void utf8TruncateChars(std::string& str, size_t numChars); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 58b29505..678af7cb 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -366,7 +367,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 "..." - StringUtils::utf8RemoveLastChar(lines.back()); + utf8RemoveLastChar(lines.back()); lines.back().append("..."); } break; @@ -375,7 +376,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) - StringUtils::utf8RemoveLastChar(i); + utf8RemoveLastChar(i); // Check if we have room for ellipsis std::string withEllipsis = i + "..."; wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); @@ -428,7 +429,7 @@ void HomeActivity::render() { if (!lastBookAuthor.empty()) { std::string trimmedAuthor = lastBookAuthor; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); + utf8RemoveLastChar(trimmedAuthor); } if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { @@ -462,14 +463,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()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); + 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()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); + utf8RemoveLastChar(trimmedAuthor); } trimmedAuthor.append("..."); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 17d81632..5ccfb4fe 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -570,8 +570,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; titleMarginLeftAdjusted = titleMarginLeft; } - while (titleWidth > availableTitleSpace && title.length() > 11) { - title.replace(title.length() - 8, 8, "..."); + if (titleWidth > availableTitleSpace) { + title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); } } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 8e756278..eb1a9eef 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -533,8 +533,8 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int 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, "..."); + if (titleWidth > availableTextWidth) { + title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTextWidth); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); } diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index 2426b687..8e2ce58e 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -61,23 +61,4 @@ bool checkFileExtension(const String& fileName, const char* extension) { return localFile.endsWith(localExtension); } -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 5c8332f0..4b93729b 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -19,10 +19,4 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); 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 -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 e5c0ddc9fa5e565fa4591150b8850a4146cb6ab2 Mon Sep 17 00:00:00 2001 From: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:53:20 +0200 Subject: [PATCH 11/11] feat: Debugging monitor script (#555) ## Summary * **What is the goal of this PR?** Add a debugging script to help developers monitor the ESP32 serial port directly from a PC. * **What changes are included?** Added a new script: scripts/debugging_monitor.py ## Additional Context While working on a new Crosspoint-Reader feature, it quickly became clear that watching the ESP32 serial output without any visual cues was inconvenient and easy to mess up. This script improves the debugging experience by reading data from the serial port and providing: 1. A timestamp prefix for every log line (instead of milliseconds since power-up) 2. Color-coded output for different message types 3. A secondary window displaying a live graph of RAM usage, which is especially useful for tracking the memory impact of new features Screenshot_20260126_183811 --- ### AI Usage Did you use AI tools to help write this code? _**< PARTIALLY >**_ I wrote the initial version of the script. Gemini was used to help add the Matplotlib-based graphing and threading logic. --- README.md | 14 +++ scripts/debugging_monitor.py | 214 +++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100755 scripts/debugging_monitor.py diff --git a/README.md b/README.md index 633ae3b8..9efa6801 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,20 @@ Connect your Xteink X4 to your computer via USB-C and run the following command. ```sh pio run --target upload ``` +### Debugging + +After flashing the new features, it’s recommended to capture detailed logs from the serial port. + +First, make sure all required Python packages are installed: + +```python +python3 -m pip install serial colorama matplotlib +``` +after that run the script: +```sh +python3 scripts/debugging_monitor.py +``` +This was tested on Debian and should work on most Linux systems. Minor adjustments may be required for Windows or macOS. ## Internals diff --git a/scripts/debugging_monitor.py b/scripts/debugging_monitor.py new file mode 100755 index 00000000..57695e2b --- /dev/null +++ b/scripts/debugging_monitor.py @@ -0,0 +1,214 @@ +import sys +import argparse +import re +import threading +from datetime import datetime +from collections import deque +import time + +# Try to import potentially missing packages +try: + import serial + from colorama import init, Fore, Style + import matplotlib.pyplot as plt + import matplotlib.animation as animation +except ImportError as e: + missing_package = e.name + print("\n" + "!" * 50) + print(f" Error: The required package '{missing_package}' is not installed.") + print("!" * 50) + + print(f"\nTo fix this, please run the following command in your terminal:\n") + + install_cmd = "pip install " + packages = [] + if 'serial' in str(e): packages.append("pyserial") + if 'colorama' in str(e): packages.append("colorama") + if 'matplotlib' in str(e): packages.append("matplotlib") + + print(f" {install_cmd}{' '.join(packages)}") + + print("\nExiting...") + sys.exit(1) + +# --- Global Variables for Data Sharing --- +# Store last 50 data points +MAX_POINTS = 50 +time_data = deque(maxlen=MAX_POINTS) +free_mem_data = deque(maxlen=MAX_POINTS) +total_mem_data = deque(maxlen=MAX_POINTS) +data_lock = threading.Lock() # Prevent reading while writing + +# Initialize colors +init(autoreset=True) + +def get_color_for_line(line): + """ + Classify log lines by type and assign appropriate colors. + """ + line_upper = line.upper() + + if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]): + return Fore.RED + if "[MEM]" in line_upper or "FREE:" in line_upper: + return Fore.CYAN + if any(keyword in line_upper for keyword in ["[GFX]", "[ERS]", "DISPLAY", "RAM WRITE", "RAM COMPLETE", "REFRESH", "POWERING ON", "FRAME BUFFER", "LUT"]): + return Fore.MAGENTA + if any(keyword in line_upper for keyword in ["[EBP]", "[BMC]", "[ZIP]", "[PARSER]", "[EHP]", "LOADING EPUB", "CACHE", "DECOMPRESSED", "PARSING"]): + return Fore.GREEN + if "[ACT]" in line_upper or "ENTERING ACTIVITY" in line_upper or "EXITING ACTIVITY" in line_upper: + return Fore.YELLOW + if any(keyword in line_upper for keyword in ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"]): + return Fore.BLUE + if any(keyword in line_upper for keyword in ["[CPS]", "SETTINGS", "[CLEAR_CACHE]"]): + return Fore.LIGHTYELLOW_EX + if any(keyword in line_upper for keyword in ["ESP-ROM", "BUILD:", "RST:", "BOOT:", "SPIWP:", "MODE:", "LOAD:", "ENTRY", "[SD]", "STARTING CROSSPOINT", "VERSION"]): + return Fore.LIGHTBLACK_EX + if "[RBS]" in line_upper: + return Fore.LIGHTCYAN_EX + if "[KRS]" in line_upper: + return Fore.LIGHTMAGENTA_EX + if any(keyword in line_upper for keyword in ["EINKDISPLAY:", "STATIC FRAME", "INITIALIZING", "SPI INITIALIZED", "GPIO PINS", "RESETTING", "SSD1677", "E-INK"]): + return Fore.LIGHTMAGENTA_EX + if any(keyword in line_upper for keyword in ["[FNS]", "FOOTNOTE"]): + return Fore.LIGHTGREEN_EX + if any(keyword in line_upper for keyword in ["[CHAP]", "[OPDS]", "[COF]"]): + return Fore.LIGHTYELLOW_EX + + return Fore.WHITE + +def parse_memory_line(line): + """ + Extracts Free and Total bytes from the specific log line. + Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes + """ + # Regex to find 'Free: ' and 'Total: ' + match = re.search(r"Free:\s*(\d+).*Total:\s*(\d+)", line) + if match: + try: + free_bytes = int(match.group(1)) + total_bytes = int(match.group(2)) + return free_bytes, total_bytes + except ValueError: + return None, None + return None, None + +def serial_worker(port, baud): + """ + Runs in a background thread. Handles reading serial, printing to console, + and updating the data lists. + """ + print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}") + + try: + ser = serial.Serial(port, baud, timeout=0.1) + ser.dtr = False + ser.rts = False + except serial.SerialException as e: + print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}") + return + + try: + while True: + try: + raw_data = ser.readline().decode('utf-8', errors='replace') + + if not raw_data: + continue + + clean_line = raw_data.strip() + if not clean_line: + continue + + # Add PC timestamp + pc_time = datetime.now().strftime("%H:%M:%S") + formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line) + + # Check for Memory Line + if "[MEM]" in formatted_line: + free_val, total_val = parse_memory_line(formatted_line) + if free_val is not None: + with data_lock: + time_data.append(pc_time) + free_mem_data.append(free_val / 1024) # Convert to KB + total_mem_data.append(total_val / 1024) # Convert to KB + + # Print to console + line_color = get_color_for_line(formatted_line) + print(f"{line_color}{formatted_line}") + + except OSError: + print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}") + break + except Exception as e: + # If thread is killed violently (e.g. main exit), silence errors + pass + finally: + if 'ser' in locals() and ser.is_open: + ser.close() + +def update_graph(frame): + """ + Called by Matplotlib animation to redraw the chart. + """ + with data_lock: + if not time_data: + return + + # Convert deques to lists for plotting + x = list(time_data) + y_free = list(free_mem_data) + y_total = list(total_mem_data) + + plt.cla() # Clear axis + + # Plot Total RAM + plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--') + + # Plot Free RAM + plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o') + + # Fill area under Free RAM + plt.fill_between(x, y_free, color='green', alpha=0.1) + + plt.title("ESP32 Memory Monitor") + plt.ylabel("Memory (KB)") + plt.xlabel("Time") + plt.legend(loc='upper left') + plt.grid(True, linestyle=':', alpha=0.6) + + # Rotate date labels + plt.xticks(rotation=45, ha='right') + plt.tight_layout() + +def main(): + parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph") + parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port") + parser.add_argument("--baud", type=int, default=115200, help="Baud rate") + args = parser.parse_args() + + # 1. Start the Serial Reader in a separate thread + # Daemon=True means this thread dies when the main program closes + t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True) + t.start() + + # 2. Set up the Graph (Main Thread) + try: + plt.style.use('light_background') + except: + pass + + fig = plt.figure(figsize=(10, 6)) + + # Update graph every 1000ms + ani = animation.FuncAnimation(fig, update_graph, interval=1000) + + try: + print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}") + plt.show() + except KeyboardInterrupt: + print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}") + plt.close('all') # Force close any lingering plot windows + +if __name__ == "__main__": + main()