mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 15:17:37 +03:00
Merge branch 'master' into fork/jonasdiemer/fix/487-failed-load-settings
This commit is contained in:
commit
2a15f714ea
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,5 +4,6 @@
|
|||||||
.vscode
|
.vscode
|
||||||
lib/EpdFont/fontsrc
|
lib/EpdFont/fontsrc
|
||||||
*.generated.h
|
*.generated.h
|
||||||
|
.vs
|
||||||
build
|
build
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
@ -41,6 +41,8 @@ This project is **not affiliated with Xteink**; it's built as a community projec
|
|||||||
- [ ] Full UTF support
|
- [ ] Full UTF support
|
||||||
- [x] Screen rotation
|
- [x] Screen rotation
|
||||||
|
|
||||||
|
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.
|
See [the user guide](./USER_GUIDE.md) for instructions on operating CrossPoint.
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|||||||
@ -81,6 +81,18 @@ See the [webserver docs](./docs/webserver.md) for more information on how to con
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details.
|
> 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
|
### 3.5 Settings
|
||||||
|
|
||||||
The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust:
|
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)
|
- Back, Confirm, Left, Right (default)
|
||||||
- Left, Right, Back, Confirm
|
- Left, Right, Back, Confirm
|
||||||
- Left, Back, Confirm, Right
|
- 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.
|
- **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.
|
- **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
|
- "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".
|
- **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.
|
- **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.
|
- **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.
|
- **Check for updates**: Check for firmware updates over WiFi.
|
||||||
|
|
||||||
### 3.6 Sleep Screen
|
### 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.
|
* **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)**.
|
* **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
|
## 5. Chapter Selection Screen
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_12_bold
|
* name: bookerly_12_bold
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_12_bold 12 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_12_bolditalic
|
* name: bookerly_12_bolditalic
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_12_bolditalic 12 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_12_italic
|
* name: bookerly_12_italic
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_12_italic 12 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_12_regular
|
* name: bookerly_12_regular
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_12_regular 12 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_14_bold
|
* name: bookerly_14_bold
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_14_bold 14 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_14_bolditalic
|
* name: bookerly_14_bolditalic
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_14_bolditalic 14 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_14_italic
|
* name: bookerly_14_italic
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_14_italic 14 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_14_regular
|
* name: bookerly_14_regular
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_14_regular 14 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_16_bold
|
* name: bookerly_16_bold
|
||||||
* size: 16
|
* size: 16
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_16_bold 16 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_16_bolditalic
|
* name: bookerly_16_bolditalic
|
||||||
* size: 16
|
* size: 16
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_16_bolditalic 16 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_16_italic
|
* name: bookerly_16_italic
|
||||||
* size: 16
|
* size: 16
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_16_italic 16 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_16_regular
|
* name: bookerly_16_regular
|
||||||
* size: 16
|
* size: 16
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_16_regular 16 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_18_bold
|
* name: bookerly_18_bold
|
||||||
* size: 18
|
* size: 18
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_18_bold 18 ../builtinFonts/source/Bookerly/Bookerly-Bold.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_18_bolditalic
|
* name: bookerly_18_bolditalic
|
||||||
* size: 18
|
* size: 18
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_18_bolditalic 18 ../builtinFonts/source/Bookerly/Bookerly-BoldItalic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_18_italic
|
* name: bookerly_18_italic
|
||||||
* size: 18
|
* size: 18
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_18_italic 18 ../builtinFonts/source/Bookerly/Bookerly-Italic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: bookerly_18_regular
|
* name: bookerly_18_regular
|
||||||
* size: 18
|
* size: 18
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py bookerly_18_regular 18 ../builtinFonts/source/Bookerly/Bookerly-Regular.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_12_bold
|
* name: notosans_12_bold
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_12_bold 12 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_12_bolditalic
|
* name: notosans_12_bolditalic
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_12_bolditalic 12 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_12_italic
|
* name: notosans_12_italic
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_12_italic 12 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_12_regular
|
* name: notosans_12_regular
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_12_regular 12 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_14_bold
|
* name: notosans_14_bold
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_14_bold 14 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_14_bolditalic
|
* name: notosans_14_bolditalic
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_14_bolditalic 14 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_14_italic
|
* name: notosans_14_italic
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_14_italic 14 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_14_regular
|
* name: notosans_14_regular
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_14_regular 14 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_16_bold
|
* name: notosans_16_bold
|
||||||
* size: 16
|
* size: 16
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_16_bold 16 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_16_bolditalic
|
* name: notosans_16_bolditalic
|
||||||
* size: 16
|
* size: 16
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_16_bolditalic 16 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_16_italic
|
* name: notosans_16_italic
|
||||||
* size: 16
|
* size: 16
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_16_italic 16 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_16_regular
|
* name: notosans_16_regular
|
||||||
* size: 16
|
* size: 16
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_16_regular 16 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_18_bold
|
* name: notosans_18_bold
|
||||||
* size: 18
|
* size: 18
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_18_bold 18 ../builtinFonts/source/NotoSans/NotoSans-Bold.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_18_bolditalic
|
* name: notosans_18_bolditalic
|
||||||
* size: 18
|
* size: 18
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_18_bolditalic 18 ../builtinFonts/source/NotoSans/NotoSans-BoldItalic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_18_italic
|
* name: notosans_18_italic
|
||||||
* size: 18
|
* size: 18
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_18_italic 18 ../builtinFonts/source/NotoSans/NotoSans-Italic.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_18_regular
|
* name: notosans_18_regular
|
||||||
* size: 18
|
* size: 18
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py notosans_18_regular 18 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: notosans_8_regular
|
* name: notosans_8_regular
|
||||||
* size: 8
|
* size: 8
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py notosans_8_regular 8 ../builtinFonts/source/NotoSans/NotoSans-Regular.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_10_bold
|
* name: opendyslexic_10_bold
|
||||||
* size: 10
|
* size: 10
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_10_bold 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_10_bolditalic
|
* name: opendyslexic_10_bolditalic
|
||||||
* size: 10
|
* size: 10
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_10_bolditalic 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_10_italic
|
* name: opendyslexic_10_italic
|
||||||
* size: 10
|
* size: 10
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_10_italic 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_10_regular
|
* name: opendyslexic_10_regular
|
||||||
* size: 10
|
* size: 10
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_10_regular 10 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_12_bold
|
* name: opendyslexic_12_bold
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_12_bold 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_12_bolditalic
|
* name: opendyslexic_12_bolditalic
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_12_bolditalic 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_12_italic
|
* name: opendyslexic_12_italic
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_12_italic 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_12_regular
|
* name: opendyslexic_12_regular
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_12_regular 12 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_14_bold
|
* name: opendyslexic_14_bold
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_14_bold 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_14_bolditalic
|
* name: opendyslexic_14_bolditalic
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_14_bolditalic 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_14_italic
|
* name: opendyslexic_14_italic
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_14_italic 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_14_regular
|
* name: opendyslexic_14_regular
|
||||||
* size: 14
|
* size: 14
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_14_regular 14 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_8_bold
|
* name: opendyslexic_8_bold
|
||||||
* size: 8
|
* size: 8
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_8_bold 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Bold.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_8_bolditalic
|
* name: opendyslexic_8_bolditalic
|
||||||
* size: 8
|
* size: 8
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_8_bolditalic 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-BoldItalic.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_8_italic
|
* name: opendyslexic_8_italic
|
||||||
* size: 8
|
* size: 8
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_8_italic 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Italic.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: opendyslexic_8_regular
|
* name: opendyslexic_8_regular
|
||||||
* size: 8
|
* size: 8
|
||||||
* mode: 2-bit
|
* mode: 2-bit
|
||||||
|
* Command used: fontconvert.py opendyslexic_8_regular 8 ../builtinFonts/source/OpenDyslexic/OpenDyslexic-Regular.otf --2bit
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: ubuntu_10_bold
|
* name: ubuntu_10_bold
|
||||||
* size: 10
|
* size: 10
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py ubuntu_10_bold 10 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: ubuntu_10_regular
|
* name: ubuntu_10_regular
|
||||||
* size: 10
|
* size: 10
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py ubuntu_10_regular 10 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: ubuntu_12_bold
|
* name: ubuntu_12_bold
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py ubuntu_12_bold 12 ../builtinFonts/source/Ubuntu/Ubuntu-Bold.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
* name: ubuntu_12_regular
|
* name: ubuntu_12_regular
|
||||||
* size: 12
|
* size: 12
|
||||||
* mode: 1-bit
|
* mode: 1-bit
|
||||||
|
* Command used: fontconvert.py ubuntu_12_regular 12 ../builtinFonts/source/Ubuntu/Ubuntu-Regular.ttf
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "EpdFontData.h"
|
#include "EpdFontData.h"
|
||||||
|
|||||||
@ -270,9 +270,17 @@ for index, glyph in enumerate(all_glyphs):
|
|||||||
glyph_data.extend([b for b in packed])
|
glyph_data.extend([b for b in packed])
|
||||||
glyph_props.append(props)
|
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(f"""/**
|
||||||
print("#pragma once")
|
* generated by fontconvert.py
|
||||||
print("#include \"EpdFontData.h\"\n")
|
* 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)}] = {{")
|
print(f"static const uint8_t {font_name}Bitmaps[{len(glyph_data)}] = {{")
|
||||||
for c in chunks(glyph_data, 16):
|
for c in chunks(glyph_data, 16):
|
||||||
print (" " + " ".join(f"0x{b:02X}," for b in c))
|
print (" " + " ".join(f"0x{b:02X}," for b in c))
|
||||||
|
|||||||
@ -359,7 +359,7 @@ const std::string& Epub::getLanguage() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string Epub::getCoverBmpPath(bool cropped) 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";
|
return cachePath + "/" + coverFileName + ".bmp";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,7 +382,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
|||||||
|
|
||||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
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";
|
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||||
|
|
||||||
FsFile coverJpg;
|
FsFile coverJpg;
|
||||||
@ -401,7 +401,7 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
|||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp);
|
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
|
||||||
coverJpg.close();
|
coverJpg.close();
|
||||||
coverBmp.close();
|
coverBmp.close();
|
||||||
SdMan.remove(coverJpgTempPath.c_str());
|
SdMan.remove(coverJpgTempPath.c_str());
|
||||||
|
|||||||
@ -125,6 +125,8 @@ bool isExplicitHyphen(const uint32_t cp) {
|
|||||||
case 0xFE58: // small em dash
|
case 0xFE58: // small em dash
|
||||||
case 0xFE63: // small hyphen-minus
|
case 0xFE63: // small hyphen-minus
|
||||||
case 0xFF0D: // fullwidth hyphen-minus
|
case 0xFF0D: // fullwidth hyphen-minus
|
||||||
|
case 0x005F: // Underscore
|
||||||
|
case 0x2026: // Ellipsis
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -40,6 +40,23 @@ bool matches(const char* tag_name, const char* possible_tags[], const int possib
|
|||||||
return false;
|
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
|
// start a new text block if needed
|
||||||
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) {
|
void ChapterHtmlSlimParser::startNewTextBlock(const TextBlock::Style style) {
|
||||||
if (currentTextBlock) {
|
if (currentTextBlock) {
|
||||||
@ -83,7 +100,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
if (atts != nullptr) {
|
if (atts != nullptr) {
|
||||||
for (int i = 0; atts[i]; i += 2) {
|
for (int i = 0; atts[i]; i += 2) {
|
||||||
if (strcmp(atts[i], "alt") == 0) {
|
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());
|
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->italicUntilDepth = min(self->italicUntilDepth, self->depth);
|
||||||
self->depth += 1;
|
self->depth += 1;
|
||||||
self->characterData(userData, alt.c_str(), alt.length());
|
self->characterData(userData, alt.c_str(), alt.length());
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Skip for now
|
// Skip for now
|
||||||
self->skipUntilDepth = self->depth;
|
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);
|
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||||
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
|
} else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) {
|
||||||
if (strcmp(name, "br") == 0) {
|
if (strcmp(name, "br") == 0) {
|
||||||
|
if (self->partWordBufferIndex > 0) {
|
||||||
|
// flush word preceding <br/> to currentTextBlock before calling startNewTextBlock
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
}
|
||||||
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
||||||
} else {
|
} else {
|
||||||
self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment);
|
self->startNewTextBlock((TextBlock::Style)self->paragraphAlignment);
|
||||||
@ -149,22 +173,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
|||||||
return;
|
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++) {
|
for (int i = 0; i < len; i++) {
|
||||||
if (isWhitespace(s[i])) {
|
if (isWhitespace(s[i])) {
|
||||||
// Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
|
// Currently looking at whitespace, if there's anything in the partWordBuffer, flush it
|
||||||
if (self->partWordBufferIndex > 0) {
|
if (self->partWordBufferIndex > 0) {
|
||||||
self->partWordBuffer[self->partWordBufferIndex] = '\0';
|
self->flushPartWordBuffer();
|
||||||
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
|
|
||||||
self->partWordBufferIndex = 0;
|
|
||||||
}
|
}
|
||||||
// Skip the whitespace char
|
// Skip the whitespace char
|
||||||
continue;
|
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 we're about to run out of space, then cut the word off and start a new one
|
||||||
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
|
if (self->partWordBufferIndex >= MAX_WORD_SIZE) {
|
||||||
self->partWordBuffer[self->partWordBufferIndex] = '\0';
|
self->flushPartWordBuffer();
|
||||||
self->currentTextBlock->addWord(self->partWordBuffer, fontStyle);
|
|
||||||
self->partWordBufferIndex = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
|
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;
|
matches(name, BOLD_TAGS, NUM_BOLD_TAGS) || matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) || self->depth == 1;
|
||||||
|
|
||||||
if (shouldBreakText) {
|
if (shouldBreakText) {
|
||||||
EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR;
|
self->flushPartWordBuffer();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ class ChapterHtmlSlimParser {
|
|||||||
bool hyphenationEnabled;
|
bool hyphenationEnabled;
|
||||||
|
|
||||||
void startNewTextBlock(TextBlock::Style style);
|
void startNewTextBlock(TextBlock::Style style);
|
||||||
|
void flushPartWordBuffer();
|
||||||
void makePages();
|
void makePages();
|
||||||
// XML callbacks
|
// XML callbacks
|
||||||
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts);
|
||||||
|
|||||||
@ -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 {
|
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 rotatedX = 0;
|
||||||
int rotatedY = 0;
|
int rotatedY = 0;
|
||||||
rotateCoordinates(x, y, &rotatedX, &rotatedY);
|
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);
|
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -200,7 +200,7 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un
|
|||||||
|
|
||||||
// Internal implementation with configurable target size and bit depth
|
// Internal implementation with configurable target size and bit depth
|
||||||
bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
|
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",
|
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit",
|
||||||
targetWidth, targetHeight);
|
targetWidth, targetHeight);
|
||||||
|
|
||||||
@ -242,8 +242,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
|
|||||||
const float scaleToFitWidth = static_cast<float>(targetWidth) / imageInfo.m_width;
|
const float scaleToFitWidth = static_cast<float>(targetWidth) / imageInfo.m_width;
|
||||||
const float scaleToFitHeight = static_cast<float>(targetHeight) / imageInfo.m_height;
|
const float scaleToFitHeight = static_cast<float>(targetHeight) / imageInfo.m_height;
|
||||||
// We scale to the smaller dimension, so we can potentially crop later.
|
// We scale to the smaller dimension, so we can potentially crop later.
|
||||||
// TODO: ideally, we already crop here.
|
float scale = 1.0;
|
||||||
const float scale = (scaleToFitWidth > scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight;
|
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<int>(imageInfo.m_width * scale);
|
outWidth = static_cast<int>(imageInfo.m_width * scale);
|
||||||
outHeight = static_cast<int>(imageInfo.m_height * scale);
|
outHeight = static_cast<int>(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)
|
// Core function: Convert JPEG file to 2-bit BMP (uses default target size)
|
||||||
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) {
|
bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut, bool crop) {
|
||||||
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false);
|
return jpegFileToBmpStreamInternal(jpegFile, bmpOut, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT, false, crop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert with custom target size (for thumbnails, 2-bit)
|
// Convert with custom target size (for thumbnails, 2-bit)
|
||||||
|
|||||||
@ -8,10 +8,10 @@ class JpegToBmpConverter {
|
|||||||
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size,
|
||||||
unsigned char* pBytes_actually_read, void* pCallback_data);
|
unsigned char* pBytes_actually_read, void* pCallback_data);
|
||||||
static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
|
static bool jpegFileToBmpStreamInternal(class FsFile& jpegFile, Print& bmpOut, int targetWidth, int targetHeight,
|
||||||
bool oneBit);
|
bool oneBit, bool crop = true);
|
||||||
|
|
||||||
public:
|
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)
|
// Convert with custom target size (for thumbnails)
|
||||||
static bool jpegFileToBmpStreamWithSize(FsFile& jpegFile, Print& bmpOut, int targetMaxWidth, int targetMaxHeight);
|
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
|
// Convert to 1-bit BMP (black and white only, no grays) for fast home screen rendering
|
||||||
|
|||||||
@ -33,10 +33,10 @@ std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePat
|
|||||||
|
|
||||||
size_t KOReaderDocumentId::getOffset(int i) {
|
size_t KOReaderDocumentId::getOffset(int i) {
|
||||||
// Offset = 1024 << (2*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)
|
// For i >= 0: 1024 << (2*i)
|
||||||
if (i < 0) {
|
if (i < 0) {
|
||||||
return CHUNK_SIZE >> (-2 * i);
|
return 0;
|
||||||
}
|
}
|
||||||
return CHUNK_SIZE << (2 * i);
|
return CHUNK_SIZE << (2 * i);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
#include "Xtc.h"
|
#include "Xtc.h"
|
||||||
|
|
||||||
#include <FsHelpers.h>
|
|
||||||
#include <HardwareSerial.h>
|
#include <HardwareSerial.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
@ -87,6 +86,15 @@ std::string Xtc::getTitle() const {
|
|||||||
return filepath.substr(lastSlash, lastDot - lastSlash);
|
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 {
|
bool Xtc::hasChapters() const {
|
||||||
if (!loaded || !parser) {
|
if (!loaded || !parser) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -56,6 +56,7 @@ class Xtc {
|
|||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
std::string getTitle() const;
|
std::string getTitle() const;
|
||||||
|
std::string getAuthor() const;
|
||||||
bool hasChapters() const;
|
bool hasChapters() const;
|
||||||
const std::vector<xtc::ChapterInfo>& getChapters() const;
|
const std::vector<xtc::ChapterInfo>& getChapters() const;
|
||||||
|
|
||||||
|
|||||||
@ -47,8 +47,21 @@ XtcError XtcParser::open(const char* filepath) {
|
|||||||
return m_lastError;
|
return m_lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read title if available
|
// Read title & author if available
|
||||||
readTitle();
|
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
|
// Read page table
|
||||||
m_lastError = readPageTable();
|
m_lastError = readPageTable();
|
||||||
@ -124,24 +137,34 @@ XtcError XtcParser::readHeader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
XtcError XtcParser::readTitle() {
|
XtcError XtcParser::readTitle() {
|
||||||
// Title is usually at offset 0x38 (56) for 88-byte headers
|
constexpr auto titleOffset = 0x38;
|
||||||
// Read title as null-terminated UTF-8 string
|
if (!m_file.seek(titleOffset)) {
|
||||||
if (m_header.titleOffset == 0) {
|
|
||||||
m_header.titleOffset = 0x38; // Default offset
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!m_file.seek(m_header.titleOffset)) {
|
|
||||||
return XtcError::READ_ERROR;
|
return XtcError::READ_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
char titleBuf[128] = {0};
|
char titleBuf[128] = {0};
|
||||||
m_file.read(reinterpret_cast<uint8_t*>(titleBuf), sizeof(titleBuf) - 1);
|
m_file.read(titleBuf, sizeof(titleBuf) - 1);
|
||||||
m_title = titleBuf;
|
m_title = titleBuf;
|
||||||
|
|
||||||
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str());
|
Serial.printf("[%lu] [XTC] Title: %s\n", millis(), m_title.c_str());
|
||||||
return XtcError::OK;
|
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() {
|
XtcError XtcParser::readPageTable() {
|
||||||
if (m_header.pageTableOffset == 0) {
|
if (m_header.pageTableOffset == 0) {
|
||||||
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis());
|
Serial.printf("[%lu] [XTC] Page table offset is 0, cannot read\n", millis());
|
||||||
|
|||||||
@ -67,8 +67,9 @@ class XtcParser {
|
|||||||
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
std::function<void(const uint8_t* data, size_t size, size_t offset)> callback,
|
||||||
size_t chunkSize = 1024);
|
size_t chunkSize = 1024);
|
||||||
|
|
||||||
// Get title from metadata
|
// Get title/author from metadata
|
||||||
std::string getTitle() const { return m_title; }
|
std::string getTitle() const { return m_title; }
|
||||||
|
std::string getAuthor() const { return m_author; }
|
||||||
|
|
||||||
bool hasChapters() const { return m_hasChapters; }
|
bool hasChapters() const { return m_hasChapters; }
|
||||||
const std::vector<ChapterInfo>& getChapters() const { return m_chapters; }
|
const std::vector<ChapterInfo>& getChapters() const { return m_chapters; }
|
||||||
@ -86,6 +87,7 @@ class XtcParser {
|
|||||||
std::vector<PageInfo> m_pageTable;
|
std::vector<PageInfo> m_pageTable;
|
||||||
std::vector<ChapterInfo> m_chapters;
|
std::vector<ChapterInfo> m_chapters;
|
||||||
std::string m_title;
|
std::string m_title;
|
||||||
|
std::string m_author;
|
||||||
uint16_t m_defaultWidth;
|
uint16_t m_defaultWidth;
|
||||||
uint16_t m_defaultHeight;
|
uint16_t m_defaultHeight;
|
||||||
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
|
uint8_t m_bitDepth; // 1 = XTC/XTG (1-bit), 2 = XTCH/XTH (2-bit)
|
||||||
@ -96,6 +98,7 @@ class XtcParser {
|
|||||||
XtcError readHeader();
|
XtcError readHeader();
|
||||||
XtcError readPageTable();
|
XtcError readPageTable();
|
||||||
XtcError readTitle();
|
XtcError readTitle();
|
||||||
|
XtcError readAuthor();
|
||||||
XtcError readChapters();
|
XtcError readChapters();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -38,14 +38,16 @@ struct XtcHeader {
|
|||||||
uint8_t versionMajor; // 0x04: Format version major (typically 1) (together with minor = 1.0)
|
uint8_t versionMajor; // 0x04: Format version major (typically 1) (together with minor = 1.0)
|
||||||
uint8_t versionMinor; // 0x05: Format version minor (typically 0)
|
uint8_t versionMinor; // 0x05: Format version minor (typically 0)
|
||||||
uint16_t pageCount; // 0x06: Total page count
|
uint16_t pageCount; // 0x06: Total page count
|
||||||
uint32_t flags; // 0x08: Flags/reserved
|
uint8_t readDirection; // 0x08: Reading direction (0-2)
|
||||||
uint32_t headerSize; // 0x0C: Size of header section (typically 88)
|
uint8_t hasMetadata; // 0x09: Has metadata (0-1)
|
||||||
uint32_t reserved1; // 0x10: Reserved
|
uint8_t hasThumbnails; // 0x0A: Has thumbnails (0-1)
|
||||||
uint32_t tocOffset; // 0x14: TOC offset (0 if unused) - 4 bytes, not 8!
|
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 pageTableOffset; // 0x18: Page table offset
|
||||||
uint64_t dataOffset; // 0x20: First page data offset
|
uint64_t dataOffset; // 0x20: First page data offset
|
||||||
uint64_t reserved2; // 0x28: Reserved
|
uint64_t thumbOffset; // 0x28: Thumbnail offset
|
||||||
uint32_t titleOffset; // 0x30: Title string offset
|
uint32_t chapterOffset; // 0x30: Chapter data offset
|
||||||
uint32_t padding; // 0x34: Padding to 56 bytes
|
uint32_t padding; // 0x34: Padding to 56 bytes
|
||||||
};
|
};
|
||||||
#pragma pack(pop)
|
#pragma pack(pop)
|
||||||
|
|||||||
@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||||
// Increment this when adding new persisted settings fields
|
// 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";
|
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@ -57,6 +57,9 @@ bool CrossPointSettings::saveToFile() const {
|
|||||||
serialization::writePod(outputFile, hideBatteryPercentage);
|
serialization::writePod(outputFile, hideBatteryPercentage);
|
||||||
serialization::writePod(outputFile, longPressChapterSkip);
|
serialization::writePod(outputFile, longPressChapterSkip);
|
||||||
serialization::writePod(outputFile, hyphenationEnabled);
|
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();
|
outputFile.close();
|
||||||
|
|
||||||
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
|
||||||
@ -128,6 +131,21 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, hyphenationEnabled);
|
serialization::readPod(inputFile, hyphenationEnabled);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
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);
|
} while (false);
|
||||||
|
|
||||||
inputFile.close();
|
inputFile.close();
|
||||||
|
|||||||
@ -36,6 +36,7 @@ class CrossPointSettings {
|
|||||||
BACK_CONFIRM_LEFT_RIGHT = 0,
|
BACK_CONFIRM_LEFT_RIGHT = 0,
|
||||||
LEFT_RIGHT_BACK_CONFIRM = 1,
|
LEFT_RIGHT_BACK_CONFIRM = 1,
|
||||||
LEFT_BACK_CONFIRM_RIGHT = 2,
|
LEFT_BACK_CONFIRM_RIGHT = 2,
|
||||||
|
BACK_CONFIRM_RIGHT_LEFT = 3,
|
||||||
FRONT_BUTTON_LAYOUT_COUNT
|
FRONT_BUTTON_LAYOUT_COUNT
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -115,6 +116,8 @@ class CrossPointSettings {
|
|||||||
uint8_t screenMargin = 5;
|
uint8_t screenMargin = 5;
|
||||||
// OPDS browser settings
|
// OPDS browser settings
|
||||||
char opdsServerUrl[128] = "";
|
char opdsServerUrl[128] = "";
|
||||||
|
char opdsUsername[64] = "";
|
||||||
|
char opdsPassword[64] = "";
|
||||||
// Hide battery percentage
|
// Hide battery percentage
|
||||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||||
// Long-press chapter skip on side buttons
|
// Long-press chapter skip on side buttons
|
||||||
|
|||||||
@ -14,6 +14,9 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
|
|||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
||||||
return InputManager::BTN_CONFIRM;
|
return InputManager::BTN_CONFIRM;
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
||||||
|
/* fall through */
|
||||||
|
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
||||||
|
/* fall through */
|
||||||
default:
|
default:
|
||||||
return InputManager::BTN_BACK;
|
return InputManager::BTN_BACK;
|
||||||
}
|
}
|
||||||
@ -24,15 +27,22 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
|
|||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
||||||
return InputManager::BTN_LEFT;
|
return InputManager::BTN_LEFT;
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
||||||
|
/* fall through */
|
||||||
|
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
||||||
|
/* fall through */
|
||||||
default:
|
default:
|
||||||
return InputManager::BTN_CONFIRM;
|
return InputManager::BTN_CONFIRM;
|
||||||
}
|
}
|
||||||
case Button::Left:
|
case Button::Left:
|
||||||
switch (frontLayout) {
|
switch (frontLayout) {
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
||||||
|
/* fall through */
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
||||||
return InputManager::BTN_BACK;
|
return InputManager::BTN_BACK;
|
||||||
|
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
||||||
|
return InputManager::BTN_RIGHT;
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
||||||
|
/* fall through */
|
||||||
default:
|
default:
|
||||||
return InputManager::BTN_LEFT;
|
return InputManager::BTN_LEFT;
|
||||||
}
|
}
|
||||||
@ -40,8 +50,12 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
|
|||||||
switch (frontLayout) {
|
switch (frontLayout) {
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
||||||
return InputManager::BTN_CONFIRM;
|
return InputManager::BTN_CONFIRM;
|
||||||
|
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
||||||
|
return InputManager::BTN_LEFT;
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
||||||
|
/* fall through */
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
||||||
|
/* fall through */
|
||||||
default:
|
default:
|
||||||
return InputManager::BTN_RIGHT;
|
return InputManager::BTN_RIGHT;
|
||||||
}
|
}
|
||||||
@ -56,6 +70,7 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
|
|||||||
case CrossPointSettings::NEXT_PREV:
|
case CrossPointSettings::NEXT_PREV:
|
||||||
return InputManager::BTN_DOWN;
|
return InputManager::BTN_DOWN;
|
||||||
case CrossPointSettings::PREV_NEXT:
|
case CrossPointSettings::PREV_NEXT:
|
||||||
|
/* fall through */
|
||||||
default:
|
default:
|
||||||
return InputManager::BTN_UP;
|
return InputManager::BTN_UP;
|
||||||
}
|
}
|
||||||
@ -64,6 +79,7 @@ decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button butt
|
|||||||
case CrossPointSettings::NEXT_PREV:
|
case CrossPointSettings::NEXT_PREV:
|
||||||
return InputManager::BTN_UP;
|
return InputManager::BTN_UP;
|
||||||
case CrossPointSettings::PREV_NEXT:
|
case CrossPointSettings::PREV_NEXT:
|
||||||
|
/* fall through */
|
||||||
default:
|
default:
|
||||||
return InputManager::BTN_DOWN;
|
return InputManager::BTN_DOWN;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ void BootActivity::onEnter() {
|
|||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
renderer.clearScreen();
|
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(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 / 2 + 95, "BOOTING");
|
||||||
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION);
|
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSPOINT_VERSION);
|
||||||
|
|||||||
@ -124,7 +124,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
|||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
renderer.clearScreen();
|
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(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
|
||||||
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
|
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
|
||||||
|
|
||||||
@ -260,6 +260,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
|
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) {
|
||||||
Bitmap bitmap(file);
|
Bitmap bitmap(file);
|
||||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||||
|
Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath);
|
||||||
renderBitmapSleepScreen(bitmap);
|
renderBitmapSleepScreen(bitmap);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,6 @@
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr int PAGE_ITEMS = 23;
|
constexpr int PAGE_ITEMS = 23;
|
||||||
constexpr int SKIP_PAGE_MS = 700;
|
constexpr int SKIP_PAGE_MS = 700;
|
||||||
constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
void OpdsBookBrowserActivity::taskTrampoline(void* param) {
|
||||||
@ -33,7 +32,7 @@ void OpdsBookBrowserActivity::onEnter() {
|
|||||||
state = BrowserState::CHECK_WIFI;
|
state = BrowserState::CHECK_WIFI;
|
||||||
entries.clear();
|
entries.clear();
|
||||||
navigationHistory.clear();
|
navigationHistory.clear();
|
||||||
currentPath = OPDS_ROOT_PATH;
|
currentPath = ""; // Root path - user provides full URL in settings
|
||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
errorMessage.clear();
|
errorMessage.clear();
|
||||||
statusMessage = "Checking WiFi...";
|
statusMessage = "Checking WiFi...";
|
||||||
@ -172,7 +171,7 @@ void OpdsBookBrowserActivity::render() const {
|
|||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
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) {
|
if (state == BrowserState::CHECK_WIFI) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||||
|
|||||||
@ -71,6 +71,9 @@ void HomeActivity::onEnter() {
|
|||||||
if (!xtc.getTitle().empty()) {
|
if (!xtc.getTitle().empty()) {
|
||||||
lastBookTitle = std::string(xtc.getTitle());
|
lastBookTitle = std::string(xtc.getTitle());
|
||||||
}
|
}
|
||||||
|
if (!xtc.getAuthor().empty()) {
|
||||||
|
lastBookAuthor = std::string(xtc.getAuthor());
|
||||||
|
}
|
||||||
// Try to generate thumbnail image for Continue Reading card
|
// Try to generate thumbnail image for Continue Reading card
|
||||||
if (xtc.generateThumbBmp()) {
|
if (xtc.generateThumbBmp()) {
|
||||||
coverBmpPath = xtc.getThumbBmpPath();
|
coverBmpPath = xtc.getThumbBmpPath();
|
||||||
@ -502,8 +505,8 @@ void HomeActivity::render() {
|
|||||||
// Build menu items dynamically
|
// Build menu items dynamically
|
||||||
std::vector<const char*> menuItems = {"My Library", "File Transfer", "Settings"};
|
std::vector<const char*> menuItems = {"My Library", "File Transfer", "Settings"};
|
||||||
if (hasOpdsUrl) {
|
if (hasOpdsUrl) {
|
||||||
// Insert Calibre Library after My Library
|
// Insert OPDS Browser after My Library
|
||||||
menuItems.insert(menuItems.begin() + 1, "Calibre Library");
|
menuItems.insert(menuItems.begin() + 1, "OPDS Browser");
|
||||||
}
|
}
|
||||||
|
|
||||||
const int menuTileWidth = pageWidth - 2 * margin;
|
const int menuTileWidth = pageWidth - 2 * margin;
|
||||||
|
|||||||
@ -120,7 +120,8 @@ void MyLibraryActivity::loadFiles() {
|
|||||||
} else {
|
} else {
|
||||||
auto filename = std::string(name);
|
auto filename = std::string(name);
|
||||||
if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") ||
|
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);
|
files.emplace_back(filename);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
276
src/activities/network/CalibreConnectActivity.cpp
Normal file
276
src/activities/network/CalibreConnectActivity.cpp
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
#include "CalibreConnectActivity.h"
|
||||||
|
|
||||||
|
#include <ESPmDNS.h>
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <esp_task_wdt.h>
|
||||||
|
|
||||||
|
#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<CalibreConnectActivity*>(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<WifiSelectionActivity*>(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);
|
||||||
|
}
|
||||||
55
src/activities/network/CalibreConnectActivity.h
Normal file
55
src/activities/network/CalibreConnectActivity.h
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#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<void()> onComplete;
|
||||||
|
|
||||||
|
std::unique_ptr<CrossPointWebServer> 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<void()>& 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(); }
|
||||||
|
};
|
||||||
@ -1,756 +0,0 @@
|
|||||||
#include "CalibreWirelessActivity.h"
|
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
|
||||||
#include <HardwareSerial.h>
|
|
||||||
#include <SDCardManager.h>
|
|
||||||
#include <WiFi.h>
|
|
||||||
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
#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<CalibreWirelessActivity*>(param);
|
|
||||||
self->displayTaskLoop();
|
|
||||||
}
|
|
||||||
|
|
||||||
void CalibreWirelessActivity::networkTaskTrampoline(void* param) {
|
|
||||||
auto* self = static_cast<CalibreWirelessActivity*>(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<const uint8_t*>("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<uint16_t>(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<uint16_t>(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<OpCode>(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<int>(sizeof(buf)));
|
|
||||||
int bytesRead = tcpClient.read(reinterpret_cast<uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(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);
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <SDCardManager.h>
|
|
||||||
#include <WiFiClient.h>
|
|
||||||
#include <WiFiUdp.h>
|
|
||||||
#include <freertos/FreeRTOS.h>
|
|
||||||
#include <freertos/semphr.h>
|
|
||||||
#include <freertos/task.h>
|
|
||||||
|
|
||||||
#include <functional>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#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<void()> 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<void()>& 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; }
|
|
||||||
};
|
|
||||||
@ -12,6 +12,7 @@
|
|||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "NetworkModeSelectionActivity.h"
|
#include "NetworkModeSelectionActivity.h"
|
||||||
#include "WifiSelectionActivity.h"
|
#include "WifiSelectionActivity.h"
|
||||||
|
#include "activities/network/CalibreConnectActivity.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@ -125,8 +126,13 @@ void CrossPointWebServerActivity::onExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
|
void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
|
||||||
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(),
|
const char* modeName = "Join Network";
|
||||||
mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot");
|
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;
|
networkMode = mode;
|
||||||
isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
|
isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
|
||||||
@ -134,6 +140,18 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
|
|||||||
// Exit mode selection subactivity
|
// Exit mode selection subactivity
|
||||||
exitActivity();
|
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) {
|
if (mode == NetworkMode::JOIN_NETWORK) {
|
||||||
// STA mode - launch WiFi selection
|
// STA mode - launch WiFi selection
|
||||||
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis());
|
Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis());
|
||||||
|
|||||||
@ -23,7 +23,7 @@ enum class WebServerActivityState {
|
|||||||
/**
|
/**
|
||||||
* CrossPointWebServerActivity is the entry point for file transfer functionality.
|
* CrossPointWebServerActivity is the entry point for file transfer functionality.
|
||||||
* It:
|
* 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 STA mode: Launches WifiSelectionActivity to connect to an existing network
|
||||||
* - For AP mode: Creates an Access Point that clients can connect to
|
* - For AP mode: Creates an Access Point that clients can connect to
|
||||||
* - Starts the CrossPointWebServer when connected
|
* - Starts the CrossPointWebServer when connected
|
||||||
|
|||||||
@ -6,10 +6,13 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int MENU_ITEM_COUNT = 2;
|
constexpr int MENU_ITEM_COUNT = 3;
|
||||||
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"};
|
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",
|
const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {
|
||||||
"Create a WiFi network others can join"};
|
"Connect to an existing WiFi network",
|
||||||
|
"Use Calibre wireless device transfers",
|
||||||
|
"Create a WiFi network others can join",
|
||||||
|
};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void NetworkModeSelectionActivity::taskTrampoline(void* param) {
|
void NetworkModeSelectionActivity::taskTrampoline(void* param) {
|
||||||
@ -58,7 +61,12 @@ void NetworkModeSelectionActivity::loop() {
|
|||||||
|
|
||||||
// Handle confirm button - select current option
|
// Handle confirm button - select current option
|
||||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
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);
|
onModeSelected(mode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,11 +8,12 @@
|
|||||||
#include "../Activity.h"
|
#include "../Activity.h"
|
||||||
|
|
||||||
// Enum for network mode selection
|
// 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:
|
* NetworkModeSelectionActivity presents the user with a choice:
|
||||||
* - "Join a Network" - Connect to an existing WiFi network (STA mode)
|
* - "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)
|
* - "Create Hotspot" - Create an Access Point that others can connect to (AP mode)
|
||||||
*
|
*
|
||||||
* The onModeSelected callback is called with the user's choice.
|
* The onModeSelected callback is called with the user's choice.
|
||||||
|
|||||||
@ -354,8 +354,8 @@ void WifiSelectionActivity::loop() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}
|
}
|
||||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
if (forgetPromptSelection == 0) {
|
if (forgetPromptSelection == 1) {
|
||||||
// User chose "Yes" - forget the network
|
// User chose "Forget network" - forget the network
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
WIFI_STORE.removeCredential(selectedSSID);
|
WIFI_STORE.removeCredential(selectedSSID);
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
@ -366,7 +366,7 @@ void WifiSelectionActivity::loop() {
|
|||||||
network->hasSavedPassword = false;
|
network->hasSavedPassword = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Go back to network list
|
// Go back to network list (whether Cancel or Forget network was selected)
|
||||||
state = WifiSelectionState::NETWORK_LIST;
|
state = WifiSelectionState::NETWORK_LIST;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
} 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 we used saved credentials, offer to forget the network
|
||||||
if (usedSavedPassword) {
|
if (usedSavedPassword) {
|
||||||
state = WifiSelectionState::FORGET_PROMPT;
|
state = WifiSelectionState::FORGET_PROMPT;
|
||||||
forgetPromptSelection = 0; // Default to "Yes"
|
forgetPromptSelection = 0; // Default to "Cancel"
|
||||||
} else {
|
} else {
|
||||||
// Go back to network list on failure
|
// Go back to network list on failure
|
||||||
state = WifiSelectionState::NETWORK_LIST;
|
state = WifiSelectionState::NETWORK_LIST;
|
||||||
@ -623,7 +623,9 @@ void WifiSelectionActivity::renderConnected() const {
|
|||||||
const std::string ipInfo = "IP Address: " + connectedIP;
|
const std::string ipInfo = "IP Address: " + connectedIP;
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, ipInfo.c_str());
|
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 {
|
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.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 {
|
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_12_FONT_ID, top - 20, "Connection Failed", true, EpdFontFamily::BOLD);
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top + 20, connectionError.c_str());
|
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 {
|
void WifiSelectionActivity::renderForgetPrompt() const {
|
||||||
@ -692,26 +699,28 @@ void WifiSelectionActivity::renderForgetPrompt() const {
|
|||||||
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?");
|
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?");
|
||||||
|
|
||||||
// Draw Yes/No buttons
|
// Draw Cancel/Forget network buttons
|
||||||
const int buttonY = top + 80;
|
const int buttonY = top + 80;
|
||||||
constexpr int buttonWidth = 60;
|
constexpr int buttonWidth = 120;
|
||||||
constexpr int buttonSpacing = 30;
|
constexpr int buttonSpacing = 30;
|
||||||
constexpr int totalWidth = buttonWidth * 2 + buttonSpacing;
|
constexpr int totalWidth = buttonWidth * 2 + buttonSpacing;
|
||||||
const int startX = (pageWidth - totalWidth) / 2;
|
const int startX = (pageWidth - totalWidth) / 2;
|
||||||
|
|
||||||
// Draw "Yes" button
|
// Draw "Cancel" button
|
||||||
if (forgetPromptSelection == 0) {
|
if (forgetPromptSelection == 0) {
|
||||||
renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Yes]");
|
renderer.drawText(UI_10_FONT_ID, startX, buttonY, "[Cancel]");
|
||||||
} else {
|
} 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) {
|
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 {
|
} 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,12 +56,17 @@ void EpubReaderActivity::onEnter() {
|
|||||||
|
|
||||||
FsFile f;
|
FsFile f;
|
||||||
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
||||||
uint8_t data[4];
|
uint8_t data[6];
|
||||||
if (f.read(data, 4) == 4) {
|
int dataSize = f.read(data, 6);
|
||||||
|
if (dataSize == 4 || dataSize == 6) {
|
||||||
currentSpineIndex = data[0] + (data[1] << 8);
|
currentSpineIndex = data[0] + (data[1] << 8);
|
||||||
nextPageNumber = data[2] + (data[3] << 8);
|
nextPageNumber = data[2] + (data[3] << 8);
|
||||||
|
cachedSpineIndex = currentSpineIndex;
|
||||||
Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber);
|
Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber);
|
||||||
}
|
}
|
||||||
|
if (dataSize == 6) {
|
||||||
|
cachedChapterTotalPageCount = data[4] + (data[5] << 8);
|
||||||
|
}
|
||||||
f.close();
|
f.close();
|
||||||
}
|
}
|
||||||
// We may want a better condition to detect if we are opening for the first time.
|
// We may want a better condition to detect if we are opening for the first time.
|
||||||
@ -341,6 +346,17 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
} else {
|
} else {
|
||||||
section->currentPage = nextPageNumber;
|
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<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
||||||
|
int newPage = static_cast<int>(progress * section->pageCount);
|
||||||
|
section->currentPage = newPage;
|
||||||
|
}
|
||||||
|
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
@ -376,12 +392,14 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
|
|
||||||
FsFile f;
|
FsFile f;
|
||||||
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
||||||
uint8_t data[4];
|
uint8_t data[6];
|
||||||
data[0] = currentSpineIndex & 0xFF;
|
data[0] = currentSpineIndex & 0xFF;
|
||||||
data[1] = (currentSpineIndex >> 8) & 0xFF;
|
data[1] = (currentSpineIndex >> 8) & 0xFF;
|
||||||
data[2] = section->currentPage & 0xFF;
|
data[2] = section->currentPage & 0xFF;
|
||||||
data[3] = (section->currentPage >> 8) & 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();
|
f.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -448,7 +466,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
|||||||
|
|
||||||
// Right aligned text for progress counter
|
// Right aligned text for progress counter
|
||||||
char progressStr[32];
|
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);
|
bookProgress);
|
||||||
const std::string progress = progressStr;
|
const std::string progress = progressStr;
|
||||||
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
|
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
|
||||||
|
|||||||
@ -15,6 +15,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
|
|||||||
int currentSpineIndex = 0;
|
int currentSpineIndex = 0;
|
||||||
int nextPageNumber = 0;
|
int nextPageNumber = 0;
|
||||||
int pagesUntilFullRefresh = 0;
|
int pagesUntilFullRefresh = 0;
|
||||||
|
int cachedSpineIndex = 0;
|
||||||
|
int cachedChapterTotalPageCount = 0;
|
||||||
bool updateRequired = false;
|
bool updateRequired = false;
|
||||||
const std::function<void()> onGoBack;
|
const std::function<void()> onGoBack;
|
||||||
const std::function<void()> onGoHome;
|
const std::function<void()> onGoHome;
|
||||||
|
|||||||
@ -188,22 +188,23 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
|
|||||||
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||||
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
|
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
|
||||||
|
|
||||||
for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) {
|
for (int i = 0; i < pageItems; i++) {
|
||||||
const int displayY = 60 + (itemIndex % pageItems) * 30;
|
int itemIndex = pageStartIndex + i;
|
||||||
|
if (itemIndex >= totalItems) break;
|
||||||
|
const int displayY = 60 + i * 30;
|
||||||
const bool isSelected = (itemIndex == selectorIndex);
|
const bool isSelected = (itemIndex == selectorIndex);
|
||||||
|
|
||||||
if (isSyncItem(itemIndex)) {
|
if (isSyncItem(itemIndex)) {
|
||||||
// Draw sync option (at top or bottom)
|
|
||||||
renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected);
|
renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected);
|
||||||
} else {
|
} else {
|
||||||
// Draw TOC item (account for top sync offset)
|
|
||||||
const int tocIndex = tocIndexFromItemIndex(itemIndex);
|
const int tocIndex = tocIndexFromItemIndex(itemIndex);
|
||||||
auto item = epub->getTocItem(tocIndex);
|
auto item = epub->getTocItem(tocIndex);
|
||||||
|
|
||||||
const int indentSize = 20 + (item.level - 1) * 15;
|
const int indentSize = 20 + (item.level - 1) * 15;
|
||||||
const std::string chapterName =
|
const std::string chapterName =
|
||||||
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), pageWidth - 40 - indentSize);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,9 +22,8 @@ bool ReaderActivity::isXtcFile(const std::string& path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool ReaderActivity::isTxtFile(const std::string& path) {
|
bool ReaderActivity::isTxtFile(const std::string& path) {
|
||||||
if (path.length() < 4) return false;
|
return StringUtils::checkFileExtension(path, ".txt") ||
|
||||||
std::string ext4 = path.substr(path.length() - 4);
|
StringUtils::checkFileExtension(path, ".md"); // Treat .md as txt files (until we have a markdown reader)
|
||||||
return ext4 == ".txt" || ext4 == ".TXT";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "RecentBooksStore.h"
|
||||||
#include "ScreenComponents.h"
|
#include "ScreenComponents.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
@ -55,9 +56,10 @@ void TxtReaderActivity::onEnter() {
|
|||||||
|
|
||||||
txt->setupCacheDir();
|
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.openEpubPath = txt->getPath();
|
||||||
APP_STATE.saveToFile();
|
APP_STATE.saveToFile();
|
||||||
|
RECENT_BOOKS.addBook(txt->getPath());
|
||||||
|
|
||||||
// Trigger first update
|
// Trigger first update
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|||||||
@ -1,20 +1,17 @@
|
|||||||
#include "CalibreSettingsActivity.h"
|
#include "CalibreSettingsActivity.h"
|
||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <WiFi.h>
|
|
||||||
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "activities/network/CalibreWirelessActivity.h"
|
|
||||||
#include "activities/network/WifiSelectionActivity.h"
|
|
||||||
#include "activities/util/KeyboardEntryActivity.h"
|
#include "activities/util/KeyboardEntryActivity.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int MENU_ITEMS = 2;
|
constexpr int MENU_ITEMS = 3;
|
||||||
const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"};
|
const char* menuNames[MENU_ITEMS] = {"OPDS Server URL", "Username", "Password"};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void CalibreSettingsActivity::taskTrampoline(void* param) {
|
void CalibreSettingsActivity::taskTrampoline(void* param) {
|
||||||
@ -80,10 +77,10 @@ void CalibreSettingsActivity::handleSelection() {
|
|||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
|
||||||
if (selectedIndex == 0) {
|
if (selectedIndex == 0) {
|
||||||
// Calibre Web URL
|
// OPDS Server URL
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new KeyboardEntryActivity(
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10,
|
renderer, mappedInput, "OPDS Server URL", SETTINGS.opdsServerUrl, 10,
|
||||||
127, // maxLength
|
127, // maxLength
|
||||||
false, // not password
|
false, // not password
|
||||||
[this](const std::string& url) {
|
[this](const std::string& url) {
|
||||||
@ -98,26 +95,41 @@ void CalibreSettingsActivity::handleSelection() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}));
|
}));
|
||||||
} else if (selectedIndex == 1) {
|
} else if (selectedIndex == 1) {
|
||||||
// Wireless Device - launch the activity (handles WiFi connection internally)
|
// Username
|
||||||
exitActivity();
|
exitActivity();
|
||||||
if (WiFi.status() != WL_CONNECTED) {
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) {
|
renderer, mappedInput, "Username", SETTINGS.opdsUsername, 10,
|
||||||
exitActivity();
|
63, // maxLength
|
||||||
if (connected) {
|
false, // not password
|
||||||
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
|
[this](const std::string& username) {
|
||||||
exitActivity();
|
strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1);
|
||||||
updateRequired = true;
|
SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0';
|
||||||
}));
|
SETTINGS.saveToFile();
|
||||||
} else {
|
exitActivity();
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}
|
},
|
||||||
}));
|
[this]() {
|
||||||
} else {
|
exitActivity();
|
||||||
enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] {
|
updateRequired = true;
|
||||||
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);
|
xSemaphoreGive(renderingMutex);
|
||||||
@ -141,24 +153,32 @@ void CalibreSettingsActivity::render() {
|
|||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
|
||||||
// Draw header
|
// 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
|
// 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
|
// Draw menu items
|
||||||
for (int i = 0; i < MENU_ITEMS; i++) {
|
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);
|
const bool isSelected = (i == selectedIndex);
|
||||||
|
|
||||||
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
|
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) {
|
if (i == 0) {
|
||||||
const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
|
status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]";
|
||||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
|
} else if (i == 1) {
|
||||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
|
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
|
// Draw button hints
|
||||||
|
|||||||
@ -8,8 +8,8 @@
|
|||||||
#include "activities/ActivityWithSubactivity.h"
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submenu for Calibre settings.
|
* Submenu for OPDS Browser settings.
|
||||||
* Shows Calibre Web URL and Calibre Wireless Device options.
|
* Shows OPDS Server URL and HTTP authentication options.
|
||||||
*/
|
*/
|
||||||
class CalibreSettingsActivity final : public ActivityWithSubactivity {
|
class CalibreSettingsActivity final : public ActivityWithSubactivity {
|
||||||
public:
|
public:
|
||||||
|
|||||||
@ -103,7 +103,7 @@ void CategorySettingsActivity::toggleCurrentSetting() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}));
|
}));
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
} else if (strcmp(setting.name, "Calibre Settings") == 0) {
|
} else if (strcmp(setting.name, "OPDS Browser") == 0) {
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
||||||
|
|||||||
@ -194,7 +194,7 @@ void KOReaderSettingsActivity::render() {
|
|||||||
} else if (i == 1) {
|
} else if (i == 1) {
|
||||||
status = KOREADER_STORE.getPassword().empty() ? "[Not Set]" : "[Set]";
|
status = KOREADER_STORE.getPassword().empty() ? "[Not Set]" : "[Set]";
|
||||||
} else if (i == 2) {
|
} else if (i == 2) {
|
||||||
status = KOREADER_STORE.getServerUrl().empty() ? "[Not Set]" : "[Set]";
|
status = KOREADER_STORE.getServerUrl().empty() ? "[Default]" : "[Custom]";
|
||||||
} else if (i == 3) {
|
} else if (i == 3) {
|
||||||
status = KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? "[Filename]" : "[Binary]";
|
status = KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? "[Filename]" : "[Binary]";
|
||||||
} else if (i == 4) {
|
} else if (i == 4) {
|
||||||
|
|||||||
@ -37,8 +37,9 @@ const SettingInfo readerSettings[readerSettingsCount] = {
|
|||||||
|
|
||||||
constexpr int controlsSettingsCount = 4;
|
constexpr int controlsSettingsCount = 4;
|
||||||
const SettingInfo controlsSettings[controlsSettingsCount] = {
|
const SettingInfo controlsSettings[controlsSettingsCount] = {
|
||||||
SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout,
|
SettingInfo::Enum(
|
||||||
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}),
|
"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,
|
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
|
||||||
{"Prev, Next", "Next, Prev"}),
|
{"Prev, Next", "Next, Prev"}),
|
||||||
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
||||||
@ -48,7 +49,7 @@ constexpr int systemSettingsCount = 5;
|
|||||||
const SettingInfo systemSettings[systemSettingsCount] = {
|
const SettingInfo systemSettings[systemSettingsCount] = {
|
||||||
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
|
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
|
||||||
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
|
{"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")};
|
SettingInfo::Action("Check for updates")};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
|||||||
28
src/main.cpp
28
src/main.cpp
@ -151,8 +151,15 @@ void enterNewActivity(Activity* activity) {
|
|||||||
currentActivity->onEnter();
|
currentActivity->onEnter();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify long press on wake-up from deep sleep
|
// Verify power button press duration on wake-up from deep sleep
|
||||||
void verifyWakeupLongPress() {
|
// 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()
|
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
|
||||||
const auto start = millis();
|
const auto start = millis();
|
||||||
bool abort = false;
|
bool abort = false;
|
||||||
@ -165,6 +172,7 @@ void verifyWakeupLongPress() {
|
|||||||
|
|
||||||
inputManager.update();
|
inputManager.update();
|
||||||
// Verify the user has actually pressed
|
// 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) {
|
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.
|
delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
|
||||||
inputManager.update();
|
inputManager.update();
|
||||||
@ -281,11 +289,14 @@ bool isUsbConnected() {
|
|||||||
return digitalRead(UART0_RXD) == HIGH;
|
return digitalRead(UART0_RXD) == HIGH;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isWakeupAfterFlashing() {
|
bool isWakeupByPowerButton() {
|
||||||
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
||||||
const auto resetReason = esp_reset_reason();
|
const auto resetReason = esp_reset_reason();
|
||||||
|
if (isUsbConnected()) {
|
||||||
return isUsbConnected() && (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_UNKNOWN);
|
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
|
||||||
|
} else {
|
||||||
|
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
@ -322,9 +333,10 @@ void setup() {
|
|||||||
SETTINGS.loadFromFile();
|
SETTINGS.loadFromFile();
|
||||||
KOREADER_STORE.loadFromFile();
|
KOREADER_STORE.loadFromFile();
|
||||||
|
|
||||||
if (!isWakeupAfterFlashing()) {
|
if (isWakeupByPowerButton()) {
|
||||||
// For normal wakeups (not immediately after flashing), verify long press
|
// For normal wakeups, verify power button press duration
|
||||||
verifyWakeupLongPress();
|
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
|
// First serial output only here to avoid timing inconsistencies for power button press duration verification
|
||||||
|
|||||||
@ -18,6 +18,8 @@ namespace {
|
|||||||
// Note: Items starting with "." are automatically hidden
|
// Note: Items starting with "." are automatically hidden
|
||||||
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
|
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
|
||||||
constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
|
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)
|
// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback)
|
||||||
CrossPointWebServer* wsInstance = nullptr;
|
CrossPointWebServer* wsInstance = nullptr;
|
||||||
@ -30,6 +32,9 @@ size_t wsUploadSize = 0;
|
|||||||
size_t wsUploadReceived = 0;
|
size_t wsUploadReceived = 0;
|
||||||
unsigned long wsUploadStartTime = 0;
|
unsigned long wsUploadStartTime = 0;
|
||||||
bool wsUploadInProgress = false;
|
bool wsUploadInProgress = false;
|
||||||
|
String wsLastCompleteName;
|
||||||
|
size_t wsLastCompleteSize = 0;
|
||||||
|
unsigned long wsLastCompleteAt = 0;
|
||||||
|
|
||||||
// Helper function to clear epub cache after upload
|
// Helper function to clear epub cache after upload
|
||||||
void clearEpubCacheIfNeeded(const String& filePath) {
|
void clearEpubCacheIfNeeded(const String& filePath) {
|
||||||
@ -96,6 +101,7 @@ void CrossPointWebServer::begin() {
|
|||||||
|
|
||||||
server->on("/api/status", HTTP_GET, [this] { handleStatus(); });
|
server->on("/api/status", HTTP_GET, [this] { handleStatus(); });
|
||||||
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
|
server->on("/api/files", HTTP_GET, [this] { handleFileListData(); });
|
||||||
|
server->on("/download", HTTP_GET, [this] { handleDownload(); });
|
||||||
|
|
||||||
// Upload endpoint with special handling for multipart form data
|
// Upload endpoint with special handling for multipart form data
|
||||||
server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); });
|
server->on("/upload", HTTP_POST, [this] { handleUploadPost(); }, [this] { handleUpload(); });
|
||||||
@ -119,6 +125,10 @@ void CrossPointWebServer::begin() {
|
|||||||
wsServer->onEvent(wsEventCallback);
|
wsServer->onEvent(wsEventCallback);
|
||||||
Serial.printf("[%lu] [WEB] WebSocket server started\n", millis());
|
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;
|
running = true;
|
||||||
|
|
||||||
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
|
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());
|
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
|
// Brief delay to allow any in-flight handleClient() calls to complete
|
||||||
delay(20);
|
delay(20);
|
||||||
|
|
||||||
@ -174,7 +189,7 @@ void CrossPointWebServer::stop() {
|
|||||||
Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap());
|
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;
|
static unsigned long lastDebugPrint = 0;
|
||||||
|
|
||||||
// Check running flag FIRST before accessing server
|
// Check running flag FIRST before accessing server
|
||||||
@ -200,6 +215,40 @@ void CrossPointWebServer::handleClient() const {
|
|||||||
if (wsServer) {
|
if (wsServer) {
|
||||||
wsServer->loop();
|
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<const uint8_t*>(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 {
|
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());
|
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 variables for upload handling
|
||||||
static FsFile uploadFile;
|
static FsFile uploadFile;
|
||||||
static String uploadFileName;
|
static String uploadFileName;
|
||||||
@ -798,6 +910,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
|
|||||||
wsUploadFile.close();
|
wsUploadFile.close();
|
||||||
wsUploadInProgress = false;
|
wsUploadInProgress = false;
|
||||||
|
|
||||||
|
wsLastCompleteName = wsUploadFileName;
|
||||||
|
wsLastCompleteSize = wsUploadSize;
|
||||||
|
wsLastCompleteAt = millis();
|
||||||
|
|
||||||
unsigned long elapsed = millis() - wsUploadStartTime;
|
unsigned long elapsed = millis() - wsUploadStartTime;
|
||||||
float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
#include <WebServer.h>
|
#include <WebServer.h>
|
||||||
#include <WebSocketsServer.h>
|
#include <WebSocketsServer.h>
|
||||||
|
#include <WiFiUdp.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
// Structure to hold file information
|
// Structure to hold file information
|
||||||
@ -15,6 +18,16 @@ struct FileInfo {
|
|||||||
|
|
||||||
class CrossPointWebServer {
|
class CrossPointWebServer {
|
||||||
public:
|
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();
|
||||||
~CrossPointWebServer();
|
~CrossPointWebServer();
|
||||||
|
|
||||||
@ -25,11 +38,13 @@ class CrossPointWebServer {
|
|||||||
void stop();
|
void stop();
|
||||||
|
|
||||||
// Call this periodically to handle client requests
|
// Call this periodically to handle client requests
|
||||||
void handleClient() const;
|
void handleClient();
|
||||||
|
|
||||||
// Check if server is running
|
// Check if server is running
|
||||||
bool isRunning() const { return running; }
|
bool isRunning() const { return running; }
|
||||||
|
|
||||||
|
WsUploadStatus getWsUploadStatus() const;
|
||||||
|
|
||||||
// Get the port number
|
// Get the port number
|
||||||
uint16_t getPort() const { return port; }
|
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
|
bool apMode = false; // true when running in AP mode, false for STA mode
|
||||||
uint16_t port = 80;
|
uint16_t port = 80;
|
||||||
uint16_t wsPort = 81; // WebSocket port
|
uint16_t wsPort = 81; // WebSocket port
|
||||||
|
WiFiUDP udp;
|
||||||
|
bool udpActive = false;
|
||||||
|
|
||||||
// WebSocket upload state
|
// WebSocket upload state
|
||||||
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
||||||
@ -56,6 +73,7 @@ class CrossPointWebServer {
|
|||||||
void handleStatus() const;
|
void handleStatus() const;
|
||||||
void handleFileList() const;
|
void handleFileList() const;
|
||||||
void handleFileListData() const;
|
void handleFileListData() const;
|
||||||
|
void handleDownload() const;
|
||||||
void handleUpload() const;
|
void handleUpload() const;
|
||||||
void handleUploadPost() const;
|
void handleUploadPost() const;
|
||||||
void handleCreateFolder() const;
|
void handleCreateFolder() const;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user