Compare commits

...

6 Commits

Author SHA1 Message Date
cottongin
3853bfe113
feat: widen battery icons and add charging indicator
Some checks are pending
CI / build (push) Waiting to run
- Increase battery width to 1.5x original (small: 15→23px, large: 20→30px)
- Add lightning bolt overlay when USB/charging detected
- Bolt traced from SVG, centered in fill area regardless of charge level
- Add isUsbConnected() to MappedInputManager for charging detection
- Update layout spacing constants for wider battery
2026-02-03 20:11:28 -05:00
cottongin
fbe7d2feb4
release: ef-1.0.5 - stability, memory, and upstream merges
All checks were successful
CI / build (push) Successful in 4m51s
Compile Release / build-release (push) Successful in 1m18s
Webserver: JSON batching, removed MD5 blocking, simplified flow control
Memory: QR code caching, WiFi scan optimization, cover buffer leak fix
EPUB: Fixed errant underlining before styled inline elements
Flash screen: Version string overflow fix, half refresh for cleaner display

Upstream merges:
- PR #522: HAL abstraction layer (HalDisplay, HalGPIO)
- PR #603: Sunlight fading fix toggle in Display settings
2026-01-30 23:20:23 -05:00
cottongin
520a0cb124
feat: implement sunlight fading fix (PR #603)
Add user-toggleable setting to turn off display between refreshes,
which helps mitigate the sunlight fading issue on e-ink displays.

Changes:
- Add turnOffScreen parameter to HalDisplay methods
- Add fadingFix member and setFadingFix() to GfxRenderer
- Add fadingFix setting to CrossPointSettings with persistence
- Add "Sunlight Fading Fix" toggle in Display settings
- Update SDK submodule with turnOffScreen support
2026-01-30 23:02:29 -05:00
cottongin
be8b02efd6
feat: merge PR #522 - add HalDisplay and HalGPIO abstraction layer
Cherry-picked upstream PR #522 (da4d3b5) with conflict resolution:
- Added new lib/hal/ files (HalDisplay, HalGPIO)
- Updated GfxRenderer to use HalDisplay, preserving base viewable margins
- Adopted PR #522's MappedInputManager lookup table implementation
- Updated main.cpp to use HAL while preserving custom Serial initialization
- Updated all EInkDisplay::RefreshMode references to HalDisplay::RefreshMode

This introduces a Hardware Abstraction Layer for display and GPIO,
enabling easier emulation and testing.
2026-01-30 22:49:52 -05:00
cottongin
448ce55bb4
Add individual book cache clearing with preserve progress option
- Add "Clear Cache" option to book action menu in MyLibrary (both Recent and Files tabs)
- Prompt user to preserve reading progress when clearing cache
- Always preserve bookmarks when clearing individual book cache
- Add preserve progress option to system-level Clear Cache in Settings
- Implement BookManager::clearBookCache() for selective cache clearing
2026-01-30 22:22:22 -05:00
cottongin
5464d9de3a
fix: webserver stability, memory leaks, epub underlining, flash screen
Webserver:
- Remove MD5 hash computation from file listings (caused EAGAIN errors)
- Implement JSON batching (2KB) with pacing for file listings
- Simplify sendContentSafe() flow control

Memory:
- Cache QR codes on server start instead of regenerating per render
- Optimize WiFi scan: vector deduplication, 20 network limit, early scanDelete()
- Fix 48KB cover buffer leak when navigating Home -> File Transfer

EPUB Reader:
- Fix errant underlining by flushing partWordBuffer before style changes
- Text before styled inline elements (e.g., <a> with CSS underline) no longer
  incorrectly receives the element's styling

Flash Screen:
- Fix version string buffer overflow (30 -> 50 char limit)
- Use half refresh for cleaner display
- Adjust pre_flash.py timing for half refresh completion
2026-01-30 22:00:15 -05:00
44 changed files with 1197 additions and 546 deletions

View File

@ -6,6 +6,62 @@ Base: CrossPoint Reader 0.15.0
--- ---
## ef-1.0.5
**Stability & Memory Improvements**
### Bug Fixes - Webserver
- **File Transfer Stability**: Removed blocking MD5 hash computation from file listings that caused EAGAIN errors and connection stalls
- **JSON Batching**: Implemented 2KB batch streaming for file listings with pacing to prevent TCP buffer overflow
- **Simplified Flow Control**: Removed unnecessary yield/delay logic from content streaming
### Bug Fixes - Memory
- **QR Code Caching**: Generate QR codes once on server start instead of regenerating on each screen render
- **WiFi Scan Optimization**: Replaced memory-heavy `std::map` deduplication with in-place vector search, limited results to 20 networks, earlier `WiFi.scanDelete()` for faster memory recovery
- **Cover Buffer Leak**: Fixed 48KB memory leak when navigating from Home to File Transfer (cover buffer now explicitly freed)
### Bug Fixes - EPUB Reader
- **Errant Underlining**: Fixed words before styled inline elements (like `<a>` tags with CSS underline) incorrectly receiving the element's style by flushing the text buffer before style changes
### Bug Fixes - Flashing Screen
- **Version String Overflow**: Fixed flash notification parsing failing on longer version strings (buffer limit increased from 30 to 50 characters)
- **Display Quality**: Changed flashing screen to half refresh for cleaner appearance
- **Timing**: Adjusted pre-flash script timing for half refresh completion
### Upstream Merges
- **PR #522 - HAL Abstraction Layer**: Merged hardware abstraction layer refactor introducing `HalDisplay` and `HalGPIO` classes, decoupling application code from direct hardware access
- **PR #603 - Sunlight Fading Fix**: Added user-toggleable setting to turn off display between refreshes, mitigating the sunlight fading issue on e-ink displays
- New "Sunlight Fading Fix" toggle in Display settings (OFF/ON)
- Passes `turnOffScreen` parameter through display stack when enabled
### Files Changed
- `src/main.cpp` - flash screen fixes, cover buffer free on File Transfer entry, fading fix integration
- `scripts/pre_flash.py` - timing adjustments for full refresh
- `src/network/CrossPointWebServer.cpp` - JSON batching, removed MD5 from listings
- `src/network/CrossPointWebServer.h` - removed md5 from FileInfo, simplified sendContentSafe
- `src/activities/network/CrossPointWebServerActivity.cpp` - QR code caching
- `src/activities/network/CrossPointWebServerActivity.h` - QR code cache members
- `src/activities/network/WifiSelectionActivity.cpp` - WiFi scan memory optimization
- `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp` - flush buffer before style changes
- `lib/hal/HalDisplay.h` - new HAL abstraction for display (PR #522), turnOffScreen parameter (PR #603)
- `lib/hal/HalDisplay.cpp` - HAL display implementation with fading fix passthrough
- `lib/hal/HalGPIO.h` - new HAL abstraction for GPIO (PR #522)
- `lib/hal/HalGPIO.cpp` - HAL GPIO implementation
- `lib/GfxRenderer/GfxRenderer.h` - updated for HAL layer, added fadingFix member
- `lib/GfxRenderer/GfxRenderer.cpp` - updated for HAL layer, passes fadingFix to display
- `src/CrossPointSettings.h` - added fadingFix setting
- `src/CrossPointSettings.cpp` - fadingFix persistence
- `src/activities/settings/SettingsActivity.cpp` - added Sunlight Fading Fix toggle
- `open-x4-sdk` - updated submodule with turnOffScreen support in EInkDisplay
---
## ef-1.0.4 ## ef-1.0.4
**EPUB Rendering & Stability** **EPUB Rendering & Stability**

View File

@ -380,19 +380,15 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
// *** CRITICAL STEP: CONSUME DATA USING MOVE + ERASE *** // *** CRITICAL STEP: CONSUME DATA USING MOVE + ERASE ***
// Move first lineWordCount elements from words into lineWords // Move first lineWordCount elements from words into lineWords
std::vector<std::string> lineWords( std::vector<std::string> lineWords(std::make_move_iterator(words.begin()),
std::make_move_iterator(words.begin()),
std::make_move_iterator(words.begin() + lineWordCount)); std::make_move_iterator(words.begin() + lineWordCount));
words.erase(words.begin(), words.begin() + lineWordCount); words.erase(words.begin(), words.begin() + lineWordCount);
std::vector<EpdFontFamily::Style> lineWordStyles( std::vector<EpdFontFamily::Style> lineWordStyles(std::make_move_iterator(wordStyles.begin()),
std::make_move_iterator(wordStyles.begin()),
std::make_move_iterator(wordStyles.begin() + lineWordCount)); std::make_move_iterator(wordStyles.begin() + lineWordCount));
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + lineWordCount); wordStyles.erase(wordStyles.begin(), wordStyles.begin() + lineWordCount);
std::vector<bool> lineWordUnderlines( std::vector<bool> lineWordUnderlines(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
wordUnderlines.begin(),
wordUnderlines.begin() + lineWordCount);
wordUnderlines.erase(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount); wordUnderlines.erase(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
for (auto& word : lineWords) { for (auto& word : lineWords) {

View File

@ -2,9 +2,9 @@
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <SdFat.h> #include <SdFat.h>
#include <vector>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector>
#include "Block.h" #include "Block.h"
#include "BlockStyle.h" #include "BlockStyle.h"
@ -30,7 +30,8 @@ class TextBlock final : public Block {
public: public:
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos, explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
std::vector<EpdFontFamily::Style> word_styles, const Style style, std::vector<EpdFontFamily::Style> word_styles, const Style style,
const BlockStyle& blockStyle = BlockStyle(), std::vector<bool> word_underlines = std::vector<bool>()) const BlockStyle& blockStyle = BlockStyle(),
std::vector<bool> word_underlines = std::vector<bool>())
: words(std::move(words)), : words(std::move(words)),
wordXpos(std::move(word_xpos)), wordXpos(std::move(word_xpos)),
wordStyles(std::move(word_styles)), wordStyles(std::move(word_styles)),

View File

@ -485,6 +485,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} }
} }
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) { } else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
// Flush buffer with CURRENT style before changing effective style
self->flushPartWordBuffer();
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth); self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
// Push inline style entry for underline tag // Push inline style entry for underline tag
StyleStackEntry entry; StyleStackEntry entry;
@ -502,6 +505,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->inlineStyleStack.push_back(entry); self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle(); self->updateEffectiveInlineStyle();
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) { } else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
// Flush buffer with CURRENT style before changing effective style
self->flushPartWordBuffer();
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
// Push inline style entry for bold tag // Push inline style entry for bold tag
StyleStackEntry entry; StyleStackEntry entry;
@ -519,6 +525,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
self->inlineStyleStack.push_back(entry); self->inlineStyleStack.push_back(entry);
self->updateEffectiveInlineStyle(); self->updateEffectiveInlineStyle();
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) { } else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
// Flush buffer with CURRENT style before changing effective style
self->flushPartWordBuffer();
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
// Push inline style entry for italic tag // Push inline style entry for italic tag
StyleStackEntry entry; StyleStackEntry entry;
@ -538,6 +547,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} else if (strcmp(name, "span") == 0 || !isBlockElement) { } else if (strcmp(name, "span") == 0 || !isBlockElement) {
// Handle span and other inline elements for CSS styling // Handle span and other inline elements for CSS styling
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) { if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
// Flush buffer with CURRENT style before changing effective style
// This prevents text accumulated before this element from getting the new style
self->flushPartWordBuffer();
StyleStackEntry entry; StyleStackEntry entry;
entry.depth = self->depth; // Track depth for matching pop entry.depth = self->depth; // Track depth for matching pop
if (cssStyle.hasFontWeight()) { if (cssStyle.hasFontWeight()) {
@ -573,6 +586,33 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser); self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
} }
// If we're inside an <li> but no text block was created yet (direct text without inner <p>),
// create a text block and add the list marker now
if (self->insideListItem && !self->listItemHasContent) {
// Apply left margin for list items
CssStyle cssStyle;
cssStyle.marginLeft = 24.0f; // Default indent (~1.5em at 16px base)
cssStyle.defined.marginLeft = 1;
BlockStyle blockStyle = createBlockStyleFromCss(cssStyle);
self->startNewTextBlock(static_cast<TextBlock::Style>(self->paragraphAlignment), blockStyle);
// Add the list marker
if (!self->listStack.empty()) {
const ListContext& ctx = self->listStack.back();
if (ctx.isOrdered) {
std::string marker = std::to_string(ctx.counter) + ". ";
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
} else {
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
} else {
// No list context (orphan li), use bullet as fallback
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
}
self->listItemHasContent = true;
}
// Determine font style from depth-based tracking and CSS effective style // Determine font style from depth-based tracking and CSS effective style
const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold; const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold;
const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic; const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic;

View File

@ -10,19 +10,19 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int
// Logical portrait (480x800) → panel (800x480) // Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees clockwise // Rotation: 90 degrees clockwise
*rotatedX = y; *rotatedX = y;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - x;
break; break;
} }
case LandscapeClockwise: { case LandscapeClockwise: {
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right) // Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x; *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - x;
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y; *rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - y;
break; break;
} }
case PortraitInverted: { case PortraitInverted: {
// Logical portrait (480x800) → panel (800x480) // Logical portrait (480x800) → panel (800x480)
// Rotation: 90 degrees counter-clockwise // Rotation: 90 degrees counter-clockwise
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y; *rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - y;
*rotatedY = x; *rotatedY = x;
break; 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 { 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 // Early return if no framebuffer is set
if (!frameBuffer) { if (!frameBuffer) {
@ -49,14 +49,13 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
rotateCoordinates(x, y, &rotatedX, &rotatedY); rotateCoordinates(x, y, &rotatedX, &rotatedY);
// Bounds checking against physical panel dimensions // Bounds checking against physical panel dimensions
if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 || if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= HalDisplay::DISPLAY_HEIGHT) {
rotatedY >= EInkDisplay::DISPLAY_HEIGHT) {
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY); Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
return; return;
} }
// Calculate byte position and bit position // 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 const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first
if (state) { if (state) {
@ -202,7 +201,7 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
break; break;
} }
// TODO: Rotate bits // TODO: Rotate bits
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); display.drawImage(bitmap, rotatedX, rotatedY, width, height);
} }
void GfxRenderer::drawImageRotated(const uint8_t bitmap[], const int x, const int y, const int width, const int height, void GfxRenderer::drawImageRotated(const uint8_t bitmap[], const int x, const int y, const int width, const int height,
@ -519,21 +518,21 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
free(nodeX); 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 { void GfxRenderer::invertScreen() const {
uint8_t* buffer = einkDisplay.getFrameBuffer(); uint8_t* buffer = display.getFrameBuffer();
if (!buffer) { if (!buffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis()); Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
return; return;
} }
for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) { for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) {
buffer[i] = ~buffer[i]; buffer[i] = ~buffer[i];
} }
} }
void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) const { void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const {
einkDisplay.displayBuffer(refreshMode); display.displayBuffer(refreshMode, fadingFix);
} }
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
@ -553,13 +552,13 @@ int GfxRenderer::getScreenWidth() const {
case Portrait: case Portrait:
case PortraitInverted: case PortraitInverted:
// 480px wide in portrait logical coordinates // 480px wide in portrait logical coordinates
return EInkDisplay::DISPLAY_HEIGHT; return HalDisplay::DISPLAY_HEIGHT;
case LandscapeClockwise: case LandscapeClockwise:
case LandscapeCounterClockwise: case LandscapeCounterClockwise:
// 800px wide in landscape logical coordinates // 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 { int GfxRenderer::getScreenHeight() const {
@ -567,13 +566,13 @@ int GfxRenderer::getScreenHeight() const {
case Portrait: case Portrait:
case PortraitInverted: case PortraitInverted:
// 800px tall in portrait logical coordinates // 800px tall in portrait logical coordinates
return EInkDisplay::DISPLAY_WIDTH; return HalDisplay::DISPLAY_WIDTH;
case LandscapeClockwise: case LandscapeClockwise:
case LandscapeCounterClockwise: case LandscapeCounterClockwise:
// 480px tall in landscape logical coordinates // 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 { int GfxRenderer::getSpaceWidth(const int fontId) const {
@ -902,17 +901,18 @@ void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int
} }
} }
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(fadingFix); }
void GfxRenderer::freeBwBufferChunks() { void GfxRenderer::freeBwBufferChunks() {
for (auto& bwBufferChunk : bwBufferChunks) { for (auto& bwBufferChunk : bwBufferChunks) {
@ -930,7 +930,7 @@ void GfxRenderer::freeBwBufferChunks() {
* Returns true if buffer was stored successfully, false if allocation failed. * Returns true if buffer was stored successfully, false if allocation failed.
*/ */
bool GfxRenderer::storeBwBuffer() { bool GfxRenderer::storeBwBuffer() {
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); const uint8_t* frameBuffer = display.getFrameBuffer();
if (!frameBuffer) { if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis()); Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
return false; return false;
@ -985,14 +985,14 @@ void GfxRenderer::restoreBwBuffer() {
// CRITICAL: Even if restore fails, we must clean up the grayscale state // CRITICAL: Even if restore fails, we must clean up the grayscale state
// to prevent grayscaleRevert() from being called with corrupted RAM state // to prevent grayscaleRevert() from being called with corrupted RAM state
// Use the current framebuffer content (which may not be ideal but prevents worse issues) // Use the current framebuffer content (which may not be ideal but prevents worse issues)
uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); uint8_t* frameBuffer = display.getFrameBuffer();
if (frameBuffer) { if (frameBuffer) {
einkDisplay.cleanupGrayscaleBuffers(frameBuffer); display.cleanupGrayscaleBuffers(frameBuffer);
} }
return; return;
} }
uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); uint8_t* frameBuffer = display.getFrameBuffer();
if (!frameBuffer) { if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis()); Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
freeBwBufferChunks(); freeBwBufferChunks();
@ -1005,7 +1005,7 @@ void GfxRenderer::restoreBwBuffer() {
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis()); Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
freeBwBufferChunks(); freeBwBufferChunks();
// CRITICAL: Clean up grayscale state even on mid-restore failure // CRITICAL: Clean up grayscale state even on mid-restore failure
einkDisplay.cleanupGrayscaleBuffers(frameBuffer); display.cleanupGrayscaleBuffers(frameBuffer);
return; return;
} }
@ -1013,7 +1013,7 @@ void GfxRenderer::restoreBwBuffer() {
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE); memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
} }
einkDisplay.cleanupGrayscaleBuffers(frameBuffer); display.cleanupGrayscaleBuffers(frameBuffer);
freeBwBufferChunks(); freeBwBufferChunks();
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
@ -1024,9 +1024,9 @@ void GfxRenderer::restoreBwBuffer() {
* Use this when BW buffer was re-rendered instead of stored/restored. * Use this when BW buffer was re-rendered instead of stored/restored.
*/ */
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); uint8_t* frameBuffer = display.getFrameBuffer();
if (frameBuffer) { if (frameBuffer) {
einkDisplay.cleanupGrayscaleBuffers(frameBuffer); display.cleanupGrayscaleBuffers(frameBuffer);
} }
} }

View File

@ -1,7 +1,7 @@
#pragma once #pragma once
#include <EInkDisplay.h>
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <HalDisplay.h>
#include <map> #include <map>
@ -24,8 +24,8 @@ class GfxRenderer {
private: private:
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory 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 constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_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"); "BW buffer chunking does not line up with display buffer size");
// Base viewable margins (hardware-specific, before bezel compensation) // Base viewable margins (hardware-specific, before bezel compensation)
@ -34,9 +34,10 @@ class GfxRenderer {
static constexpr int BASE_VIEWABLE_MARGIN_BOTTOM = 3; static constexpr int BASE_VIEWABLE_MARGIN_BOTTOM = 3;
static constexpr int BASE_VIEWABLE_MARGIN_LEFT = 3; static constexpr int BASE_VIEWABLE_MARGIN_LEFT = 3;
EInkDisplay& einkDisplay; HalDisplay& display;
RenderMode renderMode; RenderMode renderMode;
Orientation orientation; Orientation orientation;
bool fadingFix = false; // Sunlight fading fix - turn off screen after refresh
int bezelCompensation = 0; // Pixels to add for bezel defect compensation int bezelCompensation = 0; // Pixels to add for bezel defect compensation
int bezelEdge = 0; // Which physical edge (0=bottom, 1=top, 2=left, 3=right in portrait) int bezelEdge = 0; // Which physical edge (0=bottom, 1=top, 2=left, 3=right in portrait)
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
@ -47,7 +48,7 @@ class GfxRenderer {
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
public: public:
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {} explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {}
~GfxRenderer() { freeBwBufferChunks(); } ~GfxRenderer() { freeBwBufferChunks(); }
// Viewable margins (includes bezel compensation applied to the configured edge) // Viewable margins (includes bezel compensation applied to the configured edge)
@ -76,10 +77,13 @@ class GfxRenderer {
void setOrientation(const Orientation o) { orientation = o; } void setOrientation(const Orientation o) { orientation = o; }
Orientation getOrientation() const { return orientation; } Orientation getOrientation() const { return orientation; }
// Fading fix control
void setFadingFix(const bool enabled) { fadingFix = enabled; }
// Screen ops // Screen ops
int getScreenWidth() const; int getScreenWidth() const;
int getScreenHeight() 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 // 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 invertScreen() const;

View File

@ -299,8 +299,7 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
const uint32_t endChunk = (offset + size - 1) / dzInfo.chunkLength; const uint32_t endChunk = (offset + size - 1) / dzInfo.chunkLength;
const uint32_t startOffsetInChunk = offset % dzInfo.chunkLength; const uint32_t startOffsetInChunk = offset % dzInfo.chunkLength;
Serial.printf("[DICT-DBG] Chunks: start=%lu, end=%lu, total=%u\n", Serial.printf("[DICT-DBG] Chunks: start=%lu, end=%lu, total=%u\n", startChunk, endChunk, dzInfo.chunkCount);
startChunk, endChunk, dzInfo.chunkCount);
if (endChunk >= dzInfo.chunkCount) { if (endChunk >= dzInfo.chunkCount) {
Serial.printf("[DICT-DBG] endChunk %lu >= chunkCount %u\n", endChunk, dzInfo.chunkCount); Serial.printf("[DICT-DBG] endChunk %lu >= chunkCount %u\n", endChunk, dzInfo.chunkCount);
@ -324,8 +323,8 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
// Allocate buffers - allocate inflator FIRST (smallest) to reduce fragmentation impact // Allocate buffers - allocate inflator FIRST (smallest) to reduce fragmentation impact
// tinfl_decompressor is ~11KB, so total allocations are ~85KB // tinfl_decompressor is ~11KB, so total allocations are ~85KB
Serial.printf("[DICT-DBG] Allocating inflator=%u, comp=%lu, decomp=%u bytes\n", Serial.printf("[DICT-DBG] Allocating inflator=%u, comp=%lu, decomp=%u bytes\n", sizeof(tinfl_decompressor),
sizeof(tinfl_decompressor), maxCompressedSize, dzInfo.chunkLength); maxCompressedSize, dzInfo.chunkLength);
auto* inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor))); auto* inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) { if (!inflator) {
@ -469,8 +468,7 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
return result; return result;
} }
Serial.printf("[DICT-DBG] Searching for: '%s' (normalized: '%s')\n", Serial.printf("[DICT-DBG] Searching for: '%s' (normalized: '%s')\n", word.c_str(), normalizedSearch.c_str());
word.c_str(), normalizedSearch.c_str());
// First try .idx (main entries) - use prefix jump table for fast lookup // First try .idx (main entries) - use prefix jump table for fast lookup
const std::string idxPath = basePath + ".idx"; const std::string idxPath = basePath + ".idx";
@ -487,8 +485,8 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]); const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]);
position = DictPrefixIndex::dictPrefixOffsets[prefixIdx]; position = DictPrefixIndex::dictPrefixOffsets[prefixIdx];
} }
Serial.printf("[DICT-DBG] Starting at position %lu (prefix: %c%c)\n", Serial.printf("[DICT-DBG] Starting at position %lu (prefix: %c%c)\n", position, normalizedSearch[0],
position, normalizedSearch[0], normalizedSearch[1]); normalizedSearch[1]);
bool found = false; bool found = false;
uint32_t wordCount = 0; uint32_t wordCount = 0;
@ -501,19 +499,18 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
} }
wordCount++; wordCount++;
if (wordCount % 50000 == 0) { if (wordCount % 50000 == 0) {
Serial.printf("[DICT-DBG] Progress: %lu words scanned, pos=%lu, current='%s'\n", Serial.printf("[DICT-DBG] Progress: %lu words scanned, pos=%lu, current='%s'\n", wordCount, position,
wordCount, position, currentWord.c_str()); currentWord.c_str());
} }
// Use stardictStrcmp for case-insensitive matching // Use stardictStrcmp for case-insensitive matching
const int cmp = stardictStrcmp(normalizedSearch, currentWord); const int cmp = stardictStrcmp(normalizedSearch, currentWord);
if (cmp == 0) { if (cmp == 0) {
Serial.printf("[DICT-DBG] MATCH: '%s' == '%s' (offset=%lu, size=%lu)\n", Serial.printf("[DICT-DBG] MATCH: '%s' == '%s' (offset=%lu, size=%lu)\n", normalizedSearch.c_str(),
normalizedSearch.c_str(), currentWord.c_str(), dictOffset, dictSize); currentWord.c_str(), dictOffset, dictSize);
std::string definition; std::string definition;
const bool loaded = useUncompressed const bool loaded = useUncompressed ? readDefinitionDirect(dictOffset, dictSize, definition)
? readDefinitionDirect(dictOffset, dictSize, definition)
: decompressDefinition(dictOffset, dictSize, definition); : decompressDefinition(dictOffset, dictSize, definition);
if (loaded) { if (loaded) {
Serial.printf("[DICT-DBG] Definition loaded, %u bytes\n", definition.length()); Serial.printf("[DICT-DBG] Definition loaded, %u bytes\n", definition.length());
@ -537,8 +534,7 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
// may not land exactly at target position // may not land exactly at target position
} }
Serial.printf("[DICT-DBG] Search complete: %lu words scanned, found=%s\n", Serial.printf("[DICT-DBG] Search complete: %lu words scanned, found=%s\n", wordCount, found ? "YES" : "NO");
wordCount, found ? "YES" : "NO");
idxFile.close(); idxFile.close();
// If not found in main index, try synonym file with prefix jump // If not found in main index, try synonym file with prefix jump
@ -591,8 +587,7 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
uint32_t dictOffset, dictSize; uint32_t dictOffset, dictSize;
if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) { if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) {
std::string definition; std::string definition;
const bool loaded = useUncompressed const bool loaded = useUncompressed ? readDefinitionDirect(dictOffset, dictSize, definition)
? readDefinitionDirect(dictOffset, dictSize, definition)
: decompressDefinition(dictOffset, dictSize, definition); : decompressDefinition(dictOffset, dictSize, definition);
if (loaded) { if (loaded) {
result.word = synWord; result.word = synWord;

53
lib/hal/HalDisplay.cpp Normal file
View File

@ -0,0 +1,53 @@
#include <HalDisplay.h>
#include <HalGPIO.h>
#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, bool turnOffScreen) {
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
}
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(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); }

52
lib/hal/HalDisplay.h Normal file
View File

@ -0,0 +1,52 @@
#pragma once
#include <Arduino.h>
#include <EInkDisplay.h>
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, bool turnOffScreen = false);
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(bool turnOffScreen = false);
private:
EInkDisplay einkDisplay;
};

55
lib/hal/HalGPIO.cpp Normal file
View File

@ -0,0 +1,55 @@
#include <HalGPIO.h>
#include <SPI.h>
#include <esp_sleep.h>
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() {
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(BTN_POWER)) {
delay(50);
inputMgr.update();
}
// 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;
}
bool HalGPIO::isWakeupByPowerButton() const {
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);
}
}

61
lib/hal/HalGPIO.h Normal file
View File

@ -0,0 +1,61 @@
#pragma once
#include <Arduino.h>
#include <BatteryMonitor.h>
#include <InputManager.h>
// 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;
// Check if wakeup was caused by power button press
bool isWakeupByPowerButton() 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;
};

@ -1 +1 @@
Subproject commit dede09001c4c7bc96bd3616716cdf80913f57658 Subproject commit be6ba1b62b1262929cded6ccdae774a098d33010

View File

@ -3,7 +3,7 @@ default_envs = default
[crosspoint] [crosspoint]
# 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch # 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch
version = 0.15.ef-1.0.4 version = 0.15.ef-1.0.5
[base] [base]
platform = espressif32 @ 6.12.0 platform = espressif32 @ 6.12.0

View File

@ -5,7 +5,11 @@ This allows the firmware to display "Flashing firmware..." on the e-ink display
before the actual flash begins. The e-ink retains this message throughout the before the actual flash begins. The e-ink retains this message throughout the
flash process since it doesn't require power to maintain the display. flash process since it doesn't require power to maintain the display.
Protocol: Sends "FLASH:version\n" where version is read from platformio.ini Protocol (Plan A - Simple timing):
1. Host opens serial port and sends "FLASH:version"
2. Host keeps port open briefly for device to receive and process
3. Device displays flash screen when it receives the command
4. Host proceeds with flash
""" """
Import("env") Import("env")
@ -15,7 +19,7 @@ from version_utils import get_version
def before_upload(source, target, env): def before_upload(source, target, env):
"""Send FLASH command with version to device before upload begins.""" """Send FLASH command to device before uploading firmware."""
port = env.GetProjectOption("upload_port", None) port = env.GetProjectOption("upload_port", None)
if not port: if not port:
@ -29,19 +33,20 @@ def before_upload(source, target, env):
] ]
port = ports[0] if ports else None port = ports[0] if ports else None
if port: if not port:
print("[pre_flash] No serial port found, skipping notification")
return
try: try:
version = get_version(env) version = get_version(env)
ser = serial.Serial(port, 115200, timeout=1) ser = serial.Serial(port, 115200, timeout=1)
ser.write(f"FLASH:{version}\n".encode()) ser.write(f"FLASH:{version}\n".encode())
ser.flush() ser.flush()
time.sleep(4.0) # Keep port open for device to receive and complete full refresh (~2-3s)
ser.close() ser.close()
time.sleep(0.8) # Wait for e-ink fast refresh (~500ms) plus margin
print(f"[pre_flash] Flash notification sent to {port} (version {version})") print(f"[pre_flash] Flash notification sent to {port} (version {version})")
except Exception as e: except Exception as e:
print(f"[pre_flash] Notification skipped: {e}") print(f"[pre_flash] Notification skipped: {e}")
else:
print("[pre_flash] No serial port found, skipping notification")
env.AddPreAction("upload", before_upload) env.AddPreAction("upload", before_upload)

View File

@ -303,6 +303,86 @@ bool BookManager::deleteBook(const std::string& bookPath, bool isArchived) {
return true; return true;
} }
bool BookManager::clearBookCache(const std::string& bookPath, bool preserveProgress) {
Serial.printf("[%lu] [%s] Clearing cache for: %s (preserveProgress=%d)\n", millis(), LOG_TAG, bookPath.c_str(),
preserveProgress);
const std::string cacheDir = getCacheDir(bookPath);
if (cacheDir.empty()) {
Serial.printf("[%lu] [%s] No cache directory for unsupported format\n", millis(), LOG_TAG);
return true; // Nothing to clear, not an error
}
if (!SdMan.exists(cacheDir.c_str())) {
Serial.printf("[%lu] [%s] Cache directory doesn't exist: %s\n", millis(), LOG_TAG, cacheDir.c_str());
return true; // Nothing to clear, not an error
}
FsFile dir = SdMan.open(cacheDir.c_str());
if (!dir || !dir.isDirectory()) {
Serial.printf("[%lu] [%s] Failed to open cache directory\n", millis(), LOG_TAG);
if (dir) dir.close();
return false;
}
// Files to preserve (always keep bookmarks, optionally keep progress)
const auto shouldPreserve = [preserveProgress](const char* name) {
// Always preserve bookmarks
if (strcmp(name, "bookmarks.bin") == 0) return true;
// Optionally preserve progress
if (preserveProgress && strcmp(name, "progress.bin") == 0) return true;
return false;
};
int deletedCount = 0;
int failedCount = 0;
char name[128];
// First pass: delete files (not directories)
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
entry.getName(name, sizeof(name));
const bool isDir = entry.isDirectory();
entry.close();
if (!isDir && !shouldPreserve(name)) {
std::string fullPath = cacheDir + "/" + name;
if (SdMan.remove(fullPath.c_str())) {
deletedCount++;
} else {
Serial.printf("[%lu] [%s] Failed to delete: %s\n", millis(), LOG_TAG, fullPath.c_str());
failedCount++;
}
}
}
dir.close();
// Second pass: delete subdirectories (like "sections/")
dir = SdMan.open(cacheDir.c_str());
if (dir && dir.isDirectory()) {
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
entry.getName(name, sizeof(name));
const bool isDir = entry.isDirectory();
entry.close();
if (isDir) {
std::string fullPath = cacheDir + "/" + name;
if (SdMan.removeDir(fullPath.c_str())) {
deletedCount++;
Serial.printf("[%lu] [%s] Deleted subdirectory: %s\n", millis(), LOG_TAG, fullPath.c_str());
} else {
Serial.printf("[%lu] [%s] Failed to delete subdirectory: %s\n", millis(), LOG_TAG, fullPath.c_str());
failedCount++;
}
}
}
dir.close();
}
Serial.printf("[%lu] [%s] Cache cleared: %d items deleted, %d failed\n", millis(), LOG_TAG, deletedCount,
failedCount);
return failedCount == 0;
}
std::vector<std::string> BookManager::listArchivedBooks() { std::vector<std::string> BookManager::listArchivedBooks() {
std::vector<std::string> archivedBooks; std::vector<std::string> archivedBooks;

View File

@ -57,6 +57,14 @@ class BookManager {
*/ */
static std::string getCacheDir(const std::string& bookPath); static std::string getCacheDir(const std::string& bookPath);
/**
* Clear cache for a single book, optionally preserving reading progress
* @param bookPath Full path to the book file
* @param preserveProgress If true, keeps progress.bin and bookmarks.bin
* @return true if successful (or if no cache exists)
*/
static bool clearBookCache(const std::string& bookPath, bool preserveProgress);
private: private:
// Extract filename from a full path // Extract filename from a full path
static std::string getFilename(const std::string& path); static std::string getFilename(const std::string& path);

View File

@ -23,7 +23,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields // Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 29; // 28 + bezelCompensationEdge constexpr uint8_t SETTINGS_COUNT = 30; // 29 + fadingFix
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
@ -72,6 +72,8 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, bezelCompensation); serialization::writePod(outputFile, bezelCompensation);
// Which physical edge needs bezel compensation // Which physical edge needs bezel compensation
serialization::writePod(outputFile, bezelCompensationEdge); serialization::writePod(outputFile, bezelCompensationEdge);
// Sunlight fading fix
serialization::writePod(outputFile, fadingFix);
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
outputFile.close(); outputFile.close();
@ -182,6 +184,9 @@ bool CrossPointSettings::loadFromFile() {
// Which physical edge needs bezel compensation // Which physical edge needs bezel compensation
readAndValidate(inputFile, bezelCompensationEdge, BEZEL_EDGE_COUNT); readAndValidate(inputFile, bezelCompensationEdge, BEZEL_EDGE_COUNT);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
// Sunlight fading fix
serialization::readPod(inputFile, fadingFix);
if (++settingsRead >= fileSettingsCount) break;
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
} while (false); } while (false);

View File

@ -144,6 +144,8 @@ class CrossPointSettings {
uint8_t hideBatteryPercentage = HIDE_NEVER; uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press chapter skip on side buttons // Long-press chapter skip on side buttons
uint8_t longPressChapterSkip = 1; uint8_t longPressChapterSkip = 1;
// Sunlight fading compensation (0 = off, 1 = on)
uint8_t fadingFix = 0;
// System-wide display contrast (0 = normal, 1 = high) // System-wide display contrast (0 = normal, 1 = high)
uint8_t displayContrast = 0; uint8_t displayContrast = 0;
// Bezel compensation - extra margin for physical screen edge defects (0-10px) // Bezel compensation - extra margin for physical screen edge defects (0-10px)

View File

@ -2,103 +2,79 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const { namespace {
using ButtonIndex = uint8_t;
struct FrontLayoutMap {
ButtonIndex back;
ButtonIndex confirm;
ButtonIndex left;
ButtonIndex right;
};
struct SideLayoutMap {
ButtonIndex pageBack;
ButtonIndex pageForward;
};
// Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT.
constexpr FrontLayoutMap kFrontLayouts[] = {
{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[] = {
{HalGPIO::BTN_UP, HalGPIO::BTN_DOWN},
{HalGPIO::BTN_DOWN, HalGPIO::BTN_UP},
};
} // namespace
bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const {
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout); const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout); const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
const auto& front = kFrontLayouts[frontLayout];
const auto& side = kSideLayouts[sideLayout];
switch (button) { switch (button) {
case Button::Back: case Button::Back:
switch (frontLayout) { return (gpio.*fn)(front.back);
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_LEFT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
/* fall through */
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
/* fall through */
default:
return InputManager::BTN_BACK;
}
case Button::Confirm: case Button::Confirm:
switch (frontLayout) { return (gpio.*fn)(front.confirm);
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_RIGHT;
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_LEFT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
/* fall through */
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
/* fall through */
default:
return InputManager::BTN_CONFIRM;
}
case Button::Left: case Button::Left:
switch (frontLayout) { return (gpio.*fn)(front.left);
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
/* fall through */
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
return InputManager::BTN_BACK;
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
return InputManager::BTN_RIGHT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
/* fall through */
default:
return InputManager::BTN_LEFT;
}
case Button::Right: case Button::Right:
switch (frontLayout) { return (gpio.*fn)(front.right);
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
return InputManager::BTN_LEFT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
/* fall through */
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
/* fall through */
default:
return InputManager::BTN_RIGHT;
}
case Button::Up: case Button::Up:
return InputManager::BTN_UP; return (gpio.*fn)(HalGPIO::BTN_UP);
case Button::Down: case Button::Down:
return InputManager::BTN_DOWN; return (gpio.*fn)(HalGPIO::BTN_DOWN);
case Button::Power: case Button::Power:
return InputManager::BTN_POWER; return (gpio.*fn)(HalGPIO::BTN_POWER);
case Button::PageBack: case Button::PageBack:
switch (sideLayout) { return (gpio.*fn)(side.pageBack);
case CrossPointSettings::NEXT_PREV:
return InputManager::BTN_DOWN;
case CrossPointSettings::PREV_NEXT:
/* fall through */
default:
return InputManager::BTN_UP;
}
case Button::PageForward: case Button::PageForward:
switch (sideLayout) { return (gpio.*fn)(side.pageForward);
case CrossPointSettings::NEXT_PREV:
return InputManager::BTN_UP;
case CrossPointSettings::PREV_NEXT:
/* fall through */
default:
return InputManager::BTN_DOWN;
}
} }
return InputManager::BTN_BACK; return false;
} }
bool MappedInputManager::wasPressed(const Button button) const { return inputManager.wasPressed(mapButton(button)); } bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
bool MappedInputManager::wasReleased(const Button button) const { return inputManager.wasReleased(mapButton(button)); } bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
bool MappedInputManager::isPressed(const Button button) const { return inputManager.isPressed(mapButton(button)); } 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(); }
bool MappedInputManager::isUsbConnected() const { return gpio.isUsbConnected(); }
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous, MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const { const char* next) const {

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include <InputManager.h> #include <HalGPIO.h>
class MappedInputManager { class MappedInputManager {
public: public:
@ -13,7 +13,7 @@ class MappedInputManager {
const char* btn4; const char* btn4;
}; };
explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {} explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
bool wasPressed(Button button) const; bool wasPressed(Button button) const;
bool wasReleased(Button button) const; bool wasReleased(Button button) const;
@ -21,9 +21,11 @@ class MappedInputManager {
bool wasAnyPressed() const; bool wasAnyPressed() const;
bool wasAnyReleased() const; bool wasAnyReleased() const;
unsigned long getHeldTime() const; unsigned long getHeldTime() const;
bool isUsbConnected() const;
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const; Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
private: private:
InputManager& inputManager; HalGPIO& gpio;
decltype(InputManager::BTN_BACK) mapButton(Button button) const;
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
}; };

View File

@ -11,14 +11,14 @@
#include "fontIds.h" #include "fontIds.h"
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top, void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top,
const bool showPercentage) { const bool showPercentage, const bool isCharging) {
// Left aligned battery icon and percentage // Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage(); const uint16_t percentage = battery.readPercentage();
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); renderer.drawText(SMALL_FONT_ID, left + 28, top, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body // 1.5x original width: 23px wide, 12px tall
constexpr int batteryWidth = 15; constexpr int batteryWidth = 23;
constexpr int batteryHeight = 12; constexpr int batteryHeight = 12;
const int x = left; const int x = left;
const int y = top + 6; const int y = top + 6;
@ -29,30 +29,69 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1); renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1);
// Left line // Left line
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2); renderer.drawLine(x, y + 1, x, y + batteryHeight - 2);
// Battery end // Battery end (right side with nub)
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2); renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2);
renderer.drawPixel(x + batteryWidth - 1, y + 3); renderer.drawPixel(x + batteryWidth - 1, y + 3);
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4); renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4);
renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5); renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5);
// The +1 is to round up, so that we always fill at least one pixel // The +1 is to round up, so that we always fill at least one pixel
// Fill area is batteryWidth - 5 = 18px
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) { if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow filledWidth = batteryWidth - 5; // Ensure we don't overflow
} }
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
// Draw 8x8 lightning bolt overlay when charging, centered in fill area
if (isCharging) {
// Center bolt in the full fill area (as if 100% charged)
const int fillAreaWidth = batteryWidth - 5; // 18px
const int fillAreaHeight = batteryHeight - 4; // 8px
const int boltX = x + 2 + (fillAreaWidth - 8) / 2; // Center 8px bolt in 18px fill area
const int boltY = y + 2 + (fillAreaHeight - 8) / 2; // Center 8px bolt in 8px fill area
// 8x8 lightning bolt from SVG: m8 22l1-7H4l9-13h2l-1 8h6L10 22z
// Row 0
renderer.drawPixel(boltX + 4, boltY + 0, false);
renderer.drawPixel(boltX + 5, boltY + 0, false);
// Row 1
renderer.drawPixel(boltX + 3, boltY + 1, false);
renderer.drawPixel(boltX + 4, boltY + 1, false);
// Row 2
renderer.drawPixel(boltX + 2, boltY + 2, false);
renderer.drawPixel(boltX + 3, boltY + 2, false);
renderer.drawPixel(boltX + 4, boltY + 2, false);
// Row 3
renderer.drawPixel(boltX + 1, boltY + 3, false);
renderer.drawPixel(boltX + 2, boltY + 3, false);
renderer.drawPixel(boltX + 3, boltY + 3, false);
renderer.drawPixel(boltX + 4, boltY + 3, false);
renderer.drawPixel(boltX + 5, boltY + 3, false);
// Row 4
renderer.drawPixel(boltX + 3, boltY + 4, false);
renderer.drawPixel(boltX + 4, boltY + 4, false);
renderer.drawPixel(boltX + 5, boltY + 4, false);
// Row 5
renderer.drawPixel(boltX + 3, boltY + 5, false);
renderer.drawPixel(boltX + 4, boltY + 5, false);
// Row 6
renderer.drawPixel(boltX + 2, boltY + 6, false);
renderer.drawPixel(boltX + 3, boltY + 6, false);
// Row 7
renderer.drawPixel(boltX + 2, boltY + 7, false);
}
} }
void ScreenComponents::drawBatteryLarge(const GfxRenderer& renderer, const int left, const int top, void ScreenComponents::drawBatteryLarge(const GfxRenderer& renderer, const int left, const int top,
const bool showPercentage) { const bool showPercentage, const bool isCharging) {
// Larger battery icon with UI_10 font for bottom button hint area // Larger battery icon with UI_10 font for bottom button hint area
const uint16_t percentage = battery.readPercentage(); const uint16_t percentage = battery.readPercentage();
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
renderer.drawText(UI_10_FONT_ID, left + 28, top, percentageText.c_str()); renderer.drawText(UI_10_FONT_ID, left + 38, top, percentageText.c_str());
// Scaled up battery dimensions (~33% larger) // 1.5x original width: 30px wide, 16px tall
constexpr int batteryWidth = 20; constexpr int batteryWidth = 30;
constexpr int batteryHeight = 16; constexpr int batteryHeight = 16;
const int x = left; const int x = left;
const int y = top + 6; const int y = top + 6;
@ -71,12 +110,70 @@ void ScreenComponents::drawBatteryLarge(const GfxRenderer& renderer, const int l
renderer.drawLine(x + batteryWidth - 1, y + 5, x + batteryWidth - 1, y + batteryHeight - 6); renderer.drawLine(x + batteryWidth - 1, y + 5, x + batteryWidth - 1, y + batteryHeight - 6);
// The +1 is to round up, so that we always fill at least one pixel // The +1 is to round up, so that we always fill at least one pixel
// Fill area is batteryWidth - 6 = 24px
int filledWidth = percentage * (batteryWidth - 6) / 100 + 1; int filledWidth = percentage * (batteryWidth - 6) / 100 + 1;
if (filledWidth > batteryWidth - 6) { if (filledWidth > batteryWidth - 6) {
filledWidth = batteryWidth - 6; // Ensure we don't overflow filledWidth = batteryWidth - 6; // Ensure we don't overflow
} }
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
// Draw 12x12 lightning bolt overlay when charging, centered in fill area
if (isCharging) {
// Center bolt in the full fill area (as if 100% charged)
const int fillAreaWidth = batteryWidth - 6; // 24px
const int fillAreaHeight = batteryHeight - 4; // 12px
const int boltX = x + 2 + (fillAreaWidth - 12) / 2; // Center 12px bolt in 24px fill area
const int boltY = y + 2 + (fillAreaHeight - 12) / 2; // Center 12px bolt in 12px fill area
// 12x12 lightning bolt from SVG: m8 22l1-7H4l9-13h2l-1 8h6L10 22z
// Row 0
renderer.drawPixel(boltX + 6, boltY + 0, false);
renderer.drawPixel(boltX + 7, boltY + 0, false);
// Row 1
renderer.drawPixel(boltX + 5, boltY + 1, false);
renderer.drawPixel(boltX + 6, boltY + 1, false);
renderer.drawPixel(boltX + 7, boltY + 1, false);
// Row 2
renderer.drawPixel(boltX + 4, boltY + 2, false);
renderer.drawPixel(boltX + 5, boltY + 2, false);
renderer.drawPixel(boltX + 6, boltY + 2, false);
// Row 3
renderer.drawPixel(boltX + 3, boltY + 3, false);
renderer.drawPixel(boltX + 4, boltY + 3, false);
renderer.drawPixel(boltX + 5, boltY + 3, false);
// Row 4
renderer.drawPixel(boltX + 2, boltY + 4, false);
renderer.drawPixel(boltX + 3, boltY + 4, false);
renderer.drawPixel(boltX + 4, boltY + 4, false);
renderer.drawPixel(boltX + 5, boltY + 4, false);
renderer.drawPixel(boltX + 6, boltY + 4, false);
renderer.drawPixel(boltX + 7, boltY + 4, false);
renderer.drawPixel(boltX + 8, boltY + 4, false);
// Row 5
renderer.drawPixel(boltX + 4, boltY + 5, false);
renderer.drawPixel(boltX + 5, boltY + 5, false);
renderer.drawPixel(boltX + 6, boltY + 5, false);
renderer.drawPixel(boltX + 7, boltY + 5, false);
renderer.drawPixel(boltX + 8, boltY + 5, false);
// Row 6
renderer.drawPixel(boltX + 5, boltY + 6, false);
renderer.drawPixel(boltX + 6, boltY + 6, false);
renderer.drawPixel(boltX + 7, boltY + 6, false);
// Row 7
renderer.drawPixel(boltX + 5, boltY + 7, false);
renderer.drawPixel(boltX + 6, boltY + 7, false);
// Row 8
renderer.drawPixel(boltX + 4, boltY + 8, false);
renderer.drawPixel(boltX + 5, boltY + 8, false);
// Row 9
renderer.drawPixel(boltX + 3, boltY + 9, false);
renderer.drawPixel(boltX + 4, boltY + 9, false);
// Row 10
renderer.drawPixel(boltX + 3, boltY + 10, false);
renderer.drawPixel(boltX + 4, boltY + 10, false);
// Row 11
renderer.drawPixel(boltX + 3, boltY + 11, false);
}
} }
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) { void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {

View File

@ -15,11 +15,13 @@ class ScreenComponents {
public: public:
static const int BOOK_PROGRESS_BAR_HEIGHT = 4; static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true,
bool isCharging = false);
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress); static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
// Draw a larger battery icon suitable for bottom button hint area // Draw a larger battery icon suitable for bottom button hint area
static void drawBatteryLarge(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); static void drawBatteryLarge(const GfxRenderer& renderer, int left, int top, bool showPercentage = true,
bool isCharging = false);
// Draw a horizontal tab bar with underline indicator for selected tab // Draw a horizontal tab bar with underline indicator for selected tab
// Returns the height of the tab bar (for positioning content below) // Returns the height of the tab bar (for positioning content below)

View File

@ -154,7 +154,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.invertScreen(); renderer.invertScreen();
} }
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
} }
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const { void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const {
@ -269,7 +269,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
renderer.invertScreen(); renderer.invertScreen();
} }
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
if (hasGreyscale) { if (hasGreyscale) {
// Grayscale LSB pass // Grayscale LSB pass
@ -400,7 +400,7 @@ void SleepActivity::renderCoverSleepScreen() const {
void SleepActivity::renderBlankSleepScreen() const { void SleepActivity::renderBlankSleepScreen() const {
renderer.clearScreen(); renderer.clearScreen();
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
} }
std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) { std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {

View File

@ -144,8 +144,8 @@ void DictionaryResultActivity::paginateDefinition() {
constexpr size_t CHUNK_SIZE_BASE = 1500; // Base chunk size constexpr size_t CHUNK_SIZE_BASE = 1500; // Base chunk size
const size_t chunkSize = std::max(CHUNK_SIZE_BASE, static_cast<size_t>(linesPerPage * 120)); const size_t chunkSize = std::max(CHUNK_SIZE_BASE, static_cast<size_t>(linesPerPage * 120));
Serial.printf("[DICT-DBG] Chunked parsing: defLen=%u, chunkSize=%u, linesPerPage=%d\n", Serial.printf("[DICT-DBG] Chunked parsing: defLen=%u, chunkSize=%u, linesPerPage=%d\n", rawDefinition.length(),
rawDefinition.length(), chunkSize, linesPerPage); chunkSize, linesPerPage);
// Determine how much to parse for first page // Determine how much to parse for first page
size_t parseEnd; size_t parseEnd;
@ -163,15 +163,13 @@ void DictionaryResultActivity::paginateDefinition() {
std::string chunk = rawDefinition.substr(0, parseEnd); std::string chunk = rawDefinition.substr(0, parseEnd);
parsePosition = parseEnd; parsePosition = parseEnd;
Serial.printf("[DICT-DBG] Parsing first chunk: 0-%u of %u, hasMore=%d\n", Serial.printf("[DICT-DBG] Parsing first chunk: 0-%u of %u, hasMore=%d\n", parseEnd, rawDefinition.length(),
parseEnd, rawDefinition.length(), hasMoreContent); hasMoreContent);
// Parse this chunk into TextBlocks // Parse this chunk into TextBlocks
std::vector<std::shared_ptr<TextBlock>> allBlocks; std::vector<std::shared_ptr<TextBlock>> allBlocks;
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth, DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
[&allBlocks](std::shared_ptr<TextBlock> block) { [&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); });
allBlocks.push_back(block);
});
Serial.printf("[DICT-DBG] First chunk parsed, %u TextBlocks\n", allBlocks.size()); Serial.printf("[DICT-DBG] First chunk parsed, %u TextBlocks\n", allBlocks.size());
if (allBlocks.empty()) { if (allBlocks.empty()) {
@ -269,8 +267,7 @@ void DictionaryResultActivity::parseNextChunk() {
return; return;
} }
Serial.printf("[DICT-DBG] parseNextChunk starting at position %u of %u\n", Serial.printf("[DICT-DBG] parseNextChunk starting at position %u of %u\n", parsePosition, rawDefinition.length());
parsePosition, rawDefinition.length());
// Get margins with button hint space for all orientations // Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft; int marginTop, marginRight, marginBottom, marginLeft;
@ -315,9 +312,7 @@ void DictionaryResultActivity::parseNextChunk() {
// Parse this chunk into TextBlocks // Parse this chunk into TextBlocks
std::vector<std::shared_ptr<TextBlock>> allBlocks; std::vector<std::shared_ptr<TextBlock>> allBlocks;
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth, DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
[&allBlocks](std::shared_ptr<TextBlock> block) { [&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); });
allBlocks.push_back(block);
});
Serial.printf("[DICT-DBG] Chunk parsed, %u TextBlocks\n", allBlocks.size()); Serial.printf("[DICT-DBG] Chunk parsed, %u TextBlocks\n", allBlocks.size());
@ -359,8 +354,8 @@ void DictionaryResultActivity::parseNextChunk() {
Serial.printf("[DICT-DBG] Trimmed old page, firstPageNumber now %d\n", firstPageNumber); Serial.printf("[DICT-DBG] Trimmed old page, firstPageNumber now %d\n", firstPageNumber);
} }
Serial.printf("[DICT-DBG] After chunk: %u cached pages (pages %d-%d)\n", Serial.printf("[DICT-DBG] After chunk: %u cached pages (pages %d-%d)\n", pages.size(), firstPageNumber,
pages.size(), firstPageNumber, firstPageNumber + static_cast<int>(pages.size()) - 1); firstPageNumber + static_cast<int>(pages.size()) - 1);
} }
void DictionaryResultActivity::reparseToPage(int targetPageNumber) { void DictionaryResultActivity::reparseToPage(int targetPageNumber) {
@ -381,8 +376,7 @@ void DictionaryResultActivity::reparseToPage(int targetPageNumber) {
} }
// Now position currentPage to show the target page // Now position currentPage to show the target page
if (targetPageNumber >= firstPageNumber && if (targetPageNumber >= firstPageNumber && targetPageNumber < firstPageNumber + static_cast<int>(pages.size())) {
targetPageNumber < firstPageNumber + static_cast<int>(pages.size())) {
currentPage = targetPageNumber - firstPageNumber; currentPage = targetPageNumber - firstPageNumber;
} else { } else {
// Target page doesn't exist (definition is shorter than expected) // Target page doesn't exist (definition is shorter than expected)
@ -390,8 +384,8 @@ void DictionaryResultActivity::reparseToPage(int targetPageNumber) {
if (currentPage < 0) currentPage = 0; if (currentPage < 0) currentPage = 0;
} }
Serial.printf("[DICT-DBG] reparseToPage done: currentPage=%d, firstPageNumber=%d, pages=%u\n", Serial.printf("[DICT-DBG] reparseToPage done: currentPage=%d, firstPageNumber=%d, pages=%u\n", currentPage,
currentPage, firstPageNumber, pages.size()); firstPageNumber, pages.size());
} }
void DictionaryResultActivity::displayTaskLoop() { void DictionaryResultActivity::displayTaskLoop() {

View File

@ -1,7 +1,7 @@
#include "EpubWordSelectionActivity.h" #include "EpubWordSelectionActivity.h"
#include <EInkDisplay.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalDisplay.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@ -263,5 +263,5 @@ void EpubWordSelectionActivity::render() const {
const char* sideBottomHint = (currentLineIndex < lastLine) ? "DOWN" : ""; const char* sideBottomHint = (currentLineIndex < lastLine) ? "DOWN" : "";
renderer.drawSideButtonHints(SMALL_FONT_ID, sideTopHint, sideBottomHint, false); // No border renderer.drawSideButtonHints(SMALL_FONT_ID, sideTopHint, sideBottomHint, false); // No border
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} }

View File

@ -751,7 +751,7 @@ void HomeActivity::render() {
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
constexpr int batteryX = 25; // Align with first button hint position constexpr int batteryX = 25; // Align with first button hint position
const int batteryY = pageHeight - 34; // Vertically centered in button hint area const int batteryY = pageHeight - 34; // Vertically centered in button hint area
ScreenComponents::drawBatteryLarge(renderer, batteryX, batteryY, showBatteryPercentage); ScreenComponents::drawBatteryLarge(renderer, batteryX, batteryY, showBatteryPercentage, mappedInput.isUsbConnected());
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -503,17 +503,23 @@ void MyLibraryActivity::executeAction() {
} else if (selectedAction == ActionType::RemoveFromRecents) { } else if (selectedAction == ActionType::RemoveFromRecents) {
// Just remove from recents list, don't touch the file // Just remove from recents list, don't touch the file
success = RECENT_BOOKS.removeBook(actionTargetPath); success = RECENT_BOOKS.removeBook(actionTargetPath);
} else if (selectedAction == ActionType::ClearCache) {
// Clear cache for this book, optionally preserving progress
success = BookManager::clearBookCache(actionTargetPath, clearCachePreserveProgress);
// Also clear thumbnail existence cache since thumbnails may have been deleted
clearThumbExistsCache();
} }
// Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state // Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state
if (success) { if (success) {
// Reload data // Reload data
loadRecentBooks(); loadRecentBooks();
if (selectedAction != ActionType::RemoveFromRecents) { if (selectedAction != ActionType::RemoveFromRecents && selectedAction != ActionType::ClearCache) {
loadFiles(); // Only reload files for Archive/Delete loadFiles(); // Only reload files for Archive/Delete (not needed for cache clear)
} }
// Adjust selector if needed // Adjust selector if needed (not needed for ClearCache since item count doesn't change)
if (selectedAction != ActionType::ClearCache) {
const int itemCount = getCurrentItemCount(); const int itemCount = getCurrentItemCount();
if (selectorIndex >= itemCount && itemCount > 0) { if (selectorIndex >= itemCount && itemCount > 0) {
selectorIndex = itemCount - 1; selectorIndex = itemCount - 1;
@ -521,6 +527,7 @@ void MyLibraryActivity::executeAction() {
selectorIndex = 0; selectorIndex = 0;
} }
} }
}
uiState = UIState::Normal; uiState = UIState::Normal;
updateRequired = true; updateRequired = true;
@ -577,8 +584,8 @@ void MyLibraryActivity::executeListAction() {
void MyLibraryActivity::loop() { void MyLibraryActivity::loop() {
// Handle action menu state // Handle action menu state
if (uiState == UIState::ActionMenu) { if (uiState == UIState::ActionMenu) {
// Menu has 4 options in Recent tab, 2 options in Files tab // Menu has 5 options in Recent tab, 3 options in Files tab
const int maxMenuSelection = (currentTab == Tab::Recent) ? 3 : 1; const int maxMenuSelection = (currentTab == Tab::Recent) ? 4 : 2;
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
uiState = UIState::Normal; uiState = UIState::Normal;
@ -608,7 +615,7 @@ void MyLibraryActivity::loop() {
// Map menu selection to action type // Map menu selection to action type
if (currentTab == Tab::Recent) { if (currentTab == Tab::Recent) {
// Recent tab: Archive(0), Delete(1), Remove from Recents(2), Clear All Recents(3) // Recent tab: Archive(0), Delete(1), Clear Cache(2), Remove from Recents(3), Clear All Recents(4)
switch (menuSelection) { switch (menuSelection) {
case 0: case 0:
selectedAction = ActionType::Archive; selectedAction = ActionType::Archive;
@ -617,20 +624,37 @@ void MyLibraryActivity::loop() {
selectedAction = ActionType::Delete; selectedAction = ActionType::Delete;
break; break;
case 2: case 2:
selectedAction = ActionType::RemoveFromRecents; selectedAction = ActionType::ClearCache;
break; break;
case 3: case 3:
selectedAction = ActionType::RemoveFromRecents;
break;
case 4:
selectedAction = ActionType::ClearAllRecents; selectedAction = ActionType::ClearAllRecents;
break; break;
} }
} else { } else {
// Files tab: Archive(0), Delete(1) // Files tab: Archive(0), Delete(1), Clear Cache(2)
selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete; switch (menuSelection) {
case 0:
selectedAction = ActionType::Archive;
break;
case 1:
selectedAction = ActionType::Delete;
break;
case 2:
selectedAction = ActionType::ClearCache;
break;
}
} }
// Clear All Recents needs its own confirmation dialog // Clear All Recents needs its own confirmation dialog
if (selectedAction == ActionType::ClearAllRecents) { if (selectedAction == ActionType::ClearAllRecents) {
uiState = UIState::ClearAllRecentsConfirming; uiState = UIState::ClearAllRecentsConfirming;
} else if (selectedAction == ActionType::ClearCache) {
// Clear Cache shows options dialog first
clearCachePreserveProgress = true; // Default to preserving progress
uiState = UIState::ClearCacheOptionsConfirming;
} else { } else {
uiState = UIState::Confirming; uiState = UIState::Confirming;
} }
@ -735,6 +759,30 @@ void MyLibraryActivity::loop() {
return; return;
} }
// Handle clear cache options confirmation state
if (uiState == UIState::ClearCacheOptionsConfirming) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
uiState = UIState::ActionMenu;
updateRequired = true;
return;
}
// Up/Down toggle between Yes/No for preserve progress
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Down)) {
clearCachePreserveProgress = !clearCachePreserveProgress;
updateRequired = true;
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
executeAction();
return;
}
return;
}
// Normal state handling // Normal state handling
const int itemCount = getCurrentItemCount(); const int itemCount = getCurrentItemCount();
const int pageItems = getPageItems(); const int pageItems = getPageItems();
@ -1303,6 +1351,12 @@ void MyLibraryActivity::render() const {
return; return;
} }
if (uiState == UIState::ClearCacheOptionsConfirming) {
renderClearCacheOptionsConfirmation();
renderer.displayBuffer();
return;
}
// Calculate bezel-adjusted margins // Calculate bezel-adjusted margins
const int bezelTop = renderer.getBezelOffsetTop(); const int bezelTop = renderer.getBezelOffsetTop();
const int bezelBottom = renderer.getBezelOffsetBottom(); const int bezelBottom = renderer.getBezelOffsetBottom();
@ -1661,40 +1715,46 @@ void MyLibraryActivity::renderActionMenu() const {
renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight); renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
// Menu options - 4 for Recent tab, 2 for Files tab // Menu options - 5 for Recent tab, 3 for Files tab
const bool isRecentTab = (currentTab == Tab::Recent); const bool isRecentTab = (currentTab == Tab::Recent);
const int menuItemCount = isRecentTab ? 4 : 2; const int menuItemCount = isRecentTab ? 5 : 3;
constexpr int menuLineHeight = 35; constexpr int menuLineHeight = 35;
constexpr int menuItemWidth = 160; constexpr int menuItemWidth = 160;
const int menuX = (pageWidth - menuItemWidth) / 2; const int menuX = (pageWidth - menuItemWidth) / 2;
const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2; const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2;
// Archive option // Archive option (index 0)
if (menuSelection == 0) { if (menuSelection == 0) {
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight); renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
} }
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0); renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0);
// Delete option // Delete option (index 1)
if (menuSelection == 1) { if (menuSelection == 1) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight); renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
} }
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1); renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1);
// Recent tab only: Remove from Recents and Clear All Recents // Clear Cache option (index 2) - available in both tabs
if (isRecentTab) {
// Remove from Recents option
if (menuSelection == 2) { if (menuSelection == 2) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight); renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight);
} }
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Remove from Recents", renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Clear Cache", menuSelection != 2);
menuSelection != 2);
// Clear All Recents option // Recent tab only: Remove from Recents and Clear All Recents
if (isRecentTab) {
// Remove from Recents option (index 3)
if (menuSelection == 3) { if (menuSelection == 3) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight); renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight);
} }
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Clear All Recents", menuSelection != 3); renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Remove from Recents",
menuSelection != 3);
// Clear All Recents option (index 4)
if (menuSelection == 4) {
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 4 - 5, menuItemWidth + 20, menuLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 4, "Clear All Recents", menuSelection != 4);
} }
// Draw side button hints (up/down navigation) // Draw side button hints (up/down navigation)
@ -1828,6 +1888,54 @@ void MyLibraryActivity::renderClearAllRecentsConfirmation() const {
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void MyLibraryActivity::renderClearCacheOptionsConfirmation() const {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Bezel compensation
const int bezelTop = renderer.getBezelOffsetTop();
// Title
renderer.drawCenteredText(UI_12_FONT_ID, 20 + bezelTop, "Clear Book Cache", true, EpdFontFamily::BOLD);
// Show filename
const int filenameY = 60 + bezelTop;
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
auto truncatedName =
renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
// Question text
const int questionY = pageHeight / 2 - 50;
renderer.drawCenteredText(UI_10_FONT_ID, questionY, "Preserve reading progress?");
// Yes/No options
constexpr int optionLineHeight = 35;
constexpr int optionWidth = 100;
const int optionX = (pageWidth - optionWidth) / 2;
const int optionStartY = questionY + 40;
// Yes option
if (clearCachePreserveProgress) {
renderer.fillRect(optionX - 10, optionStartY - 5, optionWidth + 20, optionLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY, "Yes", !clearCachePreserveProgress);
// No option
if (!clearCachePreserveProgress) {
renderer.fillRect(optionX - 10, optionStartY + optionLineHeight - 5, optionWidth + 20, optionLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY + optionLineHeight, "No", clearCachePreserveProgress);
// Draw side button hints (up/down navigation)
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
// Draw bottom button hints
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
void MyLibraryActivity::renderBookmarksTab() const { void MyLibraryActivity::renderBookmarksTab() const {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems(); const int pageItems = getPageItems();

View File

@ -44,9 +44,10 @@ class MyLibraryActivity final : public Activity {
Confirming, Confirming,
ListActionMenu, ListActionMenu,
ListConfirmingDelete, ListConfirmingDelete,
ClearAllRecentsConfirming ClearAllRecentsConfirming,
ClearCacheOptionsConfirming
}; };
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents }; enum class ActionType { Archive, Delete, RemoveFromRecents, ClearCache, ClearAllRecents };
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
@ -64,6 +65,7 @@ class MyLibraryActivity final : public Activity {
std::string actionTargetName; std::string actionTargetName;
int menuSelection = 0; // 0 = Archive, 1 = Delete int menuSelection = 0; // 0 = Archive, 1 = Delete
bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu
bool clearCachePreserveProgress = true; // For Clear Cache: whether to preserve reading progress
// Recent tab state // Recent tab state
std::vector<RecentBook> recentBooks; std::vector<RecentBook> recentBooks;
@ -153,6 +155,9 @@ class MyLibraryActivity final : public Activity {
// Clear all recents confirmation // Clear all recents confirmation
void renderClearAllRecentsConfirmation() const; void renderClearAllRecentsConfirmation() const;
// Clear cache options confirmation
void renderClearCacheOptionsConfirmation() const;
public: public:
explicit MyLibraryActivity( explicit MyLibraryActivity(
GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function<void()>& onGoHome, GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function<void()>& onGoHome,

View File

@ -300,6 +300,36 @@ void CrossPointWebServerActivity::startAccessPoint() {
startWebServer(); startWebServer();
} }
void CrossPointWebServerActivity::generateQRCodes() {
Serial.printf("[%lu] [WEBACT] Generating QR codes (cached)...\n", millis());
const unsigned long startTime = millis();
// Web browser URL QR code
std::string webUrl = "http://" + connectedIP + "/";
qrcode_initText(&qrWebBrowser, qrWebBrowserBuffer, 4, ECC_LOW, webUrl.c_str());
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), webUrl.c_str());
// Companion App (Files) deep link QR code
std::string filesUrl = getCompanionAppUrl();
qrcode_initText(&qrCompanionApp, qrCompanionAppBuffer, 4, ECC_LOW, filesUrl.c_str());
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), filesUrl.c_str());
// Companion App (Library) deep link QR code
std::string libraryUrl = getCompanionAppLibraryUrl();
qrcode_initText(&qrCompanionAppLibrary, qrCompanionAppLibraryBuffer, 4, ECC_LOW, libraryUrl.c_str());
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), libraryUrl.c_str());
// WiFi config QR code (for AP mode)
if (isApMode) {
std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
qrcode_initText(&qrWifiConfig, qrWifiConfigBuffer, 4, ECC_LOW, wifiConfig.c_str());
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), wifiConfig.c_str());
}
qrCacheValid = true;
Serial.printf("[%lu] [WEBACT] QR codes cached in %lu ms\n", millis(), millis() - startTime);
}
void CrossPointWebServerActivity::startWebServer() { void CrossPointWebServerActivity::startWebServer() {
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis()); Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
@ -311,6 +341,9 @@ void CrossPointWebServerActivity::startWebServer() {
state = WebServerActivityState::SERVER_RUNNING; state = WebServerActivityState::SERVER_RUNNING;
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis()); Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
// Generate and cache QR codes now that we have IP and server ports
generateQRCodes();
// Force an immediate render since we're transitioning from a subactivity // Force an immediate render since we're transitioning from a subactivity
// that had its own rendering task. We need to make sure our display is shown. // that had its own rendering task. We need to make sure our display is shown.
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -468,23 +501,18 @@ void CrossPointWebServerActivity::render() const {
} }
} }
// Draw QR code at specified position with configurable pixel size per module // Draw QR code from pre-computed QRCode data at specified position
// Returns the size of the QR code in pixels (width = height = size * pixelsPerModule) // Returns the size of the QR code in pixels (width = height = size * pixelsPerModule)
int drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data, int drawQRCodeCached(const GfxRenderer& renderer, const int x, const int y, QRCode* qrcode,
const uint8_t pixelsPerModule = 7) { const uint8_t pixelsPerModule = 7) {
QRCode qrcode; for (uint8_t cy = 0; cy < qrcode->size; cy++) {
uint8_t qrcodeBytes[qrcode_getBufferSize(4)]; for (uint8_t cx = 0; cx < qrcode->size; cx++) {
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str()); if (qrcode_getModule(qrcode, cx, cy)) {
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
for (uint8_t cy = 0; cy < qrcode.size; cy++) {
for (uint8_t cx = 0; cx < qrcode.size; cx++) {
if (qrcode_getModule(&qrcode, cx, cy)) {
renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true); renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true);
} }
} }
} }
return qrcode.size * pixelsPerModule; return qrcode->size * pixelsPerModule;
} }
// Helper to format bytes into human-readable sizes // Helper to format bytes into human-readable sizes
@ -612,8 +640,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
if (isApMode) { if (isApMode) {
// AP mode: Show WiFi QR code on left, connection info on right // AP mode: Show WiFi QR code on left, connection info on right
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;"; drawQRCodeCached(renderer, QR_X, QR_Y, &qrWifiConfig, QR_PX);
drawQRCode(renderer, QR_X, QR_Y, wifiConfig, QR_PX);
std::string ssidInfo = "Network: " + connectedSSID; std::string ssidInfo = "Network: " + connectedSSID;
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str()); renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
@ -635,8 +662,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str()); renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str());
} else { } else {
// STA mode: Show URL QR code on left, connection info on right // STA mode: Show URL QR code on left, connection info on right
std::string webUrl = "http://" + connectedIP + "/"; drawQRCodeCached(renderer, QR_X, QR_Y, &qrWebBrowser, QR_PX);
drawQRCode(renderer, QR_X, QR_Y, webUrl, QR_PX);
std::string ssidInfo = "Network: " + connectedSSID; std::string ssidInfo = "Network: " + connectedSSID;
if (ssidInfo.length() > 35) { if (ssidInfo.length() > 35) {
@ -650,6 +676,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str()); renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str());
textY += LINE_SPACING + 8; textY += LINE_SPACING + 8;
std::string webUrl = "http://" + connectedIP + "/";
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD); renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD);
textY += LINE_SPACING - 4; textY += LINE_SPACING - 4;
@ -704,12 +731,12 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
std::string webUrl = "http://" + connectedIP + "/files"; std::string webUrl = "http://" + connectedIP + "/files";
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str()); renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
// Draw QR code on left // Draw cached QR code on left
const std::string appUrl = getCompanionAppUrl(); drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionApp, QR_PX);
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
// Show deep link URL below QR code // Show deep link URL below QR code
const int urlY = QR_Y + QR_SIZE + 10; const int urlY = QR_Y + QR_SIZE + 10;
const std::string appUrl = getCompanionAppUrl();
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD); renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
} }
@ -754,11 +781,11 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
std::string webUrl = "http://" + connectedIP + "/"; std::string webUrl = "http://" + connectedIP + "/";
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str()); renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
// Draw QR code on left // Draw cached QR code on left
const std::string appUrl = getCompanionAppLibraryUrl(); drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionAppLibrary, QR_PX);
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
// Show deep link URL below QR code // Show deep link URL below QR code
const int urlY = QR_Y + QR_SIZE + 10; const int urlY = QR_Y + QR_SIZE + 10;
const std::string appUrl = getCompanionAppLibraryUrl();
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD); renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
} }

View File

@ -2,6 +2,7 @@
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include <qrcode.h>
#include <functional> #include <functional>
#include <memory> #include <memory>
@ -11,6 +12,10 @@
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "network/CrossPointWebServer.h" #include "network/CrossPointWebServer.h"
// QR code cache - version 4 QR codes (33x33 modules)
// Buffer size for version 4: qrcode_getBufferSize(4) ≈ 185 bytes
constexpr size_t QR_BUFFER_SIZE = 185;
// Web server activity states // Web server activity states
enum class WebServerActivityState { enum class WebServerActivityState {
MODE_SELECTION, // Choosing between Join Network and Create Hotspot MODE_SELECTION, // Choosing between Join Network and Create Hotspot
@ -62,6 +67,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY; FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
unsigned long lastStatsRefresh = 0; unsigned long lastStatsRefresh = 0;
// Cached QR codes - generated once when server starts
// Avoids recomputing QR data on every render (every 30s stats refresh)
// Marked mutable since QR drawing doesn't modify logical state but qrcode_getModule takes non-const
bool qrCacheValid = false;
mutable QRCode qrWebBrowser = {};
mutable QRCode qrCompanionApp = {};
mutable QRCode qrCompanionAppLibrary = {};
mutable QRCode qrWifiConfig = {}; // For AP mode WiFi connection QR
uint8_t qrWebBrowserBuffer[QR_BUFFER_SIZE] = {};
uint8_t qrCompanionAppBuffer[QR_BUFFER_SIZE] = {};
uint8_t qrCompanionAppLibraryBuffer[QR_BUFFER_SIZE] = {};
uint8_t qrWifiConfigBuffer[QR_BUFFER_SIZE] = {};
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
@ -78,6 +96,7 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
void startAccessPoint(); void startAccessPoint();
void startWebServer(); void startWebServer();
void stopWebServer(); void stopWebServer();
void generateQRCodes();
public: public:
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

@ -3,7 +3,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <WiFi.h> #include <WiFi.h>
#include <map> #include <algorithm>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
@ -124,48 +124,55 @@ void WifiSelectionActivity::processWifiScanResults() {
} }
// Scan complete, process results // Scan complete, process results
// Use a map to deduplicate networks by SSID, keeping the strongest signal // Deduplicate directly into the networks vector (avoids std::map overhead)
std::map<std::string, WifiNetworkInfo> uniqueNetworks; networks.clear();
networks.reserve(std::min(scanResult, static_cast<int16_t>(20))); // Limit to 20 networks max
for (int i = 0; i < scanResult; i++) { for (int i = 0; i < scanResult; i++) {
std::string ssid = WiFi.SSID(i).c_str(); String ssidStr = WiFi.SSID(i);
const int32_t rssi = WiFi.RSSI(i); const int32_t rssi = WiFi.RSSI(i);
// Skip hidden networks (empty SSID) // Skip hidden networks (empty SSID)
if (ssid.empty()) { if (ssidStr.isEmpty()) {
continue; continue;
} }
// Check if we've already seen this SSID std::string ssid = ssidStr.c_str();
auto it = uniqueNetworks.find(ssid);
if (it == uniqueNetworks.end() || rssi > it->second.rssi) { // Check if we've already seen this SSID (linear search is fine for small lists)
// New network or stronger signal than existing entry auto existing = std::find_if(networks.begin(), networks.end(),
[&ssid](const WifiNetworkInfo& net) { return net.ssid == ssid; });
if (existing != networks.end()) {
// Update if stronger signal
if (rssi > existing->rssi) {
existing->rssi = rssi;
existing->isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
}
} else if (networks.size() < 20) {
// New network - only add if under limit
WifiNetworkInfo network; WifiNetworkInfo network;
network.ssid = ssid; network.ssid = std::move(ssid);
network.rssi = rssi; network.rssi = rssi;
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN); network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid); network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
uniqueNetworks[ssid] = network; networks.push_back(std::move(network));
} }
} }
// Convert map to vector // Free WiFi scan memory immediately (before sorting)
networks.clear(); WiFi.scanDelete();
for (const auto& pair : uniqueNetworks) {
// cppcheck-suppress useStlAlgorithm
networks.push_back(pair.second);
}
// Sort by signal strength (strongest first) // Sort by signal strength (strongest first), then by saved password
std::sort(networks.begin(), networks.end(),
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; });
// Show networks with PW first
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
return a.hasSavedPassword && !b.hasSavedPassword; // Primary: saved passwords first
if (a.hasSavedPassword != b.hasSavedPassword) {
return a.hasSavedPassword;
}
// Secondary: strongest signal first
return a.rssi > b.rssi;
}); });
WiFi.scanDelete();
state = WifiSelectionState::NETWORK_LIST; state = WifiSelectionState::NETWORK_LIST;
selectedNetworkIndex = 0; selectedNetworkIndex = 0;
updateRequired = true; updateRequired = true;

View File

@ -89,7 +89,7 @@ void EpubReaderActivity::onEnter() {
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]"); renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
// Generate covers with progress callback // Generate covers with progress callback
epub->generateAllCovers([&](int percent) { epub->generateAllCovers([&](int percent) {
@ -103,7 +103,7 @@ void EpubReaderActivity::onEnter() {
char progressStr[32]; char progressStr[32];
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent); snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr); renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} }
}); });
} }
@ -717,7 +717,7 @@ void EpubReaderActivity::renderScreen() {
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) { auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100; const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}; };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
@ -761,8 +761,7 @@ void EpubReaderActivity::renderScreen() {
} }
if (section->currentPage < 0 || section->currentPage >= section->pageCount) { if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount);
section->pageCount);
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
@ -835,7 +834,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();
@ -920,7 +919,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
} }
if (showBattery) { if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage,
mappedInput.isUsbConnected());
} }
if (showChapterTitle) { if (showChapterTitle) {
@ -928,7 +928,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
// Page width minus existing content with 30px padding on each side // Page width minus existing content with 30px padding on each side
const int rendererableScreenWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const int rendererableScreenWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const int batterySize = showBattery ? (showBatteryPercentage ? 50 : 20) : 0; const int batterySize = showBattery ? (showBatteryPercentage ? 65 : 28) : 0;
const int titleMarginLeft = batterySize + 30; const int titleMarginLeft = batterySize + 30;
const int titleMarginRight = progressTextWidth + 30; const int titleMarginRight = progressTextWidth + 30;

View File

@ -85,7 +85,7 @@ void TxtReaderActivity::onEnter() {
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false); renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10); renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]"); renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
// Generate covers with progress callback // Generate covers with progress callback
(void)txt->generateAllCovers([&](int percent) { (void)txt->generateAllCovers([&](int percent) {
@ -99,7 +99,7 @@ void TxtReaderActivity::onEnter() {
char progressStr[32]; char progressStr[32];
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent); snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr); renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} }
}); });
} }
@ -339,7 +339,7 @@ void TxtReaderActivity::buildPageIndex() {
// Fill progress bar // Fill progress bar
const int fillWidth = (barWidth - 2) * progressPercent / 100; const int fillWidth = (barWidth - 2) * progressPercent / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} }
// Yield to other tasks periodically // Yield to other tasks periodically
@ -571,7 +571,7 @@ void TxtReaderActivity::renderPage() {
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) { if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency(); pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else { } else {
renderer.displayBuffer(); renderer.displayBuffer();
@ -642,11 +642,13 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
} }
if (showBattery) { if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage); ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage,
mappedInput.isUsbConnected());
} }
if (showTitle) { if (showTitle) {
const int titleMarginLeft = 50 + 30 + orientedMarginLeft; const int batterySize = showBattery ? (showBatteryPercentage ? 65 : 28) : 0;
const int titleMarginLeft = batterySize + 30 + orientedMarginLeft;
const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight; const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight;
const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight; const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight;

View File

@ -4,6 +4,9 @@
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <string>
#include <vector>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/home/HomeActivity.h" #include "activities/home/HomeActivity.h"
#include "activities/home/MyLibraryActivity.h" #include "activities/home/MyLibraryActivity.h"
@ -19,6 +22,7 @@ void ClearCacheActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
state = WARNING; state = WARNING;
preserveProgress = true; // Default to preserving progress
updateRequired = true; updateRequired = true;
xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask", xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask",
@ -56,6 +60,7 @@ void ClearCacheActivity::displayTaskLoop() {
} }
void ClearCacheActivity::render() { void ClearCacheActivity::render() {
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Bezel compensation // Bezel compensation
@ -67,11 +72,32 @@ void ClearCacheActivity::render() {
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, "Clear Cache", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, "Clear Cache", true, EpdFontFamily::BOLD);
if (state == WARNING) { if (state == WARNING) {
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 60, "This will clear all cached book data.", true); renderer.drawCenteredText(UI_10_FONT_ID, centerY - 70, "This will clear all cached book data.", true);
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 30, "All reading progress will be lost!", true, renderer.drawCenteredText(UI_10_FONT_ID, centerY - 45, "Books will need to be re-indexed.", true);
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 10, "Books will need to be re-indexed", true); // Preserve progress option
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 30, "when opened again.", true); renderer.drawCenteredText(UI_10_FONT_ID, centerY - 5, "Preserve reading progress?");
// Yes/No options
constexpr int optionLineHeight = 30;
constexpr int optionWidth = 80;
const int optionX = (pageWidth - optionWidth) / 2;
const int optionStartY = centerY + 25;
// Yes option
if (preserveProgress) {
renderer.fillRect(optionX - 10, optionStartY - 5, optionWidth + 20, optionLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY, "Yes", !preserveProgress);
// No option
if (!preserveProgress) {
renderer.fillRect(optionX - 10, optionStartY + optionLineHeight - 5, optionWidth + 20, optionLineHeight);
}
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY + optionLineHeight, "No", preserveProgress);
// Draw side button hints (up/down navigation)
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", ""); const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
@ -110,8 +136,68 @@ void ClearCacheActivity::render() {
} }
} }
void ClearCacheActivity::clearCacheDirectory(const char* dirPath) {
// Helper to check if a file should be preserved
const auto shouldPreserve = [this](const char* name) {
if (!preserveProgress) return false;
// Preserve progress and bookmarks when preserveProgress is enabled
return (strcmp(name, "progress.bin") == 0 || strcmp(name, "bookmarks.bin") == 0);
};
FsFile dir = SdMan.open(dirPath);
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
failedCount++;
return;
}
char name[128];
std::vector<std::string> filesToDelete;
std::vector<std::string> dirsToDelete;
// First pass: collect files and directories to delete
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
entry.getName(name, sizeof(name));
const bool isDir = entry.isDirectory();
entry.close();
std::string fullPath = std::string(dirPath) + "/" + name;
if (isDir) {
dirsToDelete.push_back(fullPath);
} else if (!shouldPreserve(name)) {
filesToDelete.push_back(fullPath);
}
}
dir.close();
// Delete files
for (const auto& path : filesToDelete) {
if (SdMan.remove(path.c_str())) {
clearedCount++;
} else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to delete file: %s\n", millis(), path.c_str());
failedCount++;
}
}
// Delete subdirectories (like "sections/")
for (const auto& path : dirsToDelete) {
if (SdMan.removeDir(path.c_str())) {
clearedCount++;
} else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to delete dir: %s\n", millis(), path.c_str());
failedCount++;
}
}
// If not preserving progress, try to remove the now-empty directory
if (!preserveProgress) {
SdMan.rmdir(dirPath); // This will fail if directory is not empty, which is fine
}
}
void ClearCacheActivity::clearCache() { void ClearCacheActivity::clearCache() {
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis()); Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache (preserveProgress=%d)...\n", millis(), preserveProgress);
// Open .crosspoint directory // Open .crosspoint directory
auto root = SdMan.open("/.crosspoint"); auto root = SdMan.open("/.crosspoint");
@ -127,35 +213,32 @@ void ClearCacheActivity::clearCache() {
failedCount = 0; failedCount = 0;
char name[128]; char name[128];
// Iterate through all entries in the directory // Collect all book cache directories first
std::vector<std::string> cacheDirs;
for (auto file = root.openNextFile(); file; file = root.openNextFile()) { for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name)); file.getName(name, sizeof(name));
String itemName(name); String itemName(name);
// Only delete directories starting with epub_ or txt_ // Only process directories starting with epub_ or txt_
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) { if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) {
String fullPath = "/.crosspoint/" + itemName; cacheDirs.push_back("/.crosspoint/" + std::string(name));
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str());
file.close(); // Close before attempting to delete
if (SdMan.removeDir(fullPath.c_str())) {
clearedCount++;
} else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str());
failedCount++;
} }
} else {
file.close(); file.close();
} }
}
root.close(); root.close();
// Now clear each cache directory
for (const auto& cacheDir : cacheDirs) {
Serial.printf("[%lu] [CLEAR_CACHE] Clearing: %s\n", millis(), cacheDir.c_str());
clearCacheDirectory(cacheDir.c_str());
}
// Also clear in-memory caches since disk cache is gone // Also clear in-memory caches since disk cache is gone
HomeActivity::freeCoverBufferIfAllocated(); HomeActivity::freeCoverBufferIfAllocated();
MyLibraryActivity::clearThumbExistsCache(); MyLibraryActivity::clearThumbExistsCache();
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount); Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d items removed, %d failed\n", millis(), clearedCount,
failedCount);
state = SUCCESS; state = SUCCESS;
updateRequired = true; updateRequired = true;
@ -163,8 +246,17 @@ void ClearCacheActivity::clearCache() {
void ClearCacheActivity::loop() { void ClearCacheActivity::loop() {
if (state == WARNING) { if (state == WARNING) {
// Up/Down toggle preserve progress option
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Down)) {
preserveProgress = !preserveProgress;
updateRequired = true;
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis()); Serial.printf("[%lu] [CLEAR_CACHE] User confirmed (preserveProgress=%d), starting cache clear\n", millis(),
preserveProgress);
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CLEARING; state = CLEARING;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);

View File

@ -29,9 +29,11 @@ class ClearCacheActivity final : public ActivityWithSubactivity {
int clearedCount = 0; int clearedCount = 0;
int failedCount = 0; int failedCount = 0;
bool preserveProgress = true; // Whether to keep progress.bin and bookmarks.bin
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render(); void render();
void clearCache(); void clearCache();
void clearCacheDirectory(const char* dirPath); // Helper to clear a single book's cache
}; };

View File

@ -15,7 +15,7 @@ namespace {
// Visibility condition for bezel edge setting (only show when compensation > 0) // Visibility condition for bezel edge setting (only show when compensation > 0)
bool isBezelCompensationEnabled() { return SETTINGS.bezelCompensation > 0; } bool isBezelCompensationEnabled() { return SETTINGS.bezelCompensation > 0; }
constexpr int displaySettingsCount = 9; constexpr int displaySettingsCount = 10;
const SettingInfo displaySettings[displaySettingsCount] = { const SettingInfo displaySettings[displaySettingsCount] = {
// Should match with SLEEP_SCREEN_MODE // Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
@ -28,6 +28,7 @@ const SettingInfo displaySettings[displaySettingsCount] = {
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
SettingInfo::Enum("Sunlight Fading Fix", &CrossPointSettings::fadingFix, {"OFF", "ON"}),
SettingInfo::Value("Bezel Compensation", &CrossPointSettings::bezelCompensation, {0, 10, 1}), SettingInfo::Value("Bezel Compensation", &CrossPointSettings::bezelCompensation, {0, 10, 1}),
SettingInfo::Enum("Bezel Edge", &CrossPointSettings::bezelCompensationEdge, {"Bottom", "Top", "Left", "Right"}, SettingInfo::Enum("Bezel Edge", &CrossPointSettings::bezelCompensationEdge, {"Bottom", "Top", "Left", "Right"},
isBezelCompensationEnabled)}; isBezelCompensationEnabled)};

View File

@ -1,6 +1,6 @@
#pragma once #pragma once
#include <EInkDisplay.h>
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <HalDisplay.h>
#include <string> #include <string>
#include <utility> #include <utility>
@ -10,12 +10,12 @@
class FullScreenMessageActivity final : public Activity { class FullScreenMessageActivity final : public Activity {
std::string text; std::string text;
EpdFontFamily::Style style; EpdFontFamily::Style style;
EInkDisplay::RefreshMode refreshMode; HalDisplay::RefreshMode refreshMode;
public: public:
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text, explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
const EpdFontFamily::Style style = EpdFontFamily::REGULAR, const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH)
: Activity("FullScreenMessage", renderer, mappedInput), : Activity("FullScreenMessage", renderer, mappedInput),
text(std::move(text)), text(std::move(text)),
style(style), style(style),

View File

@ -1,7 +1,7 @@
#include "KeyboardEntryActivity.h" #include "KeyboardEntryActivity.h"
#include "activities/dictionary/DictionaryMargins.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/dictionary/DictionaryMargins.h"
#include "fontIds.h" #include "fontIds.h"
// Keyboard layouts - lowercase // Keyboard layouts - lowercase

View File

@ -1,9 +1,9 @@
#include <Arduino.h> #include <Arduino.h>
#include <BitmapHelpers.h> #include <BitmapHelpers.h>
#include <EInkDisplay.h>
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <HalDisplay.h>
#include <HalGPIO.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <SPI.h> #include <SPI.h>
#include <builtinFonts/all.h> #include <builtinFonts/all.h>
@ -32,23 +32,10 @@
#include "fontIds.h" #include "fontIds.h"
#include "images/LockIcon.h" #include "images/LockIcon.h"
#define SPI_FQ 40000000 HalDisplay display;
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults) HalGPIO gpio;
#define EPD_SCLK 8 // SPI Clock MappedInputManager mappedInputManager(gpio);
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In) GfxRenderer renderer(display);
#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);
Activity* currentActivity; Activity* currentActivity;
// Fonts // Fonts
@ -130,11 +117,14 @@ void logMemoryState(const char* tag, const char* context) {
#define logMemoryState(tag, context) ((void)0) #define logMemoryState(tag, context) ((void)0)
#endif #endif
// Flash command detection - receives "FLASH\n" from pre_flash.py script // Flash command detection - receives "FLASH:version\n" from pre_flash.py script
// Plan A: Simple polling - host sends command, device checks when Serial is connected
static String flashCmdBuffer; static String flashCmdBuffer;
void checkForFlashCommand() { void checkForFlashCommand() {
if (!Serial) return; // Early exit if Serial not initialized // Only check when Serial is connected (host has port open)
if (!Serial) return;
while (Serial.available()) { while (Serial.available()) {
char c = Serial.read(); char c = Serial.read();
if (c == '\n') { if (c == '\n') {
@ -165,56 +155,51 @@ void checkForFlashCommand() {
const int screenH = renderer.getScreenHeight(); const int screenH = renderer.getScreenHeight();
// Show current version in bottom-left corner (orientation-aware) // Show current version in bottom-left corner (orientation-aware)
// "Bottom-left" is relative to the current orientation
constexpr int versionMargin = 10; constexpr int versionMargin = 10;
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION);
int versionX, versionY; int versionX, versionY;
switch (renderer.getOrientation()) { switch (renderer.getOrientation()) {
case GfxRenderer::Portrait: // Bottom-left is actual bottom-left case GfxRenderer::Portrait:
versionX = versionMargin; versionX = versionMargin;
versionY = screenH - 30; versionY = screenH - 30;
break; break;
case GfxRenderer::PortraitInverted: // Bottom-left is actual top-right case GfxRenderer::PortraitInverted:
versionX = screenW - textWidth - versionMargin; versionX = screenW - textWidth - versionMargin;
versionY = 20; versionY = 20;
break; break;
case GfxRenderer::LandscapeClockwise: // Bottom-left is actual bottom-right case GfxRenderer::LandscapeClockwise:
versionX = screenW - textWidth - versionMargin; versionX = screenW - textWidth - versionMargin;
versionY = screenH - 30; versionY = screenH - 30;
break; break;
case GfxRenderer::LandscapeCounterClockwise: // Bottom-left is actual bottom-left case GfxRenderer::LandscapeCounterClockwise:
versionX = versionMargin; versionX = versionMargin;
versionY = screenH - 30; versionY = screenH - 30;
break; break;
} }
renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false); renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false);
// Position and rotate lock icon based on current orientation (USB port location) // Position and rotate lock icon based on current orientation
// USB port locations: Portrait=bottom-left, PortraitInverted=top-right, constexpr int edgeMargin = 28;
// LandscapeCW=top-left, LandscapeCCW=bottom-right constexpr int halfWidth = LOCK_ICON_WIDTH / 2;
// Position offsets: edge margin + half-width offset to center on USB port
constexpr int edgeMargin = 28; // Distance from screen edge
constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering
int iconX, iconY; int iconX, iconY;
GfxRenderer::ImageRotation rotation; GfxRenderer::ImageRotation rotation;
// Note: 90/270 rotation swaps output dimensions (W<->H)
switch (renderer.getOrientation()) { switch (renderer.getOrientation()) {
case GfxRenderer::Portrait: // USB at bottom-left, shackle points right case GfxRenderer::Portrait:
rotation = GfxRenderer::ROTATE_90; rotation = GfxRenderer::ROTATE_90;
iconX = edgeMargin; iconX = edgeMargin;
iconY = screenH - LOCK_ICON_WIDTH - edgeMargin - halfWidth; iconY = screenH - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
break; break;
case GfxRenderer::PortraitInverted: // USB at top-right, shackle points left case GfxRenderer::PortraitInverted:
rotation = GfxRenderer::ROTATE_270; rotation = GfxRenderer::ROTATE_270;
iconX = screenW - LOCK_ICON_HEIGHT - edgeMargin; iconX = screenW - LOCK_ICON_HEIGHT - edgeMargin;
iconY = edgeMargin + halfWidth; iconY = edgeMargin + halfWidth;
break; break;
case GfxRenderer::LandscapeClockwise: // USB at top-left, shackle points down case GfxRenderer::LandscapeClockwise:
rotation = GfxRenderer::ROTATE_180; rotation = GfxRenderer::ROTATE_180;
iconX = edgeMargin + halfWidth; iconX = edgeMargin + halfWidth;
iconY = edgeMargin; iconY = edgeMargin;
break; break;
case GfxRenderer::LandscapeCounterClockwise: // USB at bottom-right, shackle points up case GfxRenderer::LandscapeCounterClockwise:
rotation = GfxRenderer::ROTATE_0; rotation = GfxRenderer::ROTATE_0;
iconX = screenW - LOCK_ICON_WIDTH - edgeMargin - halfWidth; iconX = screenW - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
iconY = screenH - LOCK_ICON_HEIGHT - edgeMargin; iconY = screenH - LOCK_ICON_HEIGHT - edgeMargin;
@ -222,13 +207,13 @@ void checkForFlashCommand() {
} }
renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation); renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); // Use full refresh for clean display before flash overwrites firmware
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
} }
flashCmdBuffer = ""; flashCmdBuffer = "";
} else if (c != '\r') { } else if (c != '\r') {
flashCmdBuffer += c; flashCmdBuffer += c;
// Prevent buffer overflow from random serial data (increased for version info) if (flashCmdBuffer.length() > 50) {
if (flashCmdBuffer.length() > 30) {
flashCmdBuffer = ""; flashCmdBuffer = "";
} }
} }
@ -274,21 +259,20 @@ void verifyPowerButtonDuration() {
const uint16_t calibratedPressDuration = const uint16_t calibratedPressDuration =
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
inputManager.update(); gpio.update();
// Verify the user has actually pressed
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state // 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. delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
inputManager.update(); gpio.update();
} }
t2 = millis(); t2 = millis();
if (inputManager.isPressed(InputManager::BTN_POWER)) { if (gpio.isPressed(HalGPIO::BTN_POWER)) {
do { do {
delay(10); delay(10);
inputManager.update(); gpio.update();
} while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration); } while (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() < calibratedPressDuration);
abort = inputManager.getHeldTime() < calibratedPressDuration; abort = gpio.getHeldTime() < calibratedPressDuration;
} else { } else {
abort = true; abort = true;
} }
@ -296,16 +280,15 @@ void verifyPowerButtonDuration() {
if (abort) { if (abort) {
// Button released too early. Returning to sleep. // Button released too early. Returning to sleep.
// IMPORTANT: Re-arm the wakeup trigger before sleeping again // 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); gpio.startDeepSleep();
esp_deep_sleep_start();
} }
} }
void waitForPowerRelease() { void waitForPowerRelease() {
inputManager.update(); gpio.update();
while (inputManager.isPressed(InputManager::BTN_POWER)) { while (gpio.isPressed(HalGPIO::BTN_POWER)) {
delay(50); delay(50);
inputManager.update(); gpio.update();
} }
} }
@ -314,14 +297,11 @@ void enterDeepSleep() {
exitActivity(); exitActivity();
enterNewActivity(new SleepActivity(renderer, mappedInputManager)); 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] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); 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 gpio.startDeepSleep();
waitForPowerRelease();
// Enter Deep Sleep
esp_deep_sleep_start();
} }
void onGoHome(); void onGoHome();
@ -388,6 +368,7 @@ void onGoToFileTransfer() {
APP_STATE.openBookTitle.shrink_to_fit(); APP_STATE.openBookTitle.shrink_to_fit();
APP_STATE.openBookAuthor.clear(); APP_STATE.openBookAuthor.clear();
APP_STATE.openBookAuthor.shrink_to_fit(); APP_STATE.openBookAuthor.shrink_to_fit();
HomeActivity::freeCoverBufferIfAllocated(); // Free 48KB cover buffer
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis()); Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
@ -445,7 +426,7 @@ void onGoHome() {
} }
void setupDisplayAndFonts() { void setupDisplayAndFonts() {
einkDisplay.begin(); display.begin();
Serial.printf("[%lu] [ ] Display initialized\n", millis()); Serial.printf("[%lu] [ ] Display initialized\n", millis());
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
#ifndef OMIT_FONTS #ifndef OMIT_FONTS
@ -467,42 +448,24 @@ void setupDisplayAndFonts() {
Serial.printf("[%lu] [ ] Fonts setup\n", millis()); 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() { void setup() {
t1 = millis(); t1 = millis();
// Only start serial if USB connected gpio.begin();
pinMode(UART0_RXD, INPUT);
if (isUsbConnected()) { // Always initialize Serial - safe on ESP32-C3 USB CDC even without USB connected
// (the peripheral just remains idle).
Serial.begin(115200); Serial.begin(115200);
// Wait up to 3 seconds for Serial to be ready to catch early logs
// Only wait for terminal connection if USB is physically connected
// This allows catching early debug logs when a serial monitor is attached
if (gpio.isUsbConnected()) {
unsigned long start = millis(); unsigned long start = millis();
while (!Serial && (millis() - start) < 3000) { while (!Serial && (millis() - start) < 3000) {
delay(10); delay(10);
} }
} }
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 // SD Card Initialization
// We need 6 open files concurrently when parsing a new chapter // We need 6 open files concurrently when parsing a new chapter
if (!SdMan.begin()) { if (!SdMan.begin()) {
@ -519,7 +482,7 @@ void setup() {
// Apply bezel compensation from settings // Apply bezel compensation from settings
renderer.setBezelCompensation(SETTINGS.bezelCompensation, SETTINGS.bezelCompensationEdge); renderer.setBezelCompensation(SETTINGS.bezelCompensation, SETTINGS.bezelCompensationEdge);
if (isWakeupByPowerButton()) { if (gpio.isWakeupByPowerButton()) {
// For normal wakeups, verify power button press duration // For normal wakeups, verify power button press duration
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
verifyPowerButtonDuration(); verifyPowerButtonDuration();
@ -559,7 +522,9 @@ void loop() {
const unsigned long loopStartTime = millis(); const unsigned long loopStartTime = millis();
static unsigned long lastMemPrint = 0; static unsigned long lastMemPrint = 0;
inputManager.update(); gpio.update();
renderer.setFadingFix(SETTINGS.fadingFix);
if (Serial && millis() - lastMemPrint >= 10000) { if (Serial && millis() - lastMemPrint >= 10000) {
// Basic heap info // Basic heap info
@ -577,8 +542,7 @@ void loop() {
// Check for any user activity (button press or release) or active background work // Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis(); static unsigned long lastActivityTime = millis();
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() || if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
(currentActivity && currentActivity->preventAutoSleep())) {
lastActivityTime = millis(); // Reset inactivity timer lastActivityTime = millis(); // Reset inactivity timer
} }
@ -590,8 +554,7 @@ void loop() {
return; return;
} }
if (inputManager.isPressed(InputManager::BTN_POWER) && if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
enterDeepSleep(); enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return; return;

View File

@ -371,27 +371,10 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
if (info.isDirectory) { if (info.isDirectory) {
info.size = 0; info.size = 0;
info.isEpub = false; info.isEpub = false;
// md5 remains empty for directories
} else { } else {
info.size = file.size(); info.size = file.size();
info.isEpub = isEpubFile(info.name); info.isEpub = isEpubFile(info.name);
// MD5 not included in listing - clients can request via /api/hash endpoint
// For EPUBs, try to get cached MD5 hash
if (info.isEpub) {
// Build full file path
String fullPath = String(path);
if (!fullPath.endsWith("/")) {
fullPath += "/";
}
fullPath += fileName;
const std::string cachedMd5 =
Md5Utils::getCachedMd5(fullPath.c_str(), BookManager::CROSSPOINT_DIR, info.size);
if (!cachedMd5.empty()) {
info.md5 = String(cachedMd5.c_str());
}
// If not cached, md5 remains empty (companion app can request via /api/hash)
}
} }
callback(info); callback(info);
@ -435,90 +418,73 @@ void CrossPointWebServer::handleFileListData() const {
} }
} }
// Check if we should show hidden files // Check if we should show hidden files (fork addition)
bool showHidden = false; bool showHidden = server->hasArg("showHidden") && server->arg("showHidden") == "true";
if (server->hasArg("showHidden")) {
showHidden = server->arg("showHidden") == "true";
}
// Check client connection before starting
if (!server->client().connected()) {
Serial.printf("[%lu] [WEB] Client disconnected before file list could start\n", millis());
return;
}
server->setContentLength(CONTENT_LENGTH_UNKNOWN); server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", ""); server->send(200, "application/json", "");
if (!sendContentSafe("[")) {
Serial.printf("[%lu] [WEB] Client disconnected at start of file list\n", millis());
return;
}
char output[512]; // Batch JSON entries to reduce number of sendContent calls
// This helps prevent TCP buffer overflow on memory-constrained systems
constexpr size_t BATCH_SIZE = 2048;
char batch[BATCH_SIZE];
size_t batchPos = 0;
batch[batchPos++] = '[';
char output[256]; // Single entry buffer (reduced from 512)
constexpr size_t outputSize = sizeof(output); constexpr size_t outputSize = sizeof(output);
bool seenFirst = false; bool seenFirst = false;
bool clientDisconnected = false;
JsonDocument doc; JsonDocument doc;
scanFiles( scanFiles(
currentPath.c_str(), currentPath.c_str(),
[this, &output, &doc, &seenFirst, &clientDisconnected](const FileInfo& info) mutable { [this, &batch, &batchPos, &output, &doc, &seenFirst](const FileInfo& info) mutable {
// Skip remaining files if client already disconnected
if (clientDisconnected) {
return;
}
doc.clear(); doc.clear();
doc["name"] = info.name; doc["name"] = info.name;
doc["size"] = info.size; doc["size"] = info.size;
doc["isDirectory"] = info.isDirectory; doc["isDirectory"] = info.isDirectory;
doc["isEpub"] = info.isEpub; doc["isEpub"] = info.isEpub;
// Include md5 field for EPUBs (null if not cached, hash string if available)
if (info.isEpub) {
if (info.md5.isEmpty()) {
doc["md5"] = nullptr; // JSON null
} else {
doc["md5"] = info.md5;
}
}
const size_t written = serializeJson(doc, output, outputSize); const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) { if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(), Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
info.name.c_str()); info.name.c_str());
return; return;
} }
// Send comma separator before all entries except the first // Calculate space needed: comma (if not first) + entry
if (seenFirst) { const size_t needed = (seenFirst ? 1 : 0) + written;
if (!sendContentSafe(",")) {
clientDisconnected = true; // If batch would overflow, send it first
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis()); if (batchPos + needed >= BATCH_SIZE - 1) {
return; batch[batchPos] = '\0';
server->sendContent(batch);
delay(5); // Brief delay between batch sends
batchPos = 0;
} }
// Add comma separator if not first entry
if (seenFirst) {
batch[batchPos++] = ',';
} else { } else {
seenFirst = true; seenFirst = true;
} }
// Send the JSON entry with flow control // Copy entry to batch
if (!sendContentSafe(output)) { memcpy(batch + batchPos, output, written);
clientDisconnected = true; batchPos += written;
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
return;
}
}, },
showHidden); showHidden);
// Only send closing bracket if client is still connected // Send remaining batch with closing bracket
if (!clientDisconnected) { batch[batchPos++] = ']';
sendContentSafe("]"); batch[batchPos] = '\0';
server->sendContent(batch);
// End of streamed response, empty chunk to signal client // End of streamed response, empty chunk to signal client
server->sendContent(""); server->sendContent("");
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
} }
}
// Static variables for upload handling // Static variables for upload handling
static FsFile uploadFile; static FsFile uploadFile;
@ -1295,39 +1261,15 @@ void CrossPointWebServer::handleRename() const {
} }
} }
// Counter for flow control pacing
static uint8_t sendContentCounter = 0;
bool CrossPointWebServer::sendContentSafe(const char* content) const { bool CrossPointWebServer::sendContentSafe(const char* content) const {
if (!server || !server->client().connected()) { if (!server || !server->client().connected()) {
return false; return false;
} }
// Send the content
server->sendContent(content); server->sendContent(content);
// Flow control: give TCP stack time to transmit data and drain the send buffer
// The ESP32 TCP buffer is limited and fills quickly when streaming many small chunks.
// We use progressive delays:
// - yield() after every send to allow WiFi processing
// - delay(5ms) every send to allow buffer draining
// - delay(50ms) every 10 sends to allow larger buffer flush
yield();
sendContentCounter++;
if (sendContentCounter >= 10) {
sendContentCounter = 0;
delay(50); // Longer pause every 10 sends for buffer catchup
} else {
delay(5); // Short pause each send
}
return server->client().connected(); return server->client().connected();
} }
bool CrossPointWebServer::sendContentSafe(const String& content) const { bool CrossPointWebServer::sendContentSafe(const String& content) const { return sendContentSafe(content.c_str()); }
return sendContentSafe(content.c_str());
}
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const { bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
FsFile srcFile; FsFile srcFile;

View File

@ -14,7 +14,6 @@ struct FileInfo {
size_t size; size_t size;
bool isEpub; bool isEpub;
bool isDirectory; bool isDirectory;
String md5; // MD5 hash for EPUBs (empty if not cached/available)
}; };
class CrossPointWebServer { class CrossPointWebServer {
@ -108,7 +107,7 @@ class CrossPointWebServer {
bool copyFile(const String& srcPath, const String& destPath) const; bool copyFile(const String& srcPath, const String& destPath) const;
bool copyFolder(const String& srcPath, const String& destPath) const; bool copyFolder(const String& srcPath, const String& destPath) const;
// Helper for safe content sending with flow control // Helper for safe content sending with connection check
// Returns false if client disconnected, true otherwise // Returns false if client disconnected, true otherwise
bool sendContentSafe(const char* content) const; bool sendContentSafe(const char* content) const;
bool sendContentSafe(const String& content) const; bool sendContentSafe(const String& content) const;