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. 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 81c9fa60..98cc0120 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; } } 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 27a33246..e579625c 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include "fontIds.h" // Initialize the static instance @@ -11,8 +13,8 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; -// Increment this when adding new persisted settings fields (number of settings - 2 for version and count) -constexpr uint8_t SETTINGS_COUNT = 16; +// Increment this when adding new persisted settings fields +constexpr uint8_t SETTINGS_COUNT = 18; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -42,6 +44,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, refreshFrequency); serialization::writePod(outputFile, screenMargin); serialization::writePod(outputFile, sleepScreenCoverMode); + serialization::writeString(outputFile, std::string(opdsServerUrl)); + serialization::writePod(outputFile, textAntiAliasing); serialization::writePod(outputFile, hyphenationEnabled); outputFile.close(); @@ -99,6 +103,14 @@ bool CrossPointSettings::loadFromFile() { 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'; + } + serialization::readPod(inputFile, textAntiAliasing); + if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hyphenationEnabled); if (++settingsRead >= fileSettingsCount) break; } while (false); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 812c788e..6d362c80 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 @@ -81,6 +82,8 @@ class CrossPointSettings { // Reader screen margin settings uint8_t screenMargin = 5; + // OPDS browser settings + char opdsServerUrl[128] = ""; ~CrossPointSettings() = default; @@ -96,7 +99,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/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 8d8fd791..43e8e60b 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(); @@ -62,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()) { @@ -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/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..a774780e 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,17 +4,27 @@ #include #include +#include +#include + +#include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "ScreenComponents.h" #include "fontIds.h" +#include "util/StringUtils.h" void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(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 +34,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; @@ -32,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()) { @@ -44,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); } } @@ -86,26 +97,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 +286,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 +319,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..0ad9094a --- /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 (!StringUtils::checkFileExtension(currentFilename, ".epub")) { + 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/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index a3a0d60b..54fa95fe 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/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index f87cc97c..33c2c3e4 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; @@ -29,7 +30,6 @@ void FileSelectionActivity::taskTrampoline(void* param) { void FileSelectionActivity::loadFiles() { files.clear(); - selectorIndex = 0; auto root = SdMan.open(basepath.c_str()); if (!root || !root.isDirectory()) { @@ -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) { @@ -51,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); } } @@ -124,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]); @@ -132,9 +132,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(); + + const auto pos = oldPath.find_last_of('/'); + const std::string dirName = oldPath.substr(pos + 1) + "/"; + selectorIndex = findEntry(dirName); + updateRequired = true; } else { onGoHome(); @@ -187,10 +194,16 @@ 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); } renderer.displayBuffer(); } + +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 88e97d0c..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; @@ -24,6 +24,8 @@ class FileSelectionActivity final : public Activity { void render() const; void loadFiles(); + size_t findEntry(const std::string& name) const; + public: explicit FileSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onSelect, 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/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 386c186a..d0607816 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,13 +13,14 @@ // Define the static settings list namespace { -constexpr int settingsCount = 17; +constexpr int settingsCount = 19; 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"}), @@ -36,6 +40,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 @@ -133,7 +138,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/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 +#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..e296d378 --- /dev/null +++ b/src/util/StringUtils.cpp @@ -0,0 +1,52 @@ +#include "StringUtils.h" + +#include + +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; +} + +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 new file mode 100644 index 00000000..f846cf17 --- /dev/null +++ b/src/util/StringUtils.h @@ -0,0 +1,19 @@ +#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); + +/** + * Check if the given filename ends with the specified extension (case-insensitive). + */ +bool checkFileExtension(const std::string& fileName, const char* extension); + +} // 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