From b792b792bf5bb5612dbd15575aa2b75f9cf5e6c0 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 7 Jan 2026 03:58:37 -0500 Subject: [PATCH 1/3] 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 0000000..da4042f --- /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 0000000..acb4b69 --- /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 572bac4..b2f541e 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 5394c4e..9584a33 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 2900f3e..3c359c0 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 2598a3e..d938bee 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 0000000..b9dbac8 --- /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 0000000..efda294 --- /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 11107fd..b91e7c5 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 b6c9767..84cb5bf 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 0000000..3ac76cb --- /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 0000000..ae2b176 --- /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 0000000..4f614ff --- /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 0000000..77b9218 --- /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 469c7bb..32fec59 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 e81448b..5261df3 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 0000000..017c687 --- /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 0000000..e6e0f16 --- /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 0000000..2161721 --- /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 0000000..27f826a --- /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 0000000..0eeeae3 --- /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 0000000..d88ca13 --- /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 2/3] 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 f87cc97..af877a1 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 88e97d0..9b28214 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 3/3] 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 b2f541e..cbb7596 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 9584a33..1f4d645 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;