From bf3f270067edace892677aef309177abb981491a Mon Sep 17 00:00:00 2001 From: IFAKA <99131130+IFAKA@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:34:58 +0100 Subject: [PATCH 01/46] fix: add NULL checks for frameBuffer in GfxRenderer (#79) ## Problem `invertScreen()`, `storeBwBuffer()`, and `restoreBwBuffer()` dereference `frameBuffer` without NULL validation. If the display isn't initialized, these functions will crash. ## Fix Add NULL checks before using `frameBuffer` in all three functions. Follows the existing pattern from `drawPixel()` (line 11) which already validates the pointer. Changed `lib/GfxRenderer/GfxRenderer.cpp`. ## Test - Follows existing validated pattern from `drawPixel()` - No logic changes - only adds early return on NULL - Manual device testing appreciated --- lib/GfxRenderer/GfxRenderer.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 19c959f8..b1a98c7e 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -183,6 +183,10 @@ void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScre void GfxRenderer::invertScreen() const { uint8_t* buffer = einkDisplay.getFrameBuffer(); + if (!buffer) { + Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis()); + return; + } for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) { buffer[i] = ~buffer[i]; } @@ -256,6 +260,10 @@ void GfxRenderer::freeBwBufferChunks() { */ void GfxRenderer::storeBwBuffer() { const uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (!frameBuffer) { + Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis()); + return; + } // Allocate and copy each chunk for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { @@ -306,6 +314,12 @@ void GfxRenderer::restoreBwBuffer() { } uint8_t* frameBuffer = einkDisplay.getFrameBuffer(); + if (!frameBuffer) { + Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis()); + freeBwBufferChunks(); + return; + } + for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { // Check if chunk is missing if (!bwBufferChunks[i]) { From cc86533e8674f3a9dbd4d32f399d4d455542bfba Mon Sep 17 00:00:00 2001 From: IFAKA <99131130+IFAKA@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:35:37 +0100 Subject: [PATCH 02/46] fix: add NULL check after malloc in readFileToMemory() (#81) ## Problem `readFileToMemory()` allocates an output buffer via `malloc()` at line 120 but doesn't check if allocation succeeds. On low memory, the NULL pointer is passed to `fread()` causing a crash. ## Fix Add NULL check after `malloc()` for the output buffer. Follows the existing pattern already used for `deflatedData` at line 141. Changed `lib/ZipFile/ZipFile.cpp`. ## Test - Follows existing validated pattern from same function - Defensive addition only - no logic changes --- lib/ZipFile/ZipFile.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index f55bb856..11ce2115 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -118,6 +118,11 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo const auto inflatedDataSize = static_cast(fileStat.m_uncomp_size); const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize; const auto data = static_cast(malloc(dataSize)); + if (data == nullptr) { + Serial.printf("[%lu] [ZIP] Failed to allocate memory for output buffer (%zu bytes)\n", millis(), dataSize); + fclose(file); + return nullptr; + } if (fileStat.m_method == MZ_NO_COMPRESSION) { // no deflation, just read content From 73d1839ddddee63df97c3af1d012bb96b7c43bf3 Mon Sep 17 00:00:00 2001 From: IFAKA <99131130+IFAKA@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:36:30 +0100 Subject: [PATCH 03/46] fix: add bounds checks to Epub getter functions (#82) ## Problem Three Epub getter functions can throw exceptions: - `getCumulativeSpineItemSize()`: No bounds check before `.at(spineIndex)` - `getSpineItem()`: If spine is empty and index invalid, `.at(0)` throws - `getTocItem()`: If toc is empty and index invalid, `.at(0)` throws ## Fix - Add bounds check to `getCumulativeSpineItemSize()`, return 0 on error - Add empty container checks to `getSpineItem()` and `getTocItem()` - Use static fallback objects for safe reference returns on empty containers Changed `lib/Epub/Epub.cpp`. ## Test - Defensive additions - follows existing bounds check patterns - No logic changes for valid inputs - Manual device testing appreciated --- lib/Epub/Epub.cpp | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index a3edac84..8d3e4ad8 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -298,10 +298,21 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { int Epub::getSpineItemsCount() const { return spine.size(); } -size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return cumulativeSpineItemSize.at(spineIndex); } +size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { + if (spineIndex < 0 || spineIndex >= static_cast(cumulativeSpineItemSize.size())) { + Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize index:%d is out of range\n", millis(), spineIndex); + return 0; + } + return cumulativeSpineItemSize.at(spineIndex); +} std::string& Epub::getSpineItem(const int spineIndex) { - if (spineIndex < 0 || spineIndex >= spine.size()) { + static std::string emptyString; + if (spine.empty()) { + Serial.printf("[%lu] [EBP] getSpineItem called but spine is empty\n", millis()); + return emptyString; + } + if (spineIndex < 0 || spineIndex >= static_cast(spine.size())) { Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); return spine.at(0).second; } @@ -310,7 +321,12 @@ std::string& Epub::getSpineItem(const int spineIndex) { } EpubTocEntry& Epub::getTocItem(const int tocTndex) { - if (tocTndex < 0 || tocTndex >= toc.size()) { + static EpubTocEntry emptyEntry("", "", "", 0); + if (toc.empty()) { + Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); + return emptyEntry; + } + if (tocTndex < 0 || tocTndex >= static_cast(toc.size())) { Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocTndex); return toc.at(0); } From 9a3bb81337becf05c32d6638769d0438aa8f07a8 Mon Sep 17 00:00:00 2001 From: IFAKA <99131130+IFAKA@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:36:59 +0100 Subject: [PATCH 04/46] fix: add NULL checks after malloc in drawBmp() (#80) ## Problem `drawBmp()` allocates two row buffers via `malloc()` but doesn't check if allocations succeed. On low memory, this causes a crash when the NULL pointers are dereferenced. ## Fix Add NULL check after both `malloc()` calls. If either fails, log error and return early. Changed `lib/GfxRenderer/GfxRenderer.cpp`. ## Test - Defensive addition only - no logic changes - Manual device testing appreciated --- lib/GfxRenderer/GfxRenderer.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index b1a98c7e..a4b9369b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -136,6 +136,13 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con auto* outputRow = static_cast(malloc(outputRowSize)); auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + if (!outputRow || !rowBytes) { + Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis()); + free(outputRow); + free(rowBytes); + return; + } + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). // Screen's (0, 0) is the top-left corner. From 299623927ea9d67fe371a56f78142b17b7e70fd9 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 13:43:19 +1100 Subject: [PATCH 05/46] Build out lines when parsing html and holding >750 words in buffer (#73) ## Summary * Build out lines for pages when holding over 750 buffered words * Should fix issues with parsing long blocks of text causing memory crashes --- lib/Epub/Epub/ParsedText.cpp | 139 ++++++++++-------- lib/Epub/Epub/ParsedText.h | 12 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 11 ++ 3 files changed, 96 insertions(+), 66 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 3747246f..eff3fd63 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -19,14 +19,25 @@ void ParsedText::addWord(std::string word, const EpdFontStyle fontStyle) { // Consumes data to minimize memory usage void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const int horizontalMargin, - const std::function)>& processLine) { + const std::function)>& processLine, + const bool includeLastLine) { if (words.empty()) { return; } - const size_t totalWordCount = words.size(); const int pageWidth = renderer.getScreenWidth() - horizontalMargin; const int spaceWidth = renderer.getSpaceWidth(fontId); + const auto wordWidths = calculateWordWidths(renderer, fontId); + const auto lineBreakIndices = computeLineBreaks(pageWidth, spaceWidth, wordWidths); + const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1; + + for (size_t i = 0; i < lineCount; ++i) { + extractLine(i, pageWidth, spaceWidth, wordWidths, lineBreakIndices, processLine); + } +} + +std::vector ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) { + const size_t totalWordCount = words.size(); std::vector wordWidths; wordWidths.reserve(totalWordCount); @@ -47,6 +58,13 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo std::advance(wordStylesIt, 1); } + return wordWidths; +} + +std::vector ParsedText::computeLineBreaks(const int pageWidth, const int spaceWidth, + const std::vector& wordWidths) const { + const size_t totalWordCount = words.size(); + // DP table to store the minimum badness (cost) of lines starting at index i std::vector dp(totalWordCount); // 'ans[i]' stores the index 'j' of the *last word* in the optimal line starting at 'i' @@ -106,66 +124,59 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo currentWordIndex = nextBreakIndex; } - // Initialize iterators for consumption - auto wordStartIt = words.begin(); - auto wordStyleStartIt = wordStyles.begin(); - size_t wordWidthIndex = 0; - - size_t lastBreakAt = 0; - for (const size_t lineBreak : lineBreakIndices) { - const size_t lineWordCount = lineBreak - lastBreakAt; - - // Calculate end iterators for the range to splice - auto wordEndIt = wordStartIt; - auto wordStyleEndIt = wordStyleStartIt; - std::advance(wordEndIt, lineWordCount); - std::advance(wordStyleEndIt, lineWordCount); - - // Calculate total word width for this line - int lineWordWidthSum = 0; - for (size_t i = 0; i < lineWordCount; ++i) { - lineWordWidthSum += wordWidths[wordWidthIndex + i]; - } - - // Calculate spacing - int spareSpace = pageWidth - lineWordWidthSum; - - int spacing = spaceWidth; - const bool isLastLine = lineBreak == totalWordCount; - - if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) { - spacing = spareSpace / (lineWordCount - 1); - } - - // Calculate initial x position - uint16_t xpos = 0; - if (style == TextBlock::RIGHT_ALIGN) { - xpos = spareSpace - (lineWordCount - 1) * spaceWidth; - } else if (style == TextBlock::CENTER_ALIGN) { - xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2; - } - - // Pre-calculate X positions for words - std::list lineXPos; - for (size_t i = 0; i < lineWordCount; ++i) { - const uint16_t currentWordWidth = wordWidths[wordWidthIndex + i]; - lineXPos.push_back(xpos); - xpos += currentWordWidth + spacing; - } - - // *** CRITICAL STEP: CONSUME DATA USING SPLICE *** - std::list lineWords; - lineWords.splice(lineWords.begin(), words, wordStartIt, wordEndIt); - std::list lineWordStyles; - lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyleStartIt, wordStyleEndIt); - - processLine( - std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style)); - - // Update pointers/indices for the next line - wordStartIt = wordEndIt; - wordStyleStartIt = wordStyleEndIt; - wordWidthIndex += lineWordCount; - lastBreakAt = lineBreak; - } + return lineBreakIndices; +} + +void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth, + const std::vector& wordWidths, const std::vector& lineBreakIndices, + const std::function)>& processLine) { + const size_t lineBreak = lineBreakIndices[breakIndex]; + const size_t lastBreakAt = breakIndex > 0 ? lineBreakIndices[breakIndex - 1] : 0; + const size_t lineWordCount = lineBreak - lastBreakAt; + + // Calculate total word width for this line + int lineWordWidthSum = 0; + for (size_t i = lastBreakAt; i < lineBreak; i++) { + lineWordWidthSum += wordWidths[i]; + } + + // Calculate spacing + const int spareSpace = pageWidth - lineWordWidthSum; + + int spacing = spaceWidth; + const bool isLastLine = lineBreak == words.size(); + + if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) { + spacing = spareSpace / (lineWordCount - 1); + } + + // Calculate initial x position + uint16_t xpos = 0; + if (style == TextBlock::RIGHT_ALIGN) { + xpos = spareSpace - (lineWordCount - 1) * spaceWidth; + } else if (style == TextBlock::CENTER_ALIGN) { + xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2; + } + + // Pre-calculate X positions for words + std::list lineXPos; + for (size_t i = lastBreakAt; i < lineBreak; i++) { + const uint16_t currentWordWidth = wordWidths[i]; + lineXPos.push_back(xpos); + xpos += currentWordWidth + spacing; + } + + // Iterators always start at the beginning as we are moving content with splice below + auto wordEndIt = words.begin(); + auto wordStyleEndIt = wordStyles.begin(); + std::advance(wordEndIt, lineWordCount); + std::advance(wordStyleEndIt, lineWordCount); + + // *** CRITICAL STEP: CONSUME DATA USING SPLICE *** + std::list lineWords; + lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt); + std::list lineWordStyles; + lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt); + + processLine(std::make_shared(std::move(lineWords), std::move(lineXPos), std::move(lineWordStyles), style)); } diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 0bd25442..7fdb1286 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -2,11 +2,11 @@ #include -#include #include #include #include #include +#include #include "blocks/TextBlock.h" @@ -18,6 +18,12 @@ class ParsedText { TextBlock::BLOCK_STYLE style; bool extraParagraphSpacing; + std::vector computeLineBreaks(int pageWidth, int spaceWidth, const std::vector& wordWidths) const; + void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector& wordWidths, + const std::vector& lineBreakIndices, + const std::function)>& processLine); + std::vector calculateWordWidths(const GfxRenderer& renderer, int fontId); + public: explicit ParsedText(const TextBlock::BLOCK_STYLE style, const bool extraParagraphSpacing) : style(style), extraParagraphSpacing(extraParagraphSpacing) {} @@ -26,7 +32,9 @@ class ParsedText { void addWord(std::string word, EpdFontStyle fontStyle); void setStyle(const TextBlock::BLOCK_STYLE style) { this->style = style; } TextBlock::BLOCK_STYLE getStyle() const { return style; } + size_t size() const { return words.size(); } bool isEmpty() const { return words.empty(); } void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, int horizontalMargin, - const std::function)>& processLine); + const std::function)>& processLine, + bool includeLastLine = true); }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index d4edc331..718f4d73 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -143,6 +143,17 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char self->partWordBuffer[self->partWordBufferIndex++] = s[i]; } + + // If we have > 750 words buffered up, perform the layout and consume out all but the last line + // There should be enough here to build out 1-2 full pages and doing this will free up a lot of + // memory. + // Spotted when reading Intermezzo, there are some really long text blocks in there. + if (self->currentTextBlock->size() > 750) { + Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis()); + self->currentTextBlock->layoutAndExtractLines( + self->renderer, self->fontId, self->marginLeft + self->marginRight, + [self](const std::shared_ptr& textBlock) { self->addLineToPage(textBlock); }, false); + } } void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { From 926c7867055c22ce70c9957a38e47da49ee838f1 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Sun, 21 Dec 2025 04:38:51 +0100 Subject: [PATCH 06/46] Keep ZipFile open to speed up getting file stats. (#76) Still a bit raw, but gets the time required to determine the size of each chapter (for reading progress) down from ~25ms to 0-1ms. This is done by keeping the zipArchive open (so simple ;)). Probably we don't need to cache the spine sizes anymore then... --------- Co-authored-by: Dave Allie --- lib/Epub/Epub.cpp | 50 ++++++++++++----------------------------- lib/Epub/Epub.h | 1 + lib/ZipFile/ZipFile.cpp | 13 +++++------ lib/ZipFile/ZipFile.h | 6 ++--- 4 files changed, 23 insertions(+), 47 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 8d3e4ad8..0b557503 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -126,7 +126,6 @@ bool Epub::parseTocNcxFile() { // load in the meta data for the epub file bool Epub::load() { Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); - ZipFile zip("/sd" + filepath); std::string contentOpfFilePath; if (!findContentOpfFile(&contentOpfFilePath)) { @@ -155,44 +154,20 @@ bool Epub::load() { } void Epub::initializeSpineItemSizes() { - setupCacheDir(); + Serial.printf("[%lu] [EBP] Calculating book size\n", millis()); - size_t spineItemsCount = getSpineItemsCount(); + const size_t spineItemsCount = getSpineItemsCount(); size_t cumSpineItemSize = 0; - if (SD.exists((getCachePath() + "/spine_size.bin").c_str())) { - File f = SD.open((getCachePath() + "/spine_size.bin").c_str()); - uint8_t data[4]; - for (size_t i = 0; i < spineItemsCount; i++) { - f.read(data, 4); - cumSpineItemSize = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); - cumulativeSpineItemSize.emplace_back(cumSpineItemSize); - // Serial.printf("[%lu] [EBP] Loading item %d size %u to %u %u\n", millis(), - // i, cumSpineItemSize, data[1], data[0]); - } - f.close(); - } else { - File f = SD.open((getCachePath() + "/spine_size.bin").c_str(), FILE_WRITE); - uint8_t data[4]; - // determine size of spine items - for (size_t i = 0; i < spineItemsCount; i++) { - std::string spineItem = getSpineItem(i); - size_t s = 0; - getItemSize(spineItem, &s); - cumSpineItemSize += s; - cumulativeSpineItemSize.emplace_back(cumSpineItemSize); + const ZipFile zip("/sd" + filepath); - // and persist to cache - data[0] = cumSpineItemSize & 0xFF; - data[1] = (cumSpineItemSize >> 8) & 0xFF; - data[2] = (cumSpineItemSize >> 16) & 0xFF; - data[3] = (cumSpineItemSize >> 24) & 0xFF; - // Serial.printf("[%lu] [EBP] Persisting item %d size %u to %u %u\n", millis(), - // i, cumSpineItemSize, data[1], data[0]); - f.write(data, 4); - } - - f.close(); + for (size_t i = 0; i < spineItemsCount; i++) { + std::string spineItem = getSpineItem(i); + size_t s = 0; + getItemSize(zip, spineItem, &s); + cumSpineItemSize += s; + cumulativeSpineItemSize.emplace_back(cumSpineItemSize); } + Serial.printf("[%lu] [EBP] Book size: %lu\n", millis(), cumSpineItemSize); } @@ -291,8 +266,11 @@ bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, con bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { const ZipFile zip("/sd" + filepath); - const std::string path = normalisePath(itemHref); + return getItemSize(zip, itemHref, size); +} +bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size) { + const std::string path = normalisePath(itemHref); return zip.getInflatedFileSize(path.c_str(), size); } diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index f22b630c..31153035 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -33,6 +33,7 @@ class Epub { bool parseContentOpf(const std::string& contentOpfFilePath); bool parseTocNcxFile(); void initializeSpineItemSizes(); + static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size); public: explicit Epub(std::string filepath, const std::string& cacheDir) : filepath(std::move(filepath)) { diff --git a/lib/ZipFile/ZipFile.cpp b/lib/ZipFile/ZipFile.cpp index 11ce2115..83b11848 100644 --- a/lib/ZipFile/ZipFile.cpp +++ b/lib/ZipFile/ZipFile.cpp @@ -27,31 +27,28 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t* return true; } -bool ZipFile::loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const { - mz_zip_archive zipArchive = {}; - const bool status = mz_zip_reader_init_file(&zipArchive, filePath.c_str(), 0); +ZipFile::ZipFile(std::string filePath) : filePath(std::move(filePath)) { + const bool status = mz_zip_reader_init_file(&zipArchive, this->filePath.c_str(), 0); if (!status) { - Serial.printf("[%lu] [ZIP] mz_zip_reader_init_file() failed! Error: %s\n", millis(), + Serial.printf("[%lu] [ZIP] mz_zip_reader_init_file() failed for %s! Error: %s\n", millis(), this->filePath.c_str(), mz_zip_get_error_string(zipArchive.m_last_error)); - return false; } +} +bool ZipFile::loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const { // find the file mz_uint32 fileIndex = 0; if (!mz_zip_reader_locate_file_v2(&zipArchive, filename, nullptr, 0, &fileIndex)) { Serial.printf("[%lu] [ZIP] Could not find file %s\n", millis(), filename); - mz_zip_reader_end(&zipArchive); return false; } if (!mz_zip_reader_file_stat(&zipArchive, fileIndex, fileStat)) { Serial.printf("[%lu] [ZIP] mz_zip_reader_file_stat() failed! Error: %s\n", millis(), mz_zip_get_error_string(zipArchive.m_last_error)); - mz_zip_reader_end(&zipArchive); return false; } - mz_zip_reader_end(&zipArchive); return true; } diff --git a/lib/ZipFile/ZipFile.h b/lib/ZipFile/ZipFile.h index e452ec5e..58e3ab91 100644 --- a/lib/ZipFile/ZipFile.h +++ b/lib/ZipFile/ZipFile.h @@ -1,19 +1,19 @@ #pragma once #include -#include #include #include "miniz.h" class ZipFile { std::string filePath; + mutable mz_zip_archive zipArchive = {}; bool loadFileStat(const char* filename, mz_zip_archive_file_stat* fileStat) const; long getDataOffset(const mz_zip_archive_file_stat& fileStat) const; public: - explicit ZipFile(std::string filePath) : filePath(std::move(filePath)) {} - ~ZipFile() = default; + explicit ZipFile(std::string filePath); + ~ZipFile() { mz_zip_reader_end(&zipArchive); } bool getInflatedFileSize(const char* filename, size_t* size) const; uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false) const; bool readFileToStream(const char* filename, Print& out, size_t chunkSize) const; From 9b4dfbd1808ea723f1d60632ca332ff1f4c6d266 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 15:43:17 +1100 Subject: [PATCH 07/46] Allow any file to be uploaded (#84) ## Summary - Allow any file to be uploaded - Removes .epub restriction ## Additional Context - Fixes #74 --- .../network/server/CrossPointWebServer.cpp | 7 -- src/html/FilesPageFooter.html | 96 ++++++++----------- 2 files changed, 39 insertions(+), 64 deletions(-) diff --git a/src/activities/network/server/CrossPointWebServer.cpp b/src/activities/network/server/CrossPointWebServer.cpp index 61a88133..90ec915a 100644 --- a/src/activities/network/server/CrossPointWebServer.cpp +++ b/src/activities/network/server/CrossPointWebServer.cpp @@ -513,13 +513,6 @@ void CrossPointWebServer::handleUpload() { Serial.printf("[%lu] [WEB] [UPLOAD] START: %s to path: %s\n", millis(), uploadFileName.c_str(), uploadPath.c_str()); Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap()); - // Validate file extension - if (!isEpubFile(uploadFileName)) { - uploadError = "Only .epub files are allowed"; - Serial.printf("[%lu] [WEB] [UPLOAD] REJECTED - not an epub file\n", millis()); - return; - } - // Create file path String filePath = uploadPath; if (!filePath.endsWith("/")) filePath += "/"; diff --git a/src/html/FilesPageFooter.html b/src/html/FilesPageFooter.html index 102430a7..961753a6 100644 --- a/src/html/FilesPageFooter.html +++ b/src/html/FilesPageFooter.html @@ -3,15 +3,15 @@ CrossPoint E-Reader • Open Source

- + - + - + From 0d32d21d756c5b45b5a7b3b3d0a29429763387ff Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 15:43:53 +1100 Subject: [PATCH 08/46] Small code cleanup (#83) ## Summary * Fix cppcheck low violations * Remove teardown method on parsers, use destructor * Code cleanup --- .github/workflows/ci.yml | 2 +- lib/Epub/Epub.cpp | 14 ++---- lib/Epub/Epub/Section.h | 7 +-- lib/Epub/Epub/parsers/ContainerParser.cpp | 3 +- lib/Epub/Epub/parsers/ContainerParser.h | 2 +- lib/Epub/Epub/parsers/ContentOpfParser.cpp | 5 +-- lib/Epub/Epub/parsers/ContentOpfParser.h | 2 +- lib/Epub/Epub/parsers/TocNcxParser.cpp | 4 +- lib/Epub/Epub/parsers/TocNcxParser.h | 2 +- platformio.ini | 2 +- src/WifiCredentialStore.cpp | 34 ++++++++------- src/activities/boot_sleep/SleepActivity.cpp | 2 +- .../network/CrossPointWebServerActivity.cpp | 5 +-- .../network/WifiSelectionActivity.cpp | 43 +++++++++---------- .../network/server/CrossPointWebServer.cpp | 4 +- src/activities/reader/EpubReaderActivity.cpp | 2 +- .../reader/FileSelectionActivity.cpp | 2 +- src/activities/util/KeyboardEntryActivity.cpp | 2 +- src/main.cpp | 19 +++----- 19 files changed, 70 insertions(+), 86 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c090791c..b1d91ec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: sudo apt-get install -y clang-format-21 - name: Run cppcheck - run: pio check --fail-on-defect medium --fail-on-defect high + run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high - name: Run clang-format run: PATH="/usr/lib/llvm-21/bin:$PATH" ./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 0b557503..2df5a3f4 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -30,24 +30,22 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const { // Stream read (reusing your existing stream logic) if (!readItemContentsToStream(containerPath, containerParser, 512)) { Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis()); - containerParser.teardown(); return false; } // Extract the result if (containerParser.fullPath.empty()) { Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis()); - containerParser.teardown(); return false; } *contentOpfFile = std::move(containerParser.fullPath); - - containerParser.teardown(); return true; } bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { + Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str()); + size_t contentOpfSize; if (!getItemSize(contentOpfFilePath, &contentOpfSize)) { Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis()); @@ -63,7 +61,6 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) { Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis()); - opfParser.teardown(); return false; } @@ -84,8 +81,6 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { } Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); - - opfParser.teardown(); return true; } @@ -96,6 +91,8 @@ bool Epub::parseTocNcxFile() { return false; } + Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); + size_t tocSize; if (!getItemSize(tocNcxItem, &tocSize)) { Serial.printf("[%lu] [EBP] Could not get size of toc ncx\n", millis()); @@ -111,15 +108,12 @@ bool Epub::parseTocNcxFile() { if (!readItemContentsToStream(tocNcxItem, ncxParser, 1024)) { Serial.printf("[%lu] [EBP] Could not read toc ncx stream\n", millis()); - ncxParser.teardown(); return false; } this->toc = std::move(ncxParser.toc); Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size()); - - ncxParser.teardown(); return true; } diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 35a17dfc..d7a2c721 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -21,9 +21,10 @@ class Section { int currentPage = 0; explicit Section(const std::shared_ptr& epub, const int spineIndex, GfxRenderer& renderer) - : epub(epub), spineIndex(spineIndex), renderer(renderer) { - cachePath = epub->getCachePath() + "/" + std::to_string(spineIndex); - } + : epub(epub), + spineIndex(spineIndex), + renderer(renderer), + cachePath(epub->getCachePath() + "/" + std::to_string(spineIndex)) {} ~Section() = default; bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, int marginLeft, bool extraParagraphSpacing); diff --git a/lib/Epub/Epub/parsers/ContainerParser.cpp b/lib/Epub/Epub/parsers/ContainerParser.cpp index b7ff5d1f..db126f25 100644 --- a/lib/Epub/Epub/parsers/ContainerParser.cpp +++ b/lib/Epub/Epub/parsers/ContainerParser.cpp @@ -14,12 +14,11 @@ bool ContainerParser::setup() { return true; } -bool ContainerParser::teardown() { +ContainerParser::~ContainerParser() { if (parser) { XML_ParserFree(parser); parser = nullptr; } - return true; } size_t ContainerParser::write(const uint8_t data) { return write(&data, 1); } diff --git a/lib/Epub/Epub/parsers/ContainerParser.h b/lib/Epub/Epub/parsers/ContainerParser.h index 07e28abb..39517750 100644 --- a/lib/Epub/Epub/parsers/ContainerParser.h +++ b/lib/Epub/Epub/parsers/ContainerParser.h @@ -23,9 +23,9 @@ class ContainerParser final : public Print { std::string fullPath; explicit ContainerParser(const size_t xmlSize) : remainingSize(xmlSize) {} + ~ContainerParser() override; bool setup(); - bool teardown(); size_t write(uint8_t) override; size_t write(const uint8_t* buffer, size_t size) override; diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 472d76cf..5aa73032 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -4,7 +4,7 @@ #include namespace { -constexpr const char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; +constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; } bool ContentOpfParser::setup() { @@ -20,12 +20,11 @@ bool ContentOpfParser::setup() { return true; } -bool ContentOpfParser::teardown() { +ContentOpfParser::~ContentOpfParser() { if (parser) { XML_ParserFree(parser); parser = nullptr; } - return true; } size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); } diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.h b/lib/Epub/Epub/parsers/ContentOpfParser.h index cba45510..a3070fcc 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.h +++ b/lib/Epub/Epub/parsers/ContentOpfParser.h @@ -34,9 +34,9 @@ class ContentOpfParser final : public Print { explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize) : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + ~ContentOpfParser() override; bool setup(); - bool teardown(); size_t write(uint8_t) override; size_t write(const uint8_t* buffer, size_t size) override; diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index f02d7c4c..4d541f5c 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -1,5 +1,6 @@ #include "TocNcxParser.h" +#include #include bool TocNcxParser::setup() { @@ -15,12 +16,11 @@ bool TocNcxParser::setup() { return true; } -bool TocNcxParser::teardown() { +TocNcxParser::~TocNcxParser() { if (parser) { XML_ParserFree(parser); parser = nullptr; } - return true; } size_t TocNcxParser::write(const uint8_t data) { return write(&data, 1); } diff --git a/lib/Epub/Epub/parsers/TocNcxParser.h b/lib/Epub/Epub/parsers/TocNcxParser.h index 6217f3f5..5d5df0be 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.h +++ b/lib/Epub/Epub/parsers/TocNcxParser.h @@ -28,9 +28,9 @@ class TocNcxParser final : public Print { explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize) : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + ~TocNcxParser() override; bool setup(); - bool teardown(); size_t write(uint8_t) override; size_t write(const uint8_t* buffer, size_t size) override; diff --git a/platformio.ini b/platformio.ini index 4f71afe4..0f923803 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,8 +9,8 @@ framework = arduino monitor_speed = 115200 upload_speed = 921600 check_tool = cppcheck +check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --inline-suppr check_skip_packages = yes -check_severity = medium, high board_upload.flash_size = 16MB board_upload.maximum_size = 16777216 diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index ec9d955a..7df9e2fe 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -111,12 +111,12 @@ bool WifiCredentialStore::loadFromFile() { bool WifiCredentialStore::addCredential(const std::string& ssid, const std::string& password) { // Check if this SSID already exists and update it - for (auto& cred : credentials) { - if (cred.ssid == ssid) { - cred.password = password; - Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str()); - return saveToFile(); - } + const auto cred = find_if(credentials.begin(), credentials.end(), + [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); + if (cred != credentials.end()) { + cred->password = password; + Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str()); + return saveToFile(); } // Check if we've reached the limit @@ -132,22 +132,24 @@ bool WifiCredentialStore::addCredential(const std::string& ssid, const std::stri } bool WifiCredentialStore::removeCredential(const std::string& ssid) { - for (auto it = credentials.begin(); it != credentials.end(); ++it) { - if (it->ssid == ssid) { - credentials.erase(it); - Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); - return saveToFile(); - } + const auto cred = find_if(credentials.begin(), credentials.end(), + [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); + if (cred != credentials.end()) { + credentials.erase(cred); + Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); + return saveToFile(); } return false; // Not found } const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssid) const { - for (const auto& cred : credentials) { - if (cred.ssid == ssid) { - return &cred; - } + const auto cred = find_if(credentials.begin(), credentials.end(), + [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); + + if (cred != credentials.end()) { + return &*cred; } + return nullptr; } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 3b369905..900a0dbb 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,11 +1,11 @@ #include "SleepActivity.h" #include +#include #include #include "CrossPointSettings.h" -#include "SD.h" #include "config.h" #include "images/CrossLarge.h" diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 82f63294..bb0f39a2 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -217,8 +217,7 @@ void CrossPointWebServerActivity::render() const { } void CrossPointWebServerActivity::renderServerRunning() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 5) / 2; @@ -226,7 +225,7 @@ void CrossPointWebServerActivity::renderServerRunning() const { std::string ssidInfo = "Network: " + connectedSSID; if (ssidInfo.length() > 28) { - ssidInfo = ssidInfo.substr(0, 25) + "..."; + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index a48891ea..d6c3b1ec 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -138,6 +138,7 @@ void WifiSelectionActivity::processWifiScanResults() { // Convert map to vector networks.clear(); for (const auto& pair : uniqueNetworks) { + // cppcheck-suppress useStlAlgorithm networks.push_back(pair.second); } @@ -334,11 +335,10 @@ void WifiSelectionActivity::loop() { // User chose "Yes" - forget the network WIFI_STORE.removeCredential(selectedSSID); // Update the network list to reflect the change - for (auto& network : networks) { - if (network.ssid == selectedSSID) { - network.hasSavedPassword = false; - break; - } + const auto network = find_if(networks.begin(), networks.end(), + [this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; }); + if (network != networks.end()) { + network->hasSavedPassword = false; } } // Go back to network list @@ -468,8 +468,8 @@ void WifiSelectionActivity::render() const { } void WifiSelectionActivity::renderNetworkList() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); // Draw header renderer.drawCenteredText(READER_FONT_ID, 10, "WiFi Networks", true, BOLD); @@ -506,7 +506,7 @@ void WifiSelectionActivity::renderNetworkList() const { // Draw network name (truncate if too long) std::string displayName = network.ssid; if (displayName.length() > 16) { - displayName = displayName.substr(0, 13) + "..."; + displayName.replace(13, displayName.length() - 13, "..."); } renderer.drawText(UI_FONT_ID, 20, networkY, displayName.c_str()); @@ -544,15 +544,13 @@ void WifiSelectionActivity::renderNetworkList() const { } void WifiSelectionActivity::renderPasswordEntry() const { - const auto pageHeight = GfxRenderer::getScreenHeight(); - // Draw header renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD); // Draw network name with good spacing from header std::string networkInfo = "Network: " + selectedSSID; if (networkInfo.length() > 30) { - networkInfo = networkInfo.substr(0, 27) + "..."; + networkInfo.replace(27, networkInfo.length() - 27, "..."); } renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR); @@ -563,7 +561,7 @@ void WifiSelectionActivity::renderPasswordEntry() const { } void WifiSelectionActivity::renderConnecting() const { - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height) / 2; @@ -574,15 +572,14 @@ void WifiSelectionActivity::renderConnecting() const { std::string ssidInfo = "to " + selectedSSID; if (ssidInfo.length() > 25) { - ssidInfo = ssidInfo.substr(0, 22) + "..."; + ssidInfo.replace(22, ssidInfo.length() - 22, "..."); } renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); } } void WifiSelectionActivity::renderConnected() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 4) / 2; @@ -590,7 +587,7 @@ void WifiSelectionActivity::renderConnected() const { std::string ssidInfo = "Network: " + selectedSSID; if (ssidInfo.length() > 28) { - ssidInfo = ssidInfo.substr(0, 25) + "..."; + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); @@ -601,8 +598,8 @@ void WifiSelectionActivity::renderConnected() const { } void WifiSelectionActivity::renderSavePrompt() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 3) / 2; @@ -610,7 +607,7 @@ void WifiSelectionActivity::renderSavePrompt() const { std::string ssidInfo = "Network: " + selectedSSID; if (ssidInfo.length() > 28) { - ssidInfo = ssidInfo.substr(0, 25) + "..."; + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); @@ -641,7 +638,7 @@ void WifiSelectionActivity::renderSavePrompt() const { } void WifiSelectionActivity::renderConnectionFailed() const { - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 2) / 2; @@ -651,8 +648,8 @@ void WifiSelectionActivity::renderConnectionFailed() const { } void WifiSelectionActivity::renderForgetPrompt() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (pageHeight - height * 3) / 2; @@ -660,7 +657,7 @@ void WifiSelectionActivity::renderForgetPrompt() const { std::string ssidInfo = "Network: " + selectedSSID; if (ssidInfo.length() > 28) { - ssidInfo = ssidInfo.substr(0, 25) + "..."; + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); } renderer.drawCenteredText(UI_FONT_ID, top, ssidInfo.c_str(), true, REGULAR); diff --git a/src/activities/network/server/CrossPointWebServer.cpp b/src/activities/network/server/CrossPointWebServer.cpp index 90ec915a..62916277 100644 --- a/src/activities/network/server/CrossPointWebServer.cpp +++ b/src/activities/network/server/CrossPointWebServer.cpp @@ -383,9 +383,7 @@ void CrossPointWebServer::handleFileList() { // Folders come first if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory; // Then sort by epub status (epubs first among files) - if (!a.isDirectory && !b.isDirectory) { - if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub; - } + if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub; // Then alphabetically return a.name < b.name; }); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index fc4504d5..8827e998 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -383,7 +383,7 @@ void EpubReaderActivity::renderStatusBar() const { title = tocItem.title; titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); while (titleWidth > availableTextWidth && title.length() > 11) { - title = title.substr(0, title.length() - 8) + "..."; + title.replace(title.length() - 8, 8, "..."); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); } } diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 9e665cb2..4389461f 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -93,7 +93,7 @@ void FileSelectionActivity::loop() { } } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (basepath != "/") { - basepath = basepath.substr(0, basepath.rfind('/')); + basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); updateRequired = true; diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 40d6fedf..68fc0792 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -20,7 +20,7 @@ KeyboardEntryActivity::KeyboardEntryActivity(GfxRenderer& renderer, InputManager void KeyboardEntryActivity::setText(const std::string& newText) { text = newText; if (maxLength > 0 && text.length() > maxLength) { - text = text.substr(0, maxLength); + text.resize(maxLength); } } diff --git a/src/main.cpp b/src/main.cpp index 86169a54..89d2af48 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -203,20 +202,18 @@ void setup() { } void loop() { - static unsigned long lastLoopTime = 0; static unsigned long maxLoopDuration = 0; - - unsigned long loopStartTime = millis(); - + const unsigned long loopStartTime = millis(); static unsigned long lastMemPrint = 0; + + inputManager.update(); + if (Serial && millis() - lastMemPrint >= 10000) { Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), ESP.getHeapSize(), ESP.getMinFreeHeap()); lastMemPrint = millis(); } - inputManager.update(); - // Check for any user activity (button press or release) static unsigned long lastActivityTime = millis(); if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased()) { @@ -237,13 +234,13 @@ void loop() { return; } - unsigned long activityStartTime = millis(); + const unsigned long activityStartTime = millis(); if (currentActivity) { currentActivity->loop(); } - unsigned long activityDuration = millis() - activityStartTime; + const unsigned long activityDuration = millis() - activityStartTime; - unsigned long loopDuration = millis() - loopStartTime; + const unsigned long loopDuration = millis() - loopStartTime; if (loopDuration > maxLoopDuration) { maxLoopDuration = loopDuration; if (maxLoopDuration > 50) { @@ -252,8 +249,6 @@ void loop() { } } - lastLoopTime = loopStartTime; - // Add delay at the end of the loop to prevent tight spinning // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // Otherwise, use longer delay to save power From f264efdb12a47c6f07c475f8ff734c71658f6833 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 17:08:34 +1100 Subject: [PATCH 09/46] Extract EPUB TOC into temp file before parsing (#85) ## Summary * Extract EPUB TOC into temp file before parsing * Streaming ZIP -> XML parser uses up a lot of memory as we're allocating inflation buffers while also holding a few copies of the buffer in different forms * Instead, but streaming the inflated file down to the SD card (like we do for HTML parsing, we can lower memory usage) ## Additional Context * This should help with https://github.com/daveallie/crosspoint-reader/issues/60 and https://github.com/daveallie/crosspoint-reader/issues/10. It won't remove those class of issues completely, but will allow for many more books to be opened. --- lib/Epub/Epub.cpp | 36 +++++++++++++++++++------- lib/Epub/Epub/EpubTocEntry.h | 7 ++--- lib/Epub/Epub/parsers/TocNcxParser.cpp | 2 +- lib/Epub/Epub/parsers/TocNcxParser.h | 2 +- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 2df5a3f4..cc3bc909 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -93,24 +93,42 @@ bool Epub::parseTocNcxFile() { Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); - size_t tocSize; - if (!getItemSize(tocNcxItem, &tocSize)) { - Serial.printf("[%lu] [EBP] Could not get size of toc ncx\n", millis()); - return false; - } + const auto tmpNcxPath = getCachePath() + "/toc.ncx"; + File tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_WRITE); + readItemContentsToStream(tocNcxItem, tempNcxFile, 1024); + tempNcxFile.close(); + tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_READ); + const auto ncxSize = tempNcxFile.size(); - TocNcxParser ncxParser(contentBasePath, tocSize); + TocNcxParser ncxParser(contentBasePath, ncxSize); if (!ncxParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); return false; } - if (!readItemContentsToStream(tocNcxItem, ncxParser, 1024)) { - Serial.printf("[%lu] [EBP] Could not read toc ncx stream\n", millis()); + const auto ncxBuffer = static_cast(malloc(1024)); + if (!ncxBuffer) { + Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis()); return false; } + while (tempNcxFile.available()) { + const auto readSize = tempNcxFile.read(ncxBuffer, 1024); + const auto processedSize = ncxParser.write(ncxBuffer, readSize); + + if (processedSize != readSize) { + Serial.printf("[%lu] [EBP] Could not process all toc ncx data\n", millis()); + free(ncxBuffer); + tempNcxFile.close(); + return false; + } + } + + free(ncxBuffer); + tempNcxFile.close(); + SD.remove(tmpNcxPath.c_str()); + this->toc = std::move(ncxParser.toc); Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size()); @@ -293,7 +311,7 @@ std::string& Epub::getSpineItem(const int spineIndex) { } EpubTocEntry& Epub::getTocItem(const int tocTndex) { - static EpubTocEntry emptyEntry("", "", "", 0); + static EpubTocEntry emptyEntry = {}; if (toc.empty()) { Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); return emptyEntry; diff --git a/lib/Epub/Epub/EpubTocEntry.h b/lib/Epub/Epub/EpubTocEntry.h index 715e4a44..94f0c90f 100644 --- a/lib/Epub/Epub/EpubTocEntry.h +++ b/lib/Epub/Epub/EpubTocEntry.h @@ -2,12 +2,9 @@ #include -class EpubTocEntry { - public: +struct EpubTocEntry { std::string title; std::string href; std::string anchor; - int level; - EpubTocEntry(std::string title, std::string href, std::string anchor, const int level) - : title(std::move(title)), href(std::move(href)), anchor(std::move(anchor)), level(level) {} + uint8_t level; }; diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index 4d541f5c..0a613f33 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -155,7 +155,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { } // Push to vector - self->toc.emplace_back(self->currentLabel, href, anchor, self->currentDepth); + self->toc.push_back({std::move(self->currentLabel), std::move(href), std::move(anchor), self->currentDepth}); // Clear them so we don't re-add them if there are weird XML structures self->currentLabel.clear(); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.h b/lib/Epub/Epub/parsers/TocNcxParser.h index 5d5df0be..2f3601a1 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.h +++ b/lib/Epub/Epub/parsers/TocNcxParser.h @@ -17,7 +17,7 @@ class TocNcxParser final : public Print { std::string currentLabel; std::string currentSrc; - size_t currentDepth = 0; + uint8_t currentDepth = 0; static void startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void characterData(void* userData, const XML_Char* s, int len); From b73ae7fe747c48dc46ae24768c2408b1202e29bf Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 17:12:53 +1100 Subject: [PATCH 10/46] Paginate book list and avoid out of bounds rendering (#86) ## Summary * Paginate book list * Avoid out of bounds rendering of long book titles, truncate with ellipsis instead ## Additional Context * Should partially help with https://github.com/daveallie/crosspoint-reader/issues/75 as it was previously rendering a lot of content off screen, will need to test with a large directory --- .../EpubReaderChapterSelectionActivity.cpp | 2 + .../reader/FileSelectionActivity.cpp | 51 +++++++++++++------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index f4dfc373..9af556ba 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -5,8 +5,10 @@ #include "config.h" +namespace { constexpr int PAGE_ITEMS = 24; constexpr int SKIP_PAGE_MS = 700; +} // namespace void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { auto* self = static_cast(param); diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 4389461f..d8aef3c7 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -5,6 +5,11 @@ #include "config.h" +namespace { +constexpr int PAGE_ITEMS = 23; +constexpr int SKIP_PAGE_MS = 700; +} // namespace + void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { if (str1.back() == '/' && str2.back() != '/') return true; @@ -73,10 +78,12 @@ void FileSelectionActivity::onExit() { } void FileSelectionActivity::loop() { - const bool prevPressed = - inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); - const bool nextPressed = - inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); + const bool prevReleased = + inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); + const bool nextReleased = + inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); + + const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (files.empty()) { @@ -101,11 +108,19 @@ void FileSelectionActivity::loop() { // At root level, go back home onGoHome(); } - } else if (prevPressed) { - selectorIndex = (selectorIndex + files.size() - 1) % files.size(); + } else if (prevReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + files.size()) % files.size(); + } else { + selectorIndex = (selectorIndex + files.size() - 1) % files.size(); + } updateRequired = true; - } else if (nextPressed) { - selectorIndex = (selectorIndex + 1) % files.size(); + } else if (nextReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % files.size(); + } else { + selectorIndex = (selectorIndex + 1) % files.size(); + } updateRequired = true; } } @@ -126,21 +141,27 @@ void FileSelectionActivity::render() const { renderer.clearScreen(); const auto pageWidth = GfxRenderer::getScreenWidth(); - renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); + renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); // Help text renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home"); if (files.empty()) { renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); - } else { - // Draw selection - renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); + renderer.displayBuffer(); + return; + } - for (size_t i = 0; i < files.size(); i++) { - const auto file = files[i]; - renderer.drawText(UI_FONT_ID, 20, 60 + i * 30, file.c_str(), i != selectorIndex); + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 + 2, pageWidth - 1, 30); + for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { + auto item = files[i]; + int itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str()); + while (itemWidth > renderer.getScreenWidth() - 40 && item.length() > 8) { + item.replace(item.length() - 5, 5, "..."); + itemWidth = renderer.getTextWidth(UI_FONT_ID, item.c_str()); } + renderer.drawText(UI_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex); } renderer.displayBuffer(); From 2a27c6d06890cc447aa772f3edc993c2d0e1982b Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 17:15:17 +1100 Subject: [PATCH 11/46] Add JPG image support (#23) ## Summary - Add basic JPG image support - Map JPG back to 2-bit BMP output - Can be used to later render the BMP file from disk or directly pass to output if wanted - Give the 3 passes over the data needed to render grayscale content, putting it on disk is preferred to outputting it multiple times ## Additional Context - WIP, looking forward to BMP support from https://github.com/daveallie/crosspoint-reader/pull/16 - Addresses some of #11 --- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 244 ++ lib/JpegToBmpConverter/JpegToBmpConverter.h | 15 + lib/picojpeg/picojpeg.c | 2087 +++++++++++++++++ lib/picojpeg/picojpeg.h | 124 + 4 files changed, 2470 insertions(+) create mode 100644 lib/JpegToBmpConverter/JpegToBmpConverter.cpp create mode 100644 lib/JpegToBmpConverter/JpegToBmpConverter.h create mode 100644 lib/picojpeg/picojpeg.c create mode 100644 lib/picojpeg/picojpeg.h diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp new file mode 100644 index 00000000..4b48d70a --- /dev/null +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -0,0 +1,244 @@ +#include "JpegToBmpConverter.h" + +#include + +#include +#include + +// Context structure for picojpeg callback +struct JpegReadContext { + File& file; + uint8_t buffer[512]; + size_t bufferPos; + size_t bufferFilled; +}; + +// Helper function: Convert 8-bit grayscale to 2-bit (0-3) +uint8_t JpegToBmpConverter::grayscaleTo2Bit(const uint8_t grayscale) { + // Simple threshold mapping: + // 0-63 -> 0 (black) + // 64-127 -> 1 (dark gray) + // 128-191 -> 2 (light gray) + // 192-255 -> 3 (white) + return grayscale >> 6; +} + +inline void write16(Print& out, const uint16_t value) { + // out.write(reinterpret_cast(&value), 2); + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); +} + +inline void write32(Print& out, const uint32_t value) { + // out.write(reinterpret_cast(&value), 4); + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); + out.write((value >> 16) & 0xFF); + out.write((value >> 24) & 0xFF); +} + +inline void write32Signed(Print& out, const int32_t value) { + // out.write(reinterpret_cast(&value), 4); + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); + out.write((value >> 16) & 0xFF); + out.write((value >> 24) & 0xFF); +} + +// Helper function: Write BMP header with 2-bit color depth +void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up + const int imageSize = bytesPerRow * height; + const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); // File size + write32(bmpOut, 0); // Reserved + write32(bmpOut, 70); // Offset to pixel data + + // DIB Header (BITMAPINFOHEADER - 40 bytes) + write32(bmpOut, 40); + write32Signed(bmpOut, width); + write32Signed(bmpOut, -height); // Negative height = top-down bitmap + write16(bmpOut, 1); // Color planes + write16(bmpOut, 2); // Bits per pixel (2 bits) + write32(bmpOut, 0); // BI_RGB (no compression) + write32(bmpOut, imageSize); + write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) + write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) + write32(bmpOut, 4); // colorsUsed + write32(bmpOut, 4); // colorsImportant + + // Color Palette (4 colors x 4 bytes = 16 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + uint8_t palette[16] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) + 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) + 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } +} + +// Callback function for picojpeg to read JPEG data +unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const unsigned char buf_size, + unsigned char* pBytes_actually_read, void* pCallback_data) { + auto* context = static_cast(pCallback_data); + + if (!context || !context->file) { + return PJPG_STREAM_READ_ERROR; + } + + // Check if we need to refill our context buffer + if (context->bufferPos >= context->bufferFilled) { + context->bufferFilled = context->file.read(context->buffer, sizeof(context->buffer)); + context->bufferPos = 0; + + if (context->bufferFilled == 0) { + // EOF or error + *pBytes_actually_read = 0; + return 0; // Success (EOF is normal) + } + } + + // Copy available bytes to picojpeg's buffer + const size_t available = context->bufferFilled - context->bufferPos; + const size_t toRead = available < buf_size ? available : buf_size; + + memcpy(pBuf, context->buffer + context->bufferPos, toRead); + context->bufferPos += toRead; + *pBytes_actually_read = static_cast(toRead); + + return 0; // Success +} + +// Core function: Convert JPEG file to 2-bit BMP +bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { + Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis()); + + // Setup context for picojpeg callback + JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; + + // Initialize picojpeg decoder + pjpeg_image_info_t imageInfo; + const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); + if (status != 0) { + Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status); + return false; + } + + Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width, + imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol); + + // Write BMP header + writeBmpHeader(bmpOut, imageInfo.m_width, imageInfo.m_height); + + // Calculate row parameters + const int bytesPerRow = (imageInfo.m_width * 2 + 31) / 32 * 4; + + // Allocate row buffer for packed 2-bit pixels + auto* rowBuffer = static_cast(malloc(bytesPerRow)); + if (!rowBuffer) { + Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis()); + return false; + } + + // Allocate a buffer for one MCU row worth of grayscale pixels + // This is the minimal memory needed for streaming conversion + const int mcuPixelHeight = imageInfo.m_MCUHeight; + const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight; + auto* mcuRowBuffer = static_cast(malloc(mcuRowPixels)); + if (!mcuRowBuffer) { + Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer\n", millis()); + free(rowBuffer); + return false; + } + + // Process MCUs row-by-row and write to BMP as we go (top-down) + const int mcuPixelWidth = imageInfo.m_MCUWidth; + + for (int mcuY = 0; mcuY < imageInfo.m_MCUSPerCol; mcuY++) { + // Clear the MCU row buffer + memset(mcuRowBuffer, 0, mcuRowPixels); + + // Decode one row of MCUs + for (int mcuX = 0; mcuX < imageInfo.m_MCUSPerRow; mcuX++) { + const unsigned char mcuStatus = pjpeg_decode_mcu(); + if (mcuStatus != 0) { + if (mcuStatus == PJPG_NO_MORE_BLOCKS) { + Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY); + } else { + Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY, + mcuStatus); + } + free(mcuRowBuffer); + free(rowBuffer); + return false; + } + + // Process MCU block into MCU row buffer + for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { + for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { + const int pixelX = mcuX * mcuPixelWidth + blockX; + + // Skip pixels outside image width (can happen with MCU alignment) + if (pixelX >= imageInfo.m_width) { + continue; + } + + // Get grayscale value + uint8_t gray; + if (imageInfo.m_comps == 1) { + // Grayscale image + gray = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; + } else { + // RGB image - convert to grayscale + const uint8_t r = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; + const uint8_t g = imageInfo.m_pMCUBufG[blockY * mcuPixelWidth + blockX]; + const uint8_t b = imageInfo.m_pMCUBufB[blockY * mcuPixelWidth + blockX]; + // Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B + // Using integer approximation: (30*R + 59*G + 11*B) / 100 + gray = (r * 30 + g * 59 + b * 11) / 100; + } + + // Store grayscale value in MCU row buffer + mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray; + } + } + } + + // Write all pixel rows from this MCU row to BMP file + const int startRow = mcuY * mcuPixelHeight; + const int endRow = (mcuY + 1) * mcuPixelHeight; + + for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) { + memset(rowBuffer, 0, bytesPerRow); + + // Pack 4 pixels per byte (2 bits each) + for (int x = 0; x < imageInfo.m_width; x++) { + const int bufferY = y - startRow; + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + const uint8_t twoBit = grayscaleTo2Bit(gray); + + const int byteIndex = (x * 2) / 8; + const int bitOffset = 6 - ((x * 2) % 8); // 6, 4, 2, 0 + rowBuffer[byteIndex] |= (twoBit << bitOffset); + } + + // Write row with padding + bmpOut.write(rowBuffer, bytesPerRow); + } + } + + // Clean up + free(mcuRowBuffer); + free(rowBuffer); + + Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis()); + return true; +} diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h new file mode 100644 index 00000000..fc881e25 --- /dev/null +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +class ZipFile; + +class JpegToBmpConverter { + static void writeBmpHeader(Print& bmpOut, int width, int height); + static uint8_t grayscaleTo2Bit(uint8_t grayscale); + static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, + unsigned char* pBytes_actually_read, void* pCallback_data); + + public: + static bool jpegFileToBmpStream(File& jpegFile, Print& bmpOut); +}; diff --git a/lib/picojpeg/picojpeg.c b/lib/picojpeg/picojpeg.c new file mode 100644 index 00000000..f612b73c --- /dev/null +++ b/lib/picojpeg/picojpeg.c @@ -0,0 +1,2087 @@ +//------------------------------------------------------------------------------ +// picojpeg.c v1.1 - Public domain, Rich Geldreich +// Nov. 27, 2010 - Initial release +// Feb. 9, 2013 - Added H1V2/H2V1 support, cleaned up macros, signed shift fixes +// Also integrated and tested changes from Chris Phoenix . +//------------------------------------------------------------------------------ +#include "picojpeg.h" +//------------------------------------------------------------------------------ +// Set to 1 if right shifts on signed ints are always unsigned (logical) shifts +// When 1, arithmetic right shifts will be emulated by using a logical shift +// with special case code to ensure the sign bit is replicated. +#define PJPG_RIGHT_SHIFT_IS_ALWAYS_UNSIGNED 0 + +// Define PJPG_INLINE to "inline" if your C compiler supports explicit inlining +#define PJPG_INLINE +//------------------------------------------------------------------------------ +typedef unsigned char uint8; +typedef unsigned short uint16; +typedef signed char int8; +typedef signed short int16; +//------------------------------------------------------------------------------ +#if PJPG_RIGHT_SHIFT_IS_ALWAYS_UNSIGNED +static int16 replicateSignBit16(int8 n) { + switch (n) { + case 0: + return 0x0000; + case 1: + return 0x8000; + case 2: + return 0xC000; + case 3: + return 0xE000; + case 4: + return 0xF000; + case 5: + return 0xF800; + case 6: + return 0xFC00; + case 7: + return 0xFE00; + case 8: + return 0xFF00; + case 9: + return 0xFF80; + case 10: + return 0xFFC0; + case 11: + return 0xFFE0; + case 12: + return 0xFFF0; + case 13: + return 0xFFF8; + case 14: + return 0xFFFC; + case 15: + return 0xFFFE; + default: + return 0xFFFF; + } +} +static PJPG_INLINE int16 arithmeticRightShiftN16(int16 x, int8 n) { + int16 r = (uint16)x >> (uint8)n; + if (x < 0) r |= replicateSignBit16(n); + return r; +} +static PJPG_INLINE long arithmeticRightShift8L(long x) { + long r = (unsigned long)x >> 8U; + if (x < 0) r |= ~(~(unsigned long)0U >> 8U); + return r; +} +#define PJPG_ARITH_SHIFT_RIGHT_N_16(x, n) arithmeticRightShiftN16(x, n) +#define PJPG_ARITH_SHIFT_RIGHT_8_L(x) arithmeticRightShift8L(x) +#else +#define PJPG_ARITH_SHIFT_RIGHT_N_16(x, n) ((x) >> (n)) +#define PJPG_ARITH_SHIFT_RIGHT_8_L(x) ((x) >> 8) +#endif +//------------------------------------------------------------------------------ +// Change as needed - the PJPG_MAX_WIDTH/PJPG_MAX_HEIGHT checks are only present +// to quickly detect bogus files. +#define PJPG_MAX_WIDTH 16384 +#define PJPG_MAX_HEIGHT 16384 +#define PJPG_MAXCOMPSINSCAN 3 +//------------------------------------------------------------------------------ +typedef enum { + M_SOF0 = 0xC0, + M_SOF1 = 0xC1, + M_SOF2 = 0xC2, + M_SOF3 = 0xC3, + + M_SOF5 = 0xC5, + M_SOF6 = 0xC6, + M_SOF7 = 0xC7, + + M_JPG = 0xC8, + M_SOF9 = 0xC9, + M_SOF10 = 0xCA, + M_SOF11 = 0xCB, + + M_SOF13 = 0xCD, + M_SOF14 = 0xCE, + M_SOF15 = 0xCF, + + M_DHT = 0xC4, + + M_DAC = 0xCC, + + M_RST0 = 0xD0, + M_RST1 = 0xD1, + M_RST2 = 0xD2, + M_RST3 = 0xD3, + M_RST4 = 0xD4, + M_RST5 = 0xD5, + M_RST6 = 0xD6, + M_RST7 = 0xD7, + + M_SOI = 0xD8, + M_EOI = 0xD9, + M_SOS = 0xDA, + M_DQT = 0xDB, + M_DNL = 0xDC, + M_DRI = 0xDD, + M_DHP = 0xDE, + M_EXP = 0xDF, + + M_APP0 = 0xE0, + M_APP15 = 0xEF, + + M_JPG0 = 0xF0, + M_JPG13 = 0xFD, + M_COM = 0xFE, + + M_TEM = 0x01, + + M_ERROR = 0x100, + + RST0 = 0xD0 +} JPEG_MARKER; +//------------------------------------------------------------------------------ +static const int8 ZAG[] = { + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, + 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, + 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +}; +//------------------------------------------------------------------------------ +// 128 bytes +static int16 gCoeffBuf[8 * 8]; + +// 8*8*4 bytes * 3 = 768 +static uint8 gMCUBufR[256]; +static uint8 gMCUBufG[256]; +static uint8 gMCUBufB[256]; + +// 256 bytes +static int16 gQuant0[8 * 8]; +static int16 gQuant1[8 * 8]; + +// 6 bytes +static int16 gLastDC[3]; + +typedef struct HuffTableT { + uint16 mMinCode[16]; + uint16 mMaxCode[16]; + uint8 mValPtr[16]; +} HuffTable; + +// DC - 192 +static HuffTable gHuffTab0; + +static uint8 gHuffVal0[16]; + +static HuffTable gHuffTab1; +static uint8 gHuffVal1[16]; + +// AC - 672 +static HuffTable gHuffTab2; +static uint8 gHuffVal2[256]; + +static HuffTable gHuffTab3; +static uint8 gHuffVal3[256]; + +static uint8 gValidHuffTables; +static uint8 gValidQuantTables; + +static uint8 gTemFlag; +#define PJPG_MAX_IN_BUF_SIZE 256 +static uint8 gInBuf[PJPG_MAX_IN_BUF_SIZE]; +static uint8 gInBufOfs; +static uint8 gInBufLeft; + +static uint16 gBitBuf; +static uint8 gBitsLeft; +//------------------------------------------------------------------------------ +static uint16 gImageXSize; +static uint16 gImageYSize; +static uint8 gCompsInFrame; +static uint8 gCompIdent[3]; +static uint8 gCompHSamp[3]; +static uint8 gCompVSamp[3]; +static uint8 gCompQuant[3]; + +static uint16 gRestartInterval; +static uint16 gNextRestartNum; +static uint16 gRestartsLeft; + +static uint8 gCompsInScan; +static uint8 gCompList[3]; +static uint8 gCompDCTab[3]; // 0,1 +static uint8 gCompACTab[3]; // 0,1 + +static pjpeg_scan_type_t gScanType; + +static uint8 gMaxBlocksPerMCU; +static uint8 gMaxMCUXSize; +static uint8 gMaxMCUYSize; +static uint16 gMaxMCUSPerRow; +static uint16 gMaxMCUSPerCol; + +static uint16 gNumMCUSRemainingX, gNumMCUSRemainingY; + +static uint8 gMCUOrg[6]; + +static pjpeg_need_bytes_callback_t g_pNeedBytesCallback; +static void* g_pCallback_data; +static uint8 gCallbackStatus; +static uint8 gReduce; +//------------------------------------------------------------------------------ +static void fillInBuf(void) { + unsigned char status; + + // Reserve a few bytes at the beginning of the buffer for putting back ("stuffing") chars. + gInBufOfs = 4; + gInBufLeft = 0; + + status = (*g_pNeedBytesCallback)(gInBuf + gInBufOfs, PJPG_MAX_IN_BUF_SIZE - gInBufOfs, &gInBufLeft, g_pCallback_data); + if (status) { + // The user provided need bytes callback has indicated an error, so record the error and continue trying to decode. + // The highest level pjpeg entrypoints will catch the error and return the non-zero status. + gCallbackStatus = status; + } +} +//------------------------------------------------------------------------------ +static PJPG_INLINE uint8 getChar(void) { + if (!gInBufLeft) { + fillInBuf(); + if (!gInBufLeft) { + gTemFlag = ~gTemFlag; + return gTemFlag ? 0xFF : 0xD9; + } + } + + gInBufLeft--; + return gInBuf[gInBufOfs++]; +} +//------------------------------------------------------------------------------ +static PJPG_INLINE void stuffChar(uint8 i) { + gInBufOfs--; + gInBuf[gInBufOfs] = i; + gInBufLeft++; +} +//------------------------------------------------------------------------------ +static PJPG_INLINE uint8 getOctet(uint8 FFCheck) { + uint8 c = getChar(); + + if ((FFCheck) && (c == 0xFF)) { + uint8 n = getChar(); + + if (n) { + stuffChar(n); + stuffChar(0xFF); + } + } + + return c; +} +//------------------------------------------------------------------------------ +static uint16 getBits(uint8 numBits, uint8 FFCheck) { + uint8 origBits = numBits; + uint16 ret = gBitBuf; + + if (numBits > 8) { + numBits -= 8; + + gBitBuf <<= gBitsLeft; + + gBitBuf |= getOctet(FFCheck); + + gBitBuf <<= (8 - gBitsLeft); + + ret = (ret & 0xFF00) | (gBitBuf >> 8); + } + + if (gBitsLeft < numBits) { + gBitBuf <<= gBitsLeft; + + gBitBuf |= getOctet(FFCheck); + + gBitBuf <<= (numBits - gBitsLeft); + + gBitsLeft = 8 - (numBits - gBitsLeft); + } else { + gBitsLeft = (uint8)(gBitsLeft - numBits); + gBitBuf <<= numBits; + } + + return ret >> (16 - origBits); +} +//------------------------------------------------------------------------------ +static PJPG_INLINE uint16 getBits1(uint8 numBits) { return getBits(numBits, 0); } +//------------------------------------------------------------------------------ +static PJPG_INLINE uint16 getBits2(uint8 numBits) { return getBits(numBits, 1); } +//------------------------------------------------------------------------------ +static PJPG_INLINE uint8 getBit(void) { + uint8 ret = 0; + if (gBitBuf & 0x8000) ret = 1; + + if (!gBitsLeft) { + gBitBuf |= getOctet(1); + + gBitsLeft += 8; + } + + gBitsLeft--; + gBitBuf <<= 1; + + return ret; +} +//------------------------------------------------------------------------------ +static uint16 getExtendTest(uint8 i) { + switch (i) { + case 0: + return 0; + case 1: + return 0x0001; + case 2: + return 0x0002; + case 3: + return 0x0004; + case 4: + return 0x0008; + case 5: + return 0x0010; + case 6: + return 0x0020; + case 7: + return 0x0040; + case 8: + return 0x0080; + case 9: + return 0x0100; + case 10: + return 0x0200; + case 11: + return 0x0400; + case 12: + return 0x0800; + case 13: + return 0x1000; + case 14: + return 0x2000; + case 15: + return 0x4000; + default: + return 0; + } +} +//------------------------------------------------------------------------------ +static int16 getExtendOffset(uint8 i) { + switch (i) { + case 0: + return 0; + case 1: + return ((-1) << 1) + 1; + case 2: + return ((-1) << 2) + 1; + case 3: + return ((-1) << 3) + 1; + case 4: + return ((-1) << 4) + 1; + case 5: + return ((-1) << 5) + 1; + case 6: + return ((-1) << 6) + 1; + case 7: + return ((-1) << 7) + 1; + case 8: + return ((-1) << 8) + 1; + case 9: + return ((-1) << 9) + 1; + case 10: + return ((-1) << 10) + 1; + case 11: + return ((-1) << 11) + 1; + case 12: + return ((-1) << 12) + 1; + case 13: + return ((-1) << 13) + 1; + case 14: + return ((-1) << 14) + 1; + case 15: + return ((-1) << 15) + 1; + default: + return 0; + } +}; +//------------------------------------------------------------------------------ +static PJPG_INLINE int16 huffExtend(uint16 x, uint8 s) { + return ((x < getExtendTest(s)) ? ((int16)x + getExtendOffset(s)) : (int16)x); +} +//------------------------------------------------------------------------------ +static PJPG_INLINE uint8 huffDecode(const HuffTable* pHuffTable, const uint8* pHuffVal) { + uint8 i = 0; + uint8 j; + uint16 code = getBit(); + + // This func only reads a bit at a time, which on modern CPU's is not terribly efficient. + // But on microcontrollers without strong integer shifting support this seems like a + // more reasonable approach. + for (;;) { + uint16 maxCode; + + if (i == 16) return 0; + + maxCode = pHuffTable->mMaxCode[i]; + if ((code <= maxCode) && (maxCode != 0xFFFF)) break; + + i++; + code <<= 1; + code |= getBit(); + } + + j = pHuffTable->mValPtr[i]; + j = (uint8)(j + (code - pHuffTable->mMinCode[i])); + + return pHuffVal[j]; +} +//------------------------------------------------------------------------------ +static void huffCreate(const uint8* pBits, HuffTable* pHuffTable) { + uint8 i = 0; + uint8 j = 0; + + uint16 code = 0; + + for (;;) { + uint8 num = pBits[i]; + + if (!num) { + pHuffTable->mMinCode[i] = 0x0000; + pHuffTable->mMaxCode[i] = 0xFFFF; + pHuffTable->mValPtr[i] = 0; + } else { + pHuffTable->mMinCode[i] = code; + pHuffTable->mMaxCode[i] = code + num - 1; + pHuffTable->mValPtr[i] = j; + + j = (uint8)(j + num); + + code = (uint16)(code + num); + } + + code <<= 1; + + i++; + if (i > 15) break; + } +} +//------------------------------------------------------------------------------ +static HuffTable* getHuffTable(uint8 index) { + // 0-1 = DC + // 2-3 = AC + switch (index) { + case 0: + return &gHuffTab0; + case 1: + return &gHuffTab1; + case 2: + return &gHuffTab2; + case 3: + return &gHuffTab3; + default: + return 0; + } +} +//------------------------------------------------------------------------------ +static uint8* getHuffVal(uint8 index) { + // 0-1 = DC + // 2-3 = AC + switch (index) { + case 0: + return gHuffVal0; + case 1: + return gHuffVal1; + case 2: + return gHuffVal2; + case 3: + return gHuffVal3; + default: + return 0; + } +} +//------------------------------------------------------------------------------ +static uint16 getMaxHuffCodes(uint8 index) { return (index < 2) ? 12 : 255; } +//------------------------------------------------------------------------------ +static uint8 readDHTMarker(void) { + uint8 bits[16]; + uint16 left = getBits1(16); + + if (left < 2) return PJPG_BAD_DHT_MARKER; + + left -= 2; + + while (left) { + uint8 i, tableIndex, index; + uint8* pHuffVal; + HuffTable* pHuffTable; + uint16 count, totalRead; + + index = (uint8)getBits1(8); + + if (((index & 0xF) > 1) || ((index & 0xF0) > 0x10)) return PJPG_BAD_DHT_INDEX; + + tableIndex = ((index >> 3) & 2) + (index & 1); + + pHuffTable = getHuffTable(tableIndex); + pHuffVal = getHuffVal(tableIndex); + + gValidHuffTables |= (1 << tableIndex); + + count = 0; + for (i = 0; i <= 15; i++) { + uint8 n = (uint8)getBits1(8); + bits[i] = n; + count = (uint16)(count + n); + } + + if (count > getMaxHuffCodes(tableIndex)) return PJPG_BAD_DHT_COUNTS; + + for (i = 0; i < count; i++) pHuffVal[i] = (uint8)getBits1(8); + + totalRead = 1 + 16 + count; + + if (left < totalRead) return PJPG_BAD_DHT_MARKER; + + left = (uint16)(left - totalRead); + + huffCreate(bits, pHuffTable); + } + + return 0; +} +//------------------------------------------------------------------------------ +static void createWinogradQuant(int16* pQuant); + +static uint8 readDQTMarker(void) { + uint16 left = getBits1(16); + + if (left < 2) return PJPG_BAD_DQT_MARKER; + + left -= 2; + + while (left) { + uint8 i; + uint8 n = (uint8)getBits1(8); + uint8 prec = n >> 4; + uint16 totalRead; + + n &= 0x0F; + + if (n > 1) return PJPG_BAD_DQT_TABLE; + + gValidQuantTables |= (n ? 2 : 1); + + // read quantization entries, in zag order + for (i = 0; i < 64; i++) { + uint16 temp = getBits1(8); + + if (prec) temp = (temp << 8) + getBits1(8); + + if (n) + gQuant1[i] = (int16)temp; + else + gQuant0[i] = (int16)temp; + } + + createWinogradQuant(n ? gQuant1 : gQuant0); + + totalRead = 64 + 1; + + if (prec) totalRead += 64; + + if (left < totalRead) return PJPG_BAD_DQT_LENGTH; + + left = (uint16)(left - totalRead); + } + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 readSOFMarker(void) { + uint8 i; + uint16 left = getBits1(16); + + if (getBits1(8) != 8) return PJPG_BAD_PRECISION; + + gImageYSize = getBits1(16); + + if ((!gImageYSize) || (gImageYSize > PJPG_MAX_HEIGHT)) return PJPG_BAD_HEIGHT; + + gImageXSize = getBits1(16); + + if ((!gImageXSize) || (gImageXSize > PJPG_MAX_WIDTH)) return PJPG_BAD_WIDTH; + + gCompsInFrame = (uint8)getBits1(8); + + if (gCompsInFrame > 3) return PJPG_TOO_MANY_COMPONENTS; + + if (left != (gCompsInFrame + gCompsInFrame + gCompsInFrame + 8)) return PJPG_BAD_SOF_LENGTH; + + for (i = 0; i < gCompsInFrame; i++) { + gCompIdent[i] = (uint8)getBits1(8); + gCompHSamp[i] = (uint8)getBits1(4); + gCompVSamp[i] = (uint8)getBits1(4); + gCompQuant[i] = (uint8)getBits1(8); + + if (gCompQuant[i] > 1) return PJPG_UNSUPPORTED_QUANT_TABLE; + } + + return 0; +} +//------------------------------------------------------------------------------ +// Used to skip unrecognized markers. +static uint8 skipVariableMarker(void) { + uint16 left = getBits1(16); + + if (left < 2) return PJPG_BAD_VARIABLE_MARKER; + + left -= 2; + + while (left) { + getBits1(8); + left--; + } + + return 0; +} +//------------------------------------------------------------------------------ +// Read a define restart interval (DRI) marker. +static uint8 readDRIMarker(void) { + if (getBits1(16) != 4) return PJPG_BAD_DRI_LENGTH; + + gRestartInterval = getBits1(16); + + return 0; +} +//------------------------------------------------------------------------------ +// Read a start of scan (SOS) marker. +static uint8 readSOSMarker(void) { + uint8 i; + uint16 left = getBits1(16); + uint8 spectral_start, spectral_end, successive_high, successive_low; + + gCompsInScan = (uint8)getBits1(8); + + left -= 3; + + if ((left != (gCompsInScan + gCompsInScan + 3)) || (gCompsInScan < 1) || (gCompsInScan > PJPG_MAXCOMPSINSCAN)) + return PJPG_BAD_SOS_LENGTH; + + for (i = 0; i < gCompsInScan; i++) { + uint8 cc = (uint8)getBits1(8); + uint8 c = (uint8)getBits1(8); + uint8 ci; + + left -= 2; + + for (ci = 0; ci < gCompsInFrame; ci++) + if (cc == gCompIdent[ci]) break; + + if (ci >= gCompsInFrame) return PJPG_BAD_SOS_COMP_ID; + + gCompList[i] = ci; + gCompDCTab[ci] = (c >> 4) & 15; + gCompACTab[ci] = (c & 15); + } + + spectral_start = (uint8)getBits1(8); + spectral_end = (uint8)getBits1(8); + successive_high = (uint8)getBits1(4); + successive_low = (uint8)getBits1(4); + + left -= 3; + + while (left) { + getBits1(8); + left--; + } + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 nextMarker(void) { + uint8 c; + uint8 bytes = 0; + + do { + do { + bytes++; + + c = (uint8)getBits1(8); + + } while (c != 0xFF); + + do { + c = (uint8)getBits1(8); + + } while (c == 0xFF); + + } while (c == 0); + + // If bytes > 0 here, there where extra bytes before the marker (not good). + + return c; +} +//------------------------------------------------------------------------------ +// Process markers. Returns when an SOFx, SOI, EOI, or SOS marker is +// encountered. +static uint8 processMarkers(uint8* pMarker) { + for (;;) { + uint8 c = nextMarker(); + + switch (c) { + case M_SOF0: + case M_SOF1: + case M_SOF2: + case M_SOF3: + case M_SOF5: + case M_SOF6: + case M_SOF7: + // case M_JPG: + case M_SOF9: + case M_SOF10: + case M_SOF11: + case M_SOF13: + case M_SOF14: + case M_SOF15: + case M_SOI: + case M_EOI: + case M_SOS: { + *pMarker = c; + return 0; + } + case M_DHT: { + readDHTMarker(); + break; + } + // Sorry, no arithmetic support at this time. Dumb patents! + case M_DAC: { + return PJPG_NO_ARITHMITIC_SUPPORT; + } + case M_DQT: { + readDQTMarker(); + break; + } + case M_DRI: { + readDRIMarker(); + break; + } + // case M_APP0: /* no need to read the JFIF marker */ + + case M_JPG: + case M_RST0: /* no parameters */ + case M_RST1: + case M_RST2: + case M_RST3: + case M_RST4: + case M_RST5: + case M_RST6: + case M_RST7: + case M_TEM: { + return PJPG_UNEXPECTED_MARKER; + } + default: /* must be DNL, DHP, EXP, APPn, JPGn, COM, or RESn or APP0 */ + { + skipVariableMarker(); + break; + } + } + } + // return 0; +} +//------------------------------------------------------------------------------ +// Finds the start of image (SOI) marker. +static uint8 locateSOIMarker(void) { + uint16 bytesleft; + + uint8 lastchar = (uint8)getBits1(8); + + uint8 thischar = (uint8)getBits1(8); + + /* ok if it's a normal JPEG file without a special header */ + + if ((lastchar == 0xFF) && (thischar == M_SOI)) return 0; + + bytesleft = 4096; // 512; + + for (;;) { + if (--bytesleft == 0) return PJPG_NOT_JPEG; + + lastchar = thischar; + + thischar = (uint8)getBits1(8); + + if (lastchar == 0xFF) { + if (thischar == M_SOI) + break; + else if (thischar == M_EOI) // getBits1 will keep returning M_EOI if we read past the end + return PJPG_NOT_JPEG; + } + } + + /* Check the next character after marker: if it's not 0xFF, it can't + be the start of the next marker, so the file is bad */ + + thischar = (uint8)((gBitBuf >> 8) & 0xFF); + + if (thischar != 0xFF) return PJPG_NOT_JPEG; + + return 0; +} +//------------------------------------------------------------------------------ +// Find a start of frame (SOF) marker. +static uint8 locateSOFMarker(void) { + uint8 c; + + uint8 status = locateSOIMarker(); + if (status) return status; + + status = processMarkers(&c); + if (status) return status; + + switch (c) { + case M_SOF2: { + // Progressive JPEG - not supported by picojpeg (would require too + // much memory, or too many IDCT's for embedded systems). + return PJPG_UNSUPPORTED_MODE; + } + case M_SOF0: /* baseline DCT */ + { + status = readSOFMarker(); + if (status) return status; + + break; + } + case M_SOF9: { + return PJPG_NO_ARITHMITIC_SUPPORT; + } + case M_SOF1: /* extended sequential DCT */ + default: { + return PJPG_UNSUPPORTED_MARKER; + } + } + + return 0; +} +//------------------------------------------------------------------------------ +// Find a start of scan (SOS) marker. +static uint8 locateSOSMarker(uint8* pFoundEOI) { + uint8 c; + uint8 status; + + *pFoundEOI = 0; + + status = processMarkers(&c); + if (status) return status; + + if (c == M_EOI) { + *pFoundEOI = 1; + return 0; + } else if (c != M_SOS) + return PJPG_UNEXPECTED_MARKER; + + return readSOSMarker(); +} +//------------------------------------------------------------------------------ +static uint8 init(void) { + gImageXSize = 0; + gImageYSize = 0; + gCompsInFrame = 0; + gRestartInterval = 0; + gCompsInScan = 0; + gValidHuffTables = 0; + gValidQuantTables = 0; + gTemFlag = 0; + gInBufOfs = 0; + gInBufLeft = 0; + gBitBuf = 0; + gBitsLeft = 8; + + getBits1(8); + getBits1(8); + + return 0; +} +//------------------------------------------------------------------------------ +// This method throws back into the stream any bytes that where read +// into the bit buffer during initial marker scanning. +static void fixInBuffer(void) { + /* In case any 0xFF's where pulled into the buffer during marker scanning */ + + if (gBitsLeft > 0) stuffChar((uint8)gBitBuf); + + stuffChar((uint8)(gBitBuf >> 8)); + + gBitsLeft = 8; + getBits2(8); + getBits2(8); +} +//------------------------------------------------------------------------------ +// Restart interval processing. +static uint8 processRestart(void) { + // Let's scan a little bit to find the marker, but not _too_ far. + // 1536 is a "fudge factor" that determines how much to scan. + uint16 i; + uint8 c = 0; + + for (i = 1536; i > 0; i--) + if (getChar() == 0xFF) break; + + if (i == 0) return PJPG_BAD_RESTART_MARKER; + + for (; i > 0; i--) + if ((c = getChar()) != 0xFF) break; + + if (i == 0) return PJPG_BAD_RESTART_MARKER; + + // Is it the expected marker? If not, something bad happened. + if (c != (gNextRestartNum + M_RST0)) return PJPG_BAD_RESTART_MARKER; + + // Reset each component's DC prediction values. + gLastDC[0] = 0; + gLastDC[1] = 0; + gLastDC[2] = 0; + + gRestartsLeft = gRestartInterval; + + gNextRestartNum = (gNextRestartNum + 1) & 7; + + // Get the bit buffer going again + + gBitsLeft = 8; + getBits2(8); + getBits2(8); + + return 0; +} +//------------------------------------------------------------------------------ +// FIXME: findEOI() is not actually called at the end of the image +// (it's optional, and probably not needed on embedded devices) +static uint8 findEOI(void) { + uint8 c; + uint8 status; + + // Prime the bit buffer + gBitsLeft = 8; + getBits1(8); + getBits1(8); + + // The next marker _should_ be EOI + status = processMarkers(&c); + if (status) + return status; + else if (gCallbackStatus) + return gCallbackStatus; + + // gTotalBytesRead -= in_buf_left; + if (c != M_EOI) return PJPG_UNEXPECTED_MARKER; + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 checkHuffTables(void) { + uint8 i; + + for (i = 0; i < gCompsInScan; i++) { + uint8 compDCTab = gCompDCTab[gCompList[i]]; + uint8 compACTab = gCompACTab[gCompList[i]] + 2; + + if (((gValidHuffTables & (1 << compDCTab)) == 0) || ((gValidHuffTables & (1 << compACTab)) == 0)) + return PJPG_UNDEFINED_HUFF_TABLE; + } + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 checkQuantTables(void) { + uint8 i; + + for (i = 0; i < gCompsInScan; i++) { + uint8 compQuantMask = gCompQuant[gCompList[i]] ? 2 : 1; + + if ((gValidQuantTables & compQuantMask) == 0) return PJPG_UNDEFINED_QUANT_TABLE; + } + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 initScan(void) { + uint8 foundEOI; + uint8 status = locateSOSMarker(&foundEOI); + if (status) return status; + if (foundEOI) return PJPG_UNEXPECTED_MARKER; + + status = checkHuffTables(); + if (status) return status; + + status = checkQuantTables(); + if (status) return status; + + gLastDC[0] = 0; + gLastDC[1] = 0; + gLastDC[2] = 0; + + if (gRestartInterval) { + gRestartsLeft = gRestartInterval; + gNextRestartNum = 0; + } + + fixInBuffer(); + + return 0; +} +//------------------------------------------------------------------------------ +static uint8 initFrame(void) { + if (gCompsInFrame == 1) { + if ((gCompHSamp[0] != 1) || (gCompVSamp[0] != 1)) return PJPG_UNSUPPORTED_SAMP_FACTORS; + + gScanType = PJPG_GRAYSCALE; + + gMaxBlocksPerMCU = 1; + gMCUOrg[0] = 0; + + gMaxMCUXSize = 8; + gMaxMCUYSize = 8; + } else if (gCompsInFrame == 3) { + if (((gCompHSamp[1] != 1) || (gCompVSamp[1] != 1)) || ((gCompHSamp[2] != 1) || (gCompVSamp[2] != 1))) + return PJPG_UNSUPPORTED_SAMP_FACTORS; + + if ((gCompHSamp[0] == 1) && (gCompVSamp[0] == 1)) { + gScanType = PJPG_YH1V1; + + gMaxBlocksPerMCU = 3; + gMCUOrg[0] = 0; + gMCUOrg[1] = 1; + gMCUOrg[2] = 2; + + gMaxMCUXSize = 8; + gMaxMCUYSize = 8; + } else if ((gCompHSamp[0] == 1) && (gCompVSamp[0] == 2)) { + gScanType = PJPG_YH1V2; + + gMaxBlocksPerMCU = 4; + gMCUOrg[0] = 0; + gMCUOrg[1] = 0; + gMCUOrg[2] = 1; + gMCUOrg[3] = 2; + + gMaxMCUXSize = 8; + gMaxMCUYSize = 16; + } else if ((gCompHSamp[0] == 2) && (gCompVSamp[0] == 1)) { + gScanType = PJPG_YH2V1; + + gMaxBlocksPerMCU = 4; + gMCUOrg[0] = 0; + gMCUOrg[1] = 0; + gMCUOrg[2] = 1; + gMCUOrg[3] = 2; + + gMaxMCUXSize = 16; + gMaxMCUYSize = 8; + } else if ((gCompHSamp[0] == 2) && (gCompVSamp[0] == 2)) { + gScanType = PJPG_YH2V2; + + gMaxBlocksPerMCU = 6; + gMCUOrg[0] = 0; + gMCUOrg[1] = 0; + gMCUOrg[2] = 0; + gMCUOrg[3] = 0; + gMCUOrg[4] = 1; + gMCUOrg[5] = 2; + + gMaxMCUXSize = 16; + gMaxMCUYSize = 16; + } else + return PJPG_UNSUPPORTED_SAMP_FACTORS; + } else + return PJPG_UNSUPPORTED_COLORSPACE; + + gMaxMCUSPerRow = (gImageXSize + (gMaxMCUXSize - 1)) >> ((gMaxMCUXSize == 8) ? 3 : 4); + gMaxMCUSPerCol = (gImageYSize + (gMaxMCUYSize - 1)) >> ((gMaxMCUYSize == 8) ? 3 : 4); + + // This can overflow on large JPEG's. + // gNumMCUSRemaining = gMaxMCUSPerRow * gMaxMCUSPerCol; + gNumMCUSRemainingX = gMaxMCUSPerRow; + gNumMCUSRemainingY = gMaxMCUSPerCol; + + return 0; +} +//---------------------------------------------------------------------------- +// Winograd IDCT: 5 multiplies per row/col, up to 80 muls for the 2D IDCT + +#define PJPG_DCT_SCALE_BITS 7 + +#define PJPG_DCT_SCALE (1U << PJPG_DCT_SCALE_BITS) + +#define PJPG_DESCALE(x) PJPG_ARITH_SHIFT_RIGHT_N_16(((x) + (1 << (PJPG_DCT_SCALE_BITS - 1))), PJPG_DCT_SCALE_BITS) + +#define PJPG_WFIX(x) ((x) * PJPG_DCT_SCALE + 0.5f) + +#define PJPG_WINOGRAD_QUANT_SCALE_BITS 10 + +const uint8 gWinogradQuant[] = { + 128, 178, 178, 167, 246, 167, 151, 232, 232, 151, 128, 209, 219, 209, 128, 101, 178, 197, 197, 178, 101, 69, + 139, 167, 177, 167, 139, 69, 35, 96, 131, 151, 151, 131, 96, 35, 49, 91, 118, 128, 118, 91, 49, 46, + 81, 101, 101, 81, 46, 42, 69, 79, 69, 42, 35, 54, 54, 35, 28, 37, 28, 19, 19, 10, +}; + +// Multiply quantization matrix by the Winograd IDCT scale factors +static void createWinogradQuant(int16* pQuant) { + uint8 i; + + for (i = 0; i < 64; i++) { + long x = pQuant[i]; + x *= gWinogradQuant[i]; + pQuant[i] = (int16)((x + (1 << (PJPG_WINOGRAD_QUANT_SCALE_BITS - PJPG_DCT_SCALE_BITS - 1))) >> + (PJPG_WINOGRAD_QUANT_SCALE_BITS - PJPG_DCT_SCALE_BITS)); + } +} + +// These multiply helper functions are the 4 types of signed multiplies needed by the Winograd IDCT. +// A smart C compiler will optimize them to use 16x8 = 24 bit muls, if not you may need to tweak +// these functions or drop to CPU specific inline assembly. + +// 1/cos(4*pi/16) +// 362, 256+106 +static PJPG_INLINE int16 imul_b1_b3(int16 w) { + long x = (w * 362L); + x += 128L; + return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); +} + +// 1/cos(6*pi/16) +// 669, 256+256+157 +static PJPG_INLINE int16 imul_b2(int16 w) { + long x = (w * 669L); + x += 128L; + return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); +} + +// 1/cos(2*pi/16) +// 277, 256+21 +static PJPG_INLINE int16 imul_b4(int16 w) { + long x = (w * 277L); + x += 128L; + return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); +} + +// 1/(cos(2*pi/16) + cos(6*pi/16)) +// 196, 196 +static PJPG_INLINE int16 imul_b5(int16 w) { + long x = (w * 196L); + x += 128L; + return (int16)(PJPG_ARITH_SHIFT_RIGHT_8_L(x)); +} + +static PJPG_INLINE uint8 clamp(int16 s) { + if ((uint16)s > 255U) { + if (s < 0) + return 0; + else if (s > 255) + return 255; + } + + return (uint8)s; +} + +static void idctRows(void) { + uint8 i; + int16* pSrc = gCoeffBuf; + + for (i = 0; i < 8; i++) { + if ((pSrc[1] | pSrc[2] | pSrc[3] | pSrc[4] | pSrc[5] | pSrc[6] | pSrc[7]) == 0) { + // Short circuit the 1D IDCT if only the DC component is non-zero + int16 src0 = *pSrc; + + *(pSrc + 1) = src0; + *(pSrc + 2) = src0; + *(pSrc + 3) = src0; + *(pSrc + 4) = src0; + *(pSrc + 5) = src0; + *(pSrc + 6) = src0; + *(pSrc + 7) = src0; + } else { + int16 src4 = *(pSrc + 5); + int16 src7 = *(pSrc + 3); + int16 x4 = src4 - src7; + int16 x7 = src4 + src7; + + int16 src5 = *(pSrc + 1); + int16 src6 = *(pSrc + 7); + int16 x5 = src5 + src6; + int16 x6 = src5 - src6; + + int16 tmp1 = imul_b5(x4 - x6); + int16 stg26 = imul_b4(x6) - tmp1; + + int16 x24 = tmp1 - imul_b2(x4); + + int16 x15 = x5 - x7; + int16 x17 = x5 + x7; + + int16 tmp2 = stg26 - x17; + int16 tmp3 = imul_b1_b3(x15) - tmp2; + int16 x44 = tmp3 + x24; + + int16 src0 = *(pSrc + 0); + int16 src1 = *(pSrc + 4); + int16 x30 = src0 + src1; + int16 x31 = src0 - src1; + + int16 src2 = *(pSrc + 2); + int16 src3 = *(pSrc + 6); + int16 x12 = src2 - src3; + int16 x13 = src2 + src3; + + int16 x32 = imul_b1_b3(x12) - x13; + + int16 x40 = x30 + x13; + int16 x43 = x30 - x13; + int16 x41 = x31 + x32; + int16 x42 = x31 - x32; + + *(pSrc + 0) = x40 + x17; + *(pSrc + 1) = x41 + tmp2; + *(pSrc + 2) = x42 + tmp3; + *(pSrc + 3) = x43 - x44; + *(pSrc + 4) = x43 + x44; + *(pSrc + 5) = x42 - tmp3; + *(pSrc + 6) = x41 - tmp2; + *(pSrc + 7) = x40 - x17; + } + + pSrc += 8; + } +} + +static void idctCols(void) { + uint8 i; + + int16* pSrc = gCoeffBuf; + + for (i = 0; i < 8; i++) { + if ((pSrc[1 * 8] | pSrc[2 * 8] | pSrc[3 * 8] | pSrc[4 * 8] | pSrc[5 * 8] | pSrc[6 * 8] | pSrc[7 * 8]) == 0) { + // Short circuit the 1D IDCT if only the DC component is non-zero + uint8 c = clamp(PJPG_DESCALE(*pSrc) + 128); + *(pSrc + 0 * 8) = c; + *(pSrc + 1 * 8) = c; + *(pSrc + 2 * 8) = c; + *(pSrc + 3 * 8) = c; + *(pSrc + 4 * 8) = c; + *(pSrc + 5 * 8) = c; + *(pSrc + 6 * 8) = c; + *(pSrc + 7 * 8) = c; + } else { + int16 src4 = *(pSrc + 5 * 8); + int16 src7 = *(pSrc + 3 * 8); + int16 x4 = src4 - src7; + int16 x7 = src4 + src7; + + int16 src5 = *(pSrc + 1 * 8); + int16 src6 = *(pSrc + 7 * 8); + int16 x5 = src5 + src6; + int16 x6 = src5 - src6; + + int16 tmp1 = imul_b5(x4 - x6); + int16 stg26 = imul_b4(x6) - tmp1; + + int16 x24 = tmp1 - imul_b2(x4); + + int16 x15 = x5 - x7; + int16 x17 = x5 + x7; + + int16 tmp2 = stg26 - x17; + int16 tmp3 = imul_b1_b3(x15) - tmp2; + int16 x44 = tmp3 + x24; + + int16 src0 = *(pSrc + 0 * 8); + int16 src1 = *(pSrc + 4 * 8); + int16 x30 = src0 + src1; + int16 x31 = src0 - src1; + + int16 src2 = *(pSrc + 2 * 8); + int16 src3 = *(pSrc + 6 * 8); + int16 x12 = src2 - src3; + int16 x13 = src2 + src3; + + int16 x32 = imul_b1_b3(x12) - x13; + + int16 x40 = x30 + x13; + int16 x43 = x30 - x13; + int16 x41 = x31 + x32; + int16 x42 = x31 - x32; + + // descale, convert to unsigned and clamp to 8-bit + *(pSrc + 0 * 8) = clamp(PJPG_DESCALE(x40 + x17) + 128); + *(pSrc + 1 * 8) = clamp(PJPG_DESCALE(x41 + tmp2) + 128); + *(pSrc + 2 * 8) = clamp(PJPG_DESCALE(x42 + tmp3) + 128); + *(pSrc + 3 * 8) = clamp(PJPG_DESCALE(x43 - x44) + 128); + *(pSrc + 4 * 8) = clamp(PJPG_DESCALE(x43 + x44) + 128); + *(pSrc + 5 * 8) = clamp(PJPG_DESCALE(x42 - tmp3) + 128); + *(pSrc + 6 * 8) = clamp(PJPG_DESCALE(x41 - tmp2) + 128); + *(pSrc + 7 * 8) = clamp(PJPG_DESCALE(x40 - x17) + 128); + } + + pSrc++; + } +} + +/*----------------------------------------------------------------------------*/ +static PJPG_INLINE uint8 addAndClamp(uint8 a, int16 b) { + b = a + b; + + if ((uint16)b > 255U) { + if (b < 0) + return 0; + else if (b > 255) + return 255; + } + + return (uint8)b; +} +/*----------------------------------------------------------------------------*/ +static PJPG_INLINE uint8 subAndClamp(uint8 a, int16 b) { + b = a - b; + + if ((uint16)b > 255U) { + if (b < 0) + return 0; + else if (b > 255) + return 255; + } + + return (uint8)b; +} +/*----------------------------------------------------------------------------*/ +// 103/256 +// R = Y + 1.402 (Cr-128) + +// 88/256, 183/256 +// G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) + +// 198/256 +// B = Y + 1.772 (Cb-128) +/*----------------------------------------------------------------------------*/ +// Cb upsample and accumulate, 4x4 to 8x8 +static void upsampleCb(uint8 srcOfs, uint8 dstOfs) { + // Cb - affects G and B + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstG = gMCUBufG + dstOfs; + uint8* pDstB = gMCUBufB + dstOfs; + for (y = 0; y < 4; y++) { + for (x = 0; x < 4; x++) { + uint8 cb = (uint8)*pSrc++; + int16 cbG, cbB; + + cbG = ((cb * 88U) >> 8U) - 44U; + pDstG[0] = subAndClamp(pDstG[0], cbG); + pDstG[1] = subAndClamp(pDstG[1], cbG); + pDstG[8] = subAndClamp(pDstG[8], cbG); + pDstG[9] = subAndClamp(pDstG[9], cbG); + + cbB = (cb + ((cb * 198U) >> 8U)) - 227U; + pDstB[0] = addAndClamp(pDstB[0], cbB); + pDstB[1] = addAndClamp(pDstB[1], cbB); + pDstB[8] = addAndClamp(pDstB[8], cbB); + pDstB[9] = addAndClamp(pDstB[9], cbB); + + pDstG += 2; + pDstB += 2; + } + + pSrc = pSrc - 4 + 8; + pDstG = pDstG - 8 + 16; + pDstB = pDstB - 8 + 16; + } +} +/*----------------------------------------------------------------------------*/ +// Cb upsample and accumulate, 4x8 to 8x8 +static void upsampleCbH(uint8 srcOfs, uint8 dstOfs) { + // Cb - affects G and B + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstG = gMCUBufG + dstOfs; + uint8* pDstB = gMCUBufB + dstOfs; + for (y = 0; y < 8; y++) { + for (x = 0; x < 4; x++) { + uint8 cb = (uint8)*pSrc++; + int16 cbG, cbB; + + cbG = ((cb * 88U) >> 8U) - 44U; + pDstG[0] = subAndClamp(pDstG[0], cbG); + pDstG[1] = subAndClamp(pDstG[1], cbG); + + cbB = (cb + ((cb * 198U) >> 8U)) - 227U; + pDstB[0] = addAndClamp(pDstB[0], cbB); + pDstB[1] = addAndClamp(pDstB[1], cbB); + + pDstG += 2; + pDstB += 2; + } + + pSrc = pSrc - 4 + 8; + } +} +/*----------------------------------------------------------------------------*/ +// Cb upsample and accumulate, 8x4 to 8x8 +static void upsampleCbV(uint8 srcOfs, uint8 dstOfs) { + // Cb - affects G and B + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstG = gMCUBufG + dstOfs; + uint8* pDstB = gMCUBufB + dstOfs; + for (y = 0; y < 4; y++) { + for (x = 0; x < 8; x++) { + uint8 cb = (uint8)*pSrc++; + int16 cbG, cbB; + + cbG = ((cb * 88U) >> 8U) - 44U; + pDstG[0] = subAndClamp(pDstG[0], cbG); + pDstG[8] = subAndClamp(pDstG[8], cbG); + + cbB = (cb + ((cb * 198U) >> 8U)) - 227U; + pDstB[0] = addAndClamp(pDstB[0], cbB); + pDstB[8] = addAndClamp(pDstB[8], cbB); + + ++pDstG; + ++pDstB; + } + + pDstG = pDstG - 8 + 16; + pDstB = pDstB - 8 + 16; + } +} +/*----------------------------------------------------------------------------*/ +// 103/256 +// R = Y + 1.402 (Cr-128) + +// 88/256, 183/256 +// G = Y - 0.34414 (Cb-128) - 0.71414 (Cr-128) + +// 198/256 +// B = Y + 1.772 (Cb-128) +/*----------------------------------------------------------------------------*/ +// Cr upsample and accumulate, 4x4 to 8x8 +static void upsampleCr(uint8 srcOfs, uint8 dstOfs) { + // Cr - affects R and G + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstR = gMCUBufR + dstOfs; + uint8* pDstG = gMCUBufG + dstOfs; + for (y = 0; y < 4; y++) { + for (x = 0; x < 4; x++) { + uint8 cr = (uint8)*pSrc++; + int16 crR, crG; + + crR = (cr + ((cr * 103U) >> 8U)) - 179; + pDstR[0] = addAndClamp(pDstR[0], crR); + pDstR[1] = addAndClamp(pDstR[1], crR); + pDstR[8] = addAndClamp(pDstR[8], crR); + pDstR[9] = addAndClamp(pDstR[9], crR); + + crG = ((cr * 183U) >> 8U) - 91; + pDstG[0] = subAndClamp(pDstG[0], crG); + pDstG[1] = subAndClamp(pDstG[1], crG); + pDstG[8] = subAndClamp(pDstG[8], crG); + pDstG[9] = subAndClamp(pDstG[9], crG); + + pDstR += 2; + pDstG += 2; + } + + pSrc = pSrc - 4 + 8; + pDstR = pDstR - 8 + 16; + pDstG = pDstG - 8 + 16; + } +} +/*----------------------------------------------------------------------------*/ +// Cr upsample and accumulate, 4x8 to 8x8 +static void upsampleCrH(uint8 srcOfs, uint8 dstOfs) { + // Cr - affects R and G + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstR = gMCUBufR + dstOfs; + uint8* pDstG = gMCUBufG + dstOfs; + for (y = 0; y < 8; y++) { + for (x = 0; x < 4; x++) { + uint8 cr = (uint8)*pSrc++; + int16 crR, crG; + + crR = (cr + ((cr * 103U) >> 8U)) - 179; + pDstR[0] = addAndClamp(pDstR[0], crR); + pDstR[1] = addAndClamp(pDstR[1], crR); + + crG = ((cr * 183U) >> 8U) - 91; + pDstG[0] = subAndClamp(pDstG[0], crG); + pDstG[1] = subAndClamp(pDstG[1], crG); + + pDstR += 2; + pDstG += 2; + } + + pSrc = pSrc - 4 + 8; + } +} +/*----------------------------------------------------------------------------*/ +// Cr upsample and accumulate, 8x4 to 8x8 +static void upsampleCrV(uint8 srcOfs, uint8 dstOfs) { + // Cr - affects R and G + uint8 x, y; + int16* pSrc = gCoeffBuf + srcOfs; + uint8* pDstR = gMCUBufR + dstOfs; + uint8* pDstG = gMCUBufG + dstOfs; + for (y = 0; y < 4; y++) { + for (x = 0; x < 8; x++) { + uint8 cr = (uint8)*pSrc++; + int16 crR, crG; + + crR = (cr + ((cr * 103U) >> 8U)) - 179; + pDstR[0] = addAndClamp(pDstR[0], crR); + pDstR[8] = addAndClamp(pDstR[8], crR); + + crG = ((cr * 183U) >> 8U) - 91; + pDstG[0] = subAndClamp(pDstG[0], crG); + pDstG[8] = subAndClamp(pDstG[8], crG); + + ++pDstR; + ++pDstG; + } + + pDstR = pDstR - 8 + 16; + pDstG = pDstG - 8 + 16; + } +} +/*----------------------------------------------------------------------------*/ +// Convert Y to RGB +static void copyY(uint8 dstOfs) { + uint8 i; + uint8* pRDst = gMCUBufR + dstOfs; + uint8* pGDst = gMCUBufG + dstOfs; + uint8* pBDst = gMCUBufB + dstOfs; + int16* pSrc = gCoeffBuf; + + for (i = 64; i > 0; i--) { + uint8 c = (uint8)*pSrc++; + + *pRDst++ = c; + *pGDst++ = c; + *pBDst++ = c; + } +} +/*----------------------------------------------------------------------------*/ +// Cb convert to RGB and accumulate +static void convertCb(uint8 dstOfs) { + uint8 i; + uint8* pDstG = gMCUBufG + dstOfs; + uint8* pDstB = gMCUBufB + dstOfs; + int16* pSrc = gCoeffBuf; + + for (i = 64; i > 0; i--) { + uint8 cb = (uint8)*pSrc++; + int16 cbG, cbB; + + cbG = ((cb * 88U) >> 8U) - 44U; + *pDstG++ = subAndClamp(pDstG[0], cbG); + + cbB = (cb + ((cb * 198U) >> 8U)) - 227U; + *pDstB++ = addAndClamp(pDstB[0], cbB); + } +} +/*----------------------------------------------------------------------------*/ +// Cr convert to RGB and accumulate +static void convertCr(uint8 dstOfs) { + uint8 i; + uint8* pDstR = gMCUBufR + dstOfs; + uint8* pDstG = gMCUBufG + dstOfs; + int16* pSrc = gCoeffBuf; + + for (i = 64; i > 0; i--) { + uint8 cr = (uint8)*pSrc++; + int16 crR, crG; + + crR = (cr + ((cr * 103U) >> 8U)) - 179; + *pDstR++ = addAndClamp(pDstR[0], crR); + + crG = ((cr * 183U) >> 8U) - 91; + *pDstG++ = subAndClamp(pDstG[0], crG); + } +} +/*----------------------------------------------------------------------------*/ +static void transformBlock(uint8 mcuBlock) { + idctRows(); + idctCols(); + + switch (gScanType) { + case PJPG_GRAYSCALE: { + // MCU size: 1, 1 block per MCU + copyY(0); + break; + } + case PJPG_YH1V1: { + // MCU size: 8x8, 3 blocks per MCU + switch (mcuBlock) { + case 0: { + copyY(0); + break; + } + case 1: { + convertCb(0); + break; + } + case 2: { + convertCr(0); + break; + } + } + + break; + } + case PJPG_YH1V2: { + // MCU size: 8x16, 4 blocks per MCU + switch (mcuBlock) { + case 0: { + copyY(0); + break; + } + case 1: { + copyY(128); + break; + } + case 2: { + upsampleCbV(0, 0); + upsampleCbV(4 * 8, 128); + break; + } + case 3: { + upsampleCrV(0, 0); + upsampleCrV(4 * 8, 128); + break; + } + } + + break; + } + case PJPG_YH2V1: { + // MCU size: 16x8, 4 blocks per MCU + switch (mcuBlock) { + case 0: { + copyY(0); + break; + } + case 1: { + copyY(64); + break; + } + case 2: { + upsampleCbH(0, 0); + upsampleCbH(4, 64); + break; + } + case 3: { + upsampleCrH(0, 0); + upsampleCrH(4, 64); + break; + } + } + + break; + } + case PJPG_YH2V2: { + // MCU size: 16x16, 6 blocks per MCU + switch (mcuBlock) { + case 0: { + copyY(0); + break; + } + case 1: { + copyY(64); + break; + } + case 2: { + copyY(128); + break; + } + case 3: { + copyY(192); + break; + } + case 4: { + upsampleCb(0, 0); + upsampleCb(4, 64); + upsampleCb(4 * 8, 128); + upsampleCb(4 + 4 * 8, 192); + break; + } + case 5: { + upsampleCr(0, 0); + upsampleCr(4, 64); + upsampleCr(4 * 8, 128); + upsampleCr(4 + 4 * 8, 192); + break; + } + } + + break; + } + } +} +//------------------------------------------------------------------------------ +static void transformBlockReduce(uint8 mcuBlock) { + uint8 c = clamp(PJPG_DESCALE(gCoeffBuf[0]) + 128); + int16 cbG, cbB, crR, crG; + + switch (gScanType) { + case PJPG_GRAYSCALE: { + // MCU size: 1, 1 block per MCU + gMCUBufR[0] = c; + break; + } + case PJPG_YH1V1: { + // MCU size: 8x8, 3 blocks per MCU + switch (mcuBlock) { + case 0: { + gMCUBufR[0] = c; + gMCUBufG[0] = c; + gMCUBufB[0] = c; + break; + } + case 1: { + cbG = ((c * 88U) >> 8U) - 44U; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); + + cbB = (c + ((c * 198U) >> 8U)) - 227U; + gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); + break; + } + case 2: { + crR = (c + ((c * 103U) >> 8U)) - 179; + gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); + + crG = ((c * 183U) >> 8U) - 91; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); + break; + } + } + + break; + } + case PJPG_YH1V2: { + // MCU size: 8x16, 4 blocks per MCU + switch (mcuBlock) { + case 0: { + gMCUBufR[0] = c; + gMCUBufG[0] = c; + gMCUBufB[0] = c; + break; + } + case 1: { + gMCUBufR[128] = c; + gMCUBufG[128] = c; + gMCUBufB[128] = c; + break; + } + case 2: { + cbG = ((c * 88U) >> 8U) - 44U; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); + gMCUBufG[128] = subAndClamp(gMCUBufG[128], cbG); + + cbB = (c + ((c * 198U) >> 8U)) - 227U; + gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); + gMCUBufB[128] = addAndClamp(gMCUBufB[128], cbB); + + break; + } + case 3: { + crR = (c + ((c * 103U) >> 8U)) - 179; + gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); + gMCUBufR[128] = addAndClamp(gMCUBufR[128], crR); + + crG = ((c * 183U) >> 8U) - 91; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); + gMCUBufG[128] = subAndClamp(gMCUBufG[128], crG); + + break; + } + } + break; + } + case PJPG_YH2V1: { + // MCU size: 16x8, 4 blocks per MCU + switch (mcuBlock) { + case 0: { + gMCUBufR[0] = c; + gMCUBufG[0] = c; + gMCUBufB[0] = c; + break; + } + case 1: { + gMCUBufR[64] = c; + gMCUBufG[64] = c; + gMCUBufB[64] = c; + break; + } + case 2: { + cbG = ((c * 88U) >> 8U) - 44U; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); + gMCUBufG[64] = subAndClamp(gMCUBufG[64], cbG); + + cbB = (c + ((c * 198U) >> 8U)) - 227U; + gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); + gMCUBufB[64] = addAndClamp(gMCUBufB[64], cbB); + + break; + } + case 3: { + crR = (c + ((c * 103U) >> 8U)) - 179; + gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); + gMCUBufR[64] = addAndClamp(gMCUBufR[64], crR); + + crG = ((c * 183U) >> 8U) - 91; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); + gMCUBufG[64] = subAndClamp(gMCUBufG[64], crG); + + break; + } + } + break; + } + case PJPG_YH2V2: { + // MCU size: 16x16, 6 blocks per MCU + switch (mcuBlock) { + case 0: { + gMCUBufR[0] = c; + gMCUBufG[0] = c; + gMCUBufB[0] = c; + break; + } + case 1: { + gMCUBufR[64] = c; + gMCUBufG[64] = c; + gMCUBufB[64] = c; + break; + } + case 2: { + gMCUBufR[128] = c; + gMCUBufG[128] = c; + gMCUBufB[128] = c; + break; + } + case 3: { + gMCUBufR[192] = c; + gMCUBufG[192] = c; + gMCUBufB[192] = c; + break; + } + case 4: { + cbG = ((c * 88U) >> 8U) - 44U; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], cbG); + gMCUBufG[64] = subAndClamp(gMCUBufG[64], cbG); + gMCUBufG[128] = subAndClamp(gMCUBufG[128], cbG); + gMCUBufG[192] = subAndClamp(gMCUBufG[192], cbG); + + cbB = (c + ((c * 198U) >> 8U)) - 227U; + gMCUBufB[0] = addAndClamp(gMCUBufB[0], cbB); + gMCUBufB[64] = addAndClamp(gMCUBufB[64], cbB); + gMCUBufB[128] = addAndClamp(gMCUBufB[128], cbB); + gMCUBufB[192] = addAndClamp(gMCUBufB[192], cbB); + + break; + } + case 5: { + crR = (c + ((c * 103U) >> 8U)) - 179; + gMCUBufR[0] = addAndClamp(gMCUBufR[0], crR); + gMCUBufR[64] = addAndClamp(gMCUBufR[64], crR); + gMCUBufR[128] = addAndClamp(gMCUBufR[128], crR); + gMCUBufR[192] = addAndClamp(gMCUBufR[192], crR); + + crG = ((c * 183U) >> 8U) - 91; + gMCUBufG[0] = subAndClamp(gMCUBufG[0], crG); + gMCUBufG[64] = subAndClamp(gMCUBufG[64], crG); + gMCUBufG[128] = subAndClamp(gMCUBufG[128], crG); + gMCUBufG[192] = subAndClamp(gMCUBufG[192], crG); + + break; + } + } + break; + } + } +} +//------------------------------------------------------------------------------ +static uint8 decodeNextMCU(void) { + uint8 status; + uint8 mcuBlock; + + if (gRestartInterval) { + if (gRestartsLeft == 0) { + status = processRestart(); + if (status) return status; + } + gRestartsLeft--; + } + + for (mcuBlock = 0; mcuBlock < gMaxBlocksPerMCU; mcuBlock++) { + uint8 componentID = gMCUOrg[mcuBlock]; + uint8 compQuant = gCompQuant[componentID]; + uint8 compDCTab = gCompDCTab[componentID]; + uint8 numExtraBits, compACTab, k; + const int16* pQ = compQuant ? gQuant1 : gQuant0; + uint16 r, dc; + + uint8 s = huffDecode(compDCTab ? &gHuffTab1 : &gHuffTab0, compDCTab ? gHuffVal1 : gHuffVal0); + + r = 0; + numExtraBits = s & 0xF; + if (numExtraBits) r = getBits2(numExtraBits); + dc = huffExtend(r, s); + + dc = dc + gLastDC[componentID]; + gLastDC[componentID] = dc; + + gCoeffBuf[0] = dc * pQ[0]; + + compACTab = gCompACTab[componentID]; + + if (gReduce) { + // Decode, but throw out the AC coefficients in reduce mode. + for (k = 1; k < 64; k++) { + s = huffDecode(compACTab ? &gHuffTab3 : &gHuffTab2, compACTab ? gHuffVal3 : gHuffVal2); + + numExtraBits = s & 0xF; + if (numExtraBits) getBits2(numExtraBits); + + r = s >> 4; + s &= 15; + + if (s) { + if (r) { + if ((k + r) > 63) return PJPG_DECODE_ERROR; + + k = (uint8)(k + r); + } + } else { + if (r == 15) { + if ((k + 16) > 64) return PJPG_DECODE_ERROR; + + k += (16 - 1); // - 1 because the loop counter is k + } else + break; + } + } + + transformBlockReduce(mcuBlock); + } else { + // Decode and dequantize AC coefficients + for (k = 1; k < 64; k++) { + uint16 extraBits; + + s = huffDecode(compACTab ? &gHuffTab3 : &gHuffTab2, compACTab ? gHuffVal3 : gHuffVal2); + + extraBits = 0; + numExtraBits = s & 0xF; + if (numExtraBits) extraBits = getBits2(numExtraBits); + + r = s >> 4; + s &= 15; + + if (s) { + int16 ac; + + if (r) { + if ((k + r) > 63) return PJPG_DECODE_ERROR; + + while (r) { + gCoeffBuf[ZAG[k++]] = 0; + r--; + } + } + + ac = huffExtend(extraBits, s); + + gCoeffBuf[ZAG[k]] = ac * pQ[k]; + } else { + if (r == 15) { + if ((k + 16) > 64) return PJPG_DECODE_ERROR; + + for (r = 16; r > 0; r--) gCoeffBuf[ZAG[k++]] = 0; + + k--; // - 1 because the loop counter is k + } else + break; + } + } + + while (k < 64) gCoeffBuf[ZAG[k++]] = 0; + + transformBlock(mcuBlock); + } + } + + return 0; +} +//------------------------------------------------------------------------------ +unsigned char pjpeg_decode_mcu(void) { + uint8 status; + + if (gCallbackStatus) return gCallbackStatus; + + if ((!gNumMCUSRemainingX) && (!gNumMCUSRemainingY)) return PJPG_NO_MORE_BLOCKS; + + status = decodeNextMCU(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + gNumMCUSRemainingX--; + if (!gNumMCUSRemainingX) { + gNumMCUSRemainingY--; + if (gNumMCUSRemainingY > 0) gNumMCUSRemainingX = gMaxMCUSPerRow; + } + + return 0; +} +//------------------------------------------------------------------------------ +unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback, + void* pCallback_data, unsigned char reduce) { + uint8 status; + + pInfo->m_width = 0; + pInfo->m_height = 0; + pInfo->m_comps = 0; + pInfo->m_MCUSPerRow = 0; + pInfo->m_MCUSPerCol = 0; + pInfo->m_scanType = PJPG_GRAYSCALE; + pInfo->m_MCUWidth = 0; + pInfo->m_MCUHeight = 0; + pInfo->m_pMCUBufR = (unsigned char*)0; + pInfo->m_pMCUBufG = (unsigned char*)0; + pInfo->m_pMCUBufB = (unsigned char*)0; + + g_pNeedBytesCallback = pNeed_bytes_callback; + g_pCallback_data = pCallback_data; + gCallbackStatus = 0; + gReduce = reduce; + + status = init(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + status = locateSOFMarker(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + status = initFrame(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + status = initScan(); + if ((status) || (gCallbackStatus)) return gCallbackStatus ? gCallbackStatus : status; + + pInfo->m_width = gImageXSize; + pInfo->m_height = gImageYSize; + pInfo->m_comps = gCompsInFrame; + pInfo->m_scanType = gScanType; + pInfo->m_MCUSPerRow = gMaxMCUSPerRow; + pInfo->m_MCUSPerCol = gMaxMCUSPerCol; + pInfo->m_MCUWidth = gMaxMCUXSize; + pInfo->m_MCUHeight = gMaxMCUYSize; + pInfo->m_pMCUBufR = gMCUBufR; + pInfo->m_pMCUBufG = gMCUBufG; + pInfo->m_pMCUBufB = gMCUBufB; + + return 0; +} diff --git a/lib/picojpeg/picojpeg.h b/lib/picojpeg/picojpeg.h new file mode 100644 index 00000000..11345fb7 --- /dev/null +++ b/lib/picojpeg/picojpeg.h @@ -0,0 +1,124 @@ +//------------------------------------------------------------------------------ +// picojpeg - Public domain, Rich Geldreich +//------------------------------------------------------------------------------ +#ifndef PICOJPEG_H +#define PICOJPEG_H + +#ifdef __cplusplus +extern "C" { +#endif + +// Error codes +enum { + PJPG_NO_MORE_BLOCKS = 1, + PJPG_BAD_DHT_COUNTS, + PJPG_BAD_DHT_INDEX, + PJPG_BAD_DHT_MARKER, + PJPG_BAD_DQT_MARKER, + PJPG_BAD_DQT_TABLE, + PJPG_BAD_PRECISION, + PJPG_BAD_HEIGHT, + PJPG_BAD_WIDTH, + PJPG_TOO_MANY_COMPONENTS, + PJPG_BAD_SOF_LENGTH, + PJPG_BAD_VARIABLE_MARKER, + PJPG_BAD_DRI_LENGTH, + PJPG_BAD_SOS_LENGTH, + PJPG_BAD_SOS_COMP_ID, + PJPG_W_EXTRA_BYTES_BEFORE_MARKER, + PJPG_NO_ARITHMITIC_SUPPORT, + PJPG_UNEXPECTED_MARKER, + PJPG_NOT_JPEG, + PJPG_UNSUPPORTED_MARKER, + PJPG_BAD_DQT_LENGTH, + PJPG_TOO_MANY_BLOCKS, + PJPG_UNDEFINED_QUANT_TABLE, + PJPG_UNDEFINED_HUFF_TABLE, + PJPG_NOT_SINGLE_SCAN, + PJPG_UNSUPPORTED_COLORSPACE, + PJPG_UNSUPPORTED_SAMP_FACTORS, + PJPG_DECODE_ERROR, + PJPG_BAD_RESTART_MARKER, + PJPG_ASSERTION_ERROR, + PJPG_BAD_SOS_SPECTRAL, + PJPG_BAD_SOS_SUCCESSIVE, + PJPG_STREAM_READ_ERROR, + PJPG_NOTENOUGHMEM, + PJPG_UNSUPPORTED_COMP_IDENT, + PJPG_UNSUPPORTED_QUANT_TABLE, + PJPG_UNSUPPORTED_MODE, // picojpeg doesn't support progressive JPEG's +}; + +// Scan types +typedef enum { PJPG_GRAYSCALE, PJPG_YH1V1, PJPG_YH2V1, PJPG_YH1V2, PJPG_YH2V2 } pjpeg_scan_type_t; + +typedef struct { + // Image resolution + int m_width; + int m_height; + + // Number of components (1 or 3) + int m_comps; + + // Total number of minimum coded units (MCU's) per row/col. + int m_MCUSPerRow; + int m_MCUSPerCol; + + // Scan type + pjpeg_scan_type_t m_scanType; + + // MCU width/height in pixels (each is either 8 or 16 depending on the scan type) + int m_MCUWidth; + int m_MCUHeight; + + // m_pMCUBufR, m_pMCUBufG, and m_pMCUBufB are pointers to internal MCU Y or RGB pixel component buffers. + // Each time pjpegDecodeMCU() is called successfully these buffers will be filled with 8x8 pixel blocks of Y or RGB + // pixels. Each MCU consists of (m_MCUWidth/8)*(m_MCUHeight/8) Y/RGB blocks: 1 for greyscale/no subsampling, 2 for + // H1V2/H2V1, or 4 blocks for H2V2 sampling factors. Each block is a contiguous array of 64 (8x8) bytes of a single + // component: either Y for grayscale images, or R, G or B components for color images. + // + // The 8x8 pixel blocks are organized in these byte arrays like this: + // + // PJPG_GRAYSCALE: Each MCU is decoded to a single block of 8x8 grayscale pixels. + // Only the values in m_pMCUBufR are valid. Each 8 bytes is a row of pixels (raster order: left to right, top to + // bottom) from the 8x8 block. + // + // PJPG_H1V1: Each MCU contains is decoded to a single block of 8x8 RGB pixels. + // + // PJPG_YH2V1: Each MCU is decoded to 2 blocks, or 16x8 pixels. + // The 2 RGB blocks are at byte offsets: 0, 64 + // + // PJPG_YH1V2: Each MCU is decoded to 2 blocks, or 8x16 pixels. + // The 2 RGB blocks are at byte offsets: 0, + // 128 + // + // PJPG_YH2V2: Each MCU is decoded to 4 blocks, or 16x16 pixels. + // The 2x2 block array is organized at byte offsets: 0, 64, + // 128, 192 + // + // It is up to the caller to copy or blit these pixels from these buffers into the destination bitmap. + unsigned char* m_pMCUBufR; + unsigned char* m_pMCUBufG; + unsigned char* m_pMCUBufB; +} pjpeg_image_info_t; + +typedef unsigned char (*pjpeg_need_bytes_callback_t)(unsigned char* pBuf, unsigned char buf_size, + unsigned char* pBytes_actually_read, void* pCallback_data); + +// Initializes the decompressor. Returns 0 on success, or one of the above error codes on failure. +// pNeed_bytes_callback will be called to fill the decompressor's internal input buffer. +// If reduce is 1, only the first pixel of each block will be decoded. This mode is much faster because it skips the AC +// dequantization, IDCT and chroma upsampling of every image pixel. Not thread safe. +unsigned char pjpeg_decode_init(pjpeg_image_info_t* pInfo, pjpeg_need_bytes_callback_t pNeed_bytes_callback, + void* pCallback_data, unsigned char reduce); + +// Decompresses the file's next MCU. Returns 0 on success, PJPG_NO_MORE_BLOCKS if no more blocks are available, or an +// error code. Must be called a total of m_MCUSPerRow*m_MCUSPerCol times to completely decompress the image. Not thread +// safe. +unsigned char pjpeg_decode_mcu(void); + +#ifdef __cplusplus +} +#endif + +#endif // PICOJPEG_H From 6aa5d41a429f9ba40c0c561d3cc81ffd4eb5a7aa Mon Sep 17 00:00:00 2001 From: Sam Davis Date: Sun, 21 Dec 2025 18:32:50 +1100 Subject: [PATCH 12/46] Add info about sleep screen customisation to user guide (#88) ## Summary - Updates user guide with information about using custom sleep screens ## Additional Context N/A --- USER_GUIDE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f5a9bd04..333fa727 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -64,6 +64,18 @@ The Settings screen allows you to configure the device's behavior. There are a f paragraphs will not have vertical space between them, but will have first word indentation. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. +### 3.6 Sleep Screen + +You can customize the sleep screen by placing custom images in specific locations on the SD card: + +- **Single Image:** Place a file named `sleep.bmp` in the root directory. +- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be randomly selected each time the device sleeps. + +> [!TIP] +> For best results: +> - Use uncompressed BMP files with 24-bit color depth +> - Use a resolution of 480x800 pixels to match the device's screen resolution. + --- ## 4. Reading Mode From 958508eb6bf53c0b2ec6443a08937dc188948e81 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 18:41:52 +1100 Subject: [PATCH 13/46] Prevent boot loop if last open epub crashes on load (#87) ## Summary * Unset openEpubPath on boot and set once epub fully loaded ## Additional Context * If an epub was crashing when loading, it was possible to get the device stuck into a loop. There was no way to get back to the home screen as we'd always load you back into old epub * Break this loop by clearing the stored value when we boot, still jumping to the last open epub, but only resetting that value once the epub has been fully loaded --- src/activities/reader/EpubReaderActivity.cpp | 5 +++++ src/activities/reader/ReaderActivity.cpp | 3 --- src/main.cpp | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 8827e998..9635952e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -6,6 +6,7 @@ #include "Battery.h" #include "CrossPointSettings.h" +#include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" #include "config.h" @@ -44,6 +45,10 @@ void EpubReaderActivity::onEnter() { f.close(); } + // Save current epub as last opened epub + APP_STATE.openEpubPath = epub->getPath(); + APP_STATE.saveToFile(); + // Trigger first update updateRequired = true; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 099d7e2c..93389fe7 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -2,7 +2,6 @@ #include -#include "CrossPointState.h" #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" @@ -29,8 +28,6 @@ void ReaderActivity::onSelectEpubFile(const std::string& path) { auto epub = loadEpub(path); if (epub) { - APP_STATE.openEpubPath = path; - APP_STATE.saveToFile(); onGoToEpubReader(std::move(epub)); } else { exitActivity(); diff --git a/src/main.cpp b/src/main.cpp index 89d2af48..5dfc25ba 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -194,7 +194,11 @@ void setup() { if (APP_STATE.openEpubPath.empty()) { onGoHome(); } else { - onGoToReader(APP_STATE.openEpubPath); + // Clear app state to avoid getting into a boot loop if the epub doesn't load + const auto path = APP_STATE.openEpubPath; + APP_STATE.openEpubPath = ""; + APP_STATE.saveToFile(); + onGoToReader(path); } // Ensure we're not still holding the power button before leaving setup From 955c78de649aaa39f2b8e357934dfe55c7ff9055 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 18:42:06 +1100 Subject: [PATCH 14/46] Book cover sleep screen (#89) ## Summary * Fix issue with 2-bit bmp rendering * Add support generate book cover BMP from JPG and use as sleep screen ## Additional Context * It does not support other image formats beyond JPG at this point * Something is cooked with my JpegToBmpConverter logic, it generates weird interlaced looking images for some JPGs | Book 1 | Book 2| | --- | --- | | ![IMG_5653](https://github.com/user-attachments/assets/49bbaeaa-b171-44c7-a68d-14cbe42aef03) | ![IMG_5652](https://github.com/user-attachments/assets/7db88d70-e09a-49b0-a9a0-4cc729b4ca0c) | --- USER_GUIDE.md | 13 ++- lib/Epub/Epub.cpp | 41 +++++++- lib/Epub/Epub.h | 3 +- lib/GfxRenderer/Bitmap.cpp | 39 +++++--- src/CrossPointSettings.cpp | 4 +- src/CrossPointSettings.h | 5 +- src/activities/boot_sleep/SleepActivity.cpp | 98 ++++++++++++++------ src/activities/boot_sleep/SleepActivity.h | 6 +- src/activities/settings/SettingsActivity.cpp | 35 ++++--- src/activities/settings/SettingsActivity.h | 9 +- 10 files changed, 182 insertions(+), 71 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 333fa727..a19a1e8d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -59,7 +59,11 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: -- **White Sleep Screen**: Whether to use the white screen or black (inverted) default sleep screen +- **Sleep Screen**: Which sleep screen to display when the device sleeps, options are: + - "Dark" (default) - The default dark sleep screen + - "Light" - The same default sleep screen, on a white background + - "Custom" - Custom images from the SD card, see [3.6 Sleep Screen](#36-sleep-screen) below for more information + - "Cover" - The book cover image (Note: this is experimental and may not work as expected) - **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled, paragraphs will not have vertical space between them, but will have first word indentation. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. @@ -69,7 +73,12 @@ The Settings screen allows you to configure the device's behavior. There are a f You can customize the sleep screen by placing custom images in specific locations on the SD card: - **Single Image:** Place a file named `sleep.bmp` in the root directory. -- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be randomly selected each time the device sleeps. +- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images + inside. If images are found in this directory, they will take priority over the `sleep.png` file, and one will be + randomly selected each time the device sleeps. + +> [!NOTE] +> You'll need to set the **Sleep Screen** setting to **Custom** in order to use these images. > [!TIP] > For best results: diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index cc3bc909..d959cb79 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -1,6 +1,7 @@ #include "Epub.h" #include +#include #include #include @@ -218,7 +219,45 @@ const std::string& Epub::getPath() const { return filepath; } const std::string& Epub::getTitle() const { return title; } -const std::string& Epub::getCoverImageItem() const { return coverImageItem; } +std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } + +bool Epub::generateCoverBmp() const { + // Already generated, return true + if (SD.exists(getCoverBmpPath().c_str())) { + return true; + } + + if (coverImageItem.empty()) { + Serial.printf("[%lu] [EBP] No known cover image\n", millis()); + return false; + } + + if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || + coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { + Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); + File coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_WRITE, true); + readItemContentsToStream(coverImageItem, coverJpg, 1024); + coverJpg.close(); + + coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_READ); + File coverBmp = SD.open(getCoverBmpPath().c_str(), FILE_WRITE, true); + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + coverJpg.close(); + coverBmp.close(); + SD.remove((getCachePath() + "/.cover.jpg").c_str()); + + if (!success) { + Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); + SD.remove(getCoverBmpPath().c_str()); + } + Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); + return success; + } else { + Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis()); + } + + return false; +} std::string normalisePath(const std::string& path) { std::vector components; diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 31153035..381379c5 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -48,7 +48,8 @@ class Epub { const std::string& getCachePath() const; const std::string& getPath() const; const std::string& getTitle() const; - const std::string& getCoverImageItem() const; + std::string getCoverBmpPath() const; + bool generateCoverBmp() const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 0e0b0d61..c9ad6f85 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -128,7 +128,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { int bitShift = 6; // Helper lambda to pack 2bpp color into the output stream - auto packPixel = [&](uint8_t lum) { + auto packPixel = [&](const uint8_t lum) { uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3 currentOutByte |= (color << bitShift); if (bitShift == 0) { @@ -140,38 +140,49 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { } }; + uint8_t lum; + switch (bpp) { - case 8: { + case 32: { + const uint8_t* p = rowBuffer; for (int x = 0; x < width; x++) { - packPixel(paletteLum[rowBuffer[x]]); + lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + packPixel(lum); + p += 4; } break; } case 24: { const uint8_t* p = rowBuffer; for (int x = 0; x < width; x++) { - uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; packPixel(lum); p += 3; } break; } + case 8: { + for (int x = 0; x < width; x++) { + packPixel(paletteLum[rowBuffer[x]]); + } + break; + } + case 2: { + for (int x = 0; x < width; x++) { + lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; + packPixel(lum); + } + break; + } case 1: { for (int x = 0; x < width; x++) { - uint8_t lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; + lum = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 0xFF : 0x00; packPixel(lum); } break; } - case 32: { - const uint8_t* p = rowBuffer; - for (int x = 0; x < width; x++) { - uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; - packPixel(lum); - p += 4; - } - break; - } + default: + return BmpReaderError::UnsupportedBpp; } // Flush remaining bits if width is not a multiple of 4 diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 89590721..fe5e2a07 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -23,7 +23,7 @@ bool CrossPointSettings::saveToFile() const { std::ofstream outputFile(SETTINGS_FILE); serialization::writePod(outputFile, SETTINGS_FILE_VERSION); serialization::writePod(outputFile, SETTINGS_COUNT); - serialization::writePod(outputFile, whiteSleepScreen); + serialization::writePod(outputFile, sleepScreen); serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, shortPwrBtn); outputFile.close(); @@ -54,7 +54,7 @@ bool CrossPointSettings::loadFromFile() { // load settings that exist uint8_t settingsRead = 0; do { - serialization::readPod(inputFile, whiteSleepScreen); + serialization::readPod(inputFile, sleepScreen); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, extraParagraphSpacing); if (++settingsRead >= fileSettingsCount) break; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d6ad7667..14c33322 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -15,8 +15,11 @@ class CrossPointSettings { CrossPointSettings(const CrossPointSettings&) = delete; CrossPointSettings& operator=(const CrossPointSettings&) = delete; + // Should match with SettingsActivity text + enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 }; + // Sleep screen settings - uint8_t whiteSleepScreen = 0; + uint8_t sleepScreen = DARK; // Text rendering settings uint8_t extraParagraphSpacing = 1; // Duration of the power button press diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 900a0dbb..4200c4e9 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,16 +1,45 @@ #include "SleepActivity.h" +#include #include #include #include #include "CrossPointSettings.h" +#include "CrossPointState.h" #include "config.h" #include "images/CrossLarge.h" void SleepActivity::onEnter() { renderPopup("Entering Sleep..."); + + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) { + return renderCustomSleepScreen(); + } + + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::COVER) { + return renderCoverSleepScreen(); + } + + renderDefaultSleepScreen(); +} + +void SleepActivity::renderPopup(const char* message) const { + const int textWidth = renderer.getTextWidth(READER_FONT_ID, message); + constexpr int margin = 20; + const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2; + constexpr int y = 117; + const int w = textWidth + margin * 2; + const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; + // renderer.clearScreen(); + renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); + renderer.drawText(READER_FONT_ID, x + margin, y + margin, message); + renderer.drawRect(x + 5, y + 5, w - 10, h - 10); + renderer.displayBuffer(); +} + +void SleepActivity::renderCustomSleepScreen() const { // Check if we have a /sleep directory auto dir = SD.open("/sleep"); if (dir && dir.isDirectory()) { @@ -28,31 +57,31 @@ void SleepActivity::onEnter() { } if (filename.substr(filename.length() - 4) != ".bmp") { - Serial.printf("[%lu] [Slp] Skipping non-.bmp file name: %s\n", millis(), file.name()); + Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), file.name()); file.close(); continue; } Bitmap bitmap(file); if (bitmap.parseHeaders() != BmpReaderError::Ok) { - Serial.printf("[%lu] [Slp] Skipping invalid BMP file: %s\n", millis(), file.name()); + Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), file.name()); file.close(); continue; } files.emplace_back(filename); file.close(); } - int numFiles = files.size(); + const auto numFiles = files.size(); if (numFiles > 0) { // Generate a random number between 1 and numFiles - int randomFileIndex = random(numFiles); - auto filename = "/sleep/" + files[randomFileIndex]; + const auto randomFileIndex = random(numFiles); + const auto filename = "/sleep/" + files[randomFileIndex]; auto file = SD.open(filename.c_str()); if (file) { - Serial.printf("[%lu] [Slp] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); + Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - renderCustomSleepScreen(bitmap); + renderBitmapSleepScreen(bitmap); dir.close(); return; } @@ -67,8 +96,8 @@ void SleepActivity::onEnter() { if (file) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - Serial.printf("[%lu] [Slp] Loading: /sleep.bmp\n", millis()); - renderCustomSleepScreen(bitmap); + Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); + renderBitmapSleepScreen(bitmap); return; } } @@ -76,41 +105,27 @@ void SleepActivity::onEnter() { renderDefaultSleepScreen(); } -void SleepActivity::renderPopup(const char* message) const { - const int textWidth = renderer.getTextWidth(READER_FONT_ID, message); - constexpr int margin = 20; - const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2; - constexpr int y = 117; - const int w = textWidth + margin * 2; - const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; - // renderer.clearScreen(); - renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false); - renderer.drawText(READER_FONT_ID, x + margin, y + margin, message); - renderer.drawRect(x + 5, y + 5, w - 10, h - 10); - renderer.displayBuffer(); -} - void SleepActivity::renderDefaultSleepScreen() const { - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING"); - // Apply white screen if enabled in settings - if (!SETTINGS.whiteSleepScreen) { + // Make sleep screen dark unless light is selected in settings + if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) { renderer.invertScreen(); } renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } -void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const { +void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { int x, y; - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { // image will scale, make sure placement is right @@ -153,3 +168,26 @@ void SleepActivity::renderCustomSleepScreen(const Bitmap& bitmap) const { renderer.setRenderMode(GfxRenderer::BW); } } + +void SleepActivity::renderCoverSleepScreen() const { + Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); + if (!lastEpub.load()) { + Serial.println("[SLP] Failed to load last epub"); + return renderDefaultSleepScreen(); + } + if (!lastEpub.generateCoverBmp()) { + Serial.println("[SLP] Failed to generate cover bmp"); + return renderDefaultSleepScreen(); + } + + auto file = SD.open(lastEpub.getCoverBmpPath().c_str(), FILE_READ); + if (file) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderBitmapSleepScreen(bitmap); + return; + } + } + + renderDefaultSleepScreen(); +} diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index defc1d5e..21121994 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -9,7 +9,9 @@ class SleepActivity final : public Activity { void onEnter() override; private: - void renderDefaultSleepScreen() const; - void renderCustomSleepScreen(const Bitmap& bitmap) const; void renderPopup(const char* message) const; + void renderDefaultSleepScreen() const; + void renderCustomSleepScreen() const; + void renderCoverSleepScreen() const; + void renderBitmapSleepScreen(const Bitmap& bitmap) const; }; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index b3acf3ff..29f68762 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -6,11 +6,14 @@ #include "config.h" // Define the static settings list - -const SettingInfo SettingsActivity::settingsList[settingsCount] = { - {"White Sleep Screen", SettingType::TOGGLE, &CrossPointSettings::whiteSleepScreen}, - {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing}, - {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn}}; +namespace { +constexpr int settingsCount = 3; +const SettingInfo settingsList[settingsCount] = { + // Should match with SLEEP_SCREEN_MODE + {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, + {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, + {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}}; +} // namespace void SettingsActivity::taskTrampoline(void* param) { auto* self = static_cast(param); @@ -81,15 +84,18 @@ void SettingsActivity::toggleCurrentSetting() { const auto& setting = settingsList[selectedSettingIndex]; - // Only toggle if it's a toggle type and has a value pointer - if (setting.type != SettingType::TOGGLE || setting.valuePtr == nullptr) { + if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { + // Toggle the boolean value using the member pointer + const bool currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = !currentValue; + } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { + const uint8_t currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); + } else { + // Only toggle if it's a toggle type and has a value pointer return; } - // Toggle the boolean value using the member pointer - bool currentValue = SETTINGS.*(setting.valuePtr); - SETTINGS.*(setting.valuePtr) = !currentValue; - // Save settings when they change SETTINGS.saveToFile(); } @@ -129,8 +135,13 @@ void SettingsActivity::render() const { // Draw value based on setting type if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { - bool value = SETTINGS.*(settingsList[i].valuePtr); + const bool value = SETTINGS.*(settingsList[i].valuePtr); renderer.drawText(UI_FONT_ID, pageWidth - 80, settingY, value ? "ON" : "OFF"); + } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { + const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); + auto valueText = settingsList[i].enumValues[value]; + const auto width = renderer.getTextWidth(UI_FONT_ID, valueText.c_str()); + renderer.drawText(UI_FONT_ID, pageWidth - 50 - width, settingY, valueText.c_str()); } } diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 7843a5cf..333f467c 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -12,13 +12,14 @@ class CrossPointSettings; -enum class SettingType { TOGGLE }; +enum class SettingType { TOGGLE, ENUM }; // Structure to hold setting information struct SettingInfo { const char* name; // Display name of the setting SettingType type; // Type of setting - uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE) + uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM) + std::vector enumValues; }; class SettingsActivity final : public Activity { @@ -28,10 +29,6 @@ class SettingsActivity final : public Activity { int selectedSettingIndex = 0; // Currently selected setting const std::function onGoHome; - // Static settings list - static constexpr int settingsCount = 3; // Number of settings - static const SettingInfo settingsList[settingsCount]; - static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; From 424104f8ff5f6c00fa7838079a615f346c1f8498 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 19:01:00 +1100 Subject: [PATCH 15/46] Fix incorrect justification of last line in paragraph (#90) ## Summary * Fix incorrect justification of last line in paragraph * `words` is changing size due to the slice, so `isLastLine` would rarely be right, either removing justification mid-paragraph, or including it in the last line. ## Additional Context * Introduced in #73 --- lib/Epub/Epub/ParsedText.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index eff3fd63..d73f80a5 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -144,7 +144,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const const int spareSpace = pageWidth - lineWordWidthSum; int spacing = spaceWidth; - const bool isLastLine = lineBreak == words.size(); + const bool isLastLine = breakIndex == lineBreakIndices.size() - 1; if (style == TextBlock::JUSTIFIED && !isLastLine && lineWordCount >= 2) { spacing = spareSpace / (lineWordCount - 1); From febf79a98aafacae82909339b6ec79e53fc00eaa Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Sun, 21 Dec 2025 13:01:11 +0500 Subject: [PATCH 16/46] Fix: restores cyrillic glyphs to Pixel Arial font (#70) ## Summary * adds cyrillic glyphs to pixel arial font, used as Small font in UI ## Additional Context * with recent changes pixel arial font lost cyrillic glyphs --- lib/EpdFont/builtinFonts/pixelarial14.h | 979 ++++++++++++++++++++++-- src/config.h | 2 +- 2 files changed, 919 insertions(+), 62 deletions(-) diff --git a/lib/EpdFont/builtinFonts/pixelarial14.h b/lib/EpdFont/builtinFonts/pixelarial14.h index c8e6a0e7..70c9e5cb 100644 --- a/lib/EpdFont/builtinFonts/pixelarial14.h +++ b/lib/EpdFont/builtinFonts/pixelarial14.h @@ -7,71 +7,452 @@ #pragma once #include "EpdFontData.h" -static const uint8_t pixelarial14Bitmaps[1145] = { - 0xFF, 0xFF, 0xFA, 0xC0, 0xFF, 0xFF, 0xFB, 0x18, 0x63, 0x0C, 0x63, 0x98, 0xCF, 0xFF, 0xFF, 0x9C, 0xE3, 0x18, 0xFF, - 0xFF, 0xFB, 0x9C, 0x63, 0x0C, 0x60, 0x30, 0xF3, 0xF7, 0xBF, 0x1F, 0x1F, 0x9B, 0x37, 0xEF, 0xFB, 0xC3, 0x00, 0x70, - 0x67, 0xCE, 0x36, 0x61, 0xB3, 0x0D, 0xB0, 0x7D, 0x81, 0xDD, 0xC0, 0xDE, 0x07, 0x98, 0xEC, 0xC6, 0x66, 0x33, 0xF3, - 0x07, 0x00, 0x3E, 0x0F, 0xE1, 0x8C, 0x31, 0x86, 0x60, 0xFC, 0x1E, 0x03, 0xE0, 0xC7, 0x98, 0xF3, 0x0E, 0x7F, 0xE7, - 0xE6, 0xFF, 0xE0, 0x37, 0x66, 0xCC, 0xCC, 0xCC, 0xCC, 0xC6, 0x67, 0x30, 0xCE, 0x66, 0x33, 0x33, 0x33, 0x33, 0x36, - 0x6E, 0xC0, 0x6F, 0xF6, 0xFB, 0x08, 0x0C, 0x06, 0x03, 0x0F, 0xFF, 0xFC, 0x60, 0x30, 0x18, 0x04, 0x00, 0xBF, 0xC0, - 0x03, 0xEF, 0x80, 0xB0, 0x18, 0x61, 0x8C, 0x30, 0xC7, 0x18, 0x61, 0x8E, 0x30, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, +static const uint8_t pixelarial14Bitmaps[8296] = { + 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x1C, 0x63, 0x8C, 0xF7, 0x98, 0xCF, 0xFF, 0xFF, 0xDC, 0xE7, 0xFE, 0xFF, + 0xFF, 0xFB, 0x9C, 0x63, 0x0C, 0x60, 0x30, 0xF3, 0xFF, 0xBF, 0x1F, 0x9F, 0x9B, 0x37, 0xEF, 0xFB, 0xE3, 0x00, 0x70, + 0x67, 0xCF, 0x37, 0x61, 0xBB, 0x0D, 0xF0, 0x7D, 0x81, 0xDD, 0xC0, 0xDF, 0x07, 0x98, 0xFC, 0xC7, 0x66, 0x7B, 0xF3, + 0x07, 0x00, 0x3E, 0x0F, 0xE1, 0x8C, 0x31, 0x86, 0x60, 0xFC, 0x1F, 0x07, 0xE4, 0xC7, 0x98, 0xF3, 0x0E, 0x7F, 0xF7, + 0xE6, 0xFF, 0xF0, 0x37, 0x66, 0xCC, 0xCC, 0xCC, 0xCC, 0xC6, 0x67, 0x30, 0xCE, 0x66, 0x33, 0x33, 0x33, 0x33, 0x36, + 0x6E, 0xC0, 0x6F, 0xF6, 0xFF, 0x08, 0x0E, 0x07, 0x03, 0x8F, 0xFF, 0xFC, 0x70, 0x38, 0x1C, 0x0E, 0x00, 0xFF, 0xC0, + 0xFB, 0xFF, 0x80, 0xF0, 0x1C, 0x73, 0xCC, 0x30, 0xC7, 0x18, 0x61, 0x8E, 0x30, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x37, 0xFF, 0x33, 0x33, 0x33, 0x33, 0x30, 0x7E, 0xFF, 0xC3, 0xC3, - 0x03, 0x03, 0x07, 0x06, 0x18, 0x38, 0x70, 0xFF, 0xFF, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x3F, 0x3F, 0x03, 0x03, 0xC3, - 0xC3, 0xFF, 0x7E, 0x03, 0x03, 0x83, 0xC3, 0x63, 0x31, 0x99, 0xCC, 0xC6, 0xC3, 0x7F, 0xFF, 0xE0, 0x60, 0x30, 0x7F, + 0x03, 0x03, 0x07, 0x0E, 0x1C, 0x38, 0x70, 0xFF, 0xFF, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x3F, 0x3F, 0x03, 0x03, 0xC3, + 0xC3, 0xFF, 0x7E, 0x03, 0x03, 0x83, 0xC3, 0xE3, 0x31, 0x99, 0xCD, 0xC6, 0xC3, 0x7F, 0xFF, 0xE0, 0x60, 0x30, 0x7F, 0x7F, 0xE0, 0xC0, 0xFE, 0xFF, 0xC3, 0x03, 0x03, 0xC3, 0xC7, 0xFE, 0x7E, 0x7E, 0xFF, 0xC3, 0xC0, 0xC0, 0xFE, 0xFF, - 0xE3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFF, 0xFF, 0x07, 0x06, 0x06, 0x0E, 0x18, 0x18, 0x18, 0x38, 0x30, 0x30, 0x30, + 0xE3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFF, 0xFF, 0x07, 0x06, 0x06, 0x1E, 0x1C, 0x1C, 0x1C, 0x38, 0x30, 0x30, 0x30, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xFF, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x7E, 0xFF, 0xC3, 0xC3, 0xC3, 0xC7, - 0xFF, 0x7B, 0x03, 0x03, 0xC7, 0xFE, 0x78, 0xB0, 0x00, 0x2C, 0xB0, 0x00, 0x2F, 0xF0, 0x03, 0x03, 0x1E, 0x7E, 0xF0, - 0xC0, 0x70, 0x3E, 0x0F, 0x03, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFE, 0x80, 0xC0, 0x70, 0x7E, 0x0F, 0x03, 0x1E, 0x7C, - 0xF0, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x07, 0x0E, 0x18, 0x30, 0x30, 0x30, 0x10, 0x30, 0x3F, 0x87, 0xFE, 0xEF, - 0x7D, 0xF3, 0xF1, 0xBF, 0x1B, 0xF3, 0xBF, 0xFF, 0xDF, 0xE6, 0x00, 0x7F, 0x03, 0xF0, 0x06, 0x00, 0xF0, 0x1B, 0x01, - 0xB0, 0x1B, 0x03, 0xB8, 0x31, 0x83, 0x18, 0x7F, 0xE7, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x30, 0xFF, 0x7F, 0xF0, 0x78, - 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x7F, 0xFF, 0xF0, 0x3F, 0x0F, 0xF3, 0x87, 0x60, 0x3C, 0x01, 0x80, - 0x30, 0x06, 0x00, 0xC0, 0x18, 0x0F, 0x87, 0x3F, 0xC3, 0xF0, 0xFF, 0x1F, 0xF3, 0x07, 0x60, 0x3C, 0x07, 0x80, 0xF0, - 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x07, 0x7F, 0xCF, 0xF0, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFF, 0xFF, 0x80, 0xC0, + 0xFF, 0x7F, 0x03, 0x03, 0xC7, 0xFE, 0x7C, 0xF0, 0x00, 0x3C, 0xF0, 0x00, 0x3F, 0xF0, 0x03, 0x03, 0x1E, 0x7E, 0xF0, + 0xF0, 0x70, 0x7E, 0x1F, 0x03, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xC0, 0xC0, 0x70, 0x7E, 0x1F, 0x0F, 0x1E, 0x7E, + 0xF0, 0xC0, 0x7E, 0xFF, 0xC3, 0xC3, 0x03, 0x07, 0x1E, 0x1C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3F, 0x87, 0xFE, 0xFF, + 0x7D, 0xFB, 0xF1, 0xBF, 0x1B, 0xF3, 0xBF, 0xFF, 0xDF, 0xE6, 0x00, 0x7F, 0x03, 0xF0, 0x06, 0x01, 0xF0, 0x1F, 0x01, + 0xF0, 0x1F, 0x03, 0xB8, 0x31, 0x87, 0xFC, 0x7F, 0xE7, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x30, 0xFF, 0x7F, 0xF0, 0x78, + 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x7F, 0xFF, 0xF0, 0x3F, 0x0F, 0xF3, 0x87, 0xE0, 0x3C, 0x01, 0x80, + 0x30, 0x06, 0x00, 0xC0, 0x18, 0x0F, 0x87, 0xBF, 0xC3, 0xF0, 0xFF, 0x1F, 0xF3, 0x07, 0xE0, 0x3C, 0x07, 0x80, 0xF0, + 0x1E, 0x03, 0xC0, 0x78, 0x0F, 0x07, 0xFF, 0xCF, 0xF0, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFF, 0xFF, 0x80, 0xC0, 0x60, 0x30, 0x1F, 0xFF, 0xF8, 0xFF, 0xFF, 0xF0, 0x18, 0x0C, 0x07, 0xFB, 0xFD, 0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x0C, 0x00, 0xC1, 0xFC, 0x1F, 0xC0, 0x3C, 0x03, 0xE0, 0x77, 0xFE, 0x3F, 0x80, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x1F, 0xFF, 0xFF, 0x83, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, 0x18, 0xFF, 0xFF, - 0xFF, 0xC0, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x07, 0x8F, 0x1F, 0xF7, 0x80, 0xC0, 0x78, 0x3B, 0x0E, 0x61, - 0x8C, 0x61, 0x9C, 0x3F, 0x87, 0xB0, 0xE3, 0x18, 0x63, 0x0E, 0x60, 0xEC, 0x06, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, + 0xFF, 0xC0, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x07, 0x8F, 0x1F, 0xF7, 0xC0, 0xC0, 0x78, 0x3F, 0x0E, 0x61, + 0x8C, 0x61, 0xBC, 0x3F, 0x87, 0xB8, 0xE3, 0x1C, 0x63, 0x0E, 0x60, 0xFC, 0x06, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x1F, 0xFF, 0xF8, 0xC0, 0x3E, 0x07, 0xE0, 0x7E, 0x07, 0xF1, 0xBF, 0x1B, 0xFB, - 0xBD, 0xB3, 0xDB, 0x3D, 0xB3, 0xCF, 0x3C, 0x63, 0xC6, 0x30, 0xC1, 0xF0, 0xFC, 0x7E, 0x3F, 0x1F, 0xCF, 0x67, 0xBB, + 0xBD, 0xF3, 0xDF, 0x3D, 0xF3, 0xDF, 0x3C, 0x63, 0xC6, 0x30, 0xC1, 0xF0, 0xFC, 0x7E, 0x3F, 0x1F, 0xEF, 0x77, 0xBB, 0xC7, 0xE3, 0xF1, 0xF8, 0x7C, 0x18, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xE0, 0x77, 0xFE, 0x3F, 0x80, 0xFF, 0x7F, 0xF0, 0x78, 0x3C, 0x1E, 0x0F, 0xFF, 0xFE, 0xC0, 0x60, 0x30, - 0x18, 0x0C, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x36, 0xE3, + 0x18, 0x0C, 0x00, 0x3F, 0x87, 0xFE, 0xE0, 0x7C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x03, 0xC0, 0x3C, 0x37, 0xE3, 0xE7, 0xFF, 0x3F, 0x70, 0xFF, 0x9F, 0xFF, 0x01, 0xE0, 0x3C, 0x07, 0x80, 0xFF, 0xFF, 0xFC, 0xC6, 0x18, 0xE3, 0x0E, - 0x60, 0xEC, 0x06, 0x7F, 0x7F, 0xF0, 0x78, 0x3C, 0x07, 0xC1, 0xFE, 0x0F, 0x01, 0xE0, 0xF0, 0x7F, 0xF7, 0xF0, 0xFF, - 0xFF, 0xC6, 0x03, 0x01, 0x80, 0xC0, 0x60, 0x30, 0x18, 0x0C, 0x06, 0x03, 0x01, 0x80, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, - 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF8, 0xEF, 0xE3, 0xE0, 0xC0, 0x3C, 0x03, 0xE0, 0x76, 0x06, 0x60, 0x67, 0x1C, - 0x31, 0x83, 0x18, 0x1B, 0x01, 0xB0, 0x0F, 0x00, 0x60, 0x06, 0x00, 0xC1, 0x81, 0xE1, 0xF0, 0xF8, 0xD8, 0xEC, 0x6C, - 0x66, 0x36, 0x33, 0x1B, 0x19, 0xDD, 0xDC, 0x6C, 0x78, 0x36, 0x3C, 0x1B, 0x1E, 0x0F, 0x8F, 0x03, 0x03, 0x01, 0x81, - 0x80, 0xC0, 0x7C, 0x39, 0x86, 0x30, 0xC3, 0x30, 0x7E, 0x07, 0x80, 0xF0, 0x33, 0x0E, 0x31, 0x86, 0x70, 0xEC, 0x06, - 0xC0, 0x3E, 0x07, 0x70, 0xE3, 0x18, 0x31, 0x83, 0xB8, 0x0F, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, - 0x00, 0xFF, 0xFF, 0xF8, 0x0E, 0x01, 0x80, 0x30, 0x0E, 0x03, 0x80, 0xC0, 0x30, 0x06, 0x01, 0xC0, 0x7F, 0xEF, 0xFE, - 0xFF, 0x6D, 0xB6, 0xDB, 0x6D, 0xB7, 0xE0, 0xC3, 0x0E, 0x18, 0x61, 0x87, 0x0C, 0x30, 0xC3, 0x86, 0x18, 0xFD, 0xB6, - 0xDB, 0x6D, 0xB6, 0xDF, 0xE0, 0x30, 0xF1, 0xE3, 0xC7, 0x9D, 0xF1, 0x80, 0xFF, 0xDF, 0xFC, 0xCE, 0x73, 0x00, 0x7C, - 0x7E, 0xC3, 0x83, 0x3F, 0x3F, 0x63, 0xC3, 0xC7, 0xFF, 0x7B, 0xC0, 0xC0, 0xDC, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, - 0xC3, 0xE3, 0xFF, 0xFE, 0x78, 0xF3, 0x1E, 0x3C, 0x18, 0x30, 0x60, 0xC7, 0xFD, 0xE0, 0x03, 0x03, 0x7B, 0x7B, 0xC7, - 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B, 0x7C, 0x7E, 0xC3, 0xC3, 0xFF, 0xFF, 0xC0, 0xC0, 0xC3, 0xFF, 0x7E, - 0x39, 0xEF, 0xBE, 0x61, 0x86, 0x18, 0x61, 0x86, 0x18, 0x60, 0x7B, 0x7B, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, - 0xFF, 0x7B, 0x03, 0xC3, 0xFF, 0x7E, 0xC0, 0xC0, 0xDC, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, - 0xFB, 0xFF, 0xFF, 0xC0, 0x33, 0x13, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0xE0, 0xC0, 0xC0, 0xC3, 0xC3, 0xC6, 0xCE, - 0xF8, 0xF0, 0xF8, 0xCE, 0xC6, 0xC7, 0xC3, 0xFF, 0xFF, 0xFF, 0xC0, 0x98, 0xCF, 0x9E, 0xE7, 0x3E, 0x73, 0xC6, 0x3C, - 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x30, 0x9C, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, - 0xC3, 0x7C, 0x7E, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0x9C, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, - 0xC3, 0xE3, 0xFF, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0x7B, 0x7B, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B, - 0x03, 0x03, 0x03, 0x03, 0x9B, 0xEE, 0x38, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x00, 0x78, 0xF3, 0x1E, 0x3F, 0x8F, 0x81, - 0x83, 0xC7, 0xFD, 0xE0, 0x61, 0x8F, 0xBE, 0x61, 0x86, 0x18, 0x61, 0x86, 0x1E, 0x78, 0x83, 0xC3, 0xC3, 0xC3, 0xC3, - 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7B, 0x80, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0x18, 0xD8, 0x6C, 0x3E, 0x0C, 0x06, 0x00, - 0x84, 0x3C, 0x63, 0xC6, 0x3E, 0xF7, 0x7B, 0x67, 0xB6, 0x7B, 0x67, 0xB6, 0x7B, 0xC3, 0x18, 0x31, 0x80, 0x83, 0xC3, - 0x66, 0x66, 0x7E, 0x38, 0x38, 0x7E, 0x66, 0xE7, 0xC3, 0x80, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0x18, 0xD8, 0x6C, 0x36, - 0x1F, 0x06, 0x03, 0x01, 0x83, 0xC1, 0xC0, 0xFF, 0xFF, 0x06, 0x0E, 0x18, 0x18, 0x30, 0x30, 0x70, 0xFF, 0xFF, 0x37, - 0x66, 0x66, 0x66, 0xCE, 0x66, 0x66, 0x67, 0x30, 0xFF, 0xFF, 0xFF, 0xC0, 0xCE, 0x66, 0x66, 0x66, 0x37, 0x66, 0x66, - 0x6E, 0xC0, 0xC3, 0x99, 0xFF, 0xF9, 0xB8, 0x30, 0xDB, 0x66, 0xC0, 0x6D, 0xBD, 0x00, 0x7B, 0xEF, 0x3C, 0xF2, 0xC0, - 0x79, 0xE7, 0x9E, 0xF2, 0xC0, + 0x60, 0xFC, 0x06, 0x7F, 0x7F, 0xF0, 0x78, 0x3C, 0x07, 0xE1, 0xFE, 0x0F, 0x01, 0xE0, 0xF0, 0x7F, 0xF7, 0xF0, 0xFF, + 0xFF, 0xC7, 0x03, 0x81, 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xC0, 0xC1, 0xE0, 0xF0, 0x78, 0x3C, + 0x1E, 0x0F, 0x07, 0x83, 0xC1, 0xE0, 0xF8, 0xEF, 0xE3, 0xE0, 0xC0, 0x3C, 0x03, 0xE0, 0x76, 0x06, 0x60, 0x67, 0x1E, + 0x31, 0x83, 0xB8, 0x1F, 0x01, 0xF0, 0x1F, 0x00, 0x60, 0x06, 0x00, 0xC1, 0x81, 0xE1, 0xF0, 0xF8, 0xD8, 0xEC, 0x6C, + 0x66, 0x36, 0x33, 0x1B, 0x19, 0xDD, 0xFC, 0x6C, 0x7C, 0x36, 0x3E, 0x1B, 0x1F, 0x0F, 0x8F, 0x03, 0x83, 0x01, 0xC1, + 0x80, 0xC0, 0x7C, 0x3D, 0x86, 0x30, 0xC3, 0x30, 0x7E, 0x07, 0x80, 0xF8, 0x33, 0x0E, 0x71, 0x86, 0x70, 0xFC, 0x06, + 0xC0, 0x3E, 0x07, 0x71, 0xE3, 0x18, 0x31, 0x83, 0xF8, 0x1F, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, 0x00, 0x60, 0x06, + 0x00, 0xFF, 0xFF, 0xFC, 0x0E, 0x01, 0x80, 0x30, 0x0E, 0x07, 0x80, 0xE0, 0x30, 0x06, 0x01, 0xC0, 0x7F, 0xFF, 0xFE, + 0xFF, 0x6D, 0xB6, 0xDB, 0x6D, 0xB7, 0xE0, 0xC3, 0x0E, 0x18, 0x61, 0x87, 0x0C, 0x30, 0xC3, 0x87, 0x1C, 0xFD, 0xB6, + 0xDB, 0x6D, 0xB6, 0xDF, 0xE0, 0x30, 0xF1, 0xF3, 0xE7, 0xDD, 0xF1, 0x80, 0xFF, 0xFF, 0xFC, 0xCE, 0x73, 0x00, 0x7E, + 0x7E, 0xC3, 0xC3, 0x3F, 0x7F, 0x63, 0xE3, 0xC7, 0xFF, 0x7F, 0xC0, 0xC0, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, + 0xC3, 0xE3, 0xFF, 0xFE, 0x78, 0xFB, 0x1E, 0x3C, 0x18, 0x30, 0x60, 0xC7, 0xFD, 0xF0, 0x03, 0x03, 0x7B, 0x7F, 0xC7, + 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, 0x7E, 0x7E, 0xC3, 0xC3, 0xFF, 0xFF, 0xC0, 0xC0, 0xC3, 0xFF, 0x7E, + 0x3D, 0xEF, 0xBF, 0x61, 0x86, 0x18, 0x61, 0x86, 0x18, 0x60, 0x7B, 0x7F, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, + 0xFF, 0x7F, 0x03, 0xC3, 0xFF, 0x7E, 0xC0, 0xC0, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, + 0xFF, 0xFF, 0xFF, 0xC0, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x3F, 0xE0, 0xC0, 0xC0, 0xC3, 0xC3, 0xC6, 0xDE, + 0xFC, 0xF8, 0xFC, 0xEE, 0xC6, 0xC7, 0xC3, 0xFF, 0xFF, 0xFF, 0xC0, 0xF9, 0xEF, 0xDE, 0xE7, 0x3E, 0x73, 0xC6, 0x3C, + 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x30, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, + 0xC3, 0x7E, 0x7E, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0x7E, 0xFE, 0xFE, 0xE3, 0xE3, 0xC3, 0xC3, 0xC3, + 0xC3, 0xE3, 0xFF, 0xFE, 0xC0, 0xC0, 0xC0, 0xC0, 0x7B, 0x7F, 0xC7, 0xC7, 0xC3, 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, + 0x03, 0x03, 0x03, 0x03, 0xFB, 0xFE, 0x38, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x00, 0x78, 0xFB, 0x1E, 0x3F, 0x8F, 0x81, + 0x83, 0xC7, 0xFD, 0xF0, 0x61, 0x8F, 0xBF, 0x61, 0x86, 0x18, 0x61, 0x86, 0x1E, 0x7C, 0xC3, 0xC3, 0xC3, 0xC3, 0xC3, + 0xC3, 0xC3, 0xC3, 0xC7, 0xFF, 0x7F, 0xC1, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0xB8, 0xD8, 0x6C, 0x3E, 0x0E, 0x07, 0x00, + 0xC6, 0x3C, 0x63, 0xC6, 0x3F, 0xF7, 0x7F, 0x67, 0xF6, 0x7F, 0x67, 0xF6, 0x7B, 0xE3, 0x18, 0x31, 0x80, 0xC3, 0xC3, + 0x66, 0x66, 0x7E, 0x3C, 0x3C, 0x7E, 0x66, 0xE7, 0xC3, 0xC1, 0xE0, 0xF0, 0x7C, 0x76, 0x33, 0xB8, 0xD8, 0x6C, 0x36, + 0x1F, 0x07, 0x03, 0x81, 0xC3, 0xE1, 0xC0, 0xFF, 0xFF, 0x06, 0x1E, 0x1C, 0x1C, 0x30, 0x30, 0x70, 0xFF, 0xFF, 0x37, + 0x66, 0x66, 0x6E, 0xCE, 0x66, 0x66, 0x67, 0x30, 0xFF, 0xFF, 0xFF, 0xC0, 0xCE, 0x66, 0x66, 0x67, 0x37, 0x66, 0x66, + 0x6E, 0xC0, 0xC3, 0x9B, 0xFF, 0xF9, 0xB8, 0x30, 0xFC, 0x0D, 0xBF, 0xFF, 0xF0, 0x1C, 0x1C, 0x3E, 0x7E, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0x7A, 0x3E, 0x1C, 0x1C, 0x3F, 0x3B, 0x70, 0x70, 0x70, 0x70, 0xFE, 0x70, 0x70, 0x70, 0x60, 0x7F, + 0x61, 0xBF, 0xDF, 0xCC, 0x66, 0x33, 0x19, 0xFC, 0xFF, 0x61, 0x80, 0xE1, 0xD8, 0xE7, 0x30, 0xDC, 0x3E, 0x07, 0x87, + 0xF8, 0x30, 0x0C, 0x1F, 0xE0, 0xC0, 0x30, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF, 0x3F, 0x70, 0x60, 0x70, 0x7C, 0x6F, + 0xE3, 0xE3, 0x7F, 0x3E, 0x07, 0x07, 0x6E, 0xFE, 0xEF, 0xB0, 0x1F, 0x87, 0x1C, 0x66, 0x6D, 0xFF, 0xD8, 0x3F, 0x83, + 0xD8, 0x3D, 0x83, 0xDF, 0xF6, 0x66, 0x71, 0xE1, 0xF8, 0x7D, 0x37, 0xFB, 0xED, 0xF0, 0x3B, 0x77, 0x7E, 0xEE, 0x7E, + 0x77, 0x3B, 0xFF, 0x80, 0xC0, 0x60, 0x30, 0x18, 0xF8, 0x1F, 0x87, 0x1C, 0x60, 0x6D, 0xF7, 0xDB, 0xBD, 0xBB, 0xDF, + 0x3D, 0xB3, 0xD9, 0xF6, 0x06, 0x71, 0xE1, 0xF8, 0xFC, 0x7B, 0xEC, 0xFE, 0x78, 0x0C, 0x06, 0x03, 0x1F, 0xF0, 0xC0, + 0x60, 0x30, 0x00, 0x00, 0x7F, 0xC0, 0xFB, 0x61, 0x8E, 0x71, 0x8F, 0xC0, 0xF9, 0x61, 0x9E, 0x1F, 0x7F, 0x80, 0x6E, + 0xC0, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0xFF, 0xC0, 0xC0, 0xC0, 0x3F, 0xDF, 0x77, 0xDF, 0xF7, 0xFD, + 0xDF, 0x77, 0xDC, 0x77, 0x0D, 0xC3, 0x70, 0xDC, 0x37, 0x0D, 0xC3, 0x70, 0xDC, 0x77, 0x67, 0xF0, 0x73, 0x8C, 0x63, + 0x18, 0xC0, 0x7C, 0xDF, 0x9F, 0x36, 0xEF, 0x80, 0xEC, 0x6E, 0x77, 0x3F, 0x77, 0x6E, 0xEC, 0x70, 0x70, 0xE0, 0xC0, + 0xC3, 0x81, 0x86, 0x03, 0x18, 0x06, 0x73, 0x8C, 0xCF, 0x03, 0x9E, 0x06, 0x6C, 0x1C, 0xFC, 0x30, 0x30, 0xE0, 0x60, + 0x70, 0x60, 0xE1, 0xC0, 0xC3, 0x01, 0x8E, 0x03, 0x18, 0x06, 0x7F, 0x8C, 0xDB, 0x03, 0x06, 0x0E, 0x1C, 0x18, 0x70, + 0x71, 0xC0, 0xC3, 0xE0, 0xF8, 0x70, 0xB0, 0xC0, 0x63, 0x83, 0xC6, 0x01, 0xDC, 0x1B, 0xB3, 0xBE, 0xEF, 0x01, 0x9E, + 0x07, 0x6C, 0x1C, 0xFC, 0x30, 0x30, 0xE0, 0x60, 0x38, 0x70, 0x00, 0x01, 0x87, 0x0E, 0x38, 0xE1, 0xC3, 0xFB, 0xF0, + 0x1C, 0x01, 0xE0, 0x06, 0x00, 0x00, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, 0x31, 0xC7, 0x1C, 0x7F, + 0xC6, 0x0E, 0xE0, 0xEE, 0x06, 0x03, 0x00, 0xF0, 0x0E, 0x00, 0x00, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, + 0xB8, 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, 0x06, 0x0E, 0x01, 0xF0, 0x1B, 0x00, 0x00, 0x0E, 0x00, 0xE0, + 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, 0x06, 0x1F, 0x81, 0xF0, 0x00, + 0x00, 0x00, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, + 0x06, 0x1B, 0x81, 0xB8, 0x00, 0x00, 0xE0, 0x0E, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, + 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x0E, 0x01, 0xF0, 0x0F, 0x00, 0xE0, 0x0F, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, + 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x03, 0xFE, 0x03, 0xC0, 0x07, 0xC0, 0x06, 0xC0, 0x0E, 0xC0, + 0x1C, 0xFE, 0x18, 0xC0, 0x38, 0xC0, 0x3F, 0xC0, 0x70, 0xC0, 0xE0, 0xC0, 0xE0, 0xFF, 0x02, 0x03, 0xF8, 0xF3, 0x38, + 0x07, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x60, 0x0E, 0x01, 0xC0, 0x1E, 0x61, 0xFC, 0x0E, 0x01, 0xC0, 0x78, 0x38, 0x1E, + 0x07, 0x00, 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xFF, 0x0E, 0x0F, 0x06, + 0x00, 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xFF, 0x18, 0x1E, 0x19, 0x80, + 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xFF, 0x7E, 0x3F, 0x00, 0x1F, 0xEE, + 0x07, 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0xFE, 0xCF, 0x70, 0x77, 0x77, 0x77, 0x77, 0x77, + 0x77, 0x6E, 0xC0, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0x31, 0xE6, 0xC0, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x8E, 0x38, + 0xE3, 0x8E, 0xEF, 0xB0, 0x0E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x80, 0x7F, 0x87, 0x3C, 0x70, 0xE7, + 0x06, 0x70, 0x7F, 0xC7, 0x70, 0x77, 0x07, 0x70, 0x67, 0x0E, 0x73, 0xC7, 0xF8, 0x3F, 0x0F, 0xC0, 0x00, 0x00, 0xE0, + 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, 0x7E, 0x1F, 0x83, 0x0E, 0x00, 0x78, 0x00, 0xC0, + 0x00, 0x01, 0xFC, 0x1E, 0xF1, 0xC1, 0xCE, 0x06, 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xC0, 0x77, 0x03, 0x38, 0x38, 0xF7, + 0x83, 0xF8, 0x03, 0x80, 0x3C, 0x01, 0xC0, 0x00, 0x01, 0xFC, 0x1E, 0xF1, 0xC1, 0xCE, 0x06, 0x60, 0x3B, 0x01, 0xD8, + 0x0E, 0xC0, 0x77, 0x03, 0x38, 0x38, 0xF7, 0x83, 0xF8, 0x07, 0x00, 0x7C, 0x03, 0x60, 0x00, 0x01, 0xFC, 0x1E, 0xF1, + 0xC1, 0xCE, 0x06, 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xC0, 0x77, 0x03, 0x38, 0x38, 0xF7, 0x83, 0xF8, 0x0F, 0xC0, 0x7C, + 0x00, 0x00, 0x00, 0x01, 0xFC, 0x1E, 0xF1, 0xC1, 0xCE, 0x06, 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xC0, 0x77, 0x03, 0x38, + 0x38, 0xF7, 0x83, 0xF8, 0x0D, 0xC0, 0x6E, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, 0x60, 0x3B, + 0x01, 0xD8, 0x0E, 0xE0, 0x67, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x42, 0xEE, 0x7E, 0x3C, 0x7E, 0xEE, 0x42, 0x1F, 0xF1, + 0xEF, 0x9C, 0x3C, 0xE3, 0xE6, 0x3B, 0xB1, 0x9D, 0x9C, 0xED, 0xC7, 0x7C, 0x33, 0xE3, 0x8F, 0x78, 0xFF, 0x80, 0x18, + 0x07, 0x00, 0xC0, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, 0x67, 0xF8, 0xFC, + 0x06, 0x03, 0x80, 0xC0, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, 0x67, 0xF8, + 0xFC, 0x0C, 0x07, 0x83, 0x20, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, 0x67, + 0xF8, 0xFC, 0x3F, 0x0F, 0xC0, 0x03, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x87, 0xE1, 0x9F, + 0xE3, 0xF0, 0x06, 0x01, 0xC0, 0x30, 0x00, 0x0E, 0x1D, 0xC3, 0x9C, 0xE1, 0xDC, 0x3F, 0x03, 0xC0, 0x78, 0x06, 0x00, + 0xC0, 0x18, 0x03, 0x00, 0x60, 0xE0, 0x70, 0x3F, 0x9D, 0xEE, 0x3F, 0x0F, 0x87, 0xC7, 0xEF, 0x7F, 0x38, 0x1C, 0x00, + 0x7E, 0x7B, 0xB9, 0xD8, 0xEC, 0xE6, 0xE3, 0x71, 0x9E, 0xC7, 0xE0, 0xF0, 0x7B, 0x7D, 0xF0, 0x38, 0x3C, 0x0C, 0x00, + 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x0E, 0x1E, 0x18, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, + 0xE3, 0x73, 0x7F, 0x1C, 0x3E, 0x36, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x3F, 0x7E, 0x00, + 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x36, 0x36, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, + 0xE3, 0x73, 0x7F, 0x1C, 0x36, 0x36, 0x1C, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x7F, 0xF8, + 0xBF, 0xF0, 0x79, 0xC0, 0xC3, 0x7F, 0xFF, 0xDC, 0x0E, 0x38, 0x1D, 0xF6, 0x7F, 0xF8, 0x3F, 0x7A, 0x60, 0x60, 0xE0, + 0x60, 0x60, 0x7B, 0x3F, 0x0C, 0x0E, 0x1C, 0x18, 0x0E, 0x03, 0x00, 0x03, 0xF3, 0xF9, 0x8E, 0xC7, 0xFF, 0xB0, 0x18, + 0x0F, 0x63, 0xF0, 0x06, 0x07, 0x03, 0x00, 0x03, 0xF3, 0xF9, 0x8E, 0xC7, 0xFF, 0xB0, 0x18, 0x0F, 0x63, 0xF0, 0x0C, + 0x0F, 0x0C, 0x80, 0x03, 0xF3, 0xF9, 0x8E, 0xC7, 0xFF, 0xB0, 0x18, 0x0F, 0x63, 0xF0, 0x3F, 0x1F, 0x80, 0x07, 0xE7, + 0xF3, 0x1D, 0x8F, 0xFF, 0x60, 0x30, 0x1E, 0xC7, 0xE0, 0xCE, 0x60, 0x66, 0x66, 0x66, 0x66, 0x60, 0x6E, 0xC0, 0xCC, + 0xCC, 0xCC, 0xCC, 0xC0, 0x31, 0xE6, 0xC0, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xFF, 0xF0, 0x0C, 0x30, 0xC3, + 0x0C, 0x30, 0xC3, 0x0C, 0x0F, 0xC3, 0xF0, 0xF8, 0x0E, 0x01, 0x8F, 0xE7, 0xF9, 0x86, 0xE1, 0x98, 0x66, 0x39, 0xFC, + 0x3F, 0x00, 0x7E, 0x7E, 0x00, 0x00, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0x18, 0x07, 0x00, 0xC0, + 0x00, 0x3F, 0x1F, 0xE6, 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x06, 0x03, 0x80, 0xC0, 0x00, 0x3F, + 0x1F, 0xE6, 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x0C, 0x07, 0x83, 0x30, 0x00, 0x3F, 0x1F, 0xE6, + 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x3F, 0x0F, 0xC0, 0x00, 0x00, 0x3F, 0x1F, 0xE6, 0x19, 0x86, + 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x3F, 0x0F, 0xC0, 0x00, 0xFC, 0x7F, 0x98, 0x66, 0x1B, 0x87, 0x61, 0x98, + 0x67, 0xF8, 0xFC, 0x1C, 0x0E, 0x00, 0x00, 0x0F, 0xF8, 0x00, 0x00, 0x38, 0x1C, 0x00, 0x3F, 0x9F, 0xE6, 0x79, 0xBE, + 0xED, 0xDF, 0x67, 0x99, 0xFE, 0x7F, 0x00, 0x30, 0x38, 0x18, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, + 0x7F, 0x0C, 0x1C, 0x18, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x18, 0x3C, 0x6C, 0x00, 0xC7, + 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x7E, 0x7E, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, + 0x7F, 0x0E, 0x0F, 0x06, 0x00, 0x0E, 0x37, 0x19, 0x9C, 0xCE, 0x76, 0x1B, 0x0F, 0x83, 0x81, 0xC0, 0xE2, 0xE1, 0xE0, + 0xC0, 0x60, 0x30, 0x18, 0x0F, 0xE7, 0x7B, 0x0D, 0x86, 0xC3, 0xE1, 0xB0, 0xDF, 0xEF, 0xE6, 0x03, 0x01, 0x80, 0x36, + 0x1B, 0x00, 0x1C, 0x6E, 0x33, 0x39, 0x9C, 0xEC, 0x36, 0x1F, 0x07, 0x03, 0x81, 0xC5, 0xC3, 0xC0, 0x1F, 0x00, 0x00, + 0x00, 0x00, 0xE0, 0x0E, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, + 0x60, 0x3E, 0x00, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x1B, 0x01, 0xF0, 0x00, 0x00, 0xE0, + 0x0E, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x36, 0x3E, + 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, + 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, 0x06, 0x00, 0xE0, 0x0C, 0x00, 0xF0, 0x7E, 0x2F, 0x03, 0x7F, 0x77, + 0xE3, 0xE3, 0x73, 0x7F, 0x06, 0x0E, 0x0F, 0x03, 0x00, 0xE0, 0x18, 0x01, 0x01, 0xFC, 0x79, 0x9C, 0x03, 0x80, 0x60, + 0x0C, 0x01, 0x80, 0x30, 0x07, 0x00, 0xE0, 0x0F, 0x30, 0xFE, 0x06, 0x0E, 0x0C, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, + 0x60, 0x60, 0x7B, 0x3F, 0x06, 0x01, 0xE0, 0x36, 0x01, 0x01, 0xFC, 0x79, 0x9C, 0x03, 0x80, 0x60, 0x0C, 0x01, 0x80, + 0x30, 0x07, 0x00, 0xE0, 0x0F, 0x30, 0xFE, 0x0C, 0x1E, 0x32, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, 0x60, 0x60, 0x7B, + 0x3F, 0x07, 0x00, 0xE0, 0x08, 0x0F, 0xE3, 0xCC, 0xE0, 0x1C, 0x03, 0x00, 0x60, 0x0C, 0x01, 0x80, 0x38, 0x07, 0x00, + 0x79, 0x87, 0xF0, 0x0C, 0x0C, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, 0x60, 0x60, 0x7B, 0x3F, 0x0D, 0x81, 0xE0, 0x18, + 0x01, 0x01, 0xFC, 0x79, 0x9C, 0x03, 0x80, 0x60, 0x0C, 0x01, 0x80, 0x30, 0x07, 0x00, 0xE0, 0x0F, 0x30, 0xFE, 0x32, + 0x1E, 0x0C, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, 0x60, 0x60, 0x7B, 0x3F, 0x36, 0x07, 0xC0, 0x70, 0x00, 0x0F, 0xF1, + 0xCF, 0x38, 0x77, 0x0E, 0xE0, 0xDC, 0x1F, 0x83, 0xF0, 0x6E, 0x1D, 0xC3, 0xB9, 0xE7, 0xF8, 0x01, 0xF0, 0x1F, 0x01, + 0xF0, 0x1E, 0x3F, 0x87, 0xF8, 0x61, 0x86, 0x18, 0xE1, 0x86, 0x18, 0x61, 0x87, 0xB8, 0x3F, 0x80, 0x7F, 0x87, 0x3C, + 0x70, 0xE7, 0x06, 0x70, 0x7F, 0xC7, 0x70, 0x77, 0x07, 0x70, 0x67, 0x0E, 0x73, 0xC7, 0xF8, 0x01, 0x80, 0x60, 0xFC, + 0x06, 0x3F, 0x9F, 0xE6, 0x19, 0x86, 0xE1, 0x98, 0x66, 0x19, 0xEE, 0x3F, 0x80, 0x7E, 0x00, 0x00, 0x1F, 0xEE, 0x07, + 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0xFE, 0x3F, 0x00, 0x00, 0x07, 0xE7, 0xF3, 0x1D, 0x8F, + 0xFF, 0x60, 0x30, 0x1E, 0xC7, 0xE0, 0x7E, 0x1E, 0x00, 0x1F, 0xEE, 0x07, 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, + 0x0E, 0x07, 0x03, 0xFE, 0x36, 0x0F, 0x00, 0x07, 0xE7, 0xF3, 0x1D, 0x8F, 0xFF, 0x60, 0x30, 0x1E, 0xC7, 0xE0, 0x18, + 0x0C, 0x00, 0x1F, 0xEE, 0x07, 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0xFE, 0x0C, 0x06, 0x00, + 0x07, 0xE7, 0xF3, 0x1D, 0x8F, 0xFF, 0x60, 0x30, 0x1E, 0xC7, 0xE0, 0xFF, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0xF3, 0x81, + 0xC0, 0xE0, 0x70, 0x38, 0x1F, 0xF0, 0x70, 0x30, 0x1E, 0x3F, 0x3F, 0x98, 0xEC, 0x7F, 0xFB, 0x01, 0x80, 0xF6, 0x3F, + 0x03, 0x83, 0x80, 0xE0, 0x64, 0x1E, 0x06, 0x00, 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, 0x0E, 0x07, + 0x03, 0x81, 0xFF, 0x32, 0x0F, 0x03, 0x00, 0x00, 0x01, 0xF9, 0xFC, 0xC7, 0x63, 0xFF, 0xD8, 0x0C, 0x07, 0xB1, 0xF8, + 0x07, 0x01, 0xF0, 0x36, 0x00, 0x01, 0xFC, 0x7B, 0x9C, 0x03, 0x80, 0x60, 0x0C, 0x01, 0x83, 0xB0, 0x77, 0x0E, 0xE1, + 0xCF, 0x38, 0xFF, 0x0C, 0x0F, 0x06, 0xC0, 0x03, 0xFB, 0xDD, 0x8E, 0xC7, 0xE3, 0xF1, 0xD8, 0xEF, 0xF3, 0xF8, 0x1D, + 0xDC, 0xFE, 0x0D, 0x81, 0xF0, 0x00, 0x0F, 0xE3, 0xDC, 0xE0, 0x1C, 0x03, 0x00, 0x60, 0x0C, 0x1D, 0x83, 0xB8, 0x77, + 0x0E, 0x79, 0xC7, 0xF8, 0x1F, 0x0F, 0x00, 0x07, 0xF7, 0xBB, 0x1D, 0x8F, 0xC7, 0xE3, 0xB1, 0xDF, 0xE7, 0xF0, 0x3B, + 0xB9, 0xFC, 0x07, 0x00, 0xE0, 0x00, 0x0F, 0xE3, 0xDC, 0xE0, 0x1C, 0x03, 0x00, 0x60, 0x0C, 0x1D, 0x83, 0xB8, 0x77, + 0x0E, 0x79, 0xC7, 0xF8, 0x0C, 0x06, 0x00, 0x07, 0xF7, 0xBB, 0x1D, 0x8F, 0xC7, 0xE3, 0xB1, 0xDF, 0xE7, 0xF0, 0x3B, + 0xB9, 0xFC, 0x1F, 0xC7, 0xB9, 0xC0, 0x38, 0x06, 0x00, 0xC0, 0x18, 0x3B, 0x07, 0x70, 0xEE, 0x1C, 0xF3, 0x8F, 0xF0, + 0x00, 0x0E, 0x03, 0x80, 0x06, 0x07, 0x03, 0x00, 0x03, 0xFB, 0xDD, 0x8E, 0xC7, 0xE3, 0xF1, 0xD8, 0xEF, 0xF3, 0xF8, + 0x1D, 0xDC, 0xFE, 0x0C, 0x07, 0x83, 0x30, 0x00, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xFF, 0xFE, 0x1F, 0x87, 0xE1, + 0xF8, 0x7E, 0x1F, 0x87, 0x78, 0x3C, 0x00, 0x0C, 0x06, 0x03, 0x01, 0x80, 0xFE, 0x77, 0x31, 0xD8, 0xEC, 0x76, 0x3B, + 0x1D, 0x8E, 0xC7, 0x70, 0x63, 0x83, 0x3F, 0xFE, 0xE0, 0xC7, 0x06, 0x3F, 0xF1, 0xC1, 0x8E, 0x0C, 0x70, 0x63, 0x83, + 0x1C, 0x18, 0xE0, 0xC0, 0x60, 0x30, 0x3F, 0x0C, 0x07, 0xF3, 0xB9, 0x8E, 0xC7, 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x38, + 0x7D, 0xF8, 0x00, 0x03, 0x87, 0x0E, 0x1C, 0x38, 0x70, 0xE1, 0xC3, 0x87, 0x0E, 0x1C, 0xFF, 0xF0, 0x00, 0x30, 0xC3, + 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xFC, 0x00, 0x0E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x80, 0xFC, 0x00, + 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0xDF, 0x80, 0xE7, 0x39, 0xCE, 0x73, 0x9C, 0xE7, 0x39, 0xC0, 0x7D, 0xE0, + 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x77, 0x77, 0x77, 0x77, 0x77, 0x77, 0xEC, 0xF0, 0x66, 0x00, 0x66, 0x66, + 0x66, 0x66, 0x6E, 0xCF, 0xFC, 0x7F, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xC0, 0xE0, 0x7C, 0x0F, 0x81, 0xF0, 0x3E, + 0x07, 0xC0, 0xF8, 0x1F, 0x03, 0xE0, 0x7C, 0x1F, 0xB7, 0xF7, 0xE0, 0xCF, 0x9C, 0x00, 0x0C, 0xF9, 0xF3, 0xE7, 0xCF, + 0x9F, 0x3E, 0x7C, 0xE1, 0xC3, 0x1E, 0x06, 0x07, 0x83, 0x60, 0x00, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0x81, 0xC0, + 0xE0, 0x60, 0x33, 0xB9, 0xF8, 0x18, 0x78, 0xD8, 0x01, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x0E, 0x38, + 0xE3, 0xB9, 0xCE, 0xF3, 0xF8, 0xFC, 0x3E, 0x0F, 0xC3, 0xF8, 0xEF, 0x39, 0xCE, 0x3B, 0x87, 0x00, 0x07, 0x03, 0xC0, + 0xC0, 0xC0, 0xC0, 0xC0, 0xCE, 0xCE, 0xDC, 0xF8, 0xF8, 0xFC, 0xDC, 0xCE, 0xC7, 0x00, 0x38, 0x78, 0xC7, 0xCE, 0xDC, + 0xF8, 0xF8, 0xFC, 0xDE, 0xCE, 0xC7, 0x30, 0xF0, 0xE0, 0x00, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0xE0, 0xFF, 0xEE, 0x0C, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xEF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0xFF, 0x00, 0x18, 0x38, 0x63, 0x18, 0xC6, 0x31, 0x8C, 0x63, 0x18, 0xE7, 0x81, 0xDC, 0xFC, 0xF8, + 0xF8, 0xF8, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xFF, 0xDE, 0xF7, 0xBC, 0x63, 0x18, 0xC6, 0x31, 0xCF, 0x00, + 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xEE, 0xEE, 0xE0, 0xE0, 0xE0, 0xE0, 0xFF, 0xC3, 0x0C, 0x30, 0xC3, 0x0D, 0xF7, 0xC3, + 0x0C, 0x38, 0xF0, 0x38, 0x0E, 0x03, 0x80, 0xE0, 0x3E, 0x0F, 0x07, 0x81, 0xE0, 0x38, 0x0E, 0x03, 0x80, 0xFF, 0x73, + 0x9C, 0xE7, 0xBF, 0xDE, 0x73, 0x9C, 0xE3, 0x80, 0x06, 0x03, 0x80, 0xC0, 0x00, 0xE0, 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, + 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, 0x7E, 0x1F, 0x83, 0x0C, 0x1C, 0x18, 0x00, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, + 0xC7, 0xC7, 0xC7, 0xE0, 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, 0x7E, 0x1F, 0x83, 0x00, + 0x03, 0x01, 0xC0, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0x00, 0x18, 0x38, 0x1B, 0x07, 0x80, 0xC0, + 0x00, 0xE0, 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, 0x7E, 0x1F, 0x83, 0x66, 0x3C, 0x18, + 0x00, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE0, 0x70, 0x30, 0x18, 0x00, 0x03, 0xF9, 0xDC, 0xC7, + 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x3B, 0x1C, 0xE0, 0xF8, 0x3F, 0x0F, 0xE3, 0xEC, 0xFB, 0x3E, 0x6F, 0x9B, 0xE3, 0xF8, + 0x7E, 0x1F, 0x83, 0x00, 0xC0, 0x70, 0x3C, 0xFE, 0xEE, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0x07, 0x07, 0x0E, + 0x0F, 0x80, 0x00, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xE0, + 0x67, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x3F, 0x00, 0x00, 0x00, 0xFC, 0x7F, 0x98, 0x66, 0x1B, 0x87, 0x61, 0x98, 0x67, + 0xF8, 0xFC, 0x0D, 0x80, 0x7C, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, 0x60, 0x3B, 0x01, 0xD8, + 0x0E, 0xE0, 0x67, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x3F, 0x07, 0x80, 0x00, 0xFC, 0x7F, 0x98, 0x66, 0x1B, 0x87, 0x61, + 0x98, 0x67, 0xF8, 0xFC, 0x07, 0xC0, 0x7E, 0x03, 0x60, 0x00, 0x01, 0xFC, 0x1E, 0xF1, 0xC1, 0xCE, 0x06, 0x60, 0x3B, + 0x01, 0xD8, 0x0E, 0xC0, 0x77, 0x03, 0x38, 0x38, 0xF7, 0x83, 0xF8, 0x0D, 0x87, 0xE1, 0xB0, 0x00, 0x3F, 0x1F, 0xE6, + 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0x1F, 0xFF, 0x1F, 0x70, 0x1C, 0x38, 0x0E, 0x1C, 0x06, 0x0E, + 0x03, 0x07, 0xF9, 0x83, 0x80, 0xC1, 0xC0, 0x70, 0xE0, 0x38, 0x70, 0x0F, 0xB8, 0x03, 0xFF, 0xF0, 0x3F, 0x7C, 0x7F, + 0xEE, 0x61, 0xC6, 0x61, 0xC7, 0xE1, 0xFF, 0x61, 0xC0, 0x61, 0xC0, 0x7F, 0xF6, 0x3F, 0x7E, 0x0C, 0x07, 0x01, 0x80, + 0x00, 0xFF, 0x3B, 0xEE, 0x3B, 0x8E, 0xE3, 0xBB, 0xEF, 0xF3, 0xB8, 0xE7, 0x38, 0xEE, 0x3B, 0x87, 0x18, 0xE3, 0x00, + 0xFF, 0xAC, 0x30, 0xC3, 0x0C, 0x30, 0xC0, 0xFF, 0x3B, 0xEE, 0x3B, 0x8E, 0xE3, 0xBB, 0xEF, 0xF3, 0xB8, 0xE7, 0x38, + 0xEE, 0x3B, 0x87, 0x00, 0x07, 0x03, 0x80, 0x7E, 0xE9, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x01, 0x87, 0x00, 0x66, + 0x0F, 0x01, 0x80, 0x00, 0xFF, 0x3B, 0xEE, 0x3B, 0x8E, 0xE3, 0xBB, 0xEF, 0xF3, 0xB8, 0xE7, 0x38, 0xEE, 0x3B, 0x87, + 0xC9, 0xE3, 0x00, 0xFF, 0xAC, 0x30, 0xC3, 0x0C, 0x30, 0xC0, 0x06, 0x0F, 0x07, 0x00, 0x03, 0xF3, 0xBB, 0x81, 0xC0, + 0x70, 0x1E, 0x03, 0xC0, 0x70, 0x38, 0x1F, 0x9F, 0xFE, 0x0C, 0x38, 0x60, 0x07, 0xEE, 0xF8, 0x38, 0x3C, 0x1C, 0x1B, + 0x7F, 0xE0, 0x0C, 0x0F, 0x0D, 0x80, 0x03, 0xF3, 0xBB, 0x81, 0xC0, 0x70, 0x1E, 0x03, 0xC0, 0x70, 0x38, 0x1F, 0x9F, + 0xFE, 0x18, 0x79, 0x90, 0x07, 0xEE, 0xF8, 0x38, 0x3C, 0x1C, 0x1B, 0x7F, 0xE0, 0x3F, 0x3B, 0xB8, 0x1C, 0x07, 0x01, + 0xE0, 0x3C, 0x07, 0x03, 0x81, 0xF9, 0xFF, 0xE1, 0xC0, 0xE0, 0xF0, 0x7E, 0xEF, 0x83, 0x83, 0xC1, 0xC1, 0xB7, 0xFE, + 0x70, 0xE1, 0xC0, 0x36, 0x0F, 0x03, 0x00, 0x03, 0xF3, 0xBB, 0x81, 0xC0, 0x70, 0x1E, 0x03, 0xC0, 0x70, 0x38, 0x1F, + 0x9F, 0xFE, 0x6C, 0x78, 0x60, 0x07, 0xEE, 0xF8, 0x38, 0x3C, 0x1C, 0x1B, 0x7F, 0xE0, 0xFF, 0xC3, 0x00, 0xC0, 0x30, + 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x1C, 0x07, 0x03, 0xC0, 0xC3, 0x0C, 0x3F, 0xC3, 0x0C, + 0x30, 0xC3, 0x0E, 0xDF, 0x30, 0xE7, 0x80, 0x36, 0x07, 0x80, 0xC0, 0x00, 0xFF, 0xC3, 0x00, 0xC0, 0x30, 0x0C, 0x03, + 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x1B, 0x6D, 0xB0, 0xFF, 0x0C, 0x30, 0xC3, 0x0C, 0x38, 0x7C, 0xFF, + 0xC3, 0x00, 0xC0, 0x30, 0x0C, 0x1F, 0xC0, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0xC3, 0x0C, 0x3F, 0xC3, 0x0C, + 0x3F, 0xC3, 0x0E, 0x1F, 0x3F, 0x0F, 0xC0, 0x00, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, + 0xE1, 0xF8, 0x67, 0xF8, 0xFC, 0x7E, 0x7E, 0x00, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x3F, + 0x00, 0x00, 0x03, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x87, 0xE1, 0x9F, 0xE3, 0xF0, 0x7E, + 0x00, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x3E, 0x07, 0x80, 0x03, 0x07, 0xC1, 0xF0, 0x7C, + 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x87, 0xE1, 0x9F, 0xE3, 0xF0, 0x7C, 0x3C, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, + 0xC7, 0xE7, 0xF7, 0x7F, 0x1E, 0x07, 0x81, 0xE0, 0x78, 0x00, 0x30, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, + 0xC1, 0xF8, 0x7E, 0x19, 0xFE, 0x3F, 0x00, 0x3C, 0x3C, 0x3C, 0x3C, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, + 0xF7, 0x7F, 0x0F, 0x87, 0xE1, 0xF0, 0x00, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, + 0x67, 0xF8, 0xFC, 0x1F, 0x3F, 0x3C, 0x00, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0xC1, 0xF0, 0x7C, + 0x1F, 0x07, 0xC1, 0xF0, 0x7C, 0x1F, 0x07, 0xE1, 0xF8, 0x67, 0xF8, 0xFC, 0x0E, 0x03, 0x00, 0xE0, 0xC7, 0xC7, 0xC7, + 0xC7, 0xC7, 0xC7, 0xE7, 0xF7, 0x7F, 0x0F, 0x0E, 0x0F, 0x01, 0x80, 0x03, 0xC0, 0x06, 0x40, 0x00, 0x00, 0xE0, 0x07, + 0xE1, 0x86, 0xE3, 0xC6, 0x63, 0xC6, 0x73, 0xCE, 0x77, 0xCE, 0x76, 0xEC, 0x36, 0x6C, 0x3E, 0x7C, 0x3C, 0x7C, 0x3C, + 0x38, 0x1C, 0x38, 0x07, 0x00, 0x7C, 0x03, 0x60, 0x00, 0x0E, 0x71, 0xF3, 0x9D, 0x9C, 0xEC, 0xF6, 0x7D, 0xB3, 0xEF, + 0x8F, 0x78, 0x79, 0xC1, 0x8E, 0x00, 0x0C, 0x03, 0xC0, 0x4C, 0x00, 0x0E, 0x1D, 0xC3, 0x9C, 0xE1, 0xDC, 0x3F, 0x03, + 0xC0, 0x78, 0x06, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x60, 0x1C, 0x1F, 0x0D, 0x80, 0x0E, 0x37, 0x19, 0x9C, 0xCE, 0x76, + 0x1B, 0x0F, 0x83, 0x81, 0xC0, 0xE2, 0xE1, 0xE0, 0x3F, 0x07, 0xE0, 0x00, 0x70, 0xEE, 0x1C, 0xE7, 0x0E, 0xE1, 0xF8, + 0x1E, 0x03, 0xC0, 0x30, 0x06, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x06, 0x03, 0x80, 0xC0, 0x00, 0xFF, 0x80, 0xE0, 0x38, + 0x1C, 0x0E, 0x07, 0x01, 0xC0, 0xE0, 0x70, 0x1C, 0x0E, 0x03, 0xFF, 0x0C, 0x1C, 0x18, 0x00, 0xFF, 0x0E, 0x0E, 0x1C, + 0x38, 0x38, 0x70, 0xE0, 0xFF, 0x0C, 0x03, 0x00, 0x03, 0xFE, 0x03, 0x80, 0xE0, 0x70, 0x38, 0x1C, 0x07, 0x03, 0x81, + 0xC0, 0x70, 0x38, 0x0F, 0xFC, 0x18, 0x18, 0x00, 0xFF, 0x0E, 0x0E, 0x1C, 0x38, 0x38, 0x70, 0xE0, 0xFF, 0x33, 0x07, + 0x80, 0xC0, 0x00, 0xFF, 0x80, 0xE0, 0x38, 0x1C, 0x0E, 0x07, 0x01, 0xC0, 0xE0, 0x70, 0x1C, 0x0E, 0x03, 0xFF, 0x66, + 0x3C, 0x18, 0x00, 0xFF, 0x0E, 0x0E, 0x1C, 0x38, 0x38, 0x70, 0xE0, 0xFF, 0x1D, 0xFE, 0x30, 0xC3, 0x0C, 0x30, 0xC3, + 0x0C, 0x30, 0xC3, 0x00, 0x79, 0xF0, 0x38, 0x1E, 0x07, 0x00, 0x0F, 0xF7, 0x03, 0x81, 0xC0, 0xE0, 0x7F, 0x38, 0x1C, + 0x0E, 0x07, 0x03, 0x81, 0xFF, 0x7E, 0x3F, 0x00, 0x1F, 0xEE, 0x07, 0x03, 0x81, 0xC0, 0xFE, 0x70, 0x38, 0x1C, 0x0E, + 0x07, 0x03, 0xFE, 0xFF, 0xC0, 0xE0, 0x07, 0x00, 0x38, 0x01, 0xC0, 0x0F, 0xF0, 0x7B, 0xC3, 0x86, 0x1C, 0x38, 0xE1, + 0x87, 0x1C, 0x3B, 0xC0, 0x0C, 0x1C, 0x18, 0x00, 0xFF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0x1F, 0xC7, 0x99, 0xC0, 0x38, 0x06, 0x00, 0xFE, 0x18, 0x03, 0x00, 0x70, 0x0E, 0x00, 0xF3, 0x0F, 0xE0, 0x3F, + 0x3B, 0xB8, 0x1C, 0x07, 0x01, 0xE0, 0x3C, 0x07, 0x03, 0x81, 0xF9, 0xFF, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xEF, + 0xB0, 0x0E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x8E, 0x38, 0xE3, 0x80, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, + 0x06, 0x06, 0xEE, 0xFC, 0x1F, 0xE0, 0x07, 0x38, 0x01, 0xCE, 0x00, 0x73, 0x80, 0x1C, 0xE0, 0x07, 0x3F, 0xC1, 0xCE, + 0x78, 0x63, 0x86, 0x18, 0xE1, 0xCE, 0x38, 0x67, 0x0E, 0x7B, 0x83, 0xFC, 0x00, 0x00, 0x00, 0xE1, 0xC0, 0xE1, 0xC0, + 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xFF, 0xFE, 0xE1, 0xDF, 0xE1, 0xC7, 0xE1, 0xC3, 0xE1, 0xC7, 0xE1, 0xCF, 0xE1, + 0xFE, 0xFF, 0xC1, 0xC0, 0x1C, 0x01, 0xC0, 0x1C, 0x01, 0xFE, 0x1F, 0xF1, 0xC7, 0x1C, 0x31, 0xC3, 0x1C, 0x31, 0xC3, + 0x0E, 0x07, 0x81, 0xC0, 0x00, 0xE3, 0xB9, 0xCE, 0xF3, 0xF8, 0xFC, 0x3E, 0x0F, 0xC3, 0xF8, 0xEF, 0x39, 0xCE, 0x3B, + 0x87, 0x18, 0x07, 0x00, 0xE0, 0x00, 0xE0, 0xF8, 0x7E, 0x3F, 0x9F, 0xE6, 0xFB, 0xBE, 0xCF, 0xE3, 0xF8, 0xFC, 0x3E, + 0x0F, 0x83, 0x3B, 0x07, 0xC0, 0x03, 0x87, 0xE1, 0xDC, 0x77, 0x18, 0xEE, 0x3B, 0x07, 0xC1, 0xF0, 0x38, 0x0E, 0x17, + 0x0F, 0x80, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0xFF, 0x0C, 0x03, + 0x00, 0xC0, 0x0E, 0x00, 0xE0, 0x1F, 0x01, 0xB0, 0x1B, 0x83, 0xB8, 0x31, 0xC7, 0x1C, 0x7F, 0xC6, 0x0E, 0xE0, 0xEE, + 0x06, 0xFF, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0xFB, 0xBF, 0xC7, 0xE1, 0xF1, 0xF9, 0xFF, 0xE0, 0xFF, 0x3B, 0xEE, 0x3B, + 0x8E, 0xE7, 0xBF, 0xCE, 0x7B, 0x86, 0xE1, 0xB8, 0x6E, 0x7B, 0xFC, 0xFF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, + 0xE0, 0xE0, 0xE0, 0xE0, 0x1F, 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x18, 0xE1, 0x8E, 0x38, 0xE3, 0x8E, + 0x70, 0xEF, 0xFF, 0xC0, 0x3C, 0x03, 0xC0, 0x30, 0xFF, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0xF3, 0x81, 0xC0, 0xE0, 0x70, + 0x38, 0x1F, 0xF0, 0xF3, 0x8E, 0xE7, 0x38, 0xEE, 0xE0, 0xFF, 0x80, 0xFF, 0x00, 0xFC, 0x03, 0xFC, 0x0D, 0xDC, 0x3B, + 0x98, 0xE7, 0x39, 0xCE, 0x3F, 0x1C, 0x70, 0x7F, 0x3B, 0x80, 0xE0, 0x70, 0xF1, 0xF8, 0x3E, 0x07, 0x01, 0x81, 0xF9, + 0xFF, 0xE0, 0xE0, 0xF8, 0x7E, 0x3F, 0x9F, 0xE6, 0xFB, 0xBE, 0xCF, 0xE3, 0xF8, 0xFC, 0x3E, 0x0F, 0x83, 0x3F, 0x07, + 0xC0, 0x03, 0x83, 0xE1, 0xF8, 0xFE, 0x7F, 0x9B, 0xEE, 0xFB, 0x3F, 0x8F, 0xE3, 0xF0, 0xF8, 0x3E, 0x0C, 0xE3, 0xB9, + 0xCE, 0xF3, 0xF8, 0xFC, 0x3E, 0x0F, 0xC3, 0xF8, 0xEF, 0x39, 0xCE, 0x3B, 0x87, 0x1F, 0xE3, 0x9C, 0x73, 0x8E, 0x71, + 0xCE, 0x39, 0xC7, 0x38, 0xC7, 0x18, 0xE7, 0x1D, 0xC3, 0xF0, 0x70, 0x60, 0x37, 0x83, 0xBC, 0x1D, 0xF1, 0xFD, 0x8F, + 0xE4, 0xDF, 0x36, 0xF9, 0xA7, 0xC7, 0x3E, 0x38, 0xF0, 0x07, 0x80, 0x30, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xFF, + 0xFE, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0x1F, 0xC1, 0xEF, 0x1C, 0x1C, 0xE0, 0x66, 0x03, 0xB0, 0x1D, 0x80, + 0xEC, 0x07, 0x70, 0x33, 0x83, 0x8F, 0x78, 0x3F, 0x80, 0xFF, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, + 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xFE, 0x77, 0xB8, 0xFC, 0x3E, 0x1F, 0x1F, 0xBD, 0xFC, 0xE0, 0x70, 0x38, 0x1C, 0x00, + 0x02, 0x03, 0xF8, 0xF3, 0x38, 0x07, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x60, 0x0E, 0x01, 0xC0, 0x1E, 0x61, 0xFC, 0xFF, + 0xC3, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x30, 0xE1, 0xF8, 0x77, 0x1D, 0xC6, + 0x3B, 0x8E, 0xC1, 0xF0, 0x7C, 0x0E, 0x03, 0x85, 0xC3, 0xE0, 0x03, 0x80, 0x7F, 0x83, 0xBF, 0x9C, 0xE6, 0x63, 0x9D, + 0x8E, 0x76, 0x38, 0xD8, 0xE7, 0x63, 0x9D, 0xCE, 0x63, 0xBF, 0x87, 0xFC, 0x03, 0x80, 0xE1, 0xCE, 0x38, 0xEE, 0x1F, + 0x81, 0xF0, 0x1C, 0x07, 0x80, 0xF8, 0x3B, 0x8E, 0x71, 0xC7, 0x70, 0x70, 0xE1, 0xDC, 0x3B, 0x87, 0x70, 0xEE, 0x1D, + 0xC3, 0xB8, 0x77, 0x0E, 0xE1, 0xDC, 0x3B, 0x87, 0x7F, 0xF0, 0x06, 0x00, 0xC0, 0x18, 0x61, 0xD8, 0x76, 0x1D, 0x87, + 0x61, 0xD8, 0x77, 0xBC, 0xFF, 0x01, 0xC0, 0x70, 0x1C, 0x07, 0xE3, 0x8F, 0xC7, 0x1F, 0x8E, 0x3F, 0x1C, 0x7E, 0x38, + 0xFC, 0x71, 0xF8, 0xE3, 0xF1, 0xC7, 0xE3, 0x8F, 0xC7, 0x1F, 0x8E, 0x3F, 0xFF, 0xF0, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, + 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xE3, 0x8E, 0xFF, 0xFF, + 0x00, 0x03, 0x00, 0x03, 0x00, 0x03, 0xF8, 0x01, 0x80, 0x18, 0x01, 0x80, 0x18, 0x01, 0xFE, 0x19, 0xF1, 0x87, 0x18, + 0x31, 0x87, 0x19, 0xF1, 0xFE, 0xE0, 0x3F, 0x01, 0xF8, 0x0F, 0xC0, 0x7E, 0x03, 0xFF, 0x9F, 0xBE, 0xFC, 0x77, 0xE3, + 0xBF, 0x1D, 0xFB, 0xEF, 0xFE, 0x70, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0xFB, 0xBF, 0xC7, 0xE1, 0xF1, 0xFB, 0xFF, + 0xE0, 0xFE, 0x3B, 0xE0, 0x38, 0x07, 0x01, 0xCF, 0xF0, 0x1C, 0x07, 0x01, 0xC0, 0xEC, 0x7B, 0xF8, 0xE1, 0xF8, 0xE3, + 0xDC, 0xE7, 0x0E, 0xE6, 0x07, 0xEE, 0x07, 0xFE, 0x07, 0xEE, 0x07, 0xE6, 0x07, 0xE6, 0x07, 0xE7, 0x0E, 0xE3, 0xDE, + 0xE1, 0xF8, 0x3F, 0xDE, 0x76, 0x1D, 0x87, 0x61, 0xDE, 0x73, 0xFC, 0xE7, 0x39, 0xDC, 0x77, 0x1F, 0x87, 0x7E, 0x2F, + 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x1F, 0x3F, 0x18, 0x1C, 0x0F, 0xE7, 0xBB, 0x0D, 0x87, 0xC3, 0xE1, 0xF8, + 0xDE, 0xE7, 0xE0, 0xFE, 0xCE, 0xC7, 0xCE, 0xFE, 0xCF, 0xC3, 0xCF, 0xFE, 0xFF, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC0, + 0x3F, 0x8E, 0x63, 0x98, 0xE6, 0x31, 0x8C, 0x67, 0x19, 0xC6, 0xFF, 0xF0, 0x3C, 0x0C, 0x3F, 0x3F, 0x98, 0xEC, 0x7F, + 0xFB, 0x01, 0x80, 0xF6, 0x3F, 0x00, 0x67, 0x33, 0xBB, 0x8F, 0xF8, 0x3F, 0x81, 0xFC, 0x0F, 0xE0, 0xDD, 0x8C, 0xE6, + 0xE7, 0x38, 0x7E, 0x6E, 0x07, 0x0E, 0x3E, 0x0F, 0x07, 0x6F, 0xFE, 0xC7, 0xC7, 0xCF, 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, + 0xE3, 0x7E, 0x3C, 0x00, 0xC7, 0xC7, 0xCF, 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, 0xE3, 0xC7, 0xCE, 0xDC, 0xF8, 0xF8, 0xFC, + 0xDE, 0xCE, 0xC7, 0x3F, 0x9C, 0xCE, 0x67, 0x33, 0x99, 0x8C, 0xC6, 0xE3, 0xE1, 0x80, 0xE1, 0xDC, 0x3B, 0xC7, 0x69, + 0xED, 0xBD, 0xBD, 0xF3, 0xBE, 0x67, 0xC0, 0xE0, 0xC3, 0xC3, 0xC3, 0xC3, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0x3F, 0x1F, + 0xE6, 0x19, 0x86, 0xE1, 0xD8, 0x66, 0x19, 0xFE, 0x3F, 0x00, 0xFF, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, + 0xFE, 0x77, 0xB0, 0xD8, 0x6C, 0x3E, 0x1B, 0x0D, 0xFE, 0xFE, 0x60, 0x30, 0x18, 0x00, 0x3F, 0x7A, 0x60, 0x60, 0xE0, + 0x60, 0x60, 0x7B, 0x3F, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0xE3, 0x71, 0x99, 0xCC, 0xE7, 0x61, + 0xB0, 0xF8, 0x38, 0x1C, 0x0E, 0x2E, 0x1E, 0x00, 0x07, 0x00, 0x38, 0x01, 0xC0, 0x1F, 0x03, 0xFE, 0x3F, 0xF9, 0xDC, + 0xCC, 0xE7, 0xE7, 0x3B, 0x39, 0x99, 0xCC, 0xFF, 0xE3, 0xFE, 0x03, 0x80, 0x1C, 0x00, 0xE0, 0xE3, 0x3B, 0x8F, 0x87, + 0x81, 0xC1, 0xF1, 0xF8, 0xCE, 0xE3, 0x80, 0xC7, 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x3B, 0x1D, 0x8E, 0xFF, 0x80, 0xC0, + 0x60, 0xE3, 0xE3, 0xE3, 0xE3, 0x7F, 0x3F, 0x03, 0x03, 0x03, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, 0xC6, 0x3C, 0x63, + 0xC6, 0x3C, 0x63, 0xFF, 0xF0, 0xC6, 0x36, 0x31, 0xB1, 0x8D, 0x8C, 0x6C, 0x63, 0x63, 0x1B, 0x18, 0xD8, 0xC6, 0xFF, + 0xF8, 0x00, 0xC0, 0x06, 0xF8, 0x0E, 0x03, 0x80, 0xFE, 0x3B, 0xCE, 0x33, 0x8C, 0xEF, 0x3F, 0x80, 0xC0, 0xF8, 0x1F, + 0x03, 0xFF, 0x7E, 0xEF, 0x8F, 0xF1, 0xFE, 0x77, 0xFE, 0xE0, 0xC0, 0xC0, 0xC0, 0xFE, 0xEF, 0xC7, 0xC7, 0xCF, 0xFE, + 0x7C, 0x6E, 0x07, 0x07, 0x3F, 0x07, 0x07, 0xDE, 0xFC, 0xC7, 0xEC, 0xFE, 0xCC, 0x7C, 0xC3, 0xFC, 0x3D, 0xC3, 0xCC, + 0x7C, 0xFE, 0xC7, 0xE0, 0x3F, 0x73, 0x63, 0x63, 0x73, 0x3F, 0x33, 0x73, 0xE3, 0x18, 0x0E, 0x03, 0x00, 0x03, 0xF3, + 0xF9, 0x8E, 0xC7, 0xFF, 0xB0, 0x18, 0x0F, 0x63, 0xF0, 0x3F, 0x1F, 0x80, 0x07, 0xE7, 0xF3, 0x1D, 0x8F, 0xFF, 0x60, + 0x30, 0x1E, 0xC7, 0xE0, 0x60, 0x30, 0x3F, 0x0C, 0x07, 0xF3, 0xB9, 0x8E, 0xC7, 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x38, + 0x1C, 0x0E, 0x0E, 0x1C, 0xF3, 0x80, 0xFF, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xC0, 0x3F, 0x7B, 0x60, 0x60, 0xFE, 0x60, + 0x60, 0x7B, 0x3F, 0x7E, 0xEF, 0x83, 0x83, 0xC1, 0xC1, 0xB7, 0xFE, 0xD8, 0x0D, 0xB6, 0xDB, 0x6C, 0xFF, 0xF0, 0x0C, + 0x30, 0xC3, 0x0C, 0x30, 0xC3, 0x0C, 0x18, 0x60, 0x00, 0x18, 0x61, 0x86, 0x18, 0x61, 0x86, 0x18, 0x63, 0x9C, 0x3F, + 0x80, 0x73, 0x00, 0xE6, 0x01, 0xCF, 0xE3, 0x99, 0xC6, 0x31, 0xCC, 0x63, 0xB8, 0xCE, 0xE1, 0xFC, 0xC3, 0x06, 0x18, + 0x30, 0xC1, 0xFF, 0xEC, 0x33, 0xE1, 0x8F, 0x0C, 0x78, 0x67, 0xC3, 0xF0, 0x60, 0x30, 0x3F, 0x0C, 0x07, 0xF3, 0xB9, + 0x8E, 0xC7, 0x63, 0xB1, 0xD8, 0xEC, 0x76, 0x38, 0x0C, 0x1C, 0x18, 0x00, 0xC7, 0xCE, 0xDC, 0xF8, 0xF8, 0xFC, 0xDE, + 0xCE, 0xC7, 0x30, 0x38, 0x18, 0x00, 0xC7, 0xC7, 0xCF, 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, 0xE3, 0x36, 0x1F, 0x00, 0x1C, + 0x6E, 0x33, 0x39, 0x9C, 0xEC, 0x36, 0x1F, 0x07, 0x03, 0x81, 0xC5, 0xC3, 0xC0, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, + 0xC7, 0xC7, 0xFF, 0x18, 0x18, 0x18, 0x01, 0x80, 0xFF, 0x81, 0x80, 0x18, 0x01, 0x80, 0x1F, 0xE1, 0x9F, 0x18, 0x71, + 0x83, 0x18, 0x71, 0x9F, 0x1F, 0xE0, 0x38, 0x0E, 0x03, 0x80, 0xE0, 0xFF, 0x0E, 0x03, 0x80, 0xFE, 0x3B, 0xCE, 0x33, + 0x8C, 0xEF, 0x3F, 0x80, 0x1F, 0xC1, 0xE7, 0x1C, 0x1C, 0xE0, 0x66, 0x03, 0xBF, 0xFD, 0xDF, 0xEC, 0x07, 0x70, 0x33, + 0x83, 0x8F, 0x38, 0x3F, 0x80, 0x3F, 0x1F, 0xE6, 0x19, 0xF6, 0xFF, 0xD8, 0x66, 0x19, 0xCE, 0x3F, 0x00, 0xE0, 0x7B, + 0x83, 0xE6, 0x0C, 0x1C, 0x70, 0x71, 0xC0, 0xC6, 0x03, 0xB8, 0x0E, 0xE0, 0x1F, 0x00, 0x7C, 0x00, 0xE0, 0x03, 0x80, + 0xE3, 0xF8, 0xC6, 0x71, 0x98, 0x76, 0x0F, 0x83, 0xC0, 0xF0, 0x1C, 0x00, 0x3F, 0x01, 0xE0, 0x00, 0x0E, 0x0C, 0xE1, + 0xCE, 0x3C, 0xE7, 0xCE, 0x6C, 0xEE, 0xCE, 0xCC, 0xF8, 0xCF, 0x8C, 0xF0, 0xCE, 0x0C, 0xE0, 0xF0, 0x07, 0x00, 0xE0, + 0x0E, 0x7E, 0x0F, 0x00, 0x03, 0x1C, 0xC7, 0x33, 0xCC, 0xF3, 0x6C, 0xF3, 0x3C, 0xCE, 0x33, 0x8F, 0x01, 0xC0, 0xE0, + 0x38, 0x70, 0x1F, 0x81, 0xC0, 0x38, 0x07, 0x00, 0xFF, 0x1D, 0xF3, 0x8E, 0x70, 0xCE, 0x19, 0xC7, 0x39, 0xE7, 0xF8, + 0x70, 0x38, 0x3F, 0x0E, 0x07, 0xF3, 0xBD, 0xCE, 0xEF, 0x7F, 0x00, 0xFE, 0x77, 0xB8, 0xFC, 0x3E, 0x9F, 0xFF, 0xBF, + 0xFE, 0xE7, 0x71, 0xB8, 0x1C, 0x00, 0xFE, 0x77, 0xB0, 0xD8, 0x6C, 0x3E, 0x1B, 0x7D, 0xFE, 0xFE, 0x63, 0x30, 0xD8, + 0x00, 0x07, 0x07, 0xFF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0x0C, 0x3F, 0xF0, 0xC3, + 0x0C, 0x30, 0xC3, 0x0C, 0x00, 0x7F, 0xB8, 0x1C, 0x0E, 0x07, 0x07, 0xE1, 0xC0, 0xE0, 0x70, 0x38, 0x1C, 0x0E, 0x00, + 0x7E, 0xE1, 0xC3, 0x8F, 0xCE, 0x1C, 0x38, 0x70, 0xFF, 0x70, 0x38, 0x1C, 0x0F, 0xE7, 0x3B, 0x8F, 0xC3, 0xE1, 0xF0, + 0xF8, 0x7C, 0x30, 0x38, 0x38, 0x78, 0xFE, 0xC0, 0xC0, 0xFE, 0xEE, 0xC7, 0xC3, 0xC3, 0xC7, 0x07, 0x0E, 0x1E, 0xF3, + 0x8E, 0x73, 0x9C, 0x3B, 0xB8, 0x1F, 0xF0, 0x0F, 0xF0, 0x07, 0xE0, 0x0F, 0xF0, 0x1B, 0xB0, 0x3B, 0x98, 0x73, 0x9C, + 0x73, 0x8C, 0xE3, 0x8F, 0x00, 0x03, 0x00, 0x03, 0x00, 0x03, 0x67, 0x31, 0xDD, 0xC3, 0xFE, 0x07, 0xF0, 0x1F, 0x80, + 0x7F, 0x03, 0x7E, 0x19, 0xDC, 0xE7, 0x3C, 0x00, 0x70, 0x01, 0xC0, 0x7F, 0x3B, 0x80, 0xE0, 0x70, 0xF1, 0xF8, 0x3E, + 0x07, 0x01, 0x81, 0xF9, 0xFF, 0xE1, 0xC0, 0xE0, 0xF0, 0x7E, 0x6E, 0x07, 0x0E, 0x3E, 0x0F, 0x07, 0x6F, 0xFE, 0x18, + 0x1C, 0x38, 0xE3, 0x9C, 0xE3, 0x98, 0x76, 0x0F, 0xC1, 0xF0, 0x3E, 0x07, 0x60, 0xEE, 0x1C, 0xE3, 0x8E, 0x70, 0xE0, + 0x0C, 0x01, 0x80, 0x30, 0xC7, 0x67, 0x37, 0x1F, 0x0F, 0x06, 0xC3, 0x71, 0x9C, 0xC7, 0x81, 0xC0, 0xE0, 0x70, 0xE0, + 0xFC, 0x3B, 0xEE, 0x7F, 0x8F, 0xE1, 0xF8, 0x3F, 0x87, 0xF8, 0xFB, 0x9C, 0x33, 0x87, 0x70, 0x70, 0xC3, 0xB1, 0xCD, + 0xE3, 0x78, 0xFC, 0x37, 0x8D, 0xF3, 0x0E, 0xC1, 0x80, 0x71, 0xFF, 0x79, 0xDE, 0x3F, 0x87, 0xE0, 0xF8, 0x1F, 0x83, + 0xF8, 0x77, 0x8E, 0x79, 0xC7, 0x38, 0x70, 0x73, 0xBF, 0xC7, 0xE1, 0xF0, 0x7C, 0x1F, 0x87, 0x71, 0xCE, 0x73, 0x80, + 0xF8, 0x70, 0xC7, 0x06, 0x70, 0x37, 0x01, 0xF0, 0x0F, 0x80, 0x7E, 0x03, 0x78, 0x19, 0xE0, 0xC7, 0x06, 0x1C, 0x30, + 0x70, 0xF9, 0xC7, 0x70, 0xFC, 0x1F, 0x03, 0xE0, 0x7E, 0x0F, 0xE1, 0xDE, 0x39, 0xC0, 0xE1, 0xDC, 0x3B, 0x87, 0x70, + 0xEE, 0x1D, 0xFF, 0xB8, 0x77, 0x0E, 0xE1, 0xDC, 0x3B, 0x87, 0x70, 0xF0, 0x06, 0x00, 0xC0, 0x18, 0xC3, 0x61, 0xB0, + 0xD8, 0x6F, 0xF6, 0x1B, 0x0D, 0x86, 0xC3, 0x80, 0xC0, 0x60, 0xE1, 0xFF, 0x0E, 0x38, 0x71, 0xC3, 0x8E, 0x1C, 0x7F, + 0xE3, 0x87, 0x1C, 0x38, 0xE1, 0xC7, 0x0E, 0x38, 0x71, 0xC3, 0x80, 0xC3, 0xF0, 0xCC, 0x33, 0x0C, 0xFF, 0x30, 0xCC, + 0x33, 0x0C, 0xC3, 0x00, 0xFF, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xC0, 0xE1, 0xFC, 0xE1, 0xFE, 0xE1, 0xC7, 0xE1, + 0xC3, 0xE1, 0xC3, 0xE1, 0xC3, 0xE1, 0xC3, 0xE1, 0xC7, 0x00, 0x07, 0x00, 0x0E, 0x00, 0x3C, 0xFF, 0x03, 0x1C, 0x0C, + 0x70, 0x31, 0xFC, 0xC7, 0x7B, 0x1C, 0x6C, 0x71, 0xB1, 0xC7, 0xC7, 0x18, 0x00, 0x60, 0x03, 0x80, 0x3C, 0x1F, 0xC1, + 0xE6, 0x1C, 0x00, 0xE0, 0x06, 0x1E, 0x31, 0xF9, 0x9C, 0xEC, 0xE7, 0x77, 0x3B, 0xB9, 0x8F, 0xDC, 0x3F, 0xC0, 0x38, + 0x00, 0xF0, 0x03, 0xC0, 0x3E, 0x1E, 0x06, 0x79, 0xBF, 0xEC, 0xDB, 0x36, 0xDD, 0xFE, 0x3F, 0x01, 0xE0, 0x3C, 0x02, + 0x03, 0xF8, 0xF3, 0x38, 0x07, 0x00, 0xC0, 0x18, 0x03, 0x00, 0x60, 0x0E, 0x01, 0xC0, 0x1E, 0x61, 0xFC, 0x0E, 0x01, + 0xC0, 0x78, 0x3F, 0x7A, 0x60, 0x60, 0xE0, 0x60, 0x60, 0x7B, 0x3F, 0x0C, 0x0E, 0x1C, 0xFF, 0xC3, 0x00, 0xC0, 0x30, + 0x0C, 0x03, 0x00, 0xC0, 0x30, 0x0C, 0x03, 0x00, 0xC0, 0x3C, 0x07, 0x01, 0xC0, 0x70, 0xFF, 0x18, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x1C, 0x0C, 0x0C, 0xE1, 0xDC, 0x39, 0xCE, 0x1D, 0xC3, 0xF0, 0x3C, 0x07, 0x80, 0x60, 0x0C, 0x01, + 0x80, 0x30, 0x06, 0x00, 0xE3, 0xF1, 0x99, 0xCC, 0xE7, 0x61, 0xF0, 0xF8, 0x78, 0x1C, 0x0C, 0x06, 0x03, 0x00, 0xE1, + 0xDC, 0x39, 0xCE, 0x19, 0xC3, 0xF0, 0x3C, 0x07, 0x83, 0xFC, 0x0C, 0x01, 0x80, 0x30, 0x06, 0x00, 0xE3, 0xF1, 0x99, + 0xCE, 0xE7, 0x61, 0xF0, 0xF8, 0x78, 0x7F, 0x0C, 0x06, 0x03, 0x00, 0xE1, 0xCE, 0x38, 0xEE, 0x1F, 0x81, 0xF0, 0x1C, + 0x07, 0x80, 0xF8, 0x3B, 0x8E, 0x71, 0xC7, 0x70, 0x70, 0x06, 0x00, 0xC0, 0x18, 0xE3, 0x3B, 0x8F, 0x87, 0x81, 0xC1, + 0xF1, 0xF8, 0xCE, 0xE3, 0x80, 0xC0, 0x60, 0xFF, 0x98, 0x38, 0x30, 0x70, 0x60, 0xE0, 0xC1, 0xC1, 0x83, 0x83, 0x07, + 0x06, 0x0E, 0x0C, 0x1C, 0x18, 0x38, 0x30, 0x70, 0x60, 0xFF, 0xF0, 0x00, 0xE0, 0x01, 0xC0, 0x03, 0x80, 0xFE, 0xC7, + 0x18, 0xE3, 0x1C, 0x63, 0x8C, 0x71, 0x8E, 0x31, 0xC6, 0x3F, 0xE0, 0x0C, 0x01, 0x80, 0x61, 0xCC, 0x39, 0x87, 0x30, + 0xE6, 0x1C, 0xC3, 0x9E, 0xF1, 0xFE, 0x01, 0xC0, 0x38, 0x07, 0x00, 0xF0, 0x06, 0x00, 0xC0, 0x18, 0x03, 0xE3, 0x71, + 0xB8, 0xDC, 0x67, 0xF1, 0xF8, 0x0C, 0x06, 0x03, 0x80, 0xC0, 0x60, 0x61, 0xD8, 0x76, 0x1D, 0x87, 0x61, 0xDB, 0x77, + 0xFC, 0xFF, 0x0D, 0xC3, 0x70, 0x1C, 0x07, 0xE3, 0xE3, 0xFB, 0xFB, 0x7F, 0x7F, 0x1B, 0x1B, 0x03, 0xE0, 0x70, 0x38, + 0x1C, 0x0F, 0xF7, 0xBF, 0x8F, 0xC3, 0xE1, 0xF0, 0xF8, 0x7C, 0x30, 0xC0, 0xC0, 0xC0, 0xC0, 0xFE, 0xEE, 0xC7, 0xC7, + 0xC7, 0xC7, 0xC7, 0xC7, 0xC7, 0x07, 0xF0, 0x3F, 0xE0, 0xC3, 0xB7, 0x06, 0xFC, 0x1D, 0xFF, 0xF1, 0x80, 0x07, 0x00, + 0x1C, 0x00, 0x30, 0x00, 0xF3, 0x81, 0xFE, 0x0F, 0xC3, 0xBB, 0x63, 0xFC, 0x7F, 0xFE, 0x70, 0x06, 0x00, 0xF6, 0x0F, + 0xC0, 0x07, 0xF0, 0x3F, 0xE0, 0xC3, 0xB7, 0x06, 0xFC, 0x1D, 0xFF, 0xF1, 0x80, 0x07, 0x00, 0x1C, 0x00, 0x30, 0x00, + 0xF3, 0x81, 0xFE, 0x01, 0xC0, 0x07, 0x00, 0x1C, 0x00, 0x0F, 0xC3, 0xBB, 0x63, 0xFC, 0x7F, 0xFE, 0x70, 0x06, 0x00, + 0xF6, 0x0F, 0xC0, 0x60, 0x0C, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x06, 0xE0, 0x0F, 0x80, 0x00, 0x07, 0x9C, 0x77, + 0x39, 0xC7, 0x77, 0x07, 0xFC, 0x07, 0xF8, 0x07, 0xE0, 0x1F, 0xE0, 0x6E, 0xE1, 0xDC, 0xC7, 0x39, 0xCE, 0x71, 0xF8, + 0xE3, 0x80, 0x0D, 0x80, 0x7C, 0x00, 0x00, 0xCE, 0x67, 0x77, 0x1F, 0xF0, 0x7F, 0x03, 0xF8, 0x1F, 0xC1, 0xBB, 0x19, + 0xCD, 0xCE, 0x70, 0xE3, 0xF3, 0xBB, 0x9F, 0x8F, 0xC7, 0xF3, 0xBD, 0xC7, 0xE1, 0xF0, 0xF8, 0x7C, 0x30, 0x38, 0xF8, + 0x78, 0xC7, 0xCE, 0xDC, 0xF8, 0xFE, 0xDE, 0xC7, 0xC3, 0xC3, 0x07, 0x0E, 0x1E, 0x1F, 0xE0, 0xE7, 0x07, 0x38, 0x39, + 0xC1, 0xCE, 0x0E, 0x70, 0x73, 0x83, 0x1C, 0x18, 0xE1, 0xC7, 0x1C, 0x39, 0xC1, 0xE0, 0x03, 0x00, 0x38, 0x01, 0x80, + 0x3F, 0x87, 0x30, 0xE6, 0x1C, 0xC3, 0x98, 0x63, 0x0C, 0x63, 0x8C, 0xE1, 0xE0, 0x1C, 0x07, 0x00, 0xE0, 0xE1, 0xF8, + 0x7E, 0x1F, 0x87, 0xE1, 0xFF, 0xFE, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0x01, 0xC0, 0x70, 0x38, 0xC3, 0xC3, + 0xC3, 0xC3, 0xFF, 0xC3, 0xC3, 0xC3, 0xC3, 0x03, 0x07, 0x0E, 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x1C, 0xE1, 0xCF, 0xFC, + 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x1C, 0xE1, 0xCE, 0x1E, 0x00, 0x60, 0x0E, 0x00, 0xC0, 0xC3, 0x30, 0xCC, 0x33, 0x0C, + 0xFF, 0x30, 0xCC, 0x33, 0x0C, 0xC3, 0x80, 0x60, 0x38, 0x0C, 0xC3, 0xE1, 0xF0, 0xF8, 0x7C, 0x3E, 0x1F, 0xDE, 0xFF, + 0x03, 0x81, 0xC0, 0xE0, 0xF0, 0x60, 0x30, 0x18, 0xE3, 0xE3, 0xE3, 0xE3, 0x7F, 0x3F, 0x03, 0x03, 0x0F, 0x0E, 0x0E, + 0x60, 0x31, 0xE0, 0xE3, 0xC1, 0xC7, 0xC7, 0xCD, 0x8F, 0x99, 0x37, 0x33, 0x6E, 0x66, 0x9C, 0xC7, 0x39, 0x8E, 0x33, + 0x00, 0x66, 0x00, 0xF0, 0x00, 0xC0, 0x03, 0x80, 0x06, 0x00, 0xE1, 0xCE, 0x1C, 0xF1, 0xCD, 0x3C, 0xDB, 0xCD, 0xEE, + 0xCE, 0xEC, 0xCE, 0xC0, 0xF0, 0x07, 0x00, 0x70, 0x06, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1B, 0x01, 0xF0, 0x00, 0x00, + 0xE0, 0x0E, 0x01, 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x36, + 0x3E, 0x00, 0x7E, 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x1B, 0x81, 0xB8, 0x00, 0x00, 0xE0, 0x0E, 0x01, + 0xF0, 0x1B, 0x01, 0xB8, 0x3B, 0x83, 0x1C, 0x71, 0xC7, 0xFC, 0x60, 0xEE, 0x0E, 0xE0, 0x60, 0x36, 0x36, 0x00, 0x7E, + 0x2F, 0x03, 0x7F, 0x77, 0xE3, 0xE3, 0x73, 0x7F, 0x03, 0xFE, 0x03, 0xC0, 0x07, 0xC0, 0x06, 0xC0, 0x0E, 0xC0, 0x1C, + 0xFE, 0x18, 0xC0, 0x38, 0xC0, 0x3F, 0xC0, 0x70, 0xC0, 0xE0, 0xC0, 0xE0, 0xFF, 0x7F, 0xF8, 0xBF, 0xF0, 0x79, 0xC0, + 0xC3, 0x7F, 0xFF, 0xDC, 0x0E, 0x38, 0x1D, 0xF6, 0x7F, 0xF8, 0x7E, 0x1E, 0x00, 0x1F, 0xEE, 0x07, 0x03, 0x81, 0xC0, + 0xFE, 0x70, 0x38, 0x1C, 0x0E, 0x07, 0x03, 0xFE, 0x3F, 0x0F, 0x00, 0x07, 0xE7, 0xF3, 0x1D, 0x8F, 0xFF, 0x60, 0x30, + 0x1E, 0xC7, 0xE0, 0x3F, 0x86, 0x78, 0x03, 0x80, 0x70, 0x06, 0x00, 0xDF, 0xFB, 0x03, 0x60, 0xEE, 0x1C, 0xEF, 0x0F, + 0xC0, 0x7E, 0x37, 0x80, 0xE0, 0x7F, 0xFB, 0x1D, 0x8E, 0xFE, 0x3E, 0x00, 0x1B, 0x83, 0x70, 0x00, 0x1F, 0xC3, 0x3C, + 0x01, 0xC0, 0x38, 0x03, 0x00, 0x6F, 0xFD, 0x81, 0xB0, 0x77, 0x0E, 0x77, 0x87, 0xE0, 0x37, 0x1B, 0x80, 0x0F, 0xC6, + 0xF0, 0x1C, 0x0F, 0xFF, 0x63, 0xB1, 0xDF, 0xC7, 0xC0, 0x06, 0xE0, 0x0D, 0xC0, 0x00, 0x07, 0x9C, 0x77, 0x39, 0xC7, + 0x77, 0x07, 0xFC, 0x07, 0xF8, 0x07, 0xE0, 0x1F, 0xE0, 0x6E, 0xE1, 0xDC, 0xC7, 0x39, 0xCE, 0x71, 0xF8, 0xE3, 0x80, + 0x0D, 0x80, 0x6C, 0x00, 0x00, 0xCE, 0x67, 0x77, 0x1F, 0xF0, 0x7F, 0x03, 0xF8, 0x1F, 0xC1, 0xBB, 0x19, 0xCD, 0xCE, + 0x70, 0x76, 0x3B, 0x00, 0x0F, 0xE7, 0x70, 0x1C, 0x0E, 0x1E, 0x3F, 0x07, 0xC0, 0xE0, 0x30, 0x3F, 0x3F, 0xFC, 0x7E, + 0x7E, 0x00, 0x7E, 0x6E, 0x07, 0x0E, 0x3E, 0x0F, 0x07, 0x6F, 0xFE, 0x7F, 0x83, 0x81, 0xC1, 0xC1, 0xC0, 0xF8, 0x3E, + 0x07, 0x01, 0x81, 0xF9, 0xFF, 0xE0, 0x7F, 0x03, 0x81, 0x81, 0xC0, 0xC0, 0xF0, 0x3C, 0x07, 0x03, 0x81, 0xDB, 0xCF, + 0xC0, 0x3F, 0x00, 0x00, 0x03, 0x83, 0xE1, 0xF8, 0xFE, 0x7F, 0x9B, 0xEE, 0xFB, 0x3F, 0x8F, 0xE3, 0xF0, 0xF8, 0x3E, + 0x0C, 0x7E, 0x00, 0x00, 0xC7, 0xC7, 0xCF, 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, 0xE3, 0x3F, 0x0F, 0xC0, 0x03, 0x83, 0xE1, + 0xF8, 0xFE, 0x7F, 0x9B, 0xEE, 0xFB, 0x3F, 0x8F, 0xE3, 0xF0, 0xF8, 0x3E, 0x0C, 0x7E, 0x7E, 0x00, 0xC7, 0xC7, 0xCF, + 0xCF, 0xDB, 0xF3, 0xF3, 0xE3, 0xE3, 0x0D, 0xC0, 0x6E, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, + 0x60, 0x3B, 0x01, 0xD8, 0x0E, 0xE0, 0x67, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x3F, 0x0F, 0xC0, 0x00, 0xFC, 0x7F, 0x98, + 0x66, 0x1B, 0x87, 0x61, 0x98, 0x67, 0xF8, 0xFC, 0x1F, 0xC1, 0xEF, 0x1C, 0x1C, 0xE0, 0x66, 0x03, 0xBF, 0xFD, 0x80, + 0xEC, 0x07, 0x70, 0x73, 0x83, 0x8F, 0x78, 0x3F, 0x80, 0x3F, 0x1F, 0xE6, 0x19, 0x86, 0xFF, 0xD8, 0x66, 0x19, 0xFE, + 0x3F, 0x00, 0x0D, 0xC0, 0x6E, 0x00, 0x00, 0x3F, 0x83, 0xDE, 0x38, 0x39, 0xC0, 0xCC, 0x07, 0x7F, 0xFB, 0x01, 0xD8, + 0x0E, 0xE0, 0xE7, 0x07, 0x1E, 0xF0, 0x7F, 0x00, 0x3F, 0x0F, 0xC0, 0x00, 0xFC, 0x7F, 0x98, 0x66, 0x1B, 0xFF, 0x61, + 0x98, 0x67, 0xF8, 0xFC, 0x76, 0x1D, 0x80, 0x03, 0xF8, 0xEF, 0x80, 0xE0, 0x1C, 0x07, 0x3F, 0xC0, 0x70, 0x1C, 0x07, + 0x03, 0xB1, 0xEF, 0xE0, 0x6C, 0x6C, 0x00, 0x7C, 0x6E, 0x07, 0x07, 0x3F, 0x07, 0x07, 0xDE, 0xFC, 0x3F, 0x00, 0x00, + 0x03, 0x87, 0xE1, 0xDC, 0x77, 0x18, 0xEE, 0x3B, 0x07, 0xC1, 0xF0, 0x38, 0x0E, 0x17, 0x0F, 0x80, 0x3E, 0x00, 0x00, + 0x1C, 0x6E, 0x33, 0x39, 0x9C, 0xEC, 0x36, 0x1F, 0x07, 0x03, 0x81, 0xC5, 0xC3, 0xC0, 0x3B, 0x0E, 0xC0, 0x03, 0x87, + 0xE1, 0xDC, 0x77, 0x18, 0xEE, 0x3B, 0x07, 0xC1, 0xF0, 0x38, 0x0E, 0x17, 0x0F, 0x80, 0x36, 0x1B, 0x00, 0x1C, 0x6E, + 0x33, 0x39, 0x9C, 0xEC, 0x36, 0x1F, 0x07, 0x03, 0x81, 0xC5, 0xC3, 0xC0, 0x0D, 0x87, 0xE1, 0xB0, 0x00, 0xE1, 0xF8, + 0x77, 0x1D, 0xC6, 0x3B, 0x8E, 0xC1, 0xF0, 0x7C, 0x0E, 0x03, 0x85, 0xC3, 0xE0, 0x1B, 0x1F, 0x8D, 0x80, 0x0E, 0x37, + 0x19, 0x9C, 0xCE, 0x76, 0x1B, 0x0F, 0x83, 0x81, 0xC0, 0xE2, 0xE1, 0xE0, 0x3B, 0x0E, 0xC0, 0x01, 0x87, 0x61, 0xD8, + 0x76, 0x1D, 0x87, 0x61, 0xDE, 0xF3, 0xFC, 0x07, 0x01, 0xC0, 0x70, 0x1C, 0x76, 0x76, 0x00, 0xE3, 0xE3, 0xE3, 0xE3, + 0x7F, 0x3F, 0x03, 0x03, 0x03, 0xFF, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xE0, 0xF0, 0x30, 0x30, + 0x30, 0xFF, 0x0C, 0x30, 0xC3, 0x0C, 0x30, 0xF1, 0xC7, 0x00, 0x1D, 0x80, 0xEC, 0x00, 0x01, 0xC0, 0x7E, 0x03, 0xF0, + 0x1F, 0x80, 0xFC, 0x07, 0xFF, 0x3F, 0x7D, 0xF8, 0xEF, 0xC7, 0x7E, 0x3B, 0xF7, 0xDF, 0xFC, 0xE0, 0x1B, 0x03, 0x60, + 0x00, 0x60, 0x7C, 0x0F, 0x81, 0xFF, 0xBF, 0x77, 0xC7, 0xF8, 0xFF, 0x3B, 0xFF, 0x70, 0x7F, 0xC0, 0x7F, 0xFF, 0xC0, + 0x7F, 0xFF, 0xC0, 0xDB, 0x66, 0xC0, 0x6D, 0xBD, 0x80, 0x6F, 0xF0, 0x7F, 0xEF, 0x3C, 0xF3, 0xC0, 0x7D, 0xF7, 0xDF, + 0xF3, 0xC0, 0x6C, 0xDB, 0xB7, 0xE0, 0x18, 0x18, 0x18, 0x18, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, + 0x18, 0x18, 0x18, 0x18, 0x18, 0xFF, 0x18, 0x18, 0x18, 0x18, 0xFF, 0x18, 0x18, 0x18, 0x18, 0x79, 0xF7, 0xDE, 0xE3, + 0x8F, 0xC7, 0x1C, 0x7C, 0x70, 0x03, 0x67, 0x00, 0x3B, 0xB8, 0x01, 0xDF, 0x80, 0x06, 0xD8, 0x00, 0x3F, 0xC0, 0x00, + 0x0D, 0xE7, 0xC0, 0xFF, 0xFE, 0x06, 0xCF, 0x30, 0x76, 0x79, 0x83, 0x3F, 0xFC, 0x38, 0xF3, 0xE0, 0x3B, 0x9D, 0xC7, + 0x38, 0xE0, 0xE3, 0x1C, 0x77, 0x33, 0x80, 0x03, 0x83, 0x81, 0x81, 0xC0, 0xC0, 0xE0, 0x60, 0x70, 0x30, 0x38, 0x38, + 0x18, 0x00, 0x0F, 0x87, 0xE3, 0x80, 0xE0, 0xFF, 0x0C, 0x03, 0x03, 0xFC, 0x38, 0x0E, 0x01, 0xDC, 0x3E, 0xFF, 0x86, + 0x03, 0x01, 0xE1, 0xF3, 0xE1, 0xBC, 0x3E, 0x7C, 0x36, 0x03, 0x01, 0x80, 0x08, 0x1F, 0x89, 0xC0, 0x70, 0x3F, 0xFC, + 0x38, 0x78, 0xFF, 0xB0, 0x18, 0x0E, 0x33, 0xF8, 0x60, 0xFF, 0x8F, 0x03, 0x9F, 0xE0, 0xE1, 0xE3, 0xE1, 0xC0, 0x70, + 0x1C, 0x07, 0x03, 0x80, 0x7E, 0x1B, 0xC0, 0x38, 0x06, 0x01, 0x8F, 0xE7, 0xB9, 0x86, 0xE1, 0xB8, 0x6E, 0x39, 0xDC, + 0x3E, 0x00, 0x0E, 0x01, 0xC0, 0x7C, 0x0D, 0x83, 0xB8, 0x63, 0x0C, 0x73, 0x8E, 0x60, 0xDC, 0x1F, 0x83, 0xFF, 0xF0, + 0xFF, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xF8, 0x7E, 0x1F, 0x87, 0xE1, 0xC0, 0xFF, 0xF0, + 0x1C, 0x07, 0x03, 0x80, 0xE0, 0x30, 0x38, 0x38, 0x1C, 0x1C, 0x0E, 0x0E, 0x07, 0xFC, 0xFF, 0x80, 0x03, 0x83, 0x81, + 0x81, 0xC0, 0xC0, 0xE0, 0x60, 0x70, 0x30, 0x38, 0x38, 0x18, 0x00, 0x77, 0x00, 0xC0, 0x70, 0x18, 0x06, 0x01, 0x80, + 0xEF, 0x33, 0xCC, 0x3F, 0x07, 0x81, 0xE0, 0x78, 0x0C, 0x00, 0x7F, 0xBF, 0xFC, 0xCF, 0xFF, 0x7F, 0x80, 0x1E, 0x70, + 0xC1, 0x83, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC1, 0x83, 0x06, 0x1C, 0x70, 0x7D, 0xFF, 0xC0, 0x00, 0x07, 0xDF, 0xFC, + 0x03, 0x03, 0xBF, 0xE1, 0x81, 0xC7, 0xFC, 0xC0, 0x60, 0x03, 0x87, 0x9F, 0x0E, 0x03, 0xC0, 0x78, 0x0E, 0x00, 0xFF, + 0x80, 0x70, 0x3E, 0x03, 0xC0, 0x70, 0xF3, 0xE1, 0xC0, 0x00, 0xFF, 0x80, }; static const EpdGlyph pixelarial14Glyphs[] = { + {0, 0, 0, 0, 0, 0, 0}, // + {0, 0, 0, 0, 0, 0, 0}, //  + {0, 0, 4, 0, 0, 0, 0}, // + {0, 0, 4, 0, 0, 0, 0}, // + {0, 0, 0, 0, 0, 0, 0}, //  {0, 0, 4, 0, 0, 0, 0}, // {2, 13, 3, 0, 13, 4, 0}, // ! {4, 6, 5, 0, 13, 3, 4}, // " @@ -167,18 +548,494 @@ static const EpdGlyph pixelarial14Glyphs[] = { {2, 13, 3, 0, 13, 4, 1110}, // | {4, 17, 5, 0, 13, 9, 1114}, // } {11, 4, 12, 0, 9, 6, 1123}, // ~ - {3, 6, 4, 0, 13, 3, 1129}, // ‘ - {3, 6, 4, 0, 13, 3, 1132}, // ’ - {6, 6, 6, 0, 13, 5, 1135}, // “ - {6, 6, 6, 0, 13, 5, 1140}, // ” + {0, 0, 4, 0, 0, 0, 1129}, //   + {3, 12, 5, 1, 9, 5, 1129}, // ¡ + {8, 13, 10, 1, 11, 13, 1134}, // ¢ + {8, 12, 10, 1, 12, 12, 1147}, // £ + {9, 9, 10, 0, 11, 11, 1159}, // ¤ + {10, 12, 10, 0, 12, 15, 1170}, // ¥ + {3, 16, 5, 1, 13, 6, 1185}, // ¦ + {8, 14, 8, 0, 13, 14, 1191}, // § + {6, 2, 6, 0, 12, 2, 1205}, // ¨ + {12, 12, 14, 1, 12, 18, 1207}, // © + {6, 6, 7, 0, 12, 5, 1225}, // ª + {8, 7, 8, 0, 8, 7, 1230}, // « + {9, 5, 10, 0, 6, 6, 1237}, // ¬ + {5, 1, 5, 0, 5, 1, 1243}, // ­ + {12, 12, 14, 1, 12, 18, 1244}, // ® + {6, 1, 6, 0, 12, 1, 1262}, // ¯ + {6, 5, 6, 0, 13, 4, 1263}, // ° + {9, 10, 10, 0, 10, 12, 1267}, // ± + {6, 7, 6, 0, 12, 6, 1279}, // ² + {6, 7, 6, 0, 12, 6, 1285}, // ³ + {4, 3, 6, 2, 13, 2, 1291}, // ´ + {8, 12, 10, 1, 9, 12, 1293}, // µ + {10, 15, 11, 0, 12, 19, 1305}, // ¶ + {4, 2, 4, 0, 6, 1, 1324}, // · + {4, 3, 6, 1, 0, 2, 1325}, // ¸ + {5, 7, 6, 0, 12, 5, 1327}, // ¹ + {7, 6, 8, 0, 12, 6, 1332}, // º + {8, 7, 8, 0, 8, 7, 1338}, // » + {15, 12, 15, 0, 12, 23, 1345}, // ¼ + {15, 12, 15, 0, 12, 23, 1368}, // ½ + {15, 12, 15, 0, 12, 23, 1391}, // ¾ + {7, 12, 7, 0, 9, 11, 1414}, // ¿ + {12, 16, 11, 0, 16, 24, 1425}, // À + {12, 16, 11, 0, 16, 24, 1449}, // Á + {12, 16, 11, 0, 16, 24, 1473}, //  + {12, 16, 11, 0, 16, 24, 1497}, // à + {12, 15, 11, 0, 15, 23, 1521}, // Ä + {12, 15, 11, 0, 15, 23, 1544}, // Å + {16, 12, 16, 0, 12, 24, 1567}, // Æ + {11, 16, 11, 0, 13, 22, 1591}, // Ç + {9, 16, 10, 1, 16, 18, 1613}, // È + {9, 16, 10, 1, 16, 18, 1631}, // É + {9, 16, 10, 1, 16, 18, 1649}, // Ê + {9, 15, 10, 1, 15, 17, 1667}, // Ë + {4, 16, 5, 0, 16, 8, 1684}, // Ì + {4, 16, 5, 1, 16, 8, 1692}, // Í + {6, 16, 5, -1, 16, 12, 1700}, // Î + {6, 15, 5, -1, 15, 12, 1712}, // Ï + {12, 12, 12, 0, 12, 18, 1724}, // Ð + {10, 16, 12, 1, 16, 20, 1742}, // Ñ + {13, 16, 13, 0, 16, 26, 1762}, // Ò + {13, 16, 13, 0, 16, 26, 1788}, // Ó + {13, 16, 13, 0, 16, 26, 1814}, // Ô + {13, 16, 13, 0, 16, 26, 1840}, // Õ + {13, 15, 13, 0, 15, 25, 1866}, // Ö + {8, 7, 10, 1, 9, 7, 1891}, // × + {13, 12, 13, 0, 12, 20, 1898}, // Ø + {10, 16, 12, 1, 16, 20, 1918}, // Ù + {10, 16, 12, 1, 16, 20, 1938}, // Ú + {10, 16, 12, 1, 16, 20, 1958}, // Û + {10, 15, 12, 1, 15, 19, 1978}, // Ü + {11, 16, 10, 0, 16, 22, 1997}, // Ý + {9, 12, 10, 1, 12, 14, 2019}, // Þ + {9, 13, 11, 1, 13, 15, 2033}, // ß + {8, 13, 9, 0, 13, 13, 2048}, // à + {8, 13, 9, 0, 13, 13, 2061}, // á + {8, 13, 9, 0, 13, 13, 2074}, // â + {8, 13, 9, 0, 13, 13, 2087}, // ã + {8, 12, 9, 0, 12, 12, 2100}, // ä + {8, 14, 9, 0, 14, 14, 2112}, // å + {14, 9, 15, 0, 9, 16, 2126}, // æ + {8, 12, 8, 0, 9, 12, 2142}, // ç + {9, 13, 10, 0, 13, 15, 2154}, // è + {9, 13, 10, 0, 13, 15, 2169}, // é + {9, 13, 10, 0, 13, 15, 2184}, // ê + {9, 12, 10, 0, 12, 14, 2199}, // ë + {4, 13, 4, 0, 13, 7, 2213}, // ì + {4, 13, 4, 1, 13, 7, 2220}, // í + {6, 13, 4, -1, 13, 10, 2227}, // î + {6, 12, 4, -1, 12, 9, 2237}, // ï + {10, 13, 10, 0, 13, 17, 2246}, // ð + {8, 13, 10, 1, 13, 13, 2263}, // ñ + {10, 13, 10, 0, 13, 17, 2276}, // ò + {10, 13, 10, 0, 13, 17, 2293}, // ó + {10, 13, 10, 0, 13, 17, 2310}, // ô + {10, 13, 10, 0, 13, 17, 2327}, // õ + {10, 12, 10, 0, 12, 15, 2344}, // ö + {9, 9, 10, 0, 9, 11, 2359}, // ÷ + {10, 9, 10, 0, 9, 12, 2370}, // ø + {8, 13, 10, 1, 13, 13, 2382}, // ù + {8, 13, 10, 1, 13, 13, 2395}, // ú + {8, 13, 10, 1, 13, 13, 2408}, // û + {8, 12, 10, 1, 12, 12, 2421}, // ü + {9, 16, 8, 0, 13, 18, 2433}, // ý + {9, 16, 10, 1, 13, 18, 2451}, // þ + {9, 15, 8, 0, 12, 17, 2469}, // ÿ + {12, 15, 11, 0, 15, 23, 2486}, // Ā + {8, 12, 9, 0, 12, 12, 2509}, // ā + {12, 15, 11, 0, 15, 23, 2521}, // Ă + {8, 12, 9, 0, 12, 12, 2544}, // ă + {12, 15, 11, 0, 12, 23, 2556}, // Ą + {8, 12, 9, 0, 9, 12, 2579}, // ą + {11, 16, 11, 0, 16, 22, 2591}, // Ć + {8, 13, 8, 0, 13, 13, 2613}, // ć + {11, 16, 11, 0, 16, 22, 2626}, // Ĉ + {8, 13, 8, 0, 13, 13, 2648}, // ĉ + {11, 15, 11, 0, 15, 21, 2661}, // Ċ + {8, 12, 8, 0, 12, 12, 2682}, // ċ + {11, 16, 11, 0, 16, 22, 2694}, // Č + {8, 13, 8, 0, 13, 13, 2716}, // č + {11, 16, 12, 1, 16, 22, 2729}, // Ď + {12, 13, 11, 0, 13, 20, 2751}, // ď + {12, 12, 12, 0, 12, 18, 2771}, // Đ + {10, 13, 10, 0, 13, 17, 2789}, // đ + {9, 15, 10, 1, 15, 17, 2806}, // Ē + {9, 12, 10, 0, 12, 14, 2823}, // ē + {9, 15, 10, 1, 15, 17, 2837}, // Ĕ + {9, 12, 10, 0, 12, 14, 2854}, // ĕ + {9, 15, 10, 1, 15, 17, 2868}, // Ė + {9, 12, 10, 0, 12, 14, 2885}, // ė + {9, 15, 10, 1, 12, 17, 2899}, // Ę + {9, 12, 10, 0, 9, 14, 2916}, // ę + {9, 16, 10, 1, 16, 18, 2930}, // Ě + {9, 14, 10, 0, 14, 16, 2948}, // ě + {11, 16, 11, 0, 16, 22, 2964}, // Ĝ + {9, 16, 10, 0, 13, 18, 2986}, // ĝ + {11, 15, 11, 0, 15, 21, 3004}, // Ğ + {9, 15, 10, 0, 12, 17, 3025}, // ğ + {11, 15, 11, 0, 15, 21, 3042}, // Ġ + {9, 15, 10, 0, 12, 17, 3063}, // ġ + {11, 15, 11, 0, 12, 21, 3080}, // Ģ + {9, 16, 10, 0, 13, 18, 3101}, // ģ + {10, 16, 12, 1, 16, 20, 3119}, // Ĥ + {9, 16, 10, 0, 16, 18, 3139}, // ĥ + {13, 12, 12, 0, 12, 20, 3157}, // Ħ + {9, 13, 10, 0, 13, 15, 3177}, // ħ + {7, 16, 5, -1, 16, 14, 3192}, // Ĩ + {6, 13, 4, -1, 13, 10, 3206}, // ĩ + {6, 15, 5, -1, 15, 12, 3216}, // Ī + {6, 12, 4, -1, 12, 9, 3228}, // ī + {5, 15, 5, 0, 15, 10, 3237}, // Ĭ + {6, 12, 4, -1, 12, 9, 3247}, // ĭ + {4, 15, 5, 0, 12, 8, 3256}, // Į + {4, 16, 4, 0, 13, 8, 3264}, // į + {3, 15, 5, 1, 15, 6, 3272}, // İ + {2, 9, 4, 1, 9, 3, 3278}, // ı + {11, 12, 13, 1, 12, 17, 3281}, // IJ + {7, 16, 9, 1, 13, 14, 3298}, // ij + {9, 16, 9, 0, 16, 18, 3312}, // Ĵ + {7, 16, 4, -2, 13, 14, 3330}, // ĵ + {10, 15, 11, 1, 12, 19, 3344}, // Ķ + {8, 16, 9, 1, 13, 16, 3363}, // ķ + {8, 9, 9, 1, 9, 9, 3379}, // ĸ + {8, 16, 9, 1, 16, 16, 3388}, // Ĺ + {4, 16, 5, 1, 16, 8, 3404}, // ĺ + {8, 15, 9, 1, 12, 15, 3412}, // Ļ + {5, 16, 5, 0, 13, 10, 3427}, // ļ + {8, 12, 9, 1, 12, 12, 3437}, // Ľ + {5, 13, 5, 1, 13, 9, 3449}, // ľ + {8, 12, 9, 1, 12, 12, 3458}, // Ŀ + {6, 13, 6, 1, 13, 10, 3470}, // ŀ + {10, 12, 9, -1, 12, 15, 3480}, // Ł + {5, 13, 5, 0, 13, 9, 3495}, // ł + {10, 16, 12, 1, 16, 20, 3504}, // Ń + {8, 13, 10, 1, 13, 13, 3524}, // ń + {10, 15, 12, 1, 12, 19, 3537}, // Ņ + {8, 12, 10, 1, 9, 12, 3556}, // ņ + {10, 16, 12, 1, 16, 20, 3568}, // Ň + {8, 13, 10, 1, 13, 13, 3588}, // ň + {9, 14, 10, 0, 14, 16, 3601}, // ʼn + {10, 15, 12, 1, 12, 19, 3617}, // Ŋ + {8, 12, 10, 1, 9, 12, 3636}, // ŋ + {13, 15, 13, 0, 15, 25, 3648}, // Ō + {10, 12, 10, 0, 12, 15, 3673}, // ō + {13, 15, 13, 0, 15, 25, 3688}, // Ŏ + {10, 12, 10, 0, 12, 15, 3713}, // ŏ + {13, 16, 13, 0, 16, 26, 3728}, // Ő + {10, 13, 10, 0, 13, 17, 3754}, // ő + {17, 12, 17, 0, 12, 26, 3771}, // Œ + {16, 9, 16, 0, 9, 18, 3797}, // œ + {10, 16, 11, 1, 16, 20, 3815}, // Ŕ + {6, 13, 7, 1, 13, 10, 3835}, // ŕ + {10, 15, 11, 1, 12, 19, 3845}, // Ŗ + {7, 12, 7, 0, 9, 11, 3864}, // ŗ + {10, 16, 11, 1, 16, 20, 3875}, // Ř + {6, 13, 7, 1, 13, 10, 3895}, // ř + {9, 16, 9, 0, 16, 18, 3905}, // Ś + {7, 13, 8, 0, 13, 12, 3923}, // ś + {9, 16, 9, 0, 16, 18, 3935}, // Ŝ + {7, 13, 8, 0, 13, 12, 3953}, // ŝ + {9, 15, 9, 0, 12, 17, 3965}, // Ş + {7, 12, 8, 0, 9, 11, 3982}, // ş + {9, 16, 9, 0, 16, 18, 3993}, // Š + {7, 13, 8, 0, 13, 12, 4011}, // š + {10, 15, 10, 0, 12, 19, 4023}, // Ţ + {6, 15, 7, 1, 12, 12, 4042}, // ţ + {10, 16, 10, 0, 16, 20, 4054}, // Ť + {6, 13, 7, 1, 13, 10, 4074}, // ť + {10, 12, 10, 0, 12, 15, 4084}, // Ŧ + {6, 12, 7, 1, 12, 9, 4099}, // ŧ + {10, 16, 12, 1, 16, 20, 4108}, // Ũ + {8, 13, 10, 1, 13, 13, 4128}, // ũ + {10, 15, 12, 1, 15, 19, 4141}, // Ū + {8, 12, 10, 1, 12, 12, 4160}, // ū + {10, 15, 12, 1, 15, 19, 4172}, // Ŭ + {8, 12, 10, 1, 12, 12, 4191}, // ŭ + {10, 17, 12, 1, 17, 22, 4203}, // Ů + {8, 14, 10, 1, 14, 14, 4225}, // ů + {10, 16, 12, 1, 16, 20, 4239}, // Ű + {8, 13, 10, 1, 13, 13, 4259}, // ű + {10, 15, 12, 1, 12, 19, 4272}, // Ų + {8, 12, 10, 1, 9, 12, 4291}, // ų + {16, 16, 16, 0, 16, 32, 4303}, // Ŵ + {13, 13, 13, 0, 13, 22, 4335}, // ŵ + {11, 16, 10, 0, 16, 22, 4357}, // Ŷ + {9, 16, 8, 0, 13, 18, 4379}, // ŷ + {11, 15, 10, 0, 15, 21, 4397}, // Ÿ + {10, 16, 10, 0, 16, 20, 4418}, // Ź + {8, 13, 8, 0, 13, 13, 4438}, // ź + {10, 15, 10, 0, 15, 19, 4451}, // Ż + {8, 12, 8, 0, 12, 12, 4470}, // ż + {10, 16, 10, 0, 16, 20, 4482}, // Ž + {8, 13, 8, 0, 13, 13, 4502}, // ž + {6, 14, 5, 1, 14, 11, 4515}, // ſ + {6, 2, 6, 0, 12, 2, 4526}, // ̑ + {9, 16, 10, 1, 16, 18, 4528}, // Ѐ + {9, 15, 10, 1, 15, 17, 4546}, // Ё + {13, 12, 13, 0, 12, 20, 4563}, // Ђ + {8, 16, 9, 1, 16, 16, 4583}, // Ѓ + {11, 12, 11, 0, 12, 17, 4599}, // Є + {9, 12, 9, 0, 12, 14, 4616}, // Ѕ + {3, 12, 5, 1, 12, 5, 4630}, // І + {6, 15, 5, -1, 15, 12, 4635}, // Ї + {8, 12, 9, 0, 12, 12, 4647}, // Ј + {18, 13, 18, 0, 12, 30, 4659}, // Љ + {16, 12, 17, 1, 12, 24, 4689}, // Њ + {12, 12, 13, 0, 12, 18, 4713}, // Ћ + {10, 16, 11, 1, 16, 20, 4731}, // Ќ + {10, 16, 12, 1, 16, 20, 4751}, // Ѝ + {10, 15, 10, 0, 15, 19, 4771}, // Ў + {10, 15, 12, 1, 12, 19, 4790}, // Џ + {12, 12, 11, 0, 12, 18, 4809}, // А + {9, 12, 11, 1, 12, 14, 4827}, // Б + {10, 12, 11, 1, 12, 15, 4841}, // В + {8, 12, 9, 1, 12, 12, 4856}, // Г + {12, 15, 12, 0, 12, 23, 4868}, // Д + {9, 12, 10, 1, 12, 14, 4891}, // Е + {15, 12, 15, 0, 12, 23, 4905}, // Ж + {9, 12, 10, 0, 12, 14, 4928}, // З + {10, 12, 12, 1, 12, 15, 4942}, // И + {10, 15, 12, 1, 15, 19, 4957}, // Й + {10, 12, 11, 1, 12, 15, 4976}, // К + {11, 12, 12, 0, 12, 17, 4991}, // Л + {13, 12, 15, 1, 12, 20, 5008}, // М + {10, 12, 12, 1, 12, 15, 5028}, // Н + {13, 12, 13, 0, 12, 20, 5043}, // О + {10, 12, 12, 1, 12, 15, 5063}, // П + {9, 12, 10, 1, 12, 14, 5078}, // Р + {11, 13, 11, 0, 13, 18, 5092}, // С + {10, 12, 10, 0, 12, 15, 5110}, // Т + {10, 12, 10, 0, 12, 15, 5125}, // У + {14, 13, 15, 0, 13, 23, 5140}, // Ф + {11, 12, 11, 0, 12, 17, 5163}, // Х + {11, 15, 12, 1, 12, 21, 5180}, // Ц + {10, 12, 11, 0, 12, 15, 5201}, // Ч + {15, 12, 17, 1, 12, 23, 5216}, // Ш + {16, 15, 17, 1, 12, 30, 5239}, // Щ + {12, 12, 12, 0, 12, 18, 5269}, // Ъ + {13, 12, 15, 1, 12, 20, 5287}, // Ы + {9, 12, 10, 1, 12, 14, 5307}, // Ь + {10, 12, 11, 0, 12, 15, 5321}, // Э + {16, 12, 18, 1, 12, 24, 5336}, // Ю + {10, 12, 11, 0, 12, 15, 5360}, // Я + {8, 9, 9, 0, 9, 9, 5375}, // а + {9, 13, 10, 1, 13, 15, 5384}, // б + {8, 9, 9, 1, 9, 9, 5399}, // в + {6, 9, 7, 1, 9, 7, 5408}, // г + {10, 11, 10, 0, 9, 14, 5415}, // д + {9, 9, 10, 0, 9, 11, 5429}, // е + {13, 9, 13, 0, 9, 15, 5440}, // ж + {8, 9, 8, 0, 9, 9, 5455}, // з + {8, 9, 10, 1, 9, 9, 5464}, // и + {8, 12, 10, 1, 12, 12, 5473}, // й + {8, 9, 9, 1, 9, 9, 5485}, // к + {9, 9, 10, 0, 9, 11, 5494}, // л + {11, 9, 12, 1, 9, 13, 5505}, // м + {8, 9, 10, 1, 9, 9, 5518}, // н + {10, 9, 10, 0, 9, 12, 5527}, // о + {8, 9, 10, 1, 9, 9, 5539}, // п + {9, 12, 10, 1, 9, 14, 5548}, // р + {8, 9, 8, 0, 9, 9, 5562}, // с + {8, 9, 8, 0, 9, 9, 5571}, // т + {9, 12, 8, 0, 9, 14, 5580}, // у + {13, 16, 13, 0, 13, 26, 5594}, // ф + {9, 9, 9, 0, 9, 11, 5620}, // х + {9, 11, 10, 1, 9, 13, 5631}, // ц + {8, 9, 9, 0, 9, 9, 5644}, // ч + {12, 9, 14, 1, 9, 14, 5653}, // ш + {13, 11, 14, 1, 9, 18, 5667}, // щ + {10, 9, 10, 0, 9, 12, 5685}, // ъ + {11, 9, 13, 1, 9, 13, 5697}, // ы + {8, 9, 9, 1, 9, 9, 5710}, // ь + {8, 9, 8, 0, 9, 9, 5719}, // э + {12, 9, 14, 1, 9, 14, 5728}, // ю + {8, 9, 9, 0, 9, 9, 5742}, // я + {9, 13, 10, 0, 13, 15, 5751}, // ѐ + {9, 12, 10, 0, 12, 14, 5766}, // ё + {9, 16, 10, 0, 13, 18, 5780}, // ђ + {6, 13, 7, 1, 13, 10, 5798}, // ѓ + {8, 9, 8, 0, 9, 9, 5808}, // є + {7, 9, 8, 0, 9, 8, 5817}, // ѕ + {3, 13, 4, 1, 13, 5, 5825}, // і + {6, 12, 4, -1, 12, 9, 5830}, // ї + {6, 16, 4, -2, 13, 12, 5839}, // ј + {15, 9, 15, 0, 9, 17, 5851}, // љ + {13, 9, 14, 1, 9, 15, 5868}, // њ + {9, 13, 10, 0, 13, 15, 5883}, // ћ + {8, 13, 9, 1, 13, 13, 5898}, // ќ + {8, 13, 10, 1, 13, 13, 5911}, // ѝ + {9, 15, 8, 0, 12, 17, 5924}, // ў + {8, 11, 10, 1, 9, 11, 5941}, // џ + {12, 13, 12, 0, 13, 20, 5952}, // Ѣ + {10, 13, 10, 0, 13, 17, 5972}, // ѣ + {13, 12, 13, 0, 12, 20, 5989}, // Ѳ + {10, 9, 10, 0, 9, 12, 6009}, // ѳ + {14, 12, 13, 0, 12, 21, 6021}, // Ѵ + {10, 9, 10, 0, 9, 12, 6042}, // ѵ + {12, 18, 13, 1, 15, 27, 6054}, // Ҋ + {10, 15, 10, 1, 12, 19, 6081}, // ҋ + {11, 13, 11, 0, 13, 18, 6100}, // Ҍ + {9, 9, 9, 0, 9, 11, 6118}, // ҍ + {9, 12, 10, 1, 12, 14, 6129}, // Ҏ + {9, 12, 10, 1, 9, 14, 6143}, // ҏ + {8, 14, 9, 1, 14, 14, 6157}, // Ґ + {6, 11, 7, 1, 11, 9, 6171}, // ґ + {9, 12, 9, 0, 12, 14, 6180}, // Ғ + {7, 9, 7, 0, 9, 8, 6194}, // ғ + {9, 15, 11, 1, 12, 17, 6202}, // Ҕ + {8, 12, 9, 1, 9, 12, 6219}, // ҕ + {16, 15, 16, 0, 12, 30, 6231}, // Җ + {14, 11, 13, 0, 9, 20, 6261}, // җ + {9, 15, 10, 0, 12, 17, 6281}, // Ҙ + {8, 12, 8, 0, 9, 12, 6298}, // ҙ + {11, 15, 11, 1, 12, 21, 6310}, // Қ + {9, 12, 9, 1, 9, 14, 6331}, // қ + {11, 12, 12, 1, 12, 17, 6345}, // Ҝ + {10, 9, 11, 1, 9, 12, 6362}, // ҝ + {11, 12, 11, 0, 12, 17, 6374}, // Ҟ + {10, 9, 9, 0, 9, 12, 6391}, // ҟ + {13, 12, 13, 0, 12, 20, 6403}, // Ҡ + {11, 9, 10, 0, 9, 13, 6423}, // ҡ + {11, 15, 12, 1, 12, 21, 6436}, // Ң + {9, 11, 10, 1, 9, 13, 6457}, // ң + {13, 12, 14, 1, 12, 20, 6470}, // Ҥ + {10, 9, 11, 1, 9, 12, 6490}, // ҥ + {16, 15, 18, 1, 12, 30, 6502}, // Ҧ + {14, 12, 15, 1, 9, 21, 6532}, // ҧ + {13, 15, 13, 0, 12, 25, 6553}, // Ҩ + {10, 11, 10, 0, 9, 14, 6578}, // ҩ + {11, 16, 11, 0, 13, 22, 6592}, // Ҫ + {8, 12, 8, 0, 9, 12, 6614}, // ҫ + {10, 15, 10, 0, 12, 19, 6626}, // Ҭ + {8, 11, 8, 0, 9, 11, 6645}, // ҭ + {11, 12, 10, 0, 12, 17, 6656}, // Ү + {9, 12, 9, 0, 9, 14, 6673}, // ү + {11, 12, 10, 0, 12, 17, 6687}, // Ұ + {9, 12, 9, 0, 9, 14, 6704}, // ұ + {11, 15, 11, 0, 12, 21, 6718}, // Ҳ + {9, 11, 9, 0, 9, 13, 6739}, // ҳ + {15, 15, 14, 0, 12, 29, 6752}, // Ҵ + {11, 11, 11, 0, 9, 16, 6781}, // ҵ + {11, 16, 11, 0, 12, 22, 6797}, // Ҷ + {9, 11, 9, 0, 9, 13, 6819}, // ҷ + {10, 12, 11, 0, 12, 15, 6832}, // Ҹ + {8, 9, 9, 0, 9, 9, 6847}, // ҹ + {9, 12, 11, 1, 12, 14, 6856}, // Һ + {8, 13, 10, 1, 13, 13, 6870}, // һ + {14, 12, 14, 0, 12, 21, 6883}, // Ҽ + {11, 9, 11, 0, 9, 13, 6904}, // ҽ + {14, 15, 14, 0, 12, 27, 6917}, // Ҿ + {11, 11, 11, 0, 9, 16, 6944}, // ҿ + {3, 12, 5, 1, 12, 5, 6960}, // Ӏ + {15, 15, 15, 0, 15, 29, 6965}, // Ӂ + {13, 12, 13, 0, 12, 20, 6994}, // ӂ + {9, 15, 11, 1, 12, 17, 7014}, // Ӄ + {8, 12, 9, 1, 9, 12, 7031}, // ӄ + {13, 15, 12, 0, 12, 25, 7043}, // Ӆ + {11, 12, 10, 0, 9, 17, 7068}, // ӆ + {10, 15, 12, 1, 12, 19, 7085}, // Ӈ + {8, 12, 10, 1, 9, 12, 7104}, // ӈ + {12, 15, 12, 1, 12, 23, 7116}, // Ӊ + {10, 12, 10, 1, 9, 15, 7139}, // ӊ + {9, 15, 11, 1, 12, 17, 7154}, // Ӌ + {8, 11, 9, 0, 9, 11, 7171}, // ӌ + {15, 15, 15, 1, 12, 29, 7182}, // Ӎ + {12, 12, 13, 1, 9, 18, 7211}, // ӎ + {3, 12, 5, 1, 12, 5, 7229}, // ӏ + {12, 15, 11, 0, 15, 23, 7234}, // Ӑ + {8, 12, 9, 0, 12, 12, 7257}, // ӑ + {12, 15, 11, 0, 15, 23, 7269}, // Ӓ + {8, 12, 9, 0, 12, 12, 7292}, // ӓ + {16, 12, 16, 0, 12, 24, 7304}, // Ӕ + {14, 9, 15, 0, 9, 16, 7328}, // ӕ + {9, 15, 10, 1, 15, 17, 7344}, // Ӗ + {9, 12, 10, 0, 12, 14, 7361}, // ӗ + {11, 12, 12, 0, 12, 17, 7375}, // Ә + {9, 9, 10, 0, 9, 11, 7392}, // ә + {11, 15, 12, 0, 15, 21, 7403}, // Ӛ + {9, 12, 10, 0, 12, 14, 7424}, // ӛ + {15, 15, 15, 0, 15, 29, 7438}, // Ӝ + {13, 12, 13, 0, 12, 20, 7467}, // ӝ + {9, 15, 10, 0, 15, 17, 7487}, // Ӟ + {8, 12, 8, 0, 12, 12, 7504}, // ӟ + {9, 12, 10, 0, 12, 14, 7516}, // Ӡ + {9, 12, 8, -1, 9, 14, 7530}, // ӡ + {10, 15, 12, 1, 15, 19, 7544}, // Ӣ + {8, 12, 10, 1, 12, 12, 7563}, // ӣ + {10, 15, 12, 1, 15, 19, 7575}, // Ӥ + {8, 12, 10, 1, 12, 12, 7594}, // ӥ + {13, 15, 13, 0, 15, 25, 7606}, // Ӧ + {10, 12, 10, 0, 12, 15, 7631}, // ӧ + {13, 12, 13, 0, 12, 20, 7646}, // Ө + {10, 9, 10, 0, 9, 12, 7666}, // ө + {13, 15, 13, 0, 15, 25, 7678}, // Ӫ + {10, 12, 10, 0, 12, 15, 7703}, // ӫ + {10, 15, 11, 0, 15, 19, 7718}, // Ӭ + {8, 12, 8, 0, 12, 12, 7737}, // ӭ + {10, 15, 10, 0, 15, 19, 7749}, // Ӯ + {9, 15, 8, 0, 12, 17, 7768}, // ӯ + {10, 15, 10, 0, 15, 19, 7785}, // Ӱ + {9, 15, 8, 0, 12, 17, 7804}, // ӱ + {10, 16, 10, 0, 16, 20, 7821}, // Ӳ + {9, 16, 8, 0, 13, 18, 7841}, // ӳ + {10, 15, 11, 0, 15, 19, 7859}, // Ӵ + {8, 12, 9, 0, 12, 12, 7878}, // ӵ + {8, 15, 9, 1, 12, 15, 7890}, // Ӷ + {6, 11, 7, 1, 9, 9, 7905}, // ӷ + {13, 15, 15, 1, 15, 25, 7914}, // Ӹ + {11, 12, 13, 1, 12, 17, 7939}, // ӹ + {10, 1, 8, -1, 5, 2, 7956}, // – + {18, 1, 17, -1, 5, 3, 7958}, // — + {18, 1, 17, -1, 5, 3, 7961}, // ― + {3, 6, 4, 0, 13, 3, 7964}, // ‘ + {3, 6, 4, 0, 13, 3, 7967}, // ’ + {3, 4, 4, 0, 2, 2, 7970}, // ‚ + {6, 6, 6, 0, 13, 5, 7972}, // “ + {6, 6, 6, 0, 13, 5, 7977}, // ” + {7, 4, 7, 0, 2, 4, 7982}, // „ + {8, 14, 8, 0, 12, 14, 7986}, // † + {8, 14, 8, 0, 12, 14, 8000}, // ‡ + {6, 4, 6, 0, 8, 3, 8014}, // • + {15, 2, 17, 1, 2, 4, 8017}, // … + {21, 12, 21, 0, 12, 32, 8021}, // ‰ + {5, 7, 5, 0, 8, 5, 8053}, // ‹ + {5, 7, 5, 0, 8, 5, 8058}, // › + {9, 12, 3, -3, 12, 14, 8063}, // ⁄ + {10, 12, 10, 0, 12, 15, 8077}, // € + {9, 12, 10, 0, 12, 14, 8092}, // ₮ + {9, 14, 10, 0, 13, 16, 8106}, // ₴ + {9, 12, 10, 1, 12, 14, 8122}, // ₹ + {10, 13, 10, 0, 13, 17, 8136}, // ∂ + {11, 12, 11, 0, 12, 17, 8153}, // ∆ + {10, 13, 12, 1, 11, 17, 8170}, // ∏ + {9, 14, 9, 0, 12, 16, 8187}, // ∑ + {9, 1, 10, 0, 5, 2, 8203}, // − + {9, 12, 3, -3, 12, 14, 8205}, // ∕ + {4, 2, 4, 0, 6, 1, 8219}, // ∙ + {10, 13, 10, 0, 13, 17, 8220}, // √ + {10, 5, 10, 0, 7, 7, 8237}, // ∞ + {7, 16, 6, 0, 13, 14, 8244}, // ∫ + {9, 6, 10, 0, 8, 7, 8258}, // ≈ + {9, 8, 10, 0, 9, 9, 8265}, // ≠ + {9, 9, 10, 0, 9, 11, 8274}, // ≤ + {9, 9, 10, 0, 9, 11, 8285}, // ≥ }; static const EpdUnicodeInterval pixelarial14Intervals[] = { - {0x20, 0x7E, 0x0}, - {0x2018, 0x2019, 0x5F}, - {0x201C, 0x201D, 0x61}, + {0x0, 0x0, 0x0}, {0x8, 0x9, 0x1}, {0xD, 0xD, 0x3}, {0x1D, 0x1D, 0x4}, + {0x20, 0x7E, 0x5}, {0xA0, 0xFF, 0x64}, {0x100, 0x17F, 0xC4}, {0x311, 0x311, 0x144}, + {0x400, 0x45F, 0x145}, {0x462, 0x463, 0x1A5}, {0x472, 0x475, 0x1A7}, {0x48A, 0x4F9, 0x1AB}, + {0x2013, 0x2015, 0x21B}, {0x2018, 0x201A, 0x21E}, {0x201C, 0x201E, 0x221}, {0x2020, 0x2022, 0x224}, + {0x2026, 0x2026, 0x227}, {0x2030, 0x2030, 0x228}, {0x2039, 0x203A, 0x229}, {0x2044, 0x2044, 0x22B}, + {0x20AC, 0x20AC, 0x22C}, {0x20AE, 0x20AE, 0x22D}, {0x20B4, 0x20B4, 0x22E}, {0x20B9, 0x20B9, 0x22F}, + {0x2202, 0x2202, 0x230}, {0x2206, 0x2206, 0x231}, {0x220F, 0x220F, 0x232}, {0x2211, 0x2212, 0x233}, + {0x2215, 0x2215, 0x235}, {0x2219, 0x221A, 0x236}, {0x221E, 0x221E, 0x238}, {0x222B, 0x222B, 0x239}, + {0x2248, 0x2248, 0x23A}, {0x2260, 0x2260, 0x23B}, {0x2264, 0x2265, 0x23C}, }; static const EpdFontData pixelarial14 = { - pixelarial14Bitmaps, pixelarial14Glyphs, pixelarial14Intervals, 3, 17, 13, -4, false, -}; + pixelarial14Bitmaps, pixelarial14Glyphs, pixelarial14Intervals, 35, 17, 13, -4, false, +}; \ No newline at end of file diff --git a/src/config.h b/src/config.h index a3784460..58a73054 100644 --- a/src/config.h +++ b/src/config.h @@ -26,4 +26,4 @@ * "./lib/EpdFont/builtinFonts/pixelarial14.h", * ].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)' */ -#define SMALL_FONT_ID (-139796914) +#define SMALL_FONT_ID 1482513144 From fcfa10bb1f41bf6efafc8e85c0de457ba4b8f80c Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 19:02:21 +1100 Subject: [PATCH 17/46] Cut release 0.8.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 0f923803..a4bdcd19 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.7.0 +crosspoint_version = 0.8.0 default_envs = default [base] From 246afae6efef96287debf595505f57d67cb47d93 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 21:16:41 +1100 Subject: [PATCH 18/46] Start power off sequence as soon as hold duration for the power button is reached (#93) ## Summary * Swap from `wasReleased` to `isPressed` when checking power button duration * In practice it makes the power down experience feel a lot snappier * Remove the unnecessary 1000ms delay when powering off ## Additional Context * A little discussion in here: https://github.com/daveallie/crosspoint-reader/discussions/53#discussioncomment-15309707 --- src/main.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 5dfc25ba..0d217812 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -119,14 +119,12 @@ void enterDeepSleep() { exitActivity(); enterNewActivity(new SleepActivity(renderer, inputManager)); - Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); - delay(1000); // Allow Serial buffer to empty and display to update - - // Enable Wakeup on LOW (button press) - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); - einkDisplay.deepSleep(); + Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); + // Ensure that the power button has been released to avoid immediately turning back on if you're holding it + waitForPowerRelease(); // Enter Deep Sleep esp_deep_sleep_start(); } @@ -231,7 +229,7 @@ void loop() { return; } - if (inputManager.wasReleased(InputManager::BTN_POWER) && + if (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) { enterDeepSleep(); // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start From 77c655fcf5f3af9969f76718ed04f60dfd106474 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 21 Dec 2025 21:17:00 +1100 Subject: [PATCH 19/46] Give activities names and log when entering and exiting them (#92) ## Summary * Give activities name and log when entering and exiting them * Clearer logs when attempting to debug, knowing where users are coming from/going to helps --- src/activities/Activity.h | 11 ++- src/activities/ActivityWithSubactivity.cpp | 5 +- src/activities/ActivityWithSubactivity.h | 4 +- src/activities/boot_sleep/BootActivity.cpp | 2 + src/activities/boot_sleep/BootActivity.h | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 6 ++ src/activities/boot_sleep/SleepActivity.h | 3 +- src/activities/home/HomeActivity.cpp | 4 + src/activities/home/HomeActivity.h | 2 +- .../network/CrossPointWebServerActivity.cpp | 91 ++++++++----------- .../network/CrossPointWebServerActivity.h | 8 +- .../network/WifiSelectionActivity.cpp | 6 +- .../network/WifiSelectionActivity.h | 2 +- src/activities/reader/EpubReaderActivity.cpp | 22 +++-- src/activities/reader/EpubReaderActivity.h | 7 +- .../EpubReaderChapterSelectionActivity.cpp | 4 + .../EpubReaderChapterSelectionActivity.h | 2 +- .../reader/FileSelectionActivity.cpp | 4 + src/activities/reader/FileSelectionActivity.h | 2 +- src/activities/reader/ReaderActivity.cpp | 2 + src/activities/reader/ReaderActivity.h | 2 +- src/activities/settings/SettingsActivity.cpp | 6 +- src/activities/settings/SettingsActivity.h | 4 +- .../util/FullScreenMessageActivity.cpp | 2 + .../util/FullScreenMessageActivity.h | 5 +- src/activities/util/KeyboardEntryActivity.cpp | 11 +-- src/activities/util/KeyboardEntryActivity.h | 8 +- 27 files changed, 123 insertions(+), 104 deletions(-) diff --git a/src/activities/Activity.h b/src/activities/Activity.h index dfe67143..90a2eb10 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -1,19 +1,22 @@ #pragma once #include +#include + class GfxRenderer; class Activity { protected: + std::string name; GfxRenderer& renderer; InputManager& inputManager; public: - explicit Activity(GfxRenderer& renderer, InputManager& inputManager) - : renderer(renderer), inputManager(inputManager) {} + explicit Activity(std::string name, GfxRenderer& renderer, InputManager& inputManager) + : name(std::move(name)), renderer(renderer), inputManager(inputManager) {} virtual ~Activity() = default; - virtual void onEnter() {} - virtual void onExit() {} + virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); } + virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); } virtual void loop() {} virtual bool skipLoopDelay() { return false; } }; diff --git a/src/activities/ActivityWithSubactivity.cpp b/src/activities/ActivityWithSubactivity.cpp index 56dccd98..61b1fc1e 100644 --- a/src/activities/ActivityWithSubactivity.cpp +++ b/src/activities/ActivityWithSubactivity.cpp @@ -18,4 +18,7 @@ void ActivityWithSubactivity::loop() { } } -void ActivityWithSubactivity::onExit() { exitActivity(); } +void ActivityWithSubactivity::onExit() { + Activity::onExit(); + exitActivity(); +} diff --git a/src/activities/ActivityWithSubactivity.h b/src/activities/ActivityWithSubactivity.h index b3a68730..af559876 100644 --- a/src/activities/ActivityWithSubactivity.h +++ b/src/activities/ActivityWithSubactivity.h @@ -10,8 +10,8 @@ class ActivityWithSubactivity : public Activity { void enterNewActivity(Activity* activity); public: - explicit ActivityWithSubactivity(GfxRenderer& renderer, InputManager& inputManager) - : Activity(renderer, inputManager) {} + explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, InputManager& inputManager) + : Activity(std::move(name), renderer, inputManager) {} void loop() override; void onExit() override; }; diff --git a/src/activities/boot_sleep/BootActivity.cpp b/src/activities/boot_sleep/BootActivity.cpp index a76cb7c9..78a12482 100644 --- a/src/activities/boot_sleep/BootActivity.cpp +++ b/src/activities/boot_sleep/BootActivity.cpp @@ -6,6 +6,8 @@ #include "images/CrossLarge.h" void BootActivity::onEnter() { + Activity::onEnter(); + const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageHeight = GfxRenderer::getScreenHeight(); diff --git a/src/activities/boot_sleep/BootActivity.h b/src/activities/boot_sleep/BootActivity.h index dd647ceb..a14d0c70 100644 --- a/src/activities/boot_sleep/BootActivity.h +++ b/src/activities/boot_sleep/BootActivity.h @@ -3,6 +3,6 @@ class BootActivity final : public Activity { public: - explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {} + explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity("Boot", renderer, inputManager) {} void onEnter() override; }; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 4200c4e9..ca72aeb1 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -12,6 +12,7 @@ #include "images/CrossLarge.h" void SleepActivity::onEnter() { + Activity::onEnter(); renderPopup("Entering Sleep..."); if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) { @@ -170,11 +171,16 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { } void SleepActivity::renderCoverSleepScreen() const { + if (APP_STATE.openEpubPath.empty()) { + return renderDefaultSleepScreen(); + } + Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastEpub.load()) { Serial.println("[SLP] Failed to load last epub"); return renderDefaultSleepScreen(); } + if (!lastEpub.generateCoverBmp()) { Serial.println("[SLP] Failed to generate cover bmp"); return renderDefaultSleepScreen(); diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 21121994..774fd25b 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -5,7 +5,8 @@ class Bitmap; class SleepActivity final : public Activity { public: - explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity(renderer, inputManager) {} + explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) + : Activity("Sleep", renderer, inputManager) {} void onEnter() override; private: diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 82d57cc8..9cfa1851 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -15,6 +15,8 @@ void HomeActivity::taskTrampoline(void* param) { } void HomeActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); selectorIndex = 0; @@ -31,6 +33,8 @@ void HomeActivity::onEnter() { } void HomeActivity::onExit() { + Activity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 5dd26ec5..943a4665 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -23,7 +23,7 @@ class HomeActivity final : public Activity { public: explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onReaderOpen, const std::function& onSettingsOpen, const std::function& onFileTransferOpen) - : Activity(renderer, inputManager), + : Activity("Home", renderer, inputManager), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen) {} diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index bb0f39a2..d02411fd 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -11,7 +11,8 @@ void CrossPointWebServerActivity::taskTrampoline(void* param) { } void CrossPointWebServerActivity::onEnter() { - Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onEnter ==========\n", millis()); + ActivityWithSubactivity::onEnter(); + Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap()); renderingMutex = xSemaphoreCreateMutex(); @@ -36,13 +37,13 @@ void CrossPointWebServerActivity::onEnter() { // Launch WiFi selection subactivity Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); - wifiSelection.reset(new WifiSelectionActivity(renderer, inputManager, - [this](bool connected) { onWifiSelectionComplete(connected); })); - wifiSelection->onEnter(); + enterNewActivity(new WifiSelectionActivity(renderer, inputManager, + [this](const bool connected) { onWifiSelectionComplete(connected); })); } void CrossPointWebServerActivity::onExit() { - Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit START ==========\n", millis()); + ActivityWithSubactivity::onExit(); + Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); state = WebServerActivityState::SHUTTING_DOWN; @@ -50,14 +51,6 @@ void CrossPointWebServerActivity::onExit() { // Stop the web server first (before disconnecting WiFi) stopWebServer(); - // Exit WiFi selection subactivity if still active - if (wifiSelection) { - Serial.printf("[%lu] [WEBACT] Exiting WifiSelectionActivity...\n", millis()); - wifiSelection->onExit(); - wifiSelection.reset(); - Serial.printf("[%lu] [WEBACT] WifiSelectionActivity exited\n", millis()); - } - // CRITICAL: Wait for LWIP stack to flush any pending packets Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); delay(500); @@ -92,20 +85,17 @@ void CrossPointWebServerActivity::onExit() { Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis()); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); - Serial.printf("[%lu] [WEBACT] ========== CrossPointWebServerActivity onExit COMPLETE ==========\n", millis()); } -void CrossPointWebServerActivity::onWifiSelectionComplete(bool connected) { +void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); if (connected) { // Get connection info before exiting subactivity - connectedIP = wifiSelection->getConnectedIP(); + connectedIP = static_cast(subActivity.get())->getConnectedIP(); connectedSSID = WiFi.SSID().c_str(); - // Exit the wifi selection subactivity - wifiSelection->onExit(); - wifiSelection.reset(); + exitActivity(); // Start the web server startWebServer(); @@ -150,47 +140,40 @@ void CrossPointWebServerActivity::stopWebServer() { } void CrossPointWebServerActivity::loop() { + if (subActivity) { + // Forward loop to subactivity + subActivity->loop(); + return; + } + // Handle different states - switch (state) { - case WebServerActivityState::WIFI_SELECTION: - // Forward loop to WiFi selection subactivity - if (wifiSelection) { - wifiSelection->loop(); - } - break; + if (state == WebServerActivityState::SERVER_RUNNING) { + // Handle web server requests - call handleClient multiple times per loop + // to improve responsiveness and upload throughput + if (webServer && webServer->isRunning()) { + const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; - case WebServerActivityState::SERVER_RUNNING: - // Handle web server requests - call handleClient multiple times per loop - // to improve responsiveness and upload throughput - if (webServer && webServer->isRunning()) { - unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; - - // Log if there's a significant gap between handleClient calls (>100ms) - if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { - Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(), - timeSinceLastHandleClient); - } - - // Call handleClient multiple times to process pending requests faster - // This is critical for upload performance - HTTP file uploads send data - // in chunks and each handleClient() call processes incoming data - constexpr int HANDLE_CLIENT_ITERATIONS = 10; - for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { - webServer->handleClient(); - } - lastHandleClientTime = millis(); + // Log if there's a significant gap between handleClient calls (>100ms) + if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { + Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(), + timeSinceLastHandleClient); } - // Handle exit on Back button - if (inputManager.wasPressed(InputManager::BTN_BACK)) { - onGoBack(); - return; + // Call handleClient multiple times to process pending requests faster + // This is critical for upload performance - HTTP file uploads send data + // in chunks and each handleClient() call processes incoming data + constexpr int HANDLE_CLIENT_ITERATIONS = 10; + for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { + webServer->handleClient(); } - break; + lastHandleClientTime = millis(); + } - case WebServerActivityState::SHUTTING_DOWN: - // Do nothing - waiting for cleanup - break; + // Handle exit on Back button + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + onGoBack(); + return; + } } } diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index ad41dcd7..76d19f1c 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -9,6 +9,7 @@ #include "../Activity.h" #include "WifiSelectionActivity.h" +#include "activities/ActivityWithSubactivity.h" #include "server/CrossPointWebServer.h" // Web server activity states @@ -26,16 +27,13 @@ enum class WebServerActivityState { * - Handles client requests in its loop() function * - Cleans up the server and shuts down WiFi on exit */ -class CrossPointWebServerActivity final : public Activity { +class CrossPointWebServerActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; WebServerActivityState state = WebServerActivityState::WIFI_SELECTION; const std::function onGoBack; - // WiFi selection subactivity - std::unique_ptr wifiSelection; - // Web server - owned by this activity std::unique_ptr webServer; @@ -58,7 +56,7 @@ class CrossPointWebServerActivity final : public Activity { public: explicit CrossPointWebServerActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoBack) - : Activity(renderer, inputManager), onGoBack(onGoBack) {} + : ActivityWithSubactivity("CrossPointWebServer", renderer, inputManager), onGoBack(onGoBack) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index d6c3b1ec..7f202fe3 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -14,6 +14,8 @@ void WifiSelectionActivity::taskTrampoline(void* param) { } void WifiSelectionActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); // Load saved WiFi credentials @@ -47,7 +49,8 @@ void WifiSelectionActivity::onEnter() { } void WifiSelectionActivity::onExit() { - Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit START ==========\n", millis()); + Activity::onExit(); + Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); // Stop any ongoing WiFi scan @@ -78,7 +81,6 @@ void WifiSelectionActivity::onExit() { Serial.printf("[%lu] [WIFI] Mutex deleted\n", millis()); Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); - Serial.printf("[%lu] [WIFI] ========== WifiSelectionActivity onExit COMPLETE ==========\n", millis()); } void WifiSelectionActivity::startWifiScan() { diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index e7b12ae2..a009de1c 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -98,7 +98,7 @@ class WifiSelectionActivity final : public Activity { public: explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onComplete) - : Activity(renderer, inputManager), onComplete(onComplete) {} + : Activity("WifiSelection", renderer, inputManager), onComplete(onComplete) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 9635952e..84abc49c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -26,6 +26,8 @@ void EpubReaderActivity::taskTrampoline(void* param) { } void EpubReaderActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + if (!epub) { return; } @@ -61,6 +63,8 @@ void EpubReaderActivity::onEnter() { } void EpubReaderActivity::onExit() { + ActivityWithSubactivity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { @@ -75,8 +79,8 @@ void EpubReaderActivity::onExit() { void EpubReaderActivity::loop() { // Pass input responsibility to sub activity if exists - if (subAcitivity) { - subAcitivity->loop(); + if (subActivity) { + subActivity->loop(); return; } @@ -84,11 +88,11 @@ void EpubReaderActivity::loop() { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { // Don't start activity transition while rendering xSemaphoreTake(renderingMutex, portMAX_DELAY); - subAcitivity.reset(new EpubReaderChapterSelectionActivity( + exitActivity(); + enterNewActivity(new EpubReaderChapterSelectionActivity( this->renderer, this->inputManager, epub, currentSpineIndex, [this] { - subAcitivity->onExit(); - subAcitivity.reset(); + exitActivity(); updateRequired = true; }, [this](const int newSpineIndex) { @@ -97,11 +101,9 @@ void EpubReaderActivity::loop() { nextPageNumber = 0; section.reset(); } - subAcitivity->onExit(); - subAcitivity.reset(); + exitActivity(); updateRequired = true; })); - subAcitivity->onEnter(); xSemaphoreGive(renderingMutex); } @@ -330,8 +332,8 @@ void EpubReaderActivity::renderStatusBar() const { constexpr auto textY = 776; // Calculate progress in book - float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; - uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); + const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; + const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); // Right aligned text for progress counter const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 8eeddc1b..4edbabc2 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,14 +5,13 @@ #include #include -#include "../Activity.h" +#include "activities/ActivityWithSubactivity.h" -class EpubReaderActivity final : public Activity { +class EpubReaderActivity final : public ActivityWithSubactivity { std::shared_ptr epub; std::unique_ptr
section = nullptr; TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; - std::unique_ptr subAcitivity = nullptr; int currentSpineIndex = 0; int nextPageNumber = 0; int pagesUntilFullRefresh = 0; @@ -28,7 +27,7 @@ class EpubReaderActivity final : public Activity { public: explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr epub, const std::function& onGoBack) - : Activity(renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} + : ActivityWithSubactivity("EpubReader", renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 9af556ba..07fd456d 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -16,6 +16,8 @@ void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) { } void EpubReaderChapterSelectionActivity::onEnter() { + Activity::onEnter(); + if (!epub) { return; } @@ -34,6 +36,8 @@ void EpubReaderChapterSelectionActivity::onEnter() { } void EpubReaderChapterSelectionActivity::onExit() { + Activity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.h b/src/activities/reader/EpubReaderChapterSelectionActivity.h index b25ef6fb..8c1adef8 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.h +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.h @@ -27,7 +27,7 @@ class EpubReaderChapterSelectionActivity final : public Activity { const std::shared_ptr& epub, const int currentSpineIndex, const std::function& onGoBack, const std::function& onSelectSpineIndex) - : Activity(renderer, inputManager), + : Activity("EpubReaderChapterSelection", renderer, inputManager), epub(epub), currentSpineIndex(currentSpineIndex), onGoBack(onGoBack), diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index d8aef3c7..fc39a8c2 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -48,6 +48,8 @@ void FileSelectionActivity::loadFiles() { } void FileSelectionActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); basepath = "/"; @@ -66,6 +68,8 @@ void FileSelectionActivity::onEnter() { } void FileSelectionActivity::onExit() { + Activity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { diff --git a/src/activities/reader/FileSelectionActivity.h b/src/activities/reader/FileSelectionActivity.h index ff1a9223..2a8f8ae1 100644 --- a/src/activities/reader/FileSelectionActivity.h +++ b/src/activities/reader/FileSelectionActivity.h @@ -28,7 +28,7 @@ class FileSelectionActivity final : public Activity { explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onSelect, const std::function& onGoHome) - : Activity(renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {} + : Activity("FileSelection", renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 93389fe7..d888fb6e 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -50,6 +50,8 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { } void ReaderActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + if (initialEpubPath.empty()) { onGoToFileSelection(); return; diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index a68cd89d..e566d6d3 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -17,7 +17,7 @@ class ReaderActivity final : public ActivityWithSubactivity { public: explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialEpubPath, const std::function& onGoBack) - : ActivityWithSubactivity(renderer, inputManager), + : ActivityWithSubactivity("Reader", renderer, inputManager), initialEpubPath(std::move(initialEpubPath)), onGoBack(onGoBack) {} void onEnter() override; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 29f68762..d38d85c7 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -21,6 +21,8 @@ void SettingsActivity::taskTrampoline(void* param) { } void SettingsActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); // Reset selection to first item @@ -38,6 +40,8 @@ void SettingsActivity::onEnter() { } void SettingsActivity::onExit() { + Activity::onExit(); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { @@ -76,7 +80,7 @@ void SettingsActivity::loop() { } } -void SettingsActivity::toggleCurrentSetting() { +void SettingsActivity::toggleCurrentSetting() const { // Validate index if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { return; diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 333f467c..6fe5db1f 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -32,11 +32,11 @@ class SettingsActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; - void toggleCurrentSetting(); + void toggleCurrentSetting() const; public: explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoHome) - : Activity(renderer, inputManager), onGoHome(onGoHome) {} + : Activity("Settings", renderer, inputManager), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/util/FullScreenMessageActivity.cpp b/src/activities/util/FullScreenMessageActivity.cpp index a56952fc..cf84cc5c 100644 --- a/src/activities/util/FullScreenMessageActivity.cpp +++ b/src/activities/util/FullScreenMessageActivity.cpp @@ -5,6 +5,8 @@ #include "config.h" void FullScreenMessageActivity::onEnter() { + Activity::onEnter(); + const auto height = renderer.getLineHeight(UI_FONT_ID); const auto top = (GfxRenderer::getScreenHeight() - height) / 2; diff --git a/src/activities/util/FullScreenMessageActivity.h b/src/activities/util/FullScreenMessageActivity.h index 3180ddbf..8c6e30e5 100644 --- a/src/activities/util/FullScreenMessageActivity.h +++ b/src/activities/util/FullScreenMessageActivity.h @@ -16,6 +16,9 @@ class FullScreenMessageActivity final : public Activity { explicit FullScreenMessageActivity(GfxRenderer& renderer, InputManager& inputManager, std::string text, const EpdFontStyle style = REGULAR, const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) - : Activity(renderer, inputManager), text(std::move(text)), style(style), refreshMode(refreshMode) {} + : Activity("FullScreenMessage", renderer, inputManager), + text(std::move(text)), + style(style), + refreshMode(refreshMode) {} void onEnter() override; }; diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 68fc0792..b4ed01ca 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -12,11 +12,6 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = { const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"", "ZXCVBNM<>?", "^ _____ 0 && text.length() > maxLength) { @@ -37,15 +32,13 @@ void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string } void KeyboardEntryActivity::onEnter() { + Activity::onEnter(); + // Reset state when entering the activity complete = false; cancelled = false; } -void KeyboardEntryActivity::onExit() { - // Clean up if needed -} - void KeyboardEntryActivity::loop() { handleInput(); render(10); diff --git a/src/activities/util/KeyboardEntryActivity.h b/src/activities/util/KeyboardEntryActivity.h index af6d9b46..3b5b8063 100644 --- a/src/activities/util/KeyboardEntryActivity.h +++ b/src/activities/util/KeyboardEntryActivity.h @@ -34,7 +34,12 @@ class KeyboardEntryActivity : public Activity { * @param isPassword If true, display asterisks instead of actual characters */ KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text", - const std::string& initialText = "", size_t maxLength = 0, bool isPassword = false); + const std::string& initialText = "", const size_t maxLength = 0, const bool isPassword = false) + : Activity("KeyboardEntry", renderer, inputManager), + title(title), + text(initialText), + maxLength(maxLength), + isPassword(isPassword) {} /** * Handle button input. Call this in your main loop. @@ -85,7 +90,6 @@ class KeyboardEntryActivity : public Activity { // Activity overrides void onEnter() override; - void onExit() override; void loop() override; private: From b39ce22e5427a528459398babc2a7ef280e456e0 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 00:31:25 +1100 Subject: [PATCH 20/46] Cleanup of activities --- src/activities/Activity.h | 5 +- src/activities/home/HomeActivity.cpp | 5 +- .../network/CrossPointWebServerActivity.cpp | 2 + .../network/CrossPointWebServerActivity.h | 4 +- .../network/WifiSelectionActivity.cpp | 70 +++++++++++-------- .../network/WifiSelectionActivity.h | 10 +-- src/activities/reader/EpubReaderActivity.cpp | 1 + .../EpubReaderChapterSelectionActivity.cpp | 1 + .../reader/FileSelectionActivity.cpp | 1 + src/activities/settings/SettingsActivity.cpp | 1 + .../CrossPointWebServer.cpp | 0 .../server => network}/CrossPointWebServer.h | 2 - src/{ => network}/html/FilesPageFooter.html | 0 src/{ => network}/html/FilesPageHeader.html | 0 src/{ => network}/html/HomePage.html | 0 15 files changed, 57 insertions(+), 45 deletions(-) rename src/{activities/network/server => network}/CrossPointWebServer.cpp (100%) rename src/{activities/network/server => network}/CrossPointWebServer.h (96%) rename src/{ => network}/html/FilesPageFooter.html (100%) rename src/{ => network}/html/FilesPageHeader.html (100%) rename src/{ => network}/html/HomePage.html (100%) diff --git a/src/activities/Activity.h b/src/activities/Activity.h index 90a2eb10..3a61db69 100644 --- a/src/activities/Activity.h +++ b/src/activities/Activity.h @@ -1,8 +1,11 @@ #pragma once -#include +#include + +#include #include +class InputManager; class GfxRenderer; class Activity { diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 9cfa1851..bbda1307 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,6 +1,7 @@ #include "HomeActivity.h" #include +#include #include #include "config.h" @@ -83,8 +84,8 @@ void HomeActivity::displayTaskLoop() { void HomeActivity::render() const { renderer.clearScreen(); - const auto pageWidth = GfxRenderer::getScreenWidth(); - const auto pageHeight = GfxRenderer::getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); // Draw selection diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index d02411fd..5bf571a9 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -1,8 +1,10 @@ #include "CrossPointWebServerActivity.h" #include +#include #include +#include "WifiSelectionActivity.h" #include "config.h" void CrossPointWebServerActivity::taskTrampoline(void* param) { diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 76d19f1c..6889f6eb 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -7,10 +7,8 @@ #include #include -#include "../Activity.h" -#include "WifiSelectionActivity.h" #include "activities/ActivityWithSubactivity.h" -#include "server/CrossPointWebServer.h" +#include "network/CrossPointWebServer.h" // Web server activity states enum class WebServerActivityState { diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 7f202fe3..c18e0f57 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -6,6 +6,7 @@ #include #include "WifiCredentialStore.h" +#include "activities/util/KeyboardEntryActivity.h" #include "config.h" void WifiSelectionActivity::taskTrampoline(void* param) { @@ -18,8 +19,10 @@ void WifiSelectionActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); - // Load saved WiFi credentials + // Load saved WiFi credentials - SD card operations need lock as we use SPI for both + xSemaphoreTake(renderingMutex, portMAX_DELAY); WIFI_STORE.loadFromFile(); + xSemaphoreGive(renderingMutex); // Reset state selectedNetworkIndex = 0; @@ -32,7 +35,6 @@ void WifiSelectionActivity::onEnter() { usedSavedPassword = false; savePromptSelection = 0; forgetPromptSelection = 0; - keyboard.reset(); // Trigger first update to show scanning message updateRequired = true; @@ -98,7 +100,7 @@ void WifiSelectionActivity::startWifiScan() { } void WifiSelectionActivity::processWifiScanResults() { - int16_t scanResult = WiFi.scanComplete(); + const int16_t scanResult = WiFi.scanComplete(); if (scanResult == WIFI_SCAN_RUNNING) { // Scan still in progress @@ -117,7 +119,7 @@ void WifiSelectionActivity::processWifiScanResults() { for (int i = 0; i < scanResult; i++) { std::string ssid = WiFi.SSID(i).c_str(); - int32_t rssi = WiFi.RSSI(i); + const int32_t rssi = WiFi.RSSI(i); // Skip hidden networks (empty SSID) if (ssid.empty()) { @@ -154,7 +156,7 @@ void WifiSelectionActivity::processWifiScanResults() { updateRequired = true; } -void WifiSelectionActivity::selectNetwork(int index) { +void WifiSelectionActivity::selectNetwork(const int index) { if (index < 0 || index >= static_cast(networks.size())) { return; } @@ -180,11 +182,11 @@ void WifiSelectionActivity::selectNetwork(int index) { if (selectedRequiresPassword) { // Show password entry state = WifiSelectionState::PASSWORD_ENTRY; - keyboard.reset(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password", - "", // No initial text - 64, // Max password length - false // Show password by default (hard keyboard to use) - )); + enterNewActivity(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password", + "", // No initial text + 64, // Max password length + false // Show password by default (hard keyboard to use) + )); updateRequired = true; } else { // Connect directly for open networks @@ -202,8 +204,8 @@ void WifiSelectionActivity::attemptConnection() { WiFi.mode(WIFI_STA); // Get password from keyboard if we just entered it - if (keyboard && !usedSavedPassword) { - enteredPassword = keyboard->getText(); + if (subActivity && !usedSavedPassword) { + enteredPassword = static_cast(subActivity.get())->getText(); } if (selectedRequiresPassword && !enteredPassword.empty()) { @@ -218,7 +220,7 @@ void WifiSelectionActivity::checkConnectionStatus() { return; } - wl_status_t status = WiFi.status(); + const wl_status_t status = WiFi.status(); if (status == WL_CONNECTED) { // Successfully connected @@ -275,7 +277,8 @@ void WifiSelectionActivity::loop() { } // Handle password entry state - if (state == WifiSelectionState::PASSWORD_ENTRY && keyboard) { + if (state == WifiSelectionState::PASSWORD_ENTRY && subActivity) { + const auto keyboard = static_cast(subActivity.get()); keyboard->handleInput(); if (keyboard->isComplete()) { @@ -285,7 +288,7 @@ void WifiSelectionActivity::loop() { if (keyboard->isCancelled()) { state = WifiSelectionState::NETWORK_LIST; - keyboard.reset(); + exitActivity(); updateRequired = true; return; } @@ -309,7 +312,9 @@ void WifiSelectionActivity::loop() { } else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (savePromptSelection == 0) { // User chose "Yes" - save the password + xSemaphoreTake(renderingMutex, portMAX_DELAY); WIFI_STORE.addCredential(selectedSSID, enteredPassword); + xSemaphoreGive(renderingMutex); } // Complete - parent will start web server onComplete(true); @@ -335,7 +340,9 @@ void WifiSelectionActivity::loop() { } else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (forgetPromptSelection == 0) { // User chose "Yes" - forget the network + xSemaphoreTake(renderingMutex, portMAX_DELAY); WIFI_STORE.removeCredential(selectedSSID); + xSemaphoreGive(renderingMutex); // Update the network list to reflect the change const auto network = find_if(networks.begin(), networks.end(), [this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; }); @@ -410,15 +417,18 @@ void WifiSelectionActivity::loop() { } } -std::string WifiSelectionActivity::getSignalStrengthIndicator(int32_t rssi) const { +std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi) const { // Convert RSSI to signal bars representation if (rssi >= -50) { return "||||"; // Excellent - } else if (rssi >= -60) { + } + if (rssi >= -60) { return "||| "; // Good - } else if (rssi >= -70) { + } + if (rssi >= -70) { return "|| "; // Fair - } else if (rssi >= -80) { + } + if (rssi >= -80) { return "| "; // Weak } return " "; // Very weak @@ -484,8 +494,8 @@ void WifiSelectionActivity::renderNetworkList() const { renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press OK to scan again", true, REGULAR); } else { // Calculate how many networks we can display - const int startY = 60; - const int lineHeight = 25; + constexpr int startY = 60; + constexpr int lineHeight = 25; const int maxVisibleNetworks = (pageHeight - startY - 40) / lineHeight; // Calculate scroll offset to keep selected item visible @@ -557,8 +567,8 @@ void WifiSelectionActivity::renderPasswordEntry() const { renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR); // Draw keyboard - if (keyboard) { - keyboard->render(58); + if (subActivity) { + static_cast(subActivity.get())->render(58); } } @@ -593,7 +603,7 @@ void WifiSelectionActivity::renderConnected() const { } renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); - std::string ipInfo = "IP Address: " + connectedIP; + const std::string ipInfo = "IP Address: " + connectedIP; renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue", true, REGULAR); @@ -617,9 +627,9 @@ void WifiSelectionActivity::renderSavePrompt() const { // Draw Yes/No buttons const int buttonY = top + 80; - const int buttonWidth = 60; - const int buttonSpacing = 30; - const int totalWidth = buttonWidth * 2 + buttonSpacing; + constexpr int buttonWidth = 60; + constexpr int buttonSpacing = 30; + constexpr int totalWidth = buttonWidth * 2 + buttonSpacing; const int startX = (pageWidth - totalWidth) / 2; // Draw "Yes" button @@ -667,9 +677,9 @@ void WifiSelectionActivity::renderForgetPrompt() const { // Draw Yes/No buttons const int buttonY = top + 80; - const int buttonWidth = 60; - const int buttonSpacing = 30; - const int totalWidth = buttonWidth * 2 + buttonSpacing; + constexpr int buttonWidth = 60; + constexpr int buttonSpacing = 30; + constexpr int totalWidth = buttonWidth * 2 + buttonSpacing; const int startX = (pageWidth - totalWidth) / 2; // Draw "Yes" button diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index a009de1c..1cba2a4a 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -9,8 +9,7 @@ #include #include -#include "../Activity.h" -#include "../util/KeyboardEntryActivity.h" +#include "activities/ActivityWithSubactivity.h" // Structure to hold WiFi network information struct WifiNetworkInfo { @@ -43,7 +42,7 @@ enum class WifiSelectionState { * * The onComplete callback receives true if connected successfully, false if cancelled. */ -class WifiSelectionActivity final : public Activity { +class WifiSelectionActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; @@ -56,9 +55,6 @@ class WifiSelectionActivity final : public Activity { std::string selectedSSID; bool selectedRequiresPassword = false; - // On-screen keyboard for password entry - std::unique_ptr keyboard; - // Connection result std::string connectedIP; std::string connectionError; @@ -98,7 +94,7 @@ class WifiSelectionActivity final : public Activity { public: explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onComplete) - : Activity("WifiSelection", renderer, inputManager), onComplete(onComplete) {} + : ActivityWithSubactivity("WifiSelection", renderer, inputManager), onComplete(onComplete) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 84abc49c..fd9a8135 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "Battery.h" diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 07fd456d..1cda06ea 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -1,6 +1,7 @@ #include "EpubReaderChapterSelectionActivity.h" #include +#include #include #include "config.h" diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index fc39a8c2..a6c10834 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -1,6 +1,7 @@ #include "FileSelectionActivity.h" #include +#include #include #include "config.h" diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index d38d85c7..a6c77140 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -1,6 +1,7 @@ #include "SettingsActivity.h" #include +#include #include "CrossPointSettings.h" #include "config.h" diff --git a/src/activities/network/server/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp similarity index 100% rename from src/activities/network/server/CrossPointWebServer.cpp rename to src/network/CrossPointWebServer.cpp diff --git a/src/activities/network/server/CrossPointWebServer.h b/src/network/CrossPointWebServer.h similarity index 96% rename from src/activities/network/server/CrossPointWebServer.h rename to src/network/CrossPointWebServer.h index 79c0b8ea..16983b0b 100644 --- a/src/activities/network/server/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -2,8 +2,6 @@ #include -#include -#include #include // Structure to hold file information diff --git a/src/html/FilesPageFooter.html b/src/network/html/FilesPageFooter.html similarity index 100% rename from src/html/FilesPageFooter.html rename to src/network/html/FilesPageFooter.html diff --git a/src/html/FilesPageHeader.html b/src/network/html/FilesPageHeader.html similarity index 100% rename from src/html/FilesPageHeader.html rename to src/network/html/FilesPageHeader.html diff --git a/src/html/HomePage.html b/src/network/html/HomePage.html similarity index 100% rename from src/html/HomePage.html rename to src/network/html/HomePage.html From ce37c80c2dc04d314018024fcf31f23807395ca2 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Sun, 21 Dec 2025 14:53:55 +0100 Subject: [PATCH 21/46] Improve power button hold measurement for boot (#95) Improves the duration for which the power button needs to be held - see #53. I left the measurement code for the calibration value in, as it will likely change if we move the settings to NVS. --- src/main.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0d217812..cf74479a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -60,6 +60,9 @@ EpdFontFamily ubuntuFontFamily(&ubuntu10Font, &ubuntuBold10Font); // Auto-sleep timeout (10 minutes of inactivity) constexpr unsigned long AUTO_SLEEP_TIMEOUT_MS = 10 * 60 * 1000; +// measurement of power button press duration calibration value +unsigned long t1 = 0; +unsigned long t2 = 0; void exitActivity() { if (currentActivity) { @@ -79,6 +82,10 @@ void verifyWakeupLongPress() { // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration() const auto start = millis(); bool abort = false; + // It takes us some time to wake up from deep sleep, so we need to subtract that from the duration + uint16_t calibration = 25; + uint16_t calibratedPressDuration = + (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; inputManager.update(); // Verify the user has actually pressed @@ -87,13 +94,13 @@ void verifyWakeupLongPress() { inputManager.update(); } + t2 = millis(); if (inputManager.isPressed(InputManager::BTN_POWER)) { do { delay(10); inputManager.update(); - } while (inputManager.isPressed(InputManager::BTN_POWER) && - inputManager.getHeldTime() < SETTINGS.getPowerButtonDuration()); - abort = inputManager.getHeldTime() < SETTINGS.getPowerButtonDuration(); + } while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration); + abort = inputManager.getHeldTime() < calibratedPressDuration; } else { abort = true; } @@ -120,7 +127,7 @@ void enterDeepSleep() { enterNewActivity(new SleepActivity(renderer, inputManager)); einkDisplay.deepSleep(); - + Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1); Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); // Ensure that the power button has been released to avoid immediately turning back on if you're holding it @@ -152,6 +159,7 @@ void onGoHome() { } void setup() { + t1 = millis(); Serial.begin(115200); Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); From 689b539c6b53bcba6495f653c5d09dec99ea4d31 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 03:19:49 +1100 Subject: [PATCH 22/46] Stream CrossPointWebServer data over JSON APIs (#97) ## Summary * HTML files are now static, streamed directly to the client without modification * For any dynamic values, load via JSON APIs * For files page, we stream the JSON content as we scan the directory to avoid holding onto too much data ## Additional details * We were previously building up a very large string all generated on the X4 directly, we should be leveraging the browser * Fixes https://github.com/daveallie/crosspoint-reader/issues/94 --- platformio.ini | 3 +- src/network/CrossPointWebServer.cpp | 342 +++------- src/network/CrossPointWebServer.h | 27 +- src/network/html/FilesPage.html | 859 ++++++++++++++++++++++++++ src/network/html/FilesPageFooter.html | 233 ------- src/network/html/FilesPageHeader.html | 472 -------------- src/network/html/HomePage.html | 27 +- 7 files changed, 970 insertions(+), 993 deletions(-) create mode 100644 src/network/html/FilesPage.html delete mode 100644 src/network/html/FilesPageFooter.html delete mode 100644 src/network/html/FilesPageHeader.html diff --git a/platformio.ini b/platformio.ini index a4bdcd19..0048dd8a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -9,7 +9,7 @@ framework = arduino monitor_speed = 115200 upload_speed = 921600 check_tool = cppcheck -check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --inline-suppr +check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr check_skip_packages = yes board_upload.flash_size = 16MB @@ -39,6 +39,7 @@ lib_deps = BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor InputManager=symlink://open-x4-sdk/libs/hardware/InputManager EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay + ArduinoJson @ 7.4.2 [env:default] extends = base diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 62916277..10159aba 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1,53 +1,19 @@ #include "CrossPointWebServer.h" +#include #include #include #include -#include "config.h" -#include "html/FilesPageFooterHtml.generated.h" -#include "html/FilesPageHeaderHtml.generated.h" +#include "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" namespace { - // Folders/files to hide from the web interface file browser // Note: Items starting with "." are automatically hidden const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; -const size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); - -// Helper function to escape HTML special characters to prevent XSS -String escapeHtml(const String& input) { - String output; - output.reserve(input.length() * 1.1); // Pre-allocate with some extra space - - for (size_t i = 0; i < input.length(); i++) { - char c = input.charAt(i); - switch (c) { - case '&': - output += "&"; - break; - case '<': - output += "<"; - break; - case '>': - output += ">"; - break; - case '"': - output += """; - break; - case '\'': - output += "'"; - break; - default: - output += c; - break; - } - } - return output; -} - +constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); } // namespace // File listing page template - now using generated headers: @@ -72,7 +38,7 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); - server = new WebServer(port); + server.reset(new WebServer(port)); Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap()); if (!server) { @@ -82,20 +48,22 @@ void CrossPointWebServer::begin() { // Setup routes Serial.printf("[%lu] [WEB] Setting up routes...\n", millis()); - server->on("/", HTTP_GET, [this]() { handleRoot(); }); - server->on("/status", HTTP_GET, [this]() { handleStatus(); }); - server->on("/files", HTTP_GET, [this]() { handleFileList(); }); + server->on("/", HTTP_GET, [this] { handleRoot(); }); + server->on("/files", HTTP_GET, [this] { handleFileList(); }); + + server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); + server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); // Upload endpoint with special handling for multipart form data - server->on("/upload", HTTP_POST, [this]() { handleUploadPost(); }, [this]() { handleUpload(); }); + server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); }); // Create folder endpoint - server->on("/mkdir", HTTP_POST, [this]() { handleCreateFolder(); }); + server->on("/mkdir", HTTP_POST, [this] { handleCreateFolder(); }); // Delete file/folder endpoint - server->on("/delete", HTTP_POST, [this]() { handleDelete(); }); + server->on("/delete", HTTP_POST, [this] { handleDelete(); }); - server->onNotFound([this]() { handleNotFound(); }); + server->onNotFound([this] { handleNotFound(); }); Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); server->begin(); @@ -108,7 +76,8 @@ void CrossPointWebServer::begin() { void CrossPointWebServer::stop() { if (!running || !server) { - Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running, server); + Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running, + server.get()); return; } @@ -128,9 +97,7 @@ void CrossPointWebServer::stop() { delay(50); Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis()); - delete server; - server = nullptr; - + server.reset(); Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -139,7 +106,7 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); } -void CrossPointWebServer::handleClient() { +void CrossPointWebServer::handleClient() const { static unsigned long lastDebugPrint = 0; // Check running flag FIRST before accessing server @@ -162,25 +129,18 @@ void CrossPointWebServer::handleClient() { server->handleClient(); } -void CrossPointWebServer::handleRoot() { - String html = HomePageHtml; - - // Replace placeholders with actual values - html.replace("%VERSION%", CROSSPOINT_VERSION); - html.replace("%IP_ADDRESS%", WiFi.localIP().toString()); - html.replace("%FREE_HEAP%", String(ESP.getFreeHeap())); - - server->send(200, "text/html", html); +void CrossPointWebServer::handleRoot() const { + server->send(200, "text/html", HomePageHtml); Serial.printf("[%lu] [WEB] Served root page\n", millis()); } -void CrossPointWebServer::handleNotFound() { +void CrossPointWebServer::handleNotFound() const { String message = "404 Not Found\n\n"; message += "URI: " + server->uri() + "\n"; server->send(404, "text/plain", message); } -void CrossPointWebServer::handleStatus() { +void CrossPointWebServer::handleStatus() const { String json = "{"; json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\","; json += "\"ip\":\"" + WiFi.localIP().toString() + "\","; @@ -192,26 +152,24 @@ void CrossPointWebServer::handleStatus() { server->send(200, "application/json", json); } -std::vector CrossPointWebServer::scanFiles(const char* path) { - std::vector files; - +void CrossPointWebServer::scanFiles(const char* path, const std::function& callback) const { File root = SD.open(path); if (!root) { Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path); - return files; + return; } if (!root.isDirectory()) { Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path); root.close(); - return files; + return; } Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path); File file = root.openNextFile(); while (file) { - String fileName = String(file.name()); + auto fileName = String(file.name()); // Skip hidden items (starting with ".") bool shouldHide = fileName.startsWith("."); @@ -239,37 +197,24 @@ std::vector CrossPointWebServer::scanFiles(const char* path) { info.isEpub = isEpubFile(info.name); } - files.push_back(info); + callback(info); } file.close(); file = root.openNextFile(); } root.close(); - - Serial.printf("[%lu] [WEB] Found %d items (files and folders)\n", millis(), files.size()); - return files; } -String CrossPointWebServer::formatFileSize(size_t bytes) { - if (bytes < 1024) { - return String(bytes) + " B"; - } else if (bytes < 1024 * 1024) { - return String(bytes / 1024.0, 1) + " KB"; - } else { - return String(bytes / (1024.0 * 1024.0), 1) + " MB"; - } -} - -bool CrossPointWebServer::isEpubFile(const String& filename) { +bool CrossPointWebServer::isEpubFile(const String& filename) const { String lower = filename; lower.toLowerCase(); return lower.endsWith(".epub"); } -void CrossPointWebServer::handleFileList() { - String html = FilesPageHeaderHtml; +void CrossPointWebServer::handleFileList() const { server->send(200, "text/html", FilesPageHtml); } +void CrossPointWebServer::handleFileListData() const { // Get current path from query string (default to root) String currentPath = "/"; if (server->hasArg("path")) { @@ -284,180 +229,35 @@ void CrossPointWebServer::handleFileList() { } } - // Get message from query string if present - if (server->hasArg("msg")) { - String msg = escapeHtml(server->arg("msg")); - String msgType = server->hasArg("type") ? escapeHtml(server->arg("type")) : "success"; - html += "
" + msg + "
"; - } + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("["); + char output[512]; + constexpr size_t outputSize = sizeof(output); + bool seenFirst = false; + scanFiles(currentPath.c_str(), [this, &output, seenFirst](const FileInfo& info) mutable { + JsonDocument doc; + doc["name"] = info.name; + doc["size"] = info.size; + doc["isDirectory"] = info.isDirectory; + doc["isEpub"] = info.isEpub; + const size_t written = serializeJson(doc, output, 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(), info.name.c_str()); + return; + } - // Hidden input to store current path for JavaScript - html += ""; - - // Scan files in current path first (we need counts for the header) - std::vector files = scanFiles(currentPath.c_str()); - - // Count items - int epubCount = 0; - int folderCount = 0; - size_t totalSize = 0; - for (const auto& file : files) { - if (file.isDirectory) { - folderCount++; + if (seenFirst) { + server->sendContent(","); } else { - if (file.isEpub) epubCount++; - totalSize += file.size; + seenFirst = true; } - } - - // Page header with inline breadcrumb and action buttons - html += "
"; - html += "
"; - html += "

📁 File Manager

"; - - // Inline breadcrumb - html += "
"; - html += "/"; - - if (currentPath == "/") { - html += "🏠"; - } else { - html += "🏠"; - String pathParts = currentPath.substring(1); // Remove leading / - String buildPath = ""; - int start = 0; - int end = pathParts.indexOf('/'); - - while (start < (int)pathParts.length()) { - String part; - if (end == -1) { - part = pathParts.substring(start); - buildPath += "/" + part; - html += "/" + escapeHtml(part) + ""; - break; - } else { - part = pathParts.substring(start, end); - buildPath += "/" + part; - html += "/" + escapeHtml(part) + ""; - start = end + 1; - end = pathParts.indexOf('/', start); - } - } - } - html += "
"; - html += "
"; - - // Action buttons - html += "
"; - html += ""; - html += ""; - html += "
"; - - html += "
"; // end page-header - - // Contents card with inline summary - html += "
"; - - // Contents header with inline stats - html += "
"; - html += "

Contents

"; - html += ""; - html += String(folderCount) + " folder" + (folderCount != 1 ? "s" : "") + ", "; - html += String(files.size() - folderCount) + " file" + ((files.size() - folderCount) != 1 ? "s" : "") + ", "; - html += formatFileSize(totalSize); - html += ""; - html += "
"; - - if (files.empty()) { - html += "
This folder is empty
"; - } else { - html += ""; - html += ""; - - // Sort files: folders first, then epub files, then other files, alphabetically within each group - std::sort(files.begin(), files.end(), [](const FileInfo& a, const FileInfo& b) { - // Folders come first - if (a.isDirectory != b.isDirectory) return a.isDirectory > b.isDirectory; - // Then sort by epub status (epubs first among files) - if (a.isEpub != b.isEpub) return a.isEpub > b.isEpub; - // Then alphabetically - return a.name < b.name; - }); - - for (const auto& file : files) { - String rowClass; - String icon; - String badge; - String typeStr; - String sizeStr; - - if (file.isDirectory) { - rowClass = "folder-row"; - icon = "📁"; - badge = "FOLDER"; - typeStr = "Folder"; - sizeStr = "-"; - - // Build the path to this folder - String folderPath = currentPath; - if (!folderPath.endsWith("/")) folderPath += "/"; - folderPath += file.name; - - html += ""; - html += ""; - html += ""; - html += ""; - // Escape quotes for JavaScript string - String escapedName = file.name; - escapedName.replace("'", "\\'"); - String escapedPath = folderPath; - escapedPath.replace("'", "\\'"); - html += ""; - html += ""; - } else { - rowClass = file.isEpub ? "epub-file" : ""; - icon = file.isEpub ? "📗" : "📄"; - badge = file.isEpub ? "EPUB" : ""; - String ext = file.name.substring(file.name.lastIndexOf('.') + 1); - ext.toUpperCase(); - typeStr = ext; - sizeStr = formatFileSize(file.size); - - // Build file path for delete - String filePath = currentPath; - if (!filePath.endsWith("/")) filePath += "/"; - filePath += file.name; - - html += ""; - html += ""; - html += ""; - html += ""; - // Escape quotes for JavaScript string - String escapedName = file.name; - escapedName.replace("'", "\\'"); - String escapedPath = filePath; - escapedPath.replace("'", "\\'"); - html += ""; - html += ""; - } - } - - html += "
NameTypeSizeActions
" + icon + ""; - html += "" + escapeHtml(file.name) + "" + - badge + "" + typeStr + "" + sizeStr + "
" + icon + "" + escapeHtml(file.name) + badge + "" + typeStr + "" + sizeStr + "
"; - } - - html += "
"; - - html += FilesPageFooterHtml; - - server->send(200, "text/html", html); + server->sendContent(output); + }); + server->sendContent("]"); + // End of streamed response, empty chunk to signal client + server->sendContent(""); Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); } @@ -469,7 +269,7 @@ static size_t uploadSize = 0; static bool uploadSuccess = false; static String uploadError = ""; -void CrossPointWebServer::handleUpload() { +void CrossPointWebServer::handleUpload() const { static unsigned long lastWriteTime = 0; static unsigned long uploadStartTime = 0; static size_t lastLoggedSize = 0; @@ -480,7 +280,7 @@ void CrossPointWebServer::handleUpload() { return; } - HTTPUpload& upload = server->upload(); + const HTTPUpload& upload = server->upload(); if (upload.status == UPLOAD_FILE_START) { uploadFileName = upload.filename; @@ -533,10 +333,10 @@ void CrossPointWebServer::handleUpload() { Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); } else if (upload.status == UPLOAD_FILE_WRITE) { if (uploadFile && uploadError.isEmpty()) { - unsigned long writeStartTime = millis(); - size_t written = uploadFile.write(upload.buf, upload.currentSize); - unsigned long writeEndTime = millis(); - unsigned long writeDuration = writeEndTime - writeStartTime; + const unsigned long writeStartTime = millis(); + const size_t written = uploadFile.write(upload.buf, upload.currentSize); + const unsigned long writeEndTime = millis(); + const unsigned long writeDuration = writeEndTime - writeStartTime; if (written != upload.currentSize) { uploadError = "Failed to write to SD card - disk may be full"; @@ -548,9 +348,9 @@ void CrossPointWebServer::handleUpload() { // Log progress every 50KB or if write took >100ms if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) { - unsigned long timeSinceStart = millis() - uploadStartTime; - unsigned long timeSinceLastWrite = millis() - lastWriteTime; - float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); + const unsigned long timeSinceStart = millis() - uploadStartTime; + const unsigned long timeSinceLastWrite = millis() - lastWriteTime; + const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); Serial.printf( "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu " @@ -584,23 +384,23 @@ void CrossPointWebServer::handleUpload() { } } -void CrossPointWebServer::handleUploadPost() { +void CrossPointWebServer::handleUploadPost() const { if (uploadSuccess) { server->send(200, "text/plain", "File uploaded successfully: " + uploadFileName); } else { - String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError; + const String error = uploadError.isEmpty() ? "Unknown error during upload" : uploadError; server->send(400, "text/plain", error); } } -void CrossPointWebServer::handleCreateFolder() { +void CrossPointWebServer::handleCreateFolder() const { // Get folder name from form data if (!server->hasArg("name")) { server->send(400, "text/plain", "Missing folder name"); return; } - String folderName = server->arg("name"); + const String folderName = server->arg("name"); // Validate folder name if (folderName.isEmpty()) { @@ -643,7 +443,7 @@ void CrossPointWebServer::handleCreateFolder() { } } -void CrossPointWebServer::handleDelete() { +void CrossPointWebServer::handleDelete() const { // Get path from form data if (!server->hasArg("path")) { server->send(400, "text/plain", "Missing path"); @@ -651,7 +451,7 @@ void CrossPointWebServer::handleDelete() { } String itemPath = server->arg("path"); - String itemType = server->hasArg("type") ? server->arg("type") : "file"; + const String itemType = server->hasArg("type") ? server->arg("type") : "file"; // Validate path if (itemPath.isEmpty() || itemPath == "/") { @@ -665,7 +465,7 @@ void CrossPointWebServer::handleDelete() { } // Security check: prevent deletion of protected items - String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); // Check if item starts with a dot (hidden/system file) if (itemName.startsWith(".")) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 16983b0b..327897fb 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -24,7 +24,7 @@ class CrossPointWebServer { void stop(); // Call this periodically to handle client requests - void handleClient(); + void handleClient() const; // Check if server is running bool isRunning() const { return running; } @@ -33,22 +33,23 @@ class CrossPointWebServer { uint16_t getPort() const { return port; } private: - WebServer* server = nullptr; + std::unique_ptr server = nullptr; bool running = false; uint16_t port = 80; // File scanning - std::vector scanFiles(const char* path = "/"); - String formatFileSize(size_t bytes); - bool isEpubFile(const String& filename); + void scanFiles(const char* path, const std::function& callback) const; + String formatFileSize(size_t bytes) const; + bool isEpubFile(const String& filename) const; // Request handlers - void handleRoot(); - void handleNotFound(); - void handleStatus(); - void handleFileList(); - void handleUpload(); - void handleUploadPost(); - void handleCreateFolder(); - void handleDelete(); + void handleRoot() const; + void handleNotFound() const; + void handleStatus() const; + void handleFileList() const; + void handleFileListData() const; + void handleUpload() const; + void handleUploadPost() const; + void handleCreateFolder() const; + void handleDelete() const; }; diff --git a/src/network/html/FilesPage.html b/src/network/html/FilesPage.html new file mode 100644 index 00000000..f58200f3 --- /dev/null +++ b/src/network/html/FilesPage.html @@ -0,0 +1,859 @@ + + + + + + CrossPoint Reader - Files + + + + + + + +
+
+

Contents

+ +
+ +
+
+ +
+
+
+ +
+

+ CrossPoint E-Reader • Open Source +

+
+ + + + + + + + + + + + + diff --git a/src/network/html/FilesPageFooter.html b/src/network/html/FilesPageFooter.html deleted file mode 100644 index 961753a6..00000000 --- a/src/network/html/FilesPageFooter.html +++ /dev/null @@ -1,233 +0,0 @@ -
-

- CrossPoint E-Reader • Open Source -

-
- - - - - - - - - - - - - diff --git a/src/network/html/FilesPageHeader.html b/src/network/html/FilesPageHeader.html deleted file mode 100644 index 7ebfc883..00000000 --- a/src/network/html/FilesPageHeader.html +++ /dev/null @@ -1,472 +0,0 @@ - - - - - - CrossPoint Reader - Files - - - - - - diff --git a/src/network/html/HomePage.html b/src/network/html/HomePage.html index 024c6a92..b464cf34 100644 --- a/src/network/html/HomePage.html +++ b/src/network/html/HomePage.html @@ -83,7 +83,7 @@

Device Status

Version - %VERSION% +
WiFi Status @@ -91,11 +91,11 @@
IP Address - %IP_ADDRESS% +
Free Memory - %FREE_HEAP% bytes +
@@ -104,5 +104,26 @@ CrossPoint E-Reader • Open Source

+ From 6fe28da41b4c0218c4a75d0a68d346f4354bd958 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 03:20:22 +1100 Subject: [PATCH 23/46] Cut release 0.8.1 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 0048dd8a..a44dd476 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.8.0 +crosspoint_version = 0.8.1 default_envs = default [base] From f4491875ab7e076678dddd1e8950ec73aa086b4b Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 17:16:39 +1100 Subject: [PATCH 24/46] Thoroughly deinitialise expat parsers before freeing them (#103) ## Summary * Thoroughly deinitialise expat parsers before freeing them * Spotted a few crashes when de-initing expat parsers --- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 20 +++++++++++++++---- lib/Epub/Epub/parsers/ContainerParser.cpp | 2 ++ lib/Epub/Epub/parsers/ContentOpfParser.cpp | 9 +++++++++ lib/Epub/Epub/parsers/TocNcxParser.cpp | 13 ++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 718f4d73..a6297070 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -214,10 +214,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - XML_SetUserData(parser, this); - XML_SetElementHandler(parser, startElement, endElement); - XML_SetCharacterDataHandler(parser, characterData); - FILE* file = fopen(filepath, "r"); if (!file) { Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath); @@ -225,10 +221,17 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } + XML_SetUserData(parser, this); + XML_SetElementHandler(parser, startElement, endElement); + XML_SetCharacterDataHandler(parser, characterData); + do { void* const buf = XML_GetBuffer(parser, 1024); if (!buf) { Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis()); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); fclose(file); return false; @@ -238,6 +241,9 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { if (ferror(file)) { Serial.printf("[%lu] [EHP] File read error\n", millis()); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); fclose(file); return false; @@ -248,12 +254,18 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), XML_ErrorString(XML_GetErrorCode(parser))); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); fclose(file); return false; } } while (!done); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); fclose(file); diff --git a/lib/Epub/Epub/parsers/ContainerParser.cpp b/lib/Epub/Epub/parsers/ContainerParser.cpp index db126f25..da3a7b15 100644 --- a/lib/Epub/Epub/parsers/ContainerParser.cpp +++ b/lib/Epub/Epub/parsers/ContainerParser.cpp @@ -16,6 +16,8 @@ bool ContainerParser::setup() { ContainerParser::~ContainerParser() { if (parser) { + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_ParserFree(parser); parser = nullptr; } diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 5aa73032..4d3d776f 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -22,6 +22,9 @@ bool ContentOpfParser::setup() { ContentOpfParser::~ContentOpfParser() { if (parser) { + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); parser = nullptr; } @@ -40,6 +43,9 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) { if (!buf) { Serial.printf("[%lu] [COF] Couldn't allocate memory for buffer\n", millis()); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); parser = nullptr; return 0; @@ -51,6 +57,9 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) { if (XML_ParseBuffer(parser, static_cast(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { Serial.printf("[%lu] [COF] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), XML_ErrorString(XML_GetErrorCode(parser))); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); parser = nullptr; return 0; diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index 0a613f33..f4700558 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -18,6 +18,9 @@ bool TocNcxParser::setup() { TocNcxParser::~TocNcxParser() { if (parser) { + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); parser = nullptr; } @@ -35,6 +38,11 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) { void* const buf = XML_GetBuffer(parser, 1024); if (!buf) { Serial.printf("[%lu] [TOC] Couldn't allocate memory for buffer\n", millis()); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); + XML_ParserFree(parser); + parser = nullptr; return 0; } @@ -44,6 +52,11 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) { if (XML_ParseBuffer(parser, static_cast(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { Serial.printf("[%lu] [TOC] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), XML_ErrorString(XML_GetErrorCode(parser))); + XML_StopParser(parser, XML_FALSE); // Stop any pending processing + XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks + XML_SetCharacterDataHandler(parser, nullptr); + XML_ParserFree(parser); + parser = nullptr; return 0; } From d23020e268d15302147c0474fa8140d1aa6fba53 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Mon, 22 Dec 2025 17:16:46 +1100 Subject: [PATCH 25/46] OTA updates (#96) ## Summary * Adds support for OTA * Gets latest firmware bin from latest GitHub release * I have noticed it be a little flaky unpacking the JSON and occasionally failing to start --- src/activities/settings/OtaUpdateActivity.cpp | 242 ++++++++++++++++++ src/activities/settings/OtaUpdateActivity.h | 43 ++++ src/activities/settings/SettingsActivity.cpp | 30 ++- src/activities/settings/SettingsActivity.h | 11 +- src/network/OtaUpdater.cpp | 169 ++++++++++++ src/network/OtaUpdater.h | 30 +++ 6 files changed, 514 insertions(+), 11 deletions(-) create mode 100644 src/activities/settings/OtaUpdateActivity.cpp create mode 100644 src/activities/settings/OtaUpdateActivity.h create mode 100644 src/network/OtaUpdater.cpp create mode 100644 src/network/OtaUpdater.h diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp new file mode 100644 index 00000000..d31f4103 --- /dev/null +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -0,0 +1,242 @@ +#include "OtaUpdateActivity.h" + +#include +#include +#include + +#include "activities/network/WifiSelectionActivity.h" +#include "config.h" +#include "network/OtaUpdater.h" + +void OtaUpdateActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void OtaUpdateActivity::onWifiSelectionComplete(const bool success) { + exitActivity(); + + if (!success) { + Serial.printf("[%lu] [OTA] WiFi connection failed, exiting\n", millis()); + goBack(); + return; + } + + Serial.printf("[%lu] [OTA] WiFi connected, checking for update\n", millis()); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = CHECKING_FOR_UPDATE; + xSemaphoreGive(renderingMutex); + updateRequired = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + const auto res = updater.checkForUpdate(); + if (res != OtaUpdater::OK) { + Serial.printf("[%lu] [OTA] Update check failed: %d\n", millis(), res); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FAILED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + if (!updater.isUpdateNewer()) { + Serial.printf("[%lu] [OTA] No new update available\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = NO_UPDATE; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = WAITING_CONFIRMATION; + xSemaphoreGive(renderingMutex); + updateRequired = true; +} + +void OtaUpdateActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + xTaskCreate(&OtaUpdateActivity::taskTrampoline, "OtaUpdateActivityTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // Turn on WiFi immediately + Serial.printf("[%lu] [OTA] Turning on WiFi...\n", millis()); + WiFi.mode(WIFI_STA); + + // Launch WiFi selection subactivity + Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis()); + enterNewActivity(new WifiSelectionActivity(renderer, inputManager, + [this](const bool connected) { onWifiSelectionComplete(connected); })); +} + +void OtaUpdateActivity::onExit() { + ActivityWithSubactivity::onExit(); + + // Turn off wifi + WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame + delay(100); // Allow disconnect frame to be sent + WiFi.mode(WIFI_OFF); + delay(100); // Allow WiFi hardware to fully power down + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void OtaUpdateActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void OtaUpdateActivity::render() { + if (subActivity) { + // Subactivity handles its own rendering + return; + } + + float updaterProgress = 0; + if (state == UPDATE_IN_PROGRESS) { + Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.processedSize, updater.totalSize); + updaterProgress = static_cast(updater.processedSize) / static_cast(updater.totalSize); + // Only update every 2% at the most + if (static_cast(updaterProgress * 50) == lastUpdaterPercentage / 2) { + return; + } + lastUpdaterPercentage = static_cast(updaterProgress * 100); + } + + const auto pageHeight = renderer.getScreenHeight(); + const auto pageWidth = renderer.getScreenWidth(); + + renderer.clearScreen(); + renderer.drawCenteredText(READER_FONT_ID, 10, "Update", true, BOLD); + + if (state == CHECKING_FOR_UPDATE) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Checking for update...", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == WAITING_CONFIRMATION) { + renderer.drawCenteredText(UI_FONT_ID, 200, "New update available!", true, BOLD); + renderer.drawText(UI_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION); + renderer.drawText(UI_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str()); + + renderer.drawRect(25, pageHeight - 40, 106, 40); + renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Cancel")) / 2, pageHeight - 35, + "Cancel"); + + renderer.drawRect(130, pageHeight - 40, 106, 40); + renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Update")) / 2, pageHeight - 35, + "Update"); + renderer.displayBuffer(); + return; + } + + if (state == UPDATE_IN_PROGRESS) { + renderer.drawCenteredText(UI_FONT_ID, 310, "Updating...", true, BOLD); + renderer.drawRect(20, 350, pageWidth - 40, 50); + renderer.fillRect(24, 354, static_cast(updaterProgress * static_cast(pageWidth - 44)), 42); + renderer.drawCenteredText(UI_FONT_ID, 420, (std::to_string(static_cast(updaterProgress * 100)) + "%").c_str()); + renderer.drawCenteredText( + UI_FONT_ID, 440, (std::to_string(updater.processedSize) + " / " + std::to_string(updater.totalSize)).c_str()); + renderer.displayBuffer(); + return; + } + + if (state == NO_UPDATE) { + renderer.drawCenteredText(UI_FONT_ID, 300, "No update available", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == FAILED) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Update failed", true, BOLD); + renderer.displayBuffer(); + return; + } + + if (state == FINISHED) { + renderer.drawCenteredText(UI_FONT_ID, 300, "Update complete", true, BOLD); + renderer.drawCenteredText(UI_FONT_ID, 350, "Press and hold power button to turn back on"); + renderer.displayBuffer(); + state = SHUTTING_DOWN; + return; + } +} + +void OtaUpdateActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (state == WAITING_CONFIRMATION) { + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = UPDATE_IN_PROGRESS; + xSemaphoreGive(renderingMutex); + updateRequired = true; + vTaskDelay(10 / portTICK_PERIOD_MS); + const auto res = updater.installUpdate([this](const size_t, const size_t) { updateRequired = true; }); + + if (res != OtaUpdater::OK) { + Serial.printf("[%lu] [OTA] Update failed: %d\n", millis(), res); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FAILED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + return; + } + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + state = FINISHED; + xSemaphoreGive(renderingMutex); + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + + return; + } + + if (state == FAILED) { + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + return; + } + + if (state == NO_UPDATE) { + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + goBack(); + } + return; + } + + if (state == SHUTTING_DOWN) { + ESP.restart(); + } +} diff --git a/src/activities/settings/OtaUpdateActivity.h b/src/activities/settings/OtaUpdateActivity.h new file mode 100644 index 00000000..20be6faa --- /dev/null +++ b/src/activities/settings/OtaUpdateActivity.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include + +#include "activities/ActivityWithSubactivity.h" +#include "network/OtaUpdater.h" + +class OtaUpdateActivity : public ActivityWithSubactivity { + enum State { + WIFI_SELECTION, + CHECKING_FOR_UPDATE, + WAITING_CONFIRMATION, + UPDATE_IN_PROGRESS, + NO_UPDATE, + FAILED, + FINISHED, + SHUTTING_DOWN + }; + + // Can't initialize this to 0 or the first render doesn't happen + static constexpr unsigned int UNINITIALIZED_PERCENTAGE = 111; + + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + const std::function goBack; + State state = WIFI_SELECTION; + unsigned int lastUpdaterPercentage = UNINITIALIZED_PERCENTAGE; + OtaUpdater updater; + + void onWifiSelectionComplete(bool success); + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + + public: + explicit OtaUpdateActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& goBack) + : ActivityWithSubactivity("OtaUpdate", renderer, inputManager), goBack(goBack), updater() {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index a6c77140..37f2e5a1 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -4,16 +4,19 @@ #include #include "CrossPointSettings.h" +#include "OtaUpdateActivity.h" #include "config.h" // Define the static settings list namespace { -constexpr int settingsCount = 3; +constexpr int settingsCount = 4; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, - {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}}; + {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}, + {"Check for updates", SettingType::ACTION, nullptr, {}}, +}; } // namespace void SettingsActivity::taskTrampoline(void* param) { @@ -41,7 +44,7 @@ void SettingsActivity::onEnter() { } void SettingsActivity::onExit() { - Activity::onExit(); + ActivityWithSubactivity::onExit(); // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -54,6 +57,11 @@ void SettingsActivity::onExit() { } void SettingsActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + // Handle actions with early return if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { toggleCurrentSetting(); @@ -81,7 +89,7 @@ void SettingsActivity::loop() { } } -void SettingsActivity::toggleCurrentSetting() const { +void SettingsActivity::toggleCurrentSetting() { // Validate index if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { return; @@ -96,6 +104,16 @@ void SettingsActivity::toggleCurrentSetting() const { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t currentValue = SETTINGS.*(setting.valuePtr); SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); + } else if (setting.type == SettingType::ACTION) { + if (std::string(setting.name) == "Check for updates") { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new OtaUpdateActivity(renderer, inputManager, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } } else { // Only toggle if it's a toggle type and has a value pointer return; @@ -107,7 +125,7 @@ void SettingsActivity::toggleCurrentSetting() const { void SettingsActivity::displayTaskLoop() { while (true) { - if (updateRequired) { + if (updateRequired && !subActivity) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); @@ -152,6 +170,8 @@ void SettingsActivity::render() const { // Draw help text renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit"); + renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), + pageHeight - 30, CROSSPOINT_VERSION); // Always use standard refresh for settings screen renderer.displayBuffer(); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 6fe5db1f..d88dc85f 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -3,16 +3,15 @@ #include #include -#include #include #include #include -#include "../Activity.h" +#include "activities/ActivityWithSubactivity.h" class CrossPointSettings; -enum class SettingType { TOGGLE, ENUM }; +enum class SettingType { TOGGLE, ENUM, ACTION }; // Structure to hold setting information struct SettingInfo { @@ -22,7 +21,7 @@ struct SettingInfo { std::vector enumValues; }; -class SettingsActivity final : public Activity { +class SettingsActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; @@ -32,11 +31,11 @@ class SettingsActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; - void toggleCurrentSetting() const; + void toggleCurrentSetting(); public: explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onGoHome) - : Activity("Settings", renderer, inputManager), onGoHome(onGoHome) {} + : ActivityWithSubactivity("Settings", renderer, inputManager), onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/network/OtaUpdater.cpp b/src/network/OtaUpdater.cpp new file mode 100644 index 00000000..249c4570 --- /dev/null +++ b/src/network/OtaUpdater.cpp @@ -0,0 +1,169 @@ +#include "OtaUpdater.h" + +#include +#include +#include +#include + +namespace { +constexpr char latestReleaseUrl[] = "https://api.github.com/repos/daveallie/crosspoint-reader/releases/latest"; +} + +OtaUpdater::OtaUpdaterError OtaUpdater::checkForUpdate() { + const std::unique_ptr client(new WiFiClientSecure); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), latestReleaseUrl); + + http.begin(*client, latestReleaseUrl); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + + const int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [OTA] HTTP error: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + JsonDocument doc; + const DeserializationError error = deserializeJson(doc, *client); + http.end(); + if (error) { + Serial.printf("[%lu] [OTA] JSON parse failed: %s\n", millis(), error.c_str()); + return JSON_PARSE_ERROR; + } + + if (!doc["tag_name"].is()) { + Serial.printf("[%lu] [OTA] No tag_name found\n", millis()); + return JSON_PARSE_ERROR; + } + if (!doc["assets"].is()) { + Serial.printf("[%lu] [OTA] No assets found\n", millis()); + return JSON_PARSE_ERROR; + } + + latestVersion = doc["tag_name"].as(); + + for (int i = 0; i < doc["assets"].size(); i++) { + if (doc["assets"][i]["name"] == "firmware.bin") { + otaUrl = doc["assets"][i]["browser_download_url"].as(); + otaSize = doc["assets"][i]["size"].as(); + totalSize = otaSize; + updateAvailable = true; + break; + } + } + + if (!updateAvailable) { + Serial.printf("[%lu] [OTA] No firmware.bin asset found\n", millis()); + return NO_UPDATE; + } + + Serial.printf("[%lu] [OTA] Found update: %s\n", millis(), latestVersion.c_str()); + return OK; +} + +bool OtaUpdater::isUpdateNewer() { + if (!updateAvailable || latestVersion.empty() || latestVersion == CROSSPOINT_VERSION) { + return false; + } + + // semantic version check (only match on 3 segments) + const auto updateMajor = stoi(latestVersion.substr(0, latestVersion.find('.'))); + const auto updateMinor = stoi( + latestVersion.substr(latestVersion.find('.') + 1, latestVersion.find_last_of('.') - latestVersion.find('.') - 1)); + const auto updatePatch = stoi(latestVersion.substr(latestVersion.find_last_of('.') + 1)); + + std::string currentVersion = CROSSPOINT_VERSION; + const auto currentMajor = stoi(currentVersion.substr(0, currentVersion.find('.'))); + const auto currentMinor = stoi(currentVersion.substr( + currentVersion.find('.') + 1, currentVersion.find_last_of('.') - currentVersion.find('.') - 1)); + const auto currentPatch = stoi(currentVersion.substr(currentVersion.find_last_of('.') + 1)); + + if (updateMajor > currentMajor) { + return true; + } + if (updateMajor < currentMajor) { + return false; + } + + if (updateMinor > currentMinor) { + return true; + } + if (updateMinor < currentMinor) { + return false; + } + + if (updatePatch > currentPatch) { + return true; + } + return false; +} + +const std::string& OtaUpdater::getLatestVersion() { return latestVersion; } + +OtaUpdater::OtaUpdaterError OtaUpdater::installUpdate(const std::function& onProgress) { + if (!isUpdateNewer()) { + return UPDATE_OLDER_ERROR; + } + + const std::unique_ptr client(new WiFiClientSecure); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [OTA] Fetching: %s\n", millis(), otaUrl.c_str()); + + http.begin(*client, otaUrl.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + const int httpCode = http.GET(); + + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [OTA] Download failed: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + // 2. Get length and stream + const size_t contentLength = http.getSize(); + + if (contentLength != otaSize) { + Serial.printf("[%lu] [OTA] Invalid content length\n", millis()); + http.end(); + return HTTP_ERROR; + } + + // 3. Begin the ESP-IDF Update process + if (!Update.begin(otaSize)) { + Serial.printf("[%lu] [OTA] Not enough space. Error: %s\n", millis(), Update.errorString()); + http.end(); + return INTERNAL_UPDATE_ERROR; + } + + this->totalSize = otaSize; + Serial.printf("[%lu] [OTA] Update started\n", millis()); + Update.onProgress([this, onProgress](const size_t progress, const size_t total) { + this->processedSize = progress; + this->totalSize = total; + onProgress(progress, total); + }); + const size_t written = Update.writeStream(*client); + http.end(); + + if (written == otaSize) { + Serial.printf("[%lu] [OTA] Successfully written %u bytes\n", millis(), written); + } else { + Serial.printf("[%lu] [OTA] Written only %u/%u bytes. Error: %s\n", millis(), written, otaSize, + Update.errorString()); + return INTERNAL_UPDATE_ERROR; + } + + if (Update.end() && Update.isFinished()) { + Serial.printf("[%lu] [OTA] Update complete\n", millis()); + return OK; + } else { + Serial.printf("[%lu] [OTA] Error Occurred: %s\n", millis(), Update.errorString()); + return INTERNAL_UPDATE_ERROR; + } +} diff --git a/src/network/OtaUpdater.h b/src/network/OtaUpdater.h new file mode 100644 index 00000000..dfaee88a --- /dev/null +++ b/src/network/OtaUpdater.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +class OtaUpdater { + bool updateAvailable = false; + std::string latestVersion; + std::string otaUrl; + size_t otaSize = 0; + + public: + enum OtaUpdaterError { + OK = 0, + NO_UPDATE, + HTTP_ERROR, + JSON_PARSE_ERROR, + UPDATE_OLDER_ERROR, + INTERNAL_UPDATE_ERROR, + OOM_ERROR, + }; + size_t processedSize = 0; + size_t totalSize = 0; + + OtaUpdater() = default; + bool isUpdateNewer(); + const std::string& getLatestVersion(); + OtaUpdaterError checkForUpdate(); + OtaUpdaterError installUpdate(const std::function& onProgress); +}; From 9f4f71fabe1ddd5e7bb68f58e7831161e354bb5e Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Mon, 22 Dec 2025 01:24:14 -0500 Subject: [PATCH 26/46] Add AP mode option for file transfers (#98) ## Summary * **What is the goal of this PR?** Adds WiFi Access Point (AP) mode support for File Transfer, allowing the device to create its own WiFi network that users can connect to directly - useful when no existing WiFi network is available. And in my experience is faster when the device is right next to your laptop (but maybe further from your wifi) * **What changes are included?** - New `NetworkModeSelectionActivity` - an interstitial screen asking users to choose between: - "Join a Network" - connects to an existing WiFi network (existing behavior) - "Create Hotspot" - creates a WiFi access point named "CrossPoint-Reader" - Modified `CrossPointWebServerActivity` to: - Launch the network mode selection screen before proceeding - Support starting an Access Point with mDNS (`crosspoint.local`) and DNS server for captive portal behavior - Display appropriate connection info for both modes - Modified `CrossPointWebServer` to support starting when WiFi is in AP mode (not just STA connected mode) ## Additional Context * **AP Mode Details**: The device creates an open WiFi network named "CrossPoint-Reader". Once connected, users can access the file transfer page at `http://crosspoint.local/` or `http://192.168.4.1/` * **DNS Captive Portal**: A DNS server redirects all domain requests to the device's IP, enabling captive portal behavior on some devices * **mDNS**: Hostname resolution via `crosspoint.local` is enabled for both AP and STA modes * **No breaking changes**: The "Join a Network" option preserves the existing WiFi connection flow * **Memory impact**: Minimal - the AP mode uses roughly the same resources as STA mode --- .../network/CrossPointWebServerActivity.cpp | 228 +++++++++++++++--- .../network/CrossPointWebServerActivity.h | 21 +- .../network/NetworkModeSelectionActivity.cpp | 128 ++++++++++ .../network/NetworkModeSelectionActivity.h | 41 ++++ src/network/CrossPointWebServer.cpp | 26 +- src/network/CrossPointWebServer.h | 1 + 6 files changed, 404 insertions(+), 41 deletions(-) create mode 100644 src/activities/network/NetworkModeSelectionActivity.cpp create mode 100644 src/activities/network/NetworkModeSelectionActivity.h diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 5bf571a9..b9c911e2 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -1,12 +1,28 @@ #include "CrossPointWebServerActivity.h" +#include +#include #include #include #include +#include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" #include "config.h" +namespace { +// AP Mode configuration +constexpr const char* AP_SSID = "CrossPoint-Reader"; +constexpr const char* AP_PASSWORD = nullptr; // Open network for ease of use +constexpr const char* AP_HOSTNAME = "crosspoint"; +constexpr uint8_t AP_CHANNEL = 1; +constexpr uint8_t AP_MAX_CONNECTIONS = 4; + +// DNS server for captive portal (redirects all DNS queries to our IP) +DNSServer* dnsServer = nullptr; +constexpr uint16_t DNS_PORT = 53; +} // namespace + void CrossPointWebServerActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -20,7 +36,9 @@ void CrossPointWebServerActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); // Reset state - state = WebServerActivityState::WIFI_SELECTION; + state = WebServerActivityState::MODE_SELECTION; + networkMode = NetworkMode::JOIN_NETWORK; + isApMode = false; connectedIP.clear(); connectedSSID.clear(); lastHandleClientTime = 0; @@ -33,14 +51,12 @@ void CrossPointWebServerActivity::onEnter() { &displayTaskHandle // Task handle ); - // Turn on WiFi immediately - Serial.printf("[%lu] [WEBACT] Turning on WiFi...\n", millis()); - WiFi.mode(WIFI_STA); - - // Launch WiFi selection subactivity - Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); - enterNewActivity(new WifiSelectionActivity(renderer, inputManager, - [this](const bool connected) { onWifiSelectionComplete(connected); })); + // Launch network mode selection subactivity + Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis()); + enterNewActivity(new NetworkModeSelectionActivity( + renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, + [this]() { onGoBack(); } // Cancel goes back to home + )); } void CrossPointWebServerActivity::onExit() { @@ -53,14 +69,30 @@ void CrossPointWebServerActivity::onExit() { // Stop the web server first (before disconnecting WiFi) stopWebServer(); + // Stop mDNS + MDNS.end(); + + // Stop DNS server if running (AP mode) + if (dnsServer) { + Serial.printf("[%lu] [WEBACT] Stopping DNS server...\n", millis()); + dnsServer->stop(); + delete dnsServer; + dnsServer = nullptr; + } + // CRITICAL: Wait for LWIP stack to flush any pending packets Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); delay(500); // Disconnect WiFi gracefully - Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); - WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame - delay(100); // Allow disconnect frame to be sent + if (isApMode) { + Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis()); + WiFi.softAPdisconnect(true); + } else { + Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); + WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame + } + delay(100); // Allow disconnect frame to be sent Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); WiFi.mode(WIFI_OFF); @@ -89,6 +121,33 @@ void CrossPointWebServerActivity::onExit() { Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); } +void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { + Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), + mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot"); + + networkMode = mode; + isApMode = (mode == NetworkMode::CREATE_HOTSPOT); + + // Exit mode selection subactivity + exitActivity(); + + if (mode == NetworkMode::JOIN_NETWORK) { + // STA mode - launch WiFi selection + Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); + WiFi.mode(WIFI_STA); + + state = WebServerActivityState::WIFI_SELECTION; + Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); + enterNewActivity(new WifiSelectionActivity(renderer, inputManager, + [this](const bool connected) { onWifiSelectionComplete(connected); })); + } else { + // AP mode - start access point + state = WebServerActivityState::AP_STARTING; + updateRequired = true; + startAccessPoint(); + } +} + void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); @@ -96,17 +155,83 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) // Get connection info before exiting subactivity connectedIP = static_cast(subActivity.get())->getConnectedIP(); connectedSSID = WiFi.SSID().c_str(); + isApMode = false; exitActivity(); + // Start mDNS for hostname resolution + if (MDNS.begin(AP_HOSTNAME)) { + Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); + } + // Start the web server startWebServer(); } else { - // User cancelled - go back - onGoBack(); + // User cancelled - go back to mode selection + exitActivity(); + state = WebServerActivityState::MODE_SELECTION; + enterNewActivity(new NetworkModeSelectionActivity( + renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, + [this]() { onGoBack(); })); } } +void CrossPointWebServerActivity::startAccessPoint() { + Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis()); + Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Configure and start the AP + WiFi.mode(WIFI_AP); + delay(100); + + // Start soft AP + bool apStarted; + if (AP_PASSWORD && strlen(AP_PASSWORD) >= 8) { + apStarted = WiFi.softAP(AP_SSID, AP_PASSWORD, AP_CHANNEL, false, AP_MAX_CONNECTIONS); + } else { + // Open network (no password) + apStarted = WiFi.softAP(AP_SSID, nullptr, AP_CHANNEL, false, AP_MAX_CONNECTIONS); + } + + if (!apStarted) { + Serial.printf("[%lu] [WEBACT] ERROR: Failed to start Access Point!\n", millis()); + onGoBack(); + return; + } + + delay(100); // Wait for AP to fully initialize + + // Get AP IP address + const IPAddress apIP = WiFi.softAPIP(); + char ipStr[16]; + snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", apIP[0], apIP[1], apIP[2], apIP[3]); + connectedIP = ipStr; + connectedSSID = AP_SSID; + + Serial.printf("[%lu] [WEBACT] Access Point started!\n", millis()); + Serial.printf("[%lu] [WEBACT] SSID: %s\n", millis(), AP_SSID); + Serial.printf("[%lu] [WEBACT] IP: %s\n", millis(), connectedIP.c_str()); + + // Start mDNS for hostname resolution + if (MDNS.begin(AP_HOSTNAME)) { + Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); + } else { + Serial.printf("[%lu] [WEBACT] WARNING: mDNS failed to start\n", millis()); + } + + // Start DNS server for captive portal behavior + // This redirects all DNS queries to our IP, making any domain typed resolve to us + dnsServer = new DNSServer(); + dnsServer->setErrorReplyCode(DNSReplyCode::NoError); + dnsServer->start(DNS_PORT, "*", apIP); + Serial.printf("[%lu] [WEBACT] DNS server started for captive portal\n", millis()); + + Serial.printf("[%lu] [WEBACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Start the web server + startWebServer(); +} + void CrossPointWebServerActivity::startWebServer() { Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis()); @@ -150,6 +275,11 @@ void CrossPointWebServerActivity::loop() { // Handle different states if (state == WebServerActivityState::SERVER_RUNNING) { + // Handle DNS requests for captive portal (AP mode only) + if (isApMode && dnsServer) { + dnsServer->processNextRequest(); + } + // Handle web server requests - call handleClient multiple times per loop // to improve responsiveness and upload throughput if (webServer && webServer->isRunning()) { @@ -193,35 +323,71 @@ void CrossPointWebServerActivity::displayTaskLoop() { void CrossPointWebServerActivity::render() const { // Only render our own UI when server is running - // WiFi selection handles its own rendering + // Subactivities handle their own rendering if (state == WebServerActivityState::SERVER_RUNNING) { renderer.clearScreen(); renderServerRunning(); renderer.displayBuffer(); + } else if (state == WebServerActivityState::AP_STARTING) { + renderer.clearScreen(); + const auto pageHeight = renderer.getScreenHeight(); + renderer.drawCenteredText(READER_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, BOLD); + renderer.displayBuffer(); } } void CrossPointWebServerActivity::renderServerRunning() const { const auto pageHeight = renderer.getScreenHeight(); - const auto height = renderer.getLineHeight(UI_FONT_ID); - const auto top = (pageHeight - height * 5) / 2; - renderer.drawCenteredText(READER_FONT_ID, top - 30, "File Transfer", true, BOLD); + // Use consistent line spacing + constexpr int LINE_SPACING = 28; // Space between lines - std::string ssidInfo = "Network: " + connectedSSID; - if (ssidInfo.length() > 28) { - ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD); + + if (isApMode) { + // AP mode display - center the content block + const int startY = 55; + + renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD); + + std::string ssidInfo = "Network: " + connectedSSID; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network", + true, REGULAR); + + // Show primary URL (hostname) + std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD); + + // Show IP address as fallback + std::string ipUrl = "or http://" + connectedIP + "/"; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR); + } else { + // STA mode display (original behavior) + const int startY = 65; + + std::string ssidInfo = "Network: " + connectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + } + renderer.drawCenteredText(UI_FONT_ID, startY, ssidInfo.c_str(), true, REGULAR); + + std::string ipInfo = "IP Address: " + connectedIP; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ipInfo.c_str(), true, REGULAR); + + // Show web server URL prominently + std::string webInfo = "http://" + connectedIP + "/"; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD); + + // Also show hostname URL + std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/"; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR); } - renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); - - std::string ipInfo = "IP Address: " + connectedIP; - renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR); - - // Show web server URL prominently - std::string webInfo = "http://" + connectedIP + "/"; - renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD); - - renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR); } diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 6889f6eb..038a0c4b 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -7,12 +7,15 @@ #include #include +#include "NetworkModeSelectionActivity.h" #include "activities/ActivityWithSubactivity.h" #include "network/CrossPointWebServer.h" // Web server activity states enum class WebServerActivityState { - WIFI_SELECTION, // WiFi selection subactivity is active + MODE_SELECTION, // Choosing between Join Network and Create Hotspot + WIFI_SELECTION, // WiFi selection subactivity is active (for Join Network mode) + AP_STARTING, // Starting Access Point mode SERVER_RUNNING, // Web server is running and handling requests SHUTTING_DOWN // Shutting down server and WiFi }; @@ -20,8 +23,10 @@ enum class WebServerActivityState { /** * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: - * - Immediately turns on WiFi and launches WifiSelectionActivity on enter - * - When WifiSelectionActivity completes successfully, starts the CrossPointWebServer + * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) + * - For STA mode: Launches WifiSelectionActivity to connect to an existing network + * - For AP mode: Creates an Access Point that clients can connect to + * - Starts the CrossPointWebServer when connected * - Handles client requests in its loop() function * - Cleans up the server and shuts down WiFi on exit */ @@ -29,15 +34,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; - WebServerActivityState state = WebServerActivityState::WIFI_SELECTION; + WebServerActivityState state = WebServerActivityState::MODE_SELECTION; const std::function onGoBack; + // Network mode + NetworkMode networkMode = NetworkMode::JOIN_NETWORK; + bool isApMode = false; + // Web server - owned by this activity std::unique_ptr webServer; // Server status std::string connectedIP; - std::string connectedSSID; + std::string connectedSSID; // For STA mode: network name, For AP mode: AP name // Performance monitoring unsigned long lastHandleClientTime = 0; @@ -47,7 +56,9 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity { void render() const; void renderServerRunning() const; + void onNetworkModeSelected(NetworkMode mode); void onWifiSelectionComplete(bool connected); + void startAccessPoint(); void startWebServer(); void stopWebServer(); diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp new file mode 100644 index 00000000..637d82d9 --- /dev/null +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -0,0 +1,128 @@ +#include "NetworkModeSelectionActivity.h" + +#include +#include + +#include "config.h" + +namespace { +constexpr int MENU_ITEM_COUNT = 2; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network", + "Create a WiFi network others can join"}; +} // namespace + +void NetworkModeSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void NetworkModeSelectionActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Reset selection + selectedIndex = 0; + + // Trigger first update + updateRequired = true; + + xTaskCreate(&NetworkModeSelectionActivity::taskTrampoline, "NetworkModeTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void NetworkModeSelectionActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void NetworkModeSelectionActivity::loop() { + // Handle back button - cancel + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + onCancel(); + return; + } + + // Handle confirm button - select current option + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT; + onModeSelected(mode); + return; + } + + // Handle navigation + const bool prevPressed = + inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); + const bool nextPressed = + inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); + + if (prevPressed) { + selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT; + updateRequired = true; + } else if (nextPressed) { + selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT; + updateRequired = true; + } +} + +void NetworkModeSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void NetworkModeSelectionActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + renderer.drawCenteredText(READER_FONT_ID, 10, "File Transfer", true, BOLD); + + // Draw subtitle + renderer.drawCenteredText(UI_FONT_ID, 50, "How would you like to connect?", true, REGULAR); + + // Draw menu items centered on screen + constexpr int itemHeight = 50; // Height for each menu item (including description) + const int startY = (pageHeight - (MENU_ITEM_COUNT * itemHeight)) / 2 + 10; + + for (int i = 0; i < MENU_ITEM_COUNT; i++) { + const int itemY = startY + i * itemHeight; + const bool isSelected = (i == selectedIndex); + + // Draw selection highlight (black fill) for selected item + if (isSelected) { + renderer.fillRect(20, itemY - 2, pageWidth - 40, itemHeight - 6); + } + + // Draw text: black=false (white text) when selected (on black background) + // black=true (black text) when not selected (on white background) + renderer.drawText(UI_FONT_ID, 30, itemY, MENU_ITEMS[i], /*black=*/!isSelected); + renderer.drawText(SMALL_FONT_ID, 30, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected); + } + + // Draw help text at bottom + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press OK to select, BACK to cancel", true, REGULAR); + + renderer.displayBuffer(); +} diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h new file mode 100644 index 00000000..90f4282b --- /dev/null +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include + +#include + +#include "../Activity.h" + +// Enum for network mode selection +enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT }; + +/** + * NetworkModeSelectionActivity presents the user with a choice: + * - "Join a Network" - Connect to an existing WiFi network (STA mode) + * - "Create Hotspot" - Create an Access Point that others can connect to (AP mode) + * + * The onModeSelected callback is called with the user's choice. + * The onCancel callback is called if the user presses back. + */ +class NetworkModeSelectionActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int selectedIndex = 0; + bool updateRequired = false; + const std::function onModeSelected; + const std::function onCancel; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit NetworkModeSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, + const std::function& onModeSelected, + const std::function& onCancel) + : Activity("NetworkModeSelection", renderer, inputManager), onModeSelected(onModeSelected), onCancel(onCancel) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 10159aba..f14081f7 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -30,12 +30,22 @@ void CrossPointWebServer::begin() { return; } - if (WiFi.status() != WL_CONNECTED) { - Serial.printf("[%lu] [WEB] Cannot start webserver - WiFi not connected\n", millis()); + // Check if we have a valid network connection (either STA connected or AP mode) + const wifi_mode_t wifiMode = WiFi.getMode(); + const bool isStaConnected = (wifiMode & WIFI_MODE_STA) && (WiFi.status() == WL_CONNECTED); + const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running + + if (!isStaConnected && !isInApMode) { + Serial.printf("[%lu] [WEB] Cannot start webserver - no valid network (mode=%d, status=%d)\n", millis(), wifiMode, + WiFi.status()); return; } + // Store AP mode flag for later use (e.g., in handleStatus) + apMode = isInApMode; + Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); + Serial.printf("[%lu] [WEB] Network mode: %s\n", millis(), apMode ? "AP" : "STA"); Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); server.reset(new WebServer(port)); @@ -70,7 +80,9 @@ void CrossPointWebServer::begin() { running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); - Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), WiFi.localIP().toString().c_str()); + // Show the correct IP based on network mode + const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); + Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); } @@ -141,10 +153,14 @@ void CrossPointWebServer::handleNotFound() const { } void CrossPointWebServer::handleStatus() const { + // Get correct IP based on AP vs STA mode + const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); + String json = "{"; json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\","; - json += "\"ip\":\"" + WiFi.localIP().toString() + "\","; - json += "\"rssi\":" + String(WiFi.RSSI()) + ","; + json += "\"ip\":\"" + ipAddr + "\","; + json += "\"mode\":\"" + String(apMode ? "AP" : "STA") + "\","; + json += "\"rssi\":" + String(apMode ? 0 : WiFi.RSSI()) + ","; // RSSI not applicable in AP mode json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ","; json += "\"uptime\":" + String(millis() / 1000); json += "}"; diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 327897fb..1be07b4a 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -35,6 +35,7 @@ class CrossPointWebServer { private: std::unique_ptr server = nullptr; bool running = false; + bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; // File scanning From 66ddb52103a7ccef8f0d04d872b6268d9224bd33 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 23 Dec 2025 12:16:42 +1100 Subject: [PATCH 27/46] Pin espressif32 platform version --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index a44dd476..42de32c0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -3,7 +3,7 @@ crosspoint_version = 0.8.1 default_envs = default [base] -platform = espressif32 +platform = espressif32 @ 6.12.0 board = esp32-c3-devkitm-1 framework = arduino monitor_speed = 115200 From 1107590b5676dbf8091be0d264a3eb6c8a1add98 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 23 Dec 2025 14:14:10 +1100 Subject: [PATCH 28/46] Standardize File handling with FsHelpers (#110) ## Summary * Standardize File handling with FsHelpers * Better central place to manage to logic of if files exist/open for reading/writing --- lib/Epub/Epub.cpp | 81 +++++-------- lib/Epub/Epub/FsHelpers.cpp | 36 ------ lib/Epub/Epub/FsHelpers.h | 6 - lib/Epub/Epub/Page.cpp | 36 +++--- lib/Epub/Epub/Page.h | 12 +- lib/Epub/Epub/Section.cpp | 50 ++++---- lib/Epub/Epub/blocks/TextBlock.cpp | 32 ++--- lib/Epub/Epub/blocks/TextBlock.h | 5 +- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 20 ++-- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 4 +- lib/FsHelpers/FsHelpers.cpp | 112 ++++++++++++++++++ lib/FsHelpers/FsHelpers.h | 14 +++ lib/Serialization/Serialization.h | 25 ++++ src/CrossPointSettings.cpp | 18 +-- src/CrossPointState.cpp | 17 ++- src/WifiCredentialStore.cpp | 20 +--- src/activities/boot_sleep/SleepActivity.cpp | 13 +- src/activities/reader/EpubReaderActivity.cpp | 24 ++-- src/main.cpp | 7 +- src/network/CrossPointWebServer.cpp | 4 +- 20 files changed, 315 insertions(+), 221 deletions(-) delete mode 100644 lib/Epub/Epub/FsHelpers.cpp delete mode 100644 lib/Epub/Epub/FsHelpers.h create mode 100644 lib/FsHelpers/FsHelpers.cpp create mode 100644 lib/FsHelpers/FsHelpers.h diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index d959cb79..010f000d 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -1,5 +1,6 @@ #include "Epub.h" +#include #include #include #include @@ -7,7 +8,6 @@ #include -#include "Epub/FsHelpers.h" #include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContentOpfParser.h" #include "Epub/parsers/TocNcxParser.h" @@ -95,10 +95,15 @@ bool Epub::parseTocNcxFile() { Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); const auto tmpNcxPath = getCachePath() + "/toc.ncx"; - File tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_WRITE); + File tempNcxFile; + if (!FsHelpers::openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) { + return false; + } readItemContentsToStream(tocNcxItem, tempNcxFile, 1024); tempNcxFile.close(); - tempNcxFile = SD.open(tmpNcxPath.c_str(), FILE_READ); + if (!FsHelpers::openFileForRead("EBP", tmpNcxPath, tempNcxFile)) { + return false; + } const auto ncxSize = tempNcxFile.size(); TocNcxParser ncxParser(contentBasePath, ncxSize); @@ -235,16 +240,28 @@ bool Epub::generateCoverBmp() const { if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); - File coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_WRITE, true); + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + + File coverJpg; + if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { + return false; + } readItemContentsToStream(coverImageItem, coverJpg, 1024); coverJpg.close(); - coverJpg = SD.open((getCachePath() + "/.cover.jpg").c_str(), FILE_READ); - File coverBmp = SD.open(getCoverBmpPath().c_str(), FILE_WRITE, true); + if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) { + return false; + } + + File coverBmp; + if (!FsHelpers::openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) { + coverJpg.close(); + return false; + } const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); coverJpg.close(); coverBmp.close(); - SD.remove((getCachePath() + "/.cover.jpg").c_str()); + SD.remove(coverJpgTempPath.c_str()); if (!success) { Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); @@ -259,45 +276,9 @@ bool Epub::generateCoverBmp() const { return false; } -std::string normalisePath(const std::string& path) { - std::vector components; - std::string component; - - for (const auto c : path) { - if (c == '/') { - if (!component.empty()) { - if (component == "..") { - if (!components.empty()) { - components.pop_back(); - } - } else { - components.push_back(component); - } - component.clear(); - } - } else { - component += c; - } - } - - if (!component.empty()) { - components.push_back(component); - } - - std::string result; - for (const auto& c : components) { - if (!result.empty()) { - result += "/"; - } - result += c; - } - - return result; -} - uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, bool trailingNullByte) const { const ZipFile zip("/sd" + filepath); - const std::string path = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); const auto content = zip.readFileToMemory(path.c_str(), size, trailingNullByte); if (!content) { @@ -310,7 +291,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const { const ZipFile zip("/sd" + filepath); - const std::string path = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); return zip.readFileToStream(path.c_str(), out, chunkSize); } @@ -321,7 +302,7 @@ bool Epub::getItemSize(const std::string& itemHref, size_t* size) const { } bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size) { - const std::string path = normalisePath(itemHref); + const std::string path = FsHelpers::normalisePath(itemHref); return zip.getInflatedFileSize(path.c_str(), size); } @@ -349,18 +330,18 @@ std::string& Epub::getSpineItem(const int spineIndex) { return spine.at(spineIndex).second; } -EpubTocEntry& Epub::getTocItem(const int tocTndex) { +EpubTocEntry& Epub::getTocItem(const int tocIndex) { static EpubTocEntry emptyEntry = {}; if (toc.empty()) { Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); return emptyEntry; } - if (tocTndex < 0 || tocTndex >= static_cast(toc.size())) { - Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocTndex); + if (tocIndex < 0 || tocIndex >= static_cast(toc.size())) { + Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); return toc.at(0); } - return toc.at(tocTndex); + return toc.at(tocIndex); } int Epub::getTocItemsCount() const { return toc.size(); } diff --git a/lib/Epub/Epub/FsHelpers.cpp b/lib/Epub/Epub/FsHelpers.cpp deleted file mode 100644 index 5287252a..00000000 --- a/lib/Epub/Epub/FsHelpers.cpp +++ /dev/null @@ -1,36 +0,0 @@ -#include "FsHelpers.h" - -#include - -bool FsHelpers::removeDir(const char* path) { - // 1. Open the directory - File dir = SD.open(path); - if (!dir) { - return false; - } - if (!dir.isDirectory()) { - return false; - } - - File file = dir.openNextFile(); - while (file) { - String filePath = path; - if (!filePath.endsWith("/")) { - filePath += "/"; - } - filePath += file.name(); - - if (file.isDirectory()) { - if (!removeDir(filePath.c_str())) { - return false; - } - } else { - if (!SD.remove(filePath.c_str())) { - return false; - } - } - file = dir.openNextFile(); - } - - return SD.rmdir(path); -} diff --git a/lib/Epub/Epub/FsHelpers.h b/lib/Epub/Epub/FsHelpers.h deleted file mode 100644 index bc5204b7..00000000 --- a/lib/Epub/Epub/FsHelpers.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -class FsHelpers { - public: - static bool removeDir(const char* path); -}; diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 01bb3aca..b41dd3c4 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -9,21 +9,21 @@ constexpr uint8_t PAGE_FILE_VERSION = 3; void PageLine::render(GfxRenderer& renderer, const int fontId) { block->render(renderer, fontId, xPos, yPos); } -void PageLine::serialize(std::ostream& os) { - serialization::writePod(os, xPos); - serialization::writePod(os, yPos); +void PageLine::serialize(File& file) { + serialization::writePod(file, xPos); + serialization::writePod(file, yPos); // serialize TextBlock pointed to by PageLine - block->serialize(os); + block->serialize(file); } -std::unique_ptr PageLine::deserialize(std::istream& is) { +std::unique_ptr PageLine::deserialize(File& file) { int16_t xPos; int16_t yPos; - serialization::readPod(is, xPos); - serialization::readPod(is, yPos); + serialization::readPod(file, xPos); + serialization::readPod(file, yPos); - auto tb = TextBlock::deserialize(is); + auto tb = TextBlock::deserialize(file); return std::unique_ptr(new PageLine(std::move(tb), xPos, yPos)); } @@ -33,22 +33,22 @@ void Page::render(GfxRenderer& renderer, const int fontId) const { } } -void Page::serialize(std::ostream& os) const { - serialization::writePod(os, PAGE_FILE_VERSION); +void Page::serialize(File& file) const { + serialization::writePod(file, PAGE_FILE_VERSION); const uint32_t count = elements.size(); - serialization::writePod(os, count); + serialization::writePod(file, count); for (const auto& el : elements) { // Only PageLine exists currently - serialization::writePod(os, static_cast(TAG_PageLine)); - el->serialize(os); + serialization::writePod(file, static_cast(TAG_PageLine)); + el->serialize(file); } } -std::unique_ptr Page::deserialize(std::istream& is) { +std::unique_ptr Page::deserialize(File& file) { uint8_t version; - serialization::readPod(is, version); + serialization::readPod(file, version); if (version != PAGE_FILE_VERSION) { Serial.printf("[%lu] [PGE] Deserialization failed: Unknown version %u\n", millis(), version); return nullptr; @@ -57,14 +57,14 @@ std::unique_ptr Page::deserialize(std::istream& is) { auto page = std::unique_ptr(new Page()); uint32_t count; - serialization::readPod(is, count); + serialization::readPod(file, count); for (uint32_t i = 0; i < count; i++) { uint8_t tag; - serialization::readPod(is, tag); + serialization::readPod(file, tag); if (tag == TAG_PageLine) { - auto pl = PageLine::deserialize(is); + auto pl = PageLine::deserialize(file); page->elements.push_back(std::move(pl)); } else { Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 59333cea..10266534 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -1,4 +1,6 @@ #pragma once +#include + #include #include @@ -16,7 +18,7 @@ class PageElement { explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} virtual ~PageElement() = default; virtual void render(GfxRenderer& renderer, int fontId) = 0; - virtual void serialize(std::ostream& os) = 0; + virtual void serialize(File& file) = 0; }; // a line from a block element @@ -27,8 +29,8 @@ class PageLine final : public PageElement { PageLine(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), block(std::move(block)) {} void render(GfxRenderer& renderer, int fontId) override; - void serialize(std::ostream& os) override; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) override; + static std::unique_ptr deserialize(File& file); }; class Page { @@ -36,6 +38,6 @@ class Page { // the list of block index and line numbers on this page std::vector> elements; void render(GfxRenderer& renderer, int fontId) const; - void serialize(std::ostream& os) const; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) const; + static std::unique_ptr deserialize(File& file); }; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 7c9d241e..ec2993f0 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -1,11 +1,9 @@ #include "Section.h" +#include #include #include -#include - -#include "FsHelpers.h" #include "Page.h" #include "parsers/ChapterHtmlSlimParser.h" @@ -16,7 +14,10 @@ constexpr uint8_t SECTION_FILE_VERSION = 5; void Section::onPageComplete(std::unique_ptr page) { const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; - std::ofstream outputFile("/sd" + filePath); + File outputFile; + if (!FsHelpers::openFileForWrite("SCT", filePath, outputFile)) { + return; + } page->serialize(outputFile); outputFile.close(); @@ -28,7 +29,10 @@ void Section::onPageComplete(std::unique_ptr page) { void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) const { - std::ofstream outputFile(("/sd" + cachePath + "/section.bin").c_str()); + File outputFile; + if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) { + return; + } serialization::writePod(outputFile, SECTION_FILE_VERSION); serialization::writePod(outputFile, fontId); serialization::writePod(outputFile, lineCompression); @@ -44,17 +48,12 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression, bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) { - if (!SD.exists(cachePath.c_str())) { - return false; - } - const auto sectionFilePath = cachePath + "/section.bin"; - if (!SD.exists(sectionFilePath.c_str())) { + File inputFile; + if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) { return false; } - std::ifstream inputFile(("/sd" + sectionFilePath).c_str()); - // Match parameters { uint8_t version; @@ -119,13 +118,13 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const bool extraParagraphSpacing) { const auto localPath = epub->getSpineItem(spineIndex); - // TODO: Should we get rid of this file all together? - // It currently saves us a bit of memory by allowing for all the inflation bits to be released - // before loading the XML parser const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; - File f = SD.open(tmpHtmlPath.c_str(), FILE_WRITE, true); - bool success = epub->readItemContentsToStream(localPath, f, 1024); - f.close(); + File tmpHtml; + if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { + return false; + } + bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); + tmpHtml.close(); if (!success) { Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis()); @@ -134,10 +133,8 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str()); - const auto sdTmpHtmlPath = "/sd" + tmpHtmlPath; - - ChapterHtmlSlimParser visitor(sdTmpHtmlPath.c_str(), renderer, fontId, lineCompression, marginTop, marginRight, - marginBottom, marginLeft, extraParagraphSpacing, + ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, + marginLeft, extraParagraphSpacing, [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }); success = visitor.parseAndBuildPages(); @@ -153,13 +150,12 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, } std::unique_ptr Section::loadPageFromSD() const { - const auto filePath = "/sd" + cachePath + "/page_" + std::to_string(currentPage) + ".bin"; - if (!SD.exists(filePath.c_str() + 3)) { - Serial.printf("[%lu] [SCT] Page file does not exist: %s\n", millis(), filePath.c_str()); + const auto filePath = cachePath + "/page_" + std::to_string(currentPage) + ".bin"; + + File inputFile; + if (!FsHelpers::openFileForRead("SCT", filePath, inputFile)) { return nullptr; } - - std::ifstream inputFile(filePath); auto page = Page::deserialize(inputFile); inputFile.close(); return page; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index cc3cb60a..bb8b14e8 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -17,27 +17,27 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } } -void TextBlock::serialize(std::ostream& os) const { +void TextBlock::serialize(File& file) const { // words const uint32_t wc = words.size(); - serialization::writePod(os, wc); - for (const auto& w : words) serialization::writeString(os, w); + serialization::writePod(file, wc); + for (const auto& w : words) serialization::writeString(file, w); // wordXpos const uint32_t xc = wordXpos.size(); - serialization::writePod(os, xc); - for (auto x : wordXpos) serialization::writePod(os, x); + serialization::writePod(file, xc); + for (auto x : wordXpos) serialization::writePod(file, x); // wordStyles const uint32_t sc = wordStyles.size(); - serialization::writePod(os, sc); - for (auto s : wordStyles) serialization::writePod(os, s); + serialization::writePod(file, sc); + for (auto s : wordStyles) serialization::writePod(file, s); // style - serialization::writePod(os, style); + serialization::writePod(file, style); } -std::unique_ptr TextBlock::deserialize(std::istream& is) { +std::unique_ptr TextBlock::deserialize(File& file) { uint32_t wc, xc, sc; std::list words; std::list wordXpos; @@ -45,22 +45,22 @@ std::unique_ptr TextBlock::deserialize(std::istream& is) { BLOCK_STYLE style; // words - serialization::readPod(is, wc); + serialization::readPod(file, wc); words.resize(wc); - for (auto& w : words) serialization::readString(is, w); + for (auto& w : words) serialization::readString(file, w); // wordXpos - serialization::readPod(is, xc); + serialization::readPod(file, xc); wordXpos.resize(xc); - for (auto& x : wordXpos) serialization::readPod(is, x); + for (auto& x : wordXpos) serialization::readPod(file, x); // wordStyles - serialization::readPod(is, sc); + serialization::readPod(file, sc); wordStyles.resize(sc); - for (auto& s : wordStyles) serialization::readPod(is, s); + for (auto& s : wordStyles) serialization::readPod(file, s); // style - serialization::readPod(is, style); + serialization::readPod(file, style); return std::unique_ptr(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style)); } diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 4b2b0319..46e320e3 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include @@ -35,6 +36,6 @@ class TextBlock final : public Block { // given a renderer works out where to break the words into lines void render(const GfxRenderer& renderer, int fontId, int x, int y) const; BlockType getType() override { return TEXT_BLOCK; } - void serialize(std::ostream& os) const; - static std::unique_ptr deserialize(std::istream& is); + void serialize(File& file) const; + static std::unique_ptr deserialize(File& file); }; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index a6297070..4a9b86cc 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -1,5 +1,6 @@ #include "ChapterHtmlSlimParser.h" +#include #include #include #include @@ -214,9 +215,8 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } - FILE* file = fopen(filepath, "r"); - if (!file) { - Serial.printf("[%lu] [EHP] Couldn't open file %s\n", millis(), filepath); + File file; + if (!FsHelpers::openFileForRead("EHP", filepath, file)) { XML_ParserFree(parser); return false; } @@ -233,23 +233,23 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } - const size_t len = fread(buf, 1, 1024, file); + const size_t len = file.read(static_cast(buf), 1024); - if (ferror(file)) { + if (len == 0) { Serial.printf("[%lu] [EHP] File read error\n", millis()); XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } - done = feof(file); + done = file.available() == 0; if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), @@ -258,7 +258,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); return false; } } while (!done); @@ -267,7 +267,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetCharacterDataHandler(parser, nullptr); XML_ParserFree(parser); - fclose(file); + file.close(); // Process last page if there is still text if (currentTextBlock) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index f656b4a5..7f74602a 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -15,7 +15,7 @@ class GfxRenderer; #define MAX_WORD_SIZE 200 class ChapterHtmlSlimParser { - const char* filepath; + const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; int depth = 0; @@ -45,7 +45,7 @@ class ChapterHtmlSlimParser { static void XMLCALL endElement(void* userData, const XML_Char* name); public: - explicit ChapterHtmlSlimParser(const char* filepath, GfxRenderer& renderer, const int fontId, + explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, const std::function)>& completePageFn) diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp new file mode 100644 index 00000000..06f3dfe6 --- /dev/null +++ b/lib/FsHelpers/FsHelpers.cpp @@ -0,0 +1,112 @@ +#include "FsHelpers.h" + +#include + +#include + +bool FsHelpers::openFileForRead(const char* moduleName, const char* path, File& file) { + if (!SD.exists(path)) { + return false; + } + + file = SD.open(path, FILE_READ); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), moduleName, path); + return false; + } + return true; +} + +bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path, File& file) { + return openFileForRead(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForRead(const char* moduleName, const String& path, File& file) { + return openFileForRead(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const char* path, File& file) { + file = SD.open(path, FILE_WRITE, true); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path); + return false; + } + return true; +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) { + return openFileForWrite(moduleName, path.c_str(), file); +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const String& path, File& file) { + return openFileForWrite(moduleName, path.c_str(), file); +} + +bool FsHelpers::removeDir(const char* path) { + // 1. Open the directory + File dir = SD.open(path); + if (!dir) { + return false; + } + if (!dir.isDirectory()) { + return false; + } + + File file = dir.openNextFile(); + while (file) { + String filePath = path; + if (!filePath.endsWith("/")) { + filePath += "/"; + } + filePath += file.name(); + + if (file.isDirectory()) { + if (!removeDir(filePath.c_str())) { + return false; + } + } else { + if (!SD.remove(filePath.c_str())) { + return false; + } + } + file = dir.openNextFile(); + } + + return SD.rmdir(path); +} + +std::string FsHelpers::normalisePath(const std::string& path) { + std::vector components; + std::string component; + + for (const auto c : path) { + if (c == '/') { + if (!component.empty()) { + if (component == "..") { + if (!components.empty()) { + components.pop_back(); + } + } else { + components.push_back(component); + } + component.clear(); + } + } else { + component += c; + } + } + + if (!component.empty()) { + components.push_back(component); + } + + std::string result; + for (const auto& c : components) { + if (!result.empty()) { + result += "/"; + } + result += c; + } + + return result; +} diff --git a/lib/FsHelpers/FsHelpers.h b/lib/FsHelpers/FsHelpers.h new file mode 100644 index 00000000..0dff145d --- /dev/null +++ b/lib/FsHelpers/FsHelpers.h @@ -0,0 +1,14 @@ +#pragma once +#include + +class FsHelpers { + public: + static bool openFileForRead(const char* moduleName, const char* path, File& file); + static bool openFileForRead(const char* moduleName, const std::string& path, File& file); + static bool openFileForRead(const char* moduleName, const String& path, File& file); + static bool openFileForWrite(const char* moduleName, const char* path, File& file); + static bool openFileForWrite(const char* moduleName, const std::string& path, File& file); + static bool openFileForWrite(const char* moduleName, const String& path, File& file); + static bool removeDir(const char* path); + static std::string normalisePath(const std::string& path); +}; diff --git a/lib/Serialization/Serialization.h b/lib/Serialization/Serialization.h index 20eb4a4b..e6bcbf29 100644 --- a/lib/Serialization/Serialization.h +++ b/lib/Serialization/Serialization.h @@ -1,4 +1,6 @@ #pragma once +#include + #include namespace serialization { @@ -7,21 +9,44 @@ static void writePod(std::ostream& os, const T& value) { os.write(reinterpret_cast(&value), sizeof(T)); } +template +static void writePod(File& file, const T& value) { + file.write(reinterpret_cast(&value), sizeof(T)); +} + template static void readPod(std::istream& is, T& value) { is.read(reinterpret_cast(&value), sizeof(T)); } +template +static void readPod(File& file, T& value) { + file.read(reinterpret_cast(&value), sizeof(T)); +} + static void writeString(std::ostream& os, const std::string& s) { const uint32_t len = s.size(); writePod(os, len); os.write(s.data(), len); } +static void writeString(File& file, const std::string& s) { + const uint32_t len = s.size(); + writePod(file, len); + file.write(reinterpret_cast(s.data()), len); +} + static void readString(std::istream& is, std::string& s) { uint32_t len; readPod(is, len); s.resize(len); is.read(&s[0], len); } + +static void readString(File& file, std::string& s) { + uint32_t len; + readPod(file, len); + s.resize(len); + file.read(reinterpret_cast(&s[0]), len); +} } // namespace serialization diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index fe5e2a07..467ee9ca 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -1,26 +1,28 @@ #include "CrossPointSettings.h" +#include #include #include #include -#include -#include - // Initialize the static instance CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_COUNT = 3; -constexpr char SETTINGS_FILE[] = "/sd/.crosspoint/settings.bin"; +constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace bool CrossPointSettings::saveToFile() const { // Make sure the directory exists SD.mkdir("/.crosspoint"); - std::ofstream outputFile(SETTINGS_FILE); + File outputFile; + if (!FsHelpers::openFileForWrite("CPS", SETTINGS_FILE, outputFile)) { + return false; + } + serialization::writePod(outputFile, SETTINGS_FILE_VERSION); serialization::writePod(outputFile, SETTINGS_COUNT); serialization::writePod(outputFile, sleepScreen); @@ -33,13 +35,11 @@ bool CrossPointSettings::saveToFile() const { } bool CrossPointSettings::loadFromFile() { - if (!SD.exists(SETTINGS_FILE + 3)) { // +3 to skip "/sd" prefix - Serial.printf("[%lu] [CPS] Settings file does not exist, using defaults\n", millis()); + File inputFile; + if (!FsHelpers::openFileForRead("CPS", SETTINGS_FILE, inputFile)) { return false; } - std::ifstream inputFile(SETTINGS_FILE); - uint8_t version; serialization::readPod(inputFile, version); if (version != SETTINGS_FILE_VERSION) { diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index dd96593f..9010822d 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -1,20 +1,22 @@ #include "CrossPointState.h" +#include #include -#include #include -#include - namespace { constexpr uint8_t STATE_FILE_VERSION = 1; -constexpr char STATE_FILE[] = "/sd/.crosspoint/state.bin"; +constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace CrossPointState CrossPointState::instance; bool CrossPointState::saveToFile() const { - std::ofstream outputFile(STATE_FILE); + File outputFile; + if (!FsHelpers::openFileForWrite("CPS", STATE_FILE, outputFile)) { + return false; + } + serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writeString(outputFile, openEpubPath); outputFile.close(); @@ -22,7 +24,10 @@ bool CrossPointState::saveToFile() const { } bool CrossPointState::loadFromFile() { - std::ifstream inputFile(STATE_FILE); + File inputFile; + if (!FsHelpers::openFileForRead("CPS", STATE_FILE, inputFile)) { + return false; + } uint8_t version; serialization::readPod(inputFile, version); diff --git a/src/WifiCredentialStore.cpp b/src/WifiCredentialStore.cpp index 7df9e2fe..856098f2 100644 --- a/src/WifiCredentialStore.cpp +++ b/src/WifiCredentialStore.cpp @@ -1,11 +1,10 @@ #include "WifiCredentialStore.h" +#include #include #include #include -#include - // Initialize the static instance WifiCredentialStore WifiCredentialStore::instance; @@ -14,7 +13,7 @@ namespace { constexpr uint8_t WIFI_FILE_VERSION = 1; // WiFi credentials file path -constexpr char WIFI_FILE[] = "/sd/.crosspoint/wifi.bin"; +constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin"; // Obfuscation key - "CrossPoint" in ASCII // This is NOT cryptographic security, just prevents casual file reading @@ -33,9 +32,8 @@ bool WifiCredentialStore::saveToFile() const { // Make sure the directory exists SD.mkdir("/.crosspoint"); - std::ofstream file(WIFI_FILE, std::ios::binary); - if (!file) { - Serial.printf("[%lu] [WCS] Failed to open wifi.bin for writing\n", millis()); + File file; + if (!FsHelpers::openFileForWrite("WCS", WIFI_FILE, file)) { return false; } @@ -62,14 +60,8 @@ bool WifiCredentialStore::saveToFile() const { } bool WifiCredentialStore::loadFromFile() { - if (!SD.exists(WIFI_FILE + 3)) { // +3 to skip "/sd" prefix - Serial.printf("[%lu] [WCS] WiFi credentials file does not exist\n", millis()); - return false; - } - - std::ifstream file(WIFI_FILE, std::ios::binary); - if (!file) { - Serial.printf("[%lu] [WCS] Failed to open wifi.bin for reading\n", millis()); + File file; + if (!FsHelpers::openFileForRead("WCS", WIFI_FILE, file)) { return false; } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index ca72aeb1..4bc70f57 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,6 +1,7 @@ #include "SleepActivity.h" #include +#include #include #include @@ -76,8 +77,8 @@ void SleepActivity::renderCustomSleepScreen() const { // Generate a random number between 1 and numFiles const auto randomFileIndex = random(numFiles); const auto filename = "/sleep/" + files[randomFileIndex]; - auto file = SD.open(filename.c_str()); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", filename, file)) { Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); Bitmap bitmap(file); @@ -93,8 +94,8 @@ void SleepActivity::renderCustomSleepScreen() const { // Look for sleep.bmp on the root of the sd card to determine if we should // render a custom sleep screen instead of the default. - auto file = SD.open("/sleep.bmp"); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", "/sleep.bmp", file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); @@ -186,8 +187,8 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - auto file = SD.open(lastEpub.getCoverBmpPath().c_str(), FILE_READ); - if (file) { + File file; + if (FsHelpers::openFileForRead("SLP", lastEpub.getCoverBmpPath(), file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index fd9a8135..7843f5bb 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1,9 +1,9 @@ #include "EpubReaderActivity.h" #include +#include #include #include -#include #include "Battery.h" #include "CrossPointSettings.h" @@ -37,8 +37,8 @@ void EpubReaderActivity::onEnter() { epub->setupCacheDir(); - File f = SD.open((epub->getCachePath() + "/progress.bin").c_str()); - if (f) { + File f; + if (FsHelpers::openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[4]; if (f.read(data, 4) == 4) { currentSpineIndex = data[0] + (data[1] << 8); @@ -282,14 +282,16 @@ void EpubReaderActivity::renderScreen() { Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); } - File f = SD.open((epub->getCachePath() + "/progress.bin").c_str(), FILE_WRITE); - uint8_t data[4]; - data[0] = currentSpineIndex & 0xFF; - data[1] = (currentSpineIndex >> 8) & 0xFF; - data[2] = section->currentPage & 0xFF; - data[3] = (section->currentPage >> 8) & 0xFF; - f.write(data, 4); - f.close(); + File f; + if (FsHelpers::openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + data[0] = currentSpineIndex & 0xFF; + data[1] = (currentSpineIndex >> 8) & 0xFF; + data[2] = section->currentPage & 0xFF; + data[3] = (section->currentPage >> 8) & 0xFF; + f.write(data, 4); + f.close(); + } } void EpubReaderActivity::renderContents(std::unique_ptr page) { diff --git a/src/main.cpp b/src/main.cpp index cf74479a..03f51508 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -160,7 +160,12 @@ void onGoHome() { void setup() { t1 = millis(); - Serial.begin(115200); + + // Only start serial if USB connected + pinMode(UART0_RXD, INPUT); + if (digitalRead(UART0_RXD) == HIGH) { + Serial.begin(115200); + } Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index f14081f7..041273f0 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1,6 +1,7 @@ #include "CrossPointWebServer.h" #include +#include #include #include @@ -339,8 +340,7 @@ void CrossPointWebServer::handleUpload() const { } // Open file for writing - uploadFile = SD.open(filePath.c_str(), FILE_WRITE); - if (!uploadFile) { + if (!FsHelpers::openFileForWrite("WEB", filePath, uploadFile)) { uploadError = "Failed to create file on SD card"; Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); return; From 27035b2b91dabd403b8fa13f5101866d0df70e6d Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:21:41 +1100 Subject: [PATCH 29/46] Handle 16x16 MCU blocks in JPEG decoding (#120) ## Summary * Handle 16x16 MCU blocks in JPEG decoding * We were only correctly handling 8x8 blocks, which means that we did not correctly support a lot of JPGs leading to an interlacing style on the images ## Additional Context * Fixes https://github.com/daveallie/crosspoint-reader/issues/118 --- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 4b48d70a..c2c049a7 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -182,6 +182,9 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { } // Process MCU block into MCU row buffer + // MCUs are composed of 8x8 blocks. For 16x16 MCUs, there are four 8x8 blocks: + // Block layout for 16x16 MCU: [0, 64] (top row of blocks) + // [128, 192] (bottom row of blocks) for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { const int pixelX = mcuX * mcuPixelWidth + blockX; @@ -191,16 +194,27 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { continue; } + // Calculate which 8x8 block and position within that block + const int block8x8Col = blockX / 8; // 0 or 1 for 16-wide MCU + const int block8x8Row = blockY / 8; // 0 or 1 for 16-tall MCU + const int pixelInBlockX = blockX % 8; + const int pixelInBlockY = blockY % 8; + + // Calculate byte offset: each 8x8 block is 64 bytes + // Blocks are arranged: [0, 64], [128, 192] + const int blockOffset = (block8x8Row * (mcuPixelWidth / 8) + block8x8Col) * 64; + const int mcuIndex = blockOffset + pixelInBlockY * 8 + pixelInBlockX; + // Get grayscale value uint8_t gray; if (imageInfo.m_comps == 1) { // Grayscale image - gray = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; + gray = imageInfo.m_pMCUBufR[mcuIndex]; } else { // RGB image - convert to grayscale - const uint8_t r = imageInfo.m_pMCUBufR[blockY * mcuPixelWidth + blockX]; - const uint8_t g = imageInfo.m_pMCUBufG[blockY * mcuPixelWidth + blockX]; - const uint8_t b = imageInfo.m_pMCUBufB[blockY * mcuPixelWidth + blockX]; + const uint8_t r = imageInfo.m_pMCUBufR[mcuIndex]; + const uint8_t g = imageInfo.m_pMCUBufG[mcuIndex]; + const uint8_t b = imageInfo.m_pMCUBufB[mcuIndex]; // Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B // Using integer approximation: (30*R + 59*G + 11*B) / 100 gray = (r * 30 + g * 59 + b * 11) / 100; From 2771579007656f78fec756987679abb98cc1ded8 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:33:17 +1100 Subject: [PATCH 30/46] Add support for blockquote, strong, and em tags (#121) ## Summary * Add support for blockquote, strong, and em tags --- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 4a9b86cc..766e5ca6 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -11,13 +11,13 @@ const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); -const char* BLOCK_TAGS[] = {"p", "li", "div", "br"}; +const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); -const char* BOLD_TAGS[] = {"b"}; +const char* BOLD_TAGS[] = {"b", "strong"}; constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]); -const char* ITALIC_TAGS[] = {"i"}; +const char* ITALIC_TAGS[] = {"i", "em"}; constexpr int NUM_ITALIC_TAGS = sizeof(ITALIC_TAGS) / sizeof(ITALIC_TAGS[0]); const char* IMAGE_TAGS[] = {"img"}; From ea0abaf3513beeb3c21c1105d91182a7a430f3fa Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:33:21 +1100 Subject: [PATCH 31/46] Prevent SD card error causing boot loop (#122) ## Summary * Prevent SD card error causing boot loop * We need the screen and fonts to be initialized to show the full screen error message * Prior to this change, trying to render the font would crash the firmware and boot loop it --- src/main.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 03f51508..83a33cc7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -158,6 +158,15 @@ void onGoHome() { enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer)); } +void setupDisplayAndFonts() { + einkDisplay.begin(); + Serial.printf("[%lu] [ ] Display initialized\n", millis()); + renderer.insertFont(READER_FONT_ID, bookerlyFontFamily); + renderer.insertFont(UI_FONT_ID, ubuntuFontFamily); + renderer.insertFont(SMALL_FONT_ID, smallFontFamily); + Serial.printf("[%lu] [ ] Fonts setup\n", millis()); +} + void setup() { t1 = millis(); @@ -179,6 +188,7 @@ void setup() { // SD Card Initialization if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) { Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); + setupDisplayAndFonts(); exitActivity(); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD)); return; @@ -189,14 +199,7 @@ void setup() { // verify power button press duration after we've read settings. verifyWakeupLongPress(); - // Initialize display - einkDisplay.begin(); - Serial.printf("[%lu] [ ] Display initialized\n", millis()); - - renderer.insertFont(READER_FONT_ID, bookerlyFontFamily); - renderer.insertFont(UI_FONT_ID, ubuntuFontFamily); - renderer.insertFont(SMALL_FONT_ID, smallFontFamily); - Serial.printf("[%lu] [ ] Fonts setup\n", millis()); + setupDisplayAndFonts(); exitActivity(); enterNewActivity(new BootActivity(renderer, inputManager)); From b6bc1f7ed365fed9a1fa655fbafacbe43047ff7e Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 22:36:13 +1100 Subject: [PATCH 32/46] New book.bin spine and table of contents cache (#104) ## Summary * Use single unified cache file for book spine, table of contents, and core metadata (title, author, cover image) * Use new temp item store file in OPF parsing to store items to be rescaned when parsing spine * This avoids us holding these items in memory * Use new toc.bin.tmp and spine.bin.tmp to build out partial toc / spine data as part of parsing content.opf and the NCX file * These files are re-read multiple times to ultimately build book.bin ## Additional Context * Spec for file format included below as an image * This should help with: * #10 * #60 * #99 --- lib/Epub/Epub.cpp | 254 ++++++++------ lib/Epub/Epub.h | 29 +- lib/Epub/Epub/BookMetadataCache.cpp | 326 ++++++++++++++++++ lib/Epub/Epub/BookMetadataCache.h | 87 +++++ lib/Epub/Epub/EpubTocEntry.h | 10 - lib/Epub/Epub/FsHelpers.cpp | 92 +++++ lib/Epub/Epub/FsHelpers.h | 12 + lib/Epub/Epub/Section.cpp | 3 +- lib/Epub/Epub/parsers/ContentOpfParser.cpp | 60 +++- lib/Epub/Epub/parsers/ContentOpfParser.h | 17 +- lib/Epub/Epub/parsers/TocNcxParser.cpp | 8 +- lib/Epub/Epub/parsers/TocNcxParser.h | 12 +- src/activities/reader/EpubReaderActivity.cpp | 2 +- .../EpubReaderChapterSelectionActivity.cpp | 2 +- src/main.cpp | 3 +- 15 files changed, 748 insertions(+), 169 deletions(-) create mode 100644 lib/Epub/Epub/BookMetadataCache.cpp create mode 100644 lib/Epub/Epub/BookMetadataCache.h delete mode 100644 lib/Epub/Epub/EpubTocEntry.h create mode 100644 lib/Epub/Epub/FsHelpers.cpp create mode 100644 lib/Epub/Epub/FsHelpers.h diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 010f000d..b48d7ea3 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -6,8 +6,6 @@ #include #include -#include - #include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContentOpfParser.h" #include "Epub/parsers/TocNcxParser.h" @@ -44,7 +42,15 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const { return true; } -bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { +bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) { + std::string contentOpfFilePath; + if (!findContentOpfFile(&contentOpfFilePath)) { + Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); + return false; + } + + contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); + Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str()); size_t contentOpfSize; @@ -53,7 +59,9 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { return false; } - ContentOpfParser opfParser(getBasePath(), contentOpfSize); + ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get()); + Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), + ESP.getHeapSize(), ESP.getMinFreeHeap()); if (!opfParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis()); @@ -66,26 +74,20 @@ bool Epub::parseContentOpf(const std::string& contentOpfFilePath) { } // Grab data from opfParser into epub - title = opfParser.title; - if (!opfParser.coverItemId.empty() && opfParser.items.count(opfParser.coverItemId) > 0) { - coverImageItem = opfParser.items.at(opfParser.coverItemId); - } + bookMetadata.title = opfParser.title; + // TODO: Parse author + bookMetadata.author = ""; + bookMetadata.coverItemHref = opfParser.coverItemHref; if (!opfParser.tocNcxPath.empty()) { tocNcxItem = opfParser.tocNcxPath; } - for (auto& spineRef : opfParser.spineRefs) { - if (opfParser.items.count(spineRef)) { - spine.emplace_back(spineRef, opfParser.items.at(spineRef)); - } - } - Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); return true; } -bool Epub::parseTocNcxFile() { +bool Epub::parseTocNcxFile() const { // the ncx file should have been specified in the content.opf file if (tocNcxItem.empty()) { Serial.printf("[%lu] [EBP] No ncx file specified\n", millis()); @@ -106,7 +108,7 @@ bool Epub::parseTocNcxFile() { } const auto ncxSize = tempNcxFile.size(); - TocNcxParser ncxParser(contentBasePath, ncxSize); + TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get()); if (!ncxParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); @@ -135,9 +137,7 @@ bool Epub::parseTocNcxFile() { tempNcxFile.close(); SD.remove(tmpNcxPath.c_str()); - this->toc = std::move(ncxParser.toc); - - Serial.printf("[%lu] [EBP] Parsed %d TOC items\n", millis(), this->toc.size()); + Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis()); return true; } @@ -145,48 +145,79 @@ bool Epub::parseTocNcxFile() { bool Epub::load() { Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); - std::string contentOpfFilePath; - if (!findContentOpfFile(&contentOpfFilePath)) { - Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); + // Initialize spine/TOC cache + bookMetadataCache.reset(new BookMetadataCache(cachePath)); + + // Try to load existing cache first + if (bookMetadataCache->load()) { + Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); + return true; + } + + // Cache doesn't exist or is invalid, build it + Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis()); + setupCacheDir(); + + // Begin building cache - stream entries to disk immediately + if (!bookMetadataCache->beginWrite()) { + Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis()); return false; } - Serial.printf("[%lu] [EBP] Found content.opf at: %s\n", millis(), contentOpfFilePath.c_str()); - - contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); - - if (!parseContentOpf(contentOpfFilePath)) { + // OPF Pass + BookMetadataCache::BookMetadata bookMetadata; + if (!bookMetadataCache->beginContentOpfPass()) { + Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis()); + return false; + } + if (!parseContentOpf(bookMetadata)) { Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis()); return false; } + if (!bookMetadataCache->endContentOpfPass()) { + Serial.printf("[%lu] [EBP] Could not end writing content.opf pass\n", millis()); + return false; + } + // TOC Pass + if (!bookMetadataCache->beginTocPass()) { + Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis()); + return false; + } if (!parseTocNcxFile()) { Serial.printf("[%lu] [EBP] Could not parse toc\n", millis()); return false; } - - initializeSpineItemSizes(); - Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); - - return true; -} - -void Epub::initializeSpineItemSizes() { - Serial.printf("[%lu] [EBP] Calculating book size\n", millis()); - - const size_t spineItemsCount = getSpineItemsCount(); - size_t cumSpineItemSize = 0; - const ZipFile zip("/sd" + filepath); - - for (size_t i = 0; i < spineItemsCount; i++) { - std::string spineItem = getSpineItem(i); - size_t s = 0; - getItemSize(zip, spineItem, &s); - cumSpineItemSize += s; - cumulativeSpineItemSize.emplace_back(cumSpineItemSize); + if (!bookMetadataCache->endTocPass()) { + Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis()); + return false; } - Serial.printf("[%lu] [EBP] Book size: %lu\n", millis(), cumSpineItemSize); + // Close the cache files + if (!bookMetadataCache->endWrite()) { + Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis()); + return false; + } + + // Build final book.bin + if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) { + Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis()); + return false; + } + + if (!bookMetadataCache->cleanupTmpFiles()) { + Serial.printf("[%lu] [EBP] Could not cleanup tmp files - ignoring\n", millis()); + } + + // Reload the cache from disk so it's in the correct state + bookMetadataCache.reset(new BookMetadataCache(cachePath)); + if (!bookMetadataCache->load()) { + Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis()); + return false; + } + + Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); + return true; } bool Epub::clearCache() const { @@ -222,7 +253,14 @@ const std::string& Epub::getCachePath() const { return cachePath; } const std::string& Epub::getPath() const { return filepath; } -const std::string& Epub::getTitle() const { return title; } +const std::string& Epub::getTitle() const { + static std::string blank; + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return blank; + } + + return bookMetadataCache->coreMetadata.title; +} std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } @@ -232,13 +270,19 @@ bool Epub::generateCoverBmp() const { return true; } - if (coverImageItem.empty()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] Cannot generate cover BMP, cache not loaded\n", millis()); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { Serial.printf("[%lu] [EBP] No known cover image\n", millis()); return false; } - if (coverImageItem.substr(coverImageItem.length() - 4) == ".jpg" || - coverImageItem.substr(coverImageItem.length() - 5) == ".jpeg") { + if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; @@ -246,7 +290,7 @@ bool Epub::generateCoverBmp() const { if (!FsHelpers::openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { return false; } - readItemContentsToStream(coverImageItem, coverJpg, 1024); + readItemContentsToStream(coverImageHref, coverJpg, 1024); coverJpg.close(); if (!FsHelpers::openFileForRead("EBP", coverJpgTempPath, coverJpg)) { @@ -276,7 +320,7 @@ bool Epub::generateCoverBmp() const { return false; } -uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, bool trailingNullByte) const { +uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { const ZipFile zip("/sd" + filepath); const std::string path = FsHelpers::normalisePath(itemHref); @@ -306,99 +350,89 @@ bool Epub::getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* return zip.getInflatedFileSize(path.c_str(), size); } -int Epub::getSpineItemsCount() const { return spine.size(); } - -size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { - if (spineIndex < 0 || spineIndex >= static_cast(cumulativeSpineItemSize.size())) { - Serial.printf("[%lu] [EBP] getCumulativeSpineItemSize index:%d is out of range\n", millis(), spineIndex); +int Epub::getSpineItemsCount() const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { return 0; } - return cumulativeSpineItemSize.at(spineIndex); + return bookMetadataCache->getSpineCount(); } -std::string& Epub::getSpineItem(const int spineIndex) { - static std::string emptyString; - if (spine.empty()) { - Serial.printf("[%lu] [EBP] getSpineItem called but spine is empty\n", millis()); - return emptyString; +size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return getSpineItem(spineIndex).cumulativeSize; } + +BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis()); + return {}; } - if (spineIndex < 0 || spineIndex >= static_cast(spine.size())) { + + if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) { Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); - return spine.at(0).second; + return bookMetadataCache->getSpineEntry(0); } - return spine.at(spineIndex).second; + return bookMetadataCache->getSpineEntry(spineIndex); } -EpubTocEntry& Epub::getTocItem(const int tocIndex) { - static EpubTocEntry emptyEntry = {}; - if (toc.empty()) { - Serial.printf("[%lu] [EBP] getTocItem called but toc is empty\n", millis()); - return emptyEntry; +BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis()); + return {}; } - if (tocIndex < 0 || tocIndex >= static_cast(toc.size())) { + + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); - return toc.at(0); + return {}; } - return toc.at(tocIndex); + return bookMetadataCache->getTocEntry(tocIndex); } -int Epub::getTocItemsCount() const { return toc.size(); } +int Epub::getTocItemsCount() const { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + return 0; + } + + return bookMetadataCache->getTocCount(); +} // work out the section index for a toc index int Epub::getSpineIndexForTocIndex(const int tocIndex) const { - if (tocIndex < 0 || tocIndex >= toc.size()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis()); + return 0; + } + + if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex); return 0; } - // the toc entry should have an href that matches the spine item - // so we can find the spine index by looking for the href - for (int i = 0; i < spine.size(); i++) { - if (spine[i].second == toc[tocIndex].href) { - return i; - } + const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex; + if (spineIndex < 0) { + Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex); + return 0; } - Serial.printf("[%lu] [EBP] Section not found\n", millis()); - // not found - default to the start of the book - return 0; + return spineIndex; } -int Epub::getTocIndexForSpineIndex(const int spineIndex) const { - if (spineIndex < 0 || spineIndex >= spine.size()) { - Serial.printf("[%lu] [EBP] getTocIndexForSpineIndex: spineIndex %d out of range\n", millis(), spineIndex); - return -1; - } - - // the toc entry should have an href that matches the spine item - // so we can find the toc index by looking for the href - for (int i = 0; i < toc.size(); i++) { - if (toc[i].href == spine[spineIndex].second) { - return i; - } - } - - Serial.printf("[%lu] [EBP] TOC item not found\n", millis()); - return -1; -} +int Epub::getTocIndexForSpineIndex(const int spineIndex) const { return getSpineItem(spineIndex).tocIndex; } size_t Epub::getBookSize() const { - if (spine.empty()) { + if (!bookMetadataCache || !bookMetadataCache->isLoaded() || bookMetadataCache->getSpineCount() == 0) { return 0; } return getCumulativeSpineItemSize(getSpineItemsCount() - 1); } // Calculate progress in book -uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) { - size_t bookSize = getBookSize(); +uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const { + const size_t bookSize = getBookSize(); if (bookSize == 0) { return 0; } - size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; - size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; - size_t sectionProgSize = currentSpineRead * curChapterSize; + const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; + const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; + const size_t sectionProgSize = currentSpineRead * curChapterSize; return round(static_cast(prevChapterSize + sectionProgSize) / bookSize * 100.0); } diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 381379c5..acdd32c8 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -1,38 +1,29 @@ #pragma once -#include +#include #include #include #include -#include "Epub/EpubTocEntry.h" +#include "Epub/BookMetadataCache.h" class ZipFile; class Epub { - // the title read from the EPUB meta data - std::string title; - // the cover image - std::string coverImageItem; // the ncx file std::string tocNcxItem; // where is the EPUBfile? std::string filepath; - // the spine of the EPUB file - std::vector> spine; - // the file size of the spine items (proxy to book progress) - std::vector cumulativeSpineItemSize; - // the toc of the EPUB file - std::vector toc; // the base path for items in the EPUB file std::string contentBasePath; // Uniq cache key based on filepath std::string cachePath; + // Spine and TOC cache + std::unique_ptr bookMetadataCache; bool findContentOpfFile(std::string* contentOpfFile) const; - bool parseContentOpf(const std::string& contentOpfFilePath); - bool parseTocNcxFile(); - void initializeSpineItemSizes(); + bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata); + bool parseTocNcxFile() const; static bool getItemSize(const ZipFile& zip, const std::string& itemHref, size_t* size); public: @@ -54,14 +45,14 @@ class Epub { bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool getItemSize(const std::string& itemHref, size_t* size) const; - std::string& getSpineItem(int spineIndex); + BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const; + BookMetadataCache::TocEntry getTocItem(int tocIndex) const; int getSpineItemsCount() const; - size_t getCumulativeSpineItemSize(const int spineIndex) const; - EpubTocEntry& getTocItem(int tocIndex); int getTocItemsCount() const; int getSpineIndexForTocIndex(int tocIndex) const; int getTocIndexForSpineIndex(int spineIndex) const; + size_t getCumulativeSpineItemSize(int spineIndex) const; size_t getBookSize() const; - uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead); + uint8_t calculateProgress(const int currentSpineIndex, const float currentSpineRead) const; }; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp new file mode 100644 index 00000000..3cef851a --- /dev/null +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -0,0 +1,326 @@ +#include "BookMetadataCache.h" + +#include +#include +#include +#include + +#include + +#include "FsHelpers.h" + +namespace { +constexpr uint8_t BOOK_CACHE_VERSION = 1; +constexpr char bookBinFile[] = "/book.bin"; +constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; +constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; +} // namespace + +/* ============= WRITING / BUILDING FUNCTIONS ================ */ + +bool BookMetadataCache::beginWrite() { + buildMode = true; + spineCount = 0; + tocCount = 0; + Serial.printf("[%lu] [BMC] Entering write mode\n", millis()); + return true; +} + +bool BookMetadataCache::beginContentOpfPass() { + Serial.printf("[%lu] [BMC] Beginning content opf pass\n", millis()); + + // Open spine file for writing + return FsHelpers::openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile); +} + +bool BookMetadataCache::endContentOpfPass() { + spineFile.close(); + return true; +} + +bool BookMetadataCache::beginTocPass() { + Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis()); + + // Open spine file for reading + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { + return false; + } + if (!FsHelpers::openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) { + spineFile.close(); + return false; + } + return true; +} + +bool BookMetadataCache::endTocPass() { + tocFile.close(); + spineFile.close(); + return true; +} + +bool BookMetadataCache::endWrite() { + if (!buildMode) { + Serial.printf("[%lu] [BMC] endWrite called but not in build mode\n", millis()); + return false; + } + + buildMode = false; + Serial.printf("[%lu] [BMC] Wrote %d spine, %d TOC entries\n", millis(), spineCount, tocCount); + return true; +} + +bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) { + // Open all three files, writing to meta, reading from spine and toc + if (!FsHelpers::openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) { + return false; + } + + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { + bookFile.close(); + return false; + } + + if (!FsHelpers::openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) { + bookFile.close(); + spineFile.close(); + return false; + } + + constexpr size_t headerASize = + sizeof(BOOK_CACHE_VERSION) + /* LUT Offset */ sizeof(size_t) + sizeof(spineCount) + sizeof(tocCount); + const size_t metadataSize = + metadata.title.size() + metadata.author.size() + metadata.coverItemHref.size() + sizeof(uint32_t) * 3; + const size_t lutSize = sizeof(size_t) * spineCount + sizeof(size_t) * tocCount; + const size_t lutOffset = headerASize + metadataSize; + + // Header A + serialization::writePod(bookFile, BOOK_CACHE_VERSION); + serialization::writePod(bookFile, lutOffset); + serialization::writePod(bookFile, spineCount); + serialization::writePod(bookFile, tocCount); + // Metadata + serialization::writeString(bookFile, metadata.title); + serialization::writeString(bookFile, metadata.author); + serialization::writeString(bookFile, metadata.coverItemHref); + + // Loop through spine entries, writing LUT positions + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto pos = spineFile.position(); + auto spineEntry = readSpineEntry(spineFile); + serialization::writePod(bookFile, pos + lutOffset + lutSize); + } + + // Loop through toc entries, writing LUT positions + tocFile.seek(0); + for (int i = 0; i < tocCount; i++) { + auto pos = tocFile.position(); + auto tocEntry = readTocEntry(tocFile); + serialization::writePod(bookFile, pos + lutOffset + lutSize + spineFile.position()); + } + + // LUTs complete + // Loop through spines from spine file matching up TOC indexes, calculating cumulative size and writing to book.bin + + const ZipFile zip("/sd" + epubPath); + size_t cumSize = 0; + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto spineEntry = readSpineEntry(spineFile); + + tocFile.seek(0); + for (int j = 0; j < tocCount; j++) { + auto tocEntry = readTocEntry(tocFile); + if (tocEntry.spineIndex == i) { + spineEntry.tocIndex = j; + break; + } + } + + // Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs + // Logging here is for debugging + if (spineEntry.tocIndex == -1) { + Serial.printf("[%lu] [BMC] Warning: Could not find TOC entry for spine item %d: %s\n", millis(), i, + spineEntry.href.c_str()); + } + + // Calculate size for cumulative size + size_t itemSize = 0; + const std::string path = FsHelpers::normalisePath(spineEntry.href); + if (zip.getInflatedFileSize(path.c_str(), &itemSize)) { + cumSize += itemSize; + spineEntry.cumulativeSize = cumSize; + } else { + Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str()); + } + + // Write out spine data to book.bin + writeSpineEntry(bookFile, spineEntry); + } + + // Loop through toc entries from toc file writing to book.bin + tocFile.seek(0); + for (int i = 0; i < tocCount; i++) { + auto tocEntry = readTocEntry(tocFile); + writeTocEntry(bookFile, tocEntry); + } + + bookFile.close(); + spineFile.close(); + tocFile.close(); + + Serial.printf("[%lu] [BMC] Successfully built book.bin\n", millis()); + return true; +} + +bool BookMetadataCache::cleanupTmpFiles() const { + if (SD.exists((cachePath + tmpSpineBinFile).c_str())) { + SD.remove((cachePath + tmpSpineBinFile).c_str()); + } + if (SD.exists((cachePath + tmpTocBinFile).c_str())) { + SD.remove((cachePath + tmpTocBinFile).c_str()); + } + return true; +} + +size_t BookMetadataCache::writeSpineEntry(File& file, const SpineEntry& entry) const { + const auto pos = file.position(); + serialization::writeString(file, entry.href); + serialization::writePod(file, entry.cumulativeSize); + serialization::writePod(file, entry.tocIndex); + return pos; +} + +size_t BookMetadataCache::writeTocEntry(File& file, const TocEntry& entry) const { + const auto pos = file.position(); + serialization::writeString(file, entry.title); + serialization::writeString(file, entry.href); + serialization::writeString(file, entry.anchor); + serialization::writePod(file, entry.level); + serialization::writePod(file, entry.spineIndex); + return pos; +} + +// Note: for the LUT to be accurate, this **MUST** be called for all spine items before `addTocEntry` is ever called +// this is because in this function we're marking positions of the items +void BookMetadataCache::createSpineEntry(const std::string& href) { + if (!buildMode || !spineFile) { + Serial.printf("[%lu] [BMC] createSpineEntry called but not in build mode\n", millis()); + return; + } + + const SpineEntry entry(href, 0, -1); + writeSpineEntry(spineFile, entry); + spineCount++; +} + +void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, + const uint8_t level) { + if (!buildMode || !tocFile || !spineFile) { + Serial.printf("[%lu] [BMC] createTocEntry called but not in build mode\n", millis()); + return; + } + + int spineIndex = -1; + // find spine index + spineFile.seek(0); + for (int i = 0; i < spineCount; i++) { + auto spineEntry = readSpineEntry(spineFile); + if (spineEntry.href == href) { + spineIndex = i; + break; + } + } + + if (spineIndex == -1) { + Serial.printf("[%lu] [BMC] addTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str()); + } + + const TocEntry entry(title, href, anchor, level, spineIndex); + writeTocEntry(tocFile, entry); + tocCount++; +} + +/* ============= READING / LOADING FUNCTIONS ================ */ + +bool BookMetadataCache::load() { + if (!FsHelpers::openFileForRead("BMC", cachePath + bookBinFile, bookFile)) { + return false; + } + + uint8_t version; + serialization::readPod(bookFile, version); + if (version != BOOK_CACHE_VERSION) { + Serial.printf("[%lu] [BMC] Cache version mismatch: expected %d, got %d\n", millis(), BOOK_CACHE_VERSION, version); + bookFile.close(); + return false; + } + + serialization::readPod(bookFile, lutOffset); + serialization::readPod(bookFile, spineCount); + serialization::readPod(bookFile, tocCount); + + serialization::readString(bookFile, coreMetadata.title); + serialization::readString(bookFile, coreMetadata.author); + serialization::readString(bookFile, coreMetadata.coverItemHref); + + loaded = true; + Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount); + return true; +} + +BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) { + if (!loaded) { + Serial.printf("[%lu] [BMC] getSpineEntry called but cache not loaded\n", millis()); + return {}; + } + + if (index < 0 || index >= static_cast(spineCount)) { + Serial.printf("[%lu] [BMC] getSpineEntry index %d out of range\n", millis(), index); + return {}; + } + + // Seek to spine LUT item, read from LUT and get out data + bookFile.seek(lutOffset + sizeof(size_t) * index); + size_t spineEntryPos; + serialization::readPod(bookFile, spineEntryPos); + bookFile.seek(spineEntryPos); + return readSpineEntry(bookFile); +} + +BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { + if (!loaded) { + Serial.printf("[%lu] [BMC] getTocEntry called but cache not loaded\n", millis()); + return {}; + } + + if (index < 0 || index >= static_cast(tocCount)) { + Serial.printf("[%lu] [BMC] getTocEntry index %d out of range\n", millis(), index); + return {}; + } + + // Seek to TOC LUT item, read from LUT and get out data + bookFile.seek(lutOffset + sizeof(size_t) * spineCount + sizeof(size_t) * index); + size_t tocEntryPos; + serialization::readPod(bookFile, tocEntryPos); + bookFile.seek(tocEntryPos); + return readTocEntry(bookFile); +} + +BookMetadataCache::SpineEntry BookMetadataCache::readSpineEntry(File& file) const { + SpineEntry entry; + serialization::readString(file, entry.href); + serialization::readPod(file, entry.cumulativeSize); + serialization::readPod(file, entry.tocIndex); + return entry; +} + +BookMetadataCache::TocEntry BookMetadataCache::readTocEntry(File& file) const { + TocEntry entry; + serialization::readString(file, entry.title); + serialization::readString(file, entry.href); + serialization::readString(file, entry.anchor); + serialization::readPod(file, entry.level); + serialization::readPod(file, entry.spineIndex); + return entry; +} diff --git a/lib/Epub/Epub/BookMetadataCache.h b/lib/Epub/Epub/BookMetadataCache.h new file mode 100644 index 00000000..7f9f419c --- /dev/null +++ b/lib/Epub/Epub/BookMetadataCache.h @@ -0,0 +1,87 @@ +#pragma once + +#include + +#include + +class BookMetadataCache { + public: + struct BookMetadata { + std::string title; + std::string author; + std::string coverItemHref; + }; + + struct SpineEntry { + std::string href; + size_t cumulativeSize; + int16_t tocIndex; + + SpineEntry() : cumulativeSize(0), tocIndex(-1) {} + SpineEntry(std::string href, const size_t cumulativeSize, const int16_t tocIndex) + : href(std::move(href)), cumulativeSize(cumulativeSize), tocIndex(tocIndex) {} + }; + + struct TocEntry { + std::string title; + std::string href; + std::string anchor; + uint8_t level; + int16_t spineIndex; + + TocEntry() : level(0), spineIndex(-1) {} + TocEntry(std::string title, std::string href, std::string anchor, const uint8_t level, const int16_t spineIndex) + : title(std::move(title)), + href(std::move(href)), + anchor(std::move(anchor)), + level(level), + spineIndex(spineIndex) {} + }; + + private: + std::string cachePath; + size_t lutOffset; + uint16_t spineCount; + uint16_t tocCount; + bool loaded; + bool buildMode; + + File bookFile; + // Temp file handles during build + File spineFile; + File tocFile; + + size_t writeSpineEntry(File& file, const SpineEntry& entry) const; + size_t writeTocEntry(File& file, const TocEntry& entry) const; + SpineEntry readSpineEntry(File& file) const; + TocEntry readTocEntry(File& file) const; + + public: + BookMetadata coreMetadata; + + explicit BookMetadataCache(std::string cachePath) + : cachePath(std::move(cachePath)), lutOffset(0), spineCount(0), tocCount(0), loaded(false), buildMode(false) {} + ~BookMetadataCache() = default; + + // Building phase (stream to disk immediately) + bool beginWrite(); + bool beginContentOpfPass(); + void createSpineEntry(const std::string& href); + bool endContentOpfPass(); + bool beginTocPass(); + void createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, uint8_t level); + bool endTocPass(); + bool endWrite(); + bool cleanupTmpFiles() const; + + // Post-processing to update mappings and sizes + bool buildBookBin(const std::string& epubPath, const BookMetadata& metadata); + + // Reading phase (read mode) + bool load(); + SpineEntry getSpineEntry(int index); + TocEntry getTocEntry(int index); + int getSpineCount() const { return spineCount; } + int getTocCount() const { return tocCount; } + bool isLoaded() const { return loaded; } +}; diff --git a/lib/Epub/Epub/EpubTocEntry.h b/lib/Epub/Epub/EpubTocEntry.h deleted file mode 100644 index 94f0c90f..00000000 --- a/lib/Epub/Epub/EpubTocEntry.h +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - -#include - -struct EpubTocEntry { - std::string title; - std::string href; - std::string anchor; - uint8_t level; -}; diff --git a/lib/Epub/Epub/FsHelpers.cpp b/lib/Epub/Epub/FsHelpers.cpp new file mode 100644 index 00000000..743ac59b --- /dev/null +++ b/lib/Epub/Epub/FsHelpers.cpp @@ -0,0 +1,92 @@ +#include "FsHelpers.h" + +#include + +#include + +bool FsHelpers::openFileForRead(const char* moduleName, const std::string& path, File& file) { + file = SD.open(path.c_str(), FILE_READ); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for reading: %s\n", millis(), moduleName, path.c_str()); + return false; + } + return true; +} + +bool FsHelpers::openFileForWrite(const char* moduleName, const std::string& path, File& file) { + file = SD.open(path.c_str(), FILE_WRITE, true); + if (!file) { + Serial.printf("[%lu] [%s] Failed to open file for writing: %s\n", millis(), moduleName, path.c_str()); + return false; + } + return true; +} + +bool FsHelpers::removeDir(const char* path) { + // 1. Open the directory + File dir = SD.open(path); + if (!dir) { + return false; + } + if (!dir.isDirectory()) { + return false; + } + + File file = dir.openNextFile(); + while (file) { + String filePath = path; + if (!filePath.endsWith("/")) { + filePath += "/"; + } + filePath += file.name(); + + if (file.isDirectory()) { + if (!removeDir(filePath.c_str())) { + return false; + } + } else { + if (!SD.remove(filePath.c_str())) { + return false; + } + } + file = dir.openNextFile(); + } + + return SD.rmdir(path); +} + +std::string FsHelpers::normalisePath(const std::string& path) { + std::vector components; + std::string component; + + for (const auto c : path) { + if (c == '/') { + if (!component.empty()) { + if (component == "..") { + if (!components.empty()) { + components.pop_back(); + } + } else { + components.push_back(component); + } + component.clear(); + } + } else { + component += c; + } + } + + if (!component.empty()) { + components.push_back(component); + } + + std::string result; + for (const auto& c : components) { + if (!result.empty()) { + result += "/"; + } + result += c; + } + + return result; +} diff --git a/lib/Epub/Epub/FsHelpers.h b/lib/Epub/Epub/FsHelpers.h new file mode 100644 index 00000000..193db65f --- /dev/null +++ b/lib/Epub/Epub/FsHelpers.h @@ -0,0 +1,12 @@ +#pragma once +#include + +#include + +class FsHelpers { + public: + static bool openFileForRead(const char* moduleName, const std::string& path, File& file); + static bool openFileForWrite(const char* moduleName, const std::string& path, File& file); + static bool removeDir(const char* path); + static std::string normalisePath(const std::string& path); +}; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index ec2993f0..5323a7a5 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -116,8 +116,7 @@ bool Section::clearCache() const { bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing) { - const auto localPath = epub->getSpineItem(spineIndex); - + const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; File tmpHtml; if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 4d3d776f..3cc64014 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -1,11 +1,16 @@ #include "ContentOpfParser.h" +#include #include +#include #include +#include "../BookMetadataCache.h" + namespace { constexpr char MEDIA_TYPE_NCX[] = "application/x-dtbncx+xml"; -} +constexpr char itemCacheFile[] = "/.items.bin"; +} // namespace bool ContentOpfParser::setup() { parser = XML_ParserCreate(nullptr); @@ -28,6 +33,12 @@ ContentOpfParser::~ContentOpfParser() { XML_ParserFree(parser); parser = nullptr; } + if (tempItemStore) { + tempItemStore.close(); + } + if (SD.exists((cachePath + itemCacheFile).c_str())) { + SD.remove((cachePath + itemCacheFile).c_str()); + } } size_t ContentOpfParser::write(const uint8_t data) { return write(&data, 1); } @@ -94,11 +105,21 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { self->state = IN_MANIFEST; + if (!FsHelpers::openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + Serial.printf( + "[%lu] [COF] Couldn't open temp items file for writing. This is probably going to be a fatal error.\n", + millis()); + } return; } if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { self->state = IN_SPINE; + if (!FsHelpers::openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + Serial.printf( + "[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n", + millis()); + } return; } @@ -135,7 +156,13 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name } } - self->items[itemId] = href; + // Write items down to SD card + serialization::writeString(self->tempItemStore, itemId); + serialization::writeString(self->tempItemStore, href); + + if (itemId == self->coverItemId) { + self->coverItemHref = href; + } if (mediaType == MEDIA_TYPE_NCX) { if (self->tocNcxPath.empty()) { @@ -148,14 +175,29 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name return; } - if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { - for (int i = 0; atts[i]; i += 2) { - if (strcmp(atts[i], "idref") == 0) { - self->spineRefs.emplace_back(atts[i + 1]); - break; + // NOTE: This relies on spine appearing after item manifest (which is pretty safe as it's part of the EPUB spec) + // Only run the spine parsing if there's a cache to add it to + if (self->cache) { + if (self->state == IN_SPINE && (strcmp(name, "itemref") == 0 || strcmp(name, "opf:itemref") == 0)) { + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], "idref") == 0) { + const std::string idref = atts[i + 1]; + // Resolve the idref to href using items map + self->tempItemStore.seek(0); + std::string itemId; + std::string href; + while (self->tempItemStore.available()) { + serialization::readString(self->tempItemStore, itemId); + serialization::readString(self->tempItemStore, href); + if (itemId == idref) { + self->cache->createSpineEntry(href); + break; + } + } + } } + return; } - return; } } @@ -174,11 +216,13 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) if (self->state == IN_SPINE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { self->state = IN_PACKAGE; + self->tempItemStore.close(); return; } if (self->state == IN_MANIFEST && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { self->state = IN_PACKAGE; + self->tempItemStore.close(); return; } diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.h b/lib/Epub/Epub/parsers/ContentOpfParser.h index a3070fcc..5415de67 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.h +++ b/lib/Epub/Epub/parsers/ContentOpfParser.h @@ -1,11 +1,11 @@ #pragma once #include -#include - #include "Epub.h" #include "expat.h" +class BookMetadataCache; + class ContentOpfParser final : public Print { enum ParserState { START, @@ -16,10 +16,14 @@ class ContentOpfParser final : public Print { IN_SPINE, }; + const std::string& cachePath; const std::string& baseContentPath; size_t remainingSize; XML_Parser parser = nullptr; ParserState state = START; + BookMetadataCache* cache; + File tempItemStore; + std::string coverItemId; static void startElement(void* userData, const XML_Char* name, const XML_Char** atts); static void characterData(void* userData, const XML_Char* s, int len); @@ -28,12 +32,11 @@ class ContentOpfParser final : public Print { public: std::string title; std::string tocNcxPath; - std::string coverItemId; - std::map items; - std::vector spineRefs; + std::string coverItemHref; - explicit ContentOpfParser(const std::string& baseContentPath, const size_t xmlSize) - : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + explicit ContentOpfParser(const std::string& cachePath, const std::string& baseContentPath, const size_t xmlSize, + BookMetadataCache* cache) + : cachePath(cachePath), baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {} ~ContentOpfParser() override; bool setup(); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index f4700558..b1fbb2fe 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -1,8 +1,9 @@ #include "TocNcxParser.h" -#include #include +#include "../BookMetadataCache.h" + bool TocNcxParser::setup() { parser = XML_ParserCreate(nullptr); if (!parser) { @@ -167,8 +168,9 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { href = href.substr(0, pos); } - // Push to vector - self->toc.push_back({std::move(self->currentLabel), std::move(href), std::move(anchor), self->currentDepth}); + if (self->cache) { + self->cache->createTocEntry(self->currentLabel, href, anchor, self->currentDepth); + } // Clear them so we don't re-add them if there are weird XML structures self->currentLabel.clear(); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.h b/lib/Epub/Epub/parsers/TocNcxParser.h index 2f3601a1..e2c86205 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.h +++ b/lib/Epub/Epub/parsers/TocNcxParser.h @@ -1,11 +1,10 @@ #pragma once #include +#include #include -#include -#include "Epub/EpubTocEntry.h" -#include "expat.h" +class BookMetadataCache; class TocNcxParser final : public Print { enum ParserState { START, IN_NCX, IN_NAV_MAP, IN_NAV_POINT, IN_NAV_LABEL, IN_NAV_LABEL_TEXT, IN_CONTENT }; @@ -14,6 +13,7 @@ class TocNcxParser final : public Print { size_t remainingSize; XML_Parser parser = nullptr; ParserState state = START; + BookMetadataCache* cache; std::string currentLabel; std::string currentSrc; @@ -24,10 +24,8 @@ class TocNcxParser final : public Print { static void endElement(void* userData, const XML_Char* name); public: - std::vector toc; - - explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize) - : baseContentPath(baseContentPath), remainingSize(xmlSize) {} + explicit TocNcxParser(const std::string& baseContentPath, const size_t xmlSize, BookMetadataCache* cache) + : baseContentPath(baseContentPath), remainingSize(xmlSize), cache(cache) {} ~TocNcxParser() override; bool setup(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7843f5bb..6195ec22 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -212,7 +212,7 @@ void EpubReaderActivity::renderScreen() { } if (!section) { - const auto filepath = epub->getSpineItem(currentSpineIndex); + const auto filepath = epub->getSpineItem(currentSpineIndex).href; Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); section = std::unique_ptr
(new Section(epub, currentSpineIndex, renderer)); if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft, diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 1cda06ea..3754fa04 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -29,7 +29,7 @@ void EpubReaderChapterSelectionActivity::onEnter() { // Trigger first update updateRequired = true; xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle diff --git a/src/main.cpp b/src/main.cpp index 83a33cc7..b71ea399 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -186,7 +186,8 @@ void setup() { SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS); // SD Card Initialization - if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ)) { + // We need 6 open files concurrently when parsing a new chapter + if (!SD.begin(SD_SPI_CS, SPI, SPI_FQ, "/sd", 6)) { Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); setupDisplayAndFonts(); exitActivity(); From 504c7b307d8cb0ec7d54cdca93c720f04464d323 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 24 Dec 2025 21:49:47 +1000 Subject: [PATCH 33/46] Cut release 0.9.0 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 42de32c0..9cd5df2e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,5 +1,5 @@ [platformio] -crosspoint_version = 0.8.1 +crosspoint_version = 0.9.0 default_envs = default [base] From dc7544d9446249fea05f342ccc3b1df29b789bb0 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Fri, 26 Dec 2025 09:46:17 +0900 Subject: [PATCH 34/46] Optimize glyph lookup with binary search (#125) Replace linear O(n) search with binary search O(log n) for unicode interval lookup. Korean fonts have many intervals (~30,000+ glyphs), so this improves text rendering performance during page navigation. ## Summary * **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Implements the new feature for file uploading.) Replace linear `O(n)` glyph lookup with binary search `O(log n)` to improve text rendering performance during page navigation. * **What changes are included?** - Modified `EpdFont::getGlyph()` to use binary search instead of linear search for unicode interval lookup - Added early return for empty interval count ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). - Performance implications: Fonts with many unicode intervals benefit the most. Korean fonts have ~30,000+ glyphs across multiple intervals, but any font with significant glyph coverage (CJK, extended Latin, emoji, etc.) will see improvement. - Complexity: from `O(n)` to `O(log n)` where n = number of unicode intervals. For fonts with 10+ intervals, this reduces lookup iterations significantly. - Risk: Low - the binary search logic is straightforward and the intervals are already sorted by unicode codepoint (required for the original early-exit optimization). --- lib/EpdFont/EpdFont.cpp | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index 0f53f95b..661317d6 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -59,14 +59,28 @@ bool EpdFont::hasPrintableChars(const char* string) const { const EpdGlyph* EpdFont::getGlyph(const uint32_t cp) const { const EpdUnicodeInterval* intervals = data->intervals; - for (int i = 0; i < data->intervalCount; i++) { - const EpdUnicodeInterval* interval = &intervals[i]; - if (cp >= interval->first && cp <= interval->last) { + const int count = data->intervalCount; + + if (count == 0) return nullptr; + + // Binary search for O(log n) lookup instead of O(n) + // Critical for Korean fonts with many unicode intervals + int left = 0; + int right = count - 1; + + while (left <= right) { + const int mid = left + (right - left) / 2; + const EpdUnicodeInterval* interval = &intervals[mid]; + + if (cp < interval->first) { + right = mid - 1; + } else if (cp > interval->last) { + left = mid + 1; + } else { + // Found: cp >= interval->first && cp <= interval->last return &data->glyph[interval->offset + (cp - interval->first)]; } - if (cp < interval->first) { - return nullptr; - } } + return nullptr; } From e3c1e28b8fff140be0977106485cf1bf05575d8c Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Thu, 25 Dec 2025 19:54:02 -0500 Subject: [PATCH 35/46] Normalize button hints (#130) ## Summary This creates a `renderer.drawButtonHints` to make all of the "hints" over buttons to match the home screen. ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --------- Co-authored-by: Dave Allie --- lib/GfxRenderer/GfxRenderer.cpp | 22 +++++++++++++++++++ lib/GfxRenderer/GfxRenderer.h | 3 +++ src/activities/home/HomeActivity.cpp | 14 +----------- .../network/CrossPointWebServerActivity.cpp | 4 +--- .../network/NetworkModeSelectionActivity.cpp | 2 +- .../network/WifiSelectionActivity.cpp | 5 +++-- .../reader/FileSelectionActivity.cpp | 2 +- src/activities/settings/SettingsActivity.cpp | 2 +- 8 files changed, 33 insertions(+), 21 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a4b9369b..6433748e 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -239,6 +239,28 @@ int GfxRenderer::getLineHeight(const int fontId) const { return fontMap.at(fontId).getData(REGULAR)->advanceY; } +void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const { + const int pageHeight = getScreenHeight(); + constexpr int buttonWidth = 106; + constexpr int buttonHeight = 40; + constexpr int buttonY = 40; // Distance from bottom + constexpr int textYOffset = 5; // Distance from top of button to text baseline + constexpr int buttonPositions[] = {25, 130, 245, 350}; + const char* labels[] = {btn1, btn2, btn3, btn4}; + + for (int i = 0; i < 4; i++) { + // Only draw if the label is non-empty + if (labels[i] != nullptr && labels[i][0] != '\0') { + const int x = buttonPositions[i]; + drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); + const int textWidth = getTextWidth(fontId, labels[i]); + const int textX = x + (buttonWidth - 1 - textWidth) / 2; + drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]); + } + } +} + uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); } size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 838e0180..00a525dd 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -57,6 +57,9 @@ class GfxRenderer { int getSpaceWidth(int fontId) const; int getLineHeight(int fontId) const; + // UI Components + void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const; + // Grayscale functions void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void copyGrayscaleLsbBuffers() const; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index bbda1307..68e64b33 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -85,7 +85,6 @@ void HomeActivity::render() const { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); renderer.drawCenteredText(READER_FONT_ID, 10, "CrossPoint Reader", true, BOLD); // Draw selection @@ -94,18 +93,7 @@ void HomeActivity::render() const { renderer.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1); renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2); - renderer.drawRect(25, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 25 + (105 - renderer.getTextWidth(UI_FONT_ID, "Back")) / 2, pageHeight - 35, "Back"); - - renderer.drawRect(130, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 130 + (105 - renderer.getTextWidth(UI_FONT_ID, "Confirm")) / 2, pageHeight - 35, - "Confirm"); - - renderer.drawRect(245, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 245 + (105 - renderer.getTextWidth(UI_FONT_ID, "Left")) / 2, pageHeight - 35, "Left"); - - renderer.drawRect(350, pageHeight - 40, 106, 40); - renderer.drawText(UI_FONT_ID, 350 + (105 - renderer.getTextWidth(UI_FONT_ID, "Right")) / 2, pageHeight - 35, "Right"); + renderer.drawButtonHints(UI_FONT_ID, "Back", "Confirm", "Left", "Right"); renderer.displayBuffer(); } diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index b9c911e2..34b1a3a8 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -337,8 +337,6 @@ void CrossPointWebServerActivity::render() const { } void CrossPointWebServerActivity::renderServerRunning() const { - const auto pageHeight = renderer.getScreenHeight(); - // Use consistent line spacing constexpr int LINE_SPACING = 28; // Space between lines @@ -389,5 +387,5 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR); } - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR); + renderer.drawButtonHints(UI_FONT_ID, "« Exit", "", "", ""); } diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index 637d82d9..af68a20b 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -122,7 +122,7 @@ void NetworkModeSelectionActivity::render() const { } // Draw help text at bottom - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press OK to select, BACK to cancel", true, REGULAR); + renderer.drawButtonHints(UI_FONT_ID, "« Back", "Select", "", ""); renderer.displayBuffer(); } diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index c18e0f57..80e46ceb 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -548,11 +548,12 @@ void WifiSelectionActivity::renderNetworkList() const { // Show network count char countStr[32]; snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size()); - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 45, countStr); + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 90, countStr); } // Draw help text - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "OK: Connect | * = Encrypted | + = Saved"); + renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); + renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", ""); } void WifiSelectionActivity::renderPasswordEntry() const { diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index a6c10834..d6504b84 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -149,7 +149,7 @@ void FileSelectionActivity::render() const { renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); // Help text - renderer.drawText(SMALL_FONT_ID, 20, GfxRenderer::getScreenHeight() - 30, "Press BACK for Home"); + renderer.drawButtonHints(UI_FONT_ID, "« Home", "", "", ""); if (files.empty()) { renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 37f2e5a1..f7af052e 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -169,7 +169,7 @@ void SettingsActivity::render() const { } // Draw help text - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 30, "Press OK to toggle, BACK to save & exit"); + renderer.drawButtonHints(UI_FONT_ID, "« Save", "Toggle", "", ""); renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), pageHeight - 30, CROSSPOINT_VERSION); From b77af16caa3e1c668d57fd6dec2aaa06b9e223f0 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Fri, 26 Dec 2025 09:55:23 +0900 Subject: [PATCH 36/46] Add Continue Reading menu and remember last book folder (#129) ## Summary * **What is the goal of this PR?** Add a "Continue Reading" feature to improve user experience when returning to a previously opened book. * **What changes are included?** - Add dynamic "Continue: " menu item in Home screen when a book was previously opened - File browser now starts from the folder of the last opened book instead of always starting from root directory - Menu dynamically shows 3 or 4 items based on reading history: - Without history: `Browse`, `File transfer`, `Settings` - With history: `Continue: `, `Browse`, `File transfer`, `Settings` ## Additional Context * This feature leverages the existing `APP_STATE.openEpubPath` which already persists the last opened book path * The Continue Reading menu only appears if the book file still exists on the SD card * Book name in the menu is truncated to 25 characters with "..." suffix if too long * If the last book's folder was deleted, the file browser gracefully falls back to root directory * No new dependencies or significant memory overhead - reuses existing state management --- src/activities/home/HomeActivity.cpp | 80 +++++++++++++++---- src/activities/home/HomeActivity.h | 7 +- src/activities/reader/EpubReaderActivity.cpp | 10 ++- src/activities/reader/EpubReaderActivity.h | 8 +- .../reader/FileSelectionActivity.cpp | 33 +++++--- src/activities/reader/FileSelectionActivity.h | 7 +- src/activities/reader/ReaderActivity.cpp | 24 +++++- src/activities/reader/ReaderActivity.h | 4 +- src/main.cpp | 4 +- 9 files changed, 140 insertions(+), 37 deletions(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 68e64b33..38dc8542 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,22 +4,24 @@ #include #include +#include "CrossPointState.h" #include "config.h" -namespace { -constexpr int menuItemCount = 3; -} - void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } +int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; } + void HomeActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); + // Check if we have a book to continue reading + hasContinueReading = !APP_STATE.openEpubPath.empty() && SD.exists(APP_STATE.openEpubPath.c_str()); + selectorIndex = 0; // Trigger first update @@ -52,19 +54,35 @@ void HomeActivity::loop() { const bool nextPressed = inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); + const int menuCount = getMenuItemCount(); + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { - if (selectorIndex == 0) { - onReaderOpen(); - } else if (selectorIndex == 1) { - onFileTransferOpen(); - } else if (selectorIndex == 2) { - onSettingsOpen(); + if (hasContinueReading) { + // Menu: Continue Reading, Browse, File transfer, Settings + if (selectorIndex == 0) { + onContinueReading(); + } else if (selectorIndex == 1) { + onReaderOpen(); + } else if (selectorIndex == 2) { + onFileTransferOpen(); + } else if (selectorIndex == 3) { + onSettingsOpen(); + } + } else { + // Menu: Browse, File transfer, Settings + if (selectorIndex == 0) { + onReaderOpen(); + } else if (selectorIndex == 1) { + onFileTransferOpen(); + } else if (selectorIndex == 2) { + onSettingsOpen(); + } } } else if (prevPressed) { - selectorIndex = (selectorIndex + menuItemCount - 1) % menuItemCount; + selectorIndex = (selectorIndex + menuCount - 1) % menuCount; updateRequired = true; } else if (nextPressed) { - selectorIndex = (selectorIndex + 1) % menuItemCount; + selectorIndex = (selectorIndex + 1) % menuCount; updateRequired = true; } } @@ -89,9 +107,41 @@ void HomeActivity::render() const { // Draw selection renderer.fillRect(0, 60 + selectorIndex * 30 + 2, pageWidth - 1, 30); - renderer.drawText(UI_FONT_ID, 20, 60, "Read", selectorIndex != 0); - renderer.drawText(UI_FONT_ID, 20, 90, "File transfer", selectorIndex != 1); - renderer.drawText(UI_FONT_ID, 20, 120, "Settings", selectorIndex != 2); + + int menuY = 60; + int menuIndex = 0; + + if (hasContinueReading) { + // Extract filename from path for display + std::string bookName = APP_STATE.openEpubPath; + const size_t lastSlash = bookName.find_last_of('/'); + if (lastSlash != std::string::npos) { + bookName = bookName.substr(lastSlash + 1); + } + // Remove .epub extension + if (bookName.length() > 5 && bookName.substr(bookName.length() - 5) == ".epub") { + bookName.resize(bookName.length() - 5); + } + // Truncate if too long + if (bookName.length() > 25) { + bookName.resize(22); + bookName += "..."; + } + std::string continueLabel = "Continue: " + bookName; + renderer.drawText(UI_FONT_ID, 20, menuY, continueLabel.c_str(), selectorIndex != menuIndex); + menuY += 30; + menuIndex++; + } + + renderer.drawText(UI_FONT_ID, 20, menuY, "Browse", selectorIndex != menuIndex); + menuY += 30; + menuIndex++; + + renderer.drawText(UI_FONT_ID, 20, menuY, "File transfer", selectorIndex != menuIndex); + menuY += 30; + menuIndex++; + + renderer.drawText(UI_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex); renderer.drawButtonHints(UI_FONT_ID, "Back", "Confirm", "Left", "Right"); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 943a4665..0704819c 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -12,6 +12,8 @@ class HomeActivity final : public Activity { SemaphoreHandle_t renderingMutex = nullptr; int selectorIndex = 0; bool updateRequired = false; + bool hasContinueReading = false; + const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; @@ -19,11 +21,14 @@ class HomeActivity final : public Activity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; + int getMenuItemCount() const; public: - explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onReaderOpen, + explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, + const std::function& onContinueReading, const std::function& onReaderOpen, const std::function& onSettingsOpen, const std::function& onFileTransferOpen) : Activity("Home", renderer, inputManager), + onContinueReading(onContinueReading), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen) {} diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6195ec22..f4905d60 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -14,6 +14,7 @@ namespace { constexpr int pagesPerRefresh = 15; constexpr unsigned long skipChapterMs = 700; +constexpr unsigned long goHomeMs = 1000; constexpr float lineCompression = 0.95f; constexpr int marginTop = 8; constexpr int marginRight = 10; @@ -108,7 +109,14 @@ void EpubReaderActivity::loop() { xSemaphoreGive(renderingMutex); } - if (inputManager.wasPressed(InputManager::BTN_BACK)) { + // Long press BACK (1s+) goes directly to home + if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) { + onGoHome(); + return; + } + + // Short press BACK goes to file selection + if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) { onGoBack(); return; } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 4edbabc2..143f56b1 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -17,6 +17,7 @@ class EpubReaderActivity final : public ActivityWithSubactivity { int pagesUntilFullRefresh = 0; bool updateRequired = false; const std::function onGoBack; + const std::function onGoHome; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); @@ -26,8 +27,11 @@ class EpubReaderActivity final : public ActivityWithSubactivity { public: explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr epub, - const std::function& onGoBack) - : ActivityWithSubactivity("EpubReader", renderer, inputManager), epub(std::move(epub)), onGoBack(onGoBack) {} + const std::function& onGoBack, const std::function& onGoHome) + : ActivityWithSubactivity("EpubReader", renderer, inputManager), + epub(std::move(epub)), + onGoBack(onGoBack), + onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index d6504b84..853b06f1 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -9,6 +9,7 @@ namespace { constexpr int PAGE_ITEMS = 23; constexpr int SKIP_PAGE_MS = 700; +constexpr unsigned long GO_HOME_MS = 1000; } // namespace void sortFileList(std::vector& strs) { @@ -53,7 +54,7 @@ void FileSelectionActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); - basepath = "/"; + // basepath is set via constructor parameter (defaults to "/" if not specified) loadFiles(); selectorIndex = 0; @@ -83,6 +84,16 @@ void FileSelectionActivity::onExit() { } void FileSelectionActivity::loop() { + // Long press BACK (1s+) goes to root folder + if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= GO_HOME_MS) { + if (basepath != "/") { + basepath = "/"; + loadFiles(); + updateRequired = true; + } + return; + } + const bool prevReleased = inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); const bool nextReleased = @@ -103,15 +114,17 @@ void FileSelectionActivity::loop() { } else { onSelect(basepath + files[selectorIndex]); } - } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { - if (basepath != "/") { - basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); - if (basepath.empty()) basepath = "/"; - loadFiles(); - updateRequired = true; - } else { - // At root level, go back home - onGoHome(); + } else if (inputManager.wasReleased(InputManager::BTN_BACK)) { + // Short press: go up one directory, or go home if at root + if (inputManager.getHeldTime() < GO_HOME_MS) { + if (basepath != "/") { + basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); + if (basepath.empty()) basepath = "/"; + loadFiles(); + updateRequired = true; + } else { + onGoHome(); + } } } else if (prevReleased) { if (skipPage) { diff --git a/src/activities/reader/FileSelectionActivity.h b/src/activities/reader/FileSelectionActivity.h index 2a8f8ae1..f642e209 100644 --- a/src/activities/reader/FileSelectionActivity.h +++ b/src/activities/reader/FileSelectionActivity.h @@ -27,8 +27,11 @@ class FileSelectionActivity final : public Activity { public: explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function& onSelect, - const std::function& onGoHome) - : Activity("FileSelection", renderer, inputManager), onSelect(onSelect), onGoHome(onGoHome) {} + const std::function& onGoHome, std::string initialPath = "/") + : Activity("FileSelection", renderer, inputManager), + basepath(initialPath.empty() ? "/" : std::move(initialPath)), + onSelect(onSelect), + onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index d888fb6e..519a33a2 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -7,6 +7,14 @@ #include "FileSelectionActivity.h" #include "activities/util/FullScreenMessageActivity.h" +std::string ReaderActivity::extractFolderPath(const std::string& filePath) { + const auto lastSlash = filePath.find_last_of('/'); + if (lastSlash == std::string::npos || lastSlash == 0) { + return "/"; + } + return filePath.substr(0, lastSlash); +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!SD.exists(path.c_str())) { Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); @@ -23,6 +31,7 @@ std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { } void ReaderActivity::onSelectEpubFile(const std::string& path) { + currentEpubPath = path; // Track current book path exitActivity(); enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading...")); @@ -38,25 +47,32 @@ void ReaderActivity::onSelectEpubFile(const std::string& path) { } } -void ReaderActivity::onGoToFileSelection() { +void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) { exitActivity(); + // If coming from a book, start in that book's folder; otherwise start from root + const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath); enterNewActivity(new FileSelectionActivity( - renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack)); + renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); } void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { + const auto epubPath = epub->getPath(); + currentEpubPath = epubPath; exitActivity(); - enterNewActivity(new EpubReaderActivity(renderer, inputManager, std::move(epub), [this] { onGoToFileSelection(); })); + enterNewActivity(new EpubReaderActivity( + renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); }, + [this] { onGoBack(); })); } void ReaderActivity::onEnter() { ActivityWithSubactivity::onEnter(); if (initialEpubPath.empty()) { - onGoToFileSelection(); + onGoToFileSelection(); // Start from root when entering via Browse return; } + currentEpubPath = initialEpubPath; auto epub = loadEpub(initialEpubPath); if (!epub) { onGoBack(); diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index e566d6d3..5bb34193 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -7,11 +7,13 @@ class Epub; class ReaderActivity final : public ActivityWithSubactivity { std::string initialEpubPath; + std::string currentEpubPath; // Track current book path for navigation const std::function onGoBack; static std::unique_ptr loadEpub(const std::string& path); + static std::string extractFolderPath(const std::string& filePath); void onSelectEpubFile(const std::string& path); - void onGoToFileSelection(); + void onGoToFileSelection(const std::string& fromEpubPath = ""); void onGoToEpubReader(std::unique_ptr epub); public: diff --git a/src/main.cpp b/src/main.cpp index b71ea399..9b950f1f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -142,6 +142,7 @@ void onGoToReader(const std::string& initialEpubPath) { enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome)); } void onGoToReaderHome() { onGoToReader(std::string()); } +void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); } void onGoToFileTransfer() { exitActivity(); @@ -155,7 +156,8 @@ void onGoToSettings() { void onGoHome() { exitActivity(); - enterNewActivity(new HomeActivity(renderer, inputManager, onGoToReaderHome, onGoToSettings, onGoToFileTransfer)); + enterNewActivity(new HomeActivity(renderer, inputManager, onContinueReading, onGoToReaderHome, onGoToSettings, + onGoToFileTransfer)); } void setupDisplayAndFonts() { From e8c0fb42d445ce208351e03adf34a7e2dcc00891 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Fri, 26 Dec 2025 02:13:40 +0100 Subject: [PATCH 37/46] Network details QR code (#113) Using QRCode library from pio to generate the QR code. Done: - Display QR code for URL in network mode - minor fixes of layout - Display QR for URL in AP mode - Display QR for AP in AP mode --------- Co-authored-by: Dave Allie --- .github/workflows/ci.yml | 6 --- platformio.ini | 1 + .../network/CrossPointWebServerActivity.cpp | 46 ++++++++++++++++++- .../network/WifiSelectionActivity.cpp | 7 ++- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1d91ec5..be9a6e59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,12 +12,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - - uses: actions/cache@v5 - with: - path: | - ~/.cache/pip - ~/.platformio/.cache - key: ${{ runner.os }}-pio - uses: actions/setup-python@v6 with: python-version: '3.14' diff --git a/platformio.ini b/platformio.ini index 9cd5df2e..0fd766a3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -40,6 +40,7 @@ lib_deps = InputManager=symlink://open-x4-sdk/libs/hardware/InputManager EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay ArduinoJson @ 7.4.2 + QRCode @ 0.0.1 [env:default] extends = base diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 34b1a3a8..c370bc28 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -4,8 +4,12 @@ #include #include #include +#include #include +#include +#include + #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" #include "config.h" @@ -336,6 +340,28 @@ void CrossPointWebServerActivity::render() const { } } +void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data) { + // Implementation of QR code calculation + // The structure to manage the QR code + QRCode qrcode; + uint8_t qrcodeBytes[qrcode_getBufferSize(4)]; + Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str()); + + qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str()); + const uint8_t px = 6; // pixels per module + for (uint8_t cy = 0; cy < qrcode.size; cy++) { + for (uint8_t cx = 0; cx < qrcode.size; cx++) { + if (qrcode_getModule(&qrcode, cx, cy)) { + // Serial.print("**"); + renderer.fillRect(x + px * cx, y + px * cy, px, px, true); + } else { + // Serial.print(" "); + } + } + // Serial.print("\n"); + } +} + void CrossPointWebServerActivity::renderServerRunning() const { // Use consistent line spacing constexpr int LINE_SPACING = 28; // Space between lines @@ -344,7 +370,7 @@ void CrossPointWebServerActivity::renderServerRunning() const { if (isApMode) { // AP mode display - center the content block - const int startY = 55; + int startY = 55; renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD); @@ -354,6 +380,13 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network", true, REGULAR); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, + "or scan QR code with your phone to connect to Wifi.", true, REGULAR); + // Show QR code for URL + std::string wifiConfig = std::string("WIFI:T:WPA;S:") + connectedSSID + ";P:" + "" + ";;"; + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig); + + startY += 6 * 29 + 3 * LINE_SPACING; // Show primary URL (hostname) std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD); @@ -361,8 +394,12 @@ void CrossPointWebServerActivity::renderServerRunning() const { // Show IP address as fallback std::string ipUrl = "or http://" + connectedIP + "/"; renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR); - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR); + + // Show QR code for URL + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "or scan QR code with your phone:", true, + REGULAR); + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 7, hostnameUrl); } else { // STA mode display (original behavior) const int startY = 65; @@ -385,6 +422,11 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR); + + // Show QR code for URL + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, webInfo); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:", true, + REGULAR); } renderer.drawButtonHints(UI_FONT_ID, "« Exit", "", "", ""); diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 80e46ceb..68c6481e 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -150,6 +150,11 @@ void WifiSelectionActivity::processWifiScanResults() { 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) { + return a.hasSavedPassword && !b.hasSavedPassword; + }); + WiFi.scanDelete(); state = WifiSelectionState::NETWORK_LIST; selectedNetworkIndex = 0; @@ -581,7 +586,7 @@ void WifiSelectionActivity::renderConnecting() const { if (state == WifiSelectionState::SCANNING) { renderer.drawCenteredText(UI_FONT_ID, top, "Scanning...", true, REGULAR); } else { - renderer.drawCenteredText(READER_FONT_ID, top - 30, "Connecting...", true, BOLD); + renderer.drawCenteredText(READER_FONT_ID, top - 40, "Connecting...", true, BOLD); std::string ssidInfo = "to " + selectedSSID; if (ssidInfo.length() > 25) { From 98a39374e8609c55fd77641dd8e781f0d5b30868 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Fri, 26 Dec 2025 11:29:27 +1000 Subject: [PATCH 38/46] Fix QRCode import --- src/activities/network/CrossPointWebServerActivity.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index c370bc28..ef31168b 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -4,11 +4,10 @@ #include #include #include -#include +#include #include #include -#include #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" From aff4dc6628703304c493c7c5224160aa27dacf65 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Fri, 26 Dec 2025 11:33:41 +1000 Subject: [PATCH 39/46] Fix QRCode import attempt 2 --- src/activities/network/CrossPointWebServerActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index ef31168b..e0ec6823 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -4,8 +4,8 @@ #include #include #include -#include #include +#include #include From 286b47f48937df499e6e4ca83254f265f70f5a09 Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 28 Dec 2025 08:35:45 +0900 Subject: [PATCH 40/46] fix(parser): remove MAX_LINES limit that truncates long chapters (#132) ## Summary * **What is the goal of this PR?** Fixes a bug where text disappears after approximately 25 pages in long chapters during EPUB indexing. * **What changes are included?** - Removed the `MAX_LINES = 1000` hard limit in `ParsedText::computeLineBreaks()` - Added safer infinite loop prevention by checking if `nextBreakIndex <= currentWordIndex` and forcing advancement by one word when stuck ## Additional Context * **Root cause:** The `MAX_LINES = 1000` limit was introduced to prevent infinite loops, but it truncates content in long chapters. For example, a 93KB chapter that generates ~242 pages (~9,680 lines) gets cut off at ~1000 lines, causing blank pages after page 25-27. * **Solution approach:** Instead of a hard line limit, I now detect when the line break algorithm gets stuck (when `nextBreakIndex` doesn't advance) and force progress by moving one word at a time. This preserves the infinite loop protection while allowing all content to be rendered. * **Testing:** Verified with a Korean EPUB containing a 93KB chapter - all 242 pages now render correctly without text disappearing. --- lib/Epub/Epub/ParsedText.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index d73f80a5..c2f13d8b 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -111,16 +111,17 @@ std::vector ParsedText::computeLineBreaks(const int pageWidth, const int // Stores the index of the word that starts the next line (last_word_index + 1) std::vector lineBreakIndices; size_t currentWordIndex = 0; - constexpr size_t MAX_LINES = 1000; while (currentWordIndex < totalWordCount) { - if (lineBreakIndices.size() >= MAX_LINES) { - break; + size_t nextBreakIndex = ans[currentWordIndex] + 1; + + // Safety check: prevent infinite loop if nextBreakIndex doesn't advance + if (nextBreakIndex <= currentWordIndex) { + // Force advance by at least one word to avoid infinite loop + nextBreakIndex = currentWordIndex + 1; } - size_t nextBreakIndex = ans[currentWordIndex] + 1; lineBreakIndices.push_back(nextBreakIndex); - currentWordIndex = nextBreakIndex; } From e3d0201365b9a3d5bb0b04fc2b65b36b986481a2 Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Sat, 27 Dec 2025 18:36:26 -0500 Subject: [PATCH 41/46] Add 'Open' button hint to File Selection page (#136) ## Summary In using my build of https://github.com/daveallie/crosspoint-reader/pull/130 I realized that we need a "open" button hint above the second button in the File browser ## Additional Context * Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). --- src/activities/reader/FileSelectionActivity.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 853b06f1..9a1490c5 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -162,7 +162,7 @@ void FileSelectionActivity::render() const { renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); // Help text - renderer.drawButtonHints(UI_FONT_ID, "« Home", "", "", ""); + renderer.drawButtonHints(UI_FONT_ID, "« Home", "Open", "", ""); if (files.empty()) { renderer.drawText(UI_FONT_ID, 20, 60, "No EPUBs found"); From f96b6ab29c737b8487f8ad30be154b2d7dafe44a Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 28 Dec 2025 08:38:14 +0900 Subject: [PATCH 42/46] Improve EPUB cover image quality with pre-scaling and Atkinson dithering (#116) ## Summary * **What is the goal of this PR?** Replace simple threshold-based grayscale quantization with ordered dithering using a 4x4 Bayer matrix. This eliminates color banding artifacts and produces smoother gradients on e-ink display. * **What changes are included?** - Add 4x4 Bayer dithering matrix for 16-level threshold patterns - Modify `grayscaleTo2Bit()` function to accept pixel coordinates and apply position-based dithering - Replace simple `grayscale >> 6` threshold with ordered dithering algorithm that produces smoother gradients ## Additional Context * Bayer matrix approach: The 4x4 Bayer matrix creates a repeating pattern that distributes quantization error spatially, effectively simulating 16 levels of gray using only 4 actual color levels (black, dark gray, light gray, white). * Cache invalidation: Existing cached `cover.bmp` files will need to be deleted to see the improved rendering, as the converter only runs when the cache is missing. --- lib/GfxRenderer/Bitmap.cpp | 176 ++++- lib/GfxRenderer/Bitmap.h | 9 +- lib/GfxRenderer/GfxRenderer.cpp | 6 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 602 ++++++++++++++++-- lib/JpegToBmpConverter/JpegToBmpConverter.h | 2 +- 5 files changed, 727 insertions(+), 68 deletions(-) diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index c9ad6f85..a034c757 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -3,6 +3,126 @@ #include #include +// ============================================================================ +// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations +// ============================================================================ +// Note: For cover images, dithering is done in JpegToBmpConverter.cpp +// This file handles BMP reading - use simple quantization to avoid double-dithering +constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion +constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering +// Brightness adjustments: +constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true +constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true +// ============================================================================ + +// Integer approximation of gamma correction (brightens midtones) +static inline int applyGamma(int gray) { + if (!GAMMA_CORRECTION) return gray; + const int product = gray * 255; + int x = gray; + if (x > 0) { + x = (x + product / x) >> 1; + x = (x + product / x) >> 1; + } + return x > 255 ? 255 : x; +} + +// Simple quantization without dithering - just divide into 4 levels +static inline uint8_t quantizeSimple(int gray) { + if (USE_BRIGHTNESS) { + gray += BRIGHTNESS_BOOST; + if (gray > 255) gray = 255; + gray = applyGamma(gray); + } + return static_cast(gray >> 6); +} + +// Hash-based noise dithering - survives downsampling without moiré artifacts +static inline uint8_t quantizeNoise(int gray, int x, int y) { + if (USE_BRIGHTNESS) { + gray += BRIGHTNESS_BOOST; + if (gray > 255) gray = 255; + gray = applyGamma(gray); + } + + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); + + const int scaled = gray * 3; + if (scaled < 255) { + return (scaled + threshold >= 255) ? 1 : 0; + } else if (scaled < 510) { + return ((scaled - 255) + threshold >= 255) ? 2 : 1; + } else { + return ((scaled - 510) + threshold >= 255) ? 3 : 2; + } +} + +// Main quantization function +static inline uint8_t quantize(int gray, int x, int y) { + if (USE_NOISE_DITHERING) { + return quantizeNoise(gray, x, y); + } else { + return quantizeSimple(gray); + } +} + +// Floyd-Steinberg quantization with error diffusion and serpentine scanning +// Returns 2-bit value (0-3) and updates error buffers +static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow, + bool reverseDir) { + // Add accumulated error to this pixel + int adjusted = gray + errorCurRow[x + 1]; + + // Clamp to valid range + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels (0, 85, 170, 255) + uint8_t quantized; + int quantizedValue; + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + + // Calculate error + int error = adjusted - quantizedValue; + + // Distribute error to neighbors (serpentine: direction-aware) + if (!reverseDir) { + // Left to right + errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16 + errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16 + errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 + errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16 + } else { + // Right to left (mirrored) + errorCurRow[x] += (error * 7) >> 4; // Left: 7/16 + errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16 + errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 + errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16 + } + + return quantized; +} + +Bitmap::~Bitmap() { + delete[] errorCurRow; + delete[] errorNextRow; +} + uint16_t Bitmap::readLE16(File& f) { const int c0 = f.read(); const int c1 = f.read(); @@ -46,6 +166,8 @@ const char* Bitmap::errorToString(BmpReaderError err) { return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; case BmpReaderError::BadDimensions: return "BadDimensions"; + case BmpReaderError::ImageTooLarge: + return "ImageTooLarge (max 2048x3072)"; case BmpReaderError::PaletteTooLarge: return "PaletteTooLarge"; @@ -99,6 +221,13 @@ BmpReaderError Bitmap::parseHeaders() { if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions; + // Safety limits to prevent memory issues on ESP32 + constexpr int MAX_IMAGE_WIDTH = 2048; + constexpr int MAX_IMAGE_HEIGHT = 3072; + if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) { + return BmpReaderError::ImageTooLarge; + } + // Pre-calculate Row Bytes to avoid doing this every row rowBytes = (width * bpp + 31) / 32 * 4; @@ -115,21 +244,56 @@ BmpReaderError Bitmap::parseHeaders() { return BmpReaderError::SeekPixelDataFailed; } + // Allocate Floyd-Steinberg error buffers if enabled + if (USE_FLOYD_STEINBERG) { + delete[] errorCurRow; + delete[] errorNextRow; + errorCurRow = new int16_t[width + 2](); // +2 for boundary handling + errorNextRow = new int16_t[width + 2](); + lastRowY = -1; + } + return BmpReaderError::Ok; } // packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white -BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { +BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const { // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; + // Handle Floyd-Steinberg error buffer progression + const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow; + if (useFS) { + // Check if we need to advance to next row (or reset if jumping) + if (rowY != lastRowY + 1 && rowY != 0) { + // Non-sequential row access - reset error buffers + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + } else if (rowY > 0) { + // Sequential access - swap buffers + int16_t* temp = errorCurRow; + errorCurRow = errorNextRow; + errorNextRow = temp; + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + } + lastRowY = rowY; + } + uint8_t* outPtr = data; uint8_t currentOutByte = 0; int bitShift = 6; + int currentX = 0; // Helper lambda to pack 2bpp color into the output stream auto packPixel = [&](const uint8_t lum) { - uint8_t color = (lum >> 6); // Simple 2-bit reduction: 0-255 -> 0-3 + uint8_t color; + if (useFS) { + // Floyd-Steinberg error diffusion + color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); + } else { + // Simple quantization or noise dithering + color = quantize(lum, currentX, rowY); + } currentOutByte |= (color << bitShift); if (bitShift == 0) { *outPtr++ = currentOutByte; @@ -138,6 +302,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer) const { } else { bitShift -= 2; } + currentX++; }; uint8_t lum; @@ -196,5 +361,12 @@ BmpReaderError Bitmap::rewindToData() const { return BmpReaderError::SeekPixelDataFailed; } + // Reset Floyd-Steinberg error buffers when rewinding + if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) { + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + lastRowY = -1; + } + return BmpReaderError::Ok; } diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 88dc88de..744cb617 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -15,6 +15,7 @@ enum class BmpReaderError : uint8_t { UnsupportedCompression, BadDimensions, + ImageTooLarge, PaletteTooLarge, SeekPixelDataFailed, @@ -28,8 +29,9 @@ class Bitmap { static const char* errorToString(BmpReaderError err); explicit Bitmap(File& file) : file(file) {} + ~Bitmap(); BmpReaderError parseHeaders(); - BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer) const; + BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const; BmpReaderError rewindToData() const; int getWidth() const { return width; } int getHeight() const { return height; } @@ -49,4 +51,9 @@ class Bitmap { uint16_t bpp = 0; int rowBytes = 0; uint8_t paletteLum[256] = {}; + + // Floyd-Steinberg dithering state (mutable for const methods) + mutable int16_t* errorCurRow = nullptr; + mutable int16_t* errorNextRow = nullptr; + mutable int lastRowY = -1; // Track row progression for error propagation }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 6433748e..bcd88087 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -132,7 +132,9 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con isScaled = true; } - const uint8_t outputRowSize = (bitmap.getWidth() + 3) / 4; + // Calculate output row size (2 bits per pixel, packed into bytes) + // IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide + const int outputRowSize = (bitmap.getWidth() + 3) / 4; auto* outputRow = static_cast(malloc(outputRowSize)); auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); @@ -154,7 +156,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con break; } - if (bitmap.readRow(outputRow, rowBytes) != BmpReaderError::Ok) { + if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) { Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); free(outputRow); free(rowBytes); diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index c2c049a7..0a19701c 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -13,24 +13,296 @@ struct JpegReadContext { size_t bufferFilled; }; -// Helper function: Convert 8-bit grayscale to 2-bit (0-3) -uint8_t JpegToBmpConverter::grayscaleTo2Bit(const uint8_t grayscale) { - // Simple threshold mapping: - // 0-63 -> 0 (black) - // 64-127 -> 1 (dark gray) - // 128-191 -> 2 (light gray) - // 192-255 -> 3 (white) - return grayscale >> 6; +// ============================================================================ +// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations +// ============================================================================ +constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantization), false: 2-bit (4 levels) +// Dithering method selection (only one should be true, or all false for simple quantization): +constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion) +constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) +constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) +// Brightness/Contrast adjustments: +constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) +constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones) +constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) +// Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) +constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering +constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width) +constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height) +// ============================================================================ + +// Integer approximation of gamma correction (brightens midtones) +// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255) +static inline int applyGamma(int gray) { + if (!GAMMA_CORRECTION) return gray; + // Fast integer square root approximation for gamma ~0.5 (brightening) + // This brightens dark/mid tones while preserving highlights + const int product = gray * 255; + // Newton-Raphson integer sqrt (2 iterations for good accuracy) + int x = gray; + if (x > 0) { + x = (x + product / x) >> 1; + x = (x + product / x) >> 1; + } + return x > 255 ? 255 : x; } +// Apply contrast adjustment around midpoint (128) +// factor > 1.0 increases contrast, < 1.0 decreases +static inline int applyContrast(int gray) { + // Integer-based contrast: (gray - 128) * factor + 128 + // Using fixed-point: factor 1.15 ≈ 115/100 + constexpr int factorNum = static_cast(CONTRAST_FACTOR * 100); + int adjusted = ((gray - 128) * factorNum) / 100 + 128; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + return adjusted; +} + +// Combined brightness/contrast/gamma adjustment +static inline int adjustPixel(int gray) { + if (!USE_BRIGHTNESS) return gray; + + // Order: contrast first, then brightness, then gamma + gray = applyContrast(gray); + gray += BRIGHTNESS_BOOST; + if (gray > 255) gray = 255; + if (gray < 0) gray = 0; + gray = applyGamma(gray); + + return gray; +} + +// Simple quantization without dithering - just divide into 4 levels +static inline uint8_t quantizeSimple(int gray) { + gray = adjustPixel(gray); + // Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3 + return static_cast(gray >> 6); +} + +// Hash-based noise dithering - survives downsampling without moiré artifacts +// Uses integer hash to generate pseudo-random threshold per pixel +static inline uint8_t quantizeNoise(int gray, int x, int y) { + gray = adjustPixel(gray); + + // Generate noise threshold using integer hash (no regular pattern to alias) + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); // 0-255 + + // Map gray (0-255) to 4 levels with dithering + const int scaled = gray * 3; + + if (scaled < 255) { + return (scaled + threshold >= 255) ? 1 : 0; + } else if (scaled < 510) { + return ((scaled - 255) + threshold >= 255) ? 2 : 1; + } else { + return ((scaled - 510) + threshold >= 255) ? 3 : 2; + } +} + +// Main quantization function - selects between methods based on config +static inline uint8_t quantize(int gray, int x, int y) { + if (USE_NOISE_DITHERING) { + return quantizeNoise(gray, x, y); + } else { + return quantizeSimple(gray); + } +} + +// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results +// Error distribution pattern: +// X 1/8 1/8 +// 1/8 1/8 1/8 +// 1/8 +// Less error buildup = fewer artifacts than Floyd-Steinberg +class AtkinsonDitherer { + public: + AtkinsonDitherer(int width) : width(width) { + errorRow0 = new int16_t[width + 4](); // Current row + errorRow1 = new int16_t[width + 4](); // Next row + errorRow2 = new int16_t[width + 4](); // Row after next + } + + ~AtkinsonDitherer() { + delete[] errorRow0; + delete[] errorRow1; + delete[] errorRow2; + } + + uint8_t processPixel(int gray, int x) { + // Apply brightness/contrast/gamma adjustments + gray = adjustPixel(gray); + + // Add accumulated error + int adjusted = gray + errorRow0[x + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels + uint8_t quantized; + int quantizedValue; + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + + // Calculate error (only distribute 6/8 = 75%) + int error = (adjusted - quantizedValue) >> 3; // error/8 + + // Distribute 1/8 to each of 6 neighbors + errorRow0[x + 3] += error; // Right + errorRow0[x + 4] += error; // Right+1 + errorRow1[x + 1] += error; // Bottom-left + errorRow1[x + 2] += error; // Bottom + errorRow1[x + 3] += error; // Bottom-right + errorRow2[x + 2] += error; // Two rows down + + return quantized; + } + + void nextRow() { + int16_t* temp = errorRow0; + errorRow0 = errorRow1; + errorRow1 = errorRow2; + errorRow2 = temp; + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + void reset() { + memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + private: + int width; + int16_t* errorRow0; + int16_t* errorRow1; + int16_t* errorRow2; +}; + +// Floyd-Steinberg error diffusion dithering with serpentine scanning +// Serpentine scanning alternates direction each row to reduce "worm" artifacts +// Error distribution pattern (left-to-right): +// X 7/16 +// 3/16 5/16 1/16 +// Error distribution pattern (right-to-left, mirrored): +// 1/16 5/16 3/16 +// 7/16 X +class FloydSteinbergDitherer { + public: + FloydSteinbergDitherer(int width) : width(width), rowCount(0) { + errorCurRow = new int16_t[width + 2](); // +2 for boundary handling + errorNextRow = new int16_t[width + 2](); + } + + ~FloydSteinbergDitherer() { + delete[] errorCurRow; + delete[] errorNextRow; + } + + // Process a single pixel and return quantized 2-bit value + // x is the logical x position (0 to width-1), direction handled internally + uint8_t processPixel(int gray, int x, bool reverseDirection) { + // Add accumulated error to this pixel + int adjusted = gray + errorCurRow[x + 1]; + + // Clamp to valid range + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels (0, 85, 170, 255) + uint8_t quantized; + int quantizedValue; + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + + // Calculate error + int error = adjusted - quantizedValue; + + // Distribute error to neighbors (serpentine: direction-aware) + if (!reverseDirection) { + // Left to right: standard distribution + // Right: 7/16 + errorCurRow[x + 2] += (error * 7) >> 4; + // Bottom-left: 3/16 + errorNextRow[x] += (error * 3) >> 4; + // Bottom: 5/16 + errorNextRow[x + 1] += (error * 5) >> 4; + // Bottom-right: 1/16 + errorNextRow[x + 2] += (error) >> 4; + } else { + // Right to left: mirrored distribution + // Left: 7/16 + errorCurRow[x] += (error * 7) >> 4; + // Bottom-right: 3/16 + errorNextRow[x + 2] += (error * 3) >> 4; + // Bottom: 5/16 + errorNextRow[x + 1] += (error * 5) >> 4; + // Bottom-left: 1/16 + errorNextRow[x] += (error) >> 4; + } + + return quantized; + } + + // Call at the end of each row to swap buffers + void nextRow() { + // Swap buffers + int16_t* temp = errorCurRow; + errorCurRow = errorNextRow; + errorNextRow = temp; + // Clear the next row buffer + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + rowCount++; + } + + // Check if current row should be processed in reverse + bool isReverseRow() const { return (rowCount & 1) != 0; } + + // Reset for a new image or MCU block + void reset() { + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + rowCount = 0; + } + + private: + int width; + int rowCount; + int16_t* errorCurRow; + int16_t* errorNextRow; +}; + inline void write16(Print& out, const uint16_t value) { - // out.write(reinterpret_cast(&value), 2); out.write(value & 0xFF); out.write((value >> 8) & 0xFF); } inline void write32(Print& out, const uint32_t value) { - // out.write(reinterpret_cast(&value), 4); out.write(value & 0xFF); out.write((value >> 8) & 0xFF); out.write((value >> 16) & 0xFF); @@ -38,13 +310,49 @@ inline void write32(Print& out, const uint32_t value) { } inline void write32Signed(Print& out, const int32_t value) { - // out.write(reinterpret_cast(&value), 4); out.write(value & 0xFF); out.write((value >> 8) & 0xFF); out.write((value >> 16) & 0xFF); out.write((value >> 24) & 0xFF); } +// Helper function: Write BMP header with 8-bit grayscale (256 levels) +void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { + // Calculate row padding (each row must be multiple of 4 bytes) + const int bytesPerRow = (width + 3) / 4 * 4; // 8 bits per pixel, padded + const int imageSize = bytesPerRow * height; + const uint32_t paletteSize = 256 * 4; // 256 colors * 4 bytes (BGRA) + const uint32_t fileSize = 14 + 40 + paletteSize + imageSize; + + // BMP File Header (14 bytes) + bmpOut.write('B'); + bmpOut.write('M'); + write32(bmpOut, fileSize); + write32(bmpOut, 0); // Reserved + write32(bmpOut, 14 + 40 + paletteSize); // Offset to pixel data + + // DIB Header (BITMAPINFOHEADER - 40 bytes) + write32(bmpOut, 40); + write32Signed(bmpOut, width); + write32Signed(bmpOut, -height); // Negative height = top-down bitmap + write16(bmpOut, 1); // Color planes + write16(bmpOut, 8); // Bits per pixel (8 bits) + write32(bmpOut, 0); // BI_RGB (no compression) + write32(bmpOut, imageSize); + write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) + write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) + write32(bmpOut, 256); // colorsUsed + write32(bmpOut, 256); // colorsImportant + + // Color Palette (256 grayscale entries x 4 bytes = 1024 bytes) + for (int i = 0; i < 256; i++) { + bmpOut.write(static_cast(i)); // Blue + bmpOut.write(static_cast(i)); // Green + bmpOut.write(static_cast(i)); // Red + bmpOut.write(static_cast(0)); // Reserved + } +} + // Helper function: Write BMP header with 2-bit color depth void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { // Calculate row padding (each row must be multiple of 4 bytes) @@ -135,13 +443,59 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width, imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol); - // Write BMP header - writeBmpHeader(bmpOut, imageInfo.m_width, imageInfo.m_height); + // Safety limits to prevent memory issues on ESP32 + constexpr int MAX_IMAGE_WIDTH = 2048; + constexpr int MAX_IMAGE_HEIGHT = 3072; + constexpr int MAX_MCU_ROW_BYTES = 65536; - // Calculate row parameters - const int bytesPerRow = (imageInfo.m_width * 2 + 31) / 32 * 4; + if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) { + Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width, + imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT); + return false; + } - // Allocate row buffer for packed 2-bit pixels + // Calculate output dimensions (pre-scale to fit display exactly) + int outWidth = imageInfo.m_width; + int outHeight = imageInfo.m_height; + // Use fixed-point scaling (16.16) for sub-pixel accuracy + uint32_t scaleX_fp = 65536; // 1.0 in 16.16 fixed point + uint32_t scaleY_fp = 65536; + bool needsScaling = false; + + if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) { + // Calculate scale to fit within target dimensions while maintaining aspect ratio + const float scaleToFitWidth = static_cast(TARGET_MAX_WIDTH) / imageInfo.m_width; + const float scaleToFitHeight = static_cast(TARGET_MAX_HEIGHT) / imageInfo.m_height; + const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + + outWidth = static_cast(imageInfo.m_width * scale); + outHeight = static_cast(imageInfo.m_height * scale); + + // Ensure at least 1 pixel + if (outWidth < 1) outWidth = 1; + if (outHeight < 1) outHeight = 1; + + // Calculate fixed-point scale factors (source pixels per output pixel) + // scaleX_fp = (srcWidth << 16) / outWidth + scaleX_fp = (static_cast(imageInfo.m_width) << 16) / outWidth; + scaleY_fp = (static_cast(imageInfo.m_height) << 16) / outHeight; + needsScaling = true; + + Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width, + imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); + } + + // Write BMP header with output dimensions + int bytesPerRow; + if (USE_8BIT_OUTPUT) { + writeBmpHeader8bit(bmpOut, outWidth, outHeight); + bytesPerRow = (outWidth + 3) / 4 * 4; + } else { + writeBmpHeader(bmpOut, outWidth, outHeight); + bytesPerRow = (outWidth * 2 + 31) / 32 * 4; + } + + // Allocate row buffer auto* rowBuffer = static_cast(malloc(bytesPerRow)); if (!rowBuffer) { Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis()); @@ -152,13 +506,48 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { // This is the minimal memory needed for streaming conversion const int mcuPixelHeight = imageInfo.m_MCUHeight; const int mcuRowPixels = imageInfo.m_width * mcuPixelHeight; - auto* mcuRowBuffer = static_cast(malloc(mcuRowPixels)); - if (!mcuRowBuffer) { - Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer\n", millis()); + + // Validate MCU row buffer size before allocation + if (mcuRowPixels > MAX_MCU_ROW_BYTES) { + Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels, + MAX_MCU_ROW_BYTES); free(rowBuffer); return false; } + auto* mcuRowBuffer = static_cast(malloc(mcuRowPixels)); + if (!mcuRowBuffer) { + Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels); + free(rowBuffer); + return false; + } + + // Create ditherer if enabled (only for 2-bit output) + // Use OUTPUT dimensions for dithering (after prescaling) + AtkinsonDitherer* atkinsonDitherer = nullptr; + FloydSteinbergDitherer* fsDitherer = nullptr; + if (!USE_8BIT_OUTPUT) { + if (USE_ATKINSON) { + atkinsonDitherer = new AtkinsonDitherer(outWidth); + } else if (USE_FLOYD_STEINBERG) { + fsDitherer = new FloydSteinbergDitherer(outWidth); + } + } + + // For scaling: accumulate source rows into scaled output rows + // We need to track which source Y maps to which output Y + // Using fixed-point: srcY_fp = outY * scaleY_fp (gives source Y in 16.16 format) + uint32_t* rowAccum = nullptr; // Accumulator for each output X (32-bit for larger sums) + uint16_t* rowCount = nullptr; // Count of source pixels accumulated per output X + int currentOutY = 0; // Current output row being accumulated + uint32_t nextOutY_srcStart = 0; // Source Y where next output row starts (16.16 fixed point) + + if (needsScaling) { + rowAccum = new uint32_t[outWidth](); + rowCount = new uint16_t[outWidth](); + nextOutY_srcStart = scaleY_fp; // First boundary is at scaleY_fp (source Y for outY=1) + } + // Process MCUs row-by-row and write to BMP as we go (top-down) const int mcuPixelWidth = imageInfo.m_MCUWidth; @@ -181,75 +570,164 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { return false; } - // Process MCU block into MCU row buffer - // MCUs are composed of 8x8 blocks. For 16x16 MCUs, there are four 8x8 blocks: - // Block layout for 16x16 MCU: [0, 64] (top row of blocks) - // [128, 192] (bottom row of blocks) + // picojpeg stores MCU data in 8x8 blocks + // Block layout: H2V2(16x16)=0,64,128,192 H2V1(16x8)=0,64 H1V2(8x16)=0,128 for (int blockY = 0; blockY < mcuPixelHeight; blockY++) { for (int blockX = 0; blockX < mcuPixelWidth; blockX++) { const int pixelX = mcuX * mcuPixelWidth + blockX; + if (pixelX >= imageInfo.m_width) continue; - // Skip pixels outside image width (can happen with MCU alignment) - if (pixelX >= imageInfo.m_width) { - continue; - } + // Calculate proper block offset for picojpeg buffer + const int blockCol = blockX / 8; + const int blockRow = blockY / 8; + const int localX = blockX % 8; + const int localY = blockY % 8; + const int blocksPerRow = mcuPixelWidth / 8; + const int blockIndex = blockRow * blocksPerRow + blockCol; + const int pixelOffset = blockIndex * 64 + localY * 8 + localX; - // Calculate which 8x8 block and position within that block - const int block8x8Col = blockX / 8; // 0 or 1 for 16-wide MCU - const int block8x8Row = blockY / 8; // 0 or 1 for 16-tall MCU - const int pixelInBlockX = blockX % 8; - const int pixelInBlockY = blockY % 8; - - // Calculate byte offset: each 8x8 block is 64 bytes - // Blocks are arranged: [0, 64], [128, 192] - const int blockOffset = (block8x8Row * (mcuPixelWidth / 8) + block8x8Col) * 64; - const int mcuIndex = blockOffset + pixelInBlockY * 8 + pixelInBlockX; - - // Get grayscale value uint8_t gray; if (imageInfo.m_comps == 1) { - // Grayscale image - gray = imageInfo.m_pMCUBufR[mcuIndex]; + gray = imageInfo.m_pMCUBufR[pixelOffset]; } else { - // RGB image - convert to grayscale - const uint8_t r = imageInfo.m_pMCUBufR[mcuIndex]; - const uint8_t g = imageInfo.m_pMCUBufG[mcuIndex]; - const uint8_t b = imageInfo.m_pMCUBufB[mcuIndex]; - // Luminance formula: Y = 0.299*R + 0.587*G + 0.114*B - // Using integer approximation: (30*R + 59*G + 11*B) / 100 - gray = (r * 30 + g * 59 + b * 11) / 100; + const uint8_t r = imageInfo.m_pMCUBufR[pixelOffset]; + const uint8_t g = imageInfo.m_pMCUBufG[pixelOffset]; + const uint8_t b = imageInfo.m_pMCUBufB[pixelOffset]; + gray = (r * 25 + g * 50 + b * 25) / 100; } - // Store grayscale value in MCU row buffer mcuRowBuffer[blockY * imageInfo.m_width + pixelX] = gray; } } } - // Write all pixel rows from this MCU row to BMP file + // Process source rows from this MCU row const int startRow = mcuY * mcuPixelHeight; const int endRow = (mcuY + 1) * mcuPixelHeight; for (int y = startRow; y < endRow && y < imageInfo.m_height; y++) { - memset(rowBuffer, 0, bytesPerRow); + const int bufferY = y - startRow; - // Pack 4 pixels per byte (2 bits each) - for (int x = 0; x < imageInfo.m_width; x++) { - const int bufferY = y - startRow; - const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; - const uint8_t twoBit = grayscaleTo2Bit(gray); + if (!needsScaling) { + // No scaling - direct output (1:1 mapping) + memset(rowBuffer, 0, bytesPerRow); - const int byteIndex = (x * 2) / 8; - const int bitOffset = 6 - ((x * 2) % 8); // 6, 4, 2, 0 - rowBuffer[byteIndex] |= (twoBit << bitOffset); + if (USE_8BIT_OUTPUT) { + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + rowBuffer[x] = adjustPixel(gray); + } + } else { + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + uint8_t twoBit; + if (atkinsonDitherer) { + twoBit = atkinsonDitherer->processPixel(gray, x); + } else if (fsDitherer) { + twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + } else { + twoBit = quantize(gray, x, y); + } + const int byteIndex = (x * 2) / 8; + const int bitOffset = 6 - ((x * 2) % 8); + rowBuffer[byteIndex] |= (twoBit << bitOffset); + } + if (atkinsonDitherer) + atkinsonDitherer->nextRow(); + else if (fsDitherer) + fsDitherer->nextRow(); + } + bmpOut.write(rowBuffer, bytesPerRow); + } else { + // Fixed-point area averaging for exact fit scaling + // For each output pixel X, accumulate source pixels that map to it + // srcX range for outX: [outX * scaleX_fp >> 16, (outX+1) * scaleX_fp >> 16) + const uint8_t* srcRow = mcuRowBuffer + bufferY * imageInfo.m_width; + + for (int outX = 0; outX < outWidth; outX++) { + // Calculate source X range for this output pixel + const int srcXStart = (static_cast(outX) * scaleX_fp) >> 16; + const int srcXEnd = (static_cast(outX + 1) * scaleX_fp) >> 16; + + // Accumulate all source pixels in this range + int sum = 0; + int count = 0; + for (int srcX = srcXStart; srcX < srcXEnd && srcX < imageInfo.m_width; srcX++) { + sum += srcRow[srcX]; + count++; + } + + // Handle edge case: if no pixels in range, use nearest + if (count == 0 && srcXStart < imageInfo.m_width) { + sum = srcRow[srcXStart]; + count = 1; + } + + rowAccum[outX] += sum; + rowCount[outX] += count; + } + + // Check if we've crossed into the next output row + // Current source Y in fixed point: y << 16 + const uint32_t srcY_fp = static_cast(y + 1) << 16; + + // Output row when source Y crosses the boundary + if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { + memset(rowBuffer, 0, bytesPerRow); + + if (USE_8BIT_OUTPUT) { + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + rowBuffer[x] = adjustPixel(gray); + } + } else { + for (int x = 0; x < outWidth; x++) { + const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + uint8_t twoBit; + if (atkinsonDitherer) { + twoBit = atkinsonDitherer->processPixel(gray, x); + } else if (fsDitherer) { + twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + } else { + twoBit = quantize(gray, x, currentOutY); + } + const int byteIndex = (x * 2) / 8; + const int bitOffset = 6 - ((x * 2) % 8); + rowBuffer[byteIndex] |= (twoBit << bitOffset); + } + if (atkinsonDitherer) + atkinsonDitherer->nextRow(); + else if (fsDitherer) + fsDitherer->nextRow(); + } + + bmpOut.write(rowBuffer, bytesPerRow); + currentOutY++; + + // Reset accumulators for next output row + memset(rowAccum, 0, outWidth * sizeof(uint32_t)); + memset(rowCount, 0, outWidth * sizeof(uint16_t)); + + // Update boundary for next output row + nextOutY_srcStart = static_cast(currentOutY + 1) * scaleY_fp; + } } - - // Write row with padding - bmpOut.write(rowBuffer, bytesPerRow); } } // Clean up + if (rowAccum) { + delete[] rowAccum; + } + if (rowCount) { + delete[] rowCount; + } + if (atkinsonDitherer) { + delete atkinsonDitherer; + } + if (fsDitherer) { + delete fsDitherer; + } free(mcuRowBuffer); free(rowBuffer); diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index fc881e25..1cb76e59 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -6,7 +6,7 @@ class ZipFile; class JpegToBmpConverter { static void writeBmpHeader(Print& bmpOut, int width, int height); - static uint8_t grayscaleTo2Bit(uint8_t grayscale); + // [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y); static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); From 838246d1479f5aaed50f3eb4d43fd692bd77660f Mon Sep 17 00:00:00 2001 From: 1991AcuraLegend Date: Sat, 27 Dec 2025 17:48:27 -0600 Subject: [PATCH 43/46] Add setting to enable status bar display options (#111) Add setting toggle that allows status bar display options in EpubReader. Supported options would be as follows: - FULL: display as is today - PROGRESS: display progress bar only - BATTERY: display battery only - NONE: hide status bar --------- Co-authored-by: Dave Allie --- src/CrossPointSettings.cpp | 5 +- src/CrossPointSettings.h | 5 + src/activities/reader/EpubReaderActivity.cpp | 132 +++++++++++-------- src/activities/settings/SettingsActivity.cpp | 3 +- 4 files changed, 85 insertions(+), 60 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 467ee9ca..83ba59d1 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -10,7 +10,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; -constexpr uint8_t SETTINGS_COUNT = 3; +constexpr uint8_t SETTINGS_COUNT = 4; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -28,6 +28,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, sleepScreen); serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, shortPwrBtn); + serialization::writePod(outputFile, statusBar); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -60,6 +61,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, shortPwrBtn); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, statusBar); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 14c33322..ab591bef 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -18,8 +18,13 @@ class CrossPointSettings { // Should match with SettingsActivity text enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 }; + // Status bar display type enum + enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 }; + // Sleep screen settings uint8_t sleepScreen = DARK; + // Status bar settings + uint8_t statusBar = FULL; // Text rendering settings uint8_t extraParagraphSpacing = 1; // Duration of the power button press diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f4905d60..b2242376 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -340,71 +340,87 @@ void EpubReaderActivity::renderContents(std::unique_ptr page) { } void EpubReaderActivity::renderStatusBar() const { + // determine visible status bar elements + const bool showProgress = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showBattery = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || + SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + + // height variable shared by all elements constexpr auto textY = 776; + int percentageTextWidth = 0; + int progressTextWidth = 0; - // Calculate progress in book - const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; - const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); + if (showProgress) { + // Calculate progress in book + const float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; + const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); - // Right aligned text for progress counter - const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + - " " + std::to_string(bookProgress) + "%"; - const auto progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); - renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, - progress.c_str()); - - // Left aligned battery icon and percentage - const uint16_t percentage = battery.readPercentage(); - const auto percentageText = std::to_string(percentage) + "%"; - const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str()); - - // 1 column on left, 2 columns on right, 5 columns of battery body - constexpr int batteryWidth = 15; - constexpr int batteryHeight = 10; - constexpr int x = marginLeft; - constexpr int y = 783; - - // Top line - renderer.drawLine(x, y, x + batteryWidth - 4, y); - // Bottom line - renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); - // Left line - renderer.drawLine(x, y, x, y + batteryHeight - 1); - // Battery end - renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); - renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); - renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); - renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); - - // The +1 is to round up, so that we always fill at least one pixel - int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; - if (filledWidth > batteryWidth - 5) { - filledWidth = batteryWidth - 5; // Ensure we don't overflow + // Right aligned text for progress counter + const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + + " " + std::to_string(bookProgress) + "%"; + progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); + renderer.drawText(SMALL_FONT_ID, GfxRenderer::getScreenWidth() - marginRight - progressTextWidth, textY, + progress.c_str()); } - renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); - // Centered chatper title text - // Page width minus existing content with 30px padding on each side - const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; - const int titleMarginRight = progressTextWidth + 30 + marginRight; - const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight; - const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + if (showBattery) { + // Left aligned battery icon and percentage + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = std::to_string(percentage) + "%"; + percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, 20 + marginLeft, textY, percentageText.c_str()); - std::string title; - int titleWidth; - if (tocIndex == -1) { - title = "Unnamed"; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); - } else { - const auto tocItem = epub->getTocItem(tocIndex); - title = tocItem.title; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); - while (titleWidth > availableTextWidth && title.length() > 11) { - title.replace(title.length() - 8, 8, "..."); - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + // 1 column on left, 2 columns on right, 5 columns of battery body + constexpr int batteryWidth = 15; + constexpr int batteryHeight = 10; + constexpr int x = marginLeft; + constexpr int y = 783; + + // Top line + renderer.drawLine(x, y, x + batteryWidth - 4, y); + // Bottom line + renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); + // Left line + renderer.drawLine(x, y, x, y + batteryHeight - 1); + // Battery end + renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); + renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); + renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); + renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); + + // The +1 is to round up, so that we always fill at least one pixel + int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; + if (filledWidth > batteryWidth - 5) { + filledWidth = batteryWidth - 5; // Ensure we don't overflow } + renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); } - renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); + if (showChapterTitle) { + // Centered chatper title text + // Page width minus existing content with 30px padding on each side + const int titleMarginLeft = 20 + percentageTextWidth + 30 + marginLeft; + const int titleMarginRight = progressTextWidth + 30 + marginRight; + const int availableTextWidth = GfxRenderer::getScreenWidth() - titleMarginLeft - titleMarginRight; + const int tocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + + std::string title; + int titleWidth; + if (tocIndex == -1) { + title = "Unnamed"; + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, "Unnamed"); + } else { + const auto tocItem = epub->getTocItem(tocIndex); + title = tocItem.title; + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + while (titleWidth > availableTextWidth && title.length() > 11) { + title.replace(title.length() - 8, 8, "..."); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } + } + + renderer.drawText(SMALL_FONT_ID, titleMarginLeft + (availableTextWidth - titleWidth) / 2, textY, title.c_str()); + } } diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index f7af052e..b71a877c 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -9,10 +9,11 @@ // Define the static settings list namespace { -constexpr int settingsCount = 4; +constexpr int settingsCount = 5; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, + {"Status Bar", SettingType::ENUM, &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}, {"Check for updates", SettingType::ACTION, nullptr, {}}, From eabd149371ab63baae5d8751b08fdd7b80e34d5b Mon Sep 17 00:00:00 2001 From: Eunchurn Park Date: Sun, 28 Dec 2025 13:59:44 +0900 Subject: [PATCH 44/46] Add retry logic and progress bar for chapter indexing (#128) ## Summary * **What is the goal of this PR?** Improve reliability and user experience during chapter indexing by adding retry logic for SD card operations and a visual progress bar. * **What changes are included?** - **Retry logic**: Add 3 retry attempts with 50ms delay for ZIP to SD card streaming to handle timing issues after display refresh - **Progress bar**: Display a visual progress bar (0-100%) during chapter indexing based on file read progress, updating every 10% to balance responsiveness with e-ink display limitations ## Additional Context * **Problem observed**: When navigating quickly through books with many chapters (before chapter titles finish rendering), the "Indexing..." screen would appear frozen. Checking the serial log revealed the operation had silently failed, but the UI showed no indication of this. Users would likely assume the device had crashed. Pressing the next button again would resume operation, but this behavior was confusing and unexpected. * **Solution**: - Retry logic handles transient SD card timing failures automatically, so users don't need to manually retry - Progress bar provides visual feedback so users know indexing is actively working (not frozen) * **Why timing issues occur**: After display refresh operations, there can be timing conflicts when immediately starting SD card write operations. This is more likely to happen when rapidly navigating through chapters. * **Progress bar design**: Updates every 10% to avoid excessive e-ink refreshes while still providing meaningful feedback during long indexing operations (especially for large chapters with CJK characters). * **Performance**: Minimal overhead - progress calculation is simple byte counting, and display updates use `FAST_REFRESH` mode. --- lib/Epub/Epub/ParsedText.cpp | 12 +++++ lib/Epub/Epub/Section.cpp | 52 +++++++++++++++---- lib/Epub/Epub/Section.h | 5 +- lib/Epub/Epub/blocks/TextBlock.cpp | 23 +++++++- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 19 +++++++ lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 7 ++- src/activities/reader/EpubReaderActivity.cpp | 48 +++++++++++++---- 7 files changed, 141 insertions(+), 25 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index c2f13d8b..7a045d56 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -106,6 +106,18 @@ std::vector ParsedText::computeLineBreaks(const int pageWidth, const int ans[i] = j; // j is the index of the last word in this optimal line } } + + // Handle oversized word: if no valid configuration found, force single-word line + // This prevents cascade failure where one oversized word breaks all preceding words + if (dp[i] == MAX_COST) { + ans[i] = i; // Just this word on its own line + // Inherit cost from next word to allow subsequent words to find valid configurations + if (i + 1 < static_cast(totalWordCount)) { + dp[i] = dp[i + 1]; + } else { + dp[i] = 0; + } + } } // Stores the index of the word that starts the next line (last_word_index + 1) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 5323a7a5..bd46d35c 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -115,26 +115,56 @@ bool Section::clearCache() const { bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, - const bool extraParagraphSpacing) { + const bool extraParagraphSpacing, const std::function& progressSetupFn, + const std::function& progressFn) { + constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB const auto localPath = epub->getSpineItem(spineIndex).href; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; - File tmpHtml; - if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { - return false; + + // Retry logic for SD card timing issues + bool success = false; + size_t fileSize = 0; + for (int attempt = 0; attempt < 3 && !success; attempt++) { + if (attempt > 0) { + Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1); + delay(50); // Brief delay before retry + } + + // Remove any incomplete file from previous attempt before retrying + if (SD.exists(tmpHtmlPath.c_str())) { + SD.remove(tmpHtmlPath.c_str()); + } + + File tmpHtml; + if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { + continue; + } + success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); + fileSize = tmpHtml.size(); + tmpHtml.close(); + + // If streaming failed, remove the incomplete file immediately + if (!success && SD.exists(tmpHtmlPath.c_str())) { + SD.remove(tmpHtmlPath.c_str()); + Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis()); + } } - bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); - tmpHtml.close(); if (!success) { - Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis()); + Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis()); return false; } - Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str()); + Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize); - ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, - marginLeft, extraParagraphSpacing, - [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }); + // Only show progress bar for larger chapters where rendering overhead is worth it + if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) { + progressSetupFn(); + } + + ChapterHtmlSlimParser visitor( + tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, + extraParagraphSpacing, [this](std::unique_ptr page) { this->onPageComplete(std::move(page)); }, progressFn); success = visitor.parseAndBuildPages(); SD.remove(tmpHtmlPath.c_str()); diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index d7a2c721..09a2f90b 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -1,4 +1,5 @@ #pragma once +#include #include #include "Epub.h" @@ -31,6 +32,8 @@ class Section { void setupCacheDir() const; bool clearCache() const; bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, - int marginLeft, bool extraParagraphSpacing); + int marginLeft, bool extraParagraphSpacing, + const std::function& progressSetupFn = nullptr, + const std::function& progressFn = nullptr); std::unique_ptr loadPageFromSD() const; }; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index bb8b14e8..ef6fdb5d 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -4,11 +4,18 @@ #include void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { + // Validate iterator bounds before rendering + if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) { + Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), + (uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size()); + return; + } + auto wordIt = words.begin(); auto wordStylesIt = wordStyles.begin(); auto wordXposIt = wordXpos.begin(); - for (int i = 0; i < words.size(); i++) { + for (size_t i = 0; i < words.size(); i++) { renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt); std::advance(wordIt, 1); @@ -46,6 +53,13 @@ std::unique_ptr TextBlock::deserialize(File& file) { // words serialization::readPod(file, wc); + + // Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block) + if (wc > 10000) { + Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc); + return nullptr; + } + words.resize(wc); for (auto& w : words) serialization::readString(file, w); @@ -59,6 +73,13 @@ std::unique_ptr TextBlock::deserialize(File& file) { wordStyles.resize(sc); for (auto& s : wordStyles) serialization::readPod(file, s); + // Validate data consistency: all three lists must have the same size + if (wc != xc || wc != sc) { + Serial.printf("[%lu] [TXB] Deserialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), wc, + xc, sc); + return nullptr; + } + // style serialization::readPod(file, style); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 766e5ca6..d2f1c3e6 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -11,6 +11,9 @@ const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); +// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it +constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB + const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); @@ -221,6 +224,11 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } + // Get file size for progress calculation + const size_t totalSize = file.size(); + size_t bytesRead = 0; + int lastProgress = -1; + XML_SetUserData(parser, this); XML_SetElementHandler(parser, startElement, endElement); XML_SetCharacterDataHandler(parser, characterData); @@ -249,6 +257,17 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() { return false; } + // Update progress (call every 10% change to avoid too frequent updates) + // Only show progress for larger chapters where rendering overhead is worth it + bytesRead += len; + if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) { + const int progress = static_cast((bytesRead * 100) / totalSize); + if (lastProgress / 10 != progress / 10) { + lastProgress = progress; + progressFn(progress); + } + } + done = file.available() == 0; if (XML_ParseBuffer(parser, static_cast(len), done) == XML_STATUS_ERROR) { diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 7f74602a..7d753173 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -18,6 +18,7 @@ class ChapterHtmlSlimParser { const std::string& filepath; GfxRenderer& renderer; std::function)> completePageFn; + std::function progressFn; // Progress callback (0-100) int depth = 0; int skipUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX; @@ -48,7 +49,8 @@ class ChapterHtmlSlimParser { explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, - const std::function)>& completePageFn) + const std::function)>& completePageFn, + const std::function& progressFn = nullptr) : filepath(filepath), renderer(renderer), fontId(fontId), @@ -58,7 +60,8 @@ class ChapterHtmlSlimParser { marginBottom(marginBottom), marginLeft(marginLeft), extraParagraphSpacing(extraParagraphSpacing), - completePageFn(completePageFn) {} + completePageFn(completePageFn), + progressFn(progressFn) {} ~ChapterHtmlSlimParser() = default; bool parseAndBuildPages(); void addLineToPage(std::shared_ptr line); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b2242376..0dfda4bb 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -227,23 +227,51 @@ void EpubReaderActivity::renderScreen() { SETTINGS.extraParagraphSpacing)) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); + // Progress bar dimensions + constexpr int barWidth = 200; + constexpr int barHeight = 10; + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing..."); + const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; + const int boxWidthNoBar = textWidth + boxMargin * 2; + const int boxHeightWithBar = renderer.getLineHeight(READER_FONT_ID) + barHeight + boxMargin * 3; + const int boxHeightNoBar = renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2; + const int boxXWithBar = (GfxRenderer::getScreenWidth() - boxWidthWithBar) / 2; + const int boxXNoBar = (GfxRenderer::getScreenWidth() - boxWidthNoBar) / 2; + constexpr int boxY = 50; + const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2; + const int barY = boxY + renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2; + + // Always show "Indexing..." text first { - const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing..."); - constexpr int margin = 20; - const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2; - constexpr int y = 50; - const int w = textWidth + margin * 2; - const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2; - renderer.fillRect(x, y, w, h, false); - renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing..."); - renderer.drawRect(x + 5, y + 5, w - 10, h - 10); + renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false); + renderer.drawText(READER_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing..."); + renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10); renderer.displayBuffer(); pagesUntilFullRefresh = 0; } section->setupCacheDir(); + + // Setup callback - only called for chapters >= 50KB, redraws with progress bar + auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, boxMargin, barX, barY, barWidth, + barHeight]() { + renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false); + renderer.drawText(READER_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing..."); + renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10); + renderer.drawRect(barX, barY, barWidth, barHeight); + renderer.displayBuffer(); + }; + + // Progress callback to update progress bar + auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) { + const int fillWidth = (barWidth - 2) * progress / 100; + renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); + }; + if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, - marginLeft, SETTINGS.extraParagraphSpacing)) { + marginLeft, SETTINGS.extraParagraphSpacing, progressSetup, progressCallback)) { Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); section.reset(); return; From 9023b262a17e35bb289e1815013f7f9af4b0de68 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 28 Dec 2025 16:06:18 +1000 Subject: [PATCH 45/46] Fix issue where pressing back from chapter select would leave book (#137) ## Summary * Fix issue where pressing back from chapter select would leave book * Rely on `wasReleased` checks instead --- src/activities/reader/EpubReaderChapterSelectionActivity.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 3754fa04..4b7b7ec2 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -57,9 +57,9 @@ void EpubReaderChapterSelectionActivity::loop() { const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; - if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { onSelectSpineIndex(selectorIndex); - } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { + } else if (inputManager.wasReleased(InputManager::BTN_BACK)) { onGoBack(); } else if (prevReleased) { if (skipPage) { From 02350c6a9f4b27f5d9ec49dd215c1d9aa8d776c7 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Sun, 28 Dec 2025 17:57:06 +1000 Subject: [PATCH 46/46] Fix underscore on keyboard and standardize activity (#138) ## Summary * Fix underscore on keyboard * Remove special handling of special row characters * Fix navigating between special row items * Standardize keyboard activity to use standard loop * Fix issue with rendering keyboard non-stop Fixes https://github.com/daveallie/crosspoint-reader/issues/131 --- .../network/WifiSelectionActivity.cpp | 75 ++--- src/activities/util/KeyboardEntryActivity.cpp | 269 ++++++++++-------- src/activities/util/KeyboardEntryActivity.h | 85 ++---- 3 files changed, 207 insertions(+), 222 deletions(-) diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 68c6481e..bfbfeb97 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -187,11 +187,21 @@ void WifiSelectionActivity::selectNetwork(const int index) { if (selectedRequiresPassword) { // Show password entry state = WifiSelectionState::PASSWORD_ENTRY; - enterNewActivity(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password", - "", // No initial text - 64, // Max password length - false // Show password by default (hard keyboard to use) - )); + enterNewActivity(new KeyboardEntryActivity( + renderer, inputManager, "Enter WiFi Password", + "", // No initial text + 50, // Y position + 64, // Max password length + false, // Show password by default (hard keyboard to use) + [this](const std::string& text) { + enteredPassword = text; + exitActivity(); + }, + [this] { + state = WifiSelectionState::NETWORK_LIST; + updateRequired = true; + exitActivity(); + })); updateRequired = true; } else { // Connect directly for open networks @@ -208,11 +218,6 @@ void WifiSelectionActivity::attemptConnection() { WiFi.mode(WIFI_STA); - // Get password from keyboard if we just entered it - if (subActivity && !usedSavedPassword) { - enteredPassword = static_cast(subActivity.get())->getText(); - } - if (selectedRequiresPassword && !enteredPassword.empty()) { WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str()); } else { @@ -269,6 +274,11 @@ void WifiSelectionActivity::checkConnectionStatus() { } void WifiSelectionActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + // Check scan progress if (state == WifiSelectionState::SCANNING) { processWifiScanResults(); @@ -281,24 +291,9 @@ void WifiSelectionActivity::loop() { return; } - // Handle password entry state - if (state == WifiSelectionState::PASSWORD_ENTRY && subActivity) { - const auto keyboard = static_cast(subActivity.get()); - keyboard->handleInput(); - - if (keyboard->isComplete()) { - attemptConnection(); - return; - } - - if (keyboard->isCancelled()) { - state = WifiSelectionState::NETWORK_LIST; - exitActivity(); - updateRequired = true; - return; - } - - updateRequired = true; + if (state == WifiSelectionState::PASSWORD_ENTRY) { + // Reach here once password entry finished in subactivity + attemptConnection(); return; } @@ -441,6 +436,10 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi void WifiSelectionActivity::displayTaskLoop() { while (true) { + if (subActivity) { + return; + } + if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -461,9 +460,6 @@ void WifiSelectionActivity::render() const { case WifiSelectionState::NETWORK_LIST: renderNetworkList(); break; - case WifiSelectionState::PASSWORD_ENTRY: - renderPasswordEntry(); - break; case WifiSelectionState::CONNECTING: renderConnecting(); break; @@ -561,23 +557,6 @@ void WifiSelectionActivity::renderNetworkList() const { renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", ""); } -void WifiSelectionActivity::renderPasswordEntry() const { - // Draw header - renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD); - - // Draw network name with good spacing from header - std::string networkInfo = "Network: " + selectedSSID; - if (networkInfo.length() > 30) { - networkInfo.replace(27, networkInfo.length() - 27, "..."); - } - renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR); - - // Draw keyboard - if (subActivity) { - static_cast(subActivity.get())->render(58); - } -} - void WifiSelectionActivity::renderConnecting() const { const auto pageHeight = renderer.getScreenHeight(); const auto height = renderer.getLineHeight(UI_FONT_ID); diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index b4ed01ca..73f3dde7 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -10,41 +10,55 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = { // Keyboard layouts - uppercase/symbols const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"", - "ZXCVBNM<>?", "^ _____?", "SPECIAL ROW"}; -void KeyboardEntryActivity::setText(const std::string& newText) { - text = newText; - if (maxLength > 0 && text.length() > maxLength) { - text.resize(maxLength); - } +void KeyboardEntryActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); } -void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string& newInitialText) { - if (!newTitle.empty()) { - title = newTitle; +void KeyboardEntryActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); } - text = newInitialText; - selectedRow = 0; - selectedCol = 0; - shiftActive = false; - complete = false; - cancelled = false; } void KeyboardEntryActivity::onEnter() { Activity::onEnter(); - // Reset state when entering the activity - complete = false; - cancelled = false; + renderingMutex = xSemaphoreCreateMutex(); + + // Trigger first update + updateRequired = true; + + xTaskCreate(&KeyboardEntryActivity::taskTrampoline, "KeyboardEntryActivity", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); } -void KeyboardEntryActivity::loop() { - handleInput(); - render(10); +void KeyboardEntryActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; } -int KeyboardEntryActivity::getRowLength(int row) const { +int KeyboardEntryActivity::getRowLength(const int row) const { if (row < 0 || row >= NUM_ROWS) return 0; // Return actual length of each row based on keyboard layout @@ -58,7 +72,7 @@ int KeyboardEntryActivity::getRowLength(int row) const { case 3: return 10; // zxcvbnm,./ case 4: - return 10; // ^, space (5 wide), backspace, OK (2 wide) + return 10; // caps (2 wide), space (5 wide), backspace (2 wide), OK default: return 0; } @@ -75,8 +89,8 @@ char KeyboardEntryActivity::getSelectedChar() const { void KeyboardEntryActivity::handleKeyPress() { // Handle special row (bottom row with shift, space, backspace, done) - if (selectedRow == SHIFT_ROW) { - if (selectedCol == SHIFT_COL) { + if (selectedRow == SPECIAL_ROW) { + if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { // Shift toggle shiftActive = !shiftActive; return; @@ -90,7 +104,7 @@ void KeyboardEntryActivity::handleKeyPress() { return; } - if (selectedCol == BACKSPACE_COL) { + if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { // Backspace if (!text.empty()) { text.pop_back(); @@ -100,7 +114,6 @@ void KeyboardEntryActivity::handleKeyPress() { if (selectedCol >= DONE_COL) { // Done button - complete = true; if (onComplete) { onComplete(text); } @@ -109,42 +122,61 @@ void KeyboardEntryActivity::handleKeyPress() { } // Regular character - char c = getSelectedChar(); - if (c != '\0' && c != '^' && c != '_' && c != '<') { - if (maxLength == 0 || text.length() < maxLength) { - text += c; - // Auto-disable shift after typing a letter - if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { - shiftActive = false; - } + const char c = getSelectedChar(); + if (c == '\0') { + return; + } + + if (maxLength == 0 || text.length() < maxLength) { + text += c; + // Auto-disable shift after typing a letter + if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) { + shiftActive = false; } } } -bool KeyboardEntryActivity::handleInput() { - if (complete || cancelled) { - return false; - } - - bool handled = false; - +void KeyboardEntryActivity::loop() { // Navigation if (inputManager.wasPressed(InputManager::BTN_UP)) { if (selectedRow > 0) { selectedRow--; // Clamp column to valid range for new row - int maxCol = getRowLength(selectedRow) - 1; + const int maxCol = getRowLength(selectedRow) - 1; if (selectedCol > maxCol) selectedCol = maxCol; } - handled = true; - } else if (inputManager.wasPressed(InputManager::BTN_DOWN)) { + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_DOWN)) { if (selectedRow < NUM_ROWS - 1) { selectedRow++; - int maxCol = getRowLength(selectedRow) - 1; + const int maxCol = getRowLength(selectedRow) - 1; if (selectedCol > maxCol) selectedCol = maxCol; } - handled = true; - } else if (inputManager.wasPressed(InputManager::BTN_LEFT)) { + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_LEFT)) { + // Special bottom row case + if (selectedRow == SPECIAL_ROW) { + // Bottom row has special key widths + if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { + // In shift key, do nothing + } else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) { + // In space bar, move to shift + selectedCol = SHIFT_COL; + } else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { + // In backspace, move to space + selectedCol = SPACE_COL; + } else if (selectedCol >= DONE_COL) { + // At done button, move to backspace + selectedCol = BACKSPACE_COL; + } + updateRequired = true; + return; + } + if (selectedCol > 0) { selectedCol--; } else if (selectedRow > 0) { @@ -152,9 +184,31 @@ bool KeyboardEntryActivity::handleInput() { selectedRow--; selectedCol = getRowLength(selectedRow) - 1; } - handled = true; - } else if (inputManager.wasPressed(InputManager::BTN_RIGHT)) { - int maxCol = getRowLength(selectedRow) - 1; + updateRequired = true; + } + + if (inputManager.wasPressed(InputManager::BTN_RIGHT)) { + const int maxCol = getRowLength(selectedRow) - 1; + + // Special bottom row case + if (selectedRow == SPECIAL_ROW) { + // Bottom row has special key widths + if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { + // In shift key, move to space + selectedCol = SPACE_COL; + } else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) { + // In space bar, move to backspace + selectedCol = BACKSPACE_COL; + } else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { + // In backspace, move to done + selectedCol = DONE_COL; + } else if (selectedCol >= DONE_COL) { + // At done button, do nothing + } + updateRequired = true; + return; + } + if (selectedCol < maxCol) { selectedCol++; } else if (selectedRow < NUM_ROWS - 1) { @@ -162,35 +216,34 @@ bool KeyboardEntryActivity::handleInput() { selectedRow++; selectedCol = 0; } - handled = true; + updateRequired = true; } // Selection if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { handleKeyPress(); - handled = true; + updateRequired = true; } // Cancel if (inputManager.wasPressed(InputManager::BTN_BACK)) { - cancelled = true; if (onCancel) { onCancel(); } - handled = true; + updateRequired = true; } - - return handled; } -void KeyboardEntryActivity::render(int startY) const { +void KeyboardEntryActivity::render() const { const auto pageWidth = GfxRenderer::getScreenWidth(); + renderer.clearScreen(); + // Draw title renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR); // Draw input field - int inputY = startY + 22; + const int inputY = startY + 22; renderer.drawText(UI_FONT_ID, 10, inputY, "["); std::string displayText; @@ -204,9 +257,9 @@ void KeyboardEntryActivity::render(int startY) const { displayText += "_"; // Truncate if too long for display - use actual character width from font - int charWidth = renderer.getSpaceWidth(UI_FONT_ID); - if (charWidth < 1) charWidth = 8; // Fallback to approximate width - int maxDisplayLen = (pageWidth - 40) / charWidth; + int approxCharWidth = renderer.getSpaceWidth(UI_FONT_ID); + if (approxCharWidth < 1) approxCharWidth = 8; // Fallback to approximate width + const int maxDisplayLen = (pageWidth - 40) / approxCharWidth; if (displayText.length() > static_cast(maxDisplayLen)) { displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3); } @@ -215,22 +268,22 @@ void KeyboardEntryActivity::render(int startY) const { renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]"); // Draw keyboard - use compact spacing to fit 5 rows on screen - int keyboardStartY = inputY + 25; - const int keyWidth = 18; - const int keyHeight = 18; - const int keySpacing = 3; + const int keyboardStartY = inputY + 25; + constexpr int keyWidth = 18; + constexpr int keyHeight = 18; + constexpr int keySpacing = 3; const char* const* layout = shiftActive ? keyboardShift : keyboard; // Calculate left margin to center the longest row (13 keys) - int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); - int leftMargin = (pageWidth - maxRowWidth) / 2; + constexpr int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); + const int leftMargin = (pageWidth - maxRowWidth) / 2; for (int row = 0; row < NUM_ROWS; row++) { - int rowY = keyboardStartY + row * (keyHeight + keySpacing); + const int rowY = keyboardStartY + row * (keyHeight + keySpacing); // Left-align all rows for consistent navigation - int startX = leftMargin; + const int startX = leftMargin; // Handle bottom row (row 4) specially with proper multi-column keys if (row == 4) { @@ -240,64 +293,37 @@ void KeyboardEntryActivity::render(int startY) const { int currentX = startX; // CAPS key (logical col 0, spans 2 key widths) - int capsWidth = 2 * keyWidth + keySpacing; - bool capsSelected = (selectedRow == 4 && selectedCol == SHIFT_COL); - if (capsSelected) { - renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, currentX + capsWidth - 4, rowY, "]"); - } - renderer.drawText(UI_FONT_ID, currentX + 2, rowY, shiftActive ? "CAPS" : "caps"); - currentX += capsWidth + keySpacing; + const bool capsSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL); + renderItemWithSelector(currentX + 2, rowY, shiftActive ? "CAPS" : "caps", capsSelected); + currentX += 2 * (keyWidth + keySpacing); // Space bar (logical cols 2-6, spans 5 key widths) - int spaceWidth = 5 * keyWidth + 4 * keySpacing; - bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); - if (spaceSelected) { - renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, currentX + spaceWidth - 4, rowY, "]"); - } - // Draw centered underscores for space bar - int spaceTextX = currentX + (spaceWidth / 2) - 12; - renderer.drawText(UI_FONT_ID, spaceTextX, rowY, "_____"); - currentX += spaceWidth + keySpacing; + const bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); + const int spaceTextWidth = renderer.getTextWidth(UI_FONT_ID, "_____"); + const int spaceXWidth = 5 * (keyWidth + keySpacing); + const int spaceXPos = currentX + (spaceXWidth - spaceTextWidth) / 2; + renderItemWithSelector(spaceXPos, rowY, "_____", spaceSelected); + currentX += spaceXWidth; // Backspace key (logical col 7, spans 2 key widths) - int bsWidth = 2 * keyWidth + keySpacing; - bool bsSelected = (selectedRow == 4 && selectedCol == BACKSPACE_COL); - if (bsSelected) { - renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, currentX + bsWidth - 4, rowY, "]"); - } - renderer.drawText(UI_FONT_ID, currentX + 6, rowY, "<-"); - currentX += bsWidth + keySpacing; + const bool bsSelected = (selectedRow == 4 && selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL); + renderItemWithSelector(currentX + 2, rowY, "<-", bsSelected); + currentX += 2 * (keyWidth + keySpacing); // OK button (logical col 9, spans 2 key widths) - int okWidth = 2 * keyWidth + keySpacing; - bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL); - if (okSelected) { - renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, currentX + okWidth - 4, rowY, "]"); - } - renderer.drawText(UI_FONT_ID, currentX + 8, rowY, "OK"); - + const bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL); + renderItemWithSelector(currentX + 2, rowY, "OK", okSelected); } else { // Regular rows: render each key individually for (int col = 0; col < getRowLength(row); col++) { - int keyX = startX + col * (keyWidth + keySpacing); - // Get the character to display - char c = layout[row][col]; + const char c = layout[row][col]; std::string keyLabel(1, c); + const int charWidth = renderer.getTextWidth(UI_FONT_ID, keyLabel.c_str()); - // Draw selection highlight - bool isSelected = (row == selectedRow && col == selectedCol); - - if (isSelected) { - renderer.drawText(UI_FONT_ID, keyX - 2, rowY, "["); - renderer.drawText(UI_FONT_ID, keyX + keyWidth - 4, rowY, "]"); - } - - renderer.drawText(UI_FONT_ID, keyX + 2, rowY, keyLabel.c_str()); + const int keyX = startX + col * (keyWidth + keySpacing) + (keyWidth - charWidth) / 2; + const bool isSelected = row == selectedRow && col == selectedCol; + renderItemWithSelector(keyX, rowY, keyLabel.c_str(), isSelected); } } } @@ -305,4 +331,15 @@ void KeyboardEntryActivity::render(int startY) const { // Draw help text at absolute bottom of screen (consistent with other screens) const auto pageHeight = GfxRenderer::getScreenHeight(); renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK"); + renderer.displayBuffer(); +} + +void KeyboardEntryActivity::renderItemWithSelector(const int x, const int y, const char* item, + const bool isSelected) const { + if (isSelected) { + const int itemWidth = renderer.getTextWidth(UI_FONT_ID, item); + renderer.drawText(UI_FONT_ID, x - 6, y, "["); + renderer.drawText(UI_FONT_ID, x + itemWidth, y, "]"); + } + renderer.drawText(UI_FONT_ID, x, y, item); } diff --git a/src/activities/util/KeyboardEntryActivity.h b/src/activities/util/KeyboardEntryActivity.h index 3b5b8063..552a3e8f 100644 --- a/src/activities/util/KeyboardEntryActivity.h +++ b/src/activities/util/KeyboardEntryActivity.h @@ -1,9 +1,13 @@ #pragma once #include #include +#include +#include +#include #include #include +#include #include "../Activity.h" @@ -30,80 +34,44 @@ class KeyboardEntryActivity : public Activity { * @param inputManager Reference to InputManager for handling input * @param title Title to display above the keyboard * @param initialText Initial text to show in the input field + * @param startY Y position to start rendering the keyboard * @param maxLength Maximum length of input text (0 for unlimited) * @param isPassword If true, display asterisks instead of actual characters + * @param onComplete Callback invoked when input is complete + * @param onCancel Callback invoked when input is cancelled */ - KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text", - const std::string& initialText = "", const size_t maxLength = 0, const bool isPassword = false) + explicit KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, std::string title = "Enter Text", + std::string initialText = "", const int startY = 10, const size_t maxLength = 0, + const bool isPassword = false, OnCompleteCallback onComplete = nullptr, + OnCancelCallback onCancel = nullptr) : Activity("KeyboardEntry", renderer, inputManager), - title(title), - text(initialText), + title(std::move(title)), + text(std::move(initialText)), + startY(startY), maxLength(maxLength), - isPassword(isPassword) {} - - /** - * Handle button input. Call this in your main loop. - * @return true if input was handled, false otherwise - */ - bool handleInput(); - - /** - * Render the keyboard at the specified Y position. - * @param startY Y-coordinate where keyboard rendering starts (default 10) - */ - void render(int startY = 10) const; - - /** - * Get the current text entered by the user. - */ - const std::string& getText() const { return text; } - - /** - * Set the current text. - */ - void setText(const std::string& newText); - - /** - * Check if the user has completed text entry (pressed OK on Done). - */ - bool isComplete() const { return complete; } - - /** - * Check if the user has cancelled text entry. - */ - bool isCancelled() const { return cancelled; } - - /** - * Reset the keyboard state for reuse. - */ - void reset(const std::string& newTitle = "", const std::string& newInitialText = ""); - - /** - * Set callback for when input is complete. - */ - void setOnComplete(OnCompleteCallback callback) { onComplete = callback; } - - /** - * Set callback for when input is cancelled. - */ - void setOnCancel(OnCancelCallback callback) { onCancel = callback; } + isPassword(isPassword), + onComplete(std::move(onComplete)), + onCancel(std::move(onCancel)) {} // Activity overrides void onEnter() override; + void onExit() override; void loop() override; private: std::string title; + int startY; std::string text; size_t maxLength; bool isPassword; + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; // Keyboard state int selectedRow = 0; int selectedCol = 0; bool shiftActive = false; - bool complete = false; - bool cancelled = false; // Callbacks OnCompleteCallback onComplete; @@ -116,16 +84,17 @@ class KeyboardEntryActivity : public Activity { static const char* const keyboardShift[NUM_ROWS]; // Special key positions (bottom row) - static constexpr int SHIFT_ROW = 4; + static constexpr int SPECIAL_ROW = 4; static constexpr int SHIFT_COL = 0; - static constexpr int SPACE_ROW = 4; static constexpr int SPACE_COL = 2; - static constexpr int BACKSPACE_ROW = 4; static constexpr int BACKSPACE_COL = 7; - static constexpr int DONE_ROW = 4; static constexpr int DONE_COL = 9; + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); char getSelectedChar() const; void handleKeyPress(); int getRowLength(int row) const; + void render() const; + void renderItemWithSelector(int x, int y, const char* item, bool isSelected) const; };