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);