diff --git a/.gitignore b/.gitignore index 0cc30a26..754c9f68 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ .vscode lib/EpdFont/fontsrc *.generated.h +.vs build **/__pycache__/ \ No newline at end of file diff --git a/README.md b/README.md index d59df835..633ae3b8 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ This project is **not affiliated with Xteink**; it's built as a community projec - [ ] Full UTF support - [x] Screen rotation -See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint. +Multi-language support: Read EPUBs in various languages, including English, Spanish, French, German, Italian, Portuguese, Russian, Ukrainian, Polish, Swedish, Norwegian, [and more](./USER_GUIDE.md#supported-languages). + +See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint. ## Installing diff --git a/USER_GUIDE.md b/USER_GUIDE.md index d670abb7..bdc0f036 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -81,6 +81,18 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con > [!TIP] > Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details. +### 3.4.1 Calibre Wireless Transfers + +CrossPoint supports sending books from Calibre using the CrossPoint Reader device plugin. + +1. Install the plugin in Calibre: + - Head to https://github.com/crosspoint-reader/calibre-plugins/releases to download the latest version of the crosspoint_reader plugin. + - Download the zip file. + - Open Calibre → Preferences → Plugins → Load plugin from file → Select the zip file. +2. On the device: File Transfer → Connect to Calibre → Join a network. +3. Make sure your computer is on the same WiFi network. +4. In Calibre, click "Send to device" to transfer books. + ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: @@ -116,6 +128,7 @@ The Settings screen allows you to configure the device's behavior. There are a f - Back, Confirm, Left, Right (default) - Left, Right, Back, Confirm - Left, Back, Confirm, Right + - Back, Confirm, Right, Left - **Side Button Layout (reader)**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. - **Long-press Chapter Skip**: Set whether long-pressing page turn buttons skip to the next/previous chapter. - "Chapter Skip" (default) - Long-pressing skips to next/previous chapter @@ -131,7 +144,7 @@ The Settings screen allows you to configure the device's behavior. There are a f - **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. -- **Calibre Settings**: Set up integration for accessing a Calibre web library or connecting to Calibre as a wireless device. +- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication. - **Check for updates**: Check for firmware updates over WiFi. ### 3.6 Sleep Screen @@ -177,6 +190,15 @@ This feature can be disabled in **[Settings](#35-settings)** to help avoid chang * **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)**. +### Supported Languages + +CrossPoint renders text using the following Unicode character blocks, enabling support for a wide range of languages: + +* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others. +* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others. + +What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi. + --- ## 5. Chapter Selection Screen diff --git a/lib/EpdFont/builtinFonts/bookerly_12_bold.h b/lib/EpdFont/builtinFonts/bookerly_12_bold.h index c20b5742..2dd52ca0 100644 --- a/lib/EpdFont/builtinFonts/bookerly_12_bold.h +++ b/lib/EpdFont/builtinFonts/bookerly_12_bold.h @@ -3,6 +3,7 @@ * name: bookerly_12_bold * size: 12 * mode: 2-bit + * Command used: fontconvert.py bookerly_12_bold 12 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h b/lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h index 6e914f48..32b7510b 100644 --- a/lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h +++ b/lib/EpdFont/builtinFonts/bookerly_12_bolditalic.h @@ -3,6 +3,7 @@ * name: bookerly_12_bolditalic * size: 12 * mode: 2-bit + * Command used: fontconvert.py bookerly_12_bolditalic 12 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_12_italic.h b/lib/EpdFont/builtinFonts/bookerly_12_italic.h index 1fbd43b0..0344d9dc 100644 --- a/lib/EpdFont/builtinFonts/bookerly_12_italic.h +++ b/lib/EpdFont/builtinFonts/bookerly_12_italic.h @@ -3,6 +3,7 @@ * name: bookerly_12_italic * size: 12 * mode: 2-bit + * Command used: fontconvert.py bookerly_12_italic 12 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_12_regular.h b/lib/EpdFont/builtinFonts/bookerly_12_regular.h index 1e788d41..a64cbb61 100644 --- a/lib/EpdFont/builtinFonts/bookerly_12_regular.h +++ b/lib/EpdFont/builtinFonts/bookerly_12_regular.h @@ -3,6 +3,7 @@ * name: bookerly_12_regular * size: 12 * mode: 2-bit + * Command used: fontconvert.py bookerly_12_regular 12 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_14_bold.h b/lib/EpdFont/builtinFonts/bookerly_14_bold.h index 793c6d38..98d280dd 100644 --- a/lib/EpdFont/builtinFonts/bookerly_14_bold.h +++ b/lib/EpdFont/builtinFonts/bookerly_14_bold.h @@ -3,6 +3,7 @@ * name: bookerly_14_bold * size: 14 * mode: 2-bit + * Command used: fontconvert.py bookerly_14_bold 14 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h b/lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h index 60da39be..21b55bfe 100644 --- a/lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h +++ b/lib/EpdFont/builtinFonts/bookerly_14_bolditalic.h @@ -3,6 +3,7 @@ * name: bookerly_14_bolditalic * size: 14 * mode: 2-bit + * Command used: fontconvert.py bookerly_14_bolditalic 14 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_14_italic.h b/lib/EpdFont/builtinFonts/bookerly_14_italic.h index a8d196cb..592d2ed7 100644 --- a/lib/EpdFont/builtinFonts/bookerly_14_italic.h +++ b/lib/EpdFont/builtinFonts/bookerly_14_italic.h @@ -3,6 +3,7 @@ * name: bookerly_14_italic * size: 14 * mode: 2-bit + * Command used: fontconvert.py bookerly_14_italic 14 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_14_regular.h b/lib/EpdFont/builtinFonts/bookerly_14_regular.h index 8c8355fe..b1c77366 100644 --- a/lib/EpdFont/builtinFonts/bookerly_14_regular.h +++ b/lib/EpdFont/builtinFonts/bookerly_14_regular.h @@ -3,6 +3,7 @@ * name: bookerly_14_regular * size: 14 * mode: 2-bit + * Command used: fontconvert.py bookerly_14_regular 14 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_16_bold.h b/lib/EpdFont/builtinFonts/bookerly_16_bold.h index 139d37b1..63a791b2 100644 --- a/lib/EpdFont/builtinFonts/bookerly_16_bold.h +++ b/lib/EpdFont/builtinFonts/bookerly_16_bold.h @@ -3,6 +3,7 @@ * name: bookerly_16_bold * size: 16 * mode: 2-bit + * Command used: fontconvert.py bookerly_16_bold 16 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h b/lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h index c68f1208..46a0bb5a 100644 --- a/lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h +++ b/lib/EpdFont/builtinFonts/bookerly_16_bolditalic.h @@ -3,6 +3,7 @@ * name: bookerly_16_bolditalic * size: 16 * mode: 2-bit + * Command used: fontconvert.py bookerly_16_bolditalic 16 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_16_italic.h b/lib/EpdFont/builtinFonts/bookerly_16_italic.h index bdbb6a65..2d699f61 100644 --- a/lib/EpdFont/builtinFonts/bookerly_16_italic.h +++ b/lib/EpdFont/builtinFonts/bookerly_16_italic.h @@ -3,6 +3,7 @@ * name: bookerly_16_italic * size: 16 * mode: 2-bit + * Command used: fontconvert.py bookerly_16_italic 16 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_16_regular.h b/lib/EpdFont/builtinFonts/bookerly_16_regular.h index c980928e..2948146a 100644 --- a/lib/EpdFont/builtinFonts/bookerly_16_regular.h +++ b/lib/EpdFont/builtinFonts/bookerly_16_regular.h @@ -3,6 +3,7 @@ * name: bookerly_16_regular * size: 16 * mode: 2-bit + * Command used: fontconvert.py bookerly_16_regular 16 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_18_bold.h b/lib/EpdFont/builtinFonts/bookerly_18_bold.h index ca8078bf..e281af85 100644 --- a/lib/EpdFont/builtinFonts/bookerly_18_bold.h +++ b/lib/EpdFont/builtinFonts/bookerly_18_bold.h @@ -3,6 +3,7 @@ * name: bookerly_18_bold * size: 18 * mode: 2-bit + * Command used: fontconvert.py bookerly_18_bold 18 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h b/lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h index 42f46796..4562dc52 100644 --- a/lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h +++ b/lib/EpdFont/builtinFonts/bookerly_18_bolditalic.h @@ -3,6 +3,7 @@ * name: bookerly_18_bolditalic * size: 18 * mode: 2-bit + * Command used: fontconvert.py bookerly_18_bolditalic 18 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_18_italic.h b/lib/EpdFont/builtinFonts/bookerly_18_italic.h index 8534b03e..643b5cc1 100644 --- a/lib/EpdFont/builtinFonts/bookerly_18_italic.h +++ b/lib/EpdFont/builtinFonts/bookerly_18_italic.h @@ -3,6 +3,7 @@ * name: bookerly_18_italic * size: 18 * mode: 2-bit + * Command used: fontconvert.py bookerly_18_italic 18 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/bookerly_18_regular.h b/lib/EpdFont/builtinFonts/bookerly_18_regular.h index 6d638e65..a6297ea9 100644 --- a/lib/EpdFont/builtinFonts/bookerly_18_regular.h +++ b/lib/EpdFont/builtinFonts/bookerly_18_regular.h @@ -3,6 +3,7 @@ * name: bookerly_18_regular * size: 18 * mode: 2-bit + * Command used: fontconvert.py bookerly_18_regular 18 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_12_bold.h b/lib/EpdFont/builtinFonts/notosans_12_bold.h index 57107166..65ade32a 100644 --- a/lib/EpdFont/builtinFonts/notosans_12_bold.h +++ b/lib/EpdFont/builtinFonts/notosans_12_bold.h @@ -3,6 +3,7 @@ * name: notosans_12_bold * size: 12 * mode: 2-bit + * Command used: fontconvert.py notosans_12_bold 12 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_12_bolditalic.h b/lib/EpdFont/builtinFonts/notosans_12_bolditalic.h index 1b485f7d..6ef7ef4a 100644 --- a/lib/EpdFont/builtinFonts/notosans_12_bolditalic.h +++ b/lib/EpdFont/builtinFonts/notosans_12_bolditalic.h @@ -3,6 +3,7 @@ * name: notosans_12_bolditalic * size: 12 * mode: 2-bit + * Command used: fontconvert.py notosans_12_bolditalic 12 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_12_italic.h b/lib/EpdFont/builtinFonts/notosans_12_italic.h index 994dc40a..a599577f 100644 --- a/lib/EpdFont/builtinFonts/notosans_12_italic.h +++ b/lib/EpdFont/builtinFonts/notosans_12_italic.h @@ -3,6 +3,7 @@ * name: notosans_12_italic * size: 12 * mode: 2-bit + * Command used: fontconvert.py notosans_12_italic 12 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_12_regular.h b/lib/EpdFont/builtinFonts/notosans_12_regular.h index ff47f6fd..a89cb380 100644 --- a/lib/EpdFont/builtinFonts/notosans_12_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_12_regular.h @@ -3,6 +3,7 @@ * name: notosans_12_regular * size: 12 * mode: 2-bit + * Command used: fontconvert.py notosans_12_regular 12 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_14_bold.h b/lib/EpdFont/builtinFonts/notosans_14_bold.h index 1f948b93..70403581 100644 --- a/lib/EpdFont/builtinFonts/notosans_14_bold.h +++ b/lib/EpdFont/builtinFonts/notosans_14_bold.h @@ -3,6 +3,7 @@ * name: notosans_14_bold * size: 14 * mode: 2-bit + * Command used: fontconvert.py notosans_14_bold 14 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_14_bolditalic.h b/lib/EpdFont/builtinFonts/notosans_14_bolditalic.h index f75fa527..f4168354 100644 --- a/lib/EpdFont/builtinFonts/notosans_14_bolditalic.h +++ b/lib/EpdFont/builtinFonts/notosans_14_bolditalic.h @@ -3,6 +3,7 @@ * name: notosans_14_bolditalic * size: 14 * mode: 2-bit + * Command used: fontconvert.py notosans_14_bolditalic 14 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_14_italic.h b/lib/EpdFont/builtinFonts/notosans_14_italic.h index d7d00a53..18cc49e0 100644 --- a/lib/EpdFont/builtinFonts/notosans_14_italic.h +++ b/lib/EpdFont/builtinFonts/notosans_14_italic.h @@ -3,6 +3,7 @@ * name: notosans_14_italic * size: 14 * mode: 2-bit + * Command used: fontconvert.py notosans_14_italic 14 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_14_regular.h b/lib/EpdFont/builtinFonts/notosans_14_regular.h index f93afddc..a8f7fbba 100644 --- a/lib/EpdFont/builtinFonts/notosans_14_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_14_regular.h @@ -3,6 +3,7 @@ * name: notosans_14_regular * size: 14 * mode: 2-bit + * Command used: fontconvert.py notosans_14_regular 14 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_16_bold.h b/lib/EpdFont/builtinFonts/notosans_16_bold.h index b6a0414a..4e346852 100644 --- a/lib/EpdFont/builtinFonts/notosans_16_bold.h +++ b/lib/EpdFont/builtinFonts/notosans_16_bold.h @@ -3,6 +3,7 @@ * name: notosans_16_bold * size: 16 * mode: 2-bit + * Command used: fontconvert.py notosans_16_bold 16 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_16_bolditalic.h b/lib/EpdFont/builtinFonts/notosans_16_bolditalic.h index 8452a245..8c5bc3e5 100644 --- a/lib/EpdFont/builtinFonts/notosans_16_bolditalic.h +++ b/lib/EpdFont/builtinFonts/notosans_16_bolditalic.h @@ -3,6 +3,7 @@ * name: notosans_16_bolditalic * size: 16 * mode: 2-bit + * Command used: fontconvert.py notosans_16_bolditalic 16 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_16_italic.h b/lib/EpdFont/builtinFonts/notosans_16_italic.h index d1a0cac5..e129f3ed 100644 --- a/lib/EpdFont/builtinFonts/notosans_16_italic.h +++ b/lib/EpdFont/builtinFonts/notosans_16_italic.h @@ -3,6 +3,7 @@ * name: notosans_16_italic * size: 16 * mode: 2-bit + * Command used: fontconvert.py notosans_16_italic 16 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_16_regular.h b/lib/EpdFont/builtinFonts/notosans_16_regular.h index 24398196..f07dc566 100644 --- a/lib/EpdFont/builtinFonts/notosans_16_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_16_regular.h @@ -3,6 +3,7 @@ * name: notosans_16_regular * size: 16 * mode: 2-bit + * Command used: fontconvert.py notosans_16_regular 16 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_18_bold.h b/lib/EpdFont/builtinFonts/notosans_18_bold.h index cb57a3bf..e2eb5799 100644 --- a/lib/EpdFont/builtinFonts/notosans_18_bold.h +++ b/lib/EpdFont/builtinFonts/notosans_18_bold.h @@ -3,6 +3,7 @@ * name: notosans_18_bold * size: 18 * mode: 2-bit + * Command used: fontconvert.py notosans_18_bold 18 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_18_bolditalic.h b/lib/EpdFont/builtinFonts/notosans_18_bolditalic.h index bd09ce14..465d847f 100644 --- a/lib/EpdFont/builtinFonts/notosans_18_bolditalic.h +++ b/lib/EpdFont/builtinFonts/notosans_18_bolditalic.h @@ -3,6 +3,7 @@ * name: notosans_18_bolditalic * size: 18 * mode: 2-bit + * Command used: fontconvert.py notosans_18_bolditalic 18 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_18_italic.h b/lib/EpdFont/builtinFonts/notosans_18_italic.h index 926bd32e..0e36e189 100644 --- a/lib/EpdFont/builtinFonts/notosans_18_italic.h +++ b/lib/EpdFont/builtinFonts/notosans_18_italic.h @@ -3,6 +3,7 @@ * name: notosans_18_italic * size: 18 * mode: 2-bit + * Command used: fontconvert.py notosans_18_italic 18 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_18_regular.h b/lib/EpdFont/builtinFonts/notosans_18_regular.h index d8bbe9c7..029ff804 100644 --- a/lib/EpdFont/builtinFonts/notosans_18_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_18_regular.h @@ -3,6 +3,7 @@ * name: notosans_18_regular * size: 18 * mode: 2-bit + * Command used: fontconvert.py notosans_18_regular 18 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/notosans_8_regular.h b/lib/EpdFont/builtinFonts/notosans_8_regular.h index 0c01edcc..7e339184 100644 --- a/lib/EpdFont/builtinFonts/notosans_8_regular.h +++ b/lib/EpdFont/builtinFonts/notosans_8_regular.h @@ -3,6 +3,7 @@ * name: notosans_8_regular * size: 8 * mode: 1-bit + * Command used: fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_10_bold.h b/lib/EpdFont/builtinFonts/opendyslexic_10_bold.h index eb900628..b3a16e6e 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_10_bold.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_10_bold.h @@ -3,6 +3,7 @@ * name: opendyslexic_10_bold * size: 10 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_10_bold 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h b/lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h index f2a45714..e939db2d 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_10_bolditalic.h @@ -3,6 +3,7 @@ * name: opendyslexic_10_bolditalic * size: 10 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_10_bolditalic 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_10_italic.h b/lib/EpdFont/builtinFonts/opendyslexic_10_italic.h index 2e9f4127..e0f43bb1 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_10_italic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_10_italic.h @@ -3,6 +3,7 @@ * name: opendyslexic_10_italic * size: 10 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_10_italic 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_10_regular.h b/lib/EpdFont/builtinFonts/opendyslexic_10_regular.h index 928d7526..0fded271 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_10_regular.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_10_regular.h @@ -3,6 +3,7 @@ * name: opendyslexic_10_regular * size: 10 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_10_regular 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_12_bold.h b/lib/EpdFont/builtinFonts/opendyslexic_12_bold.h index 5f7c8ecc..115a737c 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_12_bold.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_12_bold.h @@ -3,6 +3,7 @@ * name: opendyslexic_12_bold * size: 12 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_12_bold 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h b/lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h index fdb3f63b..54732e1a 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_12_bolditalic.h @@ -3,6 +3,7 @@ * name: opendyslexic_12_bolditalic * size: 12 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_12_bolditalic 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_12_italic.h b/lib/EpdFont/builtinFonts/opendyslexic_12_italic.h index 4ce9eed8..d927f96c 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_12_italic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_12_italic.h @@ -3,6 +3,7 @@ * name: opendyslexic_12_italic * size: 12 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_12_italic 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_12_regular.h b/lib/EpdFont/builtinFonts/opendyslexic_12_regular.h index 596ee1ec..61643c60 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_12_regular.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_12_regular.h @@ -3,6 +3,7 @@ * name: opendyslexic_12_regular * size: 12 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_12_regular 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_14_bold.h b/lib/EpdFont/builtinFonts/opendyslexic_14_bold.h index b5de40b6..e150dbd3 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_14_bold.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_14_bold.h @@ -3,6 +3,7 @@ * name: opendyslexic_14_bold * size: 14 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_14_bold 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h b/lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h index dae158ca..9aa5e19d 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_14_bolditalic.h @@ -3,6 +3,7 @@ * name: opendyslexic_14_bolditalic * size: 14 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_14_bolditalic 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_14_italic.h b/lib/EpdFont/builtinFonts/opendyslexic_14_italic.h index d69b842a..06fd04d4 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_14_italic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_14_italic.h @@ -3,6 +3,7 @@ * name: opendyslexic_14_italic * size: 14 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_14_italic 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_14_regular.h b/lib/EpdFont/builtinFonts/opendyslexic_14_regular.h index f45e71ae..cda4f876 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_14_regular.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_14_regular.h @@ -3,6 +3,7 @@ * name: opendyslexic_14_regular * size: 14 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_14_regular 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_8_bold.h b/lib/EpdFont/builtinFonts/opendyslexic_8_bold.h index b0fc804c..72e131d8 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_8_bold.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_8_bold.h @@ -3,6 +3,7 @@ * name: opendyslexic_8_bold * size: 8 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_8_bold 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h b/lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h index 77336edf..4858ad08 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_8_bolditalic.h @@ -3,6 +3,7 @@ * name: opendyslexic_8_bolditalic * size: 8 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_8_bolditalic 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_8_italic.h b/lib/EpdFont/builtinFonts/opendyslexic_8_italic.h index 37dcfa99..62e37b32 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_8_italic.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_8_italic.h @@ -3,6 +3,7 @@ * name: opendyslexic_8_italic * size: 8 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_8_italic 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/opendyslexic_8_regular.h b/lib/EpdFont/builtinFonts/opendyslexic_8_regular.h index f68c7438..fae287a5 100644 --- a/lib/EpdFont/builtinFonts/opendyslexic_8_regular.h +++ b/lib/EpdFont/builtinFonts/opendyslexic_8_regular.h @@ -3,6 +3,7 @@ * name: opendyslexic_8_regular * size: 8 * mode: 2-bit + * Command used: fontconvert.py opendyslexic_8_regular 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/ubuntu_10_bold.h b/lib/EpdFont/builtinFonts/ubuntu_10_bold.h index cab81b10..80032fd8 100644 --- a/lib/EpdFont/builtinFonts/ubuntu_10_bold.h +++ b/lib/EpdFont/builtinFonts/ubuntu_10_bold.h @@ -3,6 +3,7 @@ * name: ubuntu_10_bold * size: 10 * mode: 1-bit + * Command used: fontconvert.py ubuntu_10_bold 10 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/ubuntu_10_regular.h b/lib/EpdFont/builtinFonts/ubuntu_10_regular.h index a7292c19..e76ab2c0 100644 --- a/lib/EpdFont/builtinFonts/ubuntu_10_regular.h +++ b/lib/EpdFont/builtinFonts/ubuntu_10_regular.h @@ -3,6 +3,7 @@ * name: ubuntu_10_regular * size: 10 * mode: 1-bit + * Command used: fontconvert.py ubuntu_10_regular 10 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/ubuntu_12_bold.h b/lib/EpdFont/builtinFonts/ubuntu_12_bold.h index 9419ed4b..5b24d067 100644 --- a/lib/EpdFont/builtinFonts/ubuntu_12_bold.h +++ b/lib/EpdFont/builtinFonts/ubuntu_12_bold.h @@ -3,6 +3,7 @@ * name: ubuntu_12_bold * size: 12 * mode: 1-bit + * Command used: fontconvert.py ubuntu_12_bold 12 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/builtinFonts/ubuntu_12_regular.h b/lib/EpdFont/builtinFonts/ubuntu_12_regular.h index f02de88c..23ddbe78 100644 --- a/lib/EpdFont/builtinFonts/ubuntu_12_regular.h +++ b/lib/EpdFont/builtinFonts/ubuntu_12_regular.h @@ -3,6 +3,7 @@ * name: ubuntu_12_regular * size: 12 * mode: 1-bit + * Command used: fontconvert.py ubuntu_12_regular 12 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf */ #pragma once #include "EpdFontData.h" diff --git a/lib/EpdFont/scripts/fontconvert.py b/lib/EpdFont/scripts/fontconvert.py index d11f73b7..ba7a44af 100755 --- a/lib/EpdFont/scripts/fontconvert.py +++ b/lib/EpdFont/scripts/fontconvert.py @@ -270,9 +270,17 @@ for index, glyph in enumerate(all_glyphs): glyph_data.extend([b for b in packed]) glyph_props.append(props) -print(f"/**\n * generated by fontconvert.py\n * name: {font_name}\n * size: {size}\n * mode: {'2-bit' if is2Bit else '1-bit'}\n */") -print("#pragma once") -print("#include \"EpdFontData.h\"\n") +print(f"""/** + * generated by fontconvert.py + * name: {font_name} + * size: {size} + * mode: {'2-bit' if is2Bit else '1-bit'} + * Command used: {' '.join(sys.argv)} + */ +#pragma once +#include "EpdFontData.h" +""") + print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{") for c in chunks(glyph_data, 16): print (" " + " ".join(f"0x{b:02X}," for b in c)) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 78607573..33f920b4 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -359,7 +359,7 @@ const std::string& Epub::getLanguage() const { } std::string Epub::getCoverBmpPath(bool cropped) const { - const auto coverFileName = "cover" + cropped ? "_crop" : ""; + const auto coverFileName = std::string("cover") + (cropped ? "_crop" : ""); return cachePath + "/" + coverFileName + ".bmp"; } @@ -382,7 +382,7 @@ bool Epub::generateCoverBmp(bool cropped) const { if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { - Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image\n", millis()); + Serial.printf("[%lu] [EBP] Generating BMP from JPG cover image (%s mode)\n", millis(), cropped ? "cropped" : "fit"); const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; FsFile coverJpg; @@ -401,7 +401,7 @@ bool Epub::generateCoverBmp(bool cropped) const { coverJpg.close(); return false; } - const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped); coverJpg.close(); coverBmp.close(); SdMan.remove(coverJpgTempPath.c_str()); diff --git a/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp b/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp index 99584fde..0a6b7a92 100644 --- a/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp +++ b/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp @@ -125,6 +125,8 @@ bool isExplicitHyphen(const uint32_t cp) { case 0xFE58: // small em dash case 0xFE63: // small hyphen-minus case 0xFF0D: // fullwidth hyphen-minus + case 0x005F: // Underscore + case 0x2026: // Ellipsis return true; default: return false; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 1d7e2ab3..f6d96be4 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -40,6 +40,23 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib return false; } +// flush the contents of partWordBuffer to currentTextBlock +void ChapterHtmlSlimParser::flushPartWordBuffer() { + // determine font style + EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; + if (boldUntilDepth < depth && italicUntilDepth < depth) { + fontStyle = EpdFontFamily::BOLD_ITALIC; + } else if (boldUntilDepth < depth) { + fontStyle = EpdFontFamily::BOLD; + } else if (italicUntilDepth < depth) { + fontStyle = EpdFontFamily::ITALIC; + } + // flush the buffer + partWordBuffer[partWordBufferIndex] = '\0'; + currentTextBlock->addWord(partWordBuffer, fontStyle); + partWordBufferIndex = 0; +} + // start a new text block if needed void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) { if (currentTextBlock) { @@ -83,7 +100,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (atts != nullptr) { for (int i = 0; atts[i]; i += 2) { if (strcmp(atts[i], "alt") == 0) { - alt = "[Image: " + std::string(atts[i + 1]) + "]"; + // add " " (counts as whitespace) at the end of alt + // so the corresponding text block ends. + // TODO: A zero-width breaking space would be more appropriate (once/if we support it) + alt = "[Image: " + std::string(atts[i + 1]) + "] "; } } Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); @@ -92,7 +112,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->italicUntilDepth = min(self->italicUntilDepth, self->depth); self->depth += 1; self->characterData(userData, alt.c_str(), alt.length()); - + return; } else { // Skip for now self->skipUntilDepth = self->depth; @@ -125,6 +145,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { if (strcmp(name, "br") == 0) { + if (self->partWordBufferIndex > 0) { + // flush word preceding
to currentTextBlock before calling startNewTextBlock + self->flushPartWordBuffer(); + } self->startNewTextBlock(self->currentTextBlock->getStyle()); } else { self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment); @@ -149,22 +173,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char return; } - EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; - if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) { - fontStyle = EpdFontFamily::BOLD_ITALIC; - } else if (self->boldUntilDepth < self->depth) { - fontStyle = EpdFontFamily::BOLD; - } else if (self->italicUntilDepth < self->depth) { - fontStyle = EpdFontFamily::ITALIC; - } - for (int i = 0; i < len; i++) { if (isWhitespace(s[i])) { // 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(self->partWordBuffer, fontStyle); - self->partWordBufferIndex = 0; + self->flushPartWordBuffer(); } // Skip the whitespace char continue; @@ -186,9 +199,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(self->partWordBuffer, fontStyle); - self->partWordBufferIndex = 0; + self->flushPartWordBuffer(); } self->partWordBuffer[self->partWordBufferIndex++] = s[i]; @@ -219,18 +230,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1; if (shouldBreakText) { - EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; - if (self->boldUntilDepth < self->depth && self->italicUntilDepth < self->depth) { - fontStyle = EpdFontFamily::BOLD_ITALIC; - } else if (self->boldUntilDepth < self->depth) { - fontStyle = EpdFontFamily::BOLD; - } else if (self->italicUntilDepth < self->depth) { - fontStyle = EpdFontFamily::ITALIC; - } - - self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); - self->partWordBufferIndex = 0; + self->flushPartWordBuffer(); } } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 5355211a..2d8ebe5c 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -39,6 +39,7 @@ class ChapterHtmlSlimParser { bool hyphenationEnabled; void startNewTextBlock(TextBlock::Style style); + void flushPartWordBuffer(); void makePages(); // XML callbacks static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 08420bf9..1dbe8ee6 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -145,10 +145,25 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int } void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { - // TODO: Rotate bits int rotatedX = 0; int rotatedY = 0; rotateCoordinates(x, y, &rotatedX, &rotatedY); + // Rotate origin corner + switch (orientation) { + case Portrait: + rotatedY = rotatedY - height; + break; + case PortraitInverted: + rotatedX = rotatedX - width; + break; + case LandscapeClockwise: + rotatedY = rotatedY - height; + rotatedX = rotatedX - width; + break; + case LandscapeCounterClockwise: + break; + } + // TODO: Rotate bits einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); } diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 01451a05..84ac1d58 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -200,7 +200,7 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un // Internal implementation with configurable target size and bit depth bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, - bool oneBit) { + bool oneBit, bool crop) { Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight); @@ -242,8 +242,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm const float scaleToFitWidth = static_cast(targetWidth) / imageInfo.m_width; const float scaleToFitHeight = static_cast(targetHeight) / imageInfo.m_height; // We scale to the smaller dimension, so we can potentially crop later. - // TODO: ideally, we already crop here. - const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + float scale = 1.0; + if (crop) { // if we will crop, scale to the smaller dimension + scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + } else { // else, scale to the larger dimension to fit + scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; + } outWidth = static_cast(imageInfo.m_width * scale); outHeight = static_cast(imageInfo.m_height * scale); @@ -550,8 +554,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm } // Core function: Convert JPEG file to 2-bit BMP (uses default target size) -bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { - return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false); +bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop) { + return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop); } // Convert with custom target size (for thumbnails, 2-bit) diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index d5e9b950..9b92bb6d 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -8,10 +8,10 @@ class JpegToBmpConverter { static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight, - bool oneBit); + bool oneBit, bool crop = true); public: - static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut); + static bool jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop = true); // Convert with custom target size (for thumbnails) static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight); // Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index 2c52464c..b33beb75 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -33,10 +33,10 @@ std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePat size_t KOReaderDocumentId::getOffset(int i) { // Offset = 1024 << (2*i) - // For i = -1: 1024 >> 2 = 256 + // For i = -1: KOReader uses a value of 0 // For i >= 0: 1024 << (2*i) if (i < 0) { - return CHUNK_SIZE >> (-2 * i); + return 0; } return CHUNK_SIZE << (2 * i); } diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index c79421d7..7850d934 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -7,7 +7,6 @@ #include "Xtc.h" -#include #include #include @@ -87,6 +86,15 @@ std::string Xtc::getTitle() const { return filepath.substr(lastSlash, lastDot - lastSlash); } +std::string Xtc::getAuthor() const { + if (!loaded || !parser) { + return ""; + } + + // Try to get author from XTC metadata + return parser->getAuthor(); +} + bool Xtc::hasChapters() const { if (!loaded || !parser) { return false; diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index 7413ef47..c8d9a040 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -56,6 +56,7 @@ class Xtc { // Metadata std::string getTitle() const; + std::string getAuthor() const; bool hasChapters() const; const std::vector& getChapters() const; diff --git a/lib/Xtc/Xtc/XtcParser.cpp b/lib/Xtc/Xtc/XtcParser.cpp index c33e7193..8db3dead 100644 --- a/lib/Xtc/Xtc/XtcParser.cpp +++ b/lib/Xtc/Xtc/XtcParser.cpp @@ -47,8 +47,21 @@ XtcError XtcParser::open(const char* filepath) { return m_lastError; } - // Read title if available - readTitle(); + // Read title & author if available + if (m_header.hasMetadata) { + m_lastError = readTitle(); + if (m_lastError != XtcError::OK) { + Serial.printf("[%lu] [XTC] Failed to read title: %s\n", millis(), errorToString(m_lastError)); + m_file.close(); + return m_lastError; + } + m_lastError = readAuthor(); + if (m_lastError != XtcError::OK) { + Serial.printf("[%lu] [XTC] Failed to read author: %s\n", millis(), errorToString(m_lastError)); + m_file.close(); + return m_lastError; + } + } // Read page table m_lastError = readPageTable(); @@ -124,24 +137,34 @@ XtcError XtcParser::readHeader() { } XtcError XtcParser::readTitle() { - // Title is usually at offset 0x38 (56) for 88-byte headers - // Read title as null-terminated UTF-8 string - if (m_header.titleOffset == 0) { - m_header.titleOffset = 0x38; // Default offset - } - - if (!m_file.seek(m_header.titleOffset)) { + constexpr auto titleOffset = 0x38; + if (!m_file.seek(titleOffset)) { return XtcError::READ_ERROR; } char titleBuf[128] = {0}; - m_file.read(reinterpret_cast(titleBuf), sizeof(titleBuf) - 1); + m_file.read(titleBuf, sizeof(titleBuf) - 1); m_title = titleBuf; Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str()); return XtcError::OK; } +XtcError XtcParser::readAuthor() { + // Read author as null-terminated UTF-8 string with max length 64, directly following title + constexpr auto authorOffset = 0xB8; + if (!m_file.seek(authorOffset)) { + return XtcError::READ_ERROR; + } + + char authorBuf[64] = {0}; + m_file.read(authorBuf, sizeof(authorBuf) - 1); + m_author = authorBuf; + + Serial.printf("[%lu] [XTC] Author: %s\n", millis(), m_author.c_str()); + return XtcError::OK; +} + XtcError XtcParser::readPageTable() { if (m_header.pageTableOffset == 0) { Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis()); diff --git a/lib/Xtc/Xtc/XtcParser.h b/lib/Xtc/Xtc/XtcParser.h index 2d2b780e..b0033542 100644 --- a/lib/Xtc/Xtc/XtcParser.h +++ b/lib/Xtc/Xtc/XtcParser.h @@ -67,8 +67,9 @@ class XtcParser { std::function callback, size_t chunkSize = 1024); - // Get title from metadata + // Get title/author from metadata std::string getTitle() const { return m_title; } + std::string getAuthor() const { return m_author; } bool hasChapters() const { return m_hasChapters; } const std::vector& getChapters() const { return m_chapters; } @@ -86,6 +87,7 @@ class XtcParser { std::vector m_pageTable; std::vector m_chapters; std::string m_title; + std::string m_author; uint16_t m_defaultWidth; uint16_t m_defaultHeight; uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit) @@ -96,6 +98,7 @@ class XtcParser { XtcError readHeader(); XtcError readPageTable(); XtcError readTitle(); + XtcError readAuthor(); XtcError readChapters(); }; diff --git a/lib/Xtc/Xtc/XtcTypes.h b/lib/Xtc/Xtc/XtcTypes.h index 08f9c00b..773c7ad5 100644 --- a/lib/Xtc/Xtc/XtcTypes.h +++ b/lib/Xtc/Xtc/XtcTypes.h @@ -38,14 +38,16 @@ struct XtcHeader { uint8_t versionMajor; // 0x04: Format version major (typically 1) (together with minor = 1.0) uint8_t versionMinor; // 0x05: Format version minor (typically 0) uint16_t pageCount; // 0x06: Total page count - uint32_t flags; // 0x08: Flags/reserved - uint32_t headerSize; // 0x0C: Size of header section (typically 88) - uint32_t reserved1; // 0x10: Reserved - uint32_t tocOffset; // 0x14: TOC offset (0 if unused) - 4 bytes, not 8! + uint8_t readDirection; // 0x08: Reading direction (0-2) + uint8_t hasMetadata; // 0x09: Has metadata (0-1) + uint8_t hasThumbnails; // 0x0A: Has thumbnails (0-1) + uint8_t hasChapters; // 0x0B: Has chapters (0-1) + uint32_t currentPage; // 0x0C: Current page (1-based) (0-65535) + uint64_t metadataOffset; // 0x10: Metadata offset (0 if unused) uint64_t pageTableOffset; // 0x18: Page table offset uint64_t dataOffset; // 0x20: First page data offset - uint64_t reserved2; // 0x28: Reserved - uint32_t titleOffset; // 0x30: Title string offset + uint64_t thumbOffset; // 0x28: Thumbnail offset + uint32_t chapterOffset; // 0x30: Chapter data offset uint32_t padding; // 0x34: Padding to 56 bytes }; #pragma pack(pop) diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index f5e8ded5..f3a7a524 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -11,10 +11,18 @@ // Initialize the static instance CrossPointSettings CrossPointSettings::instance; +void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) { + uint8_t tempValue; + serialization::readPod(file, tempValue); + if (tempValue < maxValue) { + member = tempValue; + } +} + namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 20; +constexpr uint8_t SETTINGS_COUNT = 22; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -49,6 +57,9 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, hideBatteryPercentage); serialization::writePod(outputFile, longPressChapterSkip); serialization::writePod(outputFile, hyphenationEnabled); + // New fields added at end for backward compatibility + serialization::writeString(outputFile, std::string(opdsUsername)); + serialization::writeString(outputFile, std::string(opdsPassword)); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -75,35 +86,35 @@ bool CrossPointSettings::loadFromFile() { // load settings that exist (support older files with fewer fields) uint8_t settingsRead = 0; do { - serialization::readPod(inputFile, sleepScreen); + readAndValidate(inputFile, sleepScreen, SLEEP_SCREEN_MODE_COUNT); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, extraParagraphSpacing); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, shortPwrBtn); + readAndValidate(inputFile, shortPwrBtn, SHORT_PWRBTN_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, statusBar); + readAndValidate(inputFile, statusBar, STATUS_BAR_MODE_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, orientation); + readAndValidate(inputFile, orientation, ORIENTATION_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, frontButtonLayout); + readAndValidate(inputFile, frontButtonLayout, FRONT_BUTTON_LAYOUT_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, sideButtonLayout); + readAndValidate(inputFile, sideButtonLayout, SIDE_BUTTON_LAYOUT_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, fontFamily); + readAndValidate(inputFile, fontFamily, FONT_FAMILY_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, fontSize); + readAndValidate(inputFile, fontSize, FONT_SIZE_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, lineSpacing); + readAndValidate(inputFile, lineSpacing, LINE_COMPRESSION_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, paragraphAlignment); + readAndValidate(inputFile, paragraphAlignment, PARAGRAPH_ALIGNMENT_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, sleepTimeout); + readAndValidate(inputFile, sleepTimeout, SLEEP_TIMEOUT_COUNT); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, refreshFrequency); + readAndValidate(inputFile, refreshFrequency, REFRESH_FREQUENCY_COUNT); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, screenMargin); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, sleepScreenCoverMode); + readAndValidate(inputFile, sleepScreenCoverMode, SLEEP_SCREEN_COVER_MODE_COUNT); if (++settingsRead >= fileSettingsCount) break; { std::string urlStr; @@ -114,12 +125,27 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, textAntiAliasing); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, hideBatteryPercentage); + readAndValidate(inputFile, hideBatteryPercentage, HIDE_BATTERY_PERCENTAGE_COUNT); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, longPressChapterSkip); if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hyphenationEnabled); if (++settingsRead >= fileSettingsCount) break; + // New fields added at end for backward compatibility + { + std::string usernameStr; + serialization::readString(inputFile, usernameStr); + strncpy(opdsUsername, usernameStr.c_str(), sizeof(opdsUsername) - 1); + opdsUsername[sizeof(opdsUsername) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; + { + std::string passwordStr; + serialization::readString(inputFile, passwordStr); + strncpy(opdsPassword, passwordStr.c_str(), sizeof(opdsPassword) - 1); + opdsPassword[sizeof(opdsPassword) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 8ce32a2c..e2883425 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -15,48 +15,74 @@ class CrossPointSettings { CrossPointSettings(const CrossPointSettings&) = delete; CrossPointSettings& operator=(const CrossPointSettings&) = delete; - // Should match with SettingsActivity text - enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4 }; - enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1 }; + enum SLEEP_SCREEN_MODE { DARK = 0, LIGHT = 1, CUSTOM = 2, COVER = 3, BLANK = 4, SLEEP_SCREEN_MODE_COUNT }; + enum SLEEP_SCREEN_COVER_MODE { FIT = 0, CROP = 1, SLEEP_SCREEN_COVER_MODE_COUNT }; // Status bar display type enum - enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2 }; + enum STATUS_BAR_MODE { NONE = 0, NO_PROGRESS = 1, FULL = 2, STATUS_BAR_MODE_COUNT }; enum ORIENTATION { - PORTRAIT = 0, // 480x800 logical coordinates (current default) - LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom) - INVERTED = 2, // 480x800 logical coordinates, inverted - LANDSCAPE_CCW = 3 // 800x480 logical coordinates, native panel orientation + PORTRAIT = 0, // 480x800 logical coordinates (current default) + LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom) + INVERTED = 2, // 480x800 logical coordinates, inverted + LANDSCAPE_CCW = 3, // 800x480 logical coordinates, native panel orientation + ORIENTATION_COUNT }; // Front button layout options // Default: Back, Confirm, Left, Right // Swapped: Left, Right, Back, Confirm - enum FRONT_BUTTON_LAYOUT { BACK_CONFIRM_LEFT_RIGHT = 0, LEFT_RIGHT_BACK_CONFIRM = 1, LEFT_BACK_CONFIRM_RIGHT = 2 }; + enum FRONT_BUTTON_LAYOUT { + BACK_CONFIRM_LEFT_RIGHT = 0, + LEFT_RIGHT_BACK_CONFIRM = 1, + LEFT_BACK_CONFIRM_RIGHT = 2, + BACK_CONFIRM_RIGHT_LEFT = 3, + FRONT_BUTTON_LAYOUT_COUNT + }; // Side button layout options // Default: Previous, Next // Swapped: Next, Previous - enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1 }; + enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1, SIDE_BUTTON_LAYOUT_COUNT }; // Font family options - enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2 }; + enum FONT_FAMILY { BOOKERLY = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, FONT_FAMILY_COUNT }; // Font size options - enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3 }; - enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2 }; - enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, LEFT_ALIGN = 1, CENTER_ALIGN = 2, RIGHT_ALIGN = 3 }; + enum FONT_SIZE { SMALL = 0, MEDIUM = 1, LARGE = 2, EXTRA_LARGE = 3, FONT_SIZE_COUNT }; + enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT }; + enum PARAGRAPH_ALIGNMENT { + JUSTIFIED = 0, + LEFT_ALIGN = 1, + CENTER_ALIGN = 2, + RIGHT_ALIGN = 3, + PARAGRAPH_ALIGNMENT_COUNT + }; // Auto-sleep timeout options (in minutes) - enum SLEEP_TIMEOUT { SLEEP_1_MIN = 0, SLEEP_5_MIN = 1, SLEEP_10_MIN = 2, SLEEP_15_MIN = 3, SLEEP_30_MIN = 4 }; + enum SLEEP_TIMEOUT { + SLEEP_1_MIN = 0, + SLEEP_5_MIN = 1, + SLEEP_10_MIN = 2, + SLEEP_15_MIN = 3, + SLEEP_30_MIN = 4, + SLEEP_TIMEOUT_COUNT + }; // E-ink refresh frequency (pages between full refreshes) - enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; + enum REFRESH_FREQUENCY { + REFRESH_1 = 0, + REFRESH_5 = 1, + REFRESH_10 = 2, + REFRESH_15 = 3, + REFRESH_30 = 4, + REFRESH_FREQUENCY_COUNT + }; // Short power button press actions - enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 }; + enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, SHORT_PWRBTN_COUNT }; // Hide battery percentage - enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 }; + enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; // Sleep screen settings uint8_t sleepScreen = DARK; @@ -90,6 +116,8 @@ class CrossPointSettings { uint8_t screenMargin = 5; // OPDS browser settings char opdsServerUrl[128] = ""; + char opdsUsername[64] = ""; + char opdsPassword[64] = ""; // Hide battery percentage uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press chapter skip on side buttons diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 1b038446..994dda5f 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -14,6 +14,9 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: return InputManager::BTN_CONFIRM; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: + /* fall through */ + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + /* fall through */ default: return InputManager::BTN_BACK; } @@ -24,15 +27,22 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: return InputManager::BTN_LEFT; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: + /* fall through */ + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + /* fall through */ default: return InputManager::BTN_CONFIRM; } case Button::Left: switch (frontLayout) { case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM: + /* fall through */ case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: return InputManager::BTN_BACK; + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + return InputManager::BTN_RIGHT; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: + /* fall through */ default: return InputManager::BTN_LEFT; } @@ -40,8 +50,12 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt switch (frontLayout) { case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM: return InputManager::BTN_CONFIRM; + case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT: + return InputManager::BTN_LEFT; case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT: + /* fall through */ case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT: + /* fall through */ default: return InputManager::BTN_RIGHT; } @@ -56,6 +70,7 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt case CrossPointSettings::NEXT_PREV: return InputManager::BTN_DOWN; case CrossPointSettings::PREV_NEXT: + /* fall through */ default: return InputManager::BTN_UP; } @@ -64,6 +79,7 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt case CrossPointSettings::NEXT_PREV: return InputManager::BTN_UP; case CrossPointSettings::PREV_NEXT: + /* fall through */ default: return InputManager::BTN_DOWN; } diff --git a/src/activities/boot_sleep/BootActivity.cpp b/src/activities/boot_sleep/BootActivity.cpp index 65eb6a07..b741c3e3 100644 --- a/src/activities/boot_sleep/BootActivity.cpp +++ b/src/activities/boot_sleep/BootActivity.cpp @@ -12,7 +12,7 @@ void BootActivity::onEnter() { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); - renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128); + renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "BOOTING"); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index c0d6844f..c4b98968 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -124,7 +124,7 @@ void SleepActivity::renderDefaultSleepScreen() const { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); - renderer.drawImage(CrossLarge, (pageWidth + 128) / 2, (pageHeight - 128) / 2, 128, 128); + renderer.drawImage(CrossLarge, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING"); @@ -260,6 +260,7 @@ void SleepActivity::renderCoverSleepScreen() const { if (SdMan.openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { + Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath); renderBitmapSleepScreen(bitmap); return; } diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 555cba91..2bde74de 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -18,7 +18,6 @@ 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) { @@ -33,7 +32,7 @@ void OpdsBookBrowserActivity::onEnter() { state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); - currentPath = OPDS_ROOT_PATH; + currentPath = ""; // Root path - user provides full URL in settings selectorIndex = 0; errorMessage.clear(); statusMessage = "Checking WiFi..."; @@ -172,7 +171,7 @@ void OpdsBookBrowserActivity::render() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); if (state == BrowserState::CHECK_WIFI) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index eb11ba95..58b29505 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -71,6 +71,9 @@ void HomeActivity::onEnter() { if (!xtc.getTitle().empty()) { lastBookTitle = std::string(xtc.getTitle()); } + if (!xtc.getAuthor().empty()) { + lastBookAuthor = std::string(xtc.getAuthor()); + } // Try to generate thumbnail image for Continue Reading card if (xtc.generateThumbBmp()) { coverBmpPath = xtc.getThumbBmpPath(); @@ -502,8 +505,8 @@ void HomeActivity::render() { // Build menu items dynamically std::vector menuItems = {"My Library", "File Transfer", "Settings"}; if (hasOpdsUrl) { - // Insert Calibre Library after My Library - menuItems.insert(menuItems.begin() + 1, "Calibre Library"); + // Insert OPDS Browser after My Library + menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); } const int menuTileWidth = pageWidth - 2 * margin; diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 9e6f3734..1db32397 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -120,7 +120,8 @@ void MyLibraryActivity::loadFiles() { } else { auto filename = std::string(name); if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") || - StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt")) { + StringUtils::checkFileExtension(filename, ".xtc") || StringUtils::checkFileExtension(filename, ".txt") || + StringUtils::checkFileExtension(filename, ".md")) { files.emplace_back(filename); } } diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp new file mode 100644 index 00000000..8aa60c40 --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -0,0 +1,276 @@ +#include "CalibreConnectActivity.h" + +#include +#include +#include +#include + +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "WifiSelectionActivity.h" +#include "fontIds.h" + +namespace { +constexpr const char* HOSTNAME = "crosspoint"; +} // namespace + +void CalibreConnectActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void CalibreConnectActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + updateRequired = true; + state = CalibreConnectState::WIFI_SELECTION; + connectedIP.clear(); + connectedSSID.clear(); + lastHandleClientTime = 0; + lastProgressReceived = 0; + lastProgressTotal = 0; + currentUploadName.clear(); + lastCompleteName.clear(); + lastCompleteAt = 0; + exitRequested = false; + + xTaskCreate(&CalibreConnectActivity::taskTrampoline, "CalibreConnectTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + if (WiFi.status() != WL_CONNECTED) { + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); + } else { + connectedIP = WiFi.localIP().toString().c_str(); + connectedSSID = WiFi.SSID().c_str(); + startWebServer(); + } +} + +void CalibreConnectActivity::onExit() { + ActivityWithSubactivity::onExit(); + + stopWebServer(); + MDNS.end(); + + delay(50); + WiFi.disconnect(false); + delay(30); + WiFi.mode(WIFI_OFF); + delay(30); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void CalibreConnectActivity::onWifiSelectionComplete(const bool connected) { + if (!connected) { + exitActivity(); + onComplete(); + return; + } + + if (subActivity) { + connectedIP = static_cast(subActivity.get())->getConnectedIP(); + } else { + connectedIP = WiFi.localIP().toString().c_str(); + } + connectedSSID = WiFi.SSID().c_str(); + exitActivity(); + startWebServer(); +} + +void CalibreConnectActivity::startWebServer() { + state = CalibreConnectState::SERVER_STARTING; + updateRequired = true; + + if (MDNS.begin(HOSTNAME)) { + // mDNS is optional for the Calibre plugin but still helpful for users. + Serial.printf("[%lu] [CAL] mDNS started: http://%s.local/\n", millis(), HOSTNAME); + } + + webServer.reset(new CrossPointWebServer()); + webServer->begin(); + + if (webServer->isRunning()) { + state = CalibreConnectState::SERVER_RUNNING; + updateRequired = true; + } else { + state = CalibreConnectState::ERROR; + updateRequired = true; + } +} + +void CalibreConnectActivity::stopWebServer() { + if (webServer) { + webServer->stop(); + webServer.reset(); + } +} + +void CalibreConnectActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + exitRequested = true; + } + + if (webServer && webServer->isRunning()) { + const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; + if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { + Serial.printf("[%lu] [CAL] WARNING: %lu ms gap since last handleClient\n", millis(), timeSinceLastHandleClient); + } + + esp_task_wdt_reset(); + constexpr int MAX_ITERATIONS = 80; + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { + webServer->handleClient(); + if ((i & 0x07) == 0x07) { + esp_task_wdt_reset(); + } + if ((i & 0x0F) == 0x0F) { + yield(); + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + exitRequested = true; + break; + } + } + } + lastHandleClientTime = millis(); + + const auto status = webServer->getWsUploadStatus(); + bool changed = false; + if (status.inProgress) { + if (status.received != lastProgressReceived || status.total != lastProgressTotal || + status.filename != currentUploadName) { + lastProgressReceived = status.received; + lastProgressTotal = status.total; + currentUploadName = status.filename; + changed = true; + } + } else if (lastProgressReceived != 0 || lastProgressTotal != 0) { + lastProgressReceived = 0; + lastProgressTotal = 0; + currentUploadName.clear(); + changed = true; + } + if (status.lastCompleteAt != 0 && status.lastCompleteAt != lastCompleteAt) { + lastCompleteAt = status.lastCompleteAt; + lastCompleteName = status.lastCompleteName; + changed = true; + } + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) >= 6000) { + lastCompleteAt = 0; + lastCompleteName.clear(); + changed = true; + } + if (changed) { + updateRequired = true; + } + } + + if (exitRequested) { + onComplete(); + return; + } +} + +void CalibreConnectActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void CalibreConnectActivity::render() const { + if (state == CalibreConnectState::SERVER_RUNNING) { + renderer.clearScreen(); + renderServerRunning(); + renderer.displayBuffer(); + return; + } + + renderer.clearScreen(); + const auto pageHeight = renderer.getScreenHeight(); + if (state == CalibreConnectState::SERVER_STARTING) { + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Calibre...", true, EpdFontFamily::BOLD); + } else if (state == CalibreConnectState::ERROR) { + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Calibre setup failed", true, EpdFontFamily::BOLD); + } + renderer.displayBuffer(); +} + +void CalibreConnectActivity::renderServerRunning() const { + constexpr int LINE_SPACING = 24; + constexpr int SMALL_SPACING = 20; + constexpr int SECTION_SPACING = 40; + constexpr int TOP_PADDING = 14; + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Connect to Calibre", true, EpdFontFamily::BOLD); + + int y = 55 + TOP_PADDING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Network", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + std::string ssidInfo = "Network: " + connectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + } + renderer.drawCenteredText(UI_10_FONT_ID, y, ssidInfo.c_str()); + renderer.drawCenteredText(UI_10_FONT_ID, y + LINE_SPACING, ("IP: " + connectedIP).c_str()); + + y += LINE_SPACING * 2 + SECTION_SPACING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Setup", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + renderer.drawCenteredText(SMALL_FONT_ID, y, "1) Install CrossPoint Reader plugin"); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING, "2) Be on the same WiFi network"); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 2, "3) In Calibre: \"Send to device\""); + renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 3, "Keep this screen open while sending"); + + y += SMALL_SPACING * 3 + SECTION_SPACING; + renderer.drawCenteredText(UI_10_FONT_ID, y, "Status", true, EpdFontFamily::BOLD); + y += LINE_SPACING; + if (lastProgressTotal > 0 && lastProgressReceived <= lastProgressTotal) { + std::string label = "Receiving"; + if (!currentUploadName.empty()) { + label += ": " + currentUploadName; + if (label.length() > 34) { + label.replace(31, label.length() - 31, "..."); + } + } + renderer.drawCenteredText(SMALL_FONT_ID, y, label.c_str()); + constexpr int barWidth = 300; + constexpr int barHeight = 16; + constexpr int barX = (480 - barWidth) / 2; + ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived, + lastProgressTotal); + y += 40; + } + + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) { + std::string msg = "Received: " + lastCompleteName; + if (msg.length() > 36) { + msg.replace(33, msg.length() - 33, "..."); + } + renderer.drawCenteredText(SMALL_FONT_ID, y, msg.c_str()); + } + + const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/network/CalibreConnectActivity.h b/src/activities/network/CalibreConnectActivity.h new file mode 100644 index 00000000..08cf4bb4 --- /dev/null +++ b/src/activities/network/CalibreConnectActivity.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "activities/ActivityWithSubactivity.h" +#include "network/CrossPointWebServer.h" + +enum class CalibreConnectState { WIFI_SELECTION, SERVER_STARTING, SERVER_RUNNING, ERROR }; + +/** + * CalibreConnectActivity starts the file transfer server in STA mode, + * but renders Calibre-specific instructions instead of the web transfer UI. + */ +class CalibreConnectActivity final : public ActivityWithSubactivity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + CalibreConnectState state = CalibreConnectState::WIFI_SELECTION; + const std::function onComplete; + + std::unique_ptr webServer; + std::string connectedIP; + std::string connectedSSID; + unsigned long lastHandleClientTime = 0; + size_t lastProgressReceived = 0; + size_t lastProgressTotal = 0; + std::string currentUploadName; + std::string lastCompleteName; + unsigned long lastCompleteAt = 0; + bool exitRequested = false; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderServerRunning() const; + + void onWifiSelectionComplete(bool connected); + void startWebServer(); + void stopWebServer(); + + public: + explicit CalibreConnectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onComplete) + : ActivityWithSubactivity("CalibreConnect", renderer, mappedInput), onComplete(onComplete) {} + void onEnter() override; + void onExit() override; + void loop() override; + bool skipLoopDelay() override { return webServer && webServer->isRunning(); } + bool preventAutoSleep() override { return webServer && webServer->isRunning(); } +}; diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp deleted file mode 100644 index 0ad9094a..00000000 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ /dev/null @@ -1,756 +0,0 @@ -#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 deleted file mode 100644 index ae2b1767..00000000 --- a/src/activities/network/CalibreWirelessActivity.h +++ /dev/null @@ -1,135 +0,0 @@ -#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/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 35ad58ba..c6af1497 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -12,6 +12,7 @@ #include "MappedInputManager.h" #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" +#include "activities/network/CalibreConnectActivity.h" #include "fontIds.h" namespace { @@ -125,8 +126,13 @@ void CrossPointWebServerActivity::onExit() { } void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { - Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), - mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot"); + const char* modeName = "Join Network"; + if (mode == NetworkMode::CONNECT_CALIBRE) { + modeName = "Connect to Calibre"; + } else if (mode == NetworkMode::CREATE_HOTSPOT) { + modeName = "Create Hotspot"; + } + Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), modeName); networkMode = mode; isApMode = (mode == NetworkMode::CREATE_HOTSPOT); @@ -134,6 +140,18 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) // Exit mode selection subactivity exitActivity(); + if (mode == NetworkMode::CONNECT_CALIBRE) { + exitActivity(); + enterNewActivity(new CalibreConnectActivity(renderer, mappedInput, [this] { + exitActivity(); + state = WebServerActivityState::MODE_SELECTION; + enterNewActivity(new NetworkModeSelectionActivity( + renderer, mappedInput, [this](const NetworkMode nextMode) { onNetworkModeSelected(nextMode); }, + [this]() { onGoBack(); })); + })); + return; + } + if (mode == NetworkMode::JOIN_NETWORK) { // STA mode - launch WiFi selection Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 775a2474..a1189a57 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -23,7 +23,7 @@ enum class WebServerActivityState { /** * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: - * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) + * - First presents a choice between "Join a Network" (STA), "Connect to Calibre", and "Create Hotspot" (AP) * - For STA mode: Launches WifiSelectionActivity to connect to an existing network * - For AP mode: Creates an Access Point that clients can connect to * - Starts the CrossPointWebServer when connected diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index ad05f5b8..50767084 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -6,10 +6,13 @@ #include "fontIds.h" namespace { -constexpr int MENU_ITEM_COUNT = 2; -const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"}; -const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network", - "Create a WiFi network others can join"}; +constexpr int MENU_ITEM_COUNT = 3; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Connect to Calibre", "Create Hotspot"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = { + "Connect to an existing WiFi network", + "Use Calibre wireless device transfers", + "Create a WiFi network others can join", +}; } // namespace void NetworkModeSelectionActivity::taskTrampoline(void* param) { @@ -58,7 +61,12 @@ void NetworkModeSelectionActivity::loop() { // Handle confirm button - select current option if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT; + NetworkMode mode = NetworkMode::JOIN_NETWORK; + if (selectedIndex == 1) { + mode = NetworkMode::CONNECT_CALIBRE; + } else if (selectedIndex == 2) { + mode = NetworkMode::CREATE_HOTSPOT; + } onModeSelected(mode); return; } diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h index b9f2e1ee..1b93b825 100644 --- a/src/activities/network/NetworkModeSelectionActivity.h +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -8,11 +8,12 @@ #include "../Activity.h" // Enum for network mode selection -enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT }; +enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT }; /** * NetworkModeSelectionActivity presents the user with a choice: * - "Join a Network" - Connect to an existing WiFi network (STA mode) + * - "Connect to Calibre" - Use Calibre wireless device transfers * - "Create Hotspot" - Create an Access Point that others can connect to (AP mode) * * The onModeSelected callback is called with the user's choice. diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 6676d9c0..04d51d61 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -354,8 +354,8 @@ void WifiSelectionActivity::loop() { updateRequired = true; } } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - if (forgetPromptSelection == 0) { - // User chose "Yes" - forget the network + if (forgetPromptSelection == 1) { + // User chose "Forget network" - forget the network xSemaphoreTake(renderingMutex, portMAX_DELAY); WIFI_STORE.removeCredential(selectedSSID); xSemaphoreGive(renderingMutex); @@ -366,7 +366,7 @@ void WifiSelectionActivity::loop() { network->hasSavedPassword = false; } } - // Go back to network list + // Go back to network list (whether Cancel or Forget network was selected) state = WifiSelectionState::NETWORK_LIST; updateRequired = true; } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { @@ -391,7 +391,7 @@ void WifiSelectionActivity::loop() { // If we used saved credentials, offer to forget the network if (usedSavedPassword) { state = WifiSelectionState::FORGET_PROMPT; - forgetPromptSelection = 0; // Default to "Yes" + forgetPromptSelection = 0; // Default to "Cancel" } else { // Go back to network list on failure state = WifiSelectionState::NETWORK_LIST; @@ -623,7 +623,9 @@ void WifiSelectionActivity::renderConnected() const { const std::string ipInfo = "IP Address: " + connectedIP; renderer.drawCenteredText(UI_10_FONT_ID, top + 40, ipInfo.c_str()); - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue"); + // Use centralized button hints + const auto labels = mappedInput.mapLabels("", "Continue", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderSavePrompt() const { @@ -663,7 +665,9 @@ void WifiSelectionActivity::renderSavePrompt() const { renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No"); } - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm"); + // Use centralized button hints + const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderConnectionFailed() const { @@ -673,7 +677,10 @@ void WifiSelectionActivity::renderConnectionFailed() const { renderer.drawCenteredText(UI_12_FONT_ID, top - 20, "Connection Failed", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, top + 20, connectionError.c_str()); - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press any button to continue"); + + // Use centralized button hints + const auto labels = mappedInput.mapLabels("« Back", "Continue", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderForgetPrompt() const { @@ -692,26 +699,28 @@ void WifiSelectionActivity::renderForgetPrompt() const { renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Forget network and remove saved password?"); - // Draw Yes/No buttons + // Draw Cancel/Forget network buttons const int buttonY = top + 80; - constexpr int buttonWidth = 60; + constexpr int buttonWidth = 120; constexpr int buttonSpacing = 30; constexpr int totalWidth = buttonWidth * 2 + buttonSpacing; const int startX = (pageWidth - totalWidth) / 2; - // Draw "Yes" button + // Draw "Cancel" button if (forgetPromptSelection == 0) { - renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Yes]"); + renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Cancel]"); } else { - renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Yes"); + renderer.drawText(UI_10_FONT_ID, startX + 4, buttonY, "Cancel"); } - // Draw "No" button + // Draw "Forget network" button if (forgetPromptSelection == 1) { - renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[No]"); + renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing, buttonY, "[Forget network]"); } else { - renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "No"); + renderer.drawText(UI_10_FONT_ID, startX + buttonWidth + buttonSpacing + 4, buttonY, "Forget network"); } - renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "LEFT/RIGHT: Select | OK: Confirm"); + // Use centralized button hints + const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6ff39c5e..a6d27d34 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -56,12 +56,17 @@ void EpubReaderActivity::onEnter() { FsFile f; if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; - if (f.read(data, 4) == 4) { + uint8_t data[6]; + int dataSize = f.read(data, 6); + if (dataSize == 4 || dataSize == 6) { currentSpineIndex = data[0] + (data[1] << 8); nextPageNumber = data[2] + (data[3] << 8); + cachedSpineIndex = currentSpineIndex; Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber); } + if (dataSize == 6) { + cachedChapterTotalPageCount = data[4] + (data[5] << 8); + } f.close(); } // We may want a better condition to detect if we are opening for the first time. @@ -341,6 +346,17 @@ void EpubReaderActivity::renderScreen() { } else { section->currentPage = nextPageNumber; } + + // handles changes in reader settings and reset to approximate position based on cached progress + if (cachedChapterTotalPageCount > 0) { + // only goes to relative position if spine index matches cached value + if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) { + float progress = static_cast(section->currentPage) / static_cast(cachedChapterTotalPageCount); + int newPage = static_cast(progress * section->pageCount); + section->currentPage = newPage; + } + cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again + } } renderer.clearScreen(); @@ -376,12 +392,14 @@ void EpubReaderActivity::renderScreen() { FsFile f; if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { - uint8_t data[4]; + uint8_t data[6]; data[0] = currentSpineIndex & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF; data[2] = section->currentPage & 0xFF; data[3] = (section->currentPage >> 8) & 0xFF; - f.write(data, 4); + data[4] = section->pageCount & 0xFF; + data[5] = (section->pageCount >> 8) & 0xFF; + f.write(data, 6); f.close(); } } @@ -448,7 +466,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in // Right aligned text for progress counter char progressStr[32]; - snprintf(progressStr, sizeof(progressStr), "%d/%d %.1f%%", section->currentPage + 1, section->pageCount, + snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", section->currentPage + 1, section->pageCount, bookProgress); const std::string progress = progressStr; progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 63d48872..ab4aff2d 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -15,6 +15,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity { int currentSpineIndex = 0; int nextPageNumber = 0; int pagesUntilFullRefresh = 0; + int cachedSpineIndex = 0; + int cachedChapterTotalPageCount = 0; bool updateRequired = false; const std::function onGoBack; const std::function onGoHome; diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index ad4dd2ff..1b35e143 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -188,22 +188,23 @@ void EpubReaderChapterSelectionActivity::renderScreen() { const auto pageStartIndex = selectorIndex / pageItems * pageItems; renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); - for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) { - const int displayY = 60 + (itemIndex % pageItems) * 30; + for (int i = 0; i < pageItems; i++) { + int itemIndex = pageStartIndex + i; + if (itemIndex >= totalItems) break; + const int displayY = 60 + i * 30; const bool isSelected = (itemIndex == selectorIndex); if (isSyncItem(itemIndex)) { - // Draw sync option (at top or bottom) renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected); } else { - // Draw TOC item (account for top sync offset) const int tocIndex = tocIndexFromItemIndex(itemIndex); auto item = epub->getTocItem(tocIndex); + const int indentSize = 20 + (item.level - 1) * 15; const std::string chapterName = renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - indentSize); - renderer.drawText(UI_10_FONT_ID, indentSize, 60 + (tocIndex % pageItems) * 30, chapterName.c_str(), - tocIndex != selectorIndex); + + renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected); } } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 14d6623c..04240b3c 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -22,9 +22,8 @@ bool ReaderActivity::isXtcFile(const std::string& path) { } bool ReaderActivity::isTxtFile(const std::string& path) { - if (path.length() < 4) return false; - std::string ext4 = path.substr(path.length() - 4); - return ext4 == ".txt" || ext4 == ".TXT"; + return StringUtils::checkFileExtension(path, ".txt") || + StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader) } std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index db725320..b7de16d8 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -8,6 +8,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" @@ -55,9 +56,10 @@ void TxtReaderActivity::onEnter() { txt->setupCacheDir(); - // Save current txt as last opened file + // Save current txt as last opened file and add to recent books APP_STATE.openEpubPath = txt->getPath(); APP_STATE.saveToFile(); + RECENT_BOOKS.addBook(txt->getPath()); // Trigger first update updateRequired = true; diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 4f614ffc..d1df9d0e 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -1,20 +1,17 @@ #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"}; +constexpr int MENU_ITEMS = 3; +const char* menuNames[MENU_ITEMS] = {"OPDS Server URL", "Username", "Password"}; } // namespace void CalibreSettingsActivity::taskTrampoline(void* param) { @@ -80,10 +77,10 @@ void CalibreSettingsActivity::handleSelection() { xSemaphoreTake(renderingMutex, portMAX_DELAY); if (selectedIndex == 0) { - // Calibre Web URL + // OPDS Server URL exitActivity(); enterNewActivity(new KeyboardEntryActivity( - renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10, + renderer, mappedInput, "OPDS Server URL", SETTINGS.opdsServerUrl, 10, 127, // maxLength false, // not password [this](const std::string& url) { @@ -98,26 +95,41 @@ void CalibreSettingsActivity::handleSelection() { updateRequired = true; })); } else if (selectedIndex == 1) { - // Wireless Device - launch the activity (handles WiFi connection internally) + // Username 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 { + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Username", SETTINGS.opdsUsername, 10, + 63, // maxLength + false, // not password + [this](const std::string& username) { + strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1); + SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); updateRequired = true; - } - })); - } else { - enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - } + }, + [this]() { + exitActivity(); + updateRequired = true; + })); + } else if (selectedIndex == 2) { + // Password + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Password", SETTINGS.opdsPassword, 10, + 63, // maxLength + false, // not password mode + [this](const std::string& password) { + strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1); + SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + updateRequired = true; + })); } xSemaphoreGive(renderingMutex); @@ -141,24 +153,32 @@ void CalibreSettingsActivity::render() { const auto pageWidth = renderer.getScreenWidth(); // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); + + // Draw info text about Calibre + renderer.drawCenteredText(UI_10_FONT_ID, 40, "For Calibre, add /opds to your URL"); // Draw selection highlight - renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30); + renderer.fillRect(0, 70 + selectedIndex * 30 - 2, pageWidth - 1, 30); // Draw menu items for (int i = 0; i < MENU_ITEMS; i++) { - const int settingY = 60 + i * 30; + const int settingY = 70 + i * 30; const bool isSelected = (i == selectedIndex); renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); - // Draw status for URL setting + // Draw status for each setting + const char* status = "[Not Set]"; 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); + status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; + } else if (i == 1) { + status = (strlen(SETTINGS.opdsUsername) > 0) ? "[Set]" : "[Not Set]"; + } else if (i == 2) { + status = (strlen(SETTINGS.opdsPassword) > 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 diff --git a/src/activities/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h index 77b9218c..49695c62 100644 --- a/src/activities/settings/CalibreSettingsActivity.h +++ b/src/activities/settings/CalibreSettingsActivity.h @@ -8,8 +8,8 @@ #include "activities/ActivityWithSubactivity.h" /** - * Submenu for Calibre settings. - * Shows Calibre Web URL and Calibre Wireless Device options. + * Submenu for OPDS Browser settings. + * Shows OPDS Server URL and HTTP authentication options. */ class CalibreSettingsActivity final : public ActivityWithSubactivity { public: diff --git a/src/activities/settings/CategorySettingsActivity.cpp b/src/activities/settings/CategorySettingsActivity.cpp index a6182b5c..7fd5ef5f 100644 --- a/src/activities/settings/CategorySettingsActivity.cpp +++ b/src/activities/settings/CategorySettingsActivity.cpp @@ -103,7 +103,7 @@ void CategorySettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); - } else if (strcmp(setting.name, "Calibre Settings") == 0) { + } else if (strcmp(setting.name, "OPDS Browser") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { diff --git a/src/activities/settings/KOReaderSettingsActivity.cpp b/src/activities/settings/KOReaderSettingsActivity.cpp index 6eb22c8e..71003433 100644 --- a/src/activities/settings/KOReaderSettingsActivity.cpp +++ b/src/activities/settings/KOReaderSettingsActivity.cpp @@ -194,7 +194,7 @@ void KOReaderSettingsActivity::render() { } else if (i == 1) { status = KOREADER_STORE.getPassword().empty() ? "[Not Set]" : "[Set]"; } else if (i == 2) { - status = KOREADER_STORE.getServerUrl().empty() ? "[Not Set]" : "[Set]"; + status = KOREADER_STORE.getServerUrl().empty() ? "[Default]" : "[Custom]"; } else if (i == 3) { status = KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? "[Filename]" : "[Binary]"; } else if (i == 4) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 943fdb4c..819115a5 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -37,8 +37,9 @@ const SettingInfo readerSettings[readerSettingsCount] = { constexpr int controlsSettingsCount = 4; const SettingInfo controlsSettings[controlsSettingsCount] = { - SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, - {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), + SettingInfo::Enum( + "Front Button Layout", &CrossPointSettings::frontButtonLayout, + {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}), SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}), SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), @@ -48,7 +49,7 @@ constexpr int systemSettingsCount = 5; const SettingInfo systemSettings[systemSettingsCount] = { SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, {"1 min", "5 min", "10 min", "15 min", "30 min"}), - SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Clear Cache"), + SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"), SettingInfo::Action("Check for updates")}; } // namespace diff --git a/src/main.cpp b/src/main.cpp index c0222e0d..8a081fd8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -151,8 +151,15 @@ void enterNewActivity(Activity* activity) { currentActivity->onEnter(); } -// Verify long press on wake-up from deep sleep -void verifyWakeupLongPress() { +// Verify power button press duration on wake-up from deep sleep +// Pre-condition: isWakeupByPowerButton() == true +void verifyPowerButtonDuration() { + if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) { + // Fast path for short press + // Needed because inputManager.isPressed() may take up to ~500ms to return the correct state + return; + } + // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration() const auto start = millis(); bool abort = false; @@ -165,6 +172,7 @@ void verifyWakeupLongPress() { inputManager.update(); // Verify the user has actually pressed + // Needed because inputManager.isPressed() may take up to ~500ms to return the correct state while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) { delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration. inputManager.update(); @@ -281,11 +289,14 @@ bool isUsbConnected() { return digitalRead(UART0_RXD) == HIGH; } -bool isWakeupAfterFlashing() { +bool isWakeupByPowerButton() { const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto resetReason = esp_reset_reason(); - - return isUsbConnected() && (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_UNKNOWN); + if (isUsbConnected()) { + return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; + } else { + return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); + } } void setup() { @@ -322,9 +333,10 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); - if (!isWakeupAfterFlashing()) { - // For normal wakeups (not immediately after flashing), verify long press - verifyWakeupLongPress(); + if (isWakeupByPowerButton()) { + // For normal wakeups, verify power button press duration + Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); + verifyPowerButtonDuration(); } // First serial output only here to avoid timing inconsistencies for power button press duration verification diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 90dfed7b..a135c9f0 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -18,6 +18,8 @@ namespace { // Note: Items starting with "." are automatically hidden const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); +constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; +constexpr uint16_t LOCAL_UDP_PORT = 8134; // Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) CrossPointWebServer* wsInstance = nullptr; @@ -30,6 +32,9 @@ size_t wsUploadSize = 0; size_t wsUploadReceived = 0; unsigned long wsUploadStartTime = 0; bool wsUploadInProgress = false; +String wsLastCompleteName; +size_t wsLastCompleteSize = 0; +unsigned long wsLastCompleteAt = 0; // Helper function to clear epub cache after upload void clearEpubCacheIfNeeded(const String& filePath) { @@ -96,6 +101,7 @@ void CrossPointWebServer::begin() { server->on("/api/status", HTTP_GET, [this] { handleStatus(); }); server->on("/api/files", HTTP_GET, [this] { handleFileListData(); }); + server->on("/download", HTTP_GET, [this] { handleDownload(); }); // Upload endpoint with special handling for multipart form data server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); }); @@ -119,6 +125,10 @@ void CrossPointWebServer::begin() { wsServer->onEvent(wsEventCallback); Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); + udpActive = udp.begin(LOCAL_UDP_PORT); + Serial.printf("[%lu] [WEB] Discovery UDP %s on port %d\n", millis(), udpActive ? "enabled" : "failed", + LOCAL_UDP_PORT); + running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); @@ -156,6 +166,11 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); } + if (udpActive) { + udp.stop(); + udpActive = false; + } + // Brief delay to allow any in-flight handleClient() calls to complete delay(20); @@ -174,7 +189,7 @@ void CrossPointWebServer::stop() { Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); } -void CrossPointWebServer::handleClient() const { +void CrossPointWebServer::handleClient() { static unsigned long lastDebugPrint = 0; // Check running flag FIRST before accessing server @@ -200,6 +215,40 @@ void CrossPointWebServer::handleClient() const { if (wsServer) { wsServer->loop(); } + + // Respond to discovery broadcasts + if (udpActive) { + int packetSize = udp.parsePacket(); + if (packetSize > 0) { + char buffer[16]; + int len = udp.read(buffer, sizeof(buffer) - 1); + if (len > 0) { + buffer[len] = '\0'; + if (strcmp(buffer, "hello") == 0) { + String hostname = WiFi.getHostname(); + if (hostname.isEmpty()) { + hostname = "crosspoint"; + } + String message = "crosspoint (on " + hostname + ");" + String(wsPort); + udp.beginPacket(udp.remoteIP(), udp.remotePort()); + udp.write(reinterpret_cast(message.c_str()), message.length()); + udp.endPacket(); + } + } + } + } +} + +CrossPointWebServer::WsUploadStatus CrossPointWebServer::getWsUploadStatus() const { + WsUploadStatus status; + status.inProgress = wsUploadInProgress; + status.received = wsUploadReceived; + status.total = wsUploadSize; + status.filename = wsUploadFileName.c_str(); + status.lastCompleteName = wsLastCompleteName.c_str(); + status.lastCompleteSize = wsLastCompleteSize; + status.lastCompleteAt = wsLastCompleteAt; + return status; } void CrossPointWebServer::handleRoot() const { @@ -346,6 +395,69 @@ void CrossPointWebServer::handleFileListData() const { Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); } +void CrossPointWebServer::handleDownload() const { + if (!server->hasArg("path")) { + server->send(400, "text/plain", "Missing path"); + return; + } + + String itemPath = server->arg("path"); + if (itemPath.isEmpty() || itemPath == "/") { + server->send(400, "text/plain", "Invalid path"); + return; + } + if (!itemPath.startsWith("/")) { + itemPath = "/" + itemPath; + } + + const String itemName = itemPath.substring(itemPath.lastIndexOf('/') + 1); + if (itemName.startsWith(".")) { + server->send(403, "text/plain", "Cannot access system files"); + return; + } + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (itemName.equals(HIDDEN_ITEMS[i])) { + server->send(403, "text/plain", "Cannot access protected items"); + return; + } + } + + if (!SdMan.exists(itemPath.c_str())) { + server->send(404, "text/plain", "Item not found"); + return; + } + + FsFile file = SdMan.open(itemPath.c_str()); + if (!file) { + server->send(500, "text/plain", "Failed to open file"); + return; + } + if (file.isDirectory()) { + file.close(); + server->send(400, "text/plain", "Path is a directory"); + return; + } + + String contentType = "application/octet-stream"; + if (isEpubFile(itemPath)) { + contentType = "application/epub+zip"; + } + + char nameBuf[128] = {0}; + String filename = "download"; + if (file.getName(nameBuf, sizeof(nameBuf))) { + filename = nameBuf; + } + + server->setContentLength(file.size()); + server->sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); + server->send(200, contentType.c_str(), ""); + + WiFiClient client = server->client(); + client.write(file); + file.close(); +} + // Static variables for upload handling static FsFile uploadFile; static String uploadFileName; @@ -798,6 +910,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsUploadFile.close(); wsUploadInProgress = false; + wsLastCompleteName = wsUploadFileName; + wsLastCompleteSize = wsUploadSize; + wsLastCompleteAt = millis(); + unsigned long elapsed = millis() - wsUploadStartTime; float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index ecc2d3d2..36030292 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -2,7 +2,10 @@ #include #include +#include +#include +#include #include // Structure to hold file information @@ -15,6 +18,16 @@ struct FileInfo { class CrossPointWebServer { public: + struct WsUploadStatus { + bool inProgress = false; + size_t received = 0; + size_t total = 0; + std::string filename; + std::string lastCompleteName; + size_t lastCompleteSize = 0; + unsigned long lastCompleteAt = 0; + }; + CrossPointWebServer(); ~CrossPointWebServer(); @@ -25,11 +38,13 @@ class CrossPointWebServer { void stop(); // Call this periodically to handle client requests - void handleClient() const; + void handleClient(); // Check if server is running bool isRunning() const { return running; } + WsUploadStatus getWsUploadStatus() const; + // Get the port number uint16_t getPort() const { return port; } @@ -40,6 +55,8 @@ class CrossPointWebServer { bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; uint16_t wsPort = 81; // WebSocket port + WiFiUDP udp; + bool udpActive = false; // WebSocket upload state void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); @@ -56,6 +73,7 @@ class CrossPointWebServer { void handleStatus() const; void handleFileList() const; void handleFileListData() const; + void handleDownload() const; void handleUpload() const; void handleUploadPost() const; void handleCreateFolder() const; diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index fe65ea6b..b7718c2d 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -5,9 +5,12 @@ #include #include #include +#include +#include #include +#include "CrossPointSettings.h" #include "util/UrlUtils.h" bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { @@ -28,6 +31,13 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + // Add Basic HTTP auth if credentials are configured + if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { + std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + String encoded = base64::encode(credentials.c_str()); + http.addHeader("Authorization", "Basic " + encoded); + } + const int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode); @@ -72,6 +82,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + // Add Basic HTTP auth if credentials are configured + if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { + std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + String encoded = base64::encode(credentials.c_str()); + http.addHeader("Authorization", "Basic " + encoded); + } + const int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode);