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/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. --- diff --git a/docs/webserver.md b/docs/webserver.md index 355bac41..d1d3bac5 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -153,7 +153,7 @@ Click **File Manager** to access file management features. 1. Click the **+ Add** button in the top-right corner 2. Select **New Folder** from the dropdown menu -3. Enter a folder name (letters, numbers, underscores, and hyphens only) +3. Enter a folder name (must not contain characters \" * : < > ? / \\ | and must not be . or ..) 4. Click **Create Folder** This is useful for organizing your ebooks by genre, author, or series. 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.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1dbe8ee6..b5aa7710 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -10,19 +10,19 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int // Logical portrait (480x800) → panel (800x480) // Rotation: 90 degrees clockwise *rotatedX = y; - *rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; + *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - x; break; } case LandscapeClockwise: { // Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right) - *rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x; - *rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y; + *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - x; + *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - y; break; } case PortraitInverted: { // Logical portrait (480x800) → panel (800x480) // Rotation: 90 degrees counter-clockwise - *rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y; + *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - y; *rotatedY = x; break; } @@ -36,7 +36,7 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int } void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { - uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + uint8_t* frameBuffer = display.getFrameBuffer(); // Early return if no framebuffer is set if (!frameBuffer) { @@ -49,14 +49,13 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { rotateCoordinates(x, y, &rotatedX, &rotatedY); // Bounds checking against physical panel dimensions - if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 || - rotatedY >= EInkDisplay::DISPLAY_HEIGHT) { + if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= HalDisplay::DISPLAY_HEIGHT) { Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY); return; } // Calculate byte position and bit position - const uint16_t byteIndex = rotatedY * EInkDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8); + const uint16_t byteIndex = rotatedY * HalDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8); const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first if (state) { @@ -164,7 +163,7 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co break; } // TODO: Rotate bits - einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); + display.drawImage(bitmap, rotatedX, rotatedY, width, height); } void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, @@ -399,32 +398,38 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi free(nodeX); } -void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); } +void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); } void GfxRenderer::invertScreen() const { - uint8_t* buffer = einkDisplay.getFrameBuffer(); + uint8_t* buffer = display.getFrameBuffer(); if (!buffer) { Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis()); return; } - for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) { + for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) { buffer[i] = ~buffer[i]; } } -void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) const { - einkDisplay.displayBuffer(refreshMode); -} +void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { display.displayBuffer(refreshMode); } 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 @@ -433,13 +438,13 @@ int GfxRenderer::getScreenWidth() const { case Portrait: case PortraitInverted: // 480px wide in portrait logical coordinates - return EInkDisplay::DISPLAY_HEIGHT; + return HalDisplay::DISPLAY_HEIGHT; case LandscapeClockwise: case LandscapeCounterClockwise: // 800px wide in landscape logical coordinates - return EInkDisplay::DISPLAY_WIDTH; + return HalDisplay::DISPLAY_WIDTH; } - return EInkDisplay::DISPLAY_HEIGHT; + return HalDisplay::DISPLAY_HEIGHT; } int GfxRenderer::getScreenHeight() const { @@ -447,13 +452,13 @@ int GfxRenderer::getScreenHeight() const { case Portrait: case PortraitInverted: // 800px tall in portrait logical coordinates - return EInkDisplay::DISPLAY_WIDTH; + return HalDisplay::DISPLAY_WIDTH; case LandscapeClockwise: case LandscapeCounterClockwise: // 480px tall in landscape logical coordinates - return EInkDisplay::DISPLAY_HEIGHT; + return HalDisplay::DISPLAY_HEIGHT; } - return EInkDisplay::DISPLAY_WIDTH; + return HalDisplay::DISPLAY_WIDTH; } int GfxRenderer::getSpaceWidth(const int fontId) const { @@ -653,17 +658,18 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y } } -uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } +uint8_t* GfxRenderer::getFrameBuffer() const { return display.getFrameBuffer(); } -size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } +size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; } -void GfxRenderer::grayscaleRevert() const { einkDisplay.grayscaleRevert(); } +// unused +// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); } -void GfxRenderer::copyGrayscaleLsbBuffers() const { einkDisplay.copyGrayscaleLsbBuffers(einkDisplay.getFrameBuffer()); } +void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(display.getFrameBuffer()); } -void GfxRenderer::copyGrayscaleMsbBuffers() const { einkDisplay.copyGrayscaleMsbBuffers(einkDisplay.getFrameBuffer()); } +void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(display.getFrameBuffer()); } -void GfxRenderer::displayGrayBuffer() const { einkDisplay.displayGrayBuffer(); } +void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(); } void GfxRenderer::freeBwBufferChunks() { for (auto& bwBufferChunk : bwBufferChunks) { @@ -681,7 +687,7 @@ void GfxRenderer::freeBwBufferChunks() { * Returns true if buffer was stored successfully, false if allocation failed. */ bool GfxRenderer::storeBwBuffer() { - const uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + const uint8_t* frameBuffer = display.getFrameBuffer(); if (!frameBuffer) { Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis()); return false; @@ -736,7 +742,7 @@ void GfxRenderer::restoreBwBuffer() { return; } - uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + uint8_t* frameBuffer = display.getFrameBuffer(); if (!frameBuffer) { Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis()); freeBwBufferChunks(); @@ -755,7 +761,7 @@ void GfxRenderer::restoreBwBuffer() { memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); } - einkDisplay.cleanupGrayscaleBuffers(frameBuffer); + display.cleanupGrayscaleBuffers(frameBuffer); freeBwBufferChunks(); Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); @@ -766,9 +772,9 @@ void GfxRenderer::restoreBwBuffer() { * Use this when BW buffer was re-rendered instead of stored/restored. */ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { - uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + uint8_t* frameBuffer = display.getFrameBuffer(); if (frameBuffer) { - einkDisplay.cleanupGrayscaleBuffers(frameBuffer); + display.cleanupGrayscaleBuffers(frameBuffer); } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index b1fea69b..86ddc8fc 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -1,7 +1,7 @@ #pragma once -#include #include +#include #include @@ -21,11 +21,11 @@ class GfxRenderer { private: static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory - static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; - static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_SIZE, + static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; + static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE, "BW buffer chunking does not line up with display buffer size"); - EInkDisplay& einkDisplay; + HalDisplay& display; RenderMode renderMode; Orientation orientation; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; @@ -36,7 +36,7 @@ class GfxRenderer { void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; public: - explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {} + explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {} ~GfxRenderer() { freeBwBufferChunks(); } static constexpr int VIEWABLE_MARGIN_TOP = 9; @@ -54,9 +54,9 @@ class GfxRenderer { // Screen ops int getScreenWidth() const; int getScreenHeight() const; - void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) 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/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/lib/hal/HalDisplay.cpp b/lib/hal/HalDisplay.cpp new file mode 100644 index 00000000..6f69d7fc --- /dev/null +++ b/lib/hal/HalDisplay.cpp @@ -0,0 +1,51 @@ +#include +#include + +#define SD_SPI_MISO 7 + +HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {} + +HalDisplay::~HalDisplay() {} + +void HalDisplay::begin() { einkDisplay.begin(); } + +void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); } + +void HalDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h, + bool fromProgmem) const { + einkDisplay.drawImage(imageData, x, y, w, h, fromProgmem); +} + +EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) { + switch (mode) { + case HalDisplay::FULL_REFRESH: + return EInkDisplay::FULL_REFRESH; + case HalDisplay::HALF_REFRESH: + return EInkDisplay::HALF_REFRESH; + case HalDisplay::FAST_REFRESH: + default: + return EInkDisplay::FAST_REFRESH; + } +} + +void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode) { einkDisplay.displayBuffer(convertRefreshMode(mode)); } + +void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) { + einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen); +} + +void HalDisplay::deepSleep() { einkDisplay.deepSleep(); } + +uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } + +void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) { + einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer); +} + +void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); } + +void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); } + +void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); } + +void HalDisplay::displayGrayBuffer() { einkDisplay.displayGrayBuffer(); } diff --git a/lib/hal/HalDisplay.h b/lib/hal/HalDisplay.h new file mode 100644 index 00000000..6eb7156b --- /dev/null +++ b/lib/hal/HalDisplay.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include + +class HalDisplay { + public: + // Constructor with pin configuration + HalDisplay(); + + // Destructor + ~HalDisplay(); + + // Refresh modes + enum RefreshMode { + FULL_REFRESH, // Full refresh with complete waveform + HALF_REFRESH, // Half refresh (1720ms) - balanced quality and speed + FAST_REFRESH // Fast refresh using custom LUT + }; + + // Initialize the display hardware and driver + void begin(); + + // Display dimensions + static constexpr uint16_t DISPLAY_WIDTH = EInkDisplay::DISPLAY_WIDTH; + static constexpr uint16_t DISPLAY_HEIGHT = EInkDisplay::DISPLAY_HEIGHT; + static constexpr uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8; + static constexpr uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT; + + // Frame buffer operations + void clearScreen(uint8_t color = 0xFF) const; + void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h, + bool fromProgmem = false) const; + + void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH); + void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false); + + // Power management + void deepSleep(); + + // Access to frame buffer + uint8_t* getFrameBuffer() const; + + void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer); + void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer); + void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer); + void cleanupGrayscaleBuffers(const uint8_t* bwBuffer); + + void displayGrayBuffer(); + + private: + EInkDisplay einkDisplay; +}; diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp new file mode 100644 index 00000000..89ce13ba --- /dev/null +++ b/lib/hal/HalGPIO.cpp @@ -0,0 +1,64 @@ +#include +#include +#include + +void HalGPIO::begin() { + inputMgr.begin(); + SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS); + pinMode(BAT_GPIO0, INPUT); + pinMode(UART0_RXD, INPUT); +} + +void HalGPIO::update() { inputMgr.update(); } + +bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); } + +bool HalGPIO::wasPressed(uint8_t buttonIndex) const { return inputMgr.wasPressed(buttonIndex); } + +bool HalGPIO::wasAnyPressed() const { return inputMgr.wasAnyPressed(); } + +bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleased(buttonIndex); } + +bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); } + +unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } + +void HalGPIO::startDeepSleep() { + // 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(); +} + +int HalGPIO::getBatteryPercentage() const { + static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0); + return battery.readPercentage(); +} + +bool HalGPIO::isUsbConnected() const { + // U0RXD/GPIO20 reads HIGH when USB is connected + return digitalRead(UART0_RXD) == HIGH; +} + +HalGPIO::WakeupReason HalGPIO::getWakeupReason() const { + const bool usbConnected = isUsbConnected(); + 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 (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 new file mode 100644 index 00000000..615a8d63 --- /dev/null +++ b/lib/hal/HalGPIO.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include + +// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) +#define EPD_SCLK 8 // SPI Clock +#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In) +#define EPD_CS 21 // Chip Select +#define EPD_DC 4 // Data/Command +#define EPD_RST 5 // Reset +#define EPD_BUSY 6 // Busy + +#define SPI_MISO 7 // SPI MISO, shared between SD card and display (Master In Slave Out) + +#define BAT_GPIO0 0 // Battery voltage + +#define UART0_RXD 20 // Used for USB connection detection + +class HalGPIO { +#if CROSSPOINT_EMULATED == 0 + InputManager inputMgr; +#endif + + public: + HalGPIO() = default; + + // Start button GPIO and setup SPI for screen and SD card + void begin(); + + // Button input methods + void update(); + bool isPressed(uint8_t buttonIndex) const; + bool wasPressed(uint8_t buttonIndex) const; + bool wasAnyPressed() const; + bool wasReleased(uint8_t buttonIndex) const; + bool wasAnyReleased() const; + unsigned long getHeldTime() const; + + // Setup wake up GPIO and enter deep sleep + void startDeepSleep(); + + // Get battery percentage (range 0-100) + int getBatteryPercentage() const; + + // Check if USB is connected + bool isUsbConnected() const; + + enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other }; + + WakeupReason getWakeupReason() const; + + // Button indices + static constexpr uint8_t BTN_BACK = 0; + static constexpr uint8_t BTN_CONFIRM = 1; + static constexpr uint8_t BTN_LEFT = 2; + static constexpr uint8_t BTN_RIGHT = 3; + static constexpr uint8_t BTN_UP = 4; + static constexpr uint8_t BTN_DOWN = 5; + static constexpr uint8_t BTN_POWER = 6; +}; 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() diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 25095be7..e5423724 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -19,20 +19,20 @@ struct SideLayoutMap { // Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT. constexpr FrontLayoutMap kFrontLayouts[] = { - {InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_RIGHT}, - {InputManager::BTN_LEFT, InputManager::BTN_RIGHT, InputManager::BTN_BACK, InputManager::BTN_CONFIRM}, - {InputManager::BTN_CONFIRM, InputManager::BTN_LEFT, InputManager::BTN_BACK, InputManager::BTN_RIGHT}, - {InputManager::BTN_BACK, InputManager::BTN_CONFIRM, InputManager::BTN_RIGHT, InputManager::BTN_LEFT}, + {HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT}, + {HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT, HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM}, + {HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_BACK, HalGPIO::BTN_RIGHT}, + {HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_RIGHT, HalGPIO::BTN_LEFT}, }; // Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT. constexpr SideLayoutMap kSideLayouts[] = { - {InputManager::BTN_UP, InputManager::BTN_DOWN}, - {InputManager::BTN_DOWN, InputManager::BTN_UP}, + {HalGPIO::BTN_UP, HalGPIO::BTN_DOWN}, + {HalGPIO::BTN_DOWN, HalGPIO::BTN_UP}, }; } // namespace -bool MappedInputManager::mapButton(const Button button, bool (InputManager::*fn)(uint8_t) const) const { +bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const { const auto frontLayout = static_cast(SETTINGS.frontButtonLayout); const auto sideLayout = static_cast(SETTINGS.sideButtonLayout); const auto& front = kFrontLayouts[frontLayout]; @@ -40,41 +40,39 @@ bool MappedInputManager::mapButton(const Button button, bool (InputManager::*fn) switch (button) { case Button::Back: - return (inputManager.*fn)(front.back); + return (gpio.*fn)(front.back); case Button::Confirm: - return (inputManager.*fn)(front.confirm); + return (gpio.*fn)(front.confirm); case Button::Left: - return (inputManager.*fn)(front.left); + return (gpio.*fn)(front.left); case Button::Right: - return (inputManager.*fn)(front.right); + return (gpio.*fn)(front.right); case Button::Up: - return (inputManager.*fn)(InputManager::BTN_UP); + return (gpio.*fn)(HalGPIO::BTN_UP); case Button::Down: - return (inputManager.*fn)(InputManager::BTN_DOWN); + return (gpio.*fn)(HalGPIO::BTN_DOWN); case Button::Power: - return (inputManager.*fn)(InputManager::BTN_POWER); + return (gpio.*fn)(HalGPIO::BTN_POWER); case Button::PageBack: - return (inputManager.*fn)(side.pageBack); + return (gpio.*fn)(side.pageBack); case Button::PageForward: - return (inputManager.*fn)(side.pageForward); + return (gpio.*fn)(side.pageForward); } return false; } -bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &InputManager::wasPressed); } +bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); } -bool MappedInputManager::wasReleased(const Button button) const { - return mapButton(button, &InputManager::wasReleased); -} +bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); } -bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &InputManager::isPressed); } +bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); } -bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); } +bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); } -bool MappedInputManager::wasAnyReleased() const { return inputManager.wasAnyReleased(); } +bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); } -unsigned long MappedInputManager::getHeldTime() const { return inputManager.getHeldTime(); } +unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); } MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const { @@ -91,4 +89,4 @@ MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const default: return {back, confirm, previous, next}; } -} +} \ No newline at end of file diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index bee7cd4b..f507a928 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -1,6 +1,6 @@ #pragma once -#include +#include class MappedInputManager { public: @@ -13,7 +13,7 @@ class MappedInputManager { const char* btn4; }; - explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {} + explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {} bool wasPressed(Button button) const; bool wasReleased(Button button) const; @@ -24,7 +24,7 @@ class MappedInputManager { Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const; private: - InputManager& inputManager; + HalGPIO& gpio; - bool mapButton(Button button, bool (InputManager::*fn)(uint8_t) const) const; + bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const; }; diff --git a/src/RecentBooksStore.cpp b/src/RecentBooksStore.cpp index 03cfbbd7..5932de36 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -7,22 +7,23 @@ #include namespace { -constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 1; +constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 2; constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin"; constexpr int MAX_RECENT_BOOKS = 10; } // namespace RecentBooksStore RecentBooksStore::instance; -void RecentBooksStore::addBook(const std::string& path) { +void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author) { // Remove existing entry if present - auto it = std::find(recentBooks.begin(), recentBooks.end(), path); + auto it = + std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); if (it != recentBooks.end()) { recentBooks.erase(it); } // Add to front - recentBooks.insert(recentBooks.begin(), path); + recentBooks.insert(recentBooks.begin(), {path, title, author}); // Trim to max size if (recentBooks.size() > MAX_RECENT_BOOKS) { @@ -46,7 +47,9 @@ bool RecentBooksStore::saveToFile() const { serialization::writePod(outputFile, count); for (const auto& book : recentBooks) { - serialization::writeString(outputFile, book); + serialization::writeString(outputFile, book.path); + serialization::writeString(outputFile, book.title); + serialization::writeString(outputFile, book.author); } outputFile.close(); @@ -63,24 +66,41 @@ bool RecentBooksStore::loadFromFile() { uint8_t version; serialization::readPod(inputFile, version); if (version != RECENT_BOOKS_FILE_VERSION) { - Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); - inputFile.close(); - return false; - } + if (version == 1) { + // Old version, just read paths + uint8_t count; + serialization::readPod(inputFile, count); + recentBooks.clear(); + recentBooks.reserve(count); + for (uint8_t i = 0; i < count; i++) { + std::string path; + serialization::readString(inputFile, path); + // Title and author will be empty, they will be filled when the book is + // opened again + recentBooks.push_back({path, "", ""}); + } + } else { + Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); + inputFile.close(); + return false; + } + } else { + uint8_t count; + serialization::readPod(inputFile, count); - uint8_t count; - serialization::readPod(inputFile, count); + recentBooks.clear(); + recentBooks.reserve(count); - recentBooks.clear(); - recentBooks.reserve(count); - - for (uint8_t i = 0; i < count; i++) { - std::string path; - serialization::readString(inputFile, path); - recentBooks.push_back(path); + for (uint8_t i = 0; i < count; i++) { + std::string path, title, author; + serialization::readString(inputFile, path); + serialization::readString(inputFile, title); + serialization::readString(inputFile, author); + recentBooks.push_back({path, title, author}); + } } inputFile.close(); - Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), count); + Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), recentBooks.size()); return true; } diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index b98bd406..7b87f1e0 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -2,11 +2,19 @@ #include #include +struct RecentBook { + std::string path; + std::string title; + std::string author; + + bool operator==(const RecentBook& other) const { return path == other.path; } +}; + class RecentBooksStore { // Static instance static RecentBooksStore instance; - std::vector recentBooks; + std::vector recentBooks; public: ~RecentBooksStore() = default; @@ -14,11 +22,11 @@ class RecentBooksStore { // Get singleton instance static RecentBooksStore& getInstance() { return instance; } - // Add a book path to the recent list (moves to front if already exists) - void addBook(const std::string& path); + // Add a book to the recent list (moves to front if already exists) + void addBook(const std::string& path, const std::string& title, const std::string& author); - // Get the list of recent book paths (most recent first) - const std::vector& getBooks() const { return recentBooks; } + // Get the list of recent books (most recent first) + const std::vector& getBooks() const { return recentBooks; } // Get the count of recent books int getCount() const { return static_cast(recentBooks.size()); } 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 8cad8e32..323aa277 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.sleepScreenMode == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { return renderBlankSleepScreen(); @@ -32,20 +34,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"); @@ -134,7 +122,7 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.invertScreen(); } - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { @@ -190,7 +178,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { renderer.invertScreen(); } - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); if (hasGreyscale) { bitmap.rewindToData(); @@ -285,5 +273,5 @@ void SleepActivity::renderBlankSleepScreen() const { if (SETTINGS.sleepScreenFilter == CrossPointSettings::SLEEP_SCREEN_FILTER::NEGATIVE) { renderer.invertScreen(); } - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); } 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/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/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 1db32397..29c6ea73 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -16,6 +16,7 @@ namespace { constexpr int TAB_BAR_Y = 15; constexpr int CONTENT_START_Y = 60; constexpr int LINE_HEIGHT = 30; +constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items constexpr int LEFT_MARGIN = 20; constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator @@ -47,7 +48,7 @@ int MyLibraryActivity::getPageItems() const { int MyLibraryActivity::getCurrentItemCount() const { if (currentTab == Tab::Recent) { - return static_cast(bookTitles.size()); + return static_cast(recentBooks.size()); } return static_cast(files.size()); } @@ -65,34 +66,16 @@ int MyLibraryActivity::getCurrentPage() const { } void MyLibraryActivity::loadRecentBooks() { - constexpr size_t MAX_RECENT_BOOKS = 20; - - bookTitles.clear(); - bookPaths.clear(); + recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); - bookTitles.reserve(std::min(books.size(), MAX_RECENT_BOOKS)); - bookPaths.reserve(std::min(books.size(), MAX_RECENT_BOOKS)); - - for (const auto& path : books) { - // Limit to maximum number of recent books - if (bookTitles.size() >= MAX_RECENT_BOOKS) { - break; - } + recentBooks.reserve(books.size()); + for (const auto& book : books) { // Skip if file no longer exists - if (!SdMan.exists(path.c_str())) { + if (!SdMan.exists(book.path.c_str())) { continue; } - - // Extract filename from path for display - std::string title = path; - const size_t lastSlash = title.find_last_of('/'); - if (lastSlash != std::string::npos) { - title = title.substr(lastSlash + 1); - } - - bookTitles.push_back(title); - bookPaths.push_back(path); + recentBooks.push_back(book); } } @@ -176,8 +159,6 @@ void MyLibraryActivity::onExit() { vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; - bookTitles.clear(); - bookPaths.clear(); files.clear(); } @@ -207,8 +188,8 @@ void MyLibraryActivity::loop() { // Confirm button - open selected item if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (currentTab == Tab::Recent) { - if (!bookPaths.empty() && selectorIndex < static_cast(bookPaths.size())) { - onSelectBook(bookPaths[selectorIndex], currentTab); + if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { + onSelectBook(recentBooks[selectorIndex].path, currentTab); } } else { // Files tab @@ -333,7 +314,7 @@ void MyLibraryActivity::render() const { void MyLibraryActivity::renderRecentTab() const { const auto pageWidth = renderer.getScreenWidth(); const int pageItems = getPageItems(); - const int bookCount = static_cast(bookTitles.size()); + const int bookCount = static_cast(recentBooks.size()); if (bookCount == 0) { renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books"); @@ -343,14 +324,37 @@ void MyLibraryActivity::renderRecentTab() const { const auto pageStartIndex = selectorIndex / pageItems * pageItems; // Draw selection highlight - renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN, - LINE_HEIGHT); + renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2, + pageWidth - RIGHT_MARGIN, RECENTS_LINE_HEIGHT); // Draw items for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { - auto item = renderer.truncatedText(UI_10_FONT_ID, bookTitles[i].c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), - i != selectorIndex); + const auto& book = recentBooks[i]; + const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT; + + // Line 1: Title + std::string title = book.title; + if (title.empty()) { + // Fallback for older entries or files without metadata + title = book.path; + const size_t lastSlash = title.find_last_of('/'); + if (lastSlash != std::string::npos) { + title = title.substr(lastSlash + 1); + } + const size_t dot = title.find_last_of('.'); + if (dot != std::string::npos) { + title.resize(dot); + } + } + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), i != selectorIndex); + + // Line 2: Author + if (!book.author.empty()) { + auto truncatedAuthor = + renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); + renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), i != selectorIndex); + } } } diff --git a/src/activities/home/MyLibraryActivity.h b/src/activities/home/MyLibraryActivity.h index c6c52b68..39a27ed7 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -8,6 +8,7 @@ #include #include "../Activity.h" +#include "RecentBooksStore.h" class MyLibraryActivity final : public Activity { public: @@ -22,8 +23,7 @@ class MyLibraryActivity final : public Activity { bool updateRequired = false; // Recent tab state - std::vector bookTitles; // Display titles for each book - std::vector bookPaths; // Paths for each visible book (excludes missing) + std::vector recentBooks; // Files tab state (from FileSelectionActivity) std::string basepath = "/"; diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 5c45223b..becd41a3 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; @@ -546,8 +546,8 @@ void WifiSelectionActivity::renderNetworkList() const { // Draw network name (truncate if too long) std::string displayName = network.ssid; - if (displayName.length() > 16) { - displayName.replace(13, displayName.length() - 13, "..."); + if (displayName.length() > 33) { + displayName.replace(30, displayName.length() - 30, "..."); } renderer.drawText(UI_10_FONT_ID, 20, networkY, displayName.c_str()); @@ -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; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 89be3bc7..5ccfb4fe 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -85,7 +85,7 @@ void EpubReaderActivity::onEnter() { // Save current epub as last opened epub and add to recent books APP_STATE.openEpubPath = epub->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(epub->getPath()); + RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor()); // Trigger first update updateRequired = true; @@ -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) { @@ -308,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(EInkDisplay::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; @@ -407,28 +430,33 @@ 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) { page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); @@ -542,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/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..a77b37d0 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); @@ -208,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/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(); +}; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 7df083a6..eb1a9eef 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -60,7 +60,7 @@ void TxtReaderActivity::onEnter() { // Save current txt as last opened file and add to recent books APP_STATE.openEpubPath = txt->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(txt->getPath()); + RECENT_BOOKS.addBook(txt->getPath(), "", ""); // Trigger first update updateRequired = true; @@ -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(EInkDisplay::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(); } @@ -484,7 +452,7 @@ void TxtReaderActivity::renderPage() { renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); @@ -565,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/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 9761e27d..f579abcd 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -45,7 +45,7 @@ void XtcReaderActivity::onEnter() { // Save current XTC as last opened book and add to recent books APP_STATE.openEpubPath = xtc->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(xtc->getPath()); + RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor()); // Trigger first update updateRequired = true; @@ -276,7 +276,7 @@ void XtcReaderActivity::renderPage() { // Display BW with conditional refresh based on pagesUntilFullRefresh if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { renderer.displayBuffer(); @@ -356,7 +356,7 @@ void XtcReaderActivity::renderPage() { // Display with appropriate refresh if (pagesUntilFullRefresh <= 1) { - renderer.displayBuffer(EInkDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); } else { 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(); } diff --git a/src/activities/util/FullScreenMessageActivity.h b/src/activities/util/FullScreenMessageActivity.h index 3e975c91..93909503 100644 --- a/src/activities/util/FullScreenMessageActivity.h +++ b/src/activities/util/FullScreenMessageActivity.h @@ -1,6 +1,6 @@ #pragma once -#include #include +#include #include #include @@ -10,12 +10,12 @@ class FullScreenMessageActivity final : public Activity { std::string text; EpdFontFamily::Style style; - EInkDisplay::RefreshMode refreshMode; + HalDisplay::RefreshMode refreshMode; public: explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text, const EpdFontFamily::Style style = EpdFontFamily::REGULAR, - const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) + const HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) : Activity("FullScreenMessage", renderer, mappedInput), text(std::move(text)), style(style), diff --git a/src/main.cpp b/src/main.cpp index 8a081fd8..89c4e13c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,8 +1,8 @@ #include -#include #include #include -#include +#include +#include #include #include #include @@ -26,23 +26,10 @@ #include "activities/util/FullScreenMessageActivity.h" #include "fontIds.h" -#define SPI_FQ 40000000 -// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) -#define EPD_SCLK 8 // SPI Clock -#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In) -#define EPD_CS 21 // Chip Select -#define EPD_DC 4 // Data/Command -#define EPD_RST 5 // Reset -#define EPD_BUSY 6 // Busy - -#define UART0_RXD 20 // Used for USB connection detection - -#define SD_SPI_MISO 7 - -EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY); -InputManager inputManager; -MappedInputManager mappedInputManager(inputManager); -GfxRenderer renderer(einkDisplay); +HalDisplay display; +HalGPIO gpio; +MappedInputManager mappedInputManager(gpio); +GfxRenderer renderer(display); Activity* currentActivity; // Fonts @@ -170,21 +157,20 @@ void verifyPowerButtonDuration() { const uint16_t calibratedPressDuration = (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; - inputManager.update(); - // Verify the user has actually pressed + gpio.update(); // Needed because inputManager.isPressed() may take up to ~500ms to return the correct state - while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) { + while (!gpio.isPressed(HalGPIO::BTN_POWER) && millis() - start < 1000) { delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration. - inputManager.update(); + gpio.update(); } t2 = millis(); - if (inputManager.isPressed(InputManager::BTN_POWER)) { + if (gpio.isPressed(HalGPIO::BTN_POWER)) { do { delay(10); - inputManager.update(); - } while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration); - abort = inputManager.getHeldTime() < calibratedPressDuration; + gpio.update(); + } while (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() < calibratedPressDuration); + abort = gpio.getHeldTime() < calibratedPressDuration; } else { abort = true; } @@ -192,16 +178,15 @@ void verifyPowerButtonDuration() { if (abort) { // Button released too early. Returning to sleep. // IMPORTANT: Re-arm the wakeup trigger before sleeping again - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); - esp_deep_sleep_start(); + gpio.startDeepSleep(); } } void waitForPowerRelease() { - inputManager.update(); - while (inputManager.isPressed(InputManager::BTN_POWER)) { + gpio.update(); + while (gpio.isPressed(HalGPIO::BTN_POWER)) { delay(50); - inputManager.update(); + gpio.update(); } } @@ -210,14 +195,11 @@ void enterDeepSleep() { exitActivity(); enterNewActivity(new SleepActivity(renderer, mappedInputManager)); - einkDisplay.deepSleep(); + display.deepSleep(); Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1); Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); - 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 - waitForPowerRelease(); - // Enter Deep Sleep - esp_deep_sleep_start(); + + gpio.startDeepSleep(); } void onGoHome(); @@ -261,7 +243,7 @@ void onGoHome() { } void setupDisplayAndFonts() { - einkDisplay.begin(); + display.begin(); Serial.printf("[%lu] [ ] Display initialized\n", millis()); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); #ifndef OMIT_FONTS @@ -284,27 +266,13 @@ 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 isWakeupByPowerButton() { - 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); - } -} - void setup() { t1 = millis(); + gpio.begin(); + // Only start serial if USB connected - pinMode(UART0_RXD, INPUT); - if (isUsbConnected()) { + if (gpio.isUsbConnected()) { Serial.begin(115200); // Wait up to 3 seconds for Serial to be ready to catch early logs unsigned long start = millis(); @@ -313,13 +281,6 @@ void setup() { } } - inputManager.begin(); - // Initialize pins - pinMode(BAT_GPIO0, INPUT); - - // Initialize SPI with custom pins - SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS); - // SD Card Initialization // We need 6 open files concurrently when parsing a new chapter if (!SdMan.begin()) { @@ -333,10 +294,22 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); - if (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 @@ -356,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); } @@ -370,7 +342,7 @@ void loop() { const unsigned long loopStartTime = millis(); static unsigned long lastMemPrint = 0; - inputManager.update(); + gpio.update(); if (Serial && millis() - lastMemPrint >= 10000) { Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), @@ -380,8 +352,7 @@ void loop() { // Check for any user activity (button press or release) or active background work static unsigned long lastActivityTime = millis(); - if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() || - (currentActivity && currentActivity->preventAutoSleep())) { + if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) { lastActivityTime = millis(); // Reset inactivity timer } @@ -393,8 +364,7 @@ void loop() { return; } - if (inputManager.isPressed(InputManager::BTN_POWER) && - inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) { + if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() > SETTINGS.getPowerButtonDuration()) { enterDeepSleep(); // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start return; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html index bfdbe3cc..95993b8e 100644 --- a/src/network/html/FilesPage.html +++ b/src/network/html/FilesPage.html @@ -1146,10 +1146,10 @@ function retryAllFailedUploads() { return; } - // Validate folder name (no special characters except underscore and hyphen) - const validName = /^[a-zA-Z0-9_\-]+$/.test(folderName); + // Validate folder name + const validName = /^(?!\.{1,2}$)[^"*:<>?\/\\|]+$/.test(folderName); if (!validName) { - alert('Folder name can only contain letters, numbers, underscores, and hyphens.'); + alert('Folder name cannot contain \" * : < > ? / \\ | and must not be . or ..'); return; } 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