From 881aa2e005b2f66d76a1ec29a308552e3d41d081 Mon Sep 17 00:00:00 2001 From: Justin <41591399+justinluque@users.noreply.github.com> Date: Mon, 5 Jan 2026 03:25:27 -0500 Subject: [PATCH 01/14] Fix scrolling wrap-around in settings menu (#249) ## Summary Fixes a bug in the settings menu, where previously wrap-around only worked when scrolling upwards. Now, scrolling downwards on the last list element wraps around to the top as expected. Resolves #236. --- src/activities/settings/SettingsActivity.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index a242389d..e6bf24f0 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -114,11 +114,9 @@ void SettingsActivity::loop() { updateRequired = true; } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || mappedInput.wasPressed(MappedInputManager::Button::Right)) { - // Move selection down - if (selectedSettingIndex < settingsCount - 1) { - selectedSettingIndex++; - updateRequired = true; - } + // Move selection down (with wrap around) + selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; + updateRequired = true; } } From c76507c93727596cc448ddbc770c9c9df97f2e07 Mon Sep 17 00:00:00 2001 From: Stanislav Khromov Date: Mon, 5 Jan 2026 10:08:39 +0100 Subject: [PATCH 02/14] Add blank sleep screen option (#242) ## Summary Very small change to add a blank ("None") sleep screen option, for those who prefer a clean aesthetic. Tested on X4 device. --- src/CrossPointSettings.h | 2 +- src/activities/boot_sleep/SleepActivity.cpp | 9 +++++++++ src/activities/boot_sleep/SleepActivity.h | 1 + src/activities/settings/SettingsActivity.cpp | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index bb38df68..84235672 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -16,7 +16,7 @@ class CrossPointSettings { CrossPointSettings& operator=(const CrossPointSettings&) = delete; // Should match with SettingsActivity text - enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3 }; + enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4 }; // Status bar display type enum enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 }; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index cc9fa9d9..8ae42ff5 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -28,6 +28,10 @@ void SleepActivity::onEnter() { Activity::onEnter(); renderPopup("Entering Sleep..."); + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { + return renderBlankSleepScreen(); + } + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM) { return renderCustomSleepScreen(); } @@ -234,3 +238,8 @@ void SleepActivity::renderCoverSleepScreen() const { renderDefaultSleepScreen(); } + +void SleepActivity::renderBlankSleepScreen() const { + renderer.clearScreen(); + renderer.displayBuffer(EInkDisplay::HALF_REFRESH); +} diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 3a77d33b..283220ce 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -15,4 +15,5 @@ class SleepActivity final : public Activity { void renderCustomSleepScreen() const; void renderCoverSleepScreen() const; void renderBitmapSleepScreen(const Bitmap& bitmap) const; + void renderBlankSleepScreen() const; }; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index e6bf24f0..39b1b460 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -12,7 +12,7 @@ namespace { constexpr int settingsCount = 14; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE - {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, + {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}}, {"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, {}}, From 9f95b31de5735780babda9ab7482a2e268826103 Mon Sep 17 00:00:00 2001 From: David Fischer <85546373+fischer-hub@users.noreply.github.com> Date: Mon, 5 Jan 2026 10:29:08 +0100 Subject: [PATCH 03/14] add settings for reader screen margin (#223) ## Summary * **What is the goal of this PR?** * This PR adds a setting to control the top left and right margins of the reader screen in 4 sizes (5, 10, 20, 40 pt?) and defaults to `SMALL` which is equivalent to the fixed margin of 5 that was already in use before. * **What changes are included?** ## 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 --- src/CrossPointSettings.cpp | 9 ++- src/CrossPointSettings.h | 4 ++ src/activities/reader/EpubReaderActivity.cpp | 8 +-- src/activities/settings/SettingsActivity.cpp | 75 ++++++++++---------- src/activities/settings/SettingsActivity.h | 27 ++++++- 5 files changed, 75 insertions(+), 48 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index d304c4e4..94764b0f 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 13; +constexpr uint8_t SETTINGS_COUNT = 14; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -40,6 +40,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, paragraphAlignment); serialization::writePod(outputFile, sleepTimeout); serialization::writePod(outputFile, refreshFrequency); + serialization::writePod(outputFile, screenMargin); + outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -92,6 +94,9 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, refreshFrequency); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, screenMargin); + if (++settingsRead >= fileSettingsCount) break; + } while (false); inputFile.close(); @@ -207,4 +212,4 @@ int CrossPointSettings::getReaderFontId() const { return OPENDYSLEXIC_14_FONT_ID; } } -} +} \ No newline at end of file diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 84235672..2b3f75a3 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -75,6 +75,9 @@ class CrossPointSettings { // E-ink refresh frequency (default 15 pages) uint8_t refreshFrequency = REFRESH_15; + // Reader screen margin settings + uint8_t screenMargin = 5; + ~CrossPointSettings() = default; // Get singleton instance @@ -89,6 +92,7 @@ class CrossPointSettings { float getReaderLineCompression() const; unsigned long getSleepTimeoutMs() const; int getRefreshFrequency() const; + int getReaderScreenMargin() const; }; // Helper macro to access settings diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index d3cd5016..1b5dc777 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -16,8 +16,6 @@ namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() constexpr unsigned long skipChapterMs = 700; constexpr unsigned long goHomeMs = 1000; -constexpr int topPadding = 5; -constexpr int horizontalPadding = 5; constexpr int statusBarMargin = 19; } // namespace @@ -253,9 +251,9 @@ void EpubReaderActivity::renderScreen() { int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); - orientedMarginTop += topPadding; - orientedMarginLeft += horizontalPadding; - orientedMarginRight += horizontalPadding; + orientedMarginTop += SETTINGS.screenMargin; + orientedMarginLeft += SETTINGS.screenMargin; + orientedMarginRight += SETTINGS.screenMargin; orientedMarginBottom += statusBarMargin; if (!section) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 39b1b460..963318a1 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 "MappedInputManager.h" @@ -9,45 +10,31 @@ // Define the static settings list namespace { -constexpr int settingsCount = 14; +constexpr int settingsCount = 15; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE - {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}}, - {"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, {}}, - {"Reading Orientation", - SettingType::ENUM, - &CrossPointSettings::orientation, - {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}}, - {"Front Button Layout", - SettingType::ENUM, - &CrossPointSettings::frontButtonLayout, - {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}}, - {"Side Button Layout (reader)", - SettingType::ENUM, - &CrossPointSettings::sideButtonLayout, - {"Prev, Next", "Next, Prev"}}, - {"Reader Font Family", - SettingType::ENUM, - &CrossPointSettings::fontFamily, - {"Bookerly", "Noto Sans", "Open Dyslexic"}}, - {"Reader Font Size", SettingType::ENUM, &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}}, - {"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}}, - {"Reader Paragraph Alignment", - SettingType::ENUM, - &CrossPointSettings::paragraphAlignment, - {"Justify", "Left", "Center", "Right"}}, - {"Time to Sleep", - SettingType::ENUM, - &CrossPointSettings::sleepTimeout, - {"1 min", "5 min", "10 min", "15 min", "30 min"}}, - {"Refresh Frequency", - SettingType::ENUM, - &CrossPointSettings::refreshFrequency, - {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}}, - {"Check for updates", SettingType::ACTION, nullptr, {}}, -}; + SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), + SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), + SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), + SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn), + SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, + {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), + SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, + {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), + SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, + {"Prev, Next", "Next, Prev"}), + SettingInfo::Enum("Reader Font Family", &CrossPointSettings::fontFamily, + {"Bookerly", "Noto Sans", "Open Dyslexic"}), + SettingInfo::Enum("Reader Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), + SettingInfo::Enum("Reader Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}), + SettingInfo::Value("Reader Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), + SettingInfo::Enum("Reader Paragraph Alignment", &CrossPointSettings::paragraphAlignment, + {"Justify", "Left", "Center", "Right"}), + SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, + {"1 min", "5 min", "10 min", "15 min", "30 min"}), + SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + SettingInfo::Action("Check for updates")}; } // namespace void SettingsActivity::taskTrampoline(void* param) { @@ -57,7 +44,6 @@ void SettingsActivity::taskTrampoline(void* param) { void SettingsActivity::onEnter() { Activity::onEnter(); - renderingMutex = xSemaphoreCreateMutex(); // Reset selection to first item @@ -67,7 +53,7 @@ void SettingsActivity::onEnter() { updateRequired = true; xTaskCreate(&SettingsActivity::taskTrampoline, "SettingsActivityTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -135,6 +121,15 @@ void SettingsActivity::toggleCurrentSetting() { } 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::VALUE && setting.valuePtr != nullptr) { + // Decreasing would also be nice for large ranges I think but oh well can't have everything + const int8_t currentValue = SETTINGS.*(setting.valuePtr); + // Wrap to minValue if exceeding setting value boundary + if (currentValue + setting.valueRange.step > setting.valueRange.max) { + SETTINGS.*(setting.valuePtr) = setting.valueRange.min; + } else { + SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; + } } else if (setting.type == SettingType::ACTION) { if (std::string(setting.name) == "Check for updates") { xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -193,6 +188,8 @@ void SettingsActivity::render() const { } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); valueText = settingsList[i].enumValues[value]; + } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { + valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); } const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 83beb9d9..157689e3 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -11,14 +11,37 @@ class CrossPointSettings; -enum class SettingType { TOGGLE, ENUM, ACTION }; +enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; // 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/ENUM) + uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE) std::vector enumValues; + + struct ValueRange { + uint8_t min; + uint8_t max; + uint8_t step; + }; + // Bounds/step for VALUE type settings + ValueRange valueRange; + + // Static constructors + static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { + return {name, SettingType::TOGGLE, ptr}; + } + + static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector values) { + return {name, SettingType::ENUM, ptr, std::move(values)}; + } + + static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } + + static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { + return {name, SettingType::VALUE, ptr, {}, valueRange}; + } }; class SettingsActivity final : public ActivityWithSubactivity { From afe967215669f1ff9eee427bb045d11c970dd7c7 Mon Sep 17 00:00:00 2001 From: Jonas Diemer Date: Mon, 5 Jan 2026 11:07:27 +0100 Subject: [PATCH 04/14] Feature/cover crop mode (#225) Added a setting to select `fit` or `crop` for cover image on sleep screen. Might add a `expand` feature in the future that does not crop but rather fills the blank space with a mirror of the image. --------- Co-authored-by: Dave Allie --- lib/GfxRenderer/Bitmap.cpp | 17 ++++----- lib/GfxRenderer/Bitmap.h | 4 +-- lib/GfxRenderer/GfxRenderer.cpp | 35 +++++++++++++------ lib/GfxRenderer/GfxRenderer.h | 3 +- lib/JpegToBmpConverter/JpegToBmpConverter.cpp | 4 ++- src/CrossPointSettings.cpp | 9 ++--- src/CrossPointSettings.h | 3 ++ src/activities/boot_sleep/SleepActivity.cpp | 29 +++++++++++---- src/activities/settings/SettingsActivity.cpp | 3 +- 9 files changed, 70 insertions(+), 37 deletions(-) diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 7c46df1c..8cc8a5f3 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -250,34 +250,29 @@ BmpReaderError Bitmap::parseHeaders() { delete[] errorNextRow; errorCurRow = new int16_t[width + 2](); // +2 for boundary handling errorNextRow = new int16_t[width + 2](); - lastRowY = -1; + prevRowY = -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, int rowY) const { +BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) 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) { + if (prevRowY != -1) { // Sequential access - swap buffers int16_t* temp = errorCurRow; errorCurRow = errorNextRow; errorNextRow = temp; memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); } - lastRowY = rowY; } + prevRowY += 1; uint8_t* outPtr = data; uint8_t currentOutByte = 0; @@ -292,7 +287,7 @@ BmpReaderError Bitmap::readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) cons color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); } else { // Simple quantization or noise dithering - color = quantize(lum, currentX, rowY); + color = quantize(lum, currentX, prevRowY); } currentOutByte |= (color << bitShift); if (bitShift == 0) { @@ -365,7 +360,7 @@ BmpReaderError Bitmap::rewindToData() const { if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) { memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - lastRowY = -1; + prevRowY = -1; } return BmpReaderError::Ok; diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 7e799647..a3f2e00c 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -31,7 +31,7 @@ class Bitmap { explicit Bitmap(FsFile& file) : file(file) {} ~Bitmap(); BmpReaderError parseHeaders(); - BmpReaderError readRow(uint8_t* data, uint8_t* rowBuffer, int rowY) const; + BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; BmpReaderError rewindToData() const; int getWidth() const { return width; } int getHeight() const { return height; } @@ -55,5 +55,5 @@ class Bitmap { // 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 + mutable int prevRowY = -1; // Track row progression for error propagation }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 52cd6f4d..cc1288a7 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -152,18 +152,24 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); } -void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, - const int maxHeight) const { +void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, + const float cropX, const float cropY) const { float scale = 1.0f; bool isScaled = false; - if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { - scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f); + int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f); + Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(), + cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up"); + + if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) { + scale = static_cast(maxWidth) / static_cast((1.0f - cropX) * bitmap.getWidth()); isScaled = true; } - if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { - scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); + if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) { + scale = std::min(scale, static_cast(maxHeight) / static_cast((1.0f - cropY) * bitmap.getHeight())); isScaled = true; } + Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), scale, isScaled ? "scaled" : "not scaled"); // 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 @@ -178,29 +184,36 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con return; } - for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); 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. - int screenY = y + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); + int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); if (isScaled) { screenY = std::floor(screenY * scale); } + screenY += y; // the offset should not be scaled if (screenY >= getScreenHeight()) { break; } - if (bitmap.readRow(outputRow, rowBytes, bmpY) != BmpReaderError::Ok) { + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); free(outputRow); free(rowBytes); return; } - for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { - int screenX = x + bmpX; + if (bmpY < cropPixY) { + // Skip the row if it's outside the crop area + continue; + } + + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { + int screenX = bmpX - cropPixX; if (isScaled) { screenX = std::floor(screenX * scale); } + screenX += x; // the offset should not be scaled if (screenX >= getScreenWidth()) { break; } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index af4f2d50..e3e9558d 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -66,7 +66,8 @@ class GfxRenderer { void drawRect(int x, int y, int width, int height, bool state = true) const; void fillRect(int x, int y, int width, int height, bool state = true) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; - void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; + void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, + float cropY = 0) const; // Text int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 9c61ef0d..8c8db889 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -468,7 +468,9 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { // 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; + // We scale to the smaller dimension, so we can potentially crop later. + // TODO: ideally, we already crop here. + const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; outWidth = static_cast(imageInfo.m_width * scale); outHeight = static_cast(imageInfo.m_height * scale); diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 94764b0f..572bac41 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 14; +constexpr uint8_t SETTINGS_COUNT = 15; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -41,7 +41,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, sleepTimeout); serialization::writePod(outputFile, refreshFrequency); serialization::writePod(outputFile, screenMargin); - + serialization::writePod(outputFile, sleepScreenCoverMode); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -96,7 +96,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, screenMargin); if (++settingsRead >= fileSettingsCount) break; - + serialization::readPod(inputFile, sleepScreenCoverMode); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); @@ -212,4 +213,4 @@ int CrossPointSettings::getReaderFontId() const { return OPENDYSLEXIC_14_FONT_ID; } } -} \ No newline at end of file +} diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 2b3f75a3..5394c4e3 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -17,6 +17,7 @@ class CrossPointSettings { // Should match with SettingsActivity text enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4 }; + enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1 }; // Status bar display type enum enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 }; @@ -53,6 +54,8 @@ class CrossPointSettings { // Sleep screen settings uint8_t sleepScreen = DARK; + // Sleep screen cover mode settings + uint8_t sleepScreenCoverMode = FIT; // Status bar settings uint8_t statusBar = FULL; // Text rendering settings diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 8ae42ff5..8d8fd791 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -146,20 +146,36 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { int x, y; const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); + float cropX = 0, cropY = 0; + Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(), + pageWidth, pageHeight); if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { // image will scale, make sure placement is right - const float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); + Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio); if (ratio > screenRatio) { // image wider than viewport ratio, scaled down image needs to be centered vertically + if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { + cropX = 1.0f - (screenRatio / ratio); + Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX); + ratio = (1.0f - cropX) * static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + } x = 0; - y = (pageHeight - pageWidth / ratio) / 2; + y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); + Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y); } else { // image taller than viewport ratio, scaled down image needs to be centered horizontally - x = (pageWidth - pageHeight * ratio) / 2; + if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { + cropY = 1.0f - (ratio / screenRatio); + Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY); + ratio = static_cast(bitmap.getWidth()) / ((1.0f - cropY) * static_cast(bitmap.getHeight())); + } + x = std::round((pageWidth - pageHeight * ratio) / 2); y = 0; + Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x); } } else { // center the image @@ -167,21 +183,22 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { y = (pageHeight - bitmap.getHeight()) / 2; } + Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y); renderer.clearScreen(); - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.displayBuffer(EInkDisplay::HALF_REFRESH); if (bitmap.hasGreyscale()) { bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleLsbBuffers(); bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); - renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleMsbBuffers(); renderer.displayGrayBuffer(); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 963318a1..469c7bb3 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -10,10 +10,11 @@ // Define the static settings list namespace { -constexpr int settingsCount = 15; +constexpr int settingsCount = 16; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), + SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn), From b792b792bf5bb5612dbd15575aa2b75f9cf5e6c0 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 7 Jan 2026 03:58:37 -0500 Subject: [PATCH 05/14] Calibre Web Epub Downloading + Calibre Wireless Device Syncing (#219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds support for browsing and downloading books from a Calibre-web server via OPDS. How it works 1. Configure server URL in Settings → Calibre Web URL (e.g., https://myserver.com:port I use Cloudflare tunnel to make my server accessible anywhere fwiw) 2. "Calibre Library" will now show on the the home screen 3. Browse the catalog - navigate through categories like "By Newest", "By Author", "By Series", etc. 4. Download books - select a book and press Confirm to download the EPUB to your device Navigation - Up/Down - Move through entries - Confirm - Open folder or download book - Back - Go to parent catalog, or exit to home if at root - Navigation entries show with > prefix, books show title and author - Button hints update dynamically ("Open" for folders, "Download" for books) Technical details - Fetches OPDS catalog from {server_url}/opds - Parses both navigation feeds (catalog links) and acquisition feeds (downloadable books) - Maintains navigation history stack for back navigation - Handles absolute paths in OPDS links correctly (e.g., /books/opds/navcatalog/...) - Downloads EPUBs directly to the SD card root Note The server URL should be typed to include https:// if the server requires it - HTTP→HTTPS redirects may cause SSL errors on ESP32. ## Additional Context * I also changed the home titles to use uppercase for each word and added a setting to change the size of the side margins --------- Co-authored-by: Dave Allie --- lib/OpdsParser/OpdsParser.cpp | 219 +++++ lib/OpdsParser/OpdsParser.h | 99 +++ src/CrossPointSettings.cpp | 13 +- src/CrossPointSettings.h | 3 +- src/ScreenComponents.cpp | 24 + src/ScreenComponents.h | 16 + .../browser/OpdsBookBrowserActivity.cpp | 398 +++++++++ .../browser/OpdsBookBrowserActivity.h | 61 ++ src/activities/home/HomeActivity.cpp | 81 +- src/activities/home/HomeActivity.h | 8 +- .../network/CalibreWirelessActivity.cpp | 756 ++++++++++++++++++ .../network/CalibreWirelessActivity.h | 135 ++++ .../settings/CalibreSettingsActivity.cpp | 169 ++++ .../settings/CalibreSettingsActivity.h | 36 + src/activities/settings/SettingsActivity.cpp | 16 +- src/main.cpp | 10 +- src/network/HttpDownloader.cpp | 128 +++ src/network/HttpDownloader.h | 42 + src/util/StringUtils.cpp | 36 + src/util/StringUtils.h | 13 + src/util/UrlUtils.cpp | 41 + src/util/UrlUtils.h | 23 + 22 files changed, 2287 insertions(+), 40 deletions(-) create mode 100644 lib/OpdsParser/OpdsParser.cpp create mode 100644 lib/OpdsParser/OpdsParser.h create mode 100644 src/activities/browser/OpdsBookBrowserActivity.cpp create mode 100644 src/activities/browser/OpdsBookBrowserActivity.h create mode 100644 src/activities/network/CalibreWirelessActivity.cpp create mode 100644 src/activities/network/CalibreWirelessActivity.h create mode 100644 src/activities/settings/CalibreSettingsActivity.cpp create mode 100644 src/activities/settings/CalibreSettingsActivity.h create mode 100644 src/network/HttpDownloader.cpp create mode 100644 src/network/HttpDownloader.h create mode 100644 src/util/StringUtils.cpp create mode 100644 src/util/StringUtils.h create mode 100644 src/util/UrlUtils.cpp create mode 100644 src/util/UrlUtils.h diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp new file mode 100644 index 00000000..da4042f0 --- /dev/null +++ b/lib/OpdsParser/OpdsParser.cpp @@ -0,0 +1,219 @@ +#include "OpdsParser.h" + +#include + +#include + +OpdsParser::~OpdsParser() { + if (parser) { + XML_StopParser(parser, XML_FALSE); + XML_SetElementHandler(parser, nullptr, nullptr); + XML_SetCharacterDataHandler(parser, nullptr); + XML_ParserFree(parser); + parser = nullptr; + } +} + +bool OpdsParser::parse(const char* xmlData, const size_t length) { + clear(); + + parser = XML_ParserCreate(nullptr); + if (!parser) { + Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis()); + return false; + } + + XML_SetUserData(parser, this); + XML_SetElementHandler(parser, startElement, endElement); + XML_SetCharacterDataHandler(parser, characterData); + + // Parse in chunks to avoid large buffer allocations + const char* currentPos = xmlData; + size_t remaining = length; + constexpr size_t chunkSize = 1024; + + while (remaining > 0) { + void* const buf = XML_GetBuffer(parser, chunkSize); + if (!buf) { + Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis()); + XML_ParserFree(parser); + parser = nullptr; + return false; + } + + const size_t toRead = remaining < chunkSize ? remaining : chunkSize; + memcpy(buf, currentPos, toRead); + + const bool isFinal = (remaining == toRead); + if (XML_ParseBuffer(parser, static_cast(toRead), isFinal) == XML_STATUS_ERROR) { + Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), + XML_ErrorString(XML_GetErrorCode(parser))); + XML_ParserFree(parser); + parser = nullptr; + return false; + } + + currentPos += toRead; + remaining -= toRead; + } + + // Clean up parser + XML_ParserFree(parser); + parser = nullptr; + + Serial.printf("[%lu] [OPDS] Parsed %zu entries\n", millis(), entries.size()); + return true; +} + +void OpdsParser::clear() { + entries.clear(); + currentEntry = OpdsEntry{}; + currentText.clear(); + inEntry = false; + inTitle = false; + inAuthor = false; + inAuthorName = false; + inId = false; +} + +std::vector OpdsParser::getBooks() const { + std::vector books; + for (const auto& entry : entries) { + if (entry.type == OpdsEntryType::BOOK) { + books.push_back(entry); + } + } + return books; +} + +const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) { + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], name) == 0) { + return atts[i + 1]; + } + } + return nullptr; +} + +void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { + auto* self = static_cast(userData); + + // Check for entry element (with or without namespace prefix) + if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { + self->inEntry = true; + self->currentEntry = OpdsEntry{}; + return; + } + + if (!self->inEntry) return; + + // Check for title element + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { + self->inTitle = true; + self->currentText.clear(); + return; + } + + // Check for author element + if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + self->inAuthor = true; + return; + } + + // Check for author name element + if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { + self->inAuthorName = true; + self->currentText.clear(); + return; + } + + // Check for id element + if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + self->inId = true; + self->currentText.clear(); + return; + } + + // Check for link element + if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { + const char* rel = findAttribute(atts, "rel"); + const char* type = findAttribute(atts, "type"); + const char* href = findAttribute(atts, "href"); + + if (href) { + // Check for acquisition link with epub type (this is a downloadable book) + if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && + strcmp(type, "application/epub+zip") == 0) { + self->currentEntry.type = OpdsEntryType::BOOK; + self->currentEntry.href = href; + } + // Check for navigation link (subsection or no rel specified with atom+xml type) + else if (type && strstr(type, "application/atom+xml") != nullptr) { + // Only set navigation link if we don't already have an epub link + if (self->currentEntry.type != OpdsEntryType::BOOK) { + self->currentEntry.type = OpdsEntryType::NAVIGATION; + self->currentEntry.href = href; + } + } + } + } +} + +void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { + auto* self = static_cast(userData); + + // Check for entry end + if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { + // Only add entry if it has required fields (title and href) + if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) { + self->entries.push_back(self->currentEntry); + } + self->inEntry = false; + self->currentEntry = OpdsEntry{}; + return; + } + + if (!self->inEntry) return; + + // Check for title end + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { + if (self->inTitle) { + self->currentEntry.title = self->currentText; + } + self->inTitle = false; + return; + } + + // Check for author end + if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + self->inAuthor = false; + return; + } + + // Check for author name end + if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { + if (self->inAuthorName) { + self->currentEntry.author = self->currentText; + } + self->inAuthorName = false; + return; + } + + // Check for id end + if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + if (self->inId) { + self->currentEntry.id = self->currentText; + } + self->inId = false; + return; + } +} + +void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) { + auto* self = static_cast(userData); + + // Only accumulate text when in a text element + if (self->inTitle || self->inAuthorName || self->inId) { + self->currentText.append(s, len); + } +} diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h new file mode 100644 index 00000000..acb4b694 --- /dev/null +++ b/lib/OpdsParser/OpdsParser.h @@ -0,0 +1,99 @@ +#pragma once +#include + +#include +#include + +/** + * Type of OPDS entry. + */ +enum class OpdsEntryType { + NAVIGATION, // Link to another catalog + BOOK // Downloadable book +}; + +/** + * Represents an entry from an OPDS feed (either a navigation link or a book). + */ +struct OpdsEntry { + OpdsEntryType type = OpdsEntryType::NAVIGATION; + std::string title; + std::string author; // Only for books + std::string href; // Navigation URL or epub download URL + std::string id; +}; + +// Legacy alias for backward compatibility +using OpdsBook = OpdsEntry; + +/** + * Parser for OPDS (Open Publication Distribution System) Atom feeds. + * Uses the Expat XML parser to parse OPDS catalog entries. + * + * Usage: + * OpdsParser parser; + * if (parser.parse(xmlData, xmlLength)) { + * for (const auto& entry : parser.getEntries()) { + * if (entry.type == OpdsEntryType::BOOK) { + * // Downloadable book + * } else { + * // Navigation link to another catalog + * } + * } + * } + */ +class OpdsParser { + public: + OpdsParser() = default; + ~OpdsParser(); + + // Disable copy + OpdsParser(const OpdsParser&) = delete; + OpdsParser& operator=(const OpdsParser&) = delete; + + /** + * Parse an OPDS XML feed. + * @param xmlData Pointer to the XML data + * @param length Length of the XML data + * @return true if parsing succeeded, false on error + */ + bool parse(const char* xmlData, size_t length); + + /** + * Get the parsed entries (both navigation and book entries). + * @return Vector of OpdsEntry entries + */ + const std::vector& getEntries() const { return entries; } + + /** + * Get only book entries (legacy compatibility). + * @return Vector of book entries + */ + std::vector getBooks() const; + + /** + * Clear all parsed entries. + */ + void clear(); + + private: + // Expat callbacks + static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); + static void XMLCALL endElement(void* userData, const XML_Char* name); + static void XMLCALL characterData(void* userData, const XML_Char* s, int len); + + // Helper to find attribute value + static const char* findAttribute(const XML_Char** atts, const char* name); + + XML_Parser parser = nullptr; + std::vector entries; + OpdsEntry currentEntry; + std::string currentText; + + // Parser state + bool inEntry = false; + bool inTitle = false; + bool inAuthor = false; + bool inAuthorName = false; + bool inId = false; +}; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 572bac41..b2f541e6 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include "fontIds.h" // Initialize the static instance @@ -12,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 15; +constexpr uint8_t SETTINGS_COUNT = 16; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -40,6 +42,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, paragraphAlignment); serialization::writePod(outputFile, sleepTimeout); serialization::writePod(outputFile, refreshFrequency); + serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, screenMargin); serialization::writePod(outputFile, sleepScreenCoverMode); outputFile.close(); @@ -94,10 +97,16 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, refreshFrequency); if (++settingsRead >= fileSettingsCount) break; + { + std::string urlStr; + serialization::readString(inputFile, urlStr); + strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); + opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, screenMargin); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, sleepScreenCoverMode); - if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 5394c4e3..9584a33d 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -77,6 +77,8 @@ class CrossPointSettings { uint8_t sleepTimeout = SLEEP_10_MIN; // E-ink refresh frequency (default 15 pages) uint8_t refreshFrequency = REFRESH_15; + // OPDS browser settings + char opdsServerUrl[128] = ""; // Reader screen margin settings uint8_t screenMargin = 5; @@ -95,7 +97,6 @@ class CrossPointSettings { float getReaderLineCompression() const; unsigned long getSleepTimeoutMs() const; int getRefreshFrequency() const; - int getReaderScreenMargin() const; }; // Helper macro to access settings diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp index 2900f3e4..3c359c0e 100644 --- a/src/ScreenComponents.cpp +++ b/src/ScreenComponents.cpp @@ -2,6 +2,7 @@ #include +#include #include #include "Battery.h" @@ -39,3 +40,26 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); } + +void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width, + const int height, const size_t current, const size_t total) { + if (total == 0) { + return; + } + + // Use 64-bit arithmetic to avoid overflow for large files + const int percent = static_cast((static_cast(current) * 100) / total); + + // Draw outline + renderer.drawRect(x, y, width, height); + + // Draw filled portion + const int fillWidth = (width - 4) * percent / 100; + if (fillWidth > 0) { + renderer.fillRect(x + 2, y + 2, fillWidth, height - 4); + } + + // Draw percentage text centered below bar + const std::string percentText = std::to_string(percent) + "%"; + renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str()); +} diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h index 2598a3e3..d938beea 100644 --- a/src/ScreenComponents.h +++ b/src/ScreenComponents.h @@ -1,8 +1,24 @@ #pragma once +#include +#include + class GfxRenderer; class ScreenComponents { public: static void drawBattery(const GfxRenderer& renderer, int left, int top); + + /** + * Draw a progress bar with percentage text. + * @param renderer The graphics renderer + * @param x Left position of the bar + * @param y Top position of the bar + * @param width Width of the bar + * @param height Height of the bar + * @param current Current progress value + * @param total Total value for 100% progress + */ + static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current, + size_t total); }; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp new file mode 100644 index 00000000..b9dbac8e --- /dev/null +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -0,0 +1,398 @@ +#include "OpdsBookBrowserActivity.h" + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "WifiCredentialStore.h" +#include "fontIds.h" +#include "network/HttpDownloader.h" +#include "util/StringUtils.h" +#include "util/UrlUtils.h" + +namespace { +constexpr int PAGE_ITEMS = 23; +constexpr int SKIP_PAGE_MS = 700; +constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL +} // namespace + +void OpdsBookBrowserActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void OpdsBookBrowserActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + state = BrowserState::CHECK_WIFI; + entries.clear(); + navigationHistory.clear(); + currentPath = OPDS_ROOT_PATH; + selectorIndex = 0; + errorMessage.clear(); + statusMessage = "Checking WiFi..."; + updateRequired = true; + + xTaskCreate(&OpdsBookBrowserActivity::taskTrampoline, "OpdsBookBrowserTask", + 4096, // Stack size (larger for HTTP operations) + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // Check WiFi and connect if needed, then fetch feed + checkAndConnectWifi(); +} + +void OpdsBookBrowserActivity::onExit() { + Activity::onExit(); + + // Turn off WiFi when exiting + WiFi.mode(WIFI_OFF); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + entries.clear(); + navigationHistory.clear(); +} + +void OpdsBookBrowserActivity::loop() { + // Handle error state - Confirm retries, Back goes back or home + if (state == BrowserState::ERROR) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + navigateBack(); + } + return; + } + + // Handle WiFi check state - only Back works + if (state == BrowserState::CHECK_WIFI) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + } + return; + } + + // Handle loading state - only Back works + if (state == BrowserState::LOADING) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + navigateBack(); + } + return; + } + + // Handle downloading state - no input allowed + if (state == BrowserState::DOWNLOADING) { + return; + } + + // Handle browsing state + if (state == BrowserState::BROWSING) { + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (!entries.empty()) { + const auto& entry = entries[selectorIndex]; + if (entry.type == OpdsEntryType::BOOK) { + downloadBook(entry); + } else { + navigateToEntry(entry); + } + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + navigateBack(); + } else if (prevReleased && !entries.empty()) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size(); + } else { + selectorIndex = (selectorIndex + entries.size() - 1) % entries.size(); + } + updateRequired = true; + } else if (nextReleased && !entries.empty()) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size(); + } else { + selectorIndex = (selectorIndex + 1) % entries.size(); + } + updateRequired = true; + } + } +} + +void OpdsBookBrowserActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void OpdsBookBrowserActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD); + + if (state == BrowserState::CHECK_WIFI) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == BrowserState::LOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == BrowserState::ERROR) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:"); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str()); + const auto labels = mappedInput.mapLabels("« Back", "Retry", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == BrowserState::DOWNLOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, "Downloading..."); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str()); + if (downloadTotal > 0) { + const int barWidth = pageWidth - 100; + constexpr int barHeight = 20; + constexpr int barX = 50; + const int barY = pageHeight / 2 + 20; + ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal); + } + renderer.displayBuffer(); + return; + } + + // Browsing state + // Show appropriate button hint based on selected entry type + const char* confirmLabel = "Open"; + if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) { + confirmLabel = "Download"; + } + const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + if (entries.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found"); + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); + + for (size_t i = pageStartIndex; i < entries.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { + const auto& entry = entries[i]; + + // Format display text with type indicator + std::string displayText; + if (entry.type == OpdsEntryType::NAVIGATION) { + displayText = "> " + entry.title; // Folder/navigation indicator + } else { + // Book: "Title - Author" or just "Title" + displayText = entry.title; + if (!entry.author.empty()) { + displayText += " - " + entry.author; + } + } + + auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), + i != static_cast(selectorIndex)); + } + + renderer.displayBuffer(); +} + +void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { + const char* serverUrl = SETTINGS.opdsServerUrl; + if (strlen(serverUrl) == 0) { + state = BrowserState::ERROR; + errorMessage = "No server URL configured"; + updateRequired = true; + return; + } + + std::string url = UrlUtils::buildUrl(serverUrl, path); + Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str()); + + std::string content; + if (!HttpDownloader::fetchUrl(url, content)) { + state = BrowserState::ERROR; + errorMessage = "Failed to fetch feed"; + updateRequired = true; + return; + } + + OpdsParser parser; + if (!parser.parse(content.c_str(), content.size())) { + state = BrowserState::ERROR; + errorMessage = "Failed to parse feed"; + updateRequired = true; + return; + } + + entries = parser.getEntries(); + selectorIndex = 0; + + if (entries.empty()) { + state = BrowserState::ERROR; + errorMessage = "No entries found"; + updateRequired = true; + return; + } + + state = BrowserState::BROWSING; + updateRequired = true; +} + +void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { + // Push current path to history before navigating + navigationHistory.push_back(currentPath); + currentPath = entry.href; + + state = BrowserState::LOADING; + statusMessage = "Loading..."; + entries.clear(); + selectorIndex = 0; + updateRequired = true; + + fetchFeed(currentPath); +} + +void OpdsBookBrowserActivity::navigateBack() { + if (navigationHistory.empty()) { + // At root, go home + onGoHome(); + } else { + // Go back to previous catalog + currentPath = navigationHistory.back(); + navigationHistory.pop_back(); + + state = BrowserState::LOADING; + statusMessage = "Loading..."; + entries.clear(); + selectorIndex = 0; + updateRequired = true; + + fetchFeed(currentPath); + } +} + +void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { + state = BrowserState::DOWNLOADING; + statusMessage = book.title; + downloadProgress = 0; + downloadTotal = 0; + updateRequired = true; + + // Build full download URL + std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); + + // Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author + std::string baseName = book.title; + if (!book.author.empty()) { + baseName += " - " + book.author; + } + std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; + + Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), downloadUrl.c_str(), filename.c_str()); + + const auto result = + HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { + downloadProgress = downloaded; + downloadTotal = total; + updateRequired = true; + }); + + if (result == HttpDownloader::OK) { + Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str()); + state = BrowserState::BROWSING; + updateRequired = true; + } else { + state = BrowserState::ERROR; + errorMessage = "Download failed"; + updateRequired = true; + } +} + +void OpdsBookBrowserActivity::checkAndConnectWifi() { + // Already connected? + if (WiFi.status() == WL_CONNECTED) { + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + return; + } + + // Try to connect using saved credentials + statusMessage = "Connecting to WiFi..."; + updateRequired = true; + + WIFI_STORE.loadFromFile(); + const auto& credentials = WIFI_STORE.getCredentials(); + if (credentials.empty()) { + state = BrowserState::ERROR; + errorMessage = "No WiFi credentials saved"; + updateRequired = true; + return; + } + + // Use the first saved credential + const auto& cred = credentials[0]; + WiFi.mode(WIFI_STA); + WiFi.begin(cred.ssid.c_str(), cred.password.c_str()); + + // Wait for connection with timeout + constexpr int WIFI_TIMEOUT_MS = 10000; + const unsigned long startTime = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - startTime < WIFI_TIMEOUT_MS) { + vTaskDelay(100 / portTICK_PERIOD_MS); + } + + if (WiFi.status() == WL_CONNECTED) { + Serial.printf("[%lu] [OPDS] WiFi connected: %s\n", millis(), WiFi.localIP().toString().c_str()); + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + } else { + state = BrowserState::ERROR; + errorMessage = "WiFi connection failed"; + updateRequired = true; + } +} diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h new file mode 100644 index 00000000..efda294c --- /dev/null +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -0,0 +1,61 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" + +/** + * Activity for browsing and downloading books from an OPDS server. + * Supports navigation through catalog hierarchy and downloading EPUBs. + */ +class OpdsBookBrowserActivity final : public Activity { + public: + enum class BrowserState { + CHECK_WIFI, // Checking WiFi connection + LOADING, // Fetching OPDS feed + BROWSING, // Displaying entries (navigation or books) + DOWNLOADING, // Downloading selected EPUB + ERROR // Error state with message + }; + + explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onGoHome) + : Activity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + BrowserState state = BrowserState::LOADING; + std::vector entries; + std::vector navigationHistory; // Stack of previous feed paths for back navigation + std::string currentPath; // Current feed path being displayed + int selectorIndex = 0; + std::string errorMessage; + std::string statusMessage; + size_t downloadProgress = 0; + size_t downloadTotal = 0; + + const std::function onGoHome; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + void checkAndConnectWifi(); + void fetchFeed(const std::string& path); + void navigateToEntry(const OpdsEntry& entry); + void navigateBack(); + void downloadBook(const OpdsEntry& book); +}; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 11107fdc..b91e7c5a 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,6 +4,10 @@ #include #include +#include +#include + +#include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "ScreenComponents.h" @@ -14,7 +18,12 @@ void HomeActivity::taskTrampoline(void* param) { self->displayTaskLoop(); } -int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; } +int HomeActivity::getMenuItemCount() const { + int count = 3; // Browse files, File transfer, Settings + if (hasContinueReading) count++; + if (hasOpdsUrl) count++; + return count; +} void HomeActivity::onEnter() { Activity::onEnter(); @@ -24,6 +33,9 @@ void HomeActivity::onEnter() { // Check if we have a book to continue reading hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); + // Check if OPDS browser URL is configured + hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; + if (hasContinueReading) { // Extract filename from path for display lastBookTitle = APP_STATE.openEpubPath; @@ -86,26 +98,24 @@ void HomeActivity::loop() { const int menuCount = getMenuItemCount(); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - 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(); - } + // Calculate dynamic indices based on which options are available + int idx = 0; + const int continueIdx = hasContinueReading ? idx++ : -1; + const int browseFilesIdx = idx++; + const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; + const int fileTransferIdx = idx++; + const int settingsIdx = idx; + + if (selectorIndex == continueIdx) { + onContinueReading(); + } else if (selectorIndex == browseFilesIdx) { + onReaderOpen(); + } else if (selectorIndex == opdsLibraryIdx) { + onOpdsBrowserOpen(); + } else if (selectorIndex == fileTransferIdx) { + onFileTransferOpen(); + } else if (selectorIndex == settingsIdx) { + onSettingsOpen(); } } else if (prevPressed) { selectorIndex = (selectorIndex + menuCount - 1) % menuCount; @@ -277,24 +287,31 @@ void HomeActivity::render() const { renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); } - // --- Bottom menu tiles (indices 1-3) --- - const int menuTileWidth = pageWidth - 2 * margin; - constexpr int menuTileHeight = 50; - constexpr int menuSpacing = 10; - constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing; + // --- Bottom menu tiles --- + // Build menu items dynamically + std::vector menuItems = {"Browse Files", "File Transfer", "Settings"}; + if (hasOpdsUrl) { + // Insert Calibre Library after Browse Files + menuItems.insert(menuItems.begin() + 1, "Calibre Library"); + } - int menuStartY = bookY + bookHeight + 20; + const int menuTileWidth = pageWidth - 2 * margin; + constexpr int menuTileHeight = 45; + constexpr int menuSpacing = 8; + const int totalMenuHeight = + static_cast(menuItems.size()) * menuTileHeight + (static_cast(menuItems.size()) - 1) * menuSpacing; + + int menuStartY = bookY + bookHeight + 15; // Ensure we don't collide with the bottom button legend const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; if (menuStartY > maxMenuStartY) { menuStartY = maxMenuStartY; } - for (int i = 0; i < 3; ++i) { - constexpr const char* items[3] = {"Browse files", "File transfer", "Settings"}; - const int overallIndex = i + (getMenuItemCount() - 3); + for (size_t i = 0; i < menuItems.size(); ++i) { + const int overallIndex = static_cast(i) + (hasContinueReading ? 1 : 0); constexpr int tileX = margin; - const int tileY = menuStartY + i * (menuTileHeight + menuSpacing); + const int tileY = menuStartY + static_cast(i) * (menuTileHeight + menuSpacing); const bool selected = selectorIndex == overallIndex; if (selected) { @@ -303,7 +320,7 @@ void HomeActivity::render() const { renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); } - const char* label = items[i]; + const char* label = menuItems[i]; const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); const int textX = tileX + (menuTileWidth - textWidth) / 2; const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index b6c9767d..84cb5bfd 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,12 +13,14 @@ class HomeActivity final : public Activity { int selectorIndex = 0; bool updateRequired = false; bool hasContinueReading = false; + bool hasOpdsUrl = false; std::string lastBookTitle; std::string lastBookAuthor; const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; + const std::function onOpdsBrowserOpen; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); @@ -28,12 +30,14 @@ class HomeActivity final : public Activity { public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onContinueReading, const std::function& onReaderOpen, - const std::function& onSettingsOpen, const std::function& onFileTransferOpen) + const std::function& onSettingsOpen, const std::function& onFileTransferOpen, + const std::function& onOpdsBrowserOpen) : Activity("Home", renderer, mappedInput), onContinueReading(onContinueReading), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen), - onFileTransferOpen(onFileTransferOpen) {} + onFileTransferOpen(onFileTransferOpen), + onOpdsBrowserOpen(onOpdsBrowserOpen) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp new file mode 100644 index 00000000..3ac76cb0 --- /dev/null +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -0,0 +1,756 @@ +#include "CalibreWirelessActivity.h" + +#include +#include +#include +#include + +#include + +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +namespace { +constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; +constexpr uint16_t LOCAL_UDP_PORT = 8134; // Port to receive responses +} // namespace + +void CalibreWirelessActivity::displayTaskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void CalibreWirelessActivity::networkTaskTrampoline(void* param) { + auto* self = static_cast(param); + self->networkTaskLoop(); +} + +void CalibreWirelessActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + stateMutex = xSemaphoreCreateMutex(); + + state = WirelessState::DISCOVERING; + statusMessage = "Discovering Calibre..."; + errorMessage.clear(); + calibreHostname.clear(); + calibreHost.clear(); + calibrePort = 0; + calibreAltPort = 0; + currentFilename.clear(); + currentFileSize = 0; + bytesReceived = 0; + inBinaryMode = false; + recvBuffer.clear(); + + updateRequired = true; + + // Start UDP listener for Calibre responses + udp.begin(LOCAL_UDP_PORT); + + // Create display task + xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle); + + // Create network task with larger stack for JSON parsing + xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle); +} + +void CalibreWirelessActivity::onExit() { + Activity::onExit(); + + // Turn off WiFi when exiting + WiFi.mode(WIFI_OFF); + + // Stop UDP listening + udp.stop(); + + // Close TCP client if connected + if (tcpClient.connected()) { + tcpClient.stop(); + } + + // Close any open file + if (currentFile) { + currentFile.close(); + } + + // Acquire stateMutex before deleting network task to avoid race condition + xSemaphoreTake(stateMutex, portMAX_DELAY); + if (networkTaskHandle) { + vTaskDelete(networkTaskHandle); + networkTaskHandle = nullptr; + } + xSemaphoreGive(stateMutex); + + // Acquire renderingMutex before deleting display task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + + vSemaphoreDelete(stateMutex); + stateMutex = nullptr; +} + +void CalibreWirelessActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onComplete(); + return; + } +} + +void CalibreWirelessActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(50 / portTICK_PERIOD_MS); + } +} + +void CalibreWirelessActivity::networkTaskLoop() { + while (true) { + xSemaphoreTake(stateMutex, portMAX_DELAY); + const auto currentState = state; + xSemaphoreGive(stateMutex); + + switch (currentState) { + case WirelessState::DISCOVERING: + listenForDiscovery(); + break; + + case WirelessState::CONNECTING: + case WirelessState::WAITING: + case WirelessState::RECEIVING: + handleTcpClient(); + break; + + case WirelessState::COMPLETE: + case WirelessState::DISCONNECTED: + case WirelessState::ERROR: + // Just wait, user will exit + vTaskDelay(100 / portTICK_PERIOD_MS); + break; + } + + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void CalibreWirelessActivity::listenForDiscovery() { + // Broadcast "hello" on all UDP discovery ports to find Calibre + for (const uint16_t port : UDP_PORTS) { + udp.beginPacket("255.255.255.255", port); + udp.write(reinterpret_cast("hello"), 5); + udp.endPacket(); + } + + // Wait for Calibre's response + vTaskDelay(500 / portTICK_PERIOD_MS); + + // Check for response + const int packetSize = udp.parsePacket(); + if (packetSize > 0) { + char buffer[256]; + const int len = udp.read(buffer, sizeof(buffer) - 1); + if (len > 0) { + buffer[len] = '\0'; + + // Parse Calibre's response format: + // "calibre wireless device client (on hostname);port,content_server_port" + // or just the hostname and port info + std::string response(buffer); + + // Try to extract host and port + // Format: "calibre wireless device client (on HOSTNAME);PORT,..." + size_t onPos = response.find("(on "); + size_t closePos = response.find(')'); + size_t semiPos = response.find(';'); + size_t commaPos = response.find(',', semiPos); + + if (semiPos != std::string::npos) { + // Get ports after semicolon (format: "port1,port2") + std::string portStr; + if (commaPos != std::string::npos && commaPos > semiPos) { + portStr = response.substr(semiPos + 1, commaPos - semiPos - 1); + // Get alternative port after comma + std::string altPortStr = response.substr(commaPos + 1); + // Trim whitespace and non-digits from alt port + size_t altEnd = 0; + while (altEnd < altPortStr.size() && altPortStr[altEnd] >= '0' && altPortStr[altEnd] <= '9') { + altEnd++; + } + if (altEnd > 0) { + calibreAltPort = static_cast(std::stoi(altPortStr.substr(0, altEnd))); + } + } else { + portStr = response.substr(semiPos + 1); + } + + // Trim whitespace from main port + while (!portStr.empty() && (portStr[0] == ' ' || portStr[0] == '\t')) { + portStr = portStr.substr(1); + } + + if (!portStr.empty()) { + calibrePort = static_cast(std::stoi(portStr)); + } + + // Get hostname if present, otherwise use sender IP + if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) { + calibreHostname = response.substr(onPos + 4, closePos - onPos - 4); + } + } + + // Use the sender's IP as the host to connect to + calibreHost = udp.remoteIP().toString().c_str(); + if (calibreHostname.empty()) { + calibreHostname = calibreHost; + } + + if (calibrePort > 0) { + // Connect to Calibre's TCP server - try main port first, then alt port + setState(WirelessState::CONNECTING); + setStatus("Connecting to " + calibreHostname + "..."); + + // Small delay before connecting + vTaskDelay(100 / portTICK_PERIOD_MS); + + bool connected = false; + + // Try main port first + if (tcpClient.connect(calibreHost.c_str(), calibrePort, 5000)) { + connected = true; + } + + // Try alternative port if main failed + if (!connected && calibreAltPort > 0) { + vTaskDelay(200 / portTICK_PERIOD_MS); + if (tcpClient.connect(calibreHost.c_str(), calibreAltPort, 5000)) { + connected = true; + } + } + + if (connected) { + setState(WirelessState::WAITING); + setStatus("Connected to " + calibreHostname + "\nWaiting for commands..."); + } else { + // Don't set error yet, keep trying discovery + setState(WirelessState::DISCOVERING); + setStatus("Discovering Calibre...\n(Connection failed, retrying)"); + calibrePort = 0; + calibreAltPort = 0; + } + } + } + } +} + +void CalibreWirelessActivity::handleTcpClient() { + if (!tcpClient.connected()) { + setState(WirelessState::DISCONNECTED); + setStatus("Calibre disconnected"); + return; + } + + if (inBinaryMode) { + receiveBinaryData(); + return; + } + + std::string message; + if (readJsonMessage(message)) { + // Parse opcode from JSON array format: [opcode, {...}] + // Find the opcode (first number after '[') + size_t start = message.find('['); + if (start != std::string::npos) { + start++; + size_t end = message.find(',', start); + if (end != std::string::npos) { + const int opcodeInt = std::stoi(message.substr(start, end - start)); + if (opcodeInt < 0 || opcodeInt >= OpCode::ERROR) { + Serial.printf("[%lu] [CAL] Invalid opcode: %d\n", millis(), opcodeInt); + sendJsonResponse(OpCode::OK, "{}"); + return; + } + const auto opcode = static_cast(opcodeInt); + + // Extract data object (everything after the comma until the last ']') + size_t dataStart = end + 1; + size_t dataEnd = message.rfind(']'); + std::string data = ""; + if (dataEnd != std::string::npos && dataEnd > dataStart) { + data = message.substr(dataStart, dataEnd - dataStart); + } + + handleCommand(opcode, data); + } + } + } +} + +bool CalibreWirelessActivity::readJsonMessage(std::string& message) { + // Read available data into buffer + int available = tcpClient.available(); + if (available > 0) { + // Limit buffer growth to prevent memory issues + if (recvBuffer.size() > 100000) { + recvBuffer.clear(); + return false; + } + // Read in chunks + char buf[1024]; + while (available > 0) { + int toRead = std::min(available, static_cast(sizeof(buf))); + int bytesRead = tcpClient.read(reinterpret_cast(buf), toRead); + if (bytesRead > 0) { + recvBuffer.append(buf, bytesRead); + available -= bytesRead; + } else { + break; + } + } + } + + if (recvBuffer.empty()) { + return false; + } + + // Find '[' which marks the start of JSON + size_t bracketPos = recvBuffer.find('['); + if (bracketPos == std::string::npos) { + // No '[' found - if buffer is getting large, something is wrong + if (recvBuffer.size() > 1000) { + recvBuffer.clear(); + } + return false; + } + + // Try to extract length from digits before '[' + // Calibre ALWAYS sends a length prefix, so if it's not valid digits, it's garbage + size_t msgLen = 0; + bool validPrefix = false; + + if (bracketPos > 0 && bracketPos <= 12) { + // Check if prefix is all digits + bool allDigits = true; + for (size_t i = 0; i < bracketPos; i++) { + char c = recvBuffer[i]; + if (c < '0' || c > '9') { + allDigits = false; + break; + } + } + if (allDigits) { + msgLen = std::stoul(recvBuffer.substr(0, bracketPos)); + validPrefix = true; + } + } + + if (!validPrefix) { + // Not a valid length prefix - discard everything up to '[' and treat '[' as start + if (bracketPos > 0) { + recvBuffer = recvBuffer.substr(bracketPos); + } + // Without length prefix, we can't reliably parse - wait for more data + // that hopefully starts with a proper length prefix + return false; + } + + // Sanity check the message length + if (msgLen > 1000000) { + recvBuffer = recvBuffer.substr(bracketPos + 1); // Skip past this '[' and try again + return false; + } + + // Check if we have the complete message + size_t totalNeeded = bracketPos + msgLen; + if (recvBuffer.size() < totalNeeded) { + // Not enough data yet - wait for more + return false; + } + + // Extract the message + message = recvBuffer.substr(bracketPos, msgLen); + + // Keep the rest in buffer (may contain binary data or next message) + if (recvBuffer.size() > totalNeeded) { + recvBuffer = recvBuffer.substr(totalNeeded); + } else { + recvBuffer.clear(); + } + + return true; +} + +void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) { + // Format: length + [opcode, {data}] + std::string json = "[" + std::to_string(opcode) + "," + data + "]"; + const std::string lengthPrefix = std::to_string(json.length()); + json.insert(0, lengthPrefix); + + tcpClient.write(reinterpret_cast(json.c_str()), json.length()); + tcpClient.flush(); +} + +void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) { + switch (opcode) { + case OpCode::GET_INITIALIZATION_INFO: + handleGetInitializationInfo(data); + break; + case OpCode::GET_DEVICE_INFORMATION: + handleGetDeviceInformation(); + break; + case OpCode::FREE_SPACE: + handleFreeSpace(); + break; + case OpCode::GET_BOOK_COUNT: + handleGetBookCount(); + break; + case OpCode::SEND_BOOK: + handleSendBook(data); + break; + case OpCode::SEND_BOOK_METADATA: + handleSendBookMetadata(data); + break; + case OpCode::DISPLAY_MESSAGE: + handleDisplayMessage(data); + break; + case OpCode::NOOP: + handleNoop(data); + break; + case OpCode::SET_CALIBRE_DEVICE_INFO: + case OpCode::SET_CALIBRE_DEVICE_NAME: + // These set metadata about the connected Calibre instance. + // We don't need this info, just acknowledge receipt. + sendJsonResponse(OpCode::OK, "{}"); + break; + case OpCode::SET_LIBRARY_INFO: + // Library metadata (name, UUID) - not needed for receiving books + sendJsonResponse(OpCode::OK, "{}"); + break; + case OpCode::SEND_BOOKLISTS: + // Calibre asking us to send our book list. We report 0 books in + // handleGetBookCount, so this is effectively a no-op. + sendJsonResponse(OpCode::OK, "{}"); + break; + case OpCode::TOTAL_SPACE: + handleFreeSpace(); + break; + default: + Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode); + sendJsonResponse(OpCode::OK, "{}"); + break; + } +} + +void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& data) { + setState(WirelessState::WAITING); + setStatus("Connected to " + calibreHostname + + "\nWaiting for transfer...\n\nIf transfer fails, enable\n'Ignore free space' in Calibre's\nSmartDevice " + "plugin settings."); + + // Build response with device capabilities + // Format must match what Calibre expects from a smart device + std::string response = "{"; + response += "\"appName\":\"CrossPoint\","; + response += "\"acceptedExtensions\":[\"epub\"],"; + response += "\"cacheUsesLpaths\":true,"; + response += "\"canAcceptLibraryInfo\":true,"; + response += "\"canDeleteMultipleBooks\":true,"; + response += "\"canReceiveBookBinary\":true,"; + response += "\"canSendOkToSendbook\":true,"; + response += "\"canStreamBooks\":true,"; + response += "\"canStreamMetadata\":true,"; + response += "\"canUseCachedMetadata\":true,"; + // ccVersionNumber: Calibre Companion protocol version. 212 matches CC 5.4.20+. + // Using a known version ensures compatibility with Calibre's feature detection. + response += "\"ccVersionNumber\":212,"; + // coverHeight: Max cover image height. We don't process covers, so this is informational only. + response += "\"coverHeight\":800,"; + response += "\"deviceKind\":\"CrossPoint\","; + response += "\"deviceName\":\"CrossPoint\","; + response += "\"extensionPathLengths\":{\"epub\":37},"; + response += "\"maxBookContentPacketLen\":4096,"; + response += "\"passwordHash\":\"\","; + response += "\"useUuidFileNames\":false,"; + response += "\"versionOK\":true"; + response += "}"; + + sendJsonResponse(OpCode::OK, response); +} + +void CalibreWirelessActivity::handleGetDeviceInformation() { + std::string response = "{"; + response += "\"device_info\":{"; + response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\","; + response += "\"device_name\":\"CrossPoint Reader\","; + response += "\"device_version\":\"" CROSSPOINT_VERSION "\""; + response += "},"; + response += "\"version\":1,"; + response += "\"device_version\":\"" CROSSPOINT_VERSION "\""; + response += "}"; + + sendJsonResponse(OpCode::OK, response); +} + +void CalibreWirelessActivity::handleFreeSpace() { + // TODO: Report actual SD card free space instead of hardcoded value + // Report 10GB free space for now + sendJsonResponse(OpCode::OK, "{\"free_space_on_device\":10737418240}"); +} + +void CalibreWirelessActivity::handleGetBookCount() { + // We report 0 books - Calibre will send books without checking for duplicates + std::string response = "{\"count\":0,\"willStream\":true,\"willScan\":false}"; + sendJsonResponse(OpCode::OK, response); +} + +void CalibreWirelessActivity::handleSendBook(const std::string& data) { + // Manually extract lpath and length from SEND_BOOK data + // Full JSON parsing crashes on large metadata, so we just extract what we need + + // Extract "lpath" field - format: "lpath": "value" + std::string lpath; + size_t lpathPos = data.find("\"lpath\""); + if (lpathPos != std::string::npos) { + size_t colonPos = data.find(':', lpathPos + 7); + if (colonPos != std::string::npos) { + size_t quoteStart = data.find('"', colonPos + 1); + if (quoteStart != std::string::npos) { + size_t quoteEnd = data.find('"', quoteStart + 1); + if (quoteEnd != std::string::npos) { + lpath = data.substr(quoteStart + 1, quoteEnd - quoteStart - 1); + } + } + } + } + + // Extract top-level "length" field - must track depth to skip nested objects + // The metadata contains nested "length" fields (e.g., cover image length) + size_t length = 0; + int depth = 0; + for (size_t i = 0; i < data.size(); i++) { + char c = data[i]; + if (c == '{' || c == '[') { + depth++; + } else if (c == '}' || c == ']') { + depth--; + } else if (depth == 1 && c == '"') { + // At top level, check if this is "length" + if (i + 9 < data.size() && data.substr(i, 8) == "\"length\"") { + // Found top-level "length" - extract the number after ':' + size_t colonPos = data.find(':', i + 8); + if (colonPos != std::string::npos) { + size_t numStart = colonPos + 1; + while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) { + numStart++; + } + size_t numEnd = numStart; + while (numEnd < data.size() && data[numEnd] >= '0' && data[numEnd] <= '9') { + numEnd++; + } + if (numEnd > numStart) { + length = std::stoul(data.substr(numStart, numEnd - numStart)); + break; + } + } + } + } + } + + if (lpath.empty() || length == 0) { + sendJsonResponse(OpCode::ERROR, "{\"message\":\"Invalid book data\"}"); + return; + } + + // Extract filename from lpath + std::string filename = lpath; + const size_t lastSlash = filename.rfind('/'); + if (lastSlash != std::string::npos) { + filename = filename.substr(lastSlash + 1); + } + + // Sanitize and create full path + currentFilename = "/" + StringUtils::sanitizeFilename(filename); + if (currentFilename.find(".epub") == std::string::npos) { + currentFilename += ".epub"; + } + currentFileSize = length; + bytesReceived = 0; + + setState(WirelessState::RECEIVING); + setStatus("Receiving: " + filename); + + // Open file for writing + if (!SdMan.openFileForWrite("CAL", currentFilename.c_str(), currentFile)) { + setError("Failed to create file"); + sendJsonResponse(OpCode::ERROR, "{\"message\":\"Failed to create file\"}"); + return; + } + + // Send OK to start receiving binary data + sendJsonResponse(OpCode::OK, "{}"); + + // Switch to binary mode + inBinaryMode = true; + binaryBytesRemaining = length; + + // Check if recvBuffer has leftover data (binary file data that arrived with the JSON) + if (!recvBuffer.empty()) { + size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining); + size_t written = currentFile.write(reinterpret_cast(recvBuffer.data()), toWrite); + bytesReceived += written; + binaryBytesRemaining -= written; + recvBuffer = recvBuffer.substr(toWrite); + updateRequired = true; + } +} + +void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) { + // We receive metadata after the book - just acknowledge + sendJsonResponse(OpCode::OK, "{}"); +} + +void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) { + // Calibre may send messages to display + // Check messageKind - 1 means password error + if (data.find("\"messageKind\":1") != std::string::npos) { + setError("Password required"); + } + sendJsonResponse(OpCode::OK, "{}"); +} + +void CalibreWirelessActivity::handleNoop(const std::string& data) { + // Check for ejecting flag + if (data.find("\"ejecting\":true") != std::string::npos) { + setState(WirelessState::DISCONNECTED); + setStatus("Calibre disconnected"); + } + sendJsonResponse(OpCode::NOOP, "{}"); +} + +void CalibreWirelessActivity::receiveBinaryData() { + const int available = tcpClient.available(); + if (available == 0) { + // Check if connection is still alive + if (!tcpClient.connected()) { + currentFile.close(); + inBinaryMode = false; + setError("Transfer interrupted"); + } + return; + } + + uint8_t buffer[1024]; + const size_t toRead = std::min(sizeof(buffer), binaryBytesRemaining); + const size_t bytesRead = tcpClient.read(buffer, toRead); + + if (bytesRead > 0) { + currentFile.write(buffer, bytesRead); + bytesReceived += bytesRead; + binaryBytesRemaining -= bytesRead; + updateRequired = true; + + if (binaryBytesRemaining == 0) { + // Transfer complete + currentFile.flush(); + currentFile.close(); + inBinaryMode = false; + + setState(WirelessState::WAITING); + setStatus("Received: " + currentFilename + "\nWaiting for more..."); + + // Send OK to acknowledge completion + sendJsonResponse(OpCode::OK, "{}"); + } + } +} + +void CalibreWirelessActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, 30, "Calibre Wireless", true, EpdFontFamily::BOLD); + + // Draw IP address + const std::string ipAddr = WiFi.localIP().toString().c_str(); + renderer.drawCenteredText(UI_10_FONT_ID, 60, ("IP: " + ipAddr).c_str()); + + // Draw status message + int statusY = pageHeight / 2 - 40; + + // Split status message by newlines and draw each line + std::string status = statusMessage; + size_t pos = 0; + while ((pos = status.find('\n')) != std::string::npos) { + renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.substr(0, pos).c_str()); + statusY += 25; + status = status.substr(pos + 1); + } + if (!status.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.c_str()); + statusY += 25; + } + + // Draw progress if receiving + if (state == WirelessState::RECEIVING && currentFileSize > 0) { + const int barWidth = pageWidth - 100; + constexpr int barHeight = 20; + constexpr int barX = 50; + const int barY = statusY + 20; + ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize); + } + + // Draw error if present + if (!errorMessage.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight - 120, errorMessage.c_str()); + } + + // Draw button hints + const auto labels = mappedInput.mapLabels("Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +std::string CalibreWirelessActivity::getDeviceUuid() const { + // Generate a consistent UUID based on MAC address + uint8_t mac[6]; + WiFi.macAddress(mac); + + char uuid[37]; + snprintf(uuid, sizeof(uuid), "%02x%02x%02x%02x-%02x%02x-4000-8000-%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2], + mac[3], mac[4], mac[5], mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + return std::string(uuid); +} + +void CalibreWirelessActivity::setState(WirelessState newState) { + xSemaphoreTake(stateMutex, portMAX_DELAY); + state = newState; + xSemaphoreGive(stateMutex); + updateRequired = true; +} + +void CalibreWirelessActivity::setStatus(const std::string& message) { + statusMessage = message; + updateRequired = true; +} + +void CalibreWirelessActivity::setError(const std::string& message) { + errorMessage = message; + setState(WirelessState::ERROR); +} diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h new file mode 100644 index 00000000..ae2b1767 --- /dev/null +++ b/src/activities/network/CalibreWirelessActivity.h @@ -0,0 +1,135 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "activities/Activity.h" + +/** + * CalibreWirelessActivity implements Calibre's "wireless device" protocol. + * This allows Calibre desktop to send books directly to the device over WiFi. + * + * Protocol specification sourced from Calibre's smart device driver: + * https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py + * + * Protocol overview: + * 1. Device broadcasts "hello" on UDP ports 54982, 48123, 39001, 44044, 59678 + * 2. Calibre responds with its TCP server address + * 3. Device connects to Calibre's TCP server + * 4. Calibre sends JSON commands with length-prefixed messages + * 5. Books are transferred as binary data after SEND_BOOK command + */ +class CalibreWirelessActivity final : public Activity { + // Calibre wireless device states + enum class WirelessState { + DISCOVERING, // Listening for Calibre server broadcasts + CONNECTING, // Establishing TCP connection + WAITING, // Connected, waiting for commands + RECEIVING, // Receiving a book file + COMPLETE, // Transfer complete + DISCONNECTED, // Calibre disconnected + ERROR // Connection/transfer error + }; + + // Calibre protocol opcodes (from calibre/devices/smart_device_app/driver.py) + enum OpCode : uint8_t { + OK = 0, + SET_CALIBRE_DEVICE_INFO = 1, + SET_CALIBRE_DEVICE_NAME = 2, + GET_DEVICE_INFORMATION = 3, + TOTAL_SPACE = 4, + FREE_SPACE = 5, + GET_BOOK_COUNT = 6, + SEND_BOOKLISTS = 7, + SEND_BOOK = 8, + GET_INITIALIZATION_INFO = 9, + BOOK_DONE = 11, + NOOP = 12, // Was incorrectly 18 + DELETE_BOOK = 13, + GET_BOOK_FILE_SEGMENT = 14, + GET_BOOK_METADATA = 15, + SEND_BOOK_METADATA = 16, + DISPLAY_MESSAGE = 17, + CALIBRE_BUSY = 18, + SET_LIBRARY_INFO = 19, + ERROR = 20, + }; + + TaskHandle_t displayTaskHandle = nullptr; + TaskHandle_t networkTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + SemaphoreHandle_t stateMutex = nullptr; + bool updateRequired = false; + + WirelessState state = WirelessState::DISCOVERING; + const std::function onComplete; + + // UDP discovery + WiFiUDP udp; + + // TCP connection (we connect to Calibre) + WiFiClient tcpClient; + std::string calibreHost; + uint16_t calibrePort = 0; + uint16_t calibreAltPort = 0; // Alternative port (content server) + std::string calibreHostname; + + // Transfer state + std::string currentFilename; + size_t currentFileSize = 0; + size_t bytesReceived = 0; + std::string statusMessage; + std::string errorMessage; + + // Protocol state + bool inBinaryMode = false; + size_t binaryBytesRemaining = 0; + FsFile currentFile; + std::string recvBuffer; // Buffer for incoming data (like KOReader) + + static void displayTaskTrampoline(void* param); + static void networkTaskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + [[noreturn]] void networkTaskLoop(); + void render() const; + + // Network operations + void listenForDiscovery(); + void handleTcpClient(); + bool readJsonMessage(std::string& message); + void sendJsonResponse(OpCode opcode, const std::string& data); + void handleCommand(OpCode opcode, const std::string& data); + void receiveBinaryData(); + + // Protocol handlers + void handleGetInitializationInfo(const std::string& data); + void handleGetDeviceInformation(); + void handleFreeSpace(); + void handleGetBookCount(); + void handleSendBook(const std::string& data); + void handleSendBookMetadata(const std::string& data); + void handleDisplayMessage(const std::string& data); + void handleNoop(const std::string& data); + + // Utility + std::string getDeviceUuid() const; + void setState(WirelessState newState); + void setStatus(const std::string& message); + void setError(const std::string& message); + + public: + explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onComplete) + : Activity("CalibreWireless", renderer, mappedInput), onComplete(onComplete) {} + void onEnter() override; + void onExit() override; + void loop() override; + bool preventAutoSleep() override { return true; } + bool skipLoopDelay() override { return true; } +}; diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp new file mode 100644 index 00000000..4f614ffc --- /dev/null +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -0,0 +1,169 @@ +#include "CalibreSettingsActivity.h" + +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "activities/network/CalibreWirelessActivity.h" +#include "activities/network/WifiSelectionActivity.h" +#include "activities/util/KeyboardEntryActivity.h" +#include "fontIds.h" + +namespace { +constexpr int MENU_ITEMS = 2; +const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"}; +} // namespace + +void CalibreSettingsActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void CalibreSettingsActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + selectedIndex = 0; + updateRequired = true; + + xTaskCreate(&CalibreSettingsActivity::taskTrampoline, "CalibreSettingsTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void CalibreSettingsActivity::onExit() { + ActivityWithSubactivity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void CalibreSettingsActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onBack(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) { + selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; + updateRequired = true; + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right)) { + selectedIndex = (selectedIndex + 1) % MENU_ITEMS; + updateRequired = true; + } +} + +void CalibreSettingsActivity::handleSelection() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + + if (selectedIndex == 0) { + // Calibre Web URL + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10, + 127, // maxLength + false, // not password + [this](const std::string& url) { + strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); + SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + updateRequired = true; + })); + } else if (selectedIndex == 1) { + // Wireless Device - launch the activity (handles WiFi connection internally) + exitActivity(); + if (WiFi.status() != WL_CONNECTED) { + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) { + exitActivity(); + if (connected) { + enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + } else { + updateRequired = true; + } + })); + } else { + enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + } + } + + xSemaphoreGive(renderingMutex); +} + +void CalibreSettingsActivity::displayTaskLoop() { + while (true) { + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void CalibreSettingsActivity::render() { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD); + + // Draw selection highlight + renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30); + + // Draw menu items + for (int i = 0; i < MENU_ITEMS; i++) { + const int settingY = 60 + i * 30; + const bool isSelected = (i == selectedIndex); + + renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); + + // Draw status for URL setting + if (i == 0) { + const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; + const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); + } + } + + // Draw button hints + const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h new file mode 100644 index 00000000..77b9218c --- /dev/null +++ b/src/activities/settings/CalibreSettingsActivity.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +#include + +#include "activities/ActivityWithSubactivity.h" + +/** + * Submenu for Calibre settings. + * Shows Calibre Web URL and Calibre Wireless Device options. + */ +class CalibreSettingsActivity final : public ActivityWithSubactivity { + public: + explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onBack) + : ActivityWithSubactivity("CalibreSettings", renderer, mappedInput), onBack(onBack) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + int selectedIndex = 0; + const std::function onBack; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + void handleSelection(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 469c7bb3..32fec596 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -3,6 +3,9 @@ #include #include +#include + +#include "CalibreSettingsActivity.h" #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" @@ -10,7 +13,7 @@ // Define the static settings list namespace { -constexpr int settingsCount = 16; +constexpr int settingsCount = 17; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -35,6 +38,7 @@ const SettingInfo settingsList[settingsCount] = { {"1 min", "5 min", "10 min", "15 min", "30 min"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Check for updates")}; } // namespace @@ -132,7 +136,15 @@ void SettingsActivity::toggleCurrentSetting() { SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; } } else if (setting.type == SettingType::ACTION) { - if (std::string(setting.name) == "Check for updates") { + if (strcmp(setting.name, "Calibre Settings") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Check for updates") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { diff --git a/src/main.cpp b/src/main.cpp index e81448bd..5261df3d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,12 +7,15 @@ #include #include +#include + #include "Battery.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" +#include "activities/browser/OpdsBookBrowserActivity.h" #include "activities/home/HomeActivity.h" #include "activities/network/CrossPointWebServerActivity.h" #include "activities/reader/ReaderActivity.h" @@ -222,10 +225,15 @@ void onGoToSettings() { enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome)); } +void onGoToBrowser() { + exitActivity(); + enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome)); +} + void onGoHome() { exitActivity(); enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToReaderHome, onGoToSettings, - onGoToFileTransfer)); + onGoToFileTransfer, onGoToBrowser)); } void setupDisplayAndFonts() { diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp new file mode 100644 index 00000000..017c6870 --- /dev/null +++ b/src/network/HttpDownloader.cpp @@ -0,0 +1,128 @@ +#include "HttpDownloader.h" + +#include +#include +#include + +#include + +bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { + const std::unique_ptr client(new WiFiClientSecure()); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str()); + + http.begin(*client, url.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] [HTTP] Fetch failed: %d\n", millis(), httpCode); + http.end(); + return false; + } + + outContent = http.getString().c_str(); + http.end(); + + Serial.printf("[%lu] [HTTP] Fetched %zu bytes\n", millis(), outContent.size()); + return true; +} + +HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, + ProgressCallback progress) { + const std::unique_ptr client(new WiFiClientSecure()); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str()); + Serial.printf("[%lu] [HTTP] Destination: %s\n", millis(), destPath.c_str()); + + http.begin(*client, url.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] [HTTP] Download failed: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + const size_t contentLength = http.getSize(); + Serial.printf("[%lu] [HTTP] Content-Length: %zu\n", millis(), contentLength); + + // Remove existing file if present + if (SdMan.exists(destPath.c_str())) { + SdMan.remove(destPath.c_str()); + } + + // Open file for writing + FsFile file; + if (!SdMan.openFileForWrite("HTTP", destPath.c_str(), file)) { + Serial.printf("[%lu] [HTTP] Failed to open file for writing\n", millis()); + http.end(); + return FILE_ERROR; + } + + // Get the stream for chunked reading + WiFiClient* stream = http.getStreamPtr(); + if (!stream) { + Serial.printf("[%lu] [HTTP] Failed to get stream\n", millis()); + file.close(); + SdMan.remove(destPath.c_str()); + http.end(); + return HTTP_ERROR; + } + + // Download in chunks + uint8_t buffer[DOWNLOAD_CHUNK_SIZE]; + size_t downloaded = 0; + const size_t total = contentLength > 0 ? contentLength : 0; + + while (http.connected() && (contentLength == 0 || downloaded < contentLength)) { + const size_t available = stream->available(); + if (available == 0) { + delay(1); + continue; + } + + const size_t toRead = available < DOWNLOAD_CHUNK_SIZE ? available : DOWNLOAD_CHUNK_SIZE; + const size_t bytesRead = stream->readBytes(buffer, toRead); + + if (bytesRead == 0) { + break; + } + + const size_t written = file.write(buffer, bytesRead); + if (written != bytesRead) { + Serial.printf("[%lu] [HTTP] Write failed: wrote %zu of %zu bytes\n", millis(), written, bytesRead); + file.close(); + SdMan.remove(destPath.c_str()); + http.end(); + return FILE_ERROR; + } + + downloaded += bytesRead; + + if (progress && total > 0) { + progress(downloaded, total); + } + } + + file.close(); + http.end(); + + Serial.printf("[%lu] [HTTP] Downloaded %zu bytes\n", millis(), downloaded); + + // Verify download size if known + if (contentLength > 0 && downloaded != contentLength) { + Serial.printf("[%lu] [HTTP] Size mismatch: got %zu, expected %zu\n", millis(), downloaded, contentLength); + SdMan.remove(destPath.c_str()); + return HTTP_ERROR; + } + + return OK; +} diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h new file mode 100644 index 00000000..e6e0f163 --- /dev/null +++ b/src/network/HttpDownloader.h @@ -0,0 +1,42 @@ +#pragma once +#include + +#include +#include + +/** + * HTTP client utility for fetching content and downloading files. + * Wraps WiFiClientSecure and HTTPClient for HTTPS requests. + */ +class HttpDownloader { + public: + using ProgressCallback = std::function; + + enum DownloadError { + OK = 0, + HTTP_ERROR, + FILE_ERROR, + ABORTED, + }; + + /** + * Fetch text content from a URL. + * @param url The URL to fetch + * @param outContent The fetched content (output) + * @return true if fetch succeeded, false on error + */ + static bool fetchUrl(const std::string& url, std::string& outContent); + + /** + * Download a file to the SD card. + * @param url The URL to download + * @param destPath The destination path on SD card + * @param progress Optional progress callback + * @return DownloadError indicating success or failure type + */ + static DownloadError downloadToFile(const std::string& url, const std::string& destPath, + ProgressCallback progress = nullptr); + + private: + static constexpr size_t DOWNLOAD_CHUNK_SIZE = 1024; +}; diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp new file mode 100644 index 00000000..21617210 --- /dev/null +++ b/src/util/StringUtils.cpp @@ -0,0 +1,36 @@ +#include "StringUtils.h" + +namespace StringUtils { + +std::string sanitizeFilename(const std::string& name, size_t maxLength) { + std::string result; + result.reserve(name.size()); + + for (char c : name) { + // Replace invalid filename characters with underscore + if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') { + result += '_'; + } else if (c >= 32 && c < 127) { + // Keep printable ASCII characters + result += c; + } + // Skip non-printable characters + } + + // Trim leading/trailing spaces and dots + size_t start = result.find_first_not_of(" ."); + if (start == std::string::npos) { + return "book"; // Fallback if name is all invalid characters + } + size_t end = result.find_last_not_of(" ."); + result = result.substr(start, end - start + 1); + + // Limit filename length + if (result.length() > maxLength) { + result.resize(maxLength); + } + + return result.empty() ? "book" : result; +} + +} // namespace StringUtils diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h new file mode 100644 index 00000000..27f826a2 --- /dev/null +++ b/src/util/StringUtils.h @@ -0,0 +1,13 @@ +#pragma once +#include + +namespace StringUtils { + +/** + * Sanitize a string for use as a filename. + * Replaces invalid characters with underscores, trims spaces/dots, + * and limits length to maxLength characters. + */ +std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); + +} // namespace StringUtils diff --git a/src/util/UrlUtils.cpp b/src/util/UrlUtils.cpp new file mode 100644 index 00000000..0eeeae3a --- /dev/null +++ b/src/util/UrlUtils.cpp @@ -0,0 +1,41 @@ +#include "UrlUtils.h" + +namespace UrlUtils { + +std::string ensureProtocol(const std::string& url) { + if (url.find("://") == std::string::npos) { + return "http://" + url; + } + return url; +} + +std::string extractHost(const std::string& url) { + const size_t protocolEnd = url.find("://"); + if (protocolEnd == std::string::npos) { + // No protocol, find first slash + const size_t firstSlash = url.find('/'); + return firstSlash == std::string::npos ? url : url.substr(0, firstSlash); + } + // Find the first slash after the protocol + const size_t hostStart = protocolEnd + 3; + const size_t pathStart = url.find('/', hostStart); + return pathStart == std::string::npos ? url : url.substr(0, pathStart); +} + +std::string buildUrl(const std::string& serverUrl, const std::string& path) { + const std::string urlWithProtocol = ensureProtocol(serverUrl); + if (path.empty()) { + return urlWithProtocol; + } + if (path[0] == '/') { + // Absolute path - use just the host + return extractHost(urlWithProtocol) + path; + } + // Relative path - append to server URL + if (urlWithProtocol.back() == '/') { + return urlWithProtocol + path; + } + return urlWithProtocol + "/" + path; +} + +} // namespace UrlUtils diff --git a/src/util/UrlUtils.h b/src/util/UrlUtils.h new file mode 100644 index 00000000..d88ca13c --- /dev/null +++ b/src/util/UrlUtils.h @@ -0,0 +1,23 @@ +#pragma once +#include + +namespace UrlUtils { + +/** + * Prepend http:// if no protocol specified (server will redirect to https if needed) + */ +std::string ensureProtocol(const std::string& url); + +/** + * Extract host with protocol from URL (e.g., "http://example.com" from "http://example.com/path") + */ +std::string extractHost(const std::string& url); + +/** + * Build full URL from server URL and path. + * If path starts with /, it's an absolute path from the host root. + * Otherwise, it's relative to the server URL. + */ +std::string buildUrl(const std::string& serverUrl, const std::string& path); + +} // namespace UrlUtils From 0edb2baced0a66b725b5eee02559560c1f447692 Mon Sep 17 00:00:00 2001 From: Justin <41591399+justinluque@users.noreply.github.com> Date: Wed, 7 Jan 2026 03:58:49 -0500 Subject: [PATCH 06/14] feat: remember parent folder index in menu when ascending folders (#260) ## Summary Adds feature to file selection activity for better user navigation when ascending folders. The activity now remembers the index of the parent folder instead of always resetting to the first element. I don't have any means of testing this, so if someone could test it that'd be great Resolves #259 --- src/activities/reader/FileSelectionActivity.cpp | 14 +++++++++++++- src/activities/reader/FileSelectionActivity.h | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index f87cc97c..af877a1d 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -29,7 +29,6 @@ void FileSelectionActivity::taskTrampoline(void* param) { void FileSelectionActivity::loadFiles() { files.clear(); - selectorIndex = 0; auto root = SdMan.open(basepath.c_str()); if (!root || !root.isDirectory()) { @@ -132,9 +131,16 @@ void FileSelectionActivity::loop() { // Short press: go up one directory, or go home if at root if (mappedInput.getHeldTime() < GO_HOME_MS) { if (basepath != "/") { + const std::string oldPath = basepath; + basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); + + auto pos = oldPath.find_last_of('/'); + std::string dirName = oldPath.substr(pos + 1) + "/"; + selectorIndex = findEntry(dirName); + updateRequired = true; } else { onGoHome(); @@ -194,3 +200,9 @@ void FileSelectionActivity::render() const { renderer.displayBuffer(); } + +int FileSelectionActivity::findEntry(const std::string& name) const { + for (size_t i = 0; i < files.size(); i++) + if (files[i] == name) return i; + return 0; +} diff --git a/src/activities/reader/FileSelectionActivity.h b/src/activities/reader/FileSelectionActivity.h index 88e97d0c..9b28214d 100644 --- a/src/activities/reader/FileSelectionActivity.h +++ b/src/activities/reader/FileSelectionActivity.h @@ -23,6 +23,7 @@ class FileSelectionActivity final : public Activity { [[noreturn]] void displayTaskLoop(); void render() const; void loadFiles(); + int findEntry(const std::string& name) const; public: explicit FileSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, From 9c573e6f7f20c28588074d7a8cffcfd7da77ffb1 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 7 Jan 2026 20:02:33 +1100 Subject: [PATCH 07/14] Ensure new settings are at the end of the settings file --- src/CrossPointSettings.cpp | 10 +++++----- src/CrossPointSettings.h | 5 ++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index b2f541e6..cbb75965 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -42,9 +42,9 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, paragraphAlignment); serialization::writePod(outputFile, sleepTimeout); serialization::writePod(outputFile, refreshFrequency); - serialization::writeString(outputFile, std::string(opdsServerUrl)); serialization::writePod(outputFile, screenMargin); serialization::writePod(outputFile, sleepScreenCoverMode); + serialization::writeString(outputFile, std::string(opdsServerUrl)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -97,16 +97,16 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, refreshFrequency); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, screenMargin); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, sleepScreenCoverMode); + if (++settingsRead >= fileSettingsCount) break; { std::string urlStr; serialization::readString(inputFile, urlStr); strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; } - if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, screenMargin); - if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, sleepScreenCoverMode); } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 9584a33d..1f4d645d 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -77,11 +77,10 @@ class CrossPointSettings { uint8_t sleepTimeout = SLEEP_10_MIN; // E-ink refresh frequency (default 15 pages) uint8_t refreshFrequency = REFRESH_15; - // OPDS browser settings - char opdsServerUrl[128] = ""; - // Reader screen margin settings uint8_t screenMargin = 5; + // OPDS browser settings + char opdsServerUrl[128] = ""; ~CrossPointSettings() = default; From 1f956e972b7b867abc28c9368315e11a2dbb95bd Mon Sep 17 00:00:00 2001 From: Stanislav Khromov Date: Wed, 7 Jan 2026 10:14:35 +0100 Subject: [PATCH 08/14] Allow disabling anti-aliasing via a setting (#241) ## Summary Fixes https://github.com/daveallie/crosspoint-reader/issues/233 ## Additional Context By disabling the Text Anti-Aliasing in the settings, you can get faster black-and-white page turns that work exactly like the stock firmware, instead of having an additional flash after rendering (see issue linked above for example). Tested on the X4 and confirmed working. --------- Co-authored-by: Dave Allie --- src/CrossPointSettings.cpp | 5 ++++- src/CrossPointSettings.h | 1 + src/activities/reader/EpubReaderActivity.cpp | 2 +- src/activities/settings/SettingsActivity.cpp | 3 ++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index cbb75965..cd8b56f7 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 16; +constexpr uint8_t SETTINGS_COUNT = 17; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -45,6 +45,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, screenMargin); serialization::writePod(outputFile, sleepScreenCoverMode); serialization::writeString(outputFile, std::string(opdsServerUrl)); + serialization::writePod(outputFile, textAntiAliasing); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -107,6 +108,8 @@ bool CrossPointSettings::loadFromFile() { strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; } + serialization::readPod(inputFile, textAntiAliasing); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 1f4d645d..3a2a3503 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -60,6 +60,7 @@ class CrossPointSettings { uint8_t statusBar = FULL; // Text rendering settings uint8_t extraParagraphSpacing = 1; + uint8_t textAntiAliasing = 1; // Duration of the power button press uint8_t shortPwrBtn = 0; // EPUB reading orientation settings diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 1b5dc777..1233a821 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -388,7 +388,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // grayscale rendering // TODO: Only do this if font supports it - { + if (SETTINGS.textAntiAliasing) { renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 32fec596..702db172 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -13,13 +13,14 @@ // Define the static settings list namespace { -constexpr int settingsCount = 17; +constexpr int settingsCount = 18; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), + SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn), SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), From 0cc2c64df2f32b541be043c858e567b55a1f6cb1 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Wed, 7 Jan 2026 04:28:32 -0500 Subject: [PATCH 09/14] Update User Guide to reflect release 0.12.0 (#269) ## Summary * Update the User Guide per current firmware release. * Make some formatting improvements including for readability and consistency. --- USER_GUIDE.md | 66 +++++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 26ff1075..1735ff82 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1,17 +1,16 @@ # CrossPoint User Guide -Welcome to the **CrossPoint** firmware. This guide outlines the hardware controls, navigation, and reading features of -the device. +Welcome to the **CrossPoint** firmware. This guide outlines the hardware controls, navigation, and reading features of the device. ## 1. Hardware Overview The device utilises the standard buttons on the Xtink X4 (in the same layout as the manufacturer firmware, by default): ### Button Layout -| Location | Buttons | -|-----------------|--------------------------------------------| -| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** | -| **Right Side** | **Power**, **Volume Up**, **Volume Down** | +| Location | Buttons | +| --------------- | ---------------------------------------------------- | +| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** | +| **Right Side** | **Power**, **Volume Up**, **Volume Down**, **Reset** | Button layout can be customized in **[Settings](#35-settings)**. @@ -21,8 +20,9 @@ Button layout can be customized in **[Settings](#35-settings)**. ### Power On / Off -To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure -the power button to trigger on a short press instead of a long one. +To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure the power button to trigger on a short press instead of a long one. + +To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then hold the Power button for a few seconds. ### First Launch @@ -37,15 +37,13 @@ Upon turning the device on for the first time, you will be placed on the **[Home ### 3.1 Home Screen -The Home Screen is the main entry point to the firmware. From here you can navigate to **[Reading Mode](#4-reading-mode)** with the most recently read book, **[Book Selection](#32-book-selection)**, -**[Settings](#35-settings)**, or the **[File Upload](#34-file-upload-screen)** screen. +The Home Screen is the main entry point to the firmware. From here you can navigate to **[Reading Mode](#4-reading-mode)** with the most recently read book, **[Book Selection](#32-book-selection)**, **[Settings](#35-settings)**, or the **[File Upload](#34-file-upload-screen)** screen. ### 3.2 Book Selection The Book Selection acts as a folder and file browser. -* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up - and down through folders and books. +* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down. * **Open Selection:** Press **Confirm** to open a folder or read a selected book. ### 3.3 Reading Mode @@ -54,42 +52,43 @@ See [Reading Mode](#4-reading-mode) below for more information. ### 3.4 File Upload Screen -The File Upload screen allows you to upload new e-books to the device. When you enter the screen, you'll be prompted with -a WiFi selection dialog and then your X4 will start hosting a web server. +The File Upload screen allows you to upload new e-books to the device. When you enter the screen, you'll be prompted with a WiFi selection dialog and then your X4 will start hosting a web server. See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files. ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: -- **Sleep Screen**: Which sleep screen to display when the device sleeps, options are: +- **Sleep Screen**: Which sleep screen to display when the device sleeps: - "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 [Sleep Screen](#36-sleep-screen) below for more information - "Cover" - The book cover image (Note: this is experimental and may not work as expected) -- **Status Bar**: Configure the status bar displayed while reading, options are: +- **Status Bar**: Configure the status bar displayed while reading: - "None" - No status bar - "No Progress" - Show status bar without reading progress - "Full" - Show status bar with reading progress -- **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. +- **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-line indentation. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. -- **Reading Orientation**: Set the screen orientation for reading, options are: +- **Reading Orientation**: Set the screen orientation for reading: - "Portrait" (default) - Standard portrait orientation - "Landscape CW" - Landscape, rotated clockwise - "Inverted" - Portrait, upside down - "Landscape CCW" - Landscape, rotated counter-clockwise -- **Front Button Layout**: Configure the order of the bottom edge buttons, options are: - - "Bck, Cnfrm, Lft, Rght" (default) - Back, Confirm, Left, Right - - "Lft, Rght, Bck, Cnfrm" - Left, Right, Back, Confirm - - "Lft, Bck, Cnfrm, Rght" - Left, Back, Confirm, Right -- **Side Button Layout**: Swap the order of the volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. -- **Reader Font Family**: Choose the font used for reading, options are: +- **Front Button Layout**: Configure the order of the bottom edge buttons: + - Back, Confirm, Left, Right (default) + - Left, Right, Back, Confirm + - Left, Back, Confirm, Right +- **Side Button Layout**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. +- **Reader Font Family**: Choose the font used for reading: - "Bookerly" (default) - Amazon's reading font - "Noto Sans" - Google's sans-serif font - "Open Dyslexic" - Font designed for readers with dyslexia -- **Reader Font Size**: Adjust the text size for reading, options are "Small", "Medium", "Large", or "X Large". -- **Reader Line Spacing**: Adjust the spacing between lines, options are "Tight", "Normal", or "Wide". +- **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium", "Large", or "X Large". +- **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal", or "Wide". +- **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right". +- **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep. +- **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting. - **Check for updates**: Check for firmware updates over WiFi. ### 3.6 Sleep Screen @@ -97,9 +96,7 @@ 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.bmp` 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.bmp` 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. @@ -117,17 +114,19 @@ Once you have opened a book, the button layout changes to facilitate reading. ### Page Turning | Action | Buttons | -|-------------------|--------------------------------------| +| ----------------- | ------------------------------------ | | **Previous Page** | Press **Left** _or_ **Volume Up** | | **Next Page** | Press **Right** _or_ **Volume Down** | +The role of the volume (side) buttons can be swapped in **[Settings](#35-settings)**. + ### Chapter Navigation * **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release. * **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. ### System Navigation * **Return to Book Selection:** Press **Back** to close the book and return to the **[Book Selection](#32-book-selection)** screen. -* **Return to Home:** Press and hold **Back** to close the book and return to the **[Home](#31-home-screen)** screen. +* **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen. * **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)**. --- @@ -144,7 +143,6 @@ Accessible by pressing **Confirm** while inside a book. ## 6. Current Limitations & Roadmap -Please note that this firmware is currently in active development. The following features are **not yet supported** but -are planned for future updates: +Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates: * **Images:** Embedded images in e-books will not render. From 46fa186b8279dea19128693614e03d9e74374f75 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 7 Jan 2026 20:07:23 +1000 Subject: [PATCH 10/14] Make extension checks case-insensitive (#273) ## Summary * Implement new `StringUtils::checkFileExtension` which does case insensitive checking * Move all checks over to this --- src/activities/boot_sleep/SleepActivity.cpp | 23 +++++-------------- src/activities/home/HomeActivity.cpp | 9 ++++---- .../network/CalibreWirelessActivity.cpp | 2 +- .../reader/FileSelectionActivity.cpp | 6 ++--- src/activities/reader/ReaderActivity.cpp | 10 ++------ src/util/StringUtils.cpp | 16 +++++++++++++ src/util/StringUtils.h | 6 +++++ 7 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 8d8fd791..72d74d70 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -9,20 +9,7 @@ #include "CrossPointState.h" #include "fontIds.h" #include "images/CrossLarge.h" - -namespace { -// Check if path has XTC extension (.xtc or .xtch) -bool isXtcFile(const std::string& path) { - if (path.length() < 4) return false; - std::string ext4 = path.substr(path.length() - 4); - if (ext4 == ".xtc") return true; - if (path.length() >= 5) { - std::string ext5 = path.substr(path.length() - 5); - if (ext5 == ".xtch") return true; - } - return false; -} -} // namespace +#include "util/StringUtils.h" void SleepActivity::onEnter() { Activity::onEnter(); @@ -213,8 +200,8 @@ void SleepActivity::renderCoverSleepScreen() const { std::string coverBmpPath; - // Check if the current book is XTC or EPUB - if (isXtcFile(APP_STATE.openEpubPath)) { + if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || + StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) { // Handle XTC file Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastXtc.load()) { @@ -228,7 +215,7 @@ void SleepActivity::renderCoverSleepScreen() const { } coverBmpPath = lastXtc.getCoverBmpPath(); - } else { + } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastEpub.load()) { @@ -242,6 +229,8 @@ void SleepActivity::renderCoverSleepScreen() const { } coverBmpPath = lastEpub.getCoverBmpPath(); + } else { + return renderDefaultSleepScreen(); } FsFile file; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index b91e7c5a..a774780e 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -12,6 +12,7 @@ #include "MappedInputManager.h" #include "ScreenComponents.h" #include "fontIds.h" +#include "util/StringUtils.h" void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); @@ -44,10 +45,8 @@ void HomeActivity::onEnter() { lastBookTitle = lastBookTitle.substr(lastSlash + 1); } - const std::string ext4 = lastBookTitle.length() >= 4 ? lastBookTitle.substr(lastBookTitle.length() - 4) : ""; - const std::string ext5 = lastBookTitle.length() >= 5 ? lastBookTitle.substr(lastBookTitle.length() - 5) : ""; // If epub, try to load the metadata for title/author - if (ext5 == ".epub") { + if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); epub.load(false); if (!epub.getTitle().empty()) { @@ -56,9 +55,9 @@ void HomeActivity::onEnter() { if (!epub.getAuthor().empty()) { lastBookAuthor = std::string(epub.getAuthor()); } - } else if (ext5 == ".xtch") { + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { lastBookTitle.resize(lastBookTitle.length() - 5); - } else if (ext4 == ".xtc") { + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { lastBookTitle.resize(lastBookTitle.length() - 4); } } diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index 3ac76cb0..0ad9094a 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -582,7 +582,7 @@ void CalibreWirelessActivity::handleSendBook(const std::string& data) { // Sanitize and create full path currentFilename = "/" + StringUtils::sanitizeFilename(filename); - if (currentFilename.find(".epub") == std::string::npos) { + if (!StringUtils::checkFileExtension(currentFilename, ".epub")) { currentFilename += ".epub"; } currentFileSize = length; diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index af877a1d..57a288c0 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -5,6 +5,7 @@ #include "MappedInputManager.h" #include "fontIds.h" +#include "util/StringUtils.h" namespace { constexpr int PAGE_ITEMS = 23; @@ -50,9 +51,8 @@ void FileSelectionActivity::loadFiles() { files.emplace_back(std::string(name) + "/"); } else { auto filename = std::string(name); - std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : ""; - std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : ""; - if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc") { + if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") || + StringUtils::checkFileExtension(filename, ".xtc")) { files.emplace_back(filename); } } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 1829218a..cb123e1c 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -6,6 +6,7 @@ #include "Xtc.h" #include "XtcReaderActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "util/StringUtils.h" std::string ReaderActivity::extractFolderPath(const std::string& filePath) { const auto lastSlash = filePath.find_last_of('/'); @@ -16,14 +17,7 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) { } bool ReaderActivity::isXtcFile(const std::string& path) { - if (path.length() < 4) return false; - std::string ext4 = path.substr(path.length() - 4); - if (ext4 == ".xtc") return true; - if (path.length() >= 5) { - std::string ext5 = path.substr(path.length() - 5); - if (ext5 == ".xtch") return true; - } - return false; + return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch"); } std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp index 21617210..e296d378 100644 --- a/src/util/StringUtils.cpp +++ b/src/util/StringUtils.cpp @@ -1,5 +1,7 @@ #include "StringUtils.h" +#include + namespace StringUtils { std::string sanitizeFilename(const std::string& name, size_t maxLength) { @@ -33,4 +35,18 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength) { return result.empty() ? "book" : result; } +bool checkFileExtension(const std::string& fileName, const char* extension) { + if (fileName.length() < strlen(extension)) { + return false; + } + + const std::string fileExt = fileName.substr(fileName.length() - strlen(extension)); + for (size_t i = 0; i < fileExt.length(); i++) { + if (tolower(fileExt[i]) != tolower(extension[i])) { + return false; + } + } + return true; +} + } // namespace StringUtils diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h index 27f826a2..f846cf17 100644 --- a/src/util/StringUtils.h +++ b/src/util/StringUtils.h @@ -1,4 +1,5 @@ #pragma once + #include namespace StringUtils { @@ -10,4 +11,9 @@ namespace StringUtils { */ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); +/** + * Check if the given filename ends with the specified extension (case-insensitive). + */ +bool checkFileExtension(const std::string& fileName, const char* extension); + } // namespace StringUtils From 2b12a65011293f4cf8b75094027664f2fe0a314b Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 7 Jan 2026 22:08:43 +1000 Subject: [PATCH 11/14] Remove HTML entity parsing (#274) ## Summary * Remove HTML entity parsing * This has been completely useless since the introduction of expat * expat tries to parse all entities in the document, but only knows of HTML ones * Parsing will never end with HTML entities in the text, so the additional step to parse them that we had went completely unused * We should figure out the best way to parse that content in the future, but for now remove that module as it generates a lot of heap allocations with its map and strings --- lib/Epub/Epub/htmlEntities.cpp | 163 ------------------ lib/Epub/Epub/htmlEntities.h | 7 - .../Epub/parsers/ChapterHtmlSlimParser.cpp | 7 +- 3 files changed, 3 insertions(+), 174 deletions(-) delete mode 100644 lib/Epub/Epub/htmlEntities.cpp delete mode 100644 lib/Epub/Epub/htmlEntities.h diff --git a/lib/Epub/Epub/htmlEntities.cpp b/lib/Epub/Epub/htmlEntities.cpp deleted file mode 100644 index f44a1584..00000000 --- a/lib/Epub/Epub/htmlEntities.cpp +++ /dev/null @@ -1,163 +0,0 @@ -// from -// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp - -#include "htmlEntities.h" - -#include -#include - -const int MAX_ENTITY_LENGTH = 10; - -// Use book: entities_ww2.epub to test this (Page 7: Entities parser test) -// Note the supported keys are only in lowercase -// Store the mappings in a unordered hash map -static std::unordered_map entity_lookup( - {{""", "\""}, {"⁄", "⁄"}, {"&", "&"}, {"<", "<"}, {">", ">"}, - {"À", "À"}, {"Á", "Á"}, {"Â", "Â"}, {"Ã", "Ã"}, {"Ä", "Ä"}, - {"Å", "Å"}, {"Æ", "Æ"}, {"Ç", "Ç"}, {"È", "È"}, {"É", "É"}, - {"Ê", "Ê"}, {"Ë", "Ë"}, {"Ì", "Ì"}, {"Í", "Í"}, {"Î", "Î"}, - {"Ï", "Ï"}, {"Ð", "Ð"}, {"Ñ", "Ñ"}, {"Ò", "Ò"}, {"Ó", "Ó"}, - {"Ô", "Ô"}, {"Õ", "Õ"}, {"Ö", "Ö"}, {"Ø", "Ø"}, {"Ù", "Ù"}, - {"Ú", "Ú"}, {"Û", "Û"}, {"Ü", "Ü"}, {"Ý", "Ý"}, {"Þ", "Þ"}, - {"ß", "ß"}, {"à", "à"}, {"á", "á"}, {"â", "â"}, {"ã", "ã"}, - {"ä", "ä"}, {"å", "å"}, {"æ", "æ"}, {"ç", "ç"}, {"è", "è"}, - {"é", "é"}, {"ê", "ê"}, {"ë", "ë"}, {"ì", "ì"}, {"í", "í"}, - {"î", "î"}, {"ï", "ï"}, {"ð", "ð"}, {"ñ", "ñ"}, {"ò", "ò"}, - {"ó", "ó"}, {"ô", "ô"}, {"õ", "õ"}, {"ö", "ö"}, {"ø", "ø"}, - {"ù", "ù"}, {"ú", "ú"}, {"û", "û"}, {"ü", "ü"}, {"ý", "ý"}, - {"þ", "þ"}, {"ÿ", "ÿ"}, {" ", " "}, {"¡", "¡"}, {"¢", "¢"}, - {"£", "£"}, {"¤", "¤"}, {"¥", "¥"}, {"¦", "¦"}, {"§", "§"}, - {"¨", "¨"}, {"©", "©"}, {"ª", "ª"}, {"«", "«"}, {"¬", "¬"}, - {"­", "­"}, {"®", "®"}, {"¯", "¯"}, {"°", "°"}, {"±", "±"}, - {"²", "²"}, {"³", "³"}, {"´", "´"}, {"µ", "µ"}, {"¶", "¶"}, - {"¸", "¸"}, {"¹", "¹"}, {"º", "º"}, {"»", "»"}, {"¼", "¼"}, - {"½", "½"}, {"¾", "¾"}, {"¿", "¿"}, {"×", "×"}, {"÷", "÷"}, - {"∀", "∀"}, {"∂", "∂"}, {"∃", "∃"}, {"∅", "∅"}, {"∇", "∇"}, - {"∈", "∈"}, {"∉", "∉"}, {"∋", "∋"}, {"∏", "∏"}, {"∑", "∑"}, - {"−", "−"}, {"∗", "∗"}, {"√", "√"}, {"∝", "∝"}, {"∞", "∞"}, - {"∠", "∠"}, {"∧", "∧"}, {"∨", "∨"}, {"∩", "∩"}, {"∪", "∪"}, - {"∫", "∫"}, {"∴", "∴"}, {"∼", "∼"}, {"≅", "≅"}, {"≈", "≈"}, - {"≠", "≠"}, {"≡", "≡"}, {"≤", "≤"}, {"≥", "≥"}, {"⊂", "⊂"}, - {"⊃", "⊃"}, {"⊄", "⊄"}, {"⊆", "⊆"}, {"⊇", "⊇"}, {"⊕", "⊕"}, - {"⊗", "⊗"}, {"⊥", "⊥"}, {"⋅", "⋅"}, {"Α", "Α"}, {"Β", "Β"}, - {"Γ", "Γ"}, {"Δ", "Δ"}, {"Ε", "Ε"}, {"Ζ", "Ζ"}, {"Η", "Η"}, - {"Θ", "Θ"}, {"Ι", "Ι"}, {"Κ", "Κ"}, {"Λ", "Λ"}, {"Μ", "Μ"}, - {"Ν", "Ν"}, {"Ξ", "Ξ"}, {"Ο", "Ο"}, {"Π", "Π"}, {"Ρ", "Ρ"}, - {"Σ", "Σ"}, {"Τ", "Τ"}, {"Υ", "Υ"}, {"Φ", "Φ"}, {"Χ", "Χ"}, - {"Ψ", "Ψ"}, {"Ω", "Ω"}, {"α", "α"}, {"β", "β"}, {"γ", "γ"}, - {"δ", "δ"}, {"ε", "ε"}, {"ζ", "ζ"}, {"η", "η"}, {"θ", "θ"}, - {"ι", "ι"}, {"κ", "κ"}, {"λ", "λ"}, {"μ", "μ"}, {"ν", "ν"}, - {"ξ", "ξ"}, {"ο", "ο"}, {"π", "π"}, {"ρ", "ρ"}, {"ς", "ς"}, - {"σ", "σ"}, {"τ", "τ"}, {"υ", "υ"}, {"φ", "φ"}, {"χ", "χ"}, - {"ψ", "ψ"}, {"ω", "ω"}, {"ϑ", "ϑ"}, {"ϒ", "ϒ"}, {"ϖ", "ϖ"}, - {"Œ", "Œ"}, {"œ", "œ"}, {"Š", "Š"}, {"š", "š"}, {"Ÿ", "Ÿ"}, - {"ƒ", "ƒ"}, {"ˆ", "ˆ"}, {"˜", "˜"}, {" ", ""}, {" ", ""}, - {" ", ""}, {"‌", "‌"}, {"‍", "‍"}, {"‎", "‎"}, {"‏", "‏"}, - {"–", "–"}, {"—", "—"}, {"‘", "‘"}, {"’", "’"}, {"‚", "‚"}, - {"“", "“"}, {"”", "”"}, {"„", "„"}, {"†", "†"}, {"‡", "‡"}, - {"•", "•"}, {"…", "…"}, {"‰", "‰"}, {"′", "′"}, {"″", "″"}, - {"‹", "‹"}, {"›", "›"}, {"‾", "‾"}, {"€", "€"}, {"™", "™"}, - {"←", "←"}, {"↑", "↑"}, {"→", "→"}, {"↓", "↓"}, {"↔", "↔"}, - {"↵", "↵"}, {"⌈", "⌈"}, {"⌉", "⌉"}, {"⌊", "⌊"}, {"⌋", "⌋"}, - {"◊", "◊"}, {"♠", "♠"}, {"♣", "♣"}, {"♥", "♥"}, {"♦", "♦"}}); - -// converts from a unicode code point to the utf8 equivalent -void convert_to_utf8(const int code, std::string& res) { - // convert to a utf8 sequence - if (code < 0x80) { - res += static_cast(code); - } else if (code < 0x800) { - res += static_cast(0xc0 | (code >> 6)); - res += static_cast(0x80 | (code & 0x3f)); - } else if (code < 0x10000) { - res += static_cast(0xe0 | (code >> 12)); - res += static_cast(0x80 | ((code >> 6) & 0x3f)); - res += static_cast(0x80 | (code & 0x3f)); - } else if (code < 0x200000) { - res += static_cast(0xf0 | (code >> 18)); - res += static_cast(0x80 | ((code >> 12) & 0x3f)); - res += static_cast(0x80 | ((code >> 6) & 0x3f)); - res += static_cast(0x80 | (code & 0x3f)); - } else if (code < 0x4000000) { - res += static_cast(0xf8 | (code >> 24)); - res += static_cast(0x80 | ((code >> 18) & 0x3f)); - res += static_cast(0x80 | ((code >> 12) & 0x3f)); - res += static_cast(0x80 | ((code >> 6) & 0x3f)); - res += static_cast(0x80 | (code & 0x3f)); - } else if (code < 0x80000000) { - res += static_cast(0xfc | (code >> 30)); - res += static_cast(0x80 | ((code >> 24) & 0x3f)); - res += static_cast(0x80 | ((code >> 18) & 0x3f)); - res += static_cast(0x80 | ((code >> 12) & 0x3f)); - res += static_cast(0x80 | ((code >> 6) & 0x3f)); - } -} - -// handles numeric entities - e.g. Ӓ or ሴ -bool process_numeric_entity(const std::string& entity, std::string& res) { - int code = 0; - // is it hex? - if (entity[2] == 'x' || entity[2] == 'X') { - // parse the hex code - code = strtol(entity.substr(3, entity.size() - 3).c_str(), nullptr, 16); - } else { - code = strtol(entity.substr(2, entity.size() - 3).c_str(), nullptr, 10); - } - if (code != 0) { - // special handling for nbsp - if (code == 0xA0) { - res += " "; - } else { - convert_to_utf8(code, res); - } - return true; - } - return false; -} - -// handles named entities - e.g. & -bool process_string_entity(const std::string& entity, std::string& res) { - // it's a named entity - find it in the lookup table - // find it in the map - const auto it = entity_lookup.find(entity); - if (it != entity_lookup.end()) { - res += it->second; - return true; - } - return false; -} - -// replace all the entities in the string -std::string replaceHtmlEntities(const char* text) { - std::string res; - res.reserve(strlen(text)); - for (int i = 0; i < strlen(text); ++i) { - bool flag = false; - // do we have a potential entity? - if (text[i] == '&') { - // find the end of the entity - int j = i + 1; - while (j < strlen(text) && text[j] != ';' && j - i < MAX_ENTITY_LENGTH) { - j++; - } - if (j - i > 2) { - char entity[j - i + 1]; - strncpy(entity, text + i, j - i); - // is it a numeric code? - if (entity[1] == '#') { - flag = process_numeric_entity(entity, res); - } else { - flag = process_string_entity(entity, res); - } - // skip past the entity if we successfully decoded it - if (flag) { - i = j; - } - } - } - if (!flag) { - res += text[i]; - } - } - return res; -} diff --git a/lib/Epub/Epub/htmlEntities.h b/lib/Epub/Epub/htmlEntities.h deleted file mode 100644 index 109f717a..00000000 --- a/lib/Epub/Epub/htmlEntities.h +++ /dev/null @@ -1,7 +0,0 @@ -// from -// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp - -#pragma once -#include - -std::string replaceHtmlEntities(const char* text); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index e5eb4d10..b96d28f8 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -6,7 +6,6 @@ #include #include "../Page.h" -#include "../htmlEntities.h" const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); @@ -130,7 +129,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char // Currently looking at whitespace, if there's anything in the partWordBuffer, flush it if (self->partWordBufferIndex > 0) { self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); + self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); self->partWordBufferIndex = 0; } // Skip the whitespace char @@ -155,7 +154,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char // If we're about to run out of space, then cut the word off and start a new one if (self->partWordBufferIndex >= MAX_WORD_SIZE) { self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); + self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); self->partWordBufferIndex = 0; } @@ -197,7 +196,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n } self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); + self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); self->partWordBufferIndex = 0; } } From 0bae3bbf64999ffb8e255bd06ceb6f838579c740 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Wed, 7 Jan 2026 22:43:19 +1000 Subject: [PATCH 12/14] Support up to 500 character file names (#275) ## Summary - Support up to 500 character file names ## Additional Context - Fixes #265 --- src/activities/boot_sleep/SleepActivity.cpp | 2 +- src/activities/reader/FileSelectionActivity.cpp | 11 ++++++----- src/activities/reader/FileSelectionActivity.h | 5 +++-- src/network/CrossPointWebServer.cpp | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 72d74d70..43e8e60b 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -49,7 +49,7 @@ void SleepActivity::renderCustomSleepScreen() const { auto dir = SdMan.open("/sleep"); if (dir && dir.isDirectory()) { std::vector files; - char name[128]; + char name[500]; // collect all valid BMP files for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { if (file.isDirectory()) { diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 57a288c0..33c2c3e4 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -39,7 +39,7 @@ void FileSelectionActivity::loadFiles() { root.rewindDirectory(); - char name[128]; + char name[500]; for (auto file = root.openNextFile(); file; file = root.openNextFile()) { file.getName(name, sizeof(name)); if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) { @@ -123,6 +123,7 @@ void FileSelectionActivity::loop() { if (files[selectorIndex].back() == '/') { basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1); loadFiles(); + selectorIndex = 0; updateRequired = true; } else { onSelect(basepath + files[selectorIndex]); @@ -137,8 +138,8 @@ void FileSelectionActivity::loop() { if (basepath.empty()) basepath = "/"; loadFiles(); - auto pos = oldPath.find_last_of('/'); - std::string dirName = oldPath.substr(pos + 1) + "/"; + const auto pos = oldPath.find_last_of('/'); + const std::string dirName = oldPath.substr(pos + 1) + "/"; selectorIndex = findEntry(dirName); updateRequired = true; @@ -193,7 +194,7 @@ void FileSelectionActivity::render() const { 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++) { + for (size_t i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), renderer.getScreenWidth() - 40); renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex); } @@ -201,7 +202,7 @@ void FileSelectionActivity::render() const { renderer.displayBuffer(); } -int FileSelectionActivity::findEntry(const std::string& name) const { +size_t FileSelectionActivity::findEntry(const std::string& name) const { for (size_t i = 0; i < files.size(); i++) if (files[i] == name) return i; return 0; diff --git a/src/activities/reader/FileSelectionActivity.h b/src/activities/reader/FileSelectionActivity.h index 9b28214d..3c71968a 100644 --- a/src/activities/reader/FileSelectionActivity.h +++ b/src/activities/reader/FileSelectionActivity.h @@ -14,7 +14,7 @@ class FileSelectionActivity final : public Activity { SemaphoreHandle_t renderingMutex = nullptr; std::string basepath = "/"; std::vector files; - int selectorIndex = 0; + size_t selectorIndex = 0; bool updateRequired = false; const std::function onSelect; const std::function onGoHome; @@ -23,7 +23,8 @@ class FileSelectionActivity final : public Activity { [[noreturn]] void displayTaskLoop(); void render() const; void loadFiles(); - int findEntry(const std::string& name) const; + + size_t findEntry(const std::string& name) const; public: explicit FileSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 3a26a736..8703c2ae 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -194,7 +194,7 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function Date: Thu, 8 Jan 2026 22:57:50 +0100 Subject: [PATCH 13/14] Move battery status on home screen to top left (#253) So it doesn't look so lost on a row on its own. Also sligthly (1px) moved symbol in on reader view. --- src/activities/home/HomeActivity.cpp | 3 ++- src/activities/reader/EpubReaderActivity.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index a774780e..f34283d4 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -332,7 +332,8 @@ void HomeActivity::render() const { const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - ScreenComponents::drawBattery(renderer, 20, pageHeight - 70); + const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, "100 %"); + ScreenComponents::drawBattery(renderer, batteryX, 10); renderer.displayBuffer(); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 1233a821..ac0ffd51 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -437,7 +437,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY); } if (showChapterTitle) { From d4ae108d9b8f5c81cb77c8441ef84441bd1c1b18 Mon Sep 17 00:00:00 2001 From: Luke Stein <44452336+lukestein@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:58:58 -0500 Subject: [PATCH 14/14] Docs: Add instructions for file management via curl (#282) Per a [reddit thread](https://www.reddit.com/r/xteinkereader/comments/1q0fk9r/if_using_crosspoint_firmware_you_can_upload_using/), the file manager can be accessed via curl. Given file upload or deletion via curl may be useful for advanced users, I've added instructions. --- USER_GUIDE.md | 3 +++ docs/webserver.md | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1735ff82..1af7e129 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -56,6 +56,9 @@ The File Upload screen allows you to upload new e-books to the device. When you See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files. +> [!TIP] +> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details. + ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: diff --git a/docs/webserver.md b/docs/webserver.md index 2c96b8eb..2285a927 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -170,6 +170,40 @@ This is useful for organizing your ebooks by genre, author, or series. --- +## Command Line File Management + +For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode. + +### Uploading a File +To upload a file to the root directory, use the following command: +```bash +curl -F "file=@book.epub" "http://crosspoint.local/upload?path=/" +``` + +* **`-F "file=@filename"`**: Points to the local file on your computer. +* **`path=/`**: The destination folder on the device SD card. + +### Deleting a File + +To delete a specific file, provide the full path on the SD card: + +```bash +curl -F "path=/folder/file.epub" "http://crosspoint.local/delete" +``` + +### Advanced Flags + +For more reliable transfers of large EPUB files, consider adding these flags: + +* `-#`: Shows a simple progress bar. +* `--connect-timeout 30`: Limits how long curl waits to establish a connection (in seconds). +* `--max-time 300`: Sets a maximum duration for the entire transfer (5 minutes). + +> [!NOTE] +> These examples use `crosspoint.local`. If your network does not support mDNS or the address does not resolve, replace it with the specific **IP Address** displayed on your device screen (e.g., `http://192.168.1.102/`). + +--- + ## Troubleshooting ### Cannot See the Device on the Network