38 Commits

Author SHA1 Message Date
Xuan-Son Nguyen
5816ab2a47 feat: use pre-compressed HTML pages (#861)
Some checks failed
CI (build) / clang-format (push) Has been cancelled
CI (build) / cppcheck (push) Has been cancelled
CI (build) / build (push) Has been cancelled
CI (build) / Test Status (push) Has been cancelled
## Summary

Pre-compress the HTML file to save flash space. I'm using `gzip` because
it's supported everywhere (indeed, we are using the same optimization on
[llama.cpp server](https://github.com/ggml-org/llama.cpp), our HTML page
is huge 😅 ).

This free up ~40KB flash space.

Some users suggested using `brotli` which is known to further reduce 20%
in size, but it doesn't supported by firefox (only supports if served
via HTTPS), and some reverse proxy like nginx doesn't support it out of
the box (unrelated in this context, but just mention for completeness)

```
PR:
RAM:   [===       ]  31.0% (used 101700 bytes from 327680 bytes)
Flash: [==========]  95.5% (used 6259244 bytes from 6553600 bytes)

master:
RAM:   [===       ]  31.0% (used 101700 bytes from 327680 bytes)
Flash: [==========]  96.2% (used 6302416 bytes from 6553600 bytes)
```

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **PARTIALLY**, only the
python part
2026-02-14 18:49:39 +03:00
Max Stoller
2c0a105550 docs: Add requirement device be on when flashing (#877)
## Summary
Flashing requires the device to be unlocked/awake

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< YES | PARTIALLY | NO
>**_
2026-02-14 18:46:27 +03:00
Jake Kenneally
6e51afb977 fix: Account for nbsp; character as non-breaking space (#757)
## Summary

Closes #743.

**What is the goal of this PR?**

- Add back handling for HTML entities in expat. This was originally part
of the code that got removed
[here](https://github.com/crosspoint-reader/crosspoint-reader/pull/274)
- Handle `&nbsp;` characters to resolve issue #743 

**What changes are included?**

- Brought back HTML entity table from previous commit and refactored it
to use a static const char * table with linear lookup to reduce heap
allocations.
- Used `XML_SetDefaultHandlerExpand` in expat to parse out the entities
correctly, without needing them defined in DOCTYPE
- Added handling for `&nbsp;` so that the text stays together and
doesn't break onto a new line with text separated by an `&nbsp;`

## Additional Context

- This supersedes [this
PR](https://github.com/crosspoint-reader/crosspoint-reader/pull/751)
that simply handled `nbsp;` as whitespace. Instead, we want that
character to serve its true purpose and affect the line-breaking
algorithm.
- Updated my test EPUB [here](https://github.com/jdk2pq/css-test-epub)
with `&nbsp;` characters examples at the end of the book

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
2026-02-13 15:46:46 +01:00
jpirnay
cb24947477 feat: Add central logging pragma (#843)
## Summary

* Definition and use of a central LOG function, that can later be
extended or completely be removed (for public use where debugging
information may not be required) to save flash by suppressing the
-DENABLE_SERIAL_LOG like in the slim branch

* **What changes are included?**

## Additional Context
* By using the central logger the usual:
```
#include <HardwareSerial.h>
...
  Serial.printf("[%lu] [WCS] Obfuscating/deobfuscating %zu bytes\n", millis(), data.size());
```
would then become
```
#include <Logging.h>
...
  LOG_DBG("WCS", "Obfuscating/deobfuscating %zu bytes", data.size());
```
You do have ``LOG_DBG`` for debug messages, ``LOG_ERR`` for error
messages and ``LOG_INF`` for informational messages. Depending on the
verbosity level defined (see below) soe of these message types will be
suppressed/not-compiled.

* The normal compilation (default) will create a firmware.elf file of
42.194.356 bytes, the same code via slim will create 42.024.048 bytes -
170.308 bytes less
* Firmware.bin : 6.469.984 bytes for default, 6.418.672 bytes for slim -
51.312 bytes less


### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _NO_

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
2026-02-13 12:16:39 +01:00
jpirnay
7a385d78a4 feat: Allow screenshot retrieval from device (#820)
## Summary

* Add a small loop in main to be able to receive external commands,
currently being sent via the debugging_monitor
* Implemented command: cmd:SCREENSHOT sends the currently displayed
screen to the monitor, which will then store it to screenshot.bmp

## Additional Context

I was getting annoyed with taking tilted/unsharp photos of the device
screen, so I added the ability to press Enter during the monitor
execution and type SCREENSHOT to send a command. Could be extended in
the future

[screenshot.bmp](https://github.com/user-attachments/files/25213230/screenshot.bmp)

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-02-13 02:31:15 +03:00
Xuan-Son Nguyen
0991782fb4 feat: more power saving on idle (#801)
Some checks failed
CI (build) / clang-format (push) Has been cancelled
CI (build) / cppcheck (push) Has been cancelled
CI (build) / build (push) Has been cancelled
CI (build) / Test Status (push) Has been cancelled
## Summary

This PR extends the delay in main loop from 10ms to 50ms after the
device is idle for a while. This translates to extended battery life in
a longer period (see testing section above), while not hurting too much
the user experience.

With the help from [this
patch](https://github.com/ngxson/crosspoint-reader/tree/xsn/measure_cpu_usage),
I was able to measure the CPU usage on idle:

```
PR:
[20017] [MEM] Free: 150188 bytes, Total: 232092 bytes, Min Free: 150092 bytes
[20017] [IDLE] Idle time: 99.62% (CPU load: 0.38%)
[30042] [MEM] Free: 150188 bytes, Total: 232092 bytes, Min Free: 150092 bytes
[30042] [IDLE] Idle time: 99.63% (CPU load: 0.37%)
[40067] [MEM] Free: 150188 bytes, Total: 232092 bytes, Min Free: 150092 bytes
[40067] [IDLE] Idle time: 99.62% (CPU load: 0.38%)

master:
[20012] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[20012] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
[30017] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[30017] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
[40022] [MEM] Free: 195016 bytes, Total: 231532 bytes, Min Free: 132460 bytes
[40022] [IDLE] Idle time: 98.53% (CPU load: 1.47%)
```

While this is a x3.8 reduce in CPU usage, it doesn't translate to the
same amount of battery life extension in real life. The reasons are:
1. The CPU is not shut down completely
2. freeRTOS tick is still running (however, I planned to experiment with
tickless functionality)
3. Current leakage to other components, for example: voltage dividers,
eink screen, SD card, etc

A note on
[light-sleep](https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-reference/system/sleep_modes.html)
functionality: it is not possible in our use case because:
- Light-sleep for 50ms introduce too much overhead on wake up, it has
negative effect on battery life
- Light-sleep for longer period doesn't work because the ADC GPIO
buttons cannot be used as wake up source

## Testing (duration = 6 hrs)

To test this, I patched the `CrossPointSettings::getSleepTimeoutMs()` to
always returns a timeout of 6 hrs. This allow me to leave the device
idle for 6 hrs straight.

- On master branch, 6 hrs costs 26% battery life (100% --> 74%), meaning
battery life is ~23 hrs
- With this PR, 6 hrs costs 20% battery life (100% --> 80%), meaning
battery life is ~30 hrs

So in theory, this extends the battery by about 7 hrs. Even with some
error margin added, I think 3 hrs increase is possible with a normal
usage setup (i.e. only read ebooks, no wifi)

## Additional Context

Would appreciate if someone can test this with an oscilloscope.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **NO**
2026-02-12 09:49:05 +01:00
jpirnay
3ae1007cbe fix: chore: make all debug messages uniform (#825)
## Summary

* Unify all serial port debug messages

## Additional Context

* All messages sent to the serial port now follow the "[timestamp]
[origin] payload" format (notable exception framework messages)

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-02-11 16:25:17 +01:00
Jonas Diemer
efb9b72e64 fix: Show "Back" in file browser if not in root, "Home" otherwise. (#822)
## Summary

Show "Back" in file browser if not in root, "Home" otherwise.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? YES
2026-02-11 16:44:10 +03:00
Dave Allie
4a210823a8 fix: Manually trigger GPIO update in File Browser mode (#819)
## Summary

* Manually trigger GPIO update in File Browser mode
* Previously just assumed that the GPIO data would update automatically
(presumably via yield), the data is currently updated in the main loop
(and now here as well during the middle of the processing loop).
* This allows the back button to be correctly detected instead of only
being checked once every 100ms or so for the button state.

## Additional Context

* Fixes
https://github.com/crosspoint-reader/crosspoint-reader/issues/579

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Enhanced input state detection in the web server interface for more
responsive and accurate user command recognition during high-frequency
operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-11 13:42:37 +03:00
Jonas Diemer
f5b85f5ca1 fix: Reduce MIN_SIZE_FOR_POPUP to 10KB (#809)
Noticed that the Indexing... popup went missing despite 3-5 seconds
delay. Reducing to 10KB, so we get a popup for delays > ~2s.


### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? no
2026-02-10 16:15:23 +01:00
Jonas Diemer
7e93411f46 docs: Update USER_GUIDE.md (#817)
Added explanation how to recover from broken config/cache.



### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? no
2026-02-10 23:23:14 +11:00
Dave Allie
44452a42e9 fix: Prevent sleeping when in OPDS browser / downloading books (#818)
## Summary

* Prevent sleeping when in OPDS browser / downloading books

## Additional Context

* Raised in
https://github.com/crosspoint-reader/crosspoint-reader/discussions/673

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-02-10 22:56:22 +11:00
jpirnay
0c2df24f5c feat: Extend python debugging monitor functionality (keyword filter / suppress) (#810)
## Summary

* I needed the ability to filter and or suppress debug messages
containig certain keywords (eg [GFX] for render related stuff)
* Update of debugging_monitor.py script for development work

## Additional Context
```
usage: debugging_monitor.py [-h] [--baud BAUD] [--filter FILTER] [--suppress SUPPRESS] [port]

ESP32 Monitor with Graph

positional arguments:
  port                 Serial port

options:
  -h, --help           show this help message and exit
  --baud BAUD          Baud rate
  --filter FILTER      Only display lines containing this keyword (case-insensitive)
  --suppress SUPPRESS  Suppress lines containing this keyword (case-insensitive)
```
* plus a couple of platform specific defaults (port, pip style)
---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? NO
2026-02-10 22:07:56 +11:00
Jonas Diemer
3a12ca2725 docs: Update USER_GUIDE.md (#808)
Added info about optimizing EPUB.

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? no
2026-02-10 22:04:32 +11:00
Eliz
98e6789626 feat: Connect to last wifi by default (#752)
## Summary

* **What is the goal of this PR?** 

Use last connected network as default

* **What changes are included?**

- Refactor how an action type of Settings are handled
- Add a new System Settings option → Network
- Add the ability to forget a network in the Network Selection Screen
- Add the ability to Refresh network list
- Save the last connected network SSID
- Use the last connection whenever network is needed (OPDS, Koreader
sync, update etc)

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).


![IMG_6504](https://github.com/user-attachments/assets/e48fb013-b5c3-45c0-b284-e183e6fd5a68)

![IMG_6503](https://github.com/user-attachments/assets/78c4b6b6-4e7b-4656-b356-19d65ff6aa12)




https://github.com/user-attachments/assets/95bf34a8-44ce-4279-8cd8-f78524ce745b





---

### AI Usage

Did you use AI tools to help write this code? _** PARTIALLY: I wrote
most of it but I also used Gemini as assist.

---------

Co-authored-by: Eliz Kilic <elizk@google.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-10 20:41:44 +11:00
ThatCrispyToast
b5d28a3a9c feat: use natural sort in file browser (#722)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)

Implement natural sort (e.g. "file1.txt, file2.txt, file10.txt" instead
of "file1.txt, file10.txt, file2.txt") for files in the
MyLibraryActivity menu

* **What changes are included?**

Modifies the `sortFileList` function under
`src/activities/home/MyLibraryActivity.cpp` to use natural sort as
opposed to lexicographical sort

## Additional Context

I wasn't entirely sure whether or not i should make this a configurable
option, but most file browsers and directory listing tools have this set
as an immutable default, so I opted against it.

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-02-10 01:09:24 +03:00
harshit181
14ef625679 fix: issue if book href are absolute url and not relative to server (#741)
## Summary

fixing issue if book href are absolute url and not relative to the
server

## Additional Context

* Fixes
https://github.com/crosspoint-reader/crosspoint-reader/issues/632
* https://github.com/harshit181/RSSPub/issues/43

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**<  PARTIALLY>**_
2026-02-09 22:12:21 +11:00
Istiak Tridip
64d161e88b feat: unify navigation handling with system-wide continuous navigation (#600)
This PR unifies navigation handling & adds system-wide support for
continuous navigation.

## Summary
Holding down a navigation button now continuously advances through items
until the button is released. This removes the need for repeated
press-and-release actions and makes navigation faster and smoother,
especially in long menus or documents.

When page-based navigation is available, it will navigate through pages.
If not, it will progress through menu items or similar list-based UI
elements.

Additionally, this PR fixes inconsistencies in wrap-around behavior and
navigation index calculations.

Places where the navigation system was updated:
- Home Page
- Settings Pages
- My Library Page
- WiFi Selection Page
- OPDS Browser Page
- Keyboard
- File Transfer Page
- XTC Chapter Selector Page
- EPUB Chapter Selector Page

I’ve tested this on the device as much as possible and tried to match
the existing behavior. Please let me know if I missed anything. Thanks 🙏


![crosspoint](https://github.com/user-attachments/assets/6a3c7482-f45e-4a77-b156-721bb3b679e6)

---

Following the request from @osteotek and @daveallie for system-wide
support, the old PR (#379) has been closed in favor of this
consolidated, system-wide implementation.

---

### AI Usage

Did you use AI tools to help write this code? _**PARTIALLY**_

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-09 20:19:34 +11:00
Fabio Barbon
e73bb3213f feat: Add Italian hyphenation support (#584)
## Summary

* **What is the goal of this PR?** Add Italian language hyphenation
support to improve text rendering for Italian books.
* **What changes are included?**

* Added Italian hyphenation trie (hyph-it.trie.h) generated from Typst's
hypher patterns
* Registered italianHyphenator in LanguageRegistry.cpp for language tag
it
  * Added Italian to the hyphenation evaluation test suite
  * Added Italian test data file with 5000 test cases

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_

---------

Co-authored-by: drbourbon <fabio@MacBook-Air-di-Fabio.local>
2026-02-09 19:55:58 +11:00
Dave Allie
6202bfd651 Merge branch 'release/1.0.0' 2026-02-09 17:18:24 +11:00
Jake Kenneally
9b04c2ec76 feat: Add percentage support to CSS properties (#738)
## Summary
- Closes #730

**What is the goal of this PR?**
- Adds percentage-based value support to CSS properties that accept
percentages (padding, margin, text-indent)
 
**What changes are included?**
- Adds `Percent` as another CSS unit
- Passes the viewport width to `fromCssStyle` so that we can resolve
percentage-based values
- Adds a fallback of using an emspace for text-indent if we have an
unresolvable value for whatever reason

## Additional Context

- This was missed in my CSS support feature, and the fallback when we
encounter a percentage value is to use px instead. This means 5% (which
would be ~30px on the screen) turns into 5px. When percentages are used
in `text-indent`, this fallback behavior makes the indent look like a
single space character. Whoops! 😬

My test EPUB has been updated
[here](https://github.com/jdk2pq/css-test-epub) with percentage based
CSS values at the end of the book.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
2026-02-09 08:31:52 +11:00
Dave Allie
ffddc2472b Use GITHUB_REF_NAME over GITHUB_HEAD_REF in release candidate workflow 2026-02-09 08:22:20 +11:00
Dave Allie
5765bbe821 Add release candidate workflow 2026-02-09 08:16:36 +11:00
Dave Allie
b4b028be3a fix: Allow OTA update from RC build to full release (#778)
## Summary

* Allow OTA update from RC build to full release
* If all the segments match, then also check if the current version
contains "-rc"

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No
2026-02-09 08:08:19 +11:00
Yaroslav
f34d7d2aac fix(ui): Add Back label in KOReader Sync screen (#770)
## Summary

- Remove duplicate Cancel option 
- Add Back label

<img width="435" height="613" alt="image"
src="https://github.com/user-attachments/assets/a3af4133-46fa-46e6-8360-a15dd7c4fe2a"
/>


## Result

<img width="575" height="431" alt="image"
src="https://github.com/user-attachments/assets/6ccdac89-43df-45bf-bcfa-3a7cc4bd88e4"
/>


---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY >**_

Closes #754
2026-02-09 07:51:51 +11:00
Justin Mitchell
71769490fb fix: Add EPUB 3 cover image detection (#760)
I had an epub that just showed a blank cover and wouldnt work for the
sleep screen either, turns out it was an epub3 and I guess we didn't
support that. Super simple fix here
2026-02-09 07:49:49 +11:00
Jesse Vincent
cda0a3f898 feat: A web editor for settings (#667)
## Summary

This is an updated version of @itsthisjustin's #346 that builds on
current master and also deduplicates the settings list so we don't have
two copies of the settings. In the Web UI, it should organize the
settings a little closer to what you see on device.

## Additional Context

I tested this live on device and it seems to play nicely for me. It's
re-based on master since master's settings stuff has moved somewhat
since the original PR and addresses the sole review comment #346 - it
also means that I don't need to manually key in the URL for my OPDS
server. :)

---

### AI Usage

My changes were implemented with Claude Opus 4.5 and Claude Code 2.1.25.
I don't know if @itsthisjustin's original work used AI assistance.

Co-authored-by: Dave Allie <dave@daveallie.com>
2026-02-09 07:46:14 +11:00
Xuan-Son Nguyen
7f40c3f477 feat: add HalStorage (#656)
## Summary

Continue my changes to introduce the HAL infrastructure from
https://github.com/crosspoint-reader/crosspoint-reader/pull/522

This PR touches quite a lot of files, but most of them are just name
changing. It should not have any impacts to the end behavior.

## Additional Context

My plan is to firstly add this small shim layer, which sounds useless at
first, but then I'll implement an emulated driver which can be helpful
for testing and for development.

Currently, on my fork, I'm using a FS driver that allow "mounting" a
local directory from my computer to the device, much like the `-v` mount
option on docker. This allows me to quickly reset `.crosspoint`
directory if anything goes wrong. I plan to upstream this feature when
this PR get merged.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? NO
2026-02-09 07:29:14 +11:00
Xuan-Son Nguyen
a87eacc6ab perf: optimize drawPixel() (#748)
## Summary

Ref https://github.com/crosspoint-reader/crosspoint-reader/pull/737

This PR further reduce ~25ms from rendering time, testing inside the
Setting screen:

```
master:
[68440] [GFX] Time = 73 ms from clearScreen to displayBuffer

PR:
[97806] [GFX] Time = 47 ms from clearScreen to displayBuffer
```

And in extreme case (fill the entire screen with black or gray color):

```
master:
[1125] [   ] Test fillRectDither drawn in 327 ms
[1347] [   ] Test fillRect drawn in 222 ms

PR:
[1334] [   ] Test fillRectDither drawn in 225 ms
[1455] [   ] Test fillRect drawn in 121 ms
```

Note that
https://github.com/crosspoint-reader/crosspoint-reader/pull/737 is NOT
applied on top of this PR. But with 2 of them combined, it should reduce
from 47ms --> 42ms

## Details

This PR based on the fact that function calls are costly if the function
is small enough. For example, this simple call:

```
  int rotatedX = 0;
  int rotatedY = 0;
  rotateCoordinates(x, y, &rotatedX, &rotatedY);
```

Generated assembly code:

<img width="771" height="215" alt="image"
src="https://github.com/user-attachments/assets/37991659-3304-41c3-a3b2-fb967da53f82"
/>

This adds ~10 instructions just to prepare the registers prior to the
function call, plus some more instructions for the function's
epilogue/prologue. Inlining it removing all of these:

<img width="1471" height="832" alt="image"
src="https://github.com/user-attachments/assets/b67a22ee-93ba-4017-88ed-c973e28ec914"
/>

Of course, this optimization is not magic. It's only beneficial under 3
conditions:
- The function is small, not in size, but in terms of effective
instructions. For example, the `rotateCoordinates` is simply a jump
table, where each branch is just 3-4 inst
- The function has multiple input arguments, which requires some move to
put it onto the correct place
- The function is called very frequently (i.e. critical path)

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **NO**
2026-02-09 05:05:42 +11:00
Arthur Tazhitdinov
1caad578fc feat: wakeup target detection (#731)
## Summary

* If going to sleep was from the Reader view, wake up to the same book.
Otherwise, wakeup to the Home view
2026-02-09 05:01:30 +11:00
CaptainFrito
5b90b68e99 fix: Scrolling page items calculation (#716)
## Summary

Fix for the page skip issue detected
https://github.com/crosspoint-reader/crosspoint-reader/pull/700#issuecomment-3856374323
by user @whyte-j

Skipping down on the last page now skips to the last item, and up on the
first page to the first item, rather than wrapping around the list in a
weird way.

## Additional Context

The calculation was outdated after several changes were added afterwards

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-02-09 04:58:46 +11:00
Jake Kenneally
67ddd60fce refactor: Rename "Embedded Style" to "Book's Embedded Style" (#746)
## Summary

**What is the goal of this PR?**
- Just a simple rename after feedback in #738

**What changes are included?**
- Renamed "Embedded Style" to "Book's Embedded Style" to more clearly
associate it with "Book's Style" option in "Paragraph Alignment"
settings

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO**_
2026-02-08 20:34:06 +03:00
Xuan-Son Nguyen
76908d38e1 feat: optimize fillRectDither (#737)
## Summary

This PR optimizes the `fillRectDither` function, making it as fast as a
normal `fillRect`

Testing code:

```cpp
  {
    auto start_t = millis();
    renderer.fillRectDither(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight(), Color::LightGray);
    auto elapsed = millis() - start_t;
    Serial.printf("[%lu] [   ] Test fillRectDither drawn in %lu ms\n", millis(), elapsed);
  }

  {
    auto start_t = millis();
    renderer.fillRect(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight(), true);
    auto elapsed = millis() - start_t;
    Serial.printf("[%lu] [   ] Test fillRect drawn in %lu ms\n", millis(), elapsed);
  }
```

Before:

```
[1125] [   ] Test fillRectDither drawn in 327 ms
[1347] [   ] Test fillRect drawn in 222 ms
```

After:

```
[1065] [ ] Test fillRectDither drawn in 238 ms
[1287] [ ] Test fillRect drawn in 222 ms
```

## Visual validation

Before:

<img width="415" height="216" alt="Screenshot 2026-02-07 at 01 04 19"
src="https://github.com/user-attachments/assets/5802dbba-187b-4d2b-a359-1318d3932d38"
/>

After:

<img width="420" height="191" alt="Screenshot 2026-02-07 at 01 36 30"
src="https://github.com/user-attachments/assets/3c3c8e14-3f3a-4205-be78-6ed771dcddf4"
/>

## Details

The original version is quite slow because it does quite a lot of
computations. A single pixel needs around 20 instructions just to know
if it's black or white:

<img width="1170" height="693" alt="Screenshot 2026-02-07 at 00 15 54"
src="https://github.com/user-attachments/assets/7c5a55e7-0598-4340-8b7b-17307d7921cb"
/>

With the new, templated and more light-weight approach, each pixel takes
only 3-4 instructions, the modulo operator is translated into bitwise
ops:

<img width="1175" height="682" alt="Screenshot 2026-02-07 at 01 47 51"
src="https://github.com/user-attachments/assets/4ec2cf74-6cc0-4b5b-87d5-831563ef164f"
/>

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? **NO**
2026-02-08 14:59:13 +03:00
Arthur Tazhitdinov
e6f5fa43e6 feat(ux): invert BACK button behavior in reader activities (#726)
## Summary

* Inverts back button behaviour while reading - short press to go home,
long press to open file browser

## Additional Context

* It seems counterintuitive that going into a book from home screen and
pressing back doesn’t take you back to the home screen. With the recent
books now displayed in the home view and a separate recents view, going
directly to the file browser is less necessary.
2026-02-07 10:17:00 -05:00
James Whyte
e7e31ac487 fix: increase lyra sideButtonHintsWidth to 30 (#727)
## Summary

Increase the width of Lyra's side button hints. It has been set to 30,
the same width as the classic theme.


Before:
<img width="457" height="742" alt="image"
src="https://github.com/user-attachments/assets/316e4679-fbf0-4f6e-b117-413075da1be2"
/>

After:
<img width="512" height="849" alt="image"
src="https://github.com/user-attachments/assets/3b0cf069-55ad-4d5a-a93c-4aeca3ff67f8"
/>



## Additional Context

Resolves
https://github.com/crosspoint-reader/crosspoint-reader/pull/700#issuecomment-3856983832

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code?
No
2026-02-07 10:15:41 -05:00
Jake Kenneally
9f78fd33e8 fix: Remove separations after style changes (#720)
Closes #182. Closes #710. Closes #711.

## Summary

**What is the goal of this PR?**
- A longer-term, more robust fix for the issue with spurious spaces
appearing after style changes. Replaces solution from #694.

**What changes are included?**
- Add continuation flags to determine if to add a space after a word or
if the word connects to the previous word. Replaces simple solution that
only considered ending punctuation.
- Fixed an issue with greedy line-breaking algorithm where punctuation
could appear on the next line, separated from the word, if there was a
style change between the word and punctuation

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
2026-02-06 19:10:37 +11:00
CaptainFrito
bd8132a260 fix: Lag before displaying covers on home screen (#721)
## Summary

Reduce/fix the lag on the home screen before recent book covers are
rendered

## Additional Context

We were previously rendering the screen in two steps, delaying the
recent book covers render to avoid a lag before the screen loads.
In this PR, we are now doing that only if at least one book doesn't have
the cover thumbnail generated yet. If all thumbs are already generated,
we load and display them right away, with no lag.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**NO **_
2026-02-06 18:58:32 +11:00
Jake Kenneally
f89ce514c8 feat: Add Settings for toggling CSS on or off (#717)
Closes #712 

## Summary

**What is the goal of this PR?** 

- To add new settings for toggling on/off embedded CSS styles in the
reader. This gives more control and customization to the user over how
the ereader experience looks.

**What changes are included?**

- Added new "Embedded Style" option to the Reader settings
- Added new "Book's Style" option for "Paragraph Alignment"
- User's selected "Paragraph Alignment" will take precedence and
override the embedded CSS `text-align` property, _unless_ the user has
"Book's Style" set as their "Paragraph Alignment"

## Additional Context

![IMG_6336](https://github.com/user-attachments/assets/dff619ef-986d-465e-b352-73a76baae334)


https://github.com/user-attachments/assets/9e404b13-c7e0-41c7-9406-4715f389166a


Addresses feedback from the community about the new CSS feature:
https://github.com/crosspoint-reader/crosspoint-reader/pull/700

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_, Claude Code
2026-02-06 18:49:04 +11:00
111 changed files with 8334 additions and 1434 deletions

View File

@@ -51,7 +51,7 @@ For more details about the scope of the project, see the [SCOPE.md](SCOPE.md) do
### Web (latest firmware) ### Web (latest firmware)
1. Connect your Xteink X4 to your computer via USB-C 1. Connect your Xteink X4 to your computer via USB-C and wake/unlock the device
2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware" 2. Go to https://xteink.dve.al/ and click "Flash CrossPoint firmware"
To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap To revert back to the official firmware, you can flash the latest official firmware from https://xteink.dve.al/, or swap

View File

@@ -230,6 +230,7 @@ Accessible by pressing **Confirm** while inside a book.
Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates: Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates:
* **Images:** Embedded images in e-books will not render. * **Images:** Embedded images in e-books will not render.
* **Cover Images:** Large cover images embedded into EPUB require several seconds (~10s for ~2000 pixel tall image) to convert for sleep screen and home screen thumbnail. Consider optimizing the EPUB with e.g. https://github.com/bigbag/epub-to-xtc-converter to speed this up.
--- ---
@@ -242,3 +243,5 @@ pio device monitor
``` ```
If the device is stuck in a bootloop, press and release the Reset button. Then, press and hold on to the configured Back button and the Power Button to boot to the Home Screen. If the device is stuck in a bootloop, press and release the Reset button. Then, press and hold on to the configured Back button and the Power Button to boot to the Home Screen.
There can be issues with broken cache or config. In this case, delete the `.crosspoint` directory on your SD card (or consider deleting only `settings.bin`, `state.bin`, or `epub_*` cache directories in the `.crosspoint/` folder).

View File

@@ -13,7 +13,9 @@ fi
# --modified: files tracked by git that have been modified (staged or unstaged) # --modified: files tracked by git that have been modified (staged or unstaged)
# --exclude-standard: ignores files in .gitignore # --exclude-standard: ignores files in .gitignore
# Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated. # Additionally exclude files in 'lib/EpdFont/builtinFonts/' as they are script-generated.
# Also exclude files in 'lib/Epub/Epub/hyphenation/generated/' as they are script-generated.
git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \ git ls-files --exclude-standard ${GIT_LS_FILES_FLAGS} \
| grep -E '\.(c|cpp|h|hpp)$' \ | grep -E '\.(c|cpp|h|hpp)$' \
| grep -v -E '^lib/EpdFont/builtinFonts/' \ | grep -v -E '^lib/EpdFont/builtinFonts/' \
| grep -v -E '^lib/Epub/Epub/hyphenation/generated/' \
| xargs -r clang-format -style=file -i | xargs -r clang-format -style=file -i

View File

@@ -1,9 +1,9 @@
#include "Epub.h" #include "Epub.h"
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <HalStorage.h>
#include <JpegToBmpConverter.h> #include <JpegToBmpConverter.h>
#include <SDCardManager.h> #include <Logging.h>
#include <ZipFile.h> #include <ZipFile.h>
#include "Epub/parsers/ContainerParser.h" #include "Epub/parsers/ContainerParser.h"
@@ -17,7 +17,7 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
// Get file size without loading it all into heap // Get file size without loading it all into heap
if (!getItemSize(containerPath, &containerSize)) { if (!getItemSize(containerPath, &containerSize)) {
Serial.printf("[%lu] [EBP] Could not find or size META-INF/container.xml\n", millis()); LOG_ERR("EBP", "Could not find or size META-INF/container.xml");
return false; return false;
} }
@@ -29,13 +29,13 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
// Stream read (reusing your existing stream logic) // Stream read (reusing your existing stream logic)
if (!readItemContentsToStream(containerPath, containerParser, 512)) { if (!readItemContentsToStream(containerPath, containerParser, 512)) {
Serial.printf("[%lu] [EBP] Could not read META-INF/container.xml\n", millis()); LOG_ERR("EBP", "Could not read META-INF/container.xml");
return false; return false;
} }
// Extract the result // Extract the result
if (containerParser.fullPath.empty()) { if (containerParser.fullPath.empty()) {
Serial.printf("[%lu] [EBP] Could not find valid rootfile in container.xml\n", millis()); LOG_ERR("EBP", "Could not find valid rootfile in container.xml");
return false; return false;
} }
@@ -46,28 +46,28 @@ bool Epub::findContentOpfFile(std::string* contentOpfFile) const {
bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) { bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
std::string contentOpfFilePath; std::string contentOpfFilePath;
if (!findContentOpfFile(&contentOpfFilePath)) { if (!findContentOpfFile(&contentOpfFilePath)) {
Serial.printf("[%lu] [EBP] Could not find content.opf in zip\n", millis()); LOG_ERR("EBP", "Could not find content.opf in zip");
return false; return false;
} }
contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1); contentBasePath = contentOpfFilePath.substr(0, contentOpfFilePath.find_last_of('/') + 1);
Serial.printf("[%lu] [EBP] Parsing content.opf: %s\n", millis(), contentOpfFilePath.c_str()); LOG_DBG("EBP", "Parsing content.opf: %s", contentOpfFilePath.c_str());
size_t contentOpfSize; size_t contentOpfSize;
if (!getItemSize(contentOpfFilePath, &contentOpfSize)) { if (!getItemSize(contentOpfFilePath, &contentOpfSize)) {
Serial.printf("[%lu] [EBP] Could not get size of content.opf\n", millis()); LOG_ERR("EBP", "Could not get size of content.opf");
return false; return false;
} }
ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get()); ContentOpfParser opfParser(getCachePath(), getBasePath(), contentOpfSize, bookMetadataCache.get());
if (!opfParser.setup()) { if (!opfParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup content.opf parser\n", millis()); LOG_ERR("EBP", "Could not setup content.opf parser");
return false; return false;
} }
if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) { if (!readItemContentsToStream(contentOpfFilePath, opfParser, 1024)) {
Serial.printf("[%lu] [EBP] Could not read content.opf\n", millis()); LOG_ERR("EBP", "Could not read content.opf");
return false; return false;
} }
@@ -90,27 +90,27 @@ bool Epub::parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata) {
cssFiles = opfParser.cssFiles; cssFiles = opfParser.cssFiles;
} }
Serial.printf("[%lu] [EBP] Successfully parsed content.opf\n", millis()); LOG_DBG("EBP", "Successfully parsed content.opf");
return true; return true;
} }
bool Epub::parseTocNcxFile() const { bool Epub::parseTocNcxFile() const {
// the ncx file should have been specified in the content.opf file // the ncx file should have been specified in the content.opf file
if (tocNcxItem.empty()) { if (tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] No ncx file specified\n", millis()); LOG_DBG("EBP", "No ncx file specified");
return false; return false;
} }
Serial.printf("[%lu] [EBP] Parsing toc ncx file: %s\n", millis(), tocNcxItem.c_str()); LOG_DBG("EBP", "Parsing toc ncx file: %s", tocNcxItem.c_str());
const auto tmpNcxPath = getCachePath() + "/toc.ncx"; const auto tmpNcxPath = getCachePath() + "/toc.ncx";
FsFile tempNcxFile; FsFile tempNcxFile;
if (!SdMan.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) { if (!Storage.openFileForWrite("EBP", tmpNcxPath, tempNcxFile)) {
return false; return false;
} }
readItemContentsToStream(tocNcxItem, tempNcxFile, 1024); readItemContentsToStream(tocNcxItem, tempNcxFile, 1024);
tempNcxFile.close(); tempNcxFile.close();
if (!SdMan.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) { if (!Storage.openFileForRead("EBP", tmpNcxPath, tempNcxFile)) {
return false; return false;
} }
const auto ncxSize = tempNcxFile.size(); const auto ncxSize = tempNcxFile.size();
@@ -118,14 +118,14 @@ bool Epub::parseTocNcxFile() const {
TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get()); TocNcxParser ncxParser(contentBasePath, ncxSize, bookMetadataCache.get());
if (!ncxParser.setup()) { if (!ncxParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc ncx parser\n", millis()); LOG_ERR("EBP", "Could not setup toc ncx parser");
tempNcxFile.close(); tempNcxFile.close();
return false; return false;
} }
const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024)); const auto ncxBuffer = static_cast<uint8_t*>(malloc(1024));
if (!ncxBuffer) { if (!ncxBuffer) {
Serial.printf("[%lu] [EBP] Could not allocate memory for toc ncx parser\n", millis()); LOG_ERR("EBP", "Could not allocate memory for toc ncx parser");
tempNcxFile.close(); tempNcxFile.close();
return false; return false;
} }
@@ -136,7 +136,7 @@ bool Epub::parseTocNcxFile() const {
const auto processedSize = ncxParser.write(ncxBuffer, readSize); const auto processedSize = ncxParser.write(ncxBuffer, readSize);
if (processedSize != readSize) { if (processedSize != readSize) {
Serial.printf("[%lu] [EBP] Could not process all toc ncx data\n", millis()); LOG_ERR("EBP", "Could not process all toc ncx data");
free(ncxBuffer); free(ncxBuffer);
tempNcxFile.close(); tempNcxFile.close();
return false; return false;
@@ -145,29 +145,29 @@ bool Epub::parseTocNcxFile() const {
free(ncxBuffer); free(ncxBuffer);
tempNcxFile.close(); tempNcxFile.close();
SdMan.remove(tmpNcxPath.c_str()); Storage.remove(tmpNcxPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC items\n", millis()); LOG_DBG("EBP", "Parsed TOC items");
return true; return true;
} }
bool Epub::parseTocNavFile() const { bool Epub::parseTocNavFile() const {
// the nav file should have been specified in the content.opf file (EPUB 3) // the nav file should have been specified in the content.opf file (EPUB 3)
if (tocNavItem.empty()) { if (tocNavItem.empty()) {
Serial.printf("[%lu] [EBP] No nav file specified\n", millis()); LOG_DBG("EBP", "No nav file specified");
return false; return false;
} }
Serial.printf("[%lu] [EBP] Parsing toc nav file: %s\n", millis(), tocNavItem.c_str()); LOG_DBG("EBP", "Parsing toc nav file: %s", tocNavItem.c_str());
const auto tmpNavPath = getCachePath() + "/toc.nav"; const auto tmpNavPath = getCachePath() + "/toc.nav";
FsFile tempNavFile; FsFile tempNavFile;
if (!SdMan.openFileForWrite("EBP", tmpNavPath, tempNavFile)) { if (!Storage.openFileForWrite("EBP", tmpNavPath, tempNavFile)) {
return false; return false;
} }
readItemContentsToStream(tocNavItem, tempNavFile, 1024); readItemContentsToStream(tocNavItem, tempNavFile, 1024);
tempNavFile.close(); tempNavFile.close();
if (!SdMan.openFileForRead("EBP", tmpNavPath, tempNavFile)) { if (!Storage.openFileForRead("EBP", tmpNavPath, tempNavFile)) {
return false; return false;
} }
const auto navSize = tempNavFile.size(); const auto navSize = tempNavFile.size();
@@ -178,13 +178,13 @@ bool Epub::parseTocNavFile() const {
TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get()); TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get());
if (!navParser.setup()) { if (!navParser.setup()) {
Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis()); LOG_ERR("EBP", "Could not setup toc nav parser");
return false; return false;
} }
const auto navBuffer = static_cast<uint8_t*>(malloc(1024)); const auto navBuffer = static_cast<uint8_t*>(malloc(1024));
if (!navBuffer) { if (!navBuffer) {
Serial.printf("[%lu] [EBP] Could not allocate memory for toc nav parser\n", millis()); LOG_ERR("EBP", "Could not allocate memory for toc nav parser");
return false; return false;
} }
@@ -193,7 +193,7 @@ bool Epub::parseTocNavFile() const {
const auto processedSize = navParser.write(navBuffer, readSize); const auto processedSize = navParser.write(navBuffer, readSize);
if (processedSize != readSize) { if (processedSize != readSize) {
Serial.printf("[%lu] [EBP] Could not process all toc nav data\n", millis()); LOG_ERR("EBP", "Could not process all toc nav data");
free(navBuffer); free(navBuffer);
tempNavFile.close(); tempNavFile.close();
return false; return false;
@@ -202,9 +202,9 @@ bool Epub::parseTocNavFile() const {
free(navBuffer); free(navBuffer);
tempNavFile.close(); tempNavFile.close();
SdMan.remove(tmpNavPath.c_str()); Storage.remove(tmpNavPath.c_str());
Serial.printf("[%lu] [EBP] Parsed TOC nav items\n", millis()); LOG_DBG("EBP", "Parsed TOC nav items");
return true; return true;
} }
@@ -212,70 +212,69 @@ std::string Epub::getCssRulesCache() const { return cachePath + "/css_rules.cach
bool Epub::loadCssRulesFromCache() const { bool Epub::loadCssRulesFromCache() const {
FsFile cssCacheFile; FsFile cssCacheFile;
if (SdMan.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) { if (Storage.openFileForRead("EBP", getCssRulesCache(), cssCacheFile)) {
if (cssParser->loadFromCache(cssCacheFile)) { if (cssParser->loadFromCache(cssCacheFile)) {
cssCacheFile.close(); cssCacheFile.close();
Serial.printf("[%lu] [EBP] Loaded CSS rules from cache\n", millis()); LOG_DBG("EBP", "Loaded CSS rules from cache");
return true; return true;
} }
cssCacheFile.close(); cssCacheFile.close();
Serial.printf("[%lu] [EBP] CSS cache invalid, reparsing\n", millis()); LOG_DBG("EBP", "CSS cache invalid, reparsing");
} }
return false; return false;
} }
void Epub::parseCssFiles() const { void Epub::parseCssFiles() const {
if (cssFiles.empty()) { if (cssFiles.empty()) {
Serial.printf("[%lu] [EBP] No CSS files to parse, but CssParser created for inline styles\n", millis()); LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
} }
// Try to load from CSS cache first // Try to load from CSS cache first
if (!loadCssRulesFromCache()) { if (!loadCssRulesFromCache()) {
// Cache miss - parse CSS files // Cache miss - parse CSS files
for (const auto& cssPath : cssFiles) { for (const auto& cssPath : cssFiles) {
Serial.printf("[%lu] [EBP] Parsing CSS file: %s\n", millis(), cssPath.c_str()); LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
// Extract CSS file to temp location // Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css"; const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile; FsFile tempCssFile;
if (!SdMan.openFileForWrite("EBP", tmpCssPath, tempCssFile)) { if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
Serial.printf("[%lu] [EBP] Could not create temp CSS file\n", millis()); LOG_ERR("EBP", "Could not create temp CSS file");
continue; continue;
} }
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) { if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
Serial.printf("[%lu] [EBP] Could not read CSS file: %s\n", millis(), cssPath.c_str()); LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
tempCssFile.close(); tempCssFile.close();
SdMan.remove(tmpCssPath.c_str()); Storage.remove(tmpCssPath.c_str());
continue; continue;
} }
tempCssFile.close(); tempCssFile.close();
// Parse the CSS file // Parse the CSS file
if (!SdMan.openFileForRead("EBP", tmpCssPath, tempCssFile)) { if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
Serial.printf("[%lu] [EBP] Could not open temp CSS file for reading\n", millis()); LOG_ERR("EBP", "Could not open temp CSS file for reading");
SdMan.remove(tmpCssPath.c_str()); Storage.remove(tmpCssPath.c_str());
continue; continue;
} }
cssParser->loadFromStream(tempCssFile); cssParser->loadFromStream(tempCssFile);
tempCssFile.close(); tempCssFile.close();
SdMan.remove(tmpCssPath.c_str()); Storage.remove(tmpCssPath.c_str());
} }
// Save to cache for next time // Save to cache for next time
FsFile cssCacheFile; FsFile cssCacheFile;
if (SdMan.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) { if (Storage.openFileForWrite("EBP", getCssRulesCache(), cssCacheFile)) {
cssParser->saveToCache(cssCacheFile); cssParser->saveToCache(cssCacheFile);
cssCacheFile.close(); cssCacheFile.close();
} }
Serial.printf("[%lu] [EBP] Loaded %zu CSS style rules from %zu files\n", millis(), cssParser->ruleCount(), LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
cssFiles.size());
} }
} }
// load in the meta data for the epub file // load in the meta data for the epub file
bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) { bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); LOG_DBG("EBP", "Loading ePub: %s", filepath.c_str());
// Initialize spine/TOC cache // Initialize spine/TOC cache
bookMetadataCache.reset(new BookMetadataCache(cachePath)); bookMetadataCache.reset(new BookMetadataCache(cachePath));
@@ -285,15 +284,15 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Try to load existing cache first // Try to load existing cache first
if (bookMetadataCache->load()) { if (bookMetadataCache->load()) {
if (!skipLoadingCss && !loadCssRulesFromCache()) { if (!skipLoadingCss && !loadCssRulesFromCache()) {
Serial.printf("[%lu] [EBP] Warning: CSS rules cache not found, attempting to parse CSS files\n", millis()); LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
// to get CSS file list // to get CSS file list
if (!parseContentOpf(bookMetadataCache->coreMetadata)) { if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
Serial.printf("[%lu] [EBP] Could not parse content.opf from cached bookMetadata for CSS files\n", millis()); LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
// continue anyway - book will work without CSS and we'll still load any inline style CSS // continue anyway - book will work without CSS and we'll still load any inline style CSS
} }
parseCssFiles(); parseCssFiles();
} }
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true; return true;
} }
@@ -303,14 +302,14 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
} }
// Cache doesn't exist or is invalid, build it // Cache doesn't exist or is invalid, build it
Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis()); LOG_DBG("EBP", "Cache not found, building spine/TOC cache");
setupCacheDir(); setupCacheDir();
const uint32_t indexingStart = millis(); const uint32_t indexingStart = millis();
// Begin building cache - stream entries to disk immediately // Begin building cache - stream entries to disk immediately
if (!bookMetadataCache->beginWrite()) { if (!bookMetadataCache->beginWrite()) {
Serial.printf("[%lu] [EBP] Could not begin writing cache\n", millis()); LOG_ERR("EBP", "Could not begin writing cache");
return false; return false;
} }
@@ -318,23 +317,23 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
const uint32_t opfStart = millis(); const uint32_t opfStart = millis();
BookMetadataCache::BookMetadata bookMetadata; BookMetadataCache::BookMetadata bookMetadata;
if (!bookMetadataCache->beginContentOpfPass()) { if (!bookMetadataCache->beginContentOpfPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing content.opf pass\n", millis()); LOG_ERR("EBP", "Could not begin writing content.opf pass");
return false; return false;
} }
if (!parseContentOpf(bookMetadata)) { if (!parseContentOpf(bookMetadata)) {
Serial.printf("[%lu] [EBP] Could not parse content.opf\n", millis()); LOG_ERR("EBP", "Could not parse content.opf");
return false; return false;
} }
if (!bookMetadataCache->endContentOpfPass()) { if (!bookMetadataCache->endContentOpfPass()) {
Serial.printf("[%lu] [EBP] Could not end writing content.opf pass\n", millis()); LOG_ERR("EBP", "Could not end writing content.opf pass");
return false; return false;
} }
Serial.printf("[%lu] [EBP] OPF pass completed in %lu ms\n", millis(), millis() - opfStart); LOG_DBG("EBP", "OPF pass completed in %lu ms", millis() - opfStart);
// TOC Pass - try EPUB 3 nav first, fall back to NCX // TOC Pass - try EPUB 3 nav first, fall back to NCX
const uint32_t tocStart = millis(); const uint32_t tocStart = millis();
if (!bookMetadataCache->beginTocPass()) { if (!bookMetadataCache->beginTocPass()) {
Serial.printf("[%lu] [EBP] Could not begin writing toc pass\n", millis()); LOG_ERR("EBP", "Could not begin writing toc pass");
return false; return false;
} }
@@ -342,50 +341,50 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Try EPUB 3 nav document first (preferred) // Try EPUB 3 nav document first (preferred)
if (!tocNavItem.empty()) { if (!tocNavItem.empty()) {
Serial.printf("[%lu] [EBP] Attempting to parse EPUB 3 nav document\n", millis()); LOG_DBG("EBP", "Attempting to parse EPUB 3 nav document");
tocParsed = parseTocNavFile(); tocParsed = parseTocNavFile();
} }
// Fall back to NCX if nav parsing failed or wasn't available // Fall back to NCX if nav parsing failed or wasn't available
if (!tocParsed && !tocNcxItem.empty()) { if (!tocParsed && !tocNcxItem.empty()) {
Serial.printf("[%lu] [EBP] Falling back to NCX TOC\n", millis()); LOG_DBG("EBP", "Falling back to NCX TOC");
tocParsed = parseTocNcxFile(); tocParsed = parseTocNcxFile();
} }
if (!tocParsed) { if (!tocParsed) {
Serial.printf("[%lu] [EBP] Warning: Could not parse any TOC format\n", millis()); LOG_ERR("EBP", "Warning: Could not parse any TOC format");
// Continue anyway - book will work without TOC // Continue anyway - book will work without TOC
} }
if (!bookMetadataCache->endTocPass()) { if (!bookMetadataCache->endTocPass()) {
Serial.printf("[%lu] [EBP] Could not end writing toc pass\n", millis()); LOG_ERR("EBP", "Could not end writing toc pass");
return false; return false;
} }
Serial.printf("[%lu] [EBP] TOC pass completed in %lu ms\n", millis(), millis() - tocStart); LOG_DBG("EBP", "TOC pass completed in %lu ms", millis() - tocStart);
// Close the cache files // Close the cache files
if (!bookMetadataCache->endWrite()) { if (!bookMetadataCache->endWrite()) {
Serial.printf("[%lu] [EBP] Could not end writing cache\n", millis()); LOG_ERR("EBP", "Could not end writing cache");
return false; return false;
} }
// Build final book.bin // Build final book.bin
const uint32_t buildStart = millis(); const uint32_t buildStart = millis();
if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) { if (!bookMetadataCache->buildBookBin(filepath, bookMetadata)) {
Serial.printf("[%lu] [EBP] Could not update mappings and sizes\n", millis()); LOG_ERR("EBP", "Could not update mappings and sizes");
return false; return false;
} }
Serial.printf("[%lu] [EBP] buildBookBin completed in %lu ms\n", millis(), millis() - buildStart); LOG_DBG("EBP", "buildBookBin completed in %lu ms", millis() - buildStart);
Serial.printf("[%lu] [EBP] Total indexing completed in %lu ms\n", millis(), millis() - indexingStart); LOG_DBG("EBP", "Total indexing completed in %lu ms", millis() - indexingStart);
if (!bookMetadataCache->cleanupTmpFiles()) { if (!bookMetadataCache->cleanupTmpFiles()) {
Serial.printf("[%lu] [EBP] Could not cleanup tmp files - ignoring\n", millis()); LOG_DBG("EBP", "Could not cleanup tmp files - ignoring");
} }
// Reload the cache from disk so it's in the correct state // Reload the cache from disk so it's in the correct state
bookMetadataCache.reset(new BookMetadataCache(cachePath)); bookMetadataCache.reset(new BookMetadataCache(cachePath));
if (!bookMetadataCache->load()) { if (!bookMetadataCache->load()) {
Serial.printf("[%lu] [EBP] Failed to reload cache after writing\n", millis()); LOG_ERR("EBP", "Failed to reload cache after writing");
return false; return false;
} }
@@ -394,31 +393,31 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
parseCssFiles(); parseCssFiles();
} }
Serial.printf("[%lu] [EBP] Loaded ePub: %s\n", millis(), filepath.c_str()); LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true; return true;
} }
bool Epub::clearCache() const { bool Epub::clearCache() const {
if (!SdMan.exists(cachePath.c_str())) { if (!Storage.exists(cachePath.c_str())) {
Serial.printf("[%lu] [EPB] Cache does not exist, no action needed\n", millis()); LOG_DBG("EPB", "Cache does not exist, no action needed");
return true; return true;
} }
if (!SdMan.removeDir(cachePath.c_str())) { if (!Storage.removeDir(cachePath.c_str())) {
Serial.printf("[%lu] [EPB] Failed to clear cache\n", millis()); LOG_ERR("EPB", "Failed to clear cache");
return false; return false;
} }
Serial.printf("[%lu] [EPB] Cache cleared successfully\n", millis()); LOG_DBG("EPB", "Cache cleared successfully");
return true; return true;
} }
void Epub::setupCacheDir() const { void Epub::setupCacheDir() const {
if (SdMan.exists(cachePath.c_str())) { if (Storage.exists(cachePath.c_str())) {
return; return;
} }
SdMan.mkdir(cachePath.c_str()); Storage.mkdir(cachePath.c_str());
} }
const std::string& Epub::getCachePath() const { return cachePath; } const std::string& Epub::getCachePath() const { return cachePath; }
@@ -459,55 +458,55 @@ std::string Epub::getCoverBmpPath(bool cropped) const {
bool Epub::generateCoverBmp(bool cropped) const { bool Epub::generateCoverBmp(bool cropped) const {
// Already generated, return true // Already generated, return true
if (SdMan.exists(getCoverBmpPath(cropped).c_str())) { if (Storage.exists(getCoverBmpPath(cropped).c_str())) {
return true; return true;
} }
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate cover BMP, cache not loaded\n", millis()); LOG_ERR("EBP", "Cannot generate cover BMP, cache not loaded");
return false; return false;
} }
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) { if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image\n", millis()); LOG_ERR("EBP", "No known cover image");
return false; return false;
} }
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 (%s mode)\n", millis(), cropped ? "cropped" : "fit"); LOG_DBG("EBP", "Generating BMP from JPG cover image (%s mode)", cropped ? "cropped" : "fit");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg; FsFile coverJpg;
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false; return false;
} }
readItemContentsToStream(coverImageHref, coverJpg, 1024); readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close(); coverJpg.close();
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false; return false;
} }
FsFile coverBmp; FsFile coverBmp;
if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { if (!Storage.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) {
coverJpg.close(); coverJpg.close();
return false; return false;
} }
const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped); const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, cropped);
coverJpg.close(); coverJpg.close();
coverBmp.close(); coverBmp.close();
SdMan.remove(coverJpgTempPath.c_str()); Storage.remove(coverJpgTempPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); LOG_ERR("EBP", "Failed to generate BMP from cover image");
SdMan.remove(getCoverBmpPath(cropped).c_str()); Storage.remove(getCoverBmpPath(cropped).c_str());
} }
Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); LOG_DBG("EBP", "Generated BMP from cover image, success: %s", success ? "yes" : "no");
return success; return success;
} else { } else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping\n", millis()); LOG_ERR("EBP", "Cover image is not a supported format, skipping");
} }
return false; return false;
@@ -518,36 +517,36 @@ std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb
bool Epub::generateThumbBmp(int height) const { bool Epub::generateThumbBmp(int height) const {
// Already generated, return true // Already generated, return true
if (SdMan.exists(getThumbBmpPath(height).c_str())) { if (Storage.exists(getThumbBmpPath(height).c_str())) {
return true; return true;
} }
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] Cannot generate thumb BMP, cache not loaded\n", millis()); LOG_ERR("EBP", "Cannot generate thumb BMP, cache not loaded");
return false; return false;
} }
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) { if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); LOG_DBG("EBP", "No known cover image for thumbnail");
} else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || } else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); LOG_DBG("EBP", "Generating thumb BMP from JPG cover image");
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
FsFile coverJpg; FsFile coverJpg;
if (!SdMan.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) { if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) {
return false; return false;
} }
readItemContentsToStream(coverImageHref, coverJpg, 1024); readItemContentsToStream(coverImageHref, coverJpg, 1024);
coverJpg.close(); coverJpg.close();
if (!SdMan.openFileForRead("EBP", coverJpgTempPath, coverJpg)) { if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) {
return false; return false;
} }
FsFile thumbBmp; FsFile thumbBmp;
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) { if (!Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverJpg.close(); coverJpg.close();
return false; return false;
} }
@@ -559,29 +558,28 @@ bool Epub::generateThumbBmp(int height) const {
THUMB_TARGET_HEIGHT); THUMB_TARGET_HEIGHT);
coverJpg.close(); coverJpg.close();
thumbBmp.close(); thumbBmp.close();
SdMan.remove(coverJpgTempPath.c_str()); Storage.remove(coverJpgTempPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); LOG_ERR("EBP", "Failed to generate thumb BMP from JPG cover image");
SdMan.remove(getThumbBmpPath(height).c_str()); Storage.remove(getThumbBmpPath(height).c_str());
} }
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), LOG_DBG("EBP", "Generated thumb BMP from JPG cover image, success: %s", success ? "yes" : "no");
success ? "yes" : "no");
return success; return success;
} else { } else {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail");
} }
// Write an empty bmp file to avoid generation attempts in the future // Write an empty bmp file to avoid generation attempts in the future
FsFile thumbBmp; FsFile thumbBmp;
SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp); Storage.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
thumbBmp.close(); thumbBmp.close();
return false; return false;
} }
uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const {
if (itemHref.empty()) { if (itemHref.empty()) {
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis()); LOG_DBG("EBP", "Failed to read item, empty href");
return nullptr; return nullptr;
} }
@@ -589,7 +587,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte); const auto content = ZipFile(filepath).readFileToMemory(path.c_str(), size, trailingNullByte);
if (!content) { if (!content) {
Serial.printf("[%lu] [EBP] Failed to read item %s\n", millis(), path.c_str()); LOG_DBG("EBP", "Failed to read item %s", path.c_str());
return nullptr; return nullptr;
} }
@@ -598,7 +596,7 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const { bool Epub::readItemContentsToStream(const std::string& itemHref, Print& out, const size_t chunkSize) const {
if (itemHref.empty()) { if (itemHref.empty()) {
Serial.printf("[%lu] [EBP] Failed to read item, empty href\n", millis()); LOG_DBG("EBP", "Failed to read item, empty href");
return false; return false;
} }
@@ -622,12 +620,12 @@ size_t Epub::getCumulativeSpineItemSize(const int spineIndex) const { return get
BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const { BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineItem called but cache not loaded\n", millis()); LOG_ERR("EBP", "getSpineItem called but cache not loaded");
return {}; return {};
} }
if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) { if (spineIndex < 0 || spineIndex >= bookMetadataCache->getSpineCount()) {
Serial.printf("[%lu] [EBP] getSpineItem index:%d is out of range\n", millis(), spineIndex); LOG_ERR("EBP", "getSpineItem index:%d is out of range", spineIndex);
return bookMetadataCache->getSpineEntry(0); return bookMetadataCache->getSpineEntry(0);
} }
@@ -636,12 +634,12 @@ BookMetadataCache::SpineEntry Epub::getSpineItem(const int spineIndex) const {
BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const { BookMetadataCache::TocEntry Epub::getTocItem(const int tocIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getTocItem called but cache not loaded\n", millis()); LOG_DBG("EBP", "getTocItem called but cache not loaded");
return {}; return {};
} }
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
Serial.printf("[%lu] [EBP] getTocItem index:%d is out of range\n", millis(), tocIndex); LOG_DBG("EBP", "getTocItem index:%d is out of range", tocIndex);
return {}; return {};
} }
@@ -659,18 +657,18 @@ int Epub::getTocItemsCount() const {
// work out the section index for a toc index // work out the section index for a toc index
int Epub::getSpineIndexForTocIndex(const int tocIndex) const { int Epub::getSpineIndexForTocIndex(const int tocIndex) const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex called but cache not loaded\n", millis()); LOG_ERR("EBP", "getSpineIndexForTocIndex called but cache not loaded");
return 0; return 0;
} }
if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) { if (tocIndex < 0 || tocIndex >= bookMetadataCache->getTocCount()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTocIndex: tocIndex %d out of range\n", millis(), tocIndex); LOG_ERR("EBP", "getSpineIndexForTocIndex: tocIndex %d out of range", tocIndex);
return 0; return 0;
} }
const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex; const int spineIndex = bookMetadataCache->getTocEntry(tocIndex).spineIndex;
if (spineIndex < 0) { if (spineIndex < 0) {
Serial.printf("[%lu] [EBP] Section not found for TOC index %d\n", millis(), tocIndex); LOG_DBG("EBP", "Section not found for TOC index %d", tocIndex);
return 0; return 0;
} }
@@ -688,12 +686,11 @@ size_t Epub::getBookSize() const {
int Epub::getSpineIndexForTextReference() const { int Epub::getSpineIndexForTextReference() const {
if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { if (!bookMetadataCache || !bookMetadataCache->isLoaded()) {
Serial.printf("[%lu] [EBP] getSpineIndexForTextReference called but cache not loaded\n", millis()); LOG_ERR("EBP", "getSpineIndexForTextReference called but cache not loaded");
return 0; return 0;
} }
Serial.printf("[%lu] [ERS] Core Metadata: cover(%d)=%s, textReference(%d)=%s\n", millis(), LOG_DBG("EBP", "Core Metadata: cover(%d)=%s, textReference(%d)=%s",
bookMetadataCache->coreMetadata.coverItemHref.size(), bookMetadataCache->coreMetadata.coverItemHref.size(), bookMetadataCache->coreMetadata.coverItemHref.c_str(),
bookMetadataCache->coreMetadata.coverItemHref.c_str(),
bookMetadataCache->coreMetadata.textReferenceHref.size(), bookMetadataCache->coreMetadata.textReferenceHref.size(),
bookMetadataCache->coreMetadata.textReferenceHref.c_str()); bookMetadataCache->coreMetadata.textReferenceHref.c_str());
@@ -705,13 +702,13 @@ int Epub::getSpineIndexForTextReference() const {
// loop through spine items to get the correct index matching the text href // loop through spine items to get the correct index matching the text href
for (size_t i = 0; i < getSpineItemsCount(); i++) { for (size_t i = 0; i < getSpineItemsCount(); i++) {
if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) { if (getSpineItem(i).href == bookMetadataCache->coreMetadata.textReferenceHref) {
Serial.printf("[%lu] [ERS] Text reference %s found at index %d\n", millis(), LOG_DBG("EBP", "Text reference %s found at index %d", bookMetadataCache->coreMetadata.textReferenceHref.c_str(),
bookMetadataCache->coreMetadata.textReferenceHref.c_str(), i); i);
return i; return i;
} }
} }
// This should not happen, as we checked for empty textReferenceHref earlier // This should not happen, as we checked for empty textReferenceHref earlier
Serial.printf("[%lu] [EBP] Section not found for text reference\n", millis()); LOG_DBG("EBP", "Section not found for text reference");
return 0; return 0;
} }

View File

@@ -1,6 +1,6 @@
#include "BookMetadataCache.h" #include "BookMetadataCache.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include <ZipFile.h> #include <ZipFile.h>
@@ -21,15 +21,15 @@ bool BookMetadataCache::beginWrite() {
buildMode = true; buildMode = true;
spineCount = 0; spineCount = 0;
tocCount = 0; tocCount = 0;
Serial.printf("[%lu] [BMC] Entering write mode\n", millis()); LOG_DBG("BMC", "Entering write mode");
return true; return true;
} }
bool BookMetadataCache::beginContentOpfPass() { bool BookMetadataCache::beginContentOpfPass() {
Serial.printf("[%lu] [BMC] Beginning content opf pass\n", millis()); LOG_DBG("BMC", "Beginning content opf pass");
// Open spine file for writing // Open spine file for writing
return SdMan.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile); return Storage.openFileForWrite("BMC", cachePath + tmpSpineBinFile, spineFile);
} }
bool BookMetadataCache::endContentOpfPass() { bool BookMetadataCache::endContentOpfPass() {
@@ -38,12 +38,12 @@ bool BookMetadataCache::endContentOpfPass() {
} }
bool BookMetadataCache::beginTocPass() { bool BookMetadataCache::beginTocPass() {
Serial.printf("[%lu] [BMC] Beginning toc pass\n", millis()); LOG_DBG("BMC", "Beginning toc pass");
if (!SdMan.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
return false; return false;
} }
if (!SdMan.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) { if (!Storage.openFileForWrite("BMC", cachePath + tmpTocBinFile, tocFile)) {
spineFile.close(); spineFile.close();
return false; return false;
} }
@@ -66,7 +66,7 @@ bool BookMetadataCache::beginTocPass() {
}); });
spineFile.seek(0); spineFile.seek(0);
useSpineHrefIndex = true; useSpineHrefIndex = true;
Serial.printf("[%lu] [BMC] Using fast index for %d spine items\n", millis(), spineCount); LOG_DBG("BMC", "Using fast index for %d spine items", spineCount);
} else { } else {
useSpineHrefIndex = false; useSpineHrefIndex = false;
} }
@@ -87,27 +87,27 @@ bool BookMetadataCache::endTocPass() {
bool BookMetadataCache::endWrite() { bool BookMetadataCache::endWrite() {
if (!buildMode) { if (!buildMode) {
Serial.printf("[%lu] [BMC] endWrite called but not in build mode\n", millis()); LOG_DBG("BMC", "endWrite called but not in build mode");
return false; return false;
} }
buildMode = false; buildMode = false;
Serial.printf("[%lu] [BMC] Wrote %d spine, %d TOC entries\n", millis(), spineCount, tocCount); LOG_DBG("BMC", "Wrote %d spine, %d TOC entries", spineCount, tocCount);
return true; return true;
} }
bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) { bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMetadata& metadata) {
// Open all three files, writing to meta, reading from spine and toc // Open all three files, writing to meta, reading from spine and toc
if (!SdMan.openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) { if (!Storage.openFileForWrite("BMC", cachePath + bookBinFile, bookFile)) {
return false; return false;
} }
if (!SdMan.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) { if (!Storage.openFileForRead("BMC", cachePath + tmpSpineBinFile, spineFile)) {
bookFile.close(); bookFile.close();
return false; return false;
} }
if (!SdMan.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) { if (!Storage.openFileForRead("BMC", cachePath + tmpTocBinFile, tocFile)) {
bookFile.close(); bookFile.close();
spineFile.close(); spineFile.close();
return false; return false;
@@ -167,7 +167,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
ZipFile zip(epubPath); ZipFile zip(epubPath);
// Pre-open zip file to speed up size calculations // Pre-open zip file to speed up size calculations
if (!zip.open()) { if (!zip.open()) {
Serial.printf("[%lu] [BMC] Could not open EPUB zip for size calculations\n", millis()); LOG_ERR("BMC", "Could not open EPUB zip for size calculations");
bookFile.close(); bookFile.close();
spineFile.close(); spineFile.close();
tocFile.close(); tocFile.close();
@@ -185,7 +185,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
bool useBatchSizes = false; bool useBatchSizes = false;
if (spineCount >= LARGE_SPINE_THRESHOLD) { if (spineCount >= LARGE_SPINE_THRESHOLD) {
Serial.printf("[%lu] [BMC] Using batch size lookup for %d spine items\n", millis(), spineCount); LOG_DBG("BMC", "Using batch size lookup for %d spine items", spineCount);
std::vector<ZipFile::SizeTarget> targets; std::vector<ZipFile::SizeTarget> targets;
targets.reserve(spineCount); targets.reserve(spineCount);
@@ -208,7 +208,7 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
spineSizes.resize(spineCount, 0); spineSizes.resize(spineCount, 0);
int matched = zip.fillUncompressedSizes(targets, spineSizes); int matched = zip.fillUncompressedSizes(targets, spineSizes);
Serial.printf("[%lu] [BMC] Batch lookup matched %d/%d spine items\n", millis(), matched, spineCount); LOG_DBG("BMC", "Batch lookup matched %d/%d spine items", matched, spineCount);
targets.clear(); targets.clear();
targets.shrink_to_fit(); targets.shrink_to_fit();
@@ -227,9 +227,8 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
// Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs // Not a huge deal if we don't fine a TOC entry for the spine entry, this is expected behaviour for EPUBs
// Logging here is for debugging // Logging here is for debugging
if (spineEntry.tocIndex == -1) { if (spineEntry.tocIndex == -1) {
Serial.printf( LOG_DBG("BMC", "Warning: Could not find TOC entry for spine item %d: %s, using title from last section", i,
"[%lu] [BMC] Warning: Could not find TOC entry for spine item %d: %s, using title from last section\n", spineEntry.href.c_str());
millis(), i, spineEntry.href.c_str());
spineEntry.tocIndex = lastSpineTocIndex; spineEntry.tocIndex = lastSpineTocIndex;
} }
lastSpineTocIndex = spineEntry.tocIndex; lastSpineTocIndex = spineEntry.tocIndex;
@@ -240,13 +239,13 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
if (itemSize == 0) { if (itemSize == 0) {
const std::string path = FsHelpers::normalisePath(spineEntry.href); const std::string path = FsHelpers::normalisePath(spineEntry.href);
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) { if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str()); LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
} }
} }
} else { } else {
const std::string path = FsHelpers::normalisePath(spineEntry.href); const std::string path = FsHelpers::normalisePath(spineEntry.href);
if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) { if (!zip.getInflatedFileSize(path.c_str(), &itemSize)) {
Serial.printf("[%lu] [BMC] Warning: Could not get size for spine item: %s\n", millis(), path.c_str()); LOG_ERR("BMC", "Warning: Could not get size for spine item: %s", path.c_str());
} }
} }
@@ -270,16 +269,16 @@ bool BookMetadataCache::buildBookBin(const std::string& epubPath, const BookMeta
spineFile.close(); spineFile.close();
tocFile.close(); tocFile.close();
Serial.printf("[%lu] [BMC] Successfully built book.bin\n", millis()); LOG_DBG("BMC", "Successfully built book.bin");
return true; return true;
} }
bool BookMetadataCache::cleanupTmpFiles() const { bool BookMetadataCache::cleanupTmpFiles() const {
if (SdMan.exists((cachePath + tmpSpineBinFile).c_str())) { if (Storage.exists((cachePath + tmpSpineBinFile).c_str())) {
SdMan.remove((cachePath + tmpSpineBinFile).c_str()); Storage.remove((cachePath + tmpSpineBinFile).c_str());
} }
if (SdMan.exists((cachePath + tmpTocBinFile).c_str())) { if (Storage.exists((cachePath + tmpTocBinFile).c_str())) {
SdMan.remove((cachePath + tmpTocBinFile).c_str()); Storage.remove((cachePath + tmpTocBinFile).c_str());
} }
return true; return true;
} }
@@ -306,7 +305,7 @@ uint32_t BookMetadataCache::writeTocEntry(FsFile& file, const TocEntry& entry) c
// this is because in this function we're marking positions of the items // this is because in this function we're marking positions of the items
void BookMetadataCache::createSpineEntry(const std::string& href) { void BookMetadataCache::createSpineEntry(const std::string& href) {
if (!buildMode || !spineFile) { if (!buildMode || !spineFile) {
Serial.printf("[%lu] [BMC] createSpineEntry called but not in build mode\n", millis()); LOG_DBG("BMC", "createSpineEntry called but not in build mode");
return; return;
} }
@@ -318,7 +317,7 @@ void BookMetadataCache::createSpineEntry(const std::string& href) {
void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor, void BookMetadataCache::createTocEntry(const std::string& title, const std::string& href, const std::string& anchor,
const uint8_t level) { const uint8_t level) {
if (!buildMode || !tocFile || !spineFile) { if (!buildMode || !tocFile || !spineFile) {
Serial.printf("[%lu] [BMC] createTocEntry called but not in build mode\n", millis()); LOG_DBG("BMC", "createTocEntry called but not in build mode");
return; return;
} }
@@ -340,7 +339,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
} }
if (spineIndex == -1) { if (spineIndex == -1) {
Serial.printf("[%lu] [BMC] createTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str()); LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
} }
} else { } else {
spineFile.seek(0); spineFile.seek(0);
@@ -352,7 +351,7 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
} }
} }
if (spineIndex == -1) { if (spineIndex == -1) {
Serial.printf("[%lu] [BMC] createTocEntry: Could not find spine item for TOC href %s\n", millis(), href.c_str()); LOG_DBG("BMC", "createTocEntry: Could not find spine item for TOC href %s", href.c_str());
} }
} }
@@ -364,14 +363,14 @@ void BookMetadataCache::createTocEntry(const std::string& title, const std::stri
/* ============= READING / LOADING FUNCTIONS ================ */ /* ============= READING / LOADING FUNCTIONS ================ */
bool BookMetadataCache::load() { bool BookMetadataCache::load() {
if (!SdMan.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) { if (!Storage.openFileForRead("BMC", cachePath + bookBinFile, bookFile)) {
return false; return false;
} }
uint8_t version; uint8_t version;
serialization::readPod(bookFile, version); serialization::readPod(bookFile, version);
if (version != BOOK_CACHE_VERSION) { if (version != BOOK_CACHE_VERSION) {
Serial.printf("[%lu] [BMC] Cache version mismatch: expected %d, got %d\n", millis(), BOOK_CACHE_VERSION, version); LOG_DBG("BMC", "Cache version mismatch: expected %d, got %d", BOOK_CACHE_VERSION, version);
bookFile.close(); bookFile.close();
return false; return false;
} }
@@ -387,18 +386,18 @@ bool BookMetadataCache::load() {
serialization::readString(bookFile, coreMetadata.textReferenceHref); serialization::readString(bookFile, coreMetadata.textReferenceHref);
loaded = true; loaded = true;
Serial.printf("[%lu] [BMC] Loaded cache data: %d spine, %d TOC entries\n", millis(), spineCount, tocCount); LOG_DBG("BMC", "Loaded cache data: %d spine, %d TOC entries", spineCount, tocCount);
return true; return true;
} }
BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) { BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index) {
if (!loaded) { if (!loaded) {
Serial.printf("[%lu] [BMC] getSpineEntry called but cache not loaded\n", millis()); LOG_ERR("BMC", "getSpineEntry called but cache not loaded");
return {}; return {};
} }
if (index < 0 || index >= static_cast<int>(spineCount)) { if (index < 0 || index >= static_cast<int>(spineCount)) {
Serial.printf("[%lu] [BMC] getSpineEntry index %d out of range\n", millis(), index); LOG_ERR("BMC", "getSpineEntry index %d out of range", index);
return {}; return {};
} }
@@ -412,12 +411,12 @@ BookMetadataCache::SpineEntry BookMetadataCache::getSpineEntry(const int index)
BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) { BookMetadataCache::TocEntry BookMetadataCache::getTocEntry(const int index) {
if (!loaded) { if (!loaded) {
Serial.printf("[%lu] [BMC] getTocEntry called but cache not loaded\n", millis()); LOG_ERR("BMC", "getTocEntry called but cache not loaded");
return {}; return {};
} }
if (index < 0 || index >= static_cast<int>(tocCount)) { if (index < 0 || index >= static_cast<int>(tocCount)) {
Serial.printf("[%lu] [BMC] getTocEntry index %d out of range\n", millis(), index); LOG_ERR("BMC", "getTocEntry index %d out of range", index);
return {}; return {};
} }

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <SDCardManager.h> #include <HalStorage.h>
#include <algorithm> #include <algorithm>
#include <string> #include <string>

View File

@@ -1,6 +1,6 @@
#include "Page.h" #include "Page.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
@@ -60,7 +60,7 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
auto pl = PageLine::deserialize(file); auto pl = PageLine::deserialize(file);
page->elements.push_back(std::move(pl)); page->elements.push_back(std::move(pl));
} else { } else {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown tag %u\n", millis(), tag); LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
return nullptr; return nullptr;
} }
} }

View File

@@ -1,5 +1,5 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <utility> #include <utility>
#include <vector> #include <vector>

View File

@@ -32,6 +32,9 @@ void stripSoftHyphensInPlace(std::string& word) {
// Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen. // Returns the rendered width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen.
uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word,
const EpdFontFamily::Style style, const bool appendHyphen = false) { const EpdFontFamily::Style style, const bool appendHyphen = false) {
if (word.size() == 1 && word[0] == ' ' && !appendHyphen) {
return renderer.getSpaceWidth(fontId);
}
const bool hasSoftHyphen = containsSoftHyphen(word); const bool hasSoftHyphen = containsSoftHyphen(word);
if (!hasSoftHyphen && !appendHyphen) { if (!hasSoftHyphen && !appendHyphen) {
return renderer.getTextWidth(fontId, word.c_str(), style); return renderer.getTextWidth(fontId, word.c_str(), style);

View File

@@ -1,6 +1,7 @@
#include "Section.h" #include "Section.h"
#include <SDCardManager.h> #include <HalStorage.h>
#include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include "Page.h" #include "Page.h"
@@ -16,16 +17,16 @@ constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) +
uint32_t Section::onPageComplete(std::unique_ptr<Page> page) { uint32_t Section::onPageComplete(std::unique_ptr<Page> page) {
if (!file) { if (!file) {
Serial.printf("[%lu] [SCT] File not open for writing page %d\n", millis(), pageCount); LOG_ERR("SCT", "File not open for writing page %d", pageCount);
return 0; return 0;
} }
const uint32_t position = file.position(); const uint32_t position = file.position();
if (!page->serialize(file)) { if (!page->serialize(file)) {
Serial.printf("[%lu] [SCT] Failed to serialize page %d\n", millis(), pageCount); LOG_ERR("SCT", "Failed to serialize page %d", pageCount);
return 0; return 0;
} }
Serial.printf("[%lu] [SCT] Page %d processed\n", millis(), pageCount); LOG_DBG("SCT", "Page %d processed", pageCount);
pageCount++; pageCount++;
return position; return position;
@@ -36,7 +37,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
const uint16_t viewportHeight, const bool hyphenationEnabled, const uint16_t viewportHeight, const bool hyphenationEnabled,
const bool embeddedStyle) { const bool embeddedStyle) {
if (!file) { if (!file) {
Serial.printf("[%lu] [SCT] File not open for writing header\n", millis()); LOG_DBG("SCT", "File not open for writing header");
return; return;
} }
static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) + static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
@@ -60,7 +61,7 @@ void Section::writeSectionFileHeader(const int fontId, const float lineCompressi
bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle) { const uint16_t viewportHeight, const bool hyphenationEnabled, const bool embeddedStyle) {
if (!SdMan.openFileForRead("SCT", filePath, file)) { if (!Storage.openFileForRead("SCT", filePath, file)) {
return false; return false;
} }
@@ -70,7 +71,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, version); serialization::readPod(file, version);
if (version != SECTION_FILE_VERSION) { if (version != SECTION_FILE_VERSION) {
file.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Unknown version %u\n", millis(), version); LOG_ERR("SCT", "Deserialization failed: Unknown version %u", version);
clearCache(); clearCache();
return false; return false;
} }
@@ -96,7 +97,7 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight || viewportWidth != fileViewportWidth || viewportHeight != fileViewportHeight ||
hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) { hyphenationEnabled != fileHyphenationEnabled || embeddedStyle != fileEmbeddedStyle) {
file.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); LOG_ERR("SCT", "Deserialization failed: Parameters do not match");
clearCache(); clearCache();
return false; return false;
} }
@@ -104,23 +105,23 @@ bool Section::loadSectionFile(const int fontId, const float lineCompression, con
serialization::readPod(file, pageCount); serialization::readPod(file, pageCount);
file.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization succeeded: %d pages\n", millis(), pageCount); LOG_DBG("SCT", "Deserialization succeeded: %d pages", pageCount);
return true; return true;
} }
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem) // Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
bool Section::clearCache() const { bool Section::clearCache() const {
if (!SdMan.exists(filePath.c_str())) { if (!Storage.exists(filePath.c_str())) {
Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis()); LOG_DBG("SCT", "Cache does not exist, no action needed");
return true; return true;
} }
if (!SdMan.remove(filePath.c_str())) { if (!Storage.remove(filePath.c_str())) {
Serial.printf("[%lu] [SCT] Failed to clear cache\n", millis()); LOG_ERR("SCT", "Failed to clear cache");
return false; return false;
} }
Serial.printf("[%lu] [SCT] Cache cleared successfully\n", millis()); LOG_DBG("SCT", "Cache cleared successfully");
return true; return true;
} }
@@ -134,7 +135,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
// Create cache directory if it doesn't exist // Create cache directory if it doesn't exist
{ {
const auto sectionsDir = epub->getCachePath() + "/sections"; const auto sectionsDir = epub->getCachePath() + "/sections";
SdMan.mkdir(sectionsDir.c_str()); Storage.mkdir(sectionsDir.c_str());
} }
// Retry logic for SD card timing issues // Retry logic for SD card timing issues
@@ -142,17 +143,17 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
uint32_t fileSize = 0; uint32_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !success; attempt++) { for (int attempt = 0; attempt < 3 && !success; attempt++) {
if (attempt > 0) { if (attempt > 0) {
Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1); LOG_DBG("SCT", "Retrying stream (attempt %d)...", attempt + 1);
delay(50); // Brief delay before retry delay(50); // Brief delay before retry
} }
// Remove any incomplete file from previous attempt before retrying // Remove any incomplete file from previous attempt before retrying
if (SdMan.exists(tmpHtmlPath.c_str())) { if (Storage.exists(tmpHtmlPath.c_str())) {
SdMan.remove(tmpHtmlPath.c_str()); Storage.remove(tmpHtmlPath.c_str());
} }
FsFile tmpHtml; FsFile tmpHtml;
if (!SdMan.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) { if (!Storage.openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue; continue;
} }
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024); success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
@@ -160,20 +161,20 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
tmpHtml.close(); tmpHtml.close();
// If streaming failed, remove the incomplete file immediately // If streaming failed, remove the incomplete file immediately
if (!success && SdMan.exists(tmpHtmlPath.c_str())) { if (!success && Storage.exists(tmpHtmlPath.c_str())) {
SdMan.remove(tmpHtmlPath.c_str()); Storage.remove(tmpHtmlPath.c_str());
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis()); LOG_DBG("SCT", "Removed incomplete temp file after failed attempt");
} }
} }
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis()); LOG_ERR("SCT", "Failed to stream item contents to temp file after retries");
return false; return false;
} }
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize); LOG_DBG("SCT", "Streamed temp HTML to %s (%d bytes)", tmpHtmlPath.c_str(), fileSize);
if (!SdMan.openFileForWrite("SCT", filePath, file)) { if (!Storage.openFileForWrite("SCT", filePath, file)) {
return false; return false;
} }
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
@@ -188,11 +189,11 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Hyphenator::setPreferredLanguage(epub->getLanguage()); Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
SdMan.remove(tmpHtmlPath.c_str()); Storage.remove(tmpHtmlPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis()); LOG_ERR("SCT", "Failed to parse XML and build pages");
file.close(); file.close();
SdMan.remove(filePath.c_str()); Storage.remove(filePath.c_str());
return false; return false;
} }
@@ -208,9 +209,9 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
} }
if (hasFailedLutRecords) { if (hasFailedLutRecords) {
Serial.printf("[%lu] [SCT] Failed to write LUT due to invalid page positions\n", millis()); LOG_ERR("SCT", "Failed to write LUT due to invalid page positions");
file.close(); file.close();
SdMan.remove(filePath.c_str()); Storage.remove(filePath.c_str());
return false; return false;
} }
@@ -223,7 +224,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
} }
std::unique_ptr<Page> Section::loadPageFromSectionFile() { std::unique_ptr<Page> Section::loadPageFromSectionFile() {
if (!SdMan.openFileForRead("SCT", filePath, file)) { if (!Storage.openFileForRead("SCT", filePath, file)) {
return nullptr; return nullptr;
} }

View File

@@ -1,13 +1,14 @@
#include "TextBlock.h" #include "TextBlock.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
// Validate iterator bounds before rendering // Validate iterator bounds before rendering
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) { if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), LOG_ERR("TXB", "Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", (uint32_t)words.size(),
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size()); (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
return; return;
} }
@@ -49,8 +50,8 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
bool TextBlock::serialize(FsFile& file) const { bool TextBlock::serialize(FsFile& file) const {
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) { if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
Serial.printf("[%lu] [TXB] Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), LOG_ERR("TXB", "Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", words.size(),
words.size(), wordXpos.size(), wordStyles.size()); wordXpos.size(), wordStyles.size());
return false; return false;
} }
@@ -89,7 +90,7 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block) // Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
if (wc > 10000) { if (wc > 10000) {
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc); LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
return nullptr; return nullptr;
} }

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <EpdFontFamily.h> #include <EpdFontFamily.h>
#include <SdFat.h> #include <HalStorage.h>
#include <list> #include <list>
#include <memory> #include <memory>

View File

@@ -1,6 +1,6 @@
#include "CssParser.h" #include "CssParser.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -449,7 +449,7 @@ void CssParser::processRuleBlock(const std::string& selectorGroup, const std::st
bool CssParser::loadFromStream(FsFile& source) { bool CssParser::loadFromStream(FsFile& source) {
if (!source) { if (!source) {
Serial.printf("[%lu] [CSS] Cannot read from invalid file\n", millis()); LOG_ERR("CSS", "Cannot read from invalid file");
return false; return false;
} }
@@ -470,7 +470,7 @@ bool CssParser::loadFromStream(FsFile& source) {
processRuleBlock(selector, body); processRuleBlock(selector, body);
} }
Serial.printf("[%lu] [CSS] Parsed %zu rules\n", millis(), rulesBySelector_.size()); LOG_DBG("CSS", "Parsed %zu rules", rulesBySelector_.size());
return true; return true;
} }
@@ -582,7 +582,7 @@ bool CssParser::saveToCache(FsFile& file) const {
file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits)); file.write(reinterpret_cast<const uint8_t*>(&definedBits), sizeof(definedBits));
} }
Serial.printf("[%lu] [CSS] Saved %u rules to cache\n", millis(), ruleCount); LOG_DBG("CSS", "Saved %u rules to cache", ruleCount);
return true; return true;
} }
@@ -597,7 +597,7 @@ bool CssParser::loadFromCache(FsFile& file) {
// Read and verify version // Read and verify version
uint8_t version = 0; uint8_t version = 0;
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) { if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
Serial.printf("[%lu] [CSS] Cache version mismatch (got %u, expected %u)\n", millis(), version, CSS_CACHE_VERSION); LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
return false; return false;
} }
@@ -694,6 +694,6 @@ bool CssParser::loadFromCache(FsFile& file) {
rulesBySelector_[selector] = style; rulesBySelector_[selector] = style;
} }
Serial.printf("[%lu] [CSS] Loaded %u rules from cache\n", millis(), ruleCount); LOG_DBG("CSS", "Loaded %u rules from cache", ruleCount);
return true; return true;
} }

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>

View File

@@ -0,0 +1,76 @@
// from
// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp
#include "htmlEntities.h"
#include <cstring>
struct EntityPair {
const char* key;
const char* value;
};
static const EntityPair ENTITY_LOOKUP[] = {
{"&quot;", "\""}, {"&frasl;", ""}, {"&amp;", "&"}, {"&lt;", "<"}, {"&gt;", ">"},
{"&Agrave;", "À"}, {"&Aacute;", "Á"}, {"&Acirc;", "Â"}, {"&Atilde;", "Ã"}, {"&Auml;", "Ä"},
{"&Aring;", "Å"}, {"&AElig;", "Æ"}, {"&Ccedil;", "Ç"}, {"&Egrave;", "È"}, {"&Eacute;", "É"},
{"&Ecirc;", "Ê"}, {"&Euml;", "Ë"}, {"&Igrave;", "Ì"}, {"&Iacute;", "Í"}, {"&Icirc;", "Î"},
{"&Iuml;", "Ï"}, {"&ETH;", "Ð"}, {"&Ntilde;", "Ñ"}, {"&Ograve;", "Ò"}, {"&Oacute;", "Ó"},
{"&Ocirc;", "Ô"}, {"&Otilde;", "Õ"}, {"&Ouml;", "Ö"}, {"&Oslash;", "Ø"}, {"&Ugrave;", "Ù"},
{"&Uacute;", "Ú"}, {"&Ucirc;", "Û"}, {"&Uuml;", "Ü"}, {"&Yacute;", "Ý"}, {"&THORN;", "Þ"},
{"&szlig;", "ß"}, {"&agrave;", "à"}, {"&aacute;", "á"}, {"&acirc;", "â"}, {"&atilde;", "ã"},
{"&auml;", "ä"}, {"&aring;", "å"}, {"&aelig;", "æ"}, {"&ccedil;", "ç"}, {"&egrave;", "è"},
{"&eacute;", "é"}, {"&ecirc;", "ê"}, {"&euml;", "ë"}, {"&igrave;", "ì"}, {"&iacute;", "í"},
{"&icirc;", "î"}, {"&iuml;", "ï"}, {"&eth;", "ð"}, {"&ntilde;", "ñ"}, {"&ograve;", "ò"},
{"&oacute;", "ó"}, {"&ocirc;", "ô"}, {"&otilde;", "õ"}, {"&ouml;", "ö"}, {"&oslash;", "ø"},
{"&ugrave;", "ù"}, {"&uacute;", "ú"}, {"&ucirc;", "û"}, {"&uuml;", "ü"}, {"&yacute;", "ý"},
{"&thorn;", "þ"}, {"&yuml;", "ÿ"}, {"&nbsp;", "\xC2\xA0"}, {"&iexcl;", "¡"}, {"&cent;", "¢"},
{"&pound;", "£"}, {"&curren;", "¤"}, {"&yen;", "¥"}, {"&brvbar;", "¦"}, {"&sect;", "§"},
{"&uml;", "¨"}, {"&copy;", "©"}, {"&ordf;", "ª"}, {"&laquo;", "«"}, {"&not;", "¬"},
{"&shy;", "­"}, {"&reg;", "®"}, {"&macr;", "¯"}, {"&deg;", "°"}, {"&plusmn;", "±"},
{"&sup2;", "²"}, {"&sup3;", "³"}, {"&acute;", "´"}, {"&micro;", "µ"}, {"&para;", ""},
{"&cedil;", "¸"}, {"&sup1;", "¹"}, {"&ordm;", "º"}, {"&raquo;", "»"}, {"&frac14;", "¼"},
{"&frac12;", "½"}, {"&frac34;", "¾"}, {"&iquest;", "¿"}, {"&times;", "×"}, {"&divide;", "÷"},
{"&forall;", ""}, {"&part;", ""}, {"&exist;", ""}, {"&empty;", ""}, {"&nabla;", ""},
{"&isin;", ""}, {"&notin;", ""}, {"&ni;", ""}, {"&prod;", ""}, {"&sum;", ""},
{"&minus;", ""}, {"&lowast;", ""}, {"&radic;", ""}, {"&prop;", ""}, {"&infin;", ""},
{"&ang;", ""}, {"&and;", ""}, {"&or;", ""}, {"&cap;", ""}, {"&cup;", ""},
{"&int;", ""}, {"&there4;", ""}, {"&sim;", ""}, {"&cong;", ""}, {"&asymp;", ""},
{"&ne;", ""}, {"&equiv;", ""}, {"&le;", ""}, {"&ge;", ""}, {"&sub;", ""},
{"&sup;", ""}, {"&nsub;", ""}, {"&sube;", ""}, {"&supe;", ""}, {"&oplus;", ""},
{"&otimes;", ""}, {"&perp;", ""}, {"&sdot;", ""}, {"&Alpha;", "Α"}, {"&Beta;", "Β"},
{"&Gamma;", "Γ"}, {"&Delta;", "Δ"}, {"&Epsilon;", "Ε"}, {"&Zeta;", "Ζ"}, {"&Eta;", "Η"},
{"&Theta;", "Θ"}, {"&Iota;", "Ι"}, {"&Kappa;", "Κ"}, {"&Lambda;", "Λ"}, {"&Mu;", "Μ"},
{"&Nu;", "Ν"}, {"&Xi;", "Ξ"}, {"&Omicron;", "Ο"}, {"&Pi;", "Π"}, {"&Rho;", "Ρ"},
{"&Sigma;", "Σ"}, {"&Tau;", "Τ"}, {"&Upsilon;", "Υ"}, {"&Phi;", "Φ"}, {"&Chi;", "Χ"},
{"&Psi;", "Ψ"}, {"&Omega;", "Ω"}, {"&alpha;", "α"}, {"&beta;", "β"}, {"&gamma;", "γ"},
{"&delta;", "δ"}, {"&epsilon;", "ε"}, {"&zeta;", "ζ"}, {"&eta;", "η"}, {"&theta;", "θ"},
{"&iota;", "ι"}, {"&kappa;", "κ"}, {"&lambda;", "λ"}, {"&mu;", "μ"}, {"&nu;", "ν"},
{"&xi;", "ξ"}, {"&omicron;", "ο"}, {"&pi;", "π"}, {"&rho;", "ρ"}, {"&sigmaf;", "ς"},
{"&sigma;", "σ"}, {"&tau;", "τ"}, {"&upsilon;", "υ"}, {"&phi;", "φ"}, {"&chi;", "χ"},
{"&psi;", "ψ"}, {"&omega;", "ω"}, {"&thetasym;", "ϑ"}, {"&upsih;", "ϒ"}, {"&piv;", "ϖ"},
{"&OElig;", "Œ"}, {"&oelig;", "œ"}, {"&Scaron;", "Š"}, {"&scaron;", "š"}, {"&Yuml;", "Ÿ"},
{"&fnof;", "ƒ"}, {"&circ;", "ˆ"}, {"&tilde;", "˜"}, {"&ensp;", ""}, {"&emsp;", ""},
{"&thinsp;", ""}, {"&zwnj;", ""}, {"&zwj;", ""}, {"&lrm;", ""}, {"&rlm;", ""},
{"&ndash;", ""}, {"&mdash;", ""}, {"&lsquo;", ""}, {"&rsquo;", ""}, {"&sbquo;", ""},
{"&ldquo;", ""}, {"&rdquo;", ""}, {"&bdquo;", ""}, {"&dagger;", ""}, {"&Dagger;", ""},
{"&bull;", ""}, {"&hellip;", ""}, {"&permil;", ""}, {"&prime;", ""}, {"&Prime;", ""},
{"&lsaquo;", ""}, {"&rsaquo;", ""}, {"&oline;", ""}, {"&euro;", ""}, {"&trade;", ""},
{"&larr;", ""}, {"&uarr;", ""}, {"&rarr;", ""}, {"&darr;", ""}, {"&harr;", ""},
{"&crarr;", ""}, {"&lceil;", ""}, {"&rceil;", ""}, {"&lfloor;", ""}, {"&rfloor;", ""},
{"&loz;", ""}, {"&spades;", ""}, {"&clubs;", ""}, {"&hearts;", ""}, {"&diams;", ""}};
static const size_t ENTITY_LOOKUP_COUNT = sizeof(ENTITY_LOOKUP) / sizeof(ENTITY_LOOKUP[0]);
// Lookup a single HTML entity and return its UTF-8 value
const char* lookupHtmlEntity(const char* entity, int len) {
for (size_t i = 0; i < ENTITY_LOOKUP_COUNT; i++) {
const char* key = ENTITY_LOOKUP[i].key;
const size_t keyLen = strlen(key);
if (static_cast<size_t>(len) == keyLen && memcmp(entity, key, keyLen) == 0) {
return ENTITY_LOOKUP[i].value;
}
}
return nullptr; // Entity not found
}

View File

@@ -0,0 +1,9 @@
// from
// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp
#pragma once
#include <string>
// Lookup a single HTML entity (including & and ;) and return its UTF-8 value
// Returns nullptr if entity is not found
const char* lookupHtmlEntity(const char* entity, int len);

View File

@@ -8,6 +8,7 @@
#include "generated/hyph-en.trie.h" #include "generated/hyph-en.trie.h"
#include "generated/hyph-es.trie.h" #include "generated/hyph-es.trie.h"
#include "generated/hyph-fr.trie.h" #include "generated/hyph-fr.trie.h"
#include "generated/hyph-it.trie.h"
#include "generated/hyph-ru.trie.h" #include "generated/hyph-ru.trie.h"
namespace { namespace {
@@ -18,15 +19,17 @@ LanguageHyphenator frenchHyphenator(fr_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator germanHyphenator(de_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic); LanguageHyphenator russianHyphenator(ru_ru_patterns, isCyrillicLetter, toLowerCyrillic);
LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator spanishHyphenator(es_patterns, isLatinLetter, toLowerLatin);
LanguageHyphenator italianHyphenator(it_patterns, isLatinLetter, toLowerLatin);
using EntryArray = std::array<LanguageEntry, 5>; using EntryArray = std::array<LanguageEntry, 6>;
const EntryArray& entries() { const EntryArray& entries() {
static const EntryArray kEntries = {{{"english", "en", &englishHyphenator}, static const EntryArray kEntries = {{{"english", "en", &englishHyphenator},
{"french", "fr", &frenchHyphenator}, {"french", "fr", &frenchHyphenator},
{"german", "de", &germanHyphenator}, {"german", "de", &germanHyphenator},
{"russian", "ru", &russianHyphenator}, {"russian", "ru", &russianHyphenator},
{"spanish", "es", &spanishHyphenator}}}; {"spanish", "es", &spanishHyphenator},
{"italian", "it", &italianHyphenator}}};
return kEntries; return kEntries;
} }

View File

@@ -0,0 +1,113 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "../SerializedHyphenationTrie.h"
// Auto-generated by generate_hyphenation_trie.py. Do not edit manually.
alignas(4) constexpr uint8_t it_trie_data[] = {
0x00, 0x00, 0x05, 0xC4, 0x17, 0x0C, 0x33, 0x35, 0x0C, 0x29, 0x22, 0x0D, 0x3E, 0x0B, 0x47, 0x20,
0x0D, 0x16, 0x0B, 0x34, 0x0D, 0x21, 0x0C, 0x3D, 0x1F, 0x0C, 0x2A, 0x17, 0x2A, 0x0B, 0x02, 0x0C,
0x01, 0x02, 0x16, 0x02, 0x0D, 0x0C, 0x0C, 0x0D, 0x03, 0x0C, 0x01, 0x0C, 0x0E, 0x0D, 0x04, 0x02,
0x0B, 0xA0, 0x00, 0x42, 0x21, 0x6E, 0xFD, 0xA0, 0x00, 0x72, 0x21, 0x6E, 0xFD, 0xA1, 0x00, 0x61,
0x6D, 0xFD, 0x21, 0x69, 0xFB, 0x21, 0x74, 0xFD, 0x22, 0x70, 0x6E, 0xEC, 0xFD, 0xA0, 0x00, 0x91,
0x21, 0x6F, 0xFD, 0x21, 0x69, 0xFD, 0xA0, 0x00, 0xA2, 0x21, 0x73, 0xFD, 0x21, 0x70, 0xFD, 0xA0,
0x00, 0xC2, 0x21, 0x6D, 0xFD, 0x21, 0x75, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x72, 0xFD, 0xA0, 0x00,
0xE1, 0x21, 0x6F, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x74, 0xFD, 0x21, 0x6E, 0xFD, 0xA3, 0x01, 0x11,
0x61, 0x69, 0x6F, 0xDF, 0xEE, 0xFD, 0xA0, 0x00, 0xF2, 0x21, 0x65, 0xFD, 0x21, 0x6E, 0xFD, 0x21,
0x69, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x73, 0xFD, 0xA1, 0x01, 0x11, 0x69, 0xFD, 0xA0, 0x01, 0x12,
0x21, 0x75, 0xFD, 0x21, 0x65, 0xFD, 0x21, 0x78, 0xFD, 0xA0, 0x01, 0x32, 0x21, 0x6B, 0xFD, 0x21,
0x6E, 0xFD, 0xA0, 0x00, 0x71, 0x21, 0x65, 0xFD, 0x22, 0x61, 0x65, 0xF7, 0xFD, 0x21, 0x72, 0xFB,
0xA0, 0x01, 0x52, 0x21, 0x61, 0xFD, 0x21, 0x73, 0xFD, 0x21, 0x70, 0xFD, 0x21, 0x69, 0xFD, 0xA0,
0x01, 0x71, 0x21, 0x6F, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x61, 0xFD, 0xA0, 0x00,
0x61, 0x21, 0x6F, 0xFD, 0x21, 0x74, 0xFD, 0x41, 0x70, 0xFF, 0x50, 0x21, 0x6F, 0xFC, 0x21, 0x74,
0xFD, 0x22, 0x70, 0x72, 0xF3, 0xFD, 0x21, 0x61, 0xE8, 0x21, 0x72, 0xFD, 0xA0, 0x00, 0xF1, 0x22,
0x6C, 0x72, 0xFD, 0xFD, 0x21, 0x69, 0xE3, 0x21, 0x6C, 0xFD, 0x41, 0x65, 0xFF, 0x43, 0xA0, 0x01,
0x11, 0x25, 0x61, 0x68, 0x6F, 0x72, 0x73, 0xE8, 0xEE, 0xF6, 0xF9, 0xFD, 0xA0, 0x01, 0x82, 0x21,
0x72, 0xFD, 0x21, 0x63, 0xFD, 0x21, 0x73, 0xFD, 0x21, 0x69, 0xFD, 0x21, 0x65, 0xFD, 0xA0, 0x01,
0xA2, 0x21, 0x65, 0xFD, 0x21, 0x72, 0xFD, 0x21, 0x61, 0xFD, 0x41, 0x75, 0xFF, 0x4C, 0x42, 0x6C,
0x72, 0xFF, 0xFC, 0xFF, 0x48, 0x21, 0x62, 0xF9, 0x22, 0x68, 0x75, 0xEF, 0xFD, 0x47, 0x63, 0x64,
0x6C, 0x6E, 0x70, 0x72, 0x74, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF, 0x5C, 0xFF,
0x5C, 0xFF, 0x5C, 0x21, 0x73, 0xEA, 0x21, 0x6E, 0xFD, 0x21, 0x61, 0xFD, 0xA1, 0x01, 0x11, 0x72,
0xFD, 0x41, 0x6E, 0xFF, 0x15, 0x21, 0x67, 0xFC, 0xA0, 0x01, 0xC2, 0x21, 0x74, 0xFD, 0x21, 0x6C,
0xFD, 0x22, 0x61, 0x65, 0xF4, 0xFD, 0x52, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x6C, 0x6E, 0x6F,
0x70, 0x72, 0x73, 0x74, 0x77, 0x68, 0x6A, 0x6B, 0x7A, 0xFE, 0xC2, 0xFE, 0xCD, 0xFE, 0xF7, 0xFF,
0x12, 0xFF, 0x20, 0xFF, 0x37, 0xFF, 0x46, 0xFF, 0x55, 0xFF, 0x6B, 0xFF, 0x8B, 0xFF, 0xA5, 0xFF,
0xC2, 0xFF, 0xE6, 0xFF, 0xFB, 0xFF, 0x88, 0xFF, 0x88, 0xFF, 0x88, 0xFF, 0x88, 0xA0, 0x01, 0xE2,
0xA0, 0x00, 0xD1, 0x24, 0x61, 0x65, 0x6F, 0x75, 0xFD, 0xFD, 0xFD, 0xFD, 0x21, 0x6F, 0xF4, 0x21,
0x61, 0xF1, 0xA0, 0x01, 0xE1, 0x21, 0x2E, 0xFD, 0x24, 0x69, 0x75, 0x79, 0x74, 0xEB, 0xF4, 0xF7,
0xFD, 0x21, 0x75, 0xDF, 0xA0, 0x00, 0x51, 0x22, 0x69, 0x77, 0xFA, 0xFD, 0x21, 0x69, 0xD7, 0xAE,
0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x6D, 0x6E, 0x70, 0x73, 0x74, 0x76, 0x6C, 0x72, 0x2E, 0x27,
0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xE3, 0xF5, 0xF5, 0xE3, 0xE3, 0x22, 0x2E,
0x27, 0xC4, 0xC7, 0xC6, 0x00, 0x51, 0x68, 0x2E, 0x27, 0x62, 0x72, 0x6E, 0xFF, 0xBF, 0xFF, 0xBF,
0xFF, 0xFB, 0xFF, 0xBF, 0xFE, 0xFB, 0xFF, 0xBF, 0xD0, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x6B,
0x6D, 0x6E, 0x71, 0x73, 0x74, 0x7A, 0x68, 0x6C, 0x72, 0x2E, 0x27, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF,
0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF,
0xAA, 0xFF, 0xEB, 0xFF, 0xBC, 0xFF, 0xBC, 0xFF, 0xAA, 0xFF, 0xAA, 0xCE, 0x02, 0x01, 0x62, 0x64,
0x67, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73, 0x74, 0x76, 0x77, 0x2E, 0x27, 0xFF, 0x77, 0xFF, 0x77,
0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x89, 0xFF, 0x77, 0xFF, 0x77,
0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xFF, 0x77, 0xCA, 0x02, 0x01, 0x62, 0x67, 0x66, 0x6E, 0x6C,
0x72, 0x73, 0x74, 0x2E, 0x27, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x5C, 0xFF,
0x5C, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xFF, 0x4A, 0xA0, 0x02, 0x12, 0xA1, 0x00, 0x51, 0x74,
0xFD, 0xD1, 0x02, 0x01, 0x62, 0x64, 0x66, 0x67, 0x68, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73, 0x74,
0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0xFB, 0xFF,
0x33, 0xFF, 0x21, 0xFF, 0x33, 0xFF, 0x21, 0xFF, 0x33, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF,
0x21, 0xFF, 0x21, 0xFF, 0x21, 0xFF, 0x21, 0x41, 0x70, 0xFD, 0x4D, 0xCB, 0x02, 0x01, 0x62, 0x64,
0x68, 0x69, 0x6C, 0x6D, 0x6E, 0x72, 0x76, 0x2E, 0x27, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFF,
0xFC, 0xFE, 0xF9, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xFE, 0xE7, 0xC2,
0x02, 0x01, 0x2E, 0x27, 0xFE, 0xC3, 0xFE, 0xC3, 0xCB, 0x02, 0x01, 0x67, 0x66, 0x68, 0x6B, 0x6C,
0x6D, 0x72, 0x73, 0x74, 0x2E, 0x27, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xCC, 0xFE, 0xBA, 0xFE, 0xCC,
0xFE, 0xBA, 0xFE, 0xCC, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xBA, 0xFE, 0xBA, 0xA0, 0x02, 0x33, 0x42,
0x2E, 0x27, 0xFE, 0x93, 0xFE, 0x93, 0xD5, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6A,
0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFF, 0xF6, 0xFE, 0x8C, 0xFE, 0x9E, 0xFE, 0x9E, 0xFE, 0x8C, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C,
0xFE, 0x8C, 0xFE, 0x8C, 0xFE, 0x8C, 0xFF, 0xF9, 0xCF, 0x02, 0x01, 0x62, 0x63, 0x66, 0x6C, 0x6D,
0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x2E, 0x27, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A,
0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A,
0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xFE, 0x4A, 0xA0, 0x02, 0x62, 0xA1, 0x01, 0xE1, 0x6E, 0xFD,
0x21, 0x72, 0xF8, 0x21, 0x65, 0xFD, 0xA1, 0x01, 0xE1, 0x66, 0xFD, 0x41, 0x74, 0xFE, 0x07, 0x21,
0x69, 0xFC, 0x21, 0x65, 0xFD, 0xD3, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x6B, 0x6C, 0x6D,
0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x7A, 0x68, 0x2E, 0x27, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xFD, 0xFD, 0xFF, 0xE6, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xFD, 0xFD, 0xFF, 0xF1, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xFF, 0xFD, 0xFD, 0xFD, 0xFD,
0xFD, 0xA0, 0x02, 0x82, 0xA1, 0x01, 0xE1, 0x65, 0xFD, 0x21, 0x63, 0xF8, 0xA1, 0x01, 0xE1, 0x69,
0xFD, 0xCB, 0x02, 0x01, 0x64, 0x68, 0x6C, 0x6E, 0x70, 0x72, 0x73, 0x74, 0x7A, 0x2E, 0x27, 0xFD,
0xB1, 0xFD, 0xC3, 0xFD, 0xC3, 0xFF, 0xF3, 0xFD, 0xB1, 0xFD, 0xC3, 0xFF, 0xFB, 0xFD, 0xB1, 0xFD,
0xB1, 0xFD, 0xB1, 0xFD, 0xB1, 0xC3, 0x02, 0x01, 0x71, 0x2E, 0x27, 0xFD, 0x8D, 0xFD, 0x8D, 0xFD,
0x8D, 0xA0, 0x02, 0x53, 0xA1, 0x01, 0xE1, 0x73, 0xFD, 0xD5, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66,
0x68, 0x67, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x78, 0x77, 0x7A, 0x2E,
0x27, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x8B, 0xFD, 0x79, 0xFD, 0x79, 0xFD,
0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFF, 0xFB, 0xFD,
0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0xFD, 0x79, 0x43, 0x6D, 0x2E, 0x27, 0xFD,
0x37, 0xFD, 0x37, 0xFD, 0x37, 0xA0, 0x02, 0xC2, 0xA1, 0x02, 0x32, 0x6D, 0xFD, 0x41, 0x6E, 0xFE,
0x8F, 0x4B, 0x62, 0x63, 0x64, 0x66, 0x67, 0x6D, 0x6E, 0x70, 0x73, 0x74, 0x76, 0xFD, 0x21, 0xFD,
0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD, 0x21, 0xFD,
0x21, 0xFD, 0x21, 0xA0, 0x02, 0xE1, 0x22, 0x2E, 0x27, 0xFD, 0xFD, 0xC7, 0x02, 0xA2, 0x68, 0x73,
0x70, 0x74, 0x7A, 0x2E, 0x27, 0xFF, 0xC0, 0xFF, 0xCD, 0xFF, 0xD2, 0xFF, 0xD6, 0xFC, 0xF7, 0xFF,
0xF8, 0xFF, 0xFB, 0xC1, 0x00, 0x51, 0x2E, 0xFC, 0xDF, 0x41, 0x68, 0xFF, 0x18, 0xA1, 0x00, 0x51,
0x63, 0xFC, 0xC1, 0x01, 0xE1, 0x73, 0xFE, 0xB6, 0xC2, 0x00, 0x51, 0x6B, 0x73, 0xFC, 0xCA, 0xFC,
0x06, 0xD2, 0x02, 0x01, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68, 0x6C, 0x6D, 0x6E, 0x70, 0x72, 0x73,
0x74, 0x76, 0x77, 0x7A, 0x2E, 0x27, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1,
0xFF, 0xE2, 0xFC, 0xD3, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xC1, 0xFC, 0xD3, 0xFF, 0xEC, 0xFF, 0xF1,
0xFC, 0xC1, 0xFC, 0xC1, 0xFF, 0xF7, 0xFC, 0xC1, 0xFE, 0x2E, 0xC6, 0x02, 0x01, 0x63, 0x6C, 0x72,
0x76, 0x2E, 0x27, 0xFC, 0x88, 0xFC, 0x9A, 0xFC, 0x9A, 0xFC, 0x88, 0xFC, 0x88, 0xFD, 0xF5, 0x41,
0x72, 0xFB, 0xAF, 0xA0, 0x02, 0xF2, 0xC5, 0x02, 0x01, 0x68, 0x61, 0x79, 0x2E, 0x27, 0xFC, 0x7E,
0xFF, 0xF9, 0xFF, 0xFD, 0xFC, 0x6C, 0xFC, 0x6C, 0xCA, 0x02, 0x01, 0x62, 0x63, 0x66, 0x68, 0x6D,
0x70, 0x74, 0x77, 0x2E, 0x27, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC,
0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0xFC, 0x5A, 0x42, 0x6F, 0x69, 0xFC, 0x48, 0xFC, 0x27,
0xCB, 0x02, 0x01, 0x62, 0x64, 0x6C, 0x6E, 0x70, 0x74, 0x73, 0x76, 0x7A, 0x2E, 0x27, 0xFC, 0x32,
0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32, 0xFC, 0x32,
0xFC, 0x32, 0xFD, 0x9F, 0x5A, 0x2E, 0x27, 0x61, 0x65, 0x6F, 0x62, 0x63, 0x64, 0x66, 0x67, 0x68,
0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x78, 0x79, 0x7A, 0xFB,
0xC2, 0xFB, 0xF9, 0xFC, 0x14, 0xFC, 0x23, 0xFC, 0x28, 0xFC, 0x2B, 0xFC, 0x64, 0xFC, 0x97, 0xFC,
0xC4, 0xFC, 0xED, 0xFD, 0x27, 0xFD, 0x4B, 0xFD, 0x54, 0xFD, 0x82, 0xFD, 0xC4, 0xFE, 0x11, 0xFE,
0x5D, 0xFE, 0x81, 0xFE, 0x95, 0xFF, 0x17, 0xFF, 0x4D, 0xFF, 0x86, 0xFF, 0xA2, 0xFF, 0xB4, 0xFF,
0xD5, 0xFF, 0xDC,
};
constexpr SerializedHyphenationPatterns it_patterns = {
it_trie_data,
sizeof(it_trie_data),
};

View File

@@ -1,17 +1,18 @@
#include "ChapterHtmlSlimParser.h" #include "ChapterHtmlSlimParser.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
#include <expat.h> #include <expat.h>
#include "../Page.h" #include "../Page.h"
#include "../htmlEntities.h"
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it // Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_POPUP = 50 * 1024; // 50KB constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
@@ -168,7 +169,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
} }
} }
Serial.printf("[%lu] [EHP] Image alt: %s\n", millis(), alt.c_str()); LOG_DBG("EHP", "Image alt: %s", alt.c_str());
self->startNewTextBlock(centeredBlockStyle); self->startNewTextBlock(centeredBlockStyle);
self->italicUntilDepth = min(self->italicUntilDepth, self->depth); self->italicUntilDepth = min(self->italicUntilDepth, self->depth);
@@ -359,6 +360,28 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
continue; continue;
} }
// Detect U+00A0 (non-breaking space): UTF-8 encoding is 0xC2 0xA0
// Render a visible space without allowing a line break around it.
if (static_cast<uint8_t>(s[i]) == 0xC2 && i + 1 < len && static_cast<uint8_t>(s[i + 1]) == 0xA0) {
// Flush any pending text so style is applied correctly.
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
// Add a standalone space that attaches to the previous word.
self->partWordBuffer[0] = ' ';
self->partWordBuffer[1] = '\0';
self->partWordBufferIndex = 1;
self->nextWordContinues = true; // Attach space to previous word (no break).
self->flushPartWordBuffer();
// Ensure the next real word attaches to this space (no break).
self->nextWordContinues = true;
i++; // Skip the second byte (0xA0)
continue;
}
// Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF // Skip Zero Width No-Break Space / BOM (U+FEFF) = 0xEF 0xBB 0xBF
const XML_Char FEFF_BYTE_1 = static_cast<XML_Char>(0xEF); const XML_Char FEFF_BYTE_1 = static_cast<XML_Char>(0xEF);
const XML_Char FEFF_BYTE_2 = static_cast<XML_Char>(0xBB); const XML_Char FEFF_BYTE_2 = static_cast<XML_Char>(0xBB);
@@ -386,13 +409,29 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
// memory. // memory.
// Spotted when reading Intermezzo, there are some really long text blocks in there. // Spotted when reading Intermezzo, there are some really long text blocks in there.
if (self->currentTextBlock->size() > 750) { if (self->currentTextBlock->size() > 750) {
Serial.printf("[%lu] [EHP] Text block too long, splitting into multiple pages\n", millis()); LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
self->currentTextBlock->layoutAndExtractLines( self->currentTextBlock->layoutAndExtractLines(
self->renderer, self->fontId, self->viewportWidth, self->renderer, self->fontId, self->viewportWidth,
[self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false); [self](const std::shared_ptr<TextBlock>& textBlock) { self->addLineToPage(textBlock); }, false);
} }
} }
void XMLCALL ChapterHtmlSlimParser::defaultHandlerExpand(void* userData, const XML_Char* s, const int len) {
// Check if this looks like an entity reference (&...;)
if (len >= 3 && s[0] == '&' && s[len - 1] == ';') {
const char* utf8Value = lookupHtmlEntity(s, len);
if (utf8Value != nullptr) {
// Known entity: expand to its UTF-8 value
characterData(userData, utf8Value, strlen(utf8Value));
return;
}
// Unknown entity: preserve original &...; sequence
characterData(userData, s, len);
return;
}
// Not an entity we recognize - skip it
}
void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) { void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* name) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData); auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
@@ -477,12 +516,16 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
int done; int done;
if (!parser) { if (!parser) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for parser\n", millis()); LOG_ERR("EHP", "Couldn't allocate memory for parser");
return false; return false;
} }
// Handle HTML entities (like &nbsp;) that aren't in XML spec or DTD
// Using DefaultHandlerExpand preserves normal entity expansion from DOCTYPE
XML_SetDefaultHandlerExpand(parser, defaultHandlerExpand);
FsFile file; FsFile file;
if (!SdMan.openFileForRead("EHP", filepath, file)) { if (!Storage.openFileForRead("EHP", filepath, file)) {
XML_ParserFree(parser); XML_ParserFree(parser);
return false; return false;
} }
@@ -499,7 +542,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
do { do {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [EHP] Couldn't allocate memory for buffer\n", millis()); LOG_ERR("EHP", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -511,7 +554,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
const size_t len = file.read(buf, 1024); const size_t len = file.read(buf, 1024);
if (len == 0 && file.available() > 0) { if (len == 0 && file.available() > 0) {
Serial.printf("[%lu] [EHP] File read error\n", millis()); LOG_ERR("EHP", "File read error");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -523,7 +566,7 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
done = file.available() == 0; done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [EHP] Parse error at line %lu:\n%s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_ERR("EHP", "Parse error at line %lu:\n%s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
@@ -568,7 +611,7 @@ void ChapterHtmlSlimParser::addLineToPage(std::shared_ptr<TextBlock> line) {
void ChapterHtmlSlimParser::makePages() { void ChapterHtmlSlimParser::makePages() {
if (!currentTextBlock) { if (!currentTextBlock) {
Serial.printf("[%lu] [EHP] !! No text block to make pages for !!\n", millis()); LOG_ERR("EHP", "!! No text block to make pages for !!");
return; return;
} }

View File

@@ -64,6 +64,7 @@ class ChapterHtmlSlimParser {
// 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);
static void XMLCALL characterData(void* userData, const XML_Char* s, int len); static void XMLCALL characterData(void* userData, const XML_Char* s, int len);
static void XMLCALL defaultHandlerExpand(void* userData, const XML_Char* s, int len);
static void XMLCALL endElement(void* userData, const XML_Char* name); static void XMLCALL endElement(void* userData, const XML_Char* name);
public: public:

View File

@@ -1,11 +1,11 @@
#include "ContainerParser.h" #include "ContainerParser.h"
#include <HardwareSerial.h> #include <Logging.h>
bool ContainerParser::setup() { bool ContainerParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
Serial.printf("[%lu] [CTR] Couldn't allocate memory for parser\n", millis()); LOG_ERR("CTR", "Couldn't allocate memory for parser");
return false; return false;
} }
@@ -34,7 +34,7 @@ size_t ContainerParser::write(const uint8_t* buffer, const size_t size) {
while (remainingInBuffer > 0) { while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [CTR] Couldn't allocate buffer\n", millis()); LOG_DBG("CTR", "Couldn't allocate buffer");
return 0; return 0;
} }
@@ -42,7 +42,7 @@ size_t ContainerParser::write(const uint8_t* buffer, const size_t size) {
memcpy(buf, currentBufferPos, toRead); memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [CTR] Parse error: %s\n", millis(), XML_ErrorString(XML_GetErrorCode(parser))); LOG_ERR("CTR", "Parse error: %s", XML_ErrorString(XML_GetErrorCode(parser)));
return 0; return 0;
} }

View File

@@ -1,7 +1,7 @@
#include "ContentOpfParser.h" #include "ContentOpfParser.h"
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include "../BookMetadataCache.h" #include "../BookMetadataCache.h"
@@ -15,7 +15,7 @@ constexpr char itemCacheFile[] = "/.items.bin";
bool ContentOpfParser::setup() { bool ContentOpfParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
Serial.printf("[%lu] [COF] Couldn't allocate memory for parser\n", millis()); LOG_DBG("COF", "Couldn't allocate memory for parser");
return false; return false;
} }
@@ -36,8 +36,8 @@ ContentOpfParser::~ContentOpfParser() {
if (tempItemStore) { if (tempItemStore) {
tempItemStore.close(); tempItemStore.close();
} }
if (SdMan.exists((cachePath + itemCacheFile).c_str())) { if (Storage.exists((cachePath + itemCacheFile).c_str())) {
SdMan.remove((cachePath + itemCacheFile).c_str()); Storage.remove((cachePath + itemCacheFile).c_str());
} }
itemIndex.clear(); itemIndex.clear();
itemIndex.shrink_to_fit(); itemIndex.shrink_to_fit();
@@ -56,7 +56,7 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [COF] Couldn't allocate memory for buffer\n", millis()); LOG_ERR("COF", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -69,7 +69,7 @@ size_t ContentOpfParser::write(const uint8_t* buffer, const size_t size) {
memcpy(buf, currentBufferPos, toRead); memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [COF] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_DBG("COF", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
@@ -118,20 +118,16 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "manifest") == 0 || strcmp(name, "opf:manifest") == 0)) {
self->state = IN_MANIFEST; self->state = IN_MANIFEST;
if (!SdMan.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf( LOG_ERR("COF", "Couldn't open temp items file for writing. This is probably going to be a fatal error.");
"[%lu] [COF] Couldn't open temp items file for writing. This is probably going to be a fatal error.\n",
millis());
} }
return; return;
} }
if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "spine") == 0 || strcmp(name, "opf:spine") == 0)) {
self->state = IN_SPINE; self->state = IN_SPINE;
if (!SdMan.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf( LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
millis());
} }
// Sort item index for binary search if we have enough items // Sort item index for binary search if we have enough items
@@ -140,7 +136,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
return a.idHash < b.idHash || (a.idHash == b.idHash && a.idLen < b.idLen); return a.idHash < b.idHash || (a.idHash == b.idHash && a.idLen < b.idLen);
}); });
self->useItemIndex = true; self->useItemIndex = true;
Serial.printf("[%lu] [COF] Using fast index for %zu manifest items\n", millis(), self->itemIndex.size()); LOG_DBG("COF", "Using fast index for %zu manifest items", self->itemIndex.size());
} }
return; return;
} }
@@ -148,11 +144,9 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) { if (self->state == IN_PACKAGE && (strcmp(name, "guide") == 0 || strcmp(name, "opf:guide") == 0)) {
self->state = IN_GUIDE; self->state = IN_GUIDE;
// TODO Remove print // TODO Remove print
Serial.printf("[%lu] [COF] Entering guide state.\n", millis()); LOG_DBG("COF", "Entering guide state.");
if (!SdMan.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) {
Serial.printf( LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error.");
"[%lu] [COF] Couldn't open temp items file for reading. This is probably going to be a fatal error.\n",
millis());
} }
return; return;
} }
@@ -214,8 +208,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (self->tocNcxPath.empty()) { if (self->tocNcxPath.empty()) {
self->tocNcxPath = href; self->tocNcxPath = href;
} else { } else {
Serial.printf("[%lu] [COF] Warning: Multiple NCX files found in manifest. Ignoring duplicate: %s\n", millis(), LOG_DBG("COF", "Warning: Multiple NCX files found in manifest. Ignoring duplicate: %s", href.c_str());
href.c_str());
} }
} }
@@ -229,7 +222,15 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
// Properties is space-separated, check if "nav" is present as a word // Properties is space-separated, check if "nav" is present as a word
if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) { if (properties == "nav" || properties.find("nav ") == 0 || properties.find(" nav") != std::string::npos) {
self->tocNavPath = href; self->tocNavPath = href;
Serial.printf("[%lu] [COF] Found EPUB 3 nav document: %s\n", millis(), href.c_str()); LOG_DBG("COF", "Found EPUB 3 nav document: %s", href.c_str());
}
}
// EPUB 3: Check for cover image (properties contains "cover-image")
if (!properties.empty() && self->coverItemHref.empty()) {
if (properties == "cover-image" || properties.find("cover-image ") == 0 ||
properties.find(" cover-image") != std::string::npos) {
self->coverItemHref = href;
} }
} }
return; return;
@@ -302,7 +303,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
if (type == "text" || type == "start") { if (type == "text" || type == "start") {
continue; continue;
} else { } else {
Serial.printf("[%lu] [COF] Skipping non-text reference in guide: %s\n", millis(), type.c_str()); LOG_DBG("COF", "Skipping non-text reference in guide: %s", type.c_str());
break; break;
} }
} else if (strcmp(atts[i], "href") == 0) { } else if (strcmp(atts[i], "href") == 0) {
@@ -310,7 +311,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name
} }
} }
if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) { if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) {
Serial.printf("[%lu] [COF] Found %s reference in guide: %s.\n", millis(), type.c_str(), textHref.c_str()); LOG_DBG("COF", "Found %s reference in guide: %s.", type.c_str(), textHref.c_str());
self->textReferenceHref = textHref; self->textReferenceHref = textHref;
} }
return; return;

View File

@@ -1,14 +1,14 @@
#include "TocNavParser.h" #include "TocNavParser.h"
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <Logging.h>
#include "../BookMetadataCache.h" #include "../BookMetadataCache.h"
bool TocNavParser::setup() { bool TocNavParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
Serial.printf("[%lu] [NAV] Couldn't allocate memory for parser\n", millis()); LOG_DBG("NAV", "Couldn't allocate memory for parser");
return false; return false;
} }
@@ -39,7 +39,7 @@ size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
while (remainingInBuffer > 0) { while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [NAV] Couldn't allocate memory for buffer\n", millis()); LOG_DBG("NAV", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr); XML_SetElementHandler(parser, nullptr, nullptr);
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -52,7 +52,7 @@ size_t TocNavParser::write(const uint8_t* buffer, const size_t size) {
memcpy(buf, currentBufferPos, toRead); memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [NAV] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_DBG("NAV", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); XML_StopParser(parser, XML_FALSE);
XML_SetElementHandler(parser, nullptr, nullptr); XML_SetElementHandler(parser, nullptr, nullptr);
@@ -88,7 +88,7 @@ void XMLCALL TocNavParser::startElement(void* userData, const XML_Char* name, co
for (int i = 0; atts[i]; i += 2) { for (int i = 0; atts[i]; i += 2) {
if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) { if ((strcmp(atts[i], "epub:type") == 0 || strcmp(atts[i], "type") == 0) && strcmp(atts[i + 1], "toc") == 0) {
self->state = IN_NAV_TOC; self->state = IN_NAV_TOC;
Serial.printf("[%lu] [NAV] Found nav toc element\n", millis()); LOG_DBG("NAV", "Found nav toc element");
return; return;
} }
} }
@@ -179,7 +179,7 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) {
if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) { if (strcmp(name, "nav") == 0 && self->state >= IN_NAV_TOC) {
self->state = IN_BODY; self->state = IN_BODY;
Serial.printf("[%lu] [NAV] Finished parsing nav toc\n", millis()); LOG_DBG("NAV", "Finished parsing nav toc");
return; return;
} }
} }

View File

@@ -1,14 +1,14 @@
#include "TocNcxParser.h" #include "TocNcxParser.h"
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <Logging.h>
#include "../BookMetadataCache.h" #include "../BookMetadataCache.h"
bool TocNcxParser::setup() { bool TocNcxParser::setup() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
Serial.printf("[%lu] [TOC] Couldn't allocate memory for parser\n", millis()); LOG_DBG("TOC", "Couldn't allocate memory for parser");
return false; return false;
} }
@@ -39,7 +39,7 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
while (remainingInBuffer > 0) { while (remainingInBuffer > 0) {
void* const buf = XML_GetBuffer(parser, 1024); void* const buf = XML_GetBuffer(parser, 1024);
if (!buf) { if (!buf) {
Serial.printf("[%lu] [TOC] Couldn't allocate memory for buffer\n", millis()); LOG_DBG("TOC", "Couldn't allocate memory for buffer");
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks
XML_SetCharacterDataHandler(parser, nullptr); XML_SetCharacterDataHandler(parser, nullptr);
@@ -52,7 +52,7 @@ size_t TocNcxParser::write(const uint8_t* buffer, const size_t size) {
memcpy(buf, currentBufferPos, toRead); memcpy(buf, currentBufferPos, toRead);
if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), remainingSize == toRead) == XML_STATUS_ERROR) {
Serial.printf("[%lu] [TOC] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_DBG("TOC", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_StopParser(parser, XML_FALSE); // Stop any pending processing XML_StopParser(parser, XML_FALSE); // Stop any pending processing
XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks XML_SetElementHandler(parser, nullptr, nullptr); // Clear callbacks

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <cstdint> #include <cstdint>

View File

@@ -1,11 +1,12 @@
#include "GfxRenderer.h" #include "GfxRenderer.h"
#include <Logging.h>
#include <Utf8.h> #include <Utf8.h>
void GfxRenderer::begin() { void GfxRenderer::begin() {
frameBuffer = display.getFrameBuffer(); frameBuffer = display.getFrameBuffer();
if (!frameBuffer) { if (!frameBuffer) {
Serial.printf("[%lu] [GFX] !! No framebuffer\n", millis()); LOG_ERR("GFX", "!! No framebuffer");
assert(false); assert(false);
} }
} }
@@ -57,7 +58,7 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
// Bounds checking against physical panel dimensions // Bounds checking against physical panel dimensions
if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) { if (phyX < 0 || phyX >= HalDisplay::DISPLAY_WIDTH || phyY < 0 || phyY >= HalDisplay::DISPLAY_HEIGHT) {
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, phyX, phyY); LOG_ERR("GFX", "!! Outside range (%d, %d) -> (%d, %d)", x, y, phyX, phyY);
return; return;
} }
@@ -74,7 +75,7 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -100,7 +101,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
} }
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return; return;
} }
const auto font = fontMap.at(fontId); const auto font = fontMap.at(fontId);
@@ -133,7 +134,7 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con
} }
} else { } else {
// TODO: Implement // TODO: Implement
Serial.printf("[%lu] [GFX] Line drawing not supported\n", millis()); LOG_ERR("GFX", "Line drawing not supported");
} }
} }
@@ -419,8 +420,8 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
bool isScaled = false; bool isScaled = false;
int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f); int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f);
int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f); int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f);
Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(), LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY,
cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up"); bitmap.isTopDown() ? "top-down" : "bottom-up");
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) { if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) {
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth()); scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth());
@@ -430,7 +431,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight())); scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight()));
isScaled = true; isScaled = true;
} }
Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), scale, isScaled ? "scaled" : "not scaled"); LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled");
// Calculate output row size (2 bits per pixel, packed into bytes) // Calculate output row size (2 bits per pixel, packed into bytes)
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide // IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide
@@ -439,7 +440,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes())); auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) { if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis()); LOG_ERR("GFX", "!! Failed to allocate BMP row buffers");
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
return; return;
@@ -458,7 +459,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
} }
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); LOG_ERR("GFX", "Failed to read row %d from bitmap", bmpY);
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
return; return;
@@ -521,7 +522,7 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes())); auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) { if (!outputRow || !rowBytes) {
Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis()); LOG_ERR("GFX", "!! Failed to allocate 1-bit BMP row buffers");
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
return; return;
@@ -530,7 +531,7 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
// Read rows sequentially using readNextRow // Read rows sequentially using readNextRow
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY); LOG_ERR("GFX", "Failed to read row %d from 1-bit bitmap", bmpY);
free(outputRow); free(outputRow);
free(rowBytes); free(rowBytes);
return; return;
@@ -588,7 +589,7 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
// Allocate node buffer for scanline algorithm // Allocate node buffer for scanline algorithm
auto* nodeX = static_cast<int*>(malloc(numPoints * sizeof(int))); auto* nodeX = static_cast<int*>(malloc(numPoints * sizeof(int)));
if (!nodeX) { if (!nodeX) {
Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis()); LOG_ERR("GFX", "!! Failed to allocate polygon node buffer");
return; return;
} }
@@ -655,7 +656,7 @@ void GfxRenderer::invertScreen() const {
void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const {
auto elapsed = millis() - start_ms; auto elapsed = millis() - start_ms;
Serial.printf("[%lu] [GFX] Time = %lu ms from clearScreen to displayBuffer\n", millis(), elapsed); LOG_DBG("GFX", "Time = %lu ms from clearScreen to displayBuffer", elapsed);
display.displayBuffer(refreshMode, fadingFix); display.displayBuffer(refreshMode, fadingFix);
} }
@@ -709,7 +710,7 @@ int GfxRenderer::getScreenHeight() const {
int GfxRenderer::getSpaceWidth(const int fontId) const { int GfxRenderer::getSpaceWidth(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -718,7 +719,7 @@ int GfxRenderer::getSpaceWidth(const int fontId) const {
int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const { int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -732,7 +733,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text) const {
int GfxRenderer::getFontAscenderSize(const int fontId) const { int GfxRenderer::getFontAscenderSize(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -741,7 +742,7 @@ int GfxRenderer::getFontAscenderSize(const int fontId) const {
int GfxRenderer::getLineHeight(const int fontId) const { int GfxRenderer::getLineHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
@@ -750,7 +751,7 @@ int GfxRenderer::getLineHeight(const int fontId) const {
int GfxRenderer::getTextHeight(const int fontId) const { int GfxRenderer::getTextHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return 0; return 0;
} }
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender; return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
@@ -764,7 +765,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
} }
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); LOG_ERR("GFX", "Font %d not found", fontId);
return; return;
} }
const auto font = fontMap.at(fontId); const auto font = fontMap.at(fontId);
@@ -872,8 +873,7 @@ bool GfxRenderer::storeBwBuffer() {
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if any chunks are already allocated // Check if any chunks are already allocated
if (bwBufferChunks[i]) { if (bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk\n", LOG_ERR("GFX", "!! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk", i);
millis(), i);
free(bwBufferChunks[i]); free(bwBufferChunks[i]);
bwBufferChunks[i] = nullptr; bwBufferChunks[i] = nullptr;
} }
@@ -882,8 +882,7 @@ bool GfxRenderer::storeBwBuffer() {
bwBufferChunks[i] = static_cast<uint8_t*>(malloc(BW_BUFFER_CHUNK_SIZE)); bwBufferChunks[i] = static_cast<uint8_t*>(malloc(BW_BUFFER_CHUNK_SIZE));
if (!bwBufferChunks[i]) { if (!bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! Failed to allocate BW buffer chunk %zu (%zu bytes)\n", millis(), i, LOG_ERR("GFX", "!! Failed to allocate BW buffer chunk %zu (%zu bytes)", i, BW_BUFFER_CHUNK_SIZE);
BW_BUFFER_CHUNK_SIZE);
// Free previously allocated chunks // Free previously allocated chunks
freeBwBufferChunks(); freeBwBufferChunks();
return false; return false;
@@ -892,8 +891,7 @@ bool GfxRenderer::storeBwBuffer() {
memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE); memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE);
} }
Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS, LOG_DBG("GFX", "Stored BW buffer in %zu chunks (%zu bytes each)", BW_BUFFER_NUM_CHUNKS, BW_BUFFER_CHUNK_SIZE);
BW_BUFFER_CHUNK_SIZE);
return true; return true;
} }
@@ -920,7 +918,7 @@ void GfxRenderer::restoreBwBuffer() {
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
// Check if chunk is missing // Check if chunk is missing
if (!bwBufferChunks[i]) { if (!bwBufferChunks[i]) {
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis()); LOG_ERR("GFX", "!! BW buffer chunks not stored - this is likely a bug");
freeBwBufferChunks(); freeBwBufferChunks();
return; return;
} }
@@ -932,7 +930,7 @@ void GfxRenderer::restoreBwBuffer() {
display.cleanupGrayscaleBuffers(frameBuffer); display.cleanupGrayscaleBuffers(frameBuffer);
freeBwBufferChunks(); freeBwBufferChunks();
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis()); LOG_DBG("GFX", "Restored and freed BW buffer chunks");
} }
/** /**
@@ -954,7 +952,7 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp,
// no glyph? // no glyph?
if (!glyph) { if (!glyph) {
Serial.printf("[%lu] [GFX] No glyph for codepoint %d\n", millis(), cp); LOG_ERR("GFX", "No glyph for codepoint %d", cp);
return; return;
} }

View File

@@ -1,7 +1,7 @@
#include "JpegToBmpConverter.h" #include "JpegToBmpConverter.h"
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SdFat.h> #include <Logging.h>
#include <picojpeg.h> #include <picojpeg.h>
#include <cstdio> #include <cstdio>
@@ -201,8 +201,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 crop) { bool oneBit, bool crop) {
Serial.printf("[%lu] [JPG] Converting JPEG to %s BMP (target: %dx%d)\n", millis(), oneBit ? "1-bit" : "2-bit", LOG_DBG("JPG", "Converting JPEG to %s BMP (target: %dx%d)", oneBit ? "1-bit" : "2-bit", targetWidth, targetHeight);
targetWidth, targetHeight);
// Setup context for picojpeg callback // Setup context for picojpeg callback
JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0};
@@ -211,12 +210,12 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
pjpeg_image_info_t imageInfo; pjpeg_image_info_t imageInfo;
const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0); const unsigned char status = pjpeg_decode_init(&imageInfo, jpegReadCallback, &context, 0);
if (status != 0) { if (status != 0) {
Serial.printf("[%lu] [JPG] JPEG decode init failed with error code: %d\n", millis(), status); LOG_ERR("JPG", "JPEG decode init failed with error code: %d", status);
return false; return false;
} }
Serial.printf("[%lu] [JPG] JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d\n", millis(), imageInfo.m_width, LOG_DBG("JPG", "JPEG dimensions: %dx%d, components: %d, MCUs: %dx%d", imageInfo.m_width, imageInfo.m_height,
imageInfo.m_height, imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol); imageInfo.m_comps, imageInfo.m_MCUSPerRow, imageInfo.m_MCUSPerCol);
// Safety limits to prevent memory issues on ESP32 // Safety limits to prevent memory issues on ESP32
constexpr int MAX_IMAGE_WIDTH = 2048; constexpr int MAX_IMAGE_WIDTH = 2048;
@@ -224,8 +223,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
constexpr int MAX_MCU_ROW_BYTES = 65536; constexpr int MAX_MCU_ROW_BYTES = 65536;
if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) { if (imageInfo.m_width > MAX_IMAGE_WIDTH || imageInfo.m_height > MAX_IMAGE_HEIGHT) {
Serial.printf("[%lu] [JPG] Image too large (%dx%d), max supported: %dx%d\n", millis(), imageInfo.m_width, LOG_DBG("JPG", "Image too large (%dx%d), max supported: %dx%d", imageInfo.m_width, imageInfo.m_height,
imageInfo.m_height, MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT); MAX_IMAGE_WIDTH, MAX_IMAGE_HEIGHT);
return false; return false;
} }
@@ -262,8 +261,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight; scaleY_fp = (static_cast<uint32_t>(imageInfo.m_height) << 16) / outHeight;
needsScaling = true; needsScaling = true;
Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width, LOG_DBG("JPG", "Pre-scaling %dx%d -> %dx%d (fit to %dx%d)", imageInfo.m_width, imageInfo.m_height, outWidth,
imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight); outHeight, targetWidth, targetHeight);
} }
// Write BMP header with output dimensions // Write BMP header with output dimensions
@@ -282,7 +281,7 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Allocate row buffer // Allocate row buffer
auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow)); auto* rowBuffer = static_cast<uint8_t*>(malloc(bytesPerRow));
if (!rowBuffer) { if (!rowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate row buffer\n", millis()); LOG_ERR("JPG", "Failed to allocate row buffer");
return false; return false;
} }
@@ -293,15 +292,14 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
// Validate MCU row buffer size before allocation // Validate MCU row buffer size before allocation
if (mcuRowPixels > MAX_MCU_ROW_BYTES) { if (mcuRowPixels > MAX_MCU_ROW_BYTES) {
Serial.printf("[%lu] [JPG] MCU row buffer too large (%d bytes), max: %d\n", millis(), mcuRowPixels, LOG_DBG("JPG", "MCU row buffer too large (%d bytes), max: %d", mcuRowPixels, MAX_MCU_ROW_BYTES);
MAX_MCU_ROW_BYTES);
free(rowBuffer); free(rowBuffer);
return false; return false;
} }
auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels)); auto* mcuRowBuffer = static_cast<uint8_t*>(malloc(mcuRowPixels));
if (!mcuRowBuffer) { if (!mcuRowBuffer) {
Serial.printf("[%lu] [JPG] Failed to allocate MCU row buffer (%d bytes)\n", millis(), mcuRowPixels); LOG_ERR("JPG", "Failed to allocate MCU row buffer (%d bytes)", mcuRowPixels);
free(rowBuffer); free(rowBuffer);
return false; return false;
} }
@@ -349,10 +347,9 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
const unsigned char mcuStatus = pjpeg_decode_mcu(); const unsigned char mcuStatus = pjpeg_decode_mcu();
if (mcuStatus != 0) { if (mcuStatus != 0) {
if (mcuStatus == PJPG_NO_MORE_BLOCKS) { if (mcuStatus == PJPG_NO_MORE_BLOCKS) {
Serial.printf("[%lu] [JPG] Unexpected end of blocks at MCU (%d, %d)\n", millis(), mcuX, mcuY); LOG_ERR("JPG", "Unexpected end of blocks at MCU (%d, %d)", mcuX, mcuY);
} else { } else {
Serial.printf("[%lu] [JPG] JPEG decode MCU failed at (%d, %d) with error code: %d\n", millis(), mcuX, mcuY, LOG_ERR("JPG", "JPEG decode MCU failed at (%d, %d) with error code: %d", mcuX, mcuY, mcuStatus);
mcuStatus);
} }
free(mcuRowBuffer); free(mcuRowBuffer);
free(rowBuffer); free(rowBuffer);
@@ -549,7 +546,7 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(FsFile& jpegFile, Print& bm
free(mcuRowBuffer); free(mcuRowBuffer);
free(rowBuffer); free(rowBuffer);
Serial.printf("[%lu] [JPG] Successfully converted JPEG to BMP\n", millis()); LOG_DBG("JPG", "Successfully converted JPEG to BMP");
return true; return true;
} }

View File

@@ -1,8 +1,8 @@
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include <HardwareSerial.h> #include <HalStorage.h>
#include <Logging.h>
#include <MD5Builder.h> #include <MD5Builder.h>
#include <SDCardManager.h>
#include <Serialization.h> #include <Serialization.h>
// Initialize the static instance // Initialize the static instance
@@ -32,10 +32,10 @@ void KOReaderCredentialStore::obfuscate(std::string& data) const {
bool KOReaderCredentialStore::saveToFile() const { bool KOReaderCredentialStore::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SdMan.mkdir("/.crosspoint"); Storage.mkdir("/.crosspoint");
FsFile file; FsFile file;
if (!SdMan.openFileForWrite("KRS", KOREADER_FILE, file)) { if (!Storage.openFileForWrite("KRS", KOREADER_FILE, file)) {
return false; return false;
} }
@@ -44,7 +44,7 @@ bool KOReaderCredentialStore::saveToFile() const {
// Write username (plaintext - not particularly sensitive) // Write username (plaintext - not particularly sensitive)
serialization::writeString(file, username); serialization::writeString(file, username);
Serial.printf("[%lu] [KRS] Saving username: %s\n", millis(), username.c_str()); LOG_DBG("KRS", "Saving username: %s", username.c_str());
// Write password (obfuscated) // Write password (obfuscated)
std::string obfuscatedPwd = password; std::string obfuscatedPwd = password;
@@ -58,14 +58,14 @@ bool KOReaderCredentialStore::saveToFile() const {
serialization::writePod(file, static_cast<uint8_t>(matchMethod)); serialization::writePod(file, static_cast<uint8_t>(matchMethod));
file.close(); file.close();
Serial.printf("[%lu] [KRS] Saved KOReader credentials to file\n", millis()); LOG_DBG("KRS", "Saved KOReader credentials to file");
return true; return true;
} }
bool KOReaderCredentialStore::loadFromFile() { bool KOReaderCredentialStore::loadFromFile() {
FsFile file; FsFile file;
if (!SdMan.openFileForRead("KRS", KOREADER_FILE, file)) { if (!Storage.openFileForRead("KRS", KOREADER_FILE, file)) {
Serial.printf("[%lu] [KRS] No credentials file found\n", millis()); LOG_DBG("KRS", "No credentials file found");
return false; return false;
} }
@@ -73,7 +73,7 @@ bool KOReaderCredentialStore::loadFromFile() {
uint8_t version; uint8_t version;
serialization::readPod(file, version); serialization::readPod(file, version);
if (version != KOREADER_FILE_VERSION) { if (version != KOREADER_FILE_VERSION) {
Serial.printf("[%lu] [KRS] Unknown file version: %u\n", millis(), version); LOG_DBG("KRS", "Unknown file version: %u", version);
file.close(); file.close();
return false; return false;
} }
@@ -110,14 +110,14 @@ bool KOReaderCredentialStore::loadFromFile() {
} }
file.close(); file.close();
Serial.printf("[%lu] [KRS] Loaded KOReader credentials for user: %s\n", millis(), username.c_str()); LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", username.c_str());
return true; return true;
} }
void KOReaderCredentialStore::setCredentials(const std::string& user, const std::string& pass) { void KOReaderCredentialStore::setCredentials(const std::string& user, const std::string& pass) {
username = user; username = user;
password = pass; password = pass;
Serial.printf("[%lu] [KRS] Set credentials for user: %s\n", millis(), user.c_str()); LOG_DBG("KRS", "Set credentials for user: %s", user.c_str());
} }
std::string KOReaderCredentialStore::getMd5Password() const { std::string KOReaderCredentialStore::getMd5Password() const {
@@ -140,12 +140,12 @@ void KOReaderCredentialStore::clearCredentials() {
username.clear(); username.clear();
password.clear(); password.clear();
saveToFile(); saveToFile();
Serial.printf("[%lu] [KRS] Cleared KOReader credentials\n", millis()); LOG_DBG("KRS", "Cleared KOReader credentials");
} }
void KOReaderCredentialStore::setServerUrl(const std::string& url) { void KOReaderCredentialStore::setServerUrl(const std::string& url) {
serverUrl = url; serverUrl = url;
Serial.printf("[%lu] [KRS] Set server URL: %s\n", millis(), url.empty() ? "(default)" : url.c_str()); LOG_DBG("KRS", "Set server URL: %s", url.empty() ? "(default)" : url.c_str());
} }
std::string KOReaderCredentialStore::getBaseUrl() const { std::string KOReaderCredentialStore::getBaseUrl() const {
@@ -163,6 +163,5 @@ std::string KOReaderCredentialStore::getBaseUrl() const {
void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) { void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) {
matchMethod = method; matchMethod = method;
Serial.printf("[%lu] [KRS] Set match method: %s\n", millis(), LOG_DBG("KRS", "Set match method: %s", method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary");
method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary");
} }

View File

@@ -1,8 +1,8 @@
#include "KOReaderDocumentId.h" #include "KOReaderDocumentId.h"
#include <HardwareSerial.h> #include <HalStorage.h>
#include <Logging.h>
#include <MD5Builder.h> #include <MD5Builder.h>
#include <SDCardManager.h>
namespace { namespace {
// Extract filename from path (everything after last '/') // Extract filename from path (everything after last '/')
@@ -27,7 +27,7 @@ std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePat
md5.calculate(); md5.calculate();
std::string result = md5.toString().c_str(); std::string result = md5.toString().c_str();
Serial.printf("[%lu] [KODoc] Filename hash: %s (from '%s')\n", millis(), result.c_str(), filename.c_str()); LOG_DBG("KODoc", "Filename hash: %s (from '%s')", result.c_str(), filename.c_str());
return result; return result;
} }
@@ -43,13 +43,13 @@ size_t KOReaderDocumentId::getOffset(int i) {
std::string KOReaderDocumentId::calculate(const std::string& filePath) { std::string KOReaderDocumentId::calculate(const std::string& filePath) {
FsFile file; FsFile file;
if (!SdMan.openFileForRead("KODoc", filePath, file)) { if (!Storage.openFileForRead("KODoc", filePath, file)) {
Serial.printf("[%lu] [KODoc] Failed to open file: %s\n", millis(), filePath.c_str()); LOG_DBG("KODoc", "Failed to open file: %s", filePath.c_str());
return ""; return "";
} }
const size_t fileSize = file.fileSize(); const size_t fileSize = file.fileSize();
Serial.printf("[%lu] [KODoc] Calculating hash for file: %s (size: %zu)\n", millis(), filePath.c_str(), fileSize); LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize);
// Initialize MD5 builder // Initialize MD5 builder
MD5Builder md5; MD5Builder md5;
@@ -70,7 +70,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
// Seek to offset // Seek to offset
if (!file.seekSet(offset)) { if (!file.seekSet(offset)) {
Serial.printf("[%lu] [KODoc] Failed to seek to offset %zu\n", millis(), offset); LOG_DBG("KODoc", "Failed to seek to offset %zu", offset);
continue; continue;
} }
@@ -90,7 +90,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) {
md5.calculate(); md5.calculate();
std::string result = md5.toString().c_str(); std::string result = md5.toString().c_str();
Serial.printf("[%lu] [KODoc] Hash calculated: %s (from %zu bytes)\n", millis(), result.c_str(), totalBytesRead); LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead);
return result; return result;
} }

View File

@@ -2,7 +2,7 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <WiFiClientSecure.h> #include <WiFiClientSecure.h>
@@ -30,12 +30,12 @@ bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0;
KOReaderSyncClient::Error KOReaderSyncClient::authenticate() { KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS; return NO_CREDENTIALS;
} }
std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth"; std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
Serial.printf("[%lu] [KOSync] Authenticating: %s\n", millis(), url.c_str()); LOG_DBG("KOSync", "Authenticating: %s", url.c_str());
HTTPClient http; HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient; std::unique_ptr<WiFiClientSecure> secureClient;
@@ -53,7 +53,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
const int httpCode = http.GET(); const int httpCode = http.GET();
http.end(); http.end();
Serial.printf("[%lu] [KOSync] Auth response: %d\n", millis(), httpCode); LOG_DBG("KOSync", "Auth response: %d", httpCode);
if (httpCode == 200) { if (httpCode == 200) {
return OK; return OK;
@@ -68,12 +68,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash, KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
KOReaderProgress& outProgress) { KOReaderProgress& outProgress) {
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS; return NO_CREDENTIALS;
} }
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash; std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
Serial.printf("[%lu] [KOSync] Getting progress: %s\n", millis(), url.c_str()); LOG_DBG("KOSync", "Getting progress: %s", url.c_str());
HTTPClient http; HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient; std::unique_ptr<WiFiClientSecure> secureClient;
@@ -99,7 +99,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
const DeserializationError error = deserializeJson(doc, responseBody); const DeserializationError error = deserializeJson(doc, responseBody);
if (error) { if (error) {
Serial.printf("[%lu] [KOSync] JSON parse failed: %s\n", millis(), error.c_str()); LOG_ERR("KOSync", "JSON parse failed: %s", error.c_str());
return JSON_ERROR; return JSON_ERROR;
} }
@@ -110,14 +110,13 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
outProgress.deviceId = doc["device_id"].as<std::string>(); outProgress.deviceId = doc["device_id"].as<std::string>();
outProgress.timestamp = doc["timestamp"].as<int64_t>(); outProgress.timestamp = doc["timestamp"].as<int64_t>();
Serial.printf("[%lu] [KOSync] Got progress: %.2f%% at %s\n", millis(), outProgress.percentage * 100, LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
outProgress.progress.c_str());
return OK; return OK;
} }
http.end(); http.end();
Serial.printf("[%lu] [KOSync] Get progress response: %d\n", millis(), httpCode); LOG_DBG("KOSync", "Get progress response: %d", httpCode);
if (httpCode == 401) { if (httpCode == 401) {
return AUTH_FAILED; return AUTH_FAILED;
@@ -131,12 +130,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) { KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) {
if (!KOREADER_STORE.hasCredentials()) { if (!KOREADER_STORE.hasCredentials()) {
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); LOG_DBG("KOSync", "No credentials configured");
return NO_CREDENTIALS; return NO_CREDENTIALS;
} }
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress"; std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
Serial.printf("[%lu] [KOSync] Updating progress: %s\n", millis(), url.c_str()); LOG_DBG("KOSync", "Updating progress: %s", url.c_str());
HTTPClient http; HTTPClient http;
std::unique_ptr<WiFiClientSecure> secureClient; std::unique_ptr<WiFiClientSecure> secureClient;
@@ -163,12 +162,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
std::string body; std::string body;
serializeJson(doc, body); serializeJson(doc, body);
Serial.printf("[%lu] [KOSync] Request body: %s\n", millis(), body.c_str()); LOG_DBG("KOSync", "Request body: %s", body.c_str());
const int httpCode = http.PUT(body.c_str()); const int httpCode = http.PUT(body.c_str());
http.end(); http.end();
Serial.printf("[%lu] [KOSync] Update progress response: %d\n", millis(), httpCode); LOG_DBG("KOSync", "Update progress response: %d", httpCode);
if (httpCode == 200 || httpCode == 202) { if (httpCode == 200 || httpCode == 202) {
return OK; return OK;

View File

@@ -1,6 +1,6 @@
#include "ProgressMapper.h" #include "ProgressMapper.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <cmath> #include <cmath>
@@ -23,8 +23,8 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex); const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown"; const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown";
Serial.printf("[%lu] [ProgressMapper] CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s\n", millis(), LOG_DBG("ProgressMapper", "CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s", chapterName.c_str(),
chapterName.c_str(), pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str()); pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str());
return result; return result;
} }
@@ -76,8 +76,8 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
} }
} }
Serial.printf("[%lu] [ProgressMapper] KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d\n", millis(), LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100,
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber); koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
return result; return result;
} }

47
lib/Logging/Logging.cpp Normal file
View File

@@ -0,0 +1,47 @@
#include "Logging.h"
// Since logging can take a large amount of flash, we want to make the format string as short as possible.
// This logPrintf prepend the timestamp, level and origin to the user-provided message, so that the user only needs to
// provide the format string for the message itself.
void logPrintf(const char* level, const char* origin, const char* format, ...) {
if (!logSerial) {
return; // Serial not initialized, skip logging
}
va_list args;
va_start(args, format);
char buf[256];
char* c = buf;
// add the timestamp
{
unsigned long ms = millis();
int len = snprintf(c, sizeof(buf), "[%lu] ", ms);
if (len < 0) {
return; // encoding error, skip logging
}
c += len;
}
// add the level
{
const char* p = level;
size_t remaining = sizeof(buf) - (c - buf);
while (*p && remaining > 1) {
*c++ = *p++;
remaining--;
}
if (remaining > 1) {
*c++ = ' ';
}
}
// add the origin
{
int len = snprintf(c, sizeof(buf) - (c - buf), "[%s] ", origin);
if (len < 0) {
return; // encoding error, skip logging
}
c += len;
}
// add the user message
vsnprintf(c, sizeof(buf) - (c - buf), format, args);
va_end(args);
logSerial.print(buf);
}

71
lib/Logging/Logging.h Normal file
View File

@@ -0,0 +1,71 @@
#pragma once
#include <HardwareSerial.h>
/*
Define ENABLE_SERIAL_LOG to enable logging
Can be set in platformio.ini build_flags or as a compile definition
Define LOG_LEVEL to control log verbosity:
0 = ERR only
1 = ERR + INF
2 = ERR + INF + DBG
If not defined, defaults to 0
If you have a legitimate need for raw Serial access (e.g., binary data,
special formatting), use the underlying logSerial object directly:
logSerial.printf("Special case: %d\n", value);
logSerial.write(binaryData, length);
The logSerial reference (defined below) points to the real Serial object and
won't trigger deprecation warnings.
*/
#ifndef LOG_LEVEL
#define LOG_LEVEL 0
#endif
static HWCDC& logSerial = Serial;
void logPrintf(const char* level, const char* origin, const char* format, ...);
#ifdef ENABLE_SERIAL_LOG
#if LOG_LEVEL >= 0
#define LOG_ERR(origin, format, ...) logPrintf("[ERR]", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_ERR(origin, format, ...)
#endif
#if LOG_LEVEL >= 1
#define LOG_INF(origin, format, ...) logPrintf("[INF]", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_INF(origin, format, ...)
#endif
#if LOG_LEVEL >= 2
#define LOG_DBG(origin, format, ...) logPrintf("[DBG]", origin, format "\n", ##__VA_ARGS__)
#else
#define LOG_DBG(origin, format, ...)
#endif
#else
#define LOG_DBG(origin, format, ...)
#define LOG_ERR(origin, format, ...)
#define LOG_INF(origin, format, ...)
#endif
class MySerialImpl : public Print {
public:
void begin(unsigned long baud) { logSerial.begin(baud); }
// Support boolean conversion for compatibility with code like:
// if (Serial) or while (!Serial)
operator bool() const { return logSerial; }
__attribute__((deprecated("Use LOG_* macro instead"))) size_t printf(const char* format, ...);
size_t write(uint8_t b) override;
size_t write(const uint8_t* buffer, size_t size) override;
void flush() override;
static MySerialImpl instance;
};
#define Serial MySerialImpl::instance

View File

@@ -1,6 +1,6 @@
#include "OpdsParser.h" #include "OpdsParser.h"
#include <HardwareSerial.h> #include <Logging.h>
#include <cstring> #include <cstring>
@@ -8,7 +8,7 @@ OpdsParser::OpdsParser() {
parser = XML_ParserCreate(nullptr); parser = XML_ParserCreate(nullptr);
if (!parser) { if (!parser) {
errorOccured = true; errorOccured = true;
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis()); LOG_DBG("OPDS", "Couldn't allocate memory for parser");
} }
} }
@@ -42,7 +42,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
void* const buf = XML_GetBuffer(parser, chunkSize); void* const buf = XML_GetBuffer(parser, chunkSize);
if (!buf) { if (!buf) {
errorOccured = true; errorOccured = true;
Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis()); LOG_DBG("OPDS", "Couldn't allocate memory for buffer");
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;
return length; return length;
@@ -53,7 +53,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) {
if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(toRead), 0) == XML_STATUS_ERROR) {
errorOccured = true; errorOccured = true;
Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser),
XML_ErrorString(XML_GetErrorCode(parser))); XML_ErrorString(XML_GetErrorCode(parser)));
XML_ParserFree(parser); XML_ParserFree(parser);
parser = nullptr; parser = nullptr;

View File

@@ -1,5 +1,5 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <iostream> #include <iostream>

View File

@@ -2,6 +2,7 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <JpegToBmpConverter.h> #include <JpegToBmpConverter.h>
#include <Logging.h>
Txt::Txt(std::string path, std::string cacheBasePath) Txt::Txt(std::string path, std::string cacheBasePath)
: filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) { : filepath(std::move(path)), cacheBasePath(std::move(cacheBasePath)) {
@@ -15,14 +16,14 @@ bool Txt::load() {
return true; return true;
} }
if (!SdMan.exists(filepath.c_str())) { if (!Storage.exists(filepath.c_str())) {
Serial.printf("[%lu] [TXT] File does not exist: %s\n", millis(), filepath.c_str()); LOG_ERR("TXT", "File does not exist: %s", filepath.c_str());
return false; return false;
} }
FsFile file; FsFile file;
if (!SdMan.openFileForRead("TXT", filepath, file)) { if (!Storage.openFileForRead("TXT", filepath, file)) {
Serial.printf("[%lu] [TXT] Failed to open file: %s\n", millis(), filepath.c_str()); LOG_ERR("TXT", "Failed to open file: %s", filepath.c_str());
return false; return false;
} }
@@ -30,7 +31,7 @@ bool Txt::load() {
file.close(); file.close();
loaded = true; loaded = true;
Serial.printf("[%lu] [TXT] Loaded TXT file: %s (%zu bytes)\n", millis(), filepath.c_str(), fileSize); LOG_DBG("TXT", "Loaded TXT file: %s (%zu bytes)", filepath.c_str(), fileSize);
return true; return true;
} }
@@ -48,11 +49,11 @@ std::string Txt::getTitle() const {
} }
void Txt::setupCacheDir() const { void Txt::setupCacheDir() const {
if (!SdMan.exists(cacheBasePath.c_str())) { if (!Storage.exists(cacheBasePath.c_str())) {
SdMan.mkdir(cacheBasePath.c_str()); Storage.mkdir(cacheBasePath.c_str());
} }
if (!SdMan.exists(cachePath.c_str())) { if (!Storage.exists(cachePath.c_str())) {
SdMan.mkdir(cachePath.c_str()); Storage.mkdir(cachePath.c_str());
} }
} }
@@ -73,8 +74,8 @@ std::string Txt::findCoverImage() const {
// First priority: look for image with same name as txt file (e.g., mybook.jpg) // First priority: look for image with same name as txt file (e.g., mybook.jpg)
for (const auto& ext : extensions) { for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + baseName + ext; std::string coverPath = folder + "/" + baseName + ext;
if (SdMan.exists(coverPath.c_str())) { if (Storage.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found matching cover image: %s\n", millis(), coverPath.c_str()); LOG_DBG("TXT", "Found matching cover image: %s", coverPath.c_str());
return coverPath; return coverPath;
} }
} }
@@ -84,8 +85,8 @@ std::string Txt::findCoverImage() const {
for (const auto& name : coverNames) { for (const auto& name : coverNames) {
for (const auto& ext : extensions) { for (const auto& ext : extensions) {
std::string coverPath = folder + "/" + std::string(name) + ext; std::string coverPath = folder + "/" + std::string(name) + ext;
if (SdMan.exists(coverPath.c_str())) { if (Storage.exists(coverPath.c_str())) {
Serial.printf("[%lu] [TXT] Found fallback cover image: %s\n", millis(), coverPath.c_str()); LOG_DBG("TXT", "Found fallback cover image: %s", coverPath.c_str());
return coverPath; return coverPath;
} }
} }
@@ -98,13 +99,13 @@ std::string Txt::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Txt::generateCoverBmp() const { bool Txt::generateCoverBmp() const {
// Already generated, return true // Already generated, return true
if (SdMan.exists(getCoverBmpPath().c_str())) { if (Storage.exists(getCoverBmpPath().c_str())) {
return true; return true;
} }
std::string coverImagePath = findCoverImage(); std::string coverImagePath = findCoverImage();
if (coverImagePath.empty()) { if (coverImagePath.empty()) {
Serial.printf("[%lu] [TXT] No cover image found for TXT file\n", millis()); LOG_DBG("TXT", "No cover image found for TXT file");
return false; return false;
} }
@@ -120,12 +121,12 @@ bool Txt::generateCoverBmp() const {
if (isBmp) { if (isBmp) {
// Copy BMP file to cache // Copy BMP file to cache
Serial.printf("[%lu] [TXT] Copying BMP cover image to cache\n", millis()); LOG_DBG("TXT", "Copying BMP cover image to cache");
FsFile src, dst; FsFile src, dst;
if (!SdMan.openFileForRead("TXT", coverImagePath, src)) { if (!Storage.openFileForRead("TXT", coverImagePath, src)) {
return false; return false;
} }
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), dst)) { if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), dst)) {
src.close(); src.close();
return false; return false;
} }
@@ -136,18 +137,18 @@ bool Txt::generateCoverBmp() const {
} }
src.close(); src.close();
dst.close(); dst.close();
Serial.printf("[%lu] [TXT] Copied BMP cover to cache\n", millis()); LOG_DBG("TXT", "Copied BMP cover to cache");
return true; return true;
} }
if (isJpg) { if (isJpg) {
// Convert JPG/JPEG to BMP (same approach as Epub) // Convert JPG/JPEG to BMP (same approach as Epub)
Serial.printf("[%lu] [TXT] Generating BMP from JPG cover image\n", millis()); LOG_DBG("TXT", "Generating BMP from JPG cover image");
FsFile coverJpg, coverBmp; FsFile coverJpg, coverBmp;
if (!SdMan.openFileForRead("TXT", coverImagePath, coverJpg)) { if (!Storage.openFileForRead("TXT", coverImagePath, coverJpg)) {
return false; return false;
} }
if (!SdMan.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) { if (!Storage.openFileForWrite("TXT", getCoverBmpPath(), coverBmp)) {
coverJpg.close(); coverJpg.close();
return false; return false;
} }
@@ -156,16 +157,16 @@ bool Txt::generateCoverBmp() const {
coverBmp.close(); coverBmp.close();
if (!success) { if (!success) {
Serial.printf("[%lu] [TXT] Failed to generate BMP from JPG cover image\n", millis()); LOG_ERR("TXT", "Failed to generate BMP from JPG cover image");
SdMan.remove(getCoverBmpPath().c_str()); Storage.remove(getCoverBmpPath().c_str());
} else { } else {
Serial.printf("[%lu] [TXT] Generated BMP from JPG cover image\n", millis()); LOG_DBG("TXT", "Generated BMP from JPG cover image");
} }
return success; return success;
} }
// PNG files are not supported (would need a PNG decoder) // PNG files are not supported (would need a PNG decoder)
Serial.printf("[%lu] [TXT] Cover image format not supported (only BMP/JPG/JPEG)\n", millis()); LOG_ERR("TXT", "Cover image format not supported (only BMP/JPG/JPEG)");
return false; return false;
} }
@@ -175,7 +176,7 @@ bool Txt::readContent(uint8_t* buffer, size_t offset, size_t length) const {
} }
FsFile file; FsFile file;
if (!SdMan.openFileForRead("TXT", filepath, file)) { if (!Storage.openFileForRead("TXT", filepath, file)) {
return false; return false;
} }

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <SDCardManager.h> #include <HalStorage.h>
#include <memory> #include <memory>
#include <string> #include <string>

View File

@@ -7,11 +7,11 @@
#include "Xtc.h" #include "Xtc.h"
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
bool Xtc::load() { bool Xtc::load() {
Serial.printf("[%lu] [XTC] Loading XTC: %s\n", millis(), filepath.c_str()); LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str());
// Initialize parser // Initialize parser
parser.reset(new xtc::XtcParser()); parser.reset(new xtc::XtcParser());
@@ -19,43 +19,43 @@ bool Xtc::load() {
// Open XTC file // Open XTC file
xtc::XtcError err = parser->open(filepath.c_str()); xtc::XtcError err = parser->open(filepath.c_str());
if (err != xtc::XtcError::OK) { if (err != xtc::XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to load: %s\n", millis(), xtc::errorToString(err)); LOG_ERR("XTC", "Failed to load: %s", xtc::errorToString(err));
parser.reset(); parser.reset();
return false; return false;
} }
loaded = true; loaded = true;
Serial.printf("[%lu] [XTC] Loaded XTC: %s (%lu pages)\n", millis(), filepath.c_str(), parser->getPageCount()); LOG_DBG("XTC", "Loaded XTC: %s (%lu pages)", filepath.c_str(), parser->getPageCount());
return true; return true;
} }
bool Xtc::clearCache() const { bool Xtc::clearCache() const {
if (!SdMan.exists(cachePath.c_str())) { if (!Storage.exists(cachePath.c_str())) {
Serial.printf("[%lu] [XTC] Cache does not exist, no action needed\n", millis()); LOG_DBG("XTC", "Cache does not exist, no action needed");
return true; return true;
} }
if (!SdMan.removeDir(cachePath.c_str())) { if (!Storage.removeDir(cachePath.c_str())) {
Serial.printf("[%lu] [XTC] Failed to clear cache\n", millis()); LOG_ERR("XTC", "Failed to clear cache");
return false; return false;
} }
Serial.printf("[%lu] [XTC] Cache cleared successfully\n", millis()); LOG_DBG("XTC", "Cache cleared successfully");
return true; return true;
} }
void Xtc::setupCacheDir() const { void Xtc::setupCacheDir() const {
if (SdMan.exists(cachePath.c_str())) { if (Storage.exists(cachePath.c_str())) {
return; return;
} }
// Create directories recursively // Create directories recursively
for (size_t i = 1; i < cachePath.length(); i++) { for (size_t i = 1; i < cachePath.length(); i++) {
if (cachePath[i] == '/') { if (cachePath[i] == '/') {
SdMan.mkdir(cachePath.substr(0, i).c_str()); Storage.mkdir(cachePath.substr(0, i).c_str());
} }
} }
SdMan.mkdir(cachePath.c_str()); Storage.mkdir(cachePath.c_str());
} }
std::string Xtc::getTitle() const { std::string Xtc::getTitle() const {
@@ -114,17 +114,17 @@ std::string Xtc::getCoverBmpPath() const { return cachePath + "/cover.bmp"; }
bool Xtc::generateCoverBmp() const { bool Xtc::generateCoverBmp() const {
// Already generated // Already generated
if (SdMan.exists(getCoverBmpPath().c_str())) { if (Storage.exists(getCoverBmpPath().c_str())) {
return true; return true;
} }
if (!loaded || !parser) { if (!loaded || !parser) {
Serial.printf("[%lu] [XTC] Cannot generate cover BMP, file not loaded\n", millis()); LOG_ERR("XTC", "Cannot generate cover BMP, file not loaded");
return false; return false;
} }
if (parser->getPageCount() == 0) { if (parser->getPageCount() == 0) {
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); LOG_ERR("XTC", "No pages in XTC file");
return false; return false;
} }
@@ -134,7 +134,7 @@ bool Xtc::generateCoverBmp() const {
// Get first page info for cover // Get first page info for cover
xtc::PageInfo pageInfo; xtc::PageInfo pageInfo;
if (!parser->getPageInfo(0, pageInfo)) { if (!parser->getPageInfo(0, pageInfo)) {
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); LOG_DBG("XTC", "Failed to get first page info");
return false; return false;
} }
@@ -152,22 +152,22 @@ bool Xtc::generateCoverBmp() const {
} }
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize)); uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
if (!pageBuffer) { if (!pageBuffer) {
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize);
return false; return false;
} }
// Load first page (cover) // Load first page (cover)
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize); size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
if (bytesRead == 0) { if (bytesRead == 0) {
Serial.printf("[%lu] [XTC] Failed to load cover page\n", millis()); LOG_ERR("XTC", "Failed to load cover page");
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
// Create BMP file // Create BMP file
FsFile coverBmp; FsFile coverBmp;
if (!SdMan.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) { if (!Storage.openFileForWrite("XTC", getCoverBmpPath(), coverBmp)) {
Serial.printf("[%lu] [XTC] Failed to create cover BMP file\n", millis()); LOG_DBG("XTC", "Failed to create cover BMP file");
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
@@ -297,7 +297,7 @@ bool Xtc::generateCoverBmp() const {
coverBmp.close(); coverBmp.close();
free(pageBuffer); free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated cover BMP: %s\n", millis(), getCoverBmpPath().c_str()); LOG_DBG("XTC", "Generated cover BMP: %s", getCoverBmpPath().c_str());
return true; return true;
} }
@@ -306,17 +306,17 @@ std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_
bool Xtc::generateThumbBmp(int height) const { bool Xtc::generateThumbBmp(int height) const {
// Already generated // Already generated
if (SdMan.exists(getThumbBmpPath(height).c_str())) { if (Storage.exists(getThumbBmpPath(height).c_str())) {
return true; return true;
} }
if (!loaded || !parser) { if (!loaded || !parser) {
Serial.printf("[%lu] [XTC] Cannot generate thumb BMP, file not loaded\n", millis()); LOG_ERR("XTC", "Cannot generate thumb BMP, file not loaded");
return false; return false;
} }
if (parser->getPageCount() == 0) { if (parser->getPageCount() == 0) {
Serial.printf("[%lu] [XTC] No pages in XTC file\n", millis()); LOG_ERR("XTC", "No pages in XTC file");
return false; return false;
} }
@@ -326,7 +326,7 @@ bool Xtc::generateThumbBmp(int height) const {
// Get first page info for cover // Get first page info for cover
xtc::PageInfo pageInfo; xtc::PageInfo pageInfo;
if (!parser->getPageInfo(0, pageInfo)) { if (!parser->getPageInfo(0, pageInfo)) {
Serial.printf("[%lu] [XTC] Failed to get first page info\n", millis()); LOG_DBG("XTC", "Failed to get first page info");
return false; return false;
} }
@@ -348,8 +348,8 @@ bool Xtc::generateThumbBmp(int height) const {
// Copy cover.bmp to thumb.bmp // Copy cover.bmp to thumb.bmp
if (generateCoverBmp()) { if (generateCoverBmp()) {
FsFile src, dst; FsFile src, dst;
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) { if (Storage.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(height), dst)) { if (Storage.openFileForWrite("XTC", getThumbBmpPath(height), dst)) {
uint8_t buffer[512]; uint8_t buffer[512];
while (src.available()) { while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer)); size_t bytesRead = src.read(buffer, sizeof(buffer));
@@ -359,8 +359,8 @@ bool Xtc::generateThumbBmp(int height) const {
} }
src.close(); src.close();
} }
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis()); LOG_DBG("XTC", "Copied cover to thumb (no scaling needed)");
return SdMan.exists(getThumbBmpPath(height).c_str()); return Storage.exists(getThumbBmpPath(height).c_str());
} }
return false; return false;
} }
@@ -368,8 +368,8 @@ bool Xtc::generateThumbBmp(int height) const {
uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale); uint16_t thumbWidth = static_cast<uint16_t>(pageInfo.width * scale);
uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale); uint16_t thumbHeight = static_cast<uint16_t>(pageInfo.height * scale);
Serial.printf("[%lu] [XTC] Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)\n", millis(), pageInfo.width, LOG_DBG("XTC", "Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)", pageInfo.width, pageInfo.height, thumbWidth,
pageInfo.height, thumbWidth, thumbHeight, scale); thumbHeight, scale);
// Allocate buffer for page data // Allocate buffer for page data
size_t bitmapSize; size_t bitmapSize;
@@ -380,22 +380,22 @@ bool Xtc::generateThumbBmp(int height) const {
} }
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize)); uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(bitmapSize));
if (!pageBuffer) { if (!pageBuffer) {
Serial.printf("[%lu] [XTC] Failed to allocate page buffer (%lu bytes)\n", millis(), bitmapSize); LOG_ERR("XTC", "Failed to allocate page buffer (%lu bytes)", bitmapSize);
return false; return false;
} }
// Load first page (cover) // Load first page (cover)
size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize); size_t bytesRead = const_cast<xtc::XtcParser*>(parser.get())->loadPage(0, pageBuffer, bitmapSize);
if (bytesRead == 0) { if (bytesRead == 0) {
Serial.printf("[%lu] [XTC] Failed to load cover page for thumb\n", millis()); LOG_ERR("XTC", "Failed to load cover page for thumb");
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp; FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); LOG_DBG("XTC", "Failed to create thumb BMP file");
free(pageBuffer); free(pageBuffer);
return false; return false;
} }
@@ -558,8 +558,7 @@ bool Xtc::generateThumbBmp(int height) const {
thumbBmp.close(); thumbBmp.close();
free(pageBuffer); free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, LOG_DBG("XTC", "Generated thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str());
getThumbBmpPath(height).c_str());
return true; return true;
} }

View File

@@ -8,8 +8,8 @@
#include "XtcParser.h" #include "XtcParser.h"
#include <FsHelpers.h> #include <FsHelpers.h>
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
#include <cstring> #include <cstring>
@@ -34,7 +34,7 @@ XtcError XtcParser::open(const char* filepath) {
} }
// Open file // Open file
if (!SdMan.openFileForRead("XTC", filepath, m_file)) { if (!Storage.openFileForRead("XTC", filepath, m_file)) {
m_lastError = XtcError::FILE_NOT_FOUND; m_lastError = XtcError::FILE_NOT_FOUND;
return m_lastError; return m_lastError;
} }
@@ -42,7 +42,7 @@ XtcError XtcParser::open(const char* filepath) {
// Read header // Read header
m_lastError = readHeader(); m_lastError = readHeader();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read header: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read header: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
@@ -51,13 +51,13 @@ XtcError XtcParser::open(const char* filepath) {
if (m_header.hasMetadata) { if (m_header.hasMetadata) {
m_lastError = readTitle(); m_lastError = readTitle();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read title: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read title: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
m_lastError = readAuthor(); m_lastError = readAuthor();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read author: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read author: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
@@ -66,7 +66,7 @@ XtcError XtcParser::open(const char* filepath) {
// Read page table // Read page table
m_lastError = readPageTable(); m_lastError = readPageTable();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read page table: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read page table: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
@@ -74,14 +74,13 @@ XtcError XtcParser::open(const char* filepath) {
// Read chapters if present // Read chapters if present
m_lastError = readChapters(); m_lastError = readChapters();
if (m_lastError != XtcError::OK) { if (m_lastError != XtcError::OK) {
Serial.printf("[%lu] [XTC] Failed to read chapters: %s\n", millis(), errorToString(m_lastError)); LOG_DBG("XTC", "Failed to read chapters: %s", errorToString(m_lastError));
m_file.close(); m_file.close();
return m_lastError; return m_lastError;
} }
m_isOpen = true; m_isOpen = true;
Serial.printf("[%lu] [XTC] Opened file: %s (%u pages, %dx%d)\n", millis(), filepath, m_header.pageCount, LOG_DBG("XTC", "Opened file: %s (%u pages, %dx%d)", filepath, m_header.pageCount, m_defaultWidth, m_defaultHeight);
m_defaultWidth, m_defaultHeight);
return XtcError::OK; return XtcError::OK;
} }
@@ -106,8 +105,7 @@ XtcError XtcParser::readHeader() {
// Verify magic number (accept both XTC and XTCH) // Verify magic number (accept both XTC and XTCH)
if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) { if (m_header.magic != XTC_MAGIC && m_header.magic != XTCH_MAGIC) {
Serial.printf("[%lu] [XTC] Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)\n", millis(), m_header.magic, LOG_DBG("XTC", "Invalid magic: 0x%08X (expected 0x%08X or 0x%08X)", m_header.magic, XTC_MAGIC, XTCH_MAGIC);
XTC_MAGIC, XTCH_MAGIC);
return XtcError::INVALID_MAGIC; return XtcError::INVALID_MAGIC;
} }
@@ -120,7 +118,7 @@ XtcError XtcParser::readHeader() {
const bool validVersion = m_header.versionMajor == 1 && m_header.versionMinor == 0 || const bool validVersion = m_header.versionMajor == 1 && m_header.versionMinor == 0 ||
m_header.versionMajor == 0 && m_header.versionMinor == 1; m_header.versionMajor == 0 && m_header.versionMinor == 1;
if (!validVersion) { if (!validVersion) {
Serial.printf("[%lu] [XTC] Unsupported version: %u.%u\n", millis(), m_header.versionMajor, m_header.versionMinor); LOG_DBG("XTC", "Unsupported version: %u.%u", m_header.versionMajor, m_header.versionMinor);
return XtcError::INVALID_VERSION; return XtcError::INVALID_VERSION;
} }
@@ -129,7 +127,7 @@ XtcError XtcParser::readHeader() {
return XtcError::CORRUPTED_HEADER; return XtcError::CORRUPTED_HEADER;
} }
Serial.printf("[%lu] [XTC] Header: magic=0x%08X (%s), ver=%u.%u, pages=%u, bitDepth=%u\n", millis(), m_header.magic, LOG_DBG("XTC", "Header: magic=0x%08X (%s), ver=%u.%u, pages=%u, bitDepth=%u", m_header.magic,
(m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.versionMajor, m_header.versionMinor, (m_header.magic == XTCH_MAGIC) ? "XTCH" : "XTC", m_header.versionMajor, m_header.versionMinor,
m_header.pageCount, m_bitDepth); m_header.pageCount, m_bitDepth);
@@ -146,7 +144,7 @@ XtcError XtcParser::readTitle() {
m_file.read(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()); LOG_DBG("XTC", "Title: %s", m_title.c_str());
return XtcError::OK; return XtcError::OK;
} }
@@ -161,19 +159,19 @@ XtcError XtcParser::readAuthor() {
m_file.read(authorBuf, sizeof(authorBuf) - 1); m_file.read(authorBuf, sizeof(authorBuf) - 1);
m_author = authorBuf; m_author = authorBuf;
Serial.printf("[%lu] [XTC] Author: %s\n", millis(), m_author.c_str()); LOG_DBG("XTC", "Author: %s", m_author.c_str());
return XtcError::OK; 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()); LOG_DBG("XTC", "Page table offset is 0, cannot read");
return XtcError::CORRUPTED_HEADER; return XtcError::CORRUPTED_HEADER;
} }
// Seek to page table // Seek to page table
if (!m_file.seek(m_header.pageTableOffset)) { if (!m_file.seek(m_header.pageTableOffset)) {
Serial.printf("[%lu] [XTC] Failed to seek to page table at %llu\n", millis(), m_header.pageTableOffset); LOG_DBG("XTC", "Failed to seek to page table at %llu", m_header.pageTableOffset);
return XtcError::READ_ERROR; return XtcError::READ_ERROR;
} }
@@ -184,7 +182,7 @@ XtcError XtcParser::readPageTable() {
PageTableEntry entry; PageTableEntry entry;
size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry)); size_t bytesRead = m_file.read(reinterpret_cast<uint8_t*>(&entry), sizeof(PageTableEntry));
if (bytesRead != sizeof(PageTableEntry)) { if (bytesRead != sizeof(PageTableEntry)) {
Serial.printf("[%lu] [XTC] Failed to read page table entry %u\n", millis(), i); LOG_DBG("XTC", "Failed to read page table entry %u", i);
return XtcError::READ_ERROR; return XtcError::READ_ERROR;
} }
@@ -201,7 +199,7 @@ XtcError XtcParser::readPageTable() {
} }
} }
Serial.printf("[%lu] [XTC] Read %u page table entries\n", millis(), m_header.pageCount); LOG_DBG("XTC", "Read %u page table entries", m_header.pageCount);
return XtcError::OK; return XtcError::OK;
} }
@@ -307,7 +305,7 @@ XtcError XtcParser::readChapters() {
} }
m_hasChapters = !m_chapters.empty(); m_hasChapters = !m_chapters.empty();
Serial.printf("[%lu] [XTC] Chapters: %u\n", millis(), static_cast<unsigned int>(m_chapters.size())); LOG_DBG("XTC", "Chapters: %u", static_cast<unsigned int>(m_chapters.size()));
return XtcError::OK; return XtcError::OK;
} }
@@ -334,7 +332,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Seek to page data // Seek to page data
if (!m_file.seek(page.offset)) { if (!m_file.seek(page.offset)) {
Serial.printf("[%lu] [XTC] Failed to seek to page %u at offset %lu\n", millis(), pageIndex, page.offset); LOG_DBG("XTC", "Failed to seek to page %u at offset %lu", pageIndex, page.offset);
m_lastError = XtcError::READ_ERROR; m_lastError = XtcError::READ_ERROR;
return 0; return 0;
} }
@@ -343,7 +341,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
XtgPageHeader pageHeader; XtgPageHeader pageHeader;
size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader)); size_t headerRead = m_file.read(reinterpret_cast<uint8_t*>(&pageHeader), sizeof(XtgPageHeader));
if (headerRead != sizeof(XtgPageHeader)) { if (headerRead != sizeof(XtgPageHeader)) {
Serial.printf("[%lu] [XTC] Failed to read page header for page %u\n", millis(), pageIndex); LOG_DBG("XTC", "Failed to read page header for page %u", pageIndex);
m_lastError = XtcError::READ_ERROR; m_lastError = XtcError::READ_ERROR;
return 0; return 0;
} }
@@ -351,8 +349,8 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Verify page magic (XTG for 1-bit, XTH for 2-bit) // Verify page magic (XTG for 1-bit, XTH for 2-bit)
const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC; const uint32_t expectedMagic = (m_bitDepth == 2) ? XTH_MAGIC : XTG_MAGIC;
if (pageHeader.magic != expectedMagic) { if (pageHeader.magic != expectedMagic) {
Serial.printf("[%lu] [XTC] Invalid page magic for page %u: 0x%08X (expected 0x%08X)\n", millis(), pageIndex, LOG_DBG("XTC", "Invalid page magic for page %u: 0x%08X (expected 0x%08X)", pageIndex, pageHeader.magic,
pageHeader.magic, expectedMagic); expectedMagic);
m_lastError = XtcError::INVALID_MAGIC; m_lastError = XtcError::INVALID_MAGIC;
return 0; return 0;
} }
@@ -370,7 +368,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Check buffer size // Check buffer size
if (bufferSize < bitmapSize) { if (bufferSize < bitmapSize) {
Serial.printf("[%lu] [XTC] Buffer too small: need %u, have %u\n", millis(), bitmapSize, bufferSize); LOG_DBG("XTC", "Buffer too small: need %u, have %u", bitmapSize, bufferSize);
m_lastError = XtcError::MEMORY_ERROR; m_lastError = XtcError::MEMORY_ERROR;
return 0; return 0;
} }
@@ -378,7 +376,7 @@ size_t XtcParser::loadPage(uint32_t pageIndex, uint8_t* buffer, size_t bufferSiz
// Read bitmap data // Read bitmap data
size_t bytesRead = m_file.read(buffer, bitmapSize); size_t bytesRead = m_file.read(buffer, bitmapSize);
if (bytesRead != bitmapSize) { if (bytesRead != bitmapSize) {
Serial.printf("[%lu] [XTC] Page read error: expected %u, got %u\n", millis(), bitmapSize, bytesRead); LOG_DBG("XTC", "Page read error: expected %u, got %u", bitmapSize, bytesRead);
m_lastError = XtcError::READ_ERROR; m_lastError = XtcError::READ_ERROR;
return 0; return 0;
} }
@@ -444,7 +442,7 @@ XtcError XtcParser::loadPageStreaming(uint32_t pageIndex,
bool XtcParser::isValidXtcFile(const char* filepath) { bool XtcParser::isValidXtcFile(const char* filepath) {
FsFile file; FsFile file;
if (!SdMan.openFileForRead("XTC", filepath, file)) { if (!Storage.openFileForRead("XTC", filepath, file)) {
return false; return false;
} }

View File

@@ -7,7 +7,7 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <functional> #include <functional>
#include <memory> #include <memory>

View File

@@ -1,7 +1,7 @@
#include "ZipFile.h" #include "ZipFile.h"
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
#include <miniz.h> #include <miniz.h>
#include <algorithm> #include <algorithm>
@@ -10,7 +10,7 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t*
// Setup inflator // Setup inflator
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor))); const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) { if (!inflator) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for inflator");
return false; return false;
} }
memset(inflator, 0, sizeof(tinfl_decompressor)); memset(inflator, 0, sizeof(tinfl_decompressor));
@@ -23,7 +23,7 @@ bool inflateOneShot(const uint8_t* inputBuf, const size_t deflatedSize, uint8_t*
free(inflator); free(inflator);
if (status != TINFL_STATUS_DONE) { if (status != TINFL_STATUS_DONE) {
Serial.printf("[%lu] [ZIP] tinfl_decompress() failed with status %d\n", millis(), status); LOG_ERR("ZIP", "tinfl_decompress() failed with status %d", status);
return false; return false;
} }
@@ -195,13 +195,13 @@ long ZipFile::getDataOffset(const FileStatSlim& fileStat) {
} }
if (read != localHeaderSize) { if (read != localHeaderSize) {
Serial.printf("[%lu] [ZIP] Something went wrong reading the local header\n", millis()); LOG_ERR("ZIP", "Something went wrong reading the local header");
return -1; return -1;
} }
if (pLocalHeader[0] + (pLocalHeader[1] << 8) + (pLocalHeader[2] << 16) + (pLocalHeader[3] << 24) != if (pLocalHeader[0] + (pLocalHeader[1] << 8) + (pLocalHeader[2] << 16) + (pLocalHeader[3] << 24) !=
0x04034b50 /* MZ_ZIP_LOCAL_DIR_HEADER_SIG */) { 0x04034b50 /* MZ_ZIP_LOCAL_DIR_HEADER_SIG */) {
Serial.printf("[%lu] [ZIP] Not a valid zip file header\n", millis()); LOG_ERR("ZIP", "Not a valid zip file header");
return -1; return -1;
} }
@@ -222,7 +222,7 @@ bool ZipFile::loadZipDetails() {
const size_t fileSize = file.size(); const size_t fileSize = file.size();
if (fileSize < 22) { if (fileSize < 22) {
Serial.printf("[%lu] [ZIP] File too small to be a valid zip\n", millis()); LOG_ERR("ZIP", "File too small to be a valid zip");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -234,7 +234,7 @@ bool ZipFile::loadZipDetails() {
const int scanRange = fileSize > 1024 ? 1024 : fileSize; const int scanRange = fileSize > 1024 ? 1024 : fileSize;
const auto buffer = static_cast<uint8_t*>(malloc(scanRange)); const auto buffer = static_cast<uint8_t*>(malloc(scanRange));
if (!buffer) { if (!buffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for EOCD scan buffer\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for EOCD scan buffer");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -255,7 +255,7 @@ bool ZipFile::loadZipDetails() {
} }
if (foundOffset == -1) { if (foundOffset == -1) {
Serial.printf("[%lu] [ZIP] EOCD signature not found in zip file\n", millis()); LOG_ERR("ZIP", "EOCD signature not found in zip file");
free(buffer); free(buffer);
if (!wasOpen) { if (!wasOpen) {
close(); close();
@@ -279,7 +279,7 @@ bool ZipFile::loadZipDetails() {
} }
bool ZipFile::open() { bool ZipFile::open() {
if (!SdMan.openFileForRead("ZIP", filePath, file)) { if (!Storage.openFileForRead("ZIP", filePath, file)) {
return false; return false;
} }
return true; return true;
@@ -407,7 +407,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize; const auto dataSize = trailingNullByte ? inflatedDataSize + 1 : inflatedDataSize;
const auto data = static_cast<uint8_t*>(malloc(dataSize)); const auto data = static_cast<uint8_t*>(malloc(dataSize));
if (data == nullptr) { if (data == nullptr) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for output buffer (%zu bytes)\n", millis(), dataSize); LOG_ERR("ZIP", "Failed to allocate memory for output buffer (%zu bytes)", dataSize);
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -422,7 +422,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
} }
if (dataRead != inflatedDataSize) { if (dataRead != inflatedDataSize) {
Serial.printf("[%lu] [ZIP] Failed to read data\n", millis()); LOG_ERR("ZIP", "Failed to read data");
free(data); free(data);
return nullptr; return nullptr;
} }
@@ -432,7 +432,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
// Read out deflated content from file // Read out deflated content from file
const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize)); const auto deflatedData = static_cast<uint8_t*>(malloc(deflatedDataSize));
if (deflatedData == nullptr) { if (deflatedData == nullptr) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for decompression buffer\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for decompression buffer");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -445,7 +445,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
} }
if (dataRead != deflatedDataSize) { if (dataRead != deflatedDataSize) {
Serial.printf("[%lu] [ZIP] Failed to read data, expected %d got %d\n", millis(), deflatedDataSize, dataRead); LOG_ERR("ZIP", "Failed to read data, expected %d got %d", deflatedDataSize, dataRead);
free(deflatedData); free(deflatedData);
free(data); free(data);
return nullptr; return nullptr;
@@ -455,14 +455,14 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
free(deflatedData); free(deflatedData);
if (!success) { if (!success) {
Serial.printf("[%lu] [ZIP] Failed to inflate file\n", millis()); LOG_ERR("ZIP", "Failed to inflate file");
free(data); free(data);
return nullptr; return nullptr;
} }
// Continue out of block with data set // Continue out of block with data set
} else { } else {
Serial.printf("[%lu] [ZIP] Unsupported compression method\n", millis()); LOG_ERR("ZIP", "Unsupported compression method");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -498,7 +498,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
// no deflation, just read content // no deflation, just read content
const auto buffer = static_cast<uint8_t*>(malloc(chunkSize)); const auto buffer = static_cast<uint8_t*>(malloc(chunkSize));
if (!buffer) { if (!buffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for buffer\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for buffer");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -509,7 +509,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
while (remaining > 0) { while (remaining > 0) {
const size_t dataRead = file.read(buffer, remaining < chunkSize ? remaining : chunkSize); const size_t dataRead = file.read(buffer, remaining < chunkSize ? remaining : chunkSize);
if (dataRead == 0) { if (dataRead == 0) {
Serial.printf("[%lu] [ZIP] Could not read more bytes\n", millis()); LOG_ERR("ZIP", "Could not read more bytes");
free(buffer); free(buffer);
if (!wasOpen) { if (!wasOpen) {
close(); close();
@@ -532,7 +532,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
// Setup inflator // Setup inflator
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor))); const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
if (!inflator) { if (!inflator) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for inflator");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -544,7 +544,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
// Setup file read buffer // Setup file read buffer
const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize)); const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
if (!fileReadBuffer) { if (!fileReadBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for zip file read buffer");
free(inflator); free(inflator);
if (!wasOpen) { if (!wasOpen) {
close(); close();
@@ -554,7 +554,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE)); const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
if (!outputBuffer) { if (!outputBuffer) {
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary\n", millis()); LOG_ERR("ZIP", "Failed to allocate memory for dictionary");
free(inflator); free(inflator);
free(fileReadBuffer); free(fileReadBuffer);
if (!wasOpen) { if (!wasOpen) {
@@ -605,7 +605,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
if (outBytes > 0) { if (outBytes > 0) {
processedOutputBytes += outBytes; processedOutputBytes += outBytes;
if (out.write(outputBuffer + outputCursor, outBytes) != outBytes) { if (out.write(outputBuffer + outputCursor, outBytes) != outBytes) {
Serial.printf("[%lu] [ZIP] Failed to write all output bytes to stream\n", millis()); LOG_ERR("ZIP", "Failed to write all output bytes to stream");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -619,7 +619,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
} }
if (status < 0) { if (status < 0) {
Serial.printf("[%lu] [ZIP] tinfl_decompress() failed with status %d\n", millis(), status); LOG_ERR("ZIP", "tinfl_decompress() failed with status %d", status);
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -630,8 +630,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
} }
if (status == TINFL_STATUS_DONE) { if (status == TINFL_STATUS_DONE) {
Serial.printf("[%lu] [ZIP] Decompressed %d bytes into %d bytes\n", millis(), deflatedDataSize, LOG_ERR("ZIP", "Decompressed %d bytes into %d bytes", deflatedDataSize, inflatedDataSize);
inflatedDataSize);
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -643,7 +642,7 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
} }
// If we get here, EOF reached without TINFL_STATUS_DONE // If we get here, EOF reached without TINFL_STATUS_DONE
Serial.printf("[%lu] [ZIP] Unexpected EOF\n", millis()); LOG_ERR("ZIP", "Unexpected EOF");
if (!wasOpen) { if (!wasOpen) {
close(); close();
} }
@@ -657,6 +656,6 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
close(); close();
} }
Serial.printf("[%lu] [ZIP] Unsupported compression method\n", millis()); LOG_ERR("ZIP", "Unsupported compression method");
return false; return false;
} }

View File

@@ -1,5 +1,5 @@
#pragma once #pragma once
#include <SdFat.h> #include <HalStorage.h>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>

65
lib/hal/HalStorage.cpp Normal file
View File

@@ -0,0 +1,65 @@
#include "HalStorage.h"
#include <SDCardManager.h>
#define SDCard SDCardManager::getInstance()
HalStorage HalStorage::instance;
HalStorage::HalStorage() {}
bool HalStorage::begin() { return SDCard.begin(); }
bool HalStorage::ready() const { return SDCard.ready(); }
std::vector<String> HalStorage::listFiles(const char* path, int maxFiles) { return SDCard.listFiles(path, maxFiles); }
String HalStorage::readFile(const char* path) { return SDCard.readFile(path); }
bool HalStorage::readFileToStream(const char* path, Print& out, size_t chunkSize) {
return SDCard.readFileToStream(path, out, chunkSize);
}
size_t HalStorage::readFileToBuffer(const char* path, char* buffer, size_t bufferSize, size_t maxBytes) {
return SDCard.readFileToBuffer(path, buffer, bufferSize, maxBytes);
}
bool HalStorage::writeFile(const char* path, const String& content) { return SDCard.writeFile(path, content); }
bool HalStorage::ensureDirectoryExists(const char* path) { return SDCard.ensureDirectoryExists(path); }
FsFile HalStorage::open(const char* path, const oflag_t oflag) { return SDCard.open(path, oflag); }
bool HalStorage::mkdir(const char* path, const bool pFlag) { return SDCard.mkdir(path, pFlag); }
bool HalStorage::exists(const char* path) { return SDCard.exists(path); }
bool HalStorage::remove(const char* path) { return SDCard.remove(path); }
bool HalStorage::rmdir(const char* path) { return SDCard.rmdir(path); }
bool HalStorage::openFileForRead(const char* moduleName, const char* path, FsFile& file) {
return SDCard.openFileForRead(moduleName, path, file);
}
bool HalStorage::openFileForRead(const char* moduleName, const std::string& path, FsFile& file) {
return openFileForRead(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForRead(const char* moduleName, const String& path, FsFile& file) {
return openFileForRead(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForWrite(const char* moduleName, const char* path, FsFile& file) {
return SDCard.openFileForWrite(moduleName, path, file);
}
bool HalStorage::openFileForWrite(const char* moduleName, const std::string& path, FsFile& file) {
return openFileForWrite(moduleName, path.c_str(), file);
}
bool HalStorage::openFileForWrite(const char* moduleName, const String& path, FsFile& file) {
return openFileForWrite(moduleName, path.c_str(), file);
}
bool HalStorage::removeDir(const char* path) { return SDCard.removeDir(path); }

54
lib/hal/HalStorage.h Normal file
View File

@@ -0,0 +1,54 @@
#pragma once
#include <SDCardManager.h>
#include <vector>
class HalStorage {
public:
HalStorage();
bool begin();
bool ready() const;
std::vector<String> listFiles(const char* path = "/", int maxFiles = 200);
// Read the entire file at `path` into a String. Returns empty string on failure.
String readFile(const char* path);
// Low-memory helpers:
// Stream the file contents to a `Print` (e.g. `Serial`, or any `Print`-derived object).
// Returns true on success, false on failure.
bool readFileToStream(const char* path, Print& out, size_t chunkSize = 256);
// Read up to `bufferSize-1` bytes into `buffer`, null-terminating it. Returns bytes read.
size_t readFileToBuffer(const char* path, char* buffer, size_t bufferSize, size_t maxBytes = 0);
// Write a string to `path` on the SD card. Overwrites existing file.
// Returns true on success.
bool writeFile(const char* path, const String& content);
// Ensure a directory exists, creating it if necessary. Returns true on success.
bool ensureDirectoryExists(const char* path);
FsFile open(const char* path, const oflag_t oflag = O_RDONLY);
bool mkdir(const char* path, const bool pFlag = true);
bool exists(const char* path);
bool remove(const char* path);
bool rmdir(const char* path);
bool openFileForRead(const char* moduleName, const char* path, FsFile& file);
bool openFileForRead(const char* moduleName, const std::string& path, FsFile& file);
bool openFileForRead(const char* moduleName, const String& path, FsFile& file);
bool openFileForWrite(const char* moduleName, const char* path, FsFile& file);
bool openFileForWrite(const char* moduleName, const std::string& path, FsFile& file);
bool openFileForWrite(const char* moduleName, const String& path, FsFile& file);
bool removeDir(const char* path);
static HalStorage& getInstance() { return instance; }
private:
static HalStorage instance;
bool initialized = false;
};
#define Storage HalStorage::getInstance()
// Downstream code must use Storage instead of SdMan
#ifdef SdMan
#undef SdMan
#endif

View File

@@ -54,15 +54,31 @@ extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\" -DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2 ; Set log level to debug for development builds
[env:gh_release] [env:gh_release]
extends = base extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}\" -DCROSSPOINT_VERSION=\"${crosspoint.version}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=0 ; Set log level to error for release builds
[env:gh_release_rc] [env:gh_release_rc]
extends = base extends = base
build_flags = build_flags =
${base.build_flags} ${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\" -DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
[env:slim]
extends = base
build_flags =
${base.build_flags}
-DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\"
; serial output is disabled in slim builds to save space
-UENABLE_SERIAL_LOG

View File

@@ -1,5 +1,6 @@
import os import os
import re import re
import gzip
SRC_DIR = "src" SRC_DIR = "src"
@@ -40,12 +41,34 @@ for root, _, files in os.walk(SRC_DIR):
# minified = regex.sub("\g<1>", html_content) # minified = regex.sub("\g<1>", html_content)
minified = minify_html(html_content) minified = minify_html(html_content)
# Compress with gzip (compresslevel 9 is maximum compression)
# IMPORTANT: we don't use brotli because Firefox doesn't support brotli with insecured context (only supported on HTTPS)
compressed = gzip.compress(minified.encode('utf-8'), compresslevel=9)
base_name = f"{os.path.splitext(file)[0]}Html" base_name = f"{os.path.splitext(file)[0]}Html"
header_path = os.path.join(root, f"{base_name}.generated.h") header_path = os.path.join(root, f"{base_name}.generated.h")
with open(header_path, "w", encoding="utf-8") as h: with open(header_path, "w", encoding="utf-8") as h:
h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n") h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n")
h.write(f"#pragma once\n") h.write(f"#pragma once\n")
h.write(f'constexpr char {base_name}[] PROGMEM = R"rawliteral({minified})rawliteral";\n') h.write(f"#include <cstddef>\n\n")
# Write the compressed data as a byte array
h.write(f"constexpr char {base_name}[] PROGMEM = {{\n")
# Write bytes in rows of 16
for i in range(0, len(compressed), 16):
chunk = compressed[i:i+16]
hex_values = ', '.join(f'0x{b:02x}' for b in chunk)
h.write(f" {hex_values},\n")
h.write(f"}};\n\n")
h.write(f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n")
h.write(f"constexpr size_t {base_name}OriginalSize = {len(minified)};\n")
print(f"Generated: {header_path}") print(f"Generated: {header_path}")
print(f" Original: {len(html_content)} bytes")
print(f" Minified: {len(minified)} bytes ({100*len(minified)/len(html_content):.1f}%)")
print(f" Compressed: {len(compressed)} bytes ({100*len(compressed)/len(html_content):.1f}%)")

View File

@@ -1,32 +1,73 @@
import sys #!/usr/bin/env python3
"""
ESP32 Serial Monitor with Memory Graph
This script provides a comprehensive real-time serial monitor for ESP32 devices with
integrated memory usage graphing capabilities. It reads serial output, parses memory
information, and displays it in both console and graphical form.
Features:
- Real-time serial output monitoring with color-coded log levels
- Interactive memory usage graphing with matplotlib
- Command input interface for sending commands to the ESP32 device
- Screenshot capture and processing (1-bit black/white format)
- Graceful shutdown handling with Ctrl-C signal processing
- Configurable filtering and suppression of log messages
- Thread-safe operation with coordinated shutdown events
Usage:
python debugging_monitor.py [port] [options]
The script will open a matplotlib window showing memory usage over time and provide
an interactive command prompt for sending commands to the device. Press Ctrl-C or
close the graph window to exit gracefully.
"""
from __future__ import annotations
import argparse import argparse
import glob
import platform
import re import re
import signal
import sys
import threading import threading
from datetime import datetime
from collections import deque from collections import deque
import time from datetime import datetime
# Try to import potentially missing packages # Try to import potentially missing packages
PACKAGE_MAPPING: dict[str, str] = {
"serial": "pyserial",
"colorama": "colorama",
"matplotlib": "matplotlib",
"PIL": "Pillow",
}
try: try:
import serial
from colorama import init, Fore, Style
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.animation as animation import serial
from colorama import Fore, Style, init
from matplotlib import animation
try:
from PIL import Image
except ImportError:
Image = None
except ImportError as e: except ImportError as e:
missing_package = e.name ERROR_MSG = str(e).lower()
missing_packages = [pkg for mod, pkg in PACKAGE_MAPPING.items() if mod in ERROR_MSG]
if not missing_packages:
# Fallback if mapping doesn't cover
missing_packages = ["pyserial", "colorama", "matplotlib"]
print("\n" + "!" * 50) print("\n" + "!" * 50)
print(f" Error: The required package '{missing_package}' is not installed.") print(f" Error: Required package(s) not installed: {', '.join(missing_packages)}")
print("!" * 50) print("!" * 50)
print(f"\nTo fix this, please run the following command in your terminal:\n") print("\nTo fix this, please run the following command in your terminal:\n")
INSTALL_CMD = "pip install " if sys.platform.startswith("win") else "pip3 install "
install_cmd = "pip install " print(f" {INSTALL_CMD}{' '.join(missing_packages)}")
packages = []
if 'serial' in str(e): packages.append("pyserial")
if 'colorama' in str(e): packages.append("colorama")
if 'matplotlib' in str(e): packages.append("matplotlib")
print(f" {install_cmd}{' '.join(packages)}")
print("\nExiting...") print("\nExiting...")
sys.exit(1) sys.exit(1)
@@ -34,50 +75,104 @@ except ImportError as e:
# --- Global Variables for Data Sharing --- # --- Global Variables for Data Sharing ---
# Store last 50 data points # Store last 50 data points
MAX_POINTS = 50 MAX_POINTS = 50
time_data = deque(maxlen=MAX_POINTS) time_data: deque[str] = deque(maxlen=MAX_POINTS)
free_mem_data = deque(maxlen=MAX_POINTS) free_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
total_mem_data = deque(maxlen=MAX_POINTS) total_mem_data: deque[float] = deque(maxlen=MAX_POINTS)
data_lock = threading.Lock() # Prevent reading while writing data_lock: threading.Lock = threading.Lock() # Prevent reading while writing
# Global shutdown flag
shutdown_event = threading.Event()
# Initialize colors # Initialize colors
init(autoreset=True) init(autoreset=True)
def get_color_for_line(line): # Color mapping for log lines
COLOR_KEYWORDS: dict[str, list[str]] = {
Fore.RED: ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"],
Fore.CYAN: ["[MEM]", "FREE:"],
Fore.MAGENTA: [
"[GFX]",
"[ERS]",
"DISPLAY",
"RAM WRITE",
"RAM COMPLETE",
"REFRESH",
"POWERING ON",
"FRAME BUFFER",
"LUT",
],
Fore.GREEN: [
"[EBP]",
"[BMC]",
"[ZIP]",
"[PARSER]",
"[EHP]",
"LOADING EPUB",
"CACHE",
"DECOMPRESSED",
"PARSING",
],
Fore.YELLOW: ["[ACT]", "ENTERING ACTIVITY", "EXITING ACTIVITY"],
Fore.BLUE: ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"],
Fore.LIGHTYELLOW_EX: [
"[CPS]",
"SETTINGS",
"[CLEAR_CACHE]",
"[CHAP]",
"[OPDS]",
"[COF]",
],
Fore.LIGHTBLACK_EX: [
"ESP-ROM",
"BUILD:",
"RST:",
"BOOT:",
"SPIWP:",
"MODE:",
"LOAD:",
"ENTRY",
"[SD]",
"STARTING CROSSPOINT",
"VERSION",
],
Fore.LIGHTCYAN_EX: ["[RBS]"],
Fore.LIGHTMAGENTA_EX: [
"[KRS]",
"EINKDISPLAY:",
"STATIC FRAME",
"INITIALIZING",
"SPI INITIALIZED",
"GPIO PINS",
"RESETTING",
"SSD1677",
"E-INK",
],
Fore.LIGHTGREEN_EX: ["[FNS]", "FOOTNOTE"],
}
def signal_handler(signum, frame):
"""Handle SIGINT (Ctrl-C) by setting the shutdown event."""
# frame parameter is required by signal handler signature but not used
del frame # Explicitly mark as unused to satisfy linters
print(f"\n{Fore.YELLOW}Received signal {signum}. Shutting down...{Style.RESET_ALL}")
shutdown_event.set()
plt.close("all")
# pylint: disable=R0912
def get_color_for_line(line: str) -> str:
""" """
Classify log lines by type and assign appropriate colors. Classify log lines by type and assign appropriate colors.
""" """
line_upper = line.upper() line_upper = line.upper()
for color, keywords in COLOR_KEYWORDS.items():
if any(keyword in line_upper for keyword in ["ERROR", "[ERR]", "[SCT]", "FAILED", "WARNING"]): if any(keyword in line_upper for keyword in keywords):
return Fore.RED return color
if "[MEM]" in line_upper or "FREE:" in line_upper:
return Fore.CYAN
if any(keyword in line_upper for keyword in ["[GFX]", "[ERS]", "DISPLAY", "RAM WRITE", "RAM COMPLETE", "REFRESH", "POWERING ON", "FRAME BUFFER", "LUT"]):
return Fore.MAGENTA
if any(keyword in line_upper for keyword in ["[EBP]", "[BMC]", "[ZIP]", "[PARSER]", "[EHP]", "LOADING EPUB", "CACHE", "DECOMPRESSED", "PARSING"]):
return Fore.GREEN
if "[ACT]" in line_upper or "ENTERING ACTIVITY" in line_upper or "EXITING ACTIVITY" in line_upper:
return Fore.YELLOW
if any(keyword in line_upper for keyword in ["RENDERED PAGE", "[LOOP]", "DURATION", "WAIT COMPLETE"]):
return Fore.BLUE
if any(keyword in line_upper for keyword in ["[CPS]", "SETTINGS", "[CLEAR_CACHE]"]):
return Fore.LIGHTYELLOW_EX
if any(keyword in line_upper for keyword in ["ESP-ROM", "BUILD:", "RST:", "BOOT:", "SPIWP:", "MODE:", "LOAD:", "ENTRY", "[SD]", "STARTING CROSSPOINT", "VERSION"]):
return Fore.LIGHTBLACK_EX
if "[RBS]" in line_upper:
return Fore.LIGHTCYAN_EX
if "[KRS]" in line_upper:
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["EINKDISPLAY:", "STATIC FRAME", "INITIALIZING", "SPI INITIALIZED", "GPIO PINS", "RESETTING", "SSD1677", "E-INK"]):
return Fore.LIGHTMAGENTA_EX
if any(keyword in line_upper for keyword in ["[FNS]", "FOOTNOTE"]):
return Fore.LIGHTGREEN_EX
if any(keyword in line_upper for keyword in ["[CHAP]", "[OPDS]", "[COF]"]):
return Fore.LIGHTYELLOW_EX
return Fore.WHITE return Fore.WHITE
def parse_memory_line(line):
def parse_memory_line(line: str) -> tuple[int | None, int | None]:
""" """
Extracts Free and Total bytes from the specific log line. Extracts Free and Total bytes from the specific log line.
Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes Format: [MEM] Free: 196344 bytes, Total: 226412 bytes, Min Free: 112620 bytes
@@ -93,25 +188,62 @@ def parse_memory_line(line):
return None, None return None, None
return None, None return None, None
def serial_worker(port, baud):
def serial_worker(ser, kwargs: dict[str, str]) -> None:
""" """
Runs in a background thread. Handles reading serial, printing to console, Runs in a background thread. Handles reading serial data, printing to console,
and updating the data lists. updating memory usage data for graphing, and processing screenshot data.
Monitors the global shutdown event for graceful termination.
""" """
print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}") print(f"{Fore.CYAN}--- Opening serial port ---{Style.RESET_ALL}")
filter_keyword = kwargs.get("filter", "").lower()
suppress = kwargs.get("suppress", "").lower()
if filter_keyword and suppress and filter_keyword == suppress:
print(
f"{Fore.YELLOW}Warning: Filter and Suppress keywords are the same. "
f"This may result in no output.{Style.RESET_ALL}"
)
if filter_keyword:
print(
f"{Fore.YELLOW}Filtering lines to only show those containing: "
f"'{filter_keyword}'{Style.RESET_ALL}"
)
if suppress:
print(
f"{Fore.YELLOW}Suppressing lines containing: '{suppress}'{Style.RESET_ALL}"
)
expecting_screenshot = False
screenshot_size = 0
screenshot_data = b""
try: try:
ser = serial.Serial(port, baud, timeout=0.1) while not shutdown_event.is_set():
ser.dtr = False if expecting_screenshot:
ser.rts = False data = ser.read(screenshot_size - len(screenshot_data))
except serial.SerialException as e: if not data:
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}") continue
return screenshot_data += data
if len(screenshot_data) == screenshot_size:
if Image:
img = Image.frombytes("1", (800, 480), screenshot_data)
# We need to rotate the image because the raw data is in landscape mode
img = img.transpose(Image.ROTATE_270)
img.save("screenshot.bmp")
print(
f"{Fore.GREEN}Screenshot saved to screenshot.bmp{Style.RESET_ALL}"
)
else:
with open("screenshot.raw", "wb") as f:
f.write(screenshot_data)
print(
f"{Fore.GREEN}Screenshot saved to screenshot.raw (PIL not available){Style.RESET_ALL}"
)
expecting_screenshot = False
screenshot_data = b""
else:
try: try:
while True: raw_data = ser.readline().decode("utf-8", errors="replace")
try:
raw_data = ser.readline().decode('utf-8', errors='replace')
if not raw_data: if not raw_data:
continue continue
@@ -120,6 +252,13 @@ def serial_worker(port, baud):
if not clean_line: if not clean_line:
continue continue
if clean_line.startswith("SCREENSHOT_START:"):
screenshot_size = int(clean_line.split(":")[1])
expecting_screenshot = True
continue
elif clean_line == "SCREENSHOT_END":
continue # ignore
# Add PC timestamp # Add PC timestamp
pc_time = datetime.now().strftime("%H:%M:%S") pc_time = datetime.now().strftime("%H:%M:%S")
formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line) formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line)
@@ -127,33 +266,57 @@ def serial_worker(port, baud):
# Check for Memory Line # Check for Memory Line
if "[MEM]" in formatted_line: if "[MEM]" in formatted_line:
free_val, total_val = parse_memory_line(formatted_line) free_val, total_val = parse_memory_line(formatted_line)
if free_val is not None: if free_val is not None and total_val is not None:
with data_lock: with data_lock:
time_data.append(pc_time) time_data.append(pc_time)
free_mem_data.append(free_val / 1024) # Convert to KB free_mem_data.append(free_val / 1024) # Convert to KB
total_mem_data.append(total_val / 1024) # Convert to KB total_mem_data.append(total_val / 1024) # Convert to KB
# Apply filters
if filter_keyword and filter_keyword not in formatted_line.lower():
continue
if suppress and suppress in formatted_line.lower():
continue
# Print to console # Print to console
line_color = get_color_for_line(formatted_line) line_color = get_color_for_line(formatted_line)
print(f"{line_color}{formatted_line}") print(f"{line_color}{formatted_line}")
except OSError: except (OSError, UnicodeDecodeError):
print(f"{Fore.RED}Device disconnected.{Style.RESET_ALL}") print(
f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}"
)
break break
except Exception as e: except KeyboardInterrupt:
# If thread is killed violently (e.g. main exit), silence errors # If thread is killed violently (e.g. main exit), silence errors
pass pass
finally: finally:
if 'ser' in locals() and ser.is_open: pass # ser closed in main
ser.close()
def update_graph(frame):
def input_worker(ser) -> None:
""" """
Called by Matplotlib animation to redraw the chart. Runs in a background thread. Handles user input to send commands to the ESP32 device.
Monitors the global shutdown event for graceful termination on Ctrl-C.
""" """
while not shutdown_event.is_set():
try:
cmd = input("Command: ")
ser.write(f"CMD:{cmd}\n".encode())
except (EOFError, KeyboardInterrupt):
break
def update_graph(frame) -> list: # pylint: disable=unused-argument
"""
Called by Matplotlib animation to redraw the memory usage chart.
Monitors the global shutdown event and closes the plot when shutdown is requested.
"""
if shutdown_event.is_set():
plt.close("all")
return []
with data_lock: with data_lock:
if not time_data: if not time_data:
return return []
# Convert deques to lists for plotting # Convert deques to lists for plotting
x = list(time_data) x = list(time_data)
@@ -163,52 +326,183 @@ def update_graph(frame):
plt.cla() # Clear axis plt.cla() # Clear axis
# Plot Total RAM # Plot Total RAM
plt.plot(x, y_total, label='Total RAM (KB)', color='red', linestyle='--') plt.plot(x, y_total, label="Total RAM (KB)", color="red", linestyle="--")
# Plot Free RAM # Plot Free RAM
plt.plot(x, y_free, label='Free RAM (KB)', color='green', marker='o') plt.plot(x, y_free, label="Free RAM (KB)", color="green", marker="o")
# Fill area under Free RAM # Fill area under Free RAM
plt.fill_between(x, y_free, color='green', alpha=0.1) plt.fill_between(x, y_free, color="green", alpha=0.1)
plt.title("ESP32 Memory Monitor") plt.title("ESP32 Memory Monitor")
plt.ylabel("Memory (KB)") plt.ylabel("Memory (KB)")
plt.xlabel("Time") plt.xlabel("Time")
plt.legend(loc='upper left') plt.legend(loc="upper left")
plt.grid(True, linestyle=':', alpha=0.6) plt.grid(True, linestyle=":", alpha=0.6)
# Rotate date labels # Rotate date labels
plt.xticks(rotation=45, ha='right') plt.xticks(rotation=45, ha="right")
plt.tight_layout() plt.tight_layout()
def main(): return []
parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph")
parser.add_argument("port", nargs="?", default="/dev/ttyACM0", help="Serial port")
parser.add_argument("--baud", type=int, default=115200, help="Baud rate") def get_auto_detected_port() -> list[str]:
"""
Attempts to auto-detect the serial port for the ESP32 device.
Returns a list of all detected ports.
If no suitable port is found, the list will be empty.
Darwin/Linux logic by jonasdiemer
"""
port_list = []
system = platform.system()
# Code for darwin (macOS), linux, and windows
if system in ("Darwin", "Linux"):
pattern = "/dev/tty.usbmodem*" if system == "Darwin" else "/dev/ttyACM*"
port_list = sorted(glob.glob(pattern))
elif system == "Windows":
from serial.tools import list_ports
# Be careful with this pattern list - it should be specific
# enough to avoid picking up unrelated devices, but broad enough
# to catch all common USB-serial adapters used with ESP32
# Caveat: localized versions of Windows may have different descriptions,
# so we also check for specific VID:PID (but that may not cover all clones)
pattern_list = ["CP210x", "CH340", "USB Serial"]
found_ports = list_ports.comports()
port_list = [
port.device
for port in found_ports
if any(pat in port.description for pat in pattern_list)
or port.hwid.startswith(
"USB VID:PID=303A:1001"
) # Add specific VID:PID for XTEINK X4
]
return port_list
def main() -> None:
"""
Main entry point for the ESP32 monitor application.
Sets up argument parsing, initializes serial communication, starts background threads
for serial monitoring and command input, and launches the memory usage graph.
Implements graceful shutdown handling with signal processing for clean termination.
Features:
- Serial port monitoring with color-coded output
- Real-time memory usage graphing
- Interactive command interface
- Screenshot capture capability
- Graceful shutdown on Ctrl-C or window close
"""
parser = argparse.ArgumentParser(
description="ESP32 Serial Monitor with Memory Graph - Real-time monitoring, graphing, and command interface"
)
default_baudrate = 115200
parser.add_argument(
"port",
nargs="?",
default=None,
help="Serial port (leave empty for autodetection)",
)
parser.add_argument(
"--baud",
type=int,
default=default_baudrate,
help=f"Baud rate (default: {default_baudrate})",
)
parser.add_argument(
"--filter",
type=str,
default="",
help="Only display lines containing this keyword (case-insensitive)",
)
parser.add_argument(
"--suppress",
type=str,
default="",
help="Suppress lines containing this keyword (case-insensitive)",
)
args = parser.parse_args() args = parser.parse_args()
port = args.port
if port is None:
port_list = get_auto_detected_port()
if len(port_list) == 1:
port = port_list[0]
print(f"{Fore.CYAN}Auto-detected serial port: {port}{Style.RESET_ALL}")
elif len(port_list) > 1:
print(f"{Fore.YELLOW}Multiple serial ports found:{Style.RESET_ALL}")
for p in port_list:
print(f" - {p}")
print(
f"{Fore.YELLOW}Please specify the desired port as a command-line argument.{Style.RESET_ALL}"
)
if port is None:
print(f"{Fore.RED}Error: No suitable serial port found.{Style.RESET_ALL}")
sys.exit(1)
try:
ser = serial.Serial(port, args.baud, timeout=0.1)
ser.dtr = False
ser.rts = False
except serial.SerialException as e:
print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}")
return
# Set up signal handler for graceful shutdown
signal.signal(signal.SIGINT, signal_handler)
# 1. Start the Serial Reader in a separate thread # 1. Start the Serial Reader in a separate thread
# Daemon=True means this thread dies when the main program closes # Daemon=True means this thread dies when the main program closes
t = threading.Thread(target=serial_worker, args=(args.port, args.baud), daemon=True) myargs = vars(args) # Convert Namespace to dict for easier passing
t = threading.Thread(target=serial_worker, args=(ser, myargs), daemon=True)
t.start() t.start()
# Start input thread
input_thread = threading.Thread(target=input_worker, args=(ser,), daemon=True)
input_thread.start()
# 2. Set up the Graph (Main Thread) # 2. Set up the Graph (Main Thread)
try: try:
plt.style.use('light_background') import matplotlib.style as mplstyle # pylint: disable=import-outside-toplevel
except:
default_styles = (
"light_background",
"ggplot",
"seaborn",
"dark_background",
)
styles = list(mplstyle.available)
for default_style in default_styles:
if default_style in styles:
print(
f"\n{Fore.CYAN}--- Using Matplotlib style: {default_style} ---{Style.RESET_ALL}"
)
mplstyle.use(default_style)
break
except (AttributeError, ValueError):
pass pass
fig = plt.figure(figsize=(10, 6)) fig = plt.figure(figsize=(10, 6))
# Update graph every 1000ms # Update graph every 1000ms
ani = animation.FuncAnimation(fig, update_graph, interval=1000) _ = animation.FuncAnimation(
fig, update_graph, interval=1000, cache_frame_data=False
)
try: try:
print(f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}") print(
f"{Fore.YELLOW}Starting Graph Window... (Close window or press Ctrl-C to exit){Style.RESET_ALL}"
)
plt.show() plt.show()
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}") print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}")
plt.close('all') # Force close any lingering plot windows finally:
shutdown_event.set() # Ensure all threads know to stop
plt.close("all") # Force close any lingering plot windows
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,7 +1,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include <cstring> #include <cstring>
@@ -79,10 +79,10 @@ void applyLegacyFrontButtonLayout(CrossPointSettings& settings) {
bool CrossPointSettings::saveToFile() const { bool CrossPointSettings::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SdMan.mkdir("/.crosspoint"); Storage.mkdir("/.crosspoint");
FsFile outputFile; FsFile outputFile;
if (!SdMan.openFileForWrite("CPS", SETTINGS_FILE, outputFile)) { if (!Storage.openFileForWrite("CPS", SETTINGS_FILE, outputFile)) {
return false; return false;
} }
@@ -121,20 +121,20 @@ bool CrossPointSettings::saveToFile() const {
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); LOG_DBG("CPS", "Settings saved to file");
return true; return true;
} }
bool CrossPointSettings::loadFromFile() { bool CrossPointSettings::loadFromFile() {
FsFile inputFile; FsFile inputFile;
if (!SdMan.openFileForRead("CPS", SETTINGS_FILE, inputFile)) { if (!Storage.openFileForRead("CPS", SETTINGS_FILE, inputFile)) {
return false; return false;
} }
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(inputFile, version);
if (version != SETTINGS_FILE_VERSION) { if (version != SETTINGS_FILE_VERSION) {
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); LOG_ERR("CPS", "Deserialization failed: Unknown version %u", version);
inputFile.close(); inputFile.close();
return false; return false;
} }
@@ -233,7 +233,7 @@ bool CrossPointSettings::loadFromFile() {
} }
inputFile.close(); inputFile.close();
Serial.printf("[%lu] [CPS] Settings loaded from file\n", millis()); LOG_DBG("CPS", "Settings loaded from file");
return true; return true;
} }

View File

@@ -1,7 +1,7 @@
#include "CrossPointState.h" #include "CrossPointState.h"
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
namespace { namespace {
@@ -13,7 +13,7 @@ CrossPointState CrossPointState::instance;
bool CrossPointState::saveToFile() const { bool CrossPointState::saveToFile() const {
FsFile outputFile; FsFile outputFile;
if (!SdMan.openFileForWrite("CPS", STATE_FILE, outputFile)) { if (!Storage.openFileForWrite("CPS", STATE_FILE, outputFile)) {
return false; return false;
} }
@@ -28,14 +28,14 @@ bool CrossPointState::saveToFile() const {
bool CrossPointState::loadFromFile() { bool CrossPointState::loadFromFile() {
FsFile inputFile; FsFile inputFile;
if (!SdMan.openFileForRead("CPS", STATE_FILE, inputFile)) { if (!Storage.openFileForRead("CPS", STATE_FILE, inputFile)) {
return false; return false;
} }
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(inputFile, version);
if (version > STATE_FILE_VERSION) { if (version > STATE_FILE_VERSION) {
Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); LOG_ERR("CPS", "Deserialization failed: Unknown version %u", version);
inputFile.close(); inputFile.close();
return false; return false;
} }

View File

@@ -15,6 +15,7 @@ class MappedInputManager {
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {} explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
void update() const { gpio.update(); }
bool wasPressed(Button button) const; bool wasPressed(Button button) const;
bool wasReleased(Button button) const; bool wasReleased(Button button) const;
bool isPressed(Button button) const; bool isPressed(Button button) const;

View File

@@ -1,8 +1,8 @@
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include <Epub.h> #include <Epub.h>
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
#include <Xtc.h> #include <Xtc.h>
@@ -53,10 +53,10 @@ void RecentBooksStore::updateBook(const std::string& path, const std::string& ti
bool RecentBooksStore::saveToFile() const { bool RecentBooksStore::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SdMan.mkdir("/.crosspoint"); Storage.mkdir("/.crosspoint");
FsFile outputFile; FsFile outputFile;
if (!SdMan.openFileForWrite("RBS", RECENT_BOOKS_FILE, outputFile)) { if (!Storage.openFileForWrite("RBS", RECENT_BOOKS_FILE, outputFile)) {
return false; return false;
} }
@@ -72,7 +72,7 @@ bool RecentBooksStore::saveToFile() const {
} }
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [RBS] Recent books saved to file (%d entries)\n", millis(), count); LOG_DBG("RBS", "Recent books saved to file (%d entries)", count);
return true; return true;
} }
@@ -83,7 +83,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
lastBookFileName = path.substr(lastSlash + 1); lastBookFileName = path.substr(lastSlash + 1);
} }
Serial.printf("Loading recent book: %s\n", path.c_str()); LOG_DBG("RBS", "Loading recent book: %s", path.c_str());
// If epub, try to load the metadata for title/author and cover // If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
@@ -106,7 +106,7 @@ RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
bool RecentBooksStore::loadFromFile() { bool RecentBooksStore::loadFromFile() {
FsFile inputFile; FsFile inputFile;
if (!SdMan.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) { if (!Storage.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) {
return false; return false;
} }
@@ -136,7 +136,7 @@ bool RecentBooksStore::loadFromFile() {
} }
} }
} else { } else {
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); LOG_ERR("RBS", "Deserialization failed: Unknown version %u", version);
inputFile.close(); inputFile.close();
return false; return false;
} }
@@ -158,6 +158,6 @@ bool RecentBooksStore::loadFromFile() {
} }
inputFile.close(); inputFile.close();
Serial.printf("[%lu] [RBS] Recent books loaded from file (%d entries)\n", millis(), recentBooks.size()); LOG_DBG("RBS", "Recent books loaded from file (%d entries)", recentBooks.size());
return true; return true;
} }

101
src/SettingsList.h Normal file
View File

@@ -0,0 +1,101 @@
#pragma once
#include <vector>
#include "CrossPointSettings.h"
#include "KOReaderCredentialStore.h"
#include "activities/settings/SettingsActivity.h"
// Shared settings list used by both the device settings UI and the web settings API.
// Each entry has a key (for JSON API) and category (for grouping).
// ACTION-type entries and entries without a key are device-only.
inline std::vector<SettingInfo> getSettingsList() {
return {
// --- Display ---
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
{"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}, "sleepScreen", "Display"),
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"},
"sleepScreenCoverMode", "Display"),
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"),
SettingInfo::Enum(
"Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"},
"statusBar", "Display"),
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"},
"hideBatteryPercentage", "Display"),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}, "refreshFrequency", "Display"),
SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}, "uiTheme", "Display"),
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"),
// --- Reader ---
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"},
"fontFamily", "Reader"),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}, "fontSize",
"Reader"),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}, "lineSpacing",
"Reader"),
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}, "screenMargin", "Reader"),
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right", "Book's Style"}, "paragraphAlignment", "Reader"),
SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle, "embeddedStyle", "Reader"),
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled", "Reader"),
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}, "orientation", "Reader"),
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing,
"extraParagraphSpacing", "Reader"),
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing, "textAntiAliasing", "Reader"),
// --- Controls ---
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
{"Prev, Next", "Next, Prev"}, "sideButtonLayout", "Controls"),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip, "longPressChapterSkip",
"Controls"),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"},
"shortPwrBtn", "Controls"),
// --- System ---
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}, "sleepTimeout", "System"),
// --- KOReader Sync (web-only, uses KOReaderCredentialStore) ---
SettingInfo::DynamicString(
"KOReader Username", [] { return KOREADER_STORE.getUsername(); },
[](const std::string& v) {
KOREADER_STORE.setCredentials(v, KOREADER_STORE.getPassword());
KOREADER_STORE.saveToFile();
},
"koUsername", "KOReader Sync"),
SettingInfo::DynamicString(
"KOReader Password", [] { return KOREADER_STORE.getPassword(); },
[](const std::string& v) {
KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), v);
KOREADER_STORE.saveToFile();
},
"koPassword", "KOReader Sync"),
SettingInfo::DynamicString(
"Sync Server URL", [] { return KOREADER_STORE.getServerUrl(); },
[](const std::string& v) {
KOREADER_STORE.setServerUrl(v);
KOREADER_STORE.saveToFile();
},
"koServerUrl", "KOReader Sync"),
SettingInfo::DynamicEnum(
"Document Matching", {"Filename", "Binary"},
[] { return static_cast<uint8_t>(KOREADER_STORE.getMatchMethod()); },
[](uint8_t v) {
KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(v));
KOREADER_STORE.saveToFile();
},
"koMatchMethod", "KOReader Sync"),
// --- OPDS Browser (web-only, uses CrossPointSettings char arrays) ---
SettingInfo::String("OPDS Server URL", SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), "opdsServerUrl",
"OPDS Browser"),
SettingInfo::String("OPDS Username", SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername",
"OPDS Browser"),
SettingInfo::String("OPDS Password", SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword",
"OPDS Browser"),
};
}

View File

@@ -1,7 +1,7 @@
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
#include <Serialization.h> #include <Serialization.h>
// Initialize the static instance // Initialize the static instance
@@ -9,7 +9,7 @@ WifiCredentialStore WifiCredentialStore::instance;
namespace { namespace {
// File format version // File format version
constexpr uint8_t WIFI_FILE_VERSION = 1; constexpr uint8_t WIFI_FILE_VERSION = 2; // Increased version
// WiFi credentials file path // WiFi credentials file path
constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin"; constexpr char WIFI_FILE[] = "/.crosspoint/wifi.bin";
@@ -21,7 +21,7 @@ constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY);
} // namespace } // namespace
void WifiCredentialStore::obfuscate(std::string& data) const { void WifiCredentialStore::obfuscate(std::string& data) const {
Serial.printf("[%lu] [WCS] Obfuscating/deobfuscating %zu bytes\n", millis(), data.size()); LOG_DBG("WCS", "Obfuscating/deobfuscating %zu bytes", data.size());
for (size_t i = 0; i < data.size(); i++) { for (size_t i = 0; i < data.size(); i++) {
data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH]; data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH];
} }
@@ -29,23 +29,23 @@ void WifiCredentialStore::obfuscate(std::string& data) const {
bool WifiCredentialStore::saveToFile() const { bool WifiCredentialStore::saveToFile() const {
// Make sure the directory exists // Make sure the directory exists
SdMan.mkdir("/.crosspoint"); Storage.mkdir("/.crosspoint");
FsFile file; FsFile file;
if (!SdMan.openFileForWrite("WCS", WIFI_FILE, file)) { if (!Storage.openFileForWrite("WCS", WIFI_FILE, file)) {
return false; return false;
} }
// Write header // Write header
serialization::writePod(file, WIFI_FILE_VERSION); serialization::writePod(file, WIFI_FILE_VERSION);
serialization::writeString(file, lastConnectedSsid); // Save last connected SSID
serialization::writePod(file, static_cast<uint8_t>(credentials.size())); serialization::writePod(file, static_cast<uint8_t>(credentials.size()));
// Write each credential // Write each credential
for (const auto& cred : credentials) { for (const auto& cred : credentials) {
// Write SSID (plaintext - not sensitive) // Write SSID (plaintext - not sensitive)
serialization::writeString(file, cred.ssid); serialization::writeString(file, cred.ssid);
Serial.printf("[%lu] [WCS] Saving SSID: %s, password length: %zu\n", millis(), cred.ssid.c_str(), LOG_DBG("WCS", "Saving SSID: %s, password length: %zu", cred.ssid.c_str(), cred.password.size());
cred.password.size());
// Write password (obfuscated) // Write password (obfuscated)
std::string obfuscatedPwd = cred.password; std::string obfuscatedPwd = cred.password;
@@ -54,25 +54,31 @@ bool WifiCredentialStore::saveToFile() const {
} }
file.close(); file.close();
Serial.printf("[%lu] [WCS] Saved %zu WiFi credentials to file\n", millis(), credentials.size()); LOG_DBG("WCS", "Saved %zu WiFi credentials to file", credentials.size());
return true; return true;
} }
bool WifiCredentialStore::loadFromFile() { bool WifiCredentialStore::loadFromFile() {
FsFile file; FsFile file;
if (!SdMan.openFileForRead("WCS", WIFI_FILE, file)) { if (!Storage.openFileForRead("WCS", WIFI_FILE, file)) {
return false; return false;
} }
// Read and verify version // Read and verify version
uint8_t version; uint8_t version;
serialization::readPod(file, version); serialization::readPod(file, version);
if (version != WIFI_FILE_VERSION) { if (version > WIFI_FILE_VERSION) {
Serial.printf("[%lu] [WCS] Unknown file version: %u\n", millis(), version); LOG_DBG("WCS", "Unknown file version: %u", version);
file.close(); file.close();
return false; return false;
} }
if (version >= 2) {
serialization::readString(file, lastConnectedSsid);
} else {
lastConnectedSsid.clear();
}
// Read credential count // Read credential count
uint8_t count; uint8_t count;
serialization::readPod(file, count); serialization::readPod(file, count);
@@ -87,16 +93,15 @@ bool WifiCredentialStore::loadFromFile() {
// Read and deobfuscate password // Read and deobfuscate password
serialization::readString(file, cred.password); serialization::readString(file, cred.password);
Serial.printf("[%lu] [WCS] Loaded SSID: %s, obfuscated password length: %zu\n", millis(), cred.ssid.c_str(), LOG_DBG("WCS", "Loaded SSID: %s, obfuscated password length: %zu", cred.ssid.c_str(), cred.password.size());
cred.password.size());
obfuscate(cred.password); // XOR is symmetric, so same function deobfuscates obfuscate(cred.password); // XOR is symmetric, so same function deobfuscates
Serial.printf("[%lu] [WCS] After deobfuscation, password length: %zu\n", millis(), cred.password.size()); LOG_DBG("WCS", "After deobfuscation, password length: %zu", cred.password.size());
credentials.push_back(cred); credentials.push_back(cred);
} }
file.close(); file.close();
Serial.printf("[%lu] [WCS] Loaded %zu WiFi credentials from file\n", millis(), credentials.size()); LOG_DBG("WCS", "Loaded %zu WiFi credentials from file", credentials.size());
return true; return true;
} }
@@ -106,19 +111,19 @@ bool WifiCredentialStore::addCredential(const std::string& ssid, const std::stri
[&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
if (cred != credentials.end()) { if (cred != credentials.end()) {
cred->password = password; cred->password = password;
Serial.printf("[%lu] [WCS] Updated credentials for: %s\n", millis(), ssid.c_str()); LOG_DBG("WCS", "Updated credentials for: %s", ssid.c_str());
return saveToFile(); return saveToFile();
} }
// Check if we've reached the limit // Check if we've reached the limit
if (credentials.size() >= MAX_NETWORKS) { if (credentials.size() >= MAX_NETWORKS) {
Serial.printf("[%lu] [WCS] Cannot add more networks, limit of %zu reached\n", millis(), MAX_NETWORKS); LOG_DBG("WCS", "Cannot add more networks, limit of %zu reached", MAX_NETWORKS);
return false; return false;
} }
// Add new credential // Add new credential
credentials.push_back({ssid, password}); credentials.push_back({ssid, password});
Serial.printf("[%lu] [WCS] Added credentials for: %s\n", millis(), ssid.c_str()); LOG_DBG("WCS", "Added credentials for: %s", ssid.c_str());
return saveToFile(); return saveToFile();
} }
@@ -127,7 +132,10 @@ bool WifiCredentialStore::removeCredential(const std::string& ssid) {
[&ssid](const WifiCredential& cred) { return cred.ssid == ssid; }); [&ssid](const WifiCredential& cred) { return cred.ssid == ssid; });
if (cred != credentials.end()) { if (cred != credentials.end()) {
credentials.erase(cred); credentials.erase(cred);
Serial.printf("[%lu] [WCS] Removed credentials for: %s\n", millis(), ssid.c_str()); LOG_DBG("WCS", "Removed credentials for: %s", ssid.c_str());
if (ssid == lastConnectedSsid) {
clearLastConnectedSsid();
}
return saveToFile(); return saveToFile();
} }
return false; // Not found return false; // Not found
@@ -146,8 +154,25 @@ const WifiCredential* WifiCredentialStore::findCredential(const std::string& ssi
bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; } bool WifiCredentialStore::hasSavedCredential(const std::string& ssid) const { return findCredential(ssid) != nullptr; }
void WifiCredentialStore::setLastConnectedSsid(const std::string& ssid) {
if (lastConnectedSsid != ssid) {
lastConnectedSsid = ssid;
saveToFile();
}
}
const std::string& WifiCredentialStore::getLastConnectedSsid() const { return lastConnectedSsid; }
void WifiCredentialStore::clearLastConnectedSsid() {
if (!lastConnectedSsid.empty()) {
lastConnectedSsid.clear();
saveToFile();
}
}
void WifiCredentialStore::clearAll() { void WifiCredentialStore::clearAll() {
credentials.clear(); credentials.clear();
lastConnectedSsid.clear();
saveToFile(); saveToFile();
Serial.printf("[%lu] [WCS] Cleared all WiFi credentials\n", millis()); LOG_DBG("WCS", "Cleared all WiFi credentials");
} }

View File

@@ -16,6 +16,7 @@ class WifiCredentialStore {
private: private:
static WifiCredentialStore instance; static WifiCredentialStore instance;
std::vector<WifiCredential> credentials; std::vector<WifiCredential> credentials;
std::string lastConnectedSsid;
static constexpr size_t MAX_NETWORKS = 8; static constexpr size_t MAX_NETWORKS = 8;
@@ -48,6 +49,11 @@ class WifiCredentialStore {
// Check if a network is saved // Check if a network is saved
bool hasSavedCredential(const std::string& ssid) const; bool hasSavedCredential(const std::string& ssid) const;
// Last connected network
void setLastConnectedSsid(const std::string& ssid);
const std::string& getLastConnectedSsid() const;
void clearLastConnectedSsid();
// Clear all credentials // Clear all credentials
void clearAll(); void clearAll();
}; };

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <HardwareSerial.h> #include <Logging.h>
#include <string> #include <string>
#include <utility> #include <utility>
@@ -18,8 +18,8 @@ class Activity {
explicit Activity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput) explicit Activity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
: name(std::move(name)), renderer(renderer), mappedInput(mappedInput) {} : name(std::move(name)), renderer(renderer), mappedInput(mappedInput) {}
virtual ~Activity() = default; virtual ~Activity() = default;
virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); } virtual void onEnter() { LOG_DBG("ACT", "Entering activity: %s", name.c_str()); }
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); } virtual void onExit() { LOG_DBG("ACT", "Exiting activity: %s", name.c_str()); }
virtual void loop() {} virtual void loop() {}
virtual bool skipLoopDelay() { return false; } virtual bool skipLoopDelay() { return false; }
virtual bool preventAutoSleep() { return false; } virtual bool preventAutoSleep() { return false; }

View File

@@ -2,7 +2,7 @@
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Txt.h> #include <Txt.h>
#include <Xtc.h> #include <Xtc.h>
@@ -32,7 +32,7 @@ void SleepActivity::onEnter() {
void SleepActivity::renderCustomSleepScreen() const { void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory // Check if we have a /sleep directory
auto dir = SdMan.open("/sleep"); auto dir = Storage.open("/sleep");
if (dir && dir.isDirectory()) { if (dir && dir.isDirectory()) {
std::vector<std::string> files; std::vector<std::string> files;
char name[500]; char name[500];
@@ -50,13 +50,13 @@ void SleepActivity::renderCustomSleepScreen() const {
} }
if (filename.substr(filename.length() - 4) != ".bmp") { if (filename.substr(filename.length() - 4) != ".bmp") {
Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), name); LOG_DBG("SLP", "Skipping non-.bmp file name: %s", name);
file.close(); file.close();
continue; continue;
} }
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() != BmpReaderError::Ok) { if (bitmap.parseHeaders() != BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), name); LOG_DBG("SLP", "Skipping invalid BMP file: %s", name);
file.close(); file.close();
continue; continue;
} }
@@ -75,8 +75,8 @@ void SleepActivity::renderCustomSleepScreen() const {
APP_STATE.saveToFile(); APP_STATE.saveToFile();
const auto filename = "/sleep/" + files[randomFileIndex]; const auto filename = "/sleep/" + files[randomFileIndex];
FsFile file; FsFile file;
if (SdMan.openFileForRead("SLP", filename, file)) { if (Storage.openFileForRead("SLP", filename, file)) {
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); LOG_DBG("SLP", "Randomly loading: /sleep/%s", files[randomFileIndex].c_str());
delay(100); delay(100);
Bitmap bitmap(file, true); Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
@@ -92,10 +92,10 @@ void SleepActivity::renderCustomSleepScreen() const {
// Look for sleep.bmp on the root of the sd card to determine if we should // Look for sleep.bmp on the root of the sd card to determine if we should
// render a custom sleep screen instead of the default. // render a custom sleep screen instead of the default.
FsFile file; FsFile file;
if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) { if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
Bitmap bitmap(file, true); Bitmap bitmap(file, true);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); LOG_DBG("SLP", "Loading: /sleep.bmp");
renderBitmapSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);
return; return;
} }
@@ -127,34 +127,33 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
float cropX = 0, cropY = 0; float cropX = 0, cropY = 0;
Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(), LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
pageWidth, pageHeight);
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
// image will scale, make sure placement is right // image will scale, make sure placement is right
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight()); float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight); const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio); LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
if (ratio > screenRatio) { if (ratio > screenRatio) {
// image wider than viewport ratio, scaled down image needs to be centered vertically // image wider than viewport ratio, scaled down image needs to be centered vertically
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropX = 1.0f - (screenRatio / ratio); cropX = 1.0f - (screenRatio / ratio);
Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX); LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight()); ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
} }
x = 0; x = 0;
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2); y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y); LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
} else { } else {
// image taller than viewport ratio, scaled down image needs to be centered horizontally // image taller than viewport ratio, scaled down image needs to be centered horizontally
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropY = 1.0f - (ratio / screenRatio); cropY = 1.0f - (ratio / screenRatio);
Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY); LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight())); ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
} }
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2); x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
y = 0; y = 0;
Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x); LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
} }
} else { } else {
// center the image // center the image
@@ -162,7 +161,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
y = (pageHeight - bitmap.getHeight()) / 2; y = (pageHeight - bitmap.getHeight()) / 2;
} }
Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y); LOG_DBG("SLP", "drawing to %d x %d", x, y);
renderer.clearScreen(); renderer.clearScreen();
const bool hasGreyscale = bitmap.hasGreyscale() && const bool hasGreyscale = bitmap.hasGreyscale() &&
@@ -218,12 +217,12 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle XTC file // Handle XTC file
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastXtc.load()) { if (!lastXtc.load()) {
Serial.println("[SLP] Failed to load last XTC"); LOG_ERR("SLP", "Failed to load last XTC");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastXtc.generateCoverBmp()) { if (!lastXtc.generateCoverBmp()) {
Serial.println("[SLP] Failed to generate XTC cover bmp"); LOG_ERR("SLP", "Failed to generate XTC cover bmp");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
@@ -232,12 +231,12 @@ void SleepActivity::renderCoverSleepScreen() const {
// Handle TXT file - looks for cover image in the same folder // Handle TXT file - looks for cover image in the same folder
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastTxt.load()) { if (!lastTxt.load()) {
Serial.println("[SLP] Failed to load last TXT"); LOG_ERR("SLP", "Failed to load last TXT");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastTxt.generateCoverBmp()) { if (!lastTxt.generateCoverBmp()) {
Serial.println("[SLP] No cover image found for TXT file"); LOG_ERR("SLP", "No cover image found for TXT file");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
@@ -247,12 +246,12 @@ void SleepActivity::renderCoverSleepScreen() const {
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
// Skip loading css since we only need metadata here // Skip loading css since we only need metadata here
if (!lastEpub.load(true, true)) { if (!lastEpub.load(true, true)) {
Serial.println("[SLP] Failed to load last epub"); LOG_ERR("SLP", "Failed to load last epub");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
if (!lastEpub.generateCoverBmp(cropped)) { if (!lastEpub.generateCoverBmp(cropped)) {
Serial.println("[SLP] Failed to generate cover bmp"); LOG_ERR("SLP", "Failed to generate cover bmp");
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
@@ -262,10 +261,10 @@ void SleepActivity::renderCoverSleepScreen() const {
} }
FsFile file; FsFile file;
if (SdMan.openFileForRead("SLP", coverBmpPath, file)) { if (Storage.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.c_str()); LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
renderBitmapSleepScreen(bitmap); renderBitmapSleepScreen(bitmap);
return; return;
} }

View File

@@ -2,7 +2,7 @@
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <Logging.h>
#include <OpdsStream.h> #include <OpdsStream.h>
#include <WiFi.h> #include <WiFi.h>
@@ -17,7 +17,6 @@
namespace { namespace {
constexpr int PAGE_ITEMS = 23; constexpr int PAGE_ITEMS = 23;
constexpr int SKIP_PAGE_MS = 700;
} // namespace } // namespace
void OpdsBookBrowserActivity::taskTrampoline(void* param) { void OpdsBookBrowserActivity::taskTrampoline(void* param) {
@@ -79,14 +78,14 @@ void OpdsBookBrowserActivity::loop() {
// Check if WiFi is still connected // Check if WiFi is still connected
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
// WiFi connected - just retry fetching the feed // WiFi connected - just retry fetching the feed
Serial.printf("[%lu] [OPDS] Retry: WiFi connected, retrying fetch\n", millis()); LOG_DBG("OPDS", "Retry: WiFi connected, retrying fetch");
state = BrowserState::LOADING; state = BrowserState::LOADING;
statusMessage = "Loading..."; statusMessage = "Loading...";
updateRequired = true; updateRequired = true;
fetchFeed(currentPath); fetchFeed(currentPath);
} else { } else {
// WiFi not connected - launch WiFi selection // WiFi not connected - launch WiFi selection
Serial.printf("[%lu] [OPDS] Retry: WiFi not connected, launching selection\n", millis()); LOG_DBG("OPDS", "Retry: WiFi not connected, launching selection");
launchWifiSelection(); launchWifiSelection();
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
@@ -118,12 +117,6 @@ void OpdsBookBrowserActivity::loop() {
// Handle browsing state // Handle browsing state
if (state == BrowserState::BROWSING) { if (state == BrowserState::BROWSING) {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!entries.empty()) { if (!entries.empty()) {
const auto& entry = entries[selectorIndex]; const auto& entry = entries[selectorIndex];
@@ -135,20 +128,29 @@ void OpdsBookBrowserActivity::loop() {
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
navigateBack(); navigateBack();
} else if (prevReleased && !entries.empty()) {
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size();
} else {
selectorIndex = (selectorIndex + entries.size() - 1) % entries.size();
} }
// Handle navigation
if (!entries.empty()) {
buttonNavigator.onNextRelease([this] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
updateRequired = true; updateRequired = true;
} else if (nextReleased && !entries.empty()) { });
if (skipPage) {
selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size(); buttonNavigator.onPreviousRelease([this] {
} else { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size());
selectorIndex = (selectorIndex + 1) % entries.size();
}
updateRequired = true; updateRequired = true;
});
buttonNavigator.onNextContinuous([this] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
updateRequired = true;
});
} }
} }
} }
@@ -263,7 +265,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
} }
std::string url = UrlUtils::buildUrl(serverUrl, path); std::string url = UrlUtils::buildUrl(serverUrl, path);
Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str()); LOG_DBG("OPDS", "Fetching: %s", url.c_str());
OpdsParser parser; OpdsParser parser;
@@ -285,7 +287,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
} }
entries = std::move(parser).getEntries(); entries = std::move(parser).getEntries();
Serial.printf("[%lu] [OPDS] Found %d entries\n", millis(), entries.size()); LOG_DBG("OPDS", "Found %d entries", entries.size());
selectorIndex = 0; selectorIndex = 0;
if (entries.empty()) { if (entries.empty()) {
@@ -349,7 +351,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
} }
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub"; std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), downloadUrl.c_str(), filename.c_str()); LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
const auto result = const auto result =
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
@@ -359,12 +361,12 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
}); });
if (result == HttpDownloader::OK) { if (result == HttpDownloader::OK) {
Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str()); LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
// Invalidate any existing cache for this file to prevent stale metadata issues // Invalidate any existing cache for this file to prevent stale metadata issues
Epub epub(filename, "/.crosspoint"); Epub epub(filename, "/.crosspoint");
epub.clearCache(); epub.clearCache();
Serial.printf("[%lu] [OPDS] Cleared cache for: %s\n", millis(), filename.c_str()); LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
state = BrowserState::BROWSING; state = BrowserState::BROWSING;
updateRequired = true; updateRequired = true;
@@ -401,13 +403,13 @@ void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) {
exitActivity(); exitActivity();
if (connected) { if (connected) {
Serial.printf("[%lu] [OPDS] WiFi connected via selection, fetching feed\n", millis()); LOG_DBG("OPDS", "WiFi connected via selection, fetching feed");
state = BrowserState::LOADING; state = BrowserState::LOADING;
statusMessage = "Loading..."; statusMessage = "Loading...";
updateRequired = true; updateRequired = true;
fetchFeed(currentPath); fetchFeed(currentPath);
} else { } else {
Serial.printf("[%lu] [OPDS] WiFi selection cancelled/failed\n", millis()); LOG_DBG("OPDS", "WiFi selection cancelled/failed");
// Force disconnect to ensure clean state for next retry // Force disconnect to ensure clean state for next retry
// This prevents stale connection status from interfering // This prevents stale connection status from interfering
WiFi.disconnect(); WiFi.disconnect();

View File

@@ -9,6 +9,7 @@
#include <vector> #include <vector>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/** /**
* Activity for browsing and downloading books from an OPDS server. * Activity for browsing and downloading books from an OPDS server.
@@ -37,6 +38,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
BrowserState state = BrowserState::LOADING; BrowserState state = BrowserState::LOADING;
@@ -62,4 +64,5 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
void navigateToEntry(const OpdsEntry& entry); void navigateToEntry(const OpdsEntry& entry);
void navigateBack(); void navigateBack();
void downloadBook(const OpdsEntry& book); void downloadBook(const OpdsEntry& book);
bool preventAutoSleep() override { return true; }
}; };

View File

@@ -3,7 +3,7 @@
#include <Bitmap.h> #include <Bitmap.h>
#include <Epub.h> #include <Epub.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Utf8.h> #include <Utf8.h>
#include <Xtc.h> #include <Xtc.h>
@@ -47,7 +47,7 @@ void HomeActivity::loadRecentBooks(int maxBooks) {
} }
// Skip if file no longer exists // Skip if file no longer exists
if (!SdMan.exists(book.path.c_str())) { if (!Storage.exists(book.path.c_str())) {
continue; continue;
} }
@@ -64,7 +64,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
for (RecentBook& book : recentBooks) { for (RecentBook& book : recentBooks) {
if (!book.coverBmpPath.empty()) { if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!SdMan.exists(coverPath.c_str())) { if (!Storage.exists(coverPath.c_str())) {
// If epub, try to load the metadata for title/author and cover // If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(book.path, ".epub")) { if (StringUtils::checkFileExtension(book.path, ".epub")) {
Epub epub(book.path, "/.crosspoint"); Epub epub(book.path, "/.crosspoint");
@@ -196,13 +196,18 @@ void HomeActivity::freeCoverBuffer() {
} }
void HomeActivity::loop() { void HomeActivity::loop() {
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
const int menuCount = getMenuItemCount(); const int menuCount = getMenuItemCount();
buttonNavigator.onNext([this, menuCount] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
updateRequired = true;
});
buttonNavigator.onPrevious([this, menuCount] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
updateRequired = true;
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Calculate dynamic indices based on which options are available // Calculate dynamic indices based on which options are available
int idx = 0; int idx = 0;
@@ -226,12 +231,6 @@ void HomeActivity::loop() {
} else if (menuSelectedIndex == settingsIdx) { } else if (menuSelectedIndex == settingsIdx) {
onSettingsOpen(); onSettingsOpen();
} }
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuCount;
updateRequired = true;
} }
} }

View File

@@ -8,6 +8,7 @@
#include "../Activity.h" #include "../Activity.h"
#include "./MyLibraryActivity.h" #include "./MyLibraryActivity.h"
#include "util/ButtonNavigator.h"
struct RecentBook; struct RecentBook;
struct Rect; struct Rect;
@@ -15,6 +16,7 @@ struct Rect;
class HomeActivity final : public Activity { class HomeActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectorIndex = 0; int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;
bool recentsLoading = false; bool recentsLoading = false;

View File

@@ -1,7 +1,7 @@
#include "MyLibraryActivity.h" #include "MyLibraryActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <algorithm> #include <algorithm>
@@ -11,17 +11,58 @@
#include "util/StringUtils.h" #include "util/StringUtils.h"
namespace { namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
} // namespace } // namespace
void sortFileList(std::vector<std::string>& strs) { void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
if (str1.back() == '/' && str2.back() != '/') return true; // Directories first
if (str1.back() != '/' && str2.back() == '/') return false; bool isDir1 = str1.back() == '/';
return lexicographical_compare( bool isDir2 = str2.back() == '/';
begin(str1), end(str1), begin(str2), end(str2), if (isDir1 != isDir2) return isDir1;
[](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); });
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
}); });
} }
@@ -33,7 +74,7 @@ void MyLibraryActivity::taskTrampoline(void* param) {
void MyLibraryActivity::loadFiles() { void MyLibraryActivity::loadFiles() {
files.clear(); files.clear();
auto root = SdMan.open(basepath.c_str()); auto root = Storage.open(basepath.c_str());
if (!root || !root.isDirectory()) { if (!root || !root.isDirectory()) {
if (root) root.close(); if (root) root.close();
return; return;
@@ -109,13 +150,6 @@ void MyLibraryActivity::loop() {
return; return;
} }
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false); const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -157,21 +191,26 @@ void MyLibraryActivity::loop() {
} }
int listSize = static_cast<int>(files.size()); int listSize = static_cast<int>(files.size());
if (upReleased) {
if (skipPage) { buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = std::max(static_cast<int>((selectorIndex / pageItems - 1) * pageItems), 0); selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
updateRequired = true; updateRequired = true;
} else if (downReleased) { });
if (skipPage) {
selectorIndex = std::min(static_cast<int>((selectorIndex / pageItems + 1) * pageItems), listSize - 1); buttonNavigator.onPreviousRelease([this, listSize] {
} else { selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
selectorIndex = (selectorIndex + 1) % listSize;
}
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
} }
void MyLibraryActivity::displayTaskLoop() { void MyLibraryActivity::displayTaskLoop() {
@@ -207,7 +246,7 @@ void MyLibraryActivity::render() const {
} }
// Help text // Help text
const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down"); const auto labels = mappedInput.mapLabels(basepath == "/" ? "« Home" : "« Back", "Open", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();

View File

@@ -8,11 +8,14 @@
#include <vector> #include <vector>
#include "../Activity.h" #include "../Activity.h"
#include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class MyLibraryActivity final : public Activity { class MyLibraryActivity final : public Activity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
size_t selectorIndex = 0; size_t selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;

View File

@@ -1,7 +1,7 @@
#include "RecentBooksActivity.h" #include "RecentBooksActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <algorithm> #include <algorithm>
@@ -12,7 +12,6 @@
#include "util/StringUtils.h" #include "util/StringUtils.h"
namespace { namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
} // namespace } // namespace
@@ -28,7 +27,7 @@ void RecentBooksActivity::loadRecentBooks() {
for (const auto& book : books) { for (const auto& book : books) {
// Skip if file no longer exists // Skip if file no longer exists
if (!SdMan.exists(book.path.c_str())) { if (!Storage.exists(book.path.c_str())) {
continue; continue;
} }
recentBooks.push_back(book); recentBooks.push_back(book);
@@ -70,18 +69,11 @@ void RecentBooksActivity::onExit() {
} }
void RecentBooksActivity::loop() { void RecentBooksActivity::loop() {
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) { if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
Serial.printf("Selected recent book: %s\n", recentBooks[selectorIndex].path.c_str()); LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path); onSelectBook(recentBooks[selectorIndex].path);
return; return;
} }
@@ -92,21 +84,26 @@ void RecentBooksActivity::loop() {
} }
int listSize = static_cast<int>(recentBooks.size()); int listSize = static_cast<int>(recentBooks.size());
if (upReleased) {
if (skipPage) { buttonNavigator.onNextRelease([this, listSize] {
selectorIndex = std::max(static_cast<int>((selectorIndex / pageItems - 1) * pageItems), 0); selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
updateRequired = true; updateRequired = true;
} else if (downReleased) { });
if (skipPage) {
selectorIndex = std::min(static_cast<int>((selectorIndex / pageItems + 1) * pageItems), listSize - 1); buttonNavigator.onPreviousRelease([this, listSize] {
} else { selectorIndex = ButtonNavigator::previousIndex(static_cast<int>(selectorIndex), listSize);
selectorIndex = (selectorIndex + 1) % listSize;
}
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onNextContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, listSize, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
updateRequired = true;
});
} }
void RecentBooksActivity::displayTaskLoop() { void RecentBooksActivity::displayTaskLoop() {

View File

@@ -9,11 +9,13 @@
#include "../Activity.h" #include "../Activity.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "util/ButtonNavigator.h"
class RecentBooksActivity final : public Activity { class RecentBooksActivity final : public Activity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
size_t selectorIndex = 0; size_t selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;

View File

@@ -96,7 +96,7 @@ void CalibreConnectActivity::startWebServer() {
if (MDNS.begin(HOSTNAME)) { if (MDNS.begin(HOSTNAME)) {
// mDNS is optional for the Calibre plugin but still helpful for users. // mDNS is optional for the Calibre plugin but still helpful for users.
Serial.printf("[%lu] [CAL] mDNS started: http://%s.local/\n", millis(), HOSTNAME); LOG_DBG("CAL", "mDNS started: http://%s.local/", HOSTNAME);
} }
webServer.reset(new CrossPointWebServer()); webServer.reset(new CrossPointWebServer());
@@ -131,7 +131,7 @@ void CalibreConnectActivity::loop() {
if (webServer && webServer->isRunning()) { if (webServer && webServer->isRunning()) {
const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [CAL] WARNING: %lu ms gap since last handleClient\n", millis(), timeSinceLastHandleClient); LOG_DBG("CAL", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
} }
esp_task_wdt_reset(); esp_task_wdt_reset();

View File

@@ -37,7 +37,7 @@ void CrossPointWebServerActivity::taskTrampoline(void* param) {
void CrossPointWebServerActivity::onEnter() { void CrossPointWebServerActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap at onEnter: %d bytes", ESP.getFreeHeap());
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
@@ -58,7 +58,7 @@ void CrossPointWebServerActivity::onEnter() {
); );
// Launch network mode selection subactivity // Launch network mode selection subactivity
Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis()); LOG_DBG("WEBACT", "Launching NetworkModeSelectionActivity...");
enterNewActivity(new NetworkModeSelectionActivity( enterNewActivity(new NetworkModeSelectionActivity(
renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); } // Cancel goes back to home [this]() { onGoBack(); } // Cancel goes back to home
@@ -68,7 +68,7 @@ void CrossPointWebServerActivity::onEnter() {
void CrossPointWebServerActivity::onExit() { void CrossPointWebServerActivity::onExit() {
ActivityWithSubactivity::onExit(); ActivityWithSubactivity::onExit();
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap at onExit start: %d bytes", ESP.getFreeHeap());
state = WebServerActivityState::SHUTTING_DOWN; state = WebServerActivityState::SHUTTING_DOWN;
@@ -80,7 +80,7 @@ void CrossPointWebServerActivity::onExit() {
// Stop DNS server if running (AP mode) // Stop DNS server if running (AP mode)
if (dnsServer) { if (dnsServer) {
Serial.printf("[%lu] [WEBACT] Stopping DNS server...\n", millis()); LOG_DBG("WEBACT", "Stopping DNS server...");
dnsServer->stop(); dnsServer->stop();
delete dnsServer; delete dnsServer;
dnsServer = nullptr; dnsServer = nullptr;
@@ -91,39 +91,39 @@ void CrossPointWebServerActivity::onExit() {
// Disconnect WiFi gracefully // Disconnect WiFi gracefully
if (isApMode) { if (isApMode) {
Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis()); LOG_DBG("WEBACT", "Stopping WiFi AP...");
WiFi.softAPdisconnect(true); WiFi.softAPdisconnect(true);
} else { } else {
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); LOG_DBG("WEBACT", "Disconnecting WiFi (graceful)...");
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
} }
delay(30); // Allow disconnect frame to be sent delay(30); // Allow disconnect frame to be sent
Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); LOG_DBG("WEBACT", "Setting WiFi mode OFF...");
WiFi.mode(WIFI_OFF); WiFi.mode(WIFI_OFF);
delay(30); // Allow WiFi hardware to power down delay(30); // Allow WiFi hardware to power down
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap after WiFi disconnect: %d bytes", ESP.getFreeHeap());
// Acquire mutex before deleting task // Acquire mutex before deleting task
Serial.printf("[%lu] [WEBACT] Acquiring rendering mutex before task deletion...\n", millis()); LOG_DBG("WEBACT", "Acquiring rendering mutex before task deletion...");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task // Delete the display task
Serial.printf("[%lu] [WEBACT] Deleting display task...\n", millis()); LOG_DBG("WEBACT", "Deleting display task...");
if (displayTaskHandle) { if (displayTaskHandle) {
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr; displayTaskHandle = nullptr;
Serial.printf("[%lu] [WEBACT] Display task deleted\n", millis()); LOG_DBG("WEBACT", "Display task deleted");
} }
// Delete the mutex // Delete the mutex
Serial.printf("[%lu] [WEBACT] Deleting mutex...\n", millis()); LOG_DBG("WEBACT", "Deleting mutex...");
vSemaphoreDelete(renderingMutex); vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr; renderingMutex = nullptr;
Serial.printf("[%lu] [WEBACT] Mutex deleted\n", millis()); LOG_DBG("WEBACT", "Mutex deleted");
Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap at onExit end: %d bytes", ESP.getFreeHeap());
} }
void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) {
@@ -133,7 +133,7 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
} else if (mode == NetworkMode::CREATE_HOTSPOT) { } else if (mode == NetworkMode::CREATE_HOTSPOT) {
modeName = "Create Hotspot"; modeName = "Create Hotspot";
} }
Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), modeName); LOG_DBG("WEBACT", "Network mode selected: %s", modeName);
networkMode = mode; networkMode = mode;
isApMode = (mode == NetworkMode::CREATE_HOTSPOT); isApMode = (mode == NetworkMode::CREATE_HOTSPOT);
@@ -155,11 +155,11 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
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()); LOG_DBG("WEBACT", "Turning on WiFi (STA mode)...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
state = WebServerActivityState::WIFI_SELECTION; state = WebServerActivityState::WIFI_SELECTION;
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); LOG_DBG("WEBACT", "Launching WifiSelectionActivity...");
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); })); [this](const bool connected) { onWifiSelectionComplete(connected); }));
} else { } else {
@@ -171,7 +171,7 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
} }
void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) {
Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); LOG_DBG("WEBACT", "WifiSelectionActivity completed, connected=%d", connected);
if (connected) { if (connected) {
// Get connection info before exiting subactivity // Get connection info before exiting subactivity
@@ -183,7 +183,7 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
// Start mDNS for hostname resolution // Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) { if (MDNS.begin(AP_HOSTNAME)) {
Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); LOG_DBG("WEBACT", "mDNS started: http://%s.local/", AP_HOSTNAME);
} }
// Start the web server // Start the web server
@@ -199,8 +199,8 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
} }
void CrossPointWebServerActivity::startAccessPoint() { void CrossPointWebServerActivity::startAccessPoint() {
Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis()); LOG_DBG("WEBACT", "Starting Access Point mode...");
Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap before AP start: %d bytes", ESP.getFreeHeap());
// Configure and start the AP // Configure and start the AP
WiFi.mode(WIFI_AP); WiFi.mode(WIFI_AP);
@@ -216,7 +216,7 @@ void CrossPointWebServerActivity::startAccessPoint() {
} }
if (!apStarted) { if (!apStarted) {
Serial.printf("[%lu] [WEBACT] ERROR: Failed to start Access Point!\n", millis()); LOG_ERR("WEBACT", "ERROR: Failed to start Access Point!");
onGoBack(); onGoBack();
return; return;
} }
@@ -230,15 +230,15 @@ void CrossPointWebServerActivity::startAccessPoint() {
connectedIP = ipStr; connectedIP = ipStr;
connectedSSID = AP_SSID; connectedSSID = AP_SSID;
Serial.printf("[%lu] [WEBACT] Access Point started!\n", millis()); LOG_DBG("WEBACT", "Access Point started!");
Serial.printf("[%lu] [WEBACT] SSID: %s\n", millis(), AP_SSID); LOG_DBG("WEBACT", "SSID: %s", AP_SSID);
Serial.printf("[%lu] [WEBACT] IP: %s\n", millis(), connectedIP.c_str()); LOG_DBG("WEBACT", "IP: %s", connectedIP.c_str());
// Start mDNS for hostname resolution // Start mDNS for hostname resolution
if (MDNS.begin(AP_HOSTNAME)) { if (MDNS.begin(AP_HOSTNAME)) {
Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); LOG_DBG("WEBACT", "mDNS started: http://%s.local/", AP_HOSTNAME);
} else { } else {
Serial.printf("[%lu] [WEBACT] WARNING: mDNS failed to start\n", millis()); LOG_DBG("WEBACT", "WARNING: mDNS failed to start");
} }
// Start DNS server for captive portal behavior // Start DNS server for captive portal behavior
@@ -246,16 +246,16 @@ void CrossPointWebServerActivity::startAccessPoint() {
dnsServer = new DNSServer(); dnsServer = new DNSServer();
dnsServer->setErrorReplyCode(DNSReplyCode::NoError); dnsServer->setErrorReplyCode(DNSReplyCode::NoError);
dnsServer->start(DNS_PORT, "*", apIP); dnsServer->start(DNS_PORT, "*", apIP);
Serial.printf("[%lu] [WEBACT] DNS server started for captive portal\n", millis()); LOG_DBG("WEBACT", "DNS server started for captive portal");
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEBACT] [MEM", "Free heap after AP start: %d bytes", ESP.getFreeHeap());
// Start the web server // Start the web server
startWebServer(); startWebServer();
} }
void CrossPointWebServerActivity::startWebServer() { void CrossPointWebServerActivity::startWebServer() {
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis()); LOG_DBG("WEBACT", "Starting web server...");
// Create the web server instance // Create the web server instance
webServer.reset(new CrossPointWebServer()); webServer.reset(new CrossPointWebServer());
@@ -263,16 +263,16 @@ void CrossPointWebServerActivity::startWebServer() {
if (webServer->isRunning()) { if (webServer->isRunning()) {
state = WebServerActivityState::SERVER_RUNNING; state = WebServerActivityState::SERVER_RUNNING;
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis()); LOG_DBG("WEBACT", "Web server started successfully");
// Force an immediate render since we're transitioning from a subactivity // Force an immediate render since we're transitioning from a subactivity
// that had its own rendering task. We need to make sure our display is shown. // that had its own rendering task. We need to make sure our display is shown.
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
render(); render();
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
Serial.printf("[%lu] [WEBACT] Rendered File Transfer screen\n", millis()); LOG_DBG("WEBACT", "Rendered File Transfer screen");
} else { } else {
Serial.printf("[%lu] [WEBACT] ERROR: Failed to start web server!\n", millis()); LOG_ERR("WEBACT", "ERROR: Failed to start web server!");
webServer.reset(); webServer.reset();
// Go back on error // Go back on error
onGoBack(); onGoBack();
@@ -281,9 +281,9 @@ void CrossPointWebServerActivity::startWebServer() {
void CrossPointWebServerActivity::stopWebServer() { void CrossPointWebServerActivity::stopWebServer() {
if (webServer && webServer->isRunning()) { if (webServer && webServer->isRunning()) {
Serial.printf("[%lu] [WEBACT] Stopping web server...\n", millis()); LOG_DBG("WEBACT", "Stopping web server...");
webServer->stop(); webServer->stop();
Serial.printf("[%lu] [WEBACT] Web server stopped\n", millis()); LOG_DBG("WEBACT", "Web server stopped");
} }
webServer.reset(); webServer.reset();
} }
@@ -309,7 +309,7 @@ void CrossPointWebServerActivity::loop() {
lastWifiCheck = millis(); lastWifiCheck = millis();
const wl_status_t wifiStatus = WiFi.status(); const wl_status_t wifiStatus = WiFi.status();
if (wifiStatus != WL_CONNECTED) { if (wifiStatus != WL_CONNECTED) {
Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus); LOG_DBG("WEBACT", "WiFi disconnected! Status: %d", wifiStatus);
// Show error and exit gracefully // Show error and exit gracefully
state = WebServerActivityState::SHUTTING_DOWN; state = WebServerActivityState::SHUTTING_DOWN;
updateRequired = true; updateRequired = true;
@@ -318,7 +318,7 @@ void CrossPointWebServerActivity::loop() {
// Log weak signal warnings // Log weak signal warnings
const int rssi = WiFi.RSSI(); const int rssi = WiFi.RSSI();
if (rssi < -75) { if (rssi < -75) {
Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi); LOG_DBG("WEBACT", "Warning: Weak WiFi signal: %d dBm", rssi);
} }
} }
} }
@@ -329,8 +329,7 @@ void CrossPointWebServerActivity::loop() {
// Log if there's a significant gap between handleClient calls (>100ms) // Log if there's a significant gap between handleClient calls (>100ms)
if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) {
Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(), LOG_DBG("WEBACT", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
timeSinceLastHandleClient);
} }
// Reset watchdog BEFORE processing - HTTP header parsing can be slow // Reset watchdog BEFORE processing - HTTP header parsing can be slow
@@ -348,6 +347,9 @@ void CrossPointWebServerActivity::loop() {
// Yield and check for exit button every 64 iterations // Yield and check for exit button every 64 iterations
if ((i & 0x3F) == 0x3F) { if ((i & 0x3F) == 0x3F) {
yield(); yield();
// Force trigger an update of which buttons are being pressed so be have accurate state
// for back button checking
mappedInput.update();
// Check for exit button inside loop for responsiveness // Check for exit button inside loop for responsiveness
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onGoBack(); onGoBack();
@@ -398,7 +400,7 @@ void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std
// The structure to manage the QR code // The structure to manage the QR code
QRCode qrcode; QRCode qrcode;
uint8_t qrcodeBytes[qrcode_getBufferSize(4)]; uint8_t qrcodeBytes[qrcode_getBufferSize(4)];
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str()); LOG_DBG("WEBACT", "QR Code (%lu): %s", data.length(), data.c_str());
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str()); qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
const uint8_t px = 6; // pixels per module const uint8_t px = 6; // pixels per module

View File

@@ -73,18 +73,15 @@ void NetworkModeSelectionActivity::loop() {
} }
// Handle navigation // Handle navigation
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) || buttonNavigator.onNext([this] {
mappedInput.wasPressed(MappedInputManager::Button::Left); selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) || updateRequired = true;
mappedInput.wasPressed(MappedInputManager::Button::Right); });
if (prevPressed) { buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT; selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEM_COUNT);
updateRequired = true; updateRequired = true;
} else if (nextPressed) { });
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
updateRequired = true;
}
} }
void NetworkModeSelectionActivity::displayTaskLoop() { void NetworkModeSelectionActivity::displayTaskLoop() {

View File

@@ -6,6 +6,7 @@
#include <functional> #include <functional>
#include "../Activity.h" #include "../Activity.h"
#include "util/ButtonNavigator.h"
// Enum for network mode selection // Enum for network mode selection
enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT }; enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
@@ -22,6 +23,8 @@ enum class NetworkMode { JOIN_NETWORK, CONNECT_CALIBRE, CREATE_HOTSPOT };
class NetworkModeSelectionActivity final : public Activity { class NetworkModeSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int selectedIndex = 0; int selectedIndex = 0;
bool updateRequired = false; bool updateRequired = false;
const std::function<void(NetworkMode)> onModeSelected; const std::function<void(NetworkMode)> onModeSelected;

View File

@@ -1,6 +1,7 @@
#include "WifiSelectionActivity.h" #include "WifiSelectionActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <map> #include <map>
@@ -21,7 +22,8 @@ void WifiSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
// Load saved WiFi credentials - SD card operations need lock as we use SPI for both // Load saved WiFi credentials - SD card operations need lock as we use SPI
// for both
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.loadFromFile(); WIFI_STORE.loadFromFile();
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -37,6 +39,7 @@ void WifiSelectionActivity::onEnter() {
usedSavedPassword = false; usedSavedPassword = false;
savePromptSelection = 0; savePromptSelection = 0;
forgetPromptSelection = 0; forgetPromptSelection = 0;
autoConnecting = false;
// Cache MAC address for display // Cache MAC address for display
uint8_t mac[6]; uint8_t mac[6];
@@ -46,9 +49,7 @@ void WifiSelectionActivity::onEnter() {
mac[5]); mac[5]);
cachedMacAddress = std::string(macStr); cachedMacAddress = std::string(macStr);
// Trigger first update to show scanning message // Task creation
updateRequired = true;
xTaskCreate(&WifiSelectionActivity::taskTrampoline, "WifiSelectionTask", xTaskCreate(&WifiSelectionActivity::taskTrampoline, "WifiSelectionTask",
4096, // Stack size (larger for WiFi operations) 4096, // Stack size (larger for WiFi operations)
this, // Parameters this, // Parameters
@@ -56,46 +57,68 @@ void WifiSelectionActivity::onEnter() {
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
); );
// Start WiFi scan // Attempt to auto-connect to the last network
if (allowAutoConnect) {
const std::string lastSsid = WIFI_STORE.getLastConnectedSsid();
if (!lastSsid.empty()) {
const auto* cred = WIFI_STORE.findCredential(lastSsid);
if (cred) {
LOG_DBG("WIFI", "Attempting to auto-connect to %s", lastSsid.c_str());
selectedSSID = cred->ssid;
enteredPassword = cred->password;
selectedRequiresPassword = !cred->password.empty();
usedSavedPassword = true;
autoConnecting = true;
attemptConnection();
updateRequired = true;
return;
}
}
}
// Fallback to scanning
startWifiScan(); startWifiScan();
} }
void WifiSelectionActivity::onExit() { void WifiSelectionActivity::onExit() {
Activity::onExit(); Activity::onExit();
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WIFI] [MEM", "Free heap at onExit start: %d bytes", ESP.getFreeHeap());
// Stop any ongoing WiFi scan // Stop any ongoing WiFi scan
Serial.printf("[%lu] [WIFI] Deleting WiFi scan...\n", millis()); LOG_DBG("WIFI", "Deleting WiFi scan...");
WiFi.scanDelete(); WiFi.scanDelete();
Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WIFI] [MEM", "Free heap after scanDelete: %d bytes", ESP.getFreeHeap());
// Note: We do NOT disconnect WiFi here - the parent activity (CrossPointWebServerActivity) // Note: We do NOT disconnect WiFi here - the parent activity
// manages WiFi connection state. We just clean up the scan and task. // (CrossPointWebServerActivity) manages WiFi connection state. We just clean
// up the scan and task.
// Acquire mutex before deleting task to ensure task isn't using it // Acquire mutex before deleting task to ensure task isn't using it
// This prevents hangs/crashes if the task holds the mutex when deleted // This prevents hangs/crashes if the task holds the mutex when deleted
Serial.printf("[%lu] [WIFI] Acquiring rendering mutex before task deletion...\n", millis()); LOG_DBG("WIFI", "Acquiring rendering mutex before task deletion...");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Delete the display task (we now hold the mutex, so task is blocked if it needs it) // Delete the display task (we now hold the mutex, so task is blocked if it
Serial.printf("[%lu] [WIFI] Deleting display task...\n", millis()); // needs it)
LOG_DBG("WIFI", "Deleting display task...");
if (displayTaskHandle) { if (displayTaskHandle) {
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr; displayTaskHandle = nullptr;
Serial.printf("[%lu] [WIFI] Display task deleted\n", millis()); LOG_DBG("WIFI", "Display task deleted");
} }
// Now safe to delete the mutex since we own it // Now safe to delete the mutex since we own it
Serial.printf("[%lu] [WIFI] Deleting mutex...\n", millis()); LOG_DBG("WIFI", "Deleting mutex...");
vSemaphoreDelete(renderingMutex); vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr; renderingMutex = nullptr;
Serial.printf("[%lu] [WIFI] Mutex deleted\n", millis()); LOG_DBG("WIFI", "Mutex deleted");
Serial.printf("[%lu] [WIFI] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WIFI] [MEM", "Free heap at onExit end: %d bytes", ESP.getFreeHeap());
} }
void WifiSelectionActivity::startWifiScan() { void WifiSelectionActivity::startWifiScan() {
autoConnecting = false;
state = WifiSelectionState::SCANNING; state = WifiSelectionState::SCANNING;
networks.clear(); networks.clear();
updateRequired = true; updateRequired = true;
@@ -181,6 +204,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
selectedRequiresPassword = network.isEncrypted; selectedRequiresPassword = network.isEncrypted;
usedSavedPassword = false; usedSavedPassword = false;
enteredPassword.clear(); enteredPassword.clear();
autoConnecting = false;
// Check if we have saved credentials for this network // Check if we have saved credentials for this network
const auto* savedCred = WIFI_STORE.findCredential(selectedSSID); const auto* savedCred = WIFI_STORE.findCredential(selectedSSID);
@@ -188,8 +212,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
// Use saved password - connect directly // Use saved password - connect directly
enteredPassword = savedCred->password; enteredPassword = savedCred->password;
usedSavedPassword = true; usedSavedPassword = true;
Serial.printf("[%lu] [WiFi] Using saved password for %s, length: %zu\n", millis(), selectedSSID.c_str(), LOG_DBG("WiFi", "Using saved password for %s, length: %zu", selectedSSID.c_str(), enteredPassword.size());
enteredPassword.size());
attemptConnection(); attemptConnection();
return; return;
} }
@@ -223,7 +246,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
} }
void WifiSelectionActivity::attemptConnection() { void WifiSelectionActivity::attemptConnection() {
state = WifiSelectionState::CONNECTING; state = autoConnecting ? WifiSelectionState::AUTO_CONNECTING : WifiSelectionState::CONNECTING;
connectionStartTime = millis(); connectionStartTime = millis();
connectedIP.clear(); connectedIP.clear();
connectionError.clear(); connectionError.clear();
@@ -239,7 +262,7 @@ void WifiSelectionActivity::attemptConnection() {
} }
void WifiSelectionActivity::checkConnectionStatus() { void WifiSelectionActivity::checkConnectionStatus() {
if (state != WifiSelectionState::CONNECTING) { if (state != WifiSelectionState::CONNECTING && state != WifiSelectionState::AUTO_CONNECTING) {
return; return;
} }
@@ -251,6 +274,13 @@ void WifiSelectionActivity::checkConnectionStatus() {
char ipStr[16]; char ipStr[16];
snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
connectedIP = ipStr; connectedIP = ipStr;
autoConnecting = false;
// Save this as the last connected network - SD card operations need lock as
// we use SPI for both
xSemaphoreTake(renderingMutex, portMAX_DELAY);
WIFI_STORE.setLastConnectedSsid(selectedSSID);
xSemaphoreGive(renderingMutex);
// If we entered a new password, ask if user wants to save it // If we entered a new password, ask if user wants to save it
// Otherwise, immediately complete so parent can start web server // Otherwise, immediately complete so parent can start web server
@@ -260,7 +290,9 @@ void WifiSelectionActivity::checkConnectionStatus() {
updateRequired = true; updateRequired = true;
} else { } else {
// Using saved password or open network - complete immediately // Using saved password or open network - complete immediately
Serial.printf("[%lu] [WIFI] Connected with saved/open credentials, completing immediately\n", millis()); LOG_DBG("WIFI",
"Connected with saved/open credentials, "
"completing immediately");
onComplete(true); onComplete(true);
} }
return; return;
@@ -299,7 +331,7 @@ void WifiSelectionActivity::loop() {
} }
// Check connection progress // Check connection progress
if (state == WifiSelectionState::CONNECTING) { if (state == WifiSelectionState::CONNECTING || state == WifiSelectionState::AUTO_CONNECTING) {
checkConnectionStatus(); checkConnectionStatus();
return; return;
} }
@@ -368,17 +400,16 @@ void WifiSelectionActivity::loop() {
} }
} }
// Go back to network list (whether Cancel or Forget network was selected) // Go back to network list (whether Cancel or Forget network was selected)
state = WifiSelectionState::NETWORK_LIST; startWifiScan();
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip forgetting, go back to network list // Skip forgetting, go back to network list
state = WifiSelectionState::NETWORK_LIST; startWifiScan();
updateRequired = true;
} }
return; return;
} }
// Handle connected state (should not normally be reached - connection completes immediately) // Handle connected state (should not normally be reached - connection
// completes immediately)
if (state == WifiSelectionState::CONNECTED) { if (state == WifiSelectionState::CONNECTED) {
// Safety fallback - immediately complete // Safety fallback - immediately complete
onComplete(true); onComplete(true);
@@ -389,12 +420,14 @@ void WifiSelectionActivity::loop() {
if (state == WifiSelectionState::CONNECTION_FAILED) { if (state == WifiSelectionState::CONNECTION_FAILED) {
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// If we used saved credentials, offer to forget the network // If we were auto-connecting or using a saved credential, offer to forget
if (usedSavedPassword) { // the network
if (autoConnecting || usedSavedPassword) {
autoConnecting = false;
state = WifiSelectionState::FORGET_PROMPT; state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Cancel" forgetPromptSelection = 0; // Default to "Cancel"
} else { } else {
// Go back to network list on failure // Go back to network list on failure for non-saved credentials
state = WifiSelectionState::NETWORK_LIST; state = WifiSelectionState::NETWORK_LIST;
} }
updateRequired = true; updateRequired = true;
@@ -420,20 +453,33 @@ void WifiSelectionActivity::loop() {
return; return;
} }
// Handle UP/DOWN navigation if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || startWifiScan();
mappedInput.wasPressed(MappedInputManager::Button::Left)) { return;
if (selectedNetworkIndex > 0) { }
selectedNetworkIndex--;
const bool leftPressed = mappedInput.wasPressed(MappedInputManager::Button::Left);
if (leftPressed) {
const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword;
if (hasSavedPassword) {
selectedSSID = networks[selectedNetworkIndex].ssid;
state = WifiSelectionState::FORGET_PROMPT;
forgetPromptSelection = 0; // Default to "Cancel"
updateRequired = true; updateRequired = true;
return;
} }
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || }
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) { // Handle navigation
selectedNetworkIndex++; buttonNavigator.onNext([this] {
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
updateRequired = true; updateRequired = true;
} });
}
buttonNavigator.onPrevious([this] {
selectedNetworkIndex = ButtonNavigator::previousIndex(selectedNetworkIndex, networks.size());
updateRequired = true;
});
} }
} }
@@ -483,6 +529,9 @@ void WifiSelectionActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
switch (state) { switch (state) {
case WifiSelectionState::AUTO_CONNECTING:
renderConnecting();
break;
case WifiSelectionState::SCANNING: case WifiSelectionState::SCANNING:
renderConnecting(); // Reuse connecting screen with different message renderConnecting(); // Reuse connecting screen with different message
break; break;
@@ -586,7 +635,11 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw help text // Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword;
const char* forgetLabel = hasSavedPassword ? "Forget" : "";
const auto labels = mappedInput.mapLabels("« Back", "Connect", forgetLabel, "Refresh");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
@@ -690,8 +743,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2; const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
ssidInfo.replace(25, ssidInfo.length() - 25, "..."); ssidInfo.replace(25, ssidInfo.length() - 25, "...");

View File

@@ -10,6 +10,7 @@
#include <vector> #include <vector>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
// Structure to hold WiFi network information // Structure to hold WiFi network information
struct WifiNetworkInfo { struct WifiNetworkInfo {
@@ -21,6 +22,7 @@ struct WifiNetworkInfo {
// WiFi selection states // WiFi selection states
enum class WifiSelectionState { enum class WifiSelectionState {
AUTO_CONNECTING, // Trying to connect to the last known network
SCANNING, // Scanning for networks SCANNING, // Scanning for networks
NETWORK_LIST, // Displaying available networks NETWORK_LIST, // Displaying available networks
PASSWORD_ENTRY, // Entering password for selected network PASSWORD_ENTRY, // Entering password for selected network
@@ -45,6 +47,7 @@ enum class WifiSelectionState {
class WifiSelectionActivity final : public ActivityWithSubactivity { class WifiSelectionActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
WifiSelectionState state = WifiSelectionState::SCANNING; WifiSelectionState state = WifiSelectionState::SCANNING;
int selectedNetworkIndex = 0; int selectedNetworkIndex = 0;
@@ -68,6 +71,12 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
// Whether network was connected using a saved password (skip save prompt) // Whether network was connected using a saved password (skip save prompt)
bool usedSavedPassword = false; bool usedSavedPassword = false;
// Whether to attempt auto-connect on entry
const bool allowAutoConnect;
// Whether we are attempting to auto-connect
bool autoConnecting = false;
// Save/forget prompt selection (0 = Yes, 1 = No) // Save/forget prompt selection (0 = Yes, 1 = No)
int savePromptSelection = 0; int savePromptSelection = 0;
int forgetPromptSelection = 0; int forgetPromptSelection = 0;
@@ -96,8 +105,10 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
public: public:
explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(bool connected)>& onComplete) const std::function<void(bool connected)>& onComplete, bool autoConnect = true)
: ActivityWithSubactivity("WifiSelection", renderer, mappedInput), onComplete(onComplete) {} : ActivityWithSubactivity("WifiSelection", renderer, mappedInput),
onComplete(onComplete),
allowAutoConnect(autoConnect) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@@ -3,7 +3,8 @@
#include <Epub/Page.h> #include <Epub/Page.h>
#include <FsHelpers.h> #include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Logging.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
@@ -77,14 +78,14 @@ void EpubReaderActivity::onEnter() {
epub->setupCacheDir(); epub->setupCacheDir();
FsFile f; FsFile f;
if (SdMan.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6]; uint8_t data[6];
int dataSize = f.read(data, 6); int dataSize = f.read(data, 6);
if (dataSize == 4 || dataSize == 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; cachedSpineIndex = currentSpineIndex;
Serial.printf("[%lu] [ERS] Loaded cache: %d, %d\n", millis(), currentSpineIndex, nextPageNumber); LOG_DBG("ERS", "Loaded cache: %d, %d", currentSpineIndex, nextPageNumber);
} }
if (dataSize == 6) { if (dataSize == 6) {
cachedChapterTotalPageCount = data[4] + (data[5] << 8); cachedChapterTotalPageCount = data[4] + (data[5] << 8);
@@ -97,8 +98,7 @@ void EpubReaderActivity::onEnter() {
int textSpineIndex = epub->getSpineIndexForTextReference(); int textSpineIndex = epub->getSpineIndexForTextReference();
if (textSpineIndex != 0) { if (textSpineIndex != 0) {
currentSpineIndex = textSpineIndex; currentSpineIndex = textSpineIndex;
Serial.printf("[%lu] [ERS] Opened for first time, navigating to text reference at index %d\n", millis(), LOG_DBG("ERS", "Opened for first time, navigating to text reference at index %d", textSpineIndex);
textSpineIndex);
} }
} }
@@ -204,15 +204,15 @@ void EpubReaderActivity::loop() {
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
} }
// Long press BACK (1s+) goes directly to home // Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome(); onGoBack();
return; return;
} }
// Short press BACK goes to file selection // Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack(); onGoHome();
return; return;
} }
@@ -567,7 +567,7 @@ void EpubReaderActivity::renderScreen() {
if (!section) { if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href; const auto filepath = epub->getSpineItem(currentSpineIndex).href;
Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer)); section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
@@ -576,19 +576,19 @@ void EpubReaderActivity::renderScreen() {
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) { viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); LOG_DBG("ERS", "Cache not found, building...");
const auto popupFn = [this]() { GUI.drawPopup(renderer, "Indexing..."); }; const auto popupFn = [this]() { GUI.drawPopup(renderer, "Indexing..."); };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) { viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, popupFn)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset(); section.reset();
return; return;
} }
} else { } else {
Serial.printf("[%lu] [ERS] Cache found, skipping build...\n", millis()); LOG_DBG("ERS", "Cache found, skipping build...");
} }
if (nextPageNumber == UINT16_MAX) { if (nextPageNumber == UINT16_MAX) {
@@ -622,7 +622,7 @@ void EpubReaderActivity::renderScreen() {
renderer.clearScreen(); renderer.clearScreen();
if (section->pageCount == 0) { if (section->pageCount == 0) {
Serial.printf("[%lu] [ERS] No pages to render\n", millis()); LOG_DBG("ERS", "No pages to render");
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Empty chapter", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
@@ -630,7 +630,7 @@ void EpubReaderActivity::renderScreen() {
} }
if (section->currentPage < 0 || section->currentPage >= section->pageCount) { if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount); LOG_DBG("ERS", "Page out of bounds: %d (max %d)", section->currentPage, section->pageCount);
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
renderer.displayBuffer(); renderer.displayBuffer();
@@ -640,21 +640,21 @@ void EpubReaderActivity::renderScreen() {
{ {
auto p = section->loadPageFromSectionFile(); auto p = section->loadPageFromSectionFile();
if (!p) { if (!p) {
Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis()); LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
section->clearCache(); section->clearCache();
section.reset(); section.reset();
return renderScreen(); return renderScreen();
} }
const auto start = millis(); const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
} }
saveProgress(currentSpineIndex, section->currentPage, section->pageCount); saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
} }
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
FsFile f; FsFile f;
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { if (Storage.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6]; uint8_t data[6];
data[0] = currentSpineIndex & 0xFF; data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF;
@@ -664,9 +664,9 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
data[5] = (pageCount >> 8) & 0xFF; data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6); f.write(data, 6);
f.close(); f.close();
Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage); LOG_DBG("ERS", "Progress saved: Chapter %d, Page %d", spineIndex, currentPage);
} else { } else {
Serial.printf("[ERS] Could not save progress!\n"); LOG_ERR("ERS", "Could not save progress!");
} }
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop, void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,

View File

@@ -6,11 +6,6 @@
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace {
// Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); } int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); }
int EpubReaderChapterSelectionActivity::getPageItems() const { int EpubReaderChapterSelectionActivity::getPageItems() const {
@@ -77,12 +72,6 @@ void EpubReaderChapterSelectionActivity::loop() {
return; return;
} }
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems(); const int pageItems = getPageItems();
const int totalItems = getTotalItems(); const int totalItems = getTotalItems();
@@ -95,21 +84,27 @@ void EpubReaderChapterSelectionActivity::loop() {
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack(); onGoBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems;
} else {
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
} }
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true; updateRequired = true;
} else if (nextReleased) { });
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems; buttonNavigator.onPreviousRelease([this, totalItems] {
} else { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
selectorIndex = (selectorIndex + 1) % totalItems;
}
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
} }
void EpubReaderChapterSelectionActivity::displayTaskLoop() { void EpubReaderChapterSelectionActivity::displayTaskLoop() {

View File

@@ -7,12 +7,14 @@
#include <memory> #include <memory>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity { class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub; std::shared_ptr<Epub> epub;
std::string epubPath; std::string epubPath;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
int currentSpineIndex = 0; int currentSpineIndex = 0;
int currentPage = 0; int currentPage = 0;
int totalPagesInSpine = 0; int totalPagesInSpine = 0;

View File

@@ -48,16 +48,19 @@ void EpubReaderMenuActivity::loop() {
return; return;
} }
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
updateRequired = true;
});
// Use local variables for items we need to check after potential deletion // Use local variables for items we need to check after potential deletion
if (mappedInput.wasReleased(MappedInputManager::Button::Up) || if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto selectedAction = menuItems[selectedIndex].action; const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::ROTATE_SCREEN) { if (selectedAction == MenuAction::ROTATE_SCREEN) {
// Cycle orientation preview locally; actual rotation happens on menu exit. // Cycle orientation preview locally; actual rotation happens on menu exit.

View File

@@ -9,6 +9,7 @@
#include <vector> #include <vector>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity { class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public: public:
@@ -48,6 +49,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
bool updateRequired = false; bool updateRequired = false;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
std::string title = "Reader Menu"; std::string title = "Reader Menu";
uint8_t pendingOrientation = 0; uint8_t pendingOrientation = 0;
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}; const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};

View File

@@ -79,25 +79,11 @@ void EpubReaderPercentSelectionActivity::loop() {
return; return;
} }
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
adjustPercent(-kSmallStep); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustPercent(kLargeStep); });
adjustPercent(kSmallStep); buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustPercent(-kLargeStep); });
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Up)) {
adjustPercent(kLargeStep);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Down)) {
adjustPercent(-kLargeStep);
return;
}
} }
void EpubReaderPercentSelectionActivity::renderScreen() { void EpubReaderPercentSelectionActivity::renderScreen() {

View File

@@ -7,6 +7,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity { class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity {
public: public:
@@ -31,6 +32,7 @@ class EpubReaderPercentSelectionActivity final : public ActivityWithSubactivity
// FreeRTOS task and mutex for rendering. // FreeRTOS task and mutex for rendering.
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
// Callback invoked when the user confirms a percent. // Callback invoked when the user confirms a percent.
const std::function<void(int)> onSelect; const std::function<void(int)> onSelect;

View File

@@ -1,6 +1,7 @@
#include "KOReaderSyncActivity.h" #include "KOReaderSyncActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <esp_sntp.h> #include <esp_sntp.h>
@@ -32,9 +33,9 @@ void syncTimeWithNTP() {
} }
if (retry < maxRetries) { if (retry < maxRetries) {
Serial.printf("[%lu] [KOSync] NTP time synced\n", millis()); LOG_DBG("KOSync", "NTP time synced");
} else { } else {
Serial.printf("[%lu] [KOSync] NTP sync timeout, using fallback\n", millis()); LOG_DBG("KOSync", "NTP sync timeout, using fallback");
} }
} }
} // namespace } // namespace
@@ -48,12 +49,12 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
exitActivity(); exitActivity();
if (!success) { if (!success) {
Serial.printf("[%lu] [KOSync] WiFi connection failed, exiting\n", millis()); LOG_DBG("KOSync", "WiFi connection failed, exiting");
onCancel(); onCancel();
return; return;
} }
Serial.printf("[%lu] [KOSync] WiFi connected, starting sync\n", millis()); LOG_DBG("KOSync", "WiFi connected, starting sync");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = SYNCING; state = SYNCING;
@@ -88,7 +89,7 @@ void KOReaderSyncActivity::performSync() {
return; return;
} }
Serial.printf("[%lu] [KOSync] Document hash: %s\n", millis(), documentHash.c_str()); LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
statusMessage = "Fetching remote progress..."; statusMessage = "Fetching remote progress...";
@@ -188,12 +189,12 @@ void KOReaderSyncActivity::onEnter() {
} }
// Turn on WiFi // Turn on WiFi
Serial.printf("[%lu] [KOSync] Turning on WiFi...\n", millis()); LOG_DBG("KOSync", "Turning on WiFi...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
// Check if already connected // Check if already connected
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
Serial.printf("[%lu] [KOSync] Already connected to WiFi\n", millis()); LOG_DBG("KOSync", "Already connected to WiFi");
state = SYNCING; state = SYNCING;
statusMessage = "Syncing time..."; statusMessage = "Syncing time...";
updateRequired = true; updateRequired = true;
@@ -216,7 +217,7 @@ void KOReaderSyncActivity::onEnter() {
} }
// Launch WiFi selection subactivity // Launch WiFi selection subactivity
Serial.printf("[%lu] [KOSync] Launching WifiSelectionActivity...\n", millis()); LOG_DBG("KOSync", "Launching WifiSelectionActivity...");
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); })); [this](const bool connected) { onWifiSelectionComplete(connected); }));
} }
@@ -317,7 +318,6 @@ void KOReaderSyncActivity::render() {
localProgress.percentage * 100); localProgress.percentage * 100);
renderer.drawText(UI_10_FONT_ID, 20, 320, localPageStr); renderer.drawText(UI_10_FONT_ID, 20, 320, localPageStr);
// Options
const int optionY = 350; const int optionY = 350;
const int optionHeight = 30; const int optionHeight = 30;
@@ -333,13 +333,8 @@ void KOReaderSyncActivity::render() {
} }
renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight, "Upload local progress", selectedOption != 1); renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight, "Upload local progress", selectedOption != 1);
// Cancel option // Bottom button hints: show Back and Select
if (selectedOption == 2) { const auto labels = mappedInput.mapLabels("Back", "Select", "", "");
renderer.fillRect(0, optionY + optionHeight * 2 - 2, pageWidth - 1, optionHeight);
}
renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight * 2, "Cancel", selectedOption != 2);
const auto labels = mappedInput.mapLabels("", "Select", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
@@ -349,7 +344,7 @@ void KOReaderSyncActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, 280, "No remote progress found", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 280, "No remote progress found", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?"); renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?");
const auto labels = mappedInput.mapLabels("Cancel", "Upload", "", ""); const auto labels = mappedInput.mapLabels("Back", "Upload", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
@@ -392,11 +387,11 @@ void KOReaderSyncActivity::loop() {
// Navigate options // Navigate options
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) { mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedOption = (selectedOption + 2) % 3; // Wrap around selectedOption = (selectedOption + 1) % 2; // Wrap around among 2 options
updateRequired = true; updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) { mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedOption = (selectedOption + 1) % 3; selectedOption = (selectedOption + 1) % 2; // Wrap around among 2 options
updateRequired = true; updateRequired = true;
} }
@@ -407,9 +402,6 @@ void KOReaderSyncActivity::loop() {
} else if (selectedOption == 1) { } else if (selectedOption == 1) {
// Upload local progress // Upload local progress
performUpload(); performUpload();
} else {
// Cancel
onCancel();
} }
} }

View File

@@ -18,7 +18,7 @@
* 1. Connect to WiFi (if not connected) * 1. Connect to WiFi (if not connected)
* 2. Calculate document hash * 2. Calculate document hash
* 3. Fetch remote progress * 3. Fetch remote progress
* 4. Show comparison and options (Apply/Upload/Cancel) * 4. Show comparison and options (Apply/Upload)
* 5. Apply or upload progress * 5. Apply or upload progress
*/ */
class KOReaderSyncActivity final : public ActivityWithSubactivity { class KOReaderSyncActivity final : public ActivityWithSubactivity {
@@ -82,7 +82,7 @@ class KOReaderSyncActivity final : public ActivityWithSubactivity {
// Local progress as KOReader format (for display) // Local progress as KOReader format (for display)
KOReaderPosition localProgress; KOReaderPosition localProgress;
// Selection in result screen (0=Apply, 1=Upload, 2=Cancel) // Selection in result screen (0=Apply, 1=Upload)
int selectedOption = 0; int selectedOption = 0;
OnCancelCallback onCancel; OnCancelCallback onCancel;

View File

@@ -1,5 +1,7 @@
#include "ReaderActivity.h" #include "ReaderActivity.h"
#include <HalStorage.h>
#include "Epub.h" #include "Epub.h"
#include "EpubReaderActivity.h" #include "EpubReaderActivity.h"
#include "Txt.h" #include "Txt.h"
@@ -27,8 +29,8 @@ bool ReaderActivity::isTxtFile(const std::string& path) {
} }
std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) { std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
if (!SdMan.exists(path.c_str())) { if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); LOG_ERR("READER", "File does not exist: %s", path.c_str());
return nullptr; return nullptr;
} }
@@ -37,13 +39,13 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
return epub; return epub;
} }
Serial.printf("[%lu] [ ] Failed to load epub\n", millis()); LOG_ERR("READER", "Failed to load epub");
return nullptr; return nullptr;
} }
std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) { std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
if (!SdMan.exists(path.c_str())) { if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); LOG_ERR("READER", "File does not exist: %s", path.c_str());
return nullptr; return nullptr;
} }
@@ -52,13 +54,13 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
return xtc; return xtc;
} }
Serial.printf("[%lu] [ ] Failed to load XTC\n", millis()); LOG_ERR("READER", "Failed to load XTC");
return nullptr; return nullptr;
} }
std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) { std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
if (!SdMan.exists(path.c_str())) { if (!Storage.exists(path.c_str())) {
Serial.printf("[%lu] [ ] File does not exist: %s\n", millis(), path.c_str()); LOG_ERR("READER", "File does not exist: %s", path.c_str());
return nullptr; return nullptr;
} }
@@ -67,7 +69,7 @@ std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
return txt; return txt;
} }
Serial.printf("[%lu] [ ] Failed to load TXT\n", millis()); LOG_ERR("READER", "Failed to load TXT");
return nullptr; return nullptr;
} }

View File

@@ -1,7 +1,7 @@
#include "TxtReaderActivity.h" #include "TxtReaderActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Serialization.h> #include <Serialization.h>
#include <Utf8.h> #include <Utf8.h>
@@ -102,15 +102,15 @@ void TxtReaderActivity::loop() {
return; return;
} }
// Long press BACK (1s+) goes directly to home // Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome(); onGoBack();
return; return;
} }
// Short press BACK goes to file selection // Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack(); onGoHome();
return; return;
} }
@@ -191,8 +191,7 @@ void TxtReaderActivity::initializeReader() {
linesPerPage = viewportHeight / lineHeight; linesPerPage = viewportHeight / lineHeight;
if (linesPerPage < 1) linesPerPage = 1; if (linesPerPage < 1) linesPerPage = 1;
Serial.printf("[%lu] [TRS] Viewport: %dx%d, lines per page: %d\n", millis(), viewportWidth, viewportHeight, LOG_DBG("TRS", "Viewport: %dx%d, lines per page: %d", viewportWidth, viewportHeight, linesPerPage);
linesPerPage);
// Try to load cached page index first // Try to load cached page index first
if (!loadPageIndexCache()) { if (!loadPageIndexCache()) {
@@ -215,7 +214,7 @@ void TxtReaderActivity::buildPageIndex() {
size_t offset = 0; size_t offset = 0;
const size_t fileSize = txt->getFileSize(); const size_t fileSize = txt->getFileSize();
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); LOG_DBG("TRS", "Building page index for %zu bytes...", fileSize);
GUI.drawPopup(renderer, "Indexing..."); GUI.drawPopup(renderer, "Indexing...");
@@ -244,7 +243,7 @@ void TxtReaderActivity::buildPageIndex() {
} }
totalPages = pageOffsets.size(); totalPages = pageOffsets.size();
Serial.printf("[%lu] [TRS] Built page index: %d pages\n", millis(), totalPages); LOG_DBG("TRS", "Built page index: %d pages", totalPages);
} }
bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset) { bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>& outLines, size_t& nextOffset) {
@@ -259,7 +258,7 @@ bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector<std::string>
size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset); size_t chunkSize = std::min(CHUNK_SIZE, fileSize - offset);
auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1)); auto* buffer = static_cast<uint8_t*>(malloc(chunkSize + 1));
if (!buffer) { if (!buffer) {
Serial.printf("[%lu] [TRS] Failed to allocate %zu bytes\n", millis(), chunkSize); LOG_ERR("TRS", "Failed to allocate %zu bytes", chunkSize);
return false; return false;
} }
@@ -565,7 +564,7 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
void TxtReaderActivity::saveProgress() const { void TxtReaderActivity::saveProgress() const {
FsFile f; FsFile f;
if (SdMan.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) { if (Storage.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
uint8_t data[4]; uint8_t data[4];
data[0] = currentPage & 0xFF; data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF; data[1] = (currentPage >> 8) & 0xFF;
@@ -578,7 +577,7 @@ void TxtReaderActivity::saveProgress() const {
void TxtReaderActivity::loadProgress() { void TxtReaderActivity::loadProgress() {
FsFile f; FsFile f;
if (SdMan.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) { if (Storage.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
uint8_t data[4]; uint8_t data[4];
if (f.read(data, 4) == 4) { if (f.read(data, 4) == 4) {
currentPage = data[0] + (data[1] << 8); currentPage = data[0] + (data[1] << 8);
@@ -588,7 +587,7 @@ void TxtReaderActivity::loadProgress() {
if (currentPage < 0) { if (currentPage < 0) {
currentPage = 0; currentPage = 0;
} }
Serial.printf("[%lu] [TRS] Loaded progress: page %d/%d\n", millis(), currentPage, totalPages); LOG_DBG("TRS", "Loaded progress: page %d/%d", currentPage, totalPages);
} }
f.close(); f.close();
} }
@@ -609,8 +608,8 @@ bool TxtReaderActivity::loadPageIndexCache() {
std::string cachePath = txt->getCachePath() + "/index.bin"; std::string cachePath = txt->getCachePath() + "/index.bin";
FsFile f; FsFile f;
if (!SdMan.openFileForRead("TRS", cachePath, f)) { if (!Storage.openFileForRead("TRS", cachePath, f)) {
Serial.printf("[%lu] [TRS] No page index cache found\n", millis()); LOG_DBG("TRS", "No page index cache found");
return false; return false;
} }
@@ -618,7 +617,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint32_t magic; uint32_t magic;
serialization::readPod(f, magic); serialization::readPod(f, magic);
if (magic != CACHE_MAGIC) { if (magic != CACHE_MAGIC) {
Serial.printf("[%lu] [TRS] Cache magic mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache magic mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -626,7 +625,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint8_t version; uint8_t version;
serialization::readPod(f, version); serialization::readPod(f, version);
if (version != CACHE_VERSION) { if (version != CACHE_VERSION) {
Serial.printf("[%lu] [TRS] Cache version mismatch (%d != %d), rebuilding\n", millis(), version, CACHE_VERSION); LOG_DBG("TRS", "Cache version mismatch (%d != %d), rebuilding", version, CACHE_VERSION);
f.close(); f.close();
return false; return false;
} }
@@ -634,7 +633,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint32_t fileSize; uint32_t fileSize;
serialization::readPod(f, fileSize); serialization::readPod(f, fileSize);
if (fileSize != txt->getFileSize()) { if (fileSize != txt->getFileSize()) {
Serial.printf("[%lu] [TRS] Cache file size mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache file size mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -642,7 +641,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
int32_t cachedWidth; int32_t cachedWidth;
serialization::readPod(f, cachedWidth); serialization::readPod(f, cachedWidth);
if (cachedWidth != viewportWidth) { if (cachedWidth != viewportWidth) {
Serial.printf("[%lu] [TRS] Cache viewport width mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache viewport width mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -650,7 +649,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
int32_t cachedLines; int32_t cachedLines;
serialization::readPod(f, cachedLines); serialization::readPod(f, cachedLines);
if (cachedLines != linesPerPage) { if (cachedLines != linesPerPage) {
Serial.printf("[%lu] [TRS] Cache lines per page mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache lines per page mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -658,7 +657,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
int32_t fontId; int32_t fontId;
serialization::readPod(f, fontId); serialization::readPod(f, fontId);
if (fontId != cachedFontId) { if (fontId != cachedFontId) {
Serial.printf("[%lu] [TRS] Cache font ID mismatch (%d != %d), rebuilding\n", millis(), fontId, cachedFontId); LOG_DBG("TRS", "Cache font ID mismatch (%d != %d), rebuilding", fontId, cachedFontId);
f.close(); f.close();
return false; return false;
} }
@@ -666,7 +665,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
int32_t margin; int32_t margin;
serialization::readPod(f, margin); serialization::readPod(f, margin);
if (margin != cachedScreenMargin) { if (margin != cachedScreenMargin) {
Serial.printf("[%lu] [TRS] Cache screen margin mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache screen margin mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -674,7 +673,7 @@ bool TxtReaderActivity::loadPageIndexCache() {
uint8_t alignment; uint8_t alignment;
serialization::readPod(f, alignment); serialization::readPod(f, alignment);
if (alignment != cachedParagraphAlignment) { if (alignment != cachedParagraphAlignment) {
Serial.printf("[%lu] [TRS] Cache paragraph alignment mismatch, rebuilding\n", millis()); LOG_DBG("TRS", "Cache paragraph alignment mismatch, rebuilding");
f.close(); f.close();
return false; return false;
} }
@@ -694,15 +693,15 @@ bool TxtReaderActivity::loadPageIndexCache() {
f.close(); f.close();
totalPages = pageOffsets.size(); totalPages = pageOffsets.size();
Serial.printf("[%lu] [TRS] Loaded page index cache: %d pages\n", millis(), totalPages); LOG_DBG("TRS", "Loaded page index cache: %d pages", totalPages);
return true; return true;
} }
void TxtReaderActivity::savePageIndexCache() const { void TxtReaderActivity::savePageIndexCache() const {
std::string cachePath = txt->getCachePath() + "/index.bin"; std::string cachePath = txt->getCachePath() + "/index.bin";
FsFile f; FsFile f;
if (!SdMan.openFileForWrite("TRS", cachePath, f)) { if (!Storage.openFileForWrite("TRS", cachePath, f)) {
Serial.printf("[%lu] [TRS] Failed to save page index cache\n", millis()); LOG_ERR("TRS", "Failed to save page index cache");
return; return;
} }
@@ -723,5 +722,5 @@ void TxtReaderActivity::savePageIndexCache() const {
} }
f.close(); f.close();
Serial.printf("[%lu] [TRS] Saved page index cache: %d pages\n", millis(), totalPages); LOG_DBG("TRS", "Saved page index cache: %d pages", totalPages);
} }

View File

@@ -9,7 +9,7 @@
#include <FsHelpers.h> #include <FsHelpers.h>
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
@@ -102,15 +102,15 @@ void XtcReaderActivity::loop() {
} }
} }
// Long press BACK (1s+) goes directly to home // Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) { if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome(); onGoBack();
return; return;
} }
// Short press BACK goes to file selection // Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) { if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack(); onGoHome();
return; return;
} }
@@ -206,7 +206,7 @@ void XtcReaderActivity::renderPage() {
// Allocate page buffer // Allocate page buffer
uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize)); uint8_t* pageBuffer = static_cast<uint8_t*>(malloc(pageBufferSize));
if (!pageBuffer) { if (!pageBuffer) {
Serial.printf("[%lu] [XTR] Failed to allocate page buffer (%lu bytes)\n", millis(), pageBufferSize); LOG_ERR("XTR", "Failed to allocate page buffer (%lu bytes)", pageBufferSize);
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Memory error", true, EpdFontFamily::BOLD);
renderer.displayBuffer(); renderer.displayBuffer();
@@ -216,7 +216,7 @@ void XtcReaderActivity::renderPage() {
// Load page data // Load page data
size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize); size_t bytesRead = xtc->loadPage(currentPage, pageBuffer, pageBufferSize);
if (bytesRead == 0) { if (bytesRead == 0) {
Serial.printf("[%lu] [XTR] Failed to load page %lu\n", millis(), currentPage); LOG_ERR("XTR", "Failed to load page %lu", currentPage);
free(pageBuffer); free(pageBuffer);
renderer.clearScreen(); renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, 300, "Page load error", true, EpdFontFamily::BOLD);
@@ -265,8 +265,8 @@ void XtcReaderActivity::renderPage() {
pixelCounts[getPixelValue(x, y)]++; pixelCounts[getPixelValue(x, y)]++;
} }
} }
Serial.printf("[%lu] [XTR] Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu\n", millis(), LOG_DBG("XTR", "Pixel distribution: White=%lu, DarkGrey=%lu, LightGrey=%lu, Black=%lu", pixelCounts[0],
pixelCounts[0], pixelCounts[1], pixelCounts[2], pixelCounts[3]); pixelCounts[1], pixelCounts[2], pixelCounts[3]);
// Pass 1: BW buffer - draw all non-white pixels as black // Pass 1: BW buffer - draw all non-white pixels as black
for (uint16_t y = 0; y < pageHeight; y++) { for (uint16_t y = 0; y < pageHeight; y++) {
@@ -329,8 +329,7 @@ void XtcReaderActivity::renderPage() {
free(pageBuffer); free(pageBuffer);
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (2-bit grayscale)\n", millis(), currentPage + 1, LOG_DBG("XTR", "Rendered page %lu/%lu (2-bit grayscale)", currentPage + 1, xtc->getPageCount());
xtc->getPageCount());
return; return;
} else { } else {
// 1-bit mode: 8 pixels per byte, MSB first // 1-bit mode: 8 pixels per byte, MSB first
@@ -366,13 +365,12 @@ void XtcReaderActivity::renderPage() {
pagesUntilFullRefresh--; pagesUntilFullRefresh--;
} }
Serial.printf("[%lu] [XTR] Rendered page %lu/%lu (%u-bit)\n", millis(), currentPage + 1, xtc->getPageCount(), LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth);
bitDepth);
} }
void XtcReaderActivity::saveProgress() const { void XtcReaderActivity::saveProgress() const {
FsFile f; FsFile f;
if (SdMan.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) { if (Storage.openFileForWrite("XTR", xtc->getCachePath() + "/progress.bin", f)) {
uint8_t data[4]; uint8_t data[4];
data[0] = currentPage & 0xFF; data[0] = currentPage & 0xFF;
data[1] = (currentPage >> 8) & 0xFF; data[1] = (currentPage >> 8) & 0xFF;
@@ -385,11 +383,11 @@ void XtcReaderActivity::saveProgress() const {
void XtcReaderActivity::loadProgress() { void XtcReaderActivity::loadProgress() {
FsFile f; FsFile f;
if (SdMan.openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) { if (Storage.openFileForRead("XTR", xtc->getCachePath() + "/progress.bin", f)) {
uint8_t data[4]; uint8_t data[4];
if (f.read(data, 4) == 4) { if (f.read(data, 4) == 4) {
currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
Serial.printf("[%lu] [XTR] Loaded progress: page %lu\n", millis(), currentPage); LOG_DBG("XTR", "Loaded progress: page %lu", currentPage);
// Validate page number // Validate page number
if (currentPage >= xtc->getPageCount()) { if (currentPage >= xtc->getPageCount()) {

View File

@@ -8,10 +8,6 @@
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
} // namespace
int XtcReaderChapterSelectionActivity::getPageItems() const { int XtcReaderChapterSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30; constexpr int lineHeight = 30;
@@ -78,13 +74,8 @@ void XtcReaderChapterSelectionActivity::onExit() {
} }
void XtcReaderChapterSelectionActivity::loop() { void XtcReaderChapterSelectionActivity::loop() {
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems(); const int pageItems = getPageItems();
const int totalItems = static_cast<int>(xtc->getChapters().size());
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto& chapters = xtc->getChapters(); const auto& chapters = xtc->getChapters();
@@ -93,29 +84,27 @@ void XtcReaderChapterSelectionActivity::loop() {
} }
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack(); onGoBack();
} else if (prevReleased) {
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) {
return;
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + total) % total;
} else {
selectorIndex = (selectorIndex + total - 1) % total;
} }
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
updateRequired = true; updateRequired = true;
} else if (nextReleased) { });
const int total = static_cast<int>(xtc->getChapters().size());
if (total == 0) { buttonNavigator.onPreviousRelease([this, totalItems] {
return; selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
}
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % total;
} else {
selectorIndex = (selectorIndex + 1) % total;
}
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
updateRequired = true;
});
} }
void XtcReaderChapterSelectionActivity::displayTaskLoop() { void XtcReaderChapterSelectionActivity::displayTaskLoop() {

View File

@@ -7,11 +7,13 @@
#include <memory> #include <memory>
#include "../Activity.h" #include "../Activity.h"
#include "util/ButtonNavigator.h"
class XtcReaderChapterSelectionActivity final : public Activity { class XtcReaderChapterSelectionActivity final : public Activity {
std::shared_ptr<Xtc> xtc; std::shared_ptr<Xtc> xtc;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
uint32_t currentPage = 0; uint32_t currentPage = 0;
int selectorIndex = 0; int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;

View File

@@ -63,15 +63,16 @@ void CalibreSettingsActivity::loop() {
return; return;
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || // Handle navigation
mappedInput.wasPressed(MappedInputManager::Button::Left)) { buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS; selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
});
} }
void CalibreSettingsActivity::handleSelection() { void CalibreSettingsActivity::handleSelection() {

View File

@@ -6,6 +6,7 @@
#include <functional> #include <functional>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/** /**
* Submenu for OPDS Browser settings. * Submenu for OPDS Browser settings.
@@ -24,6 +25,7 @@ class CalibreSettingsActivity final : public ActivityWithSubactivity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
int selectedIndex = 0; int selectedIndex = 0;

View File

@@ -1,8 +1,8 @@
#include "ClearCacheActivity.h" #include "ClearCacheActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HalStorage.h>
#include <SDCardManager.h> #include <Logging.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
@@ -104,12 +104,12 @@ void ClearCacheActivity::render() {
} }
void ClearCacheActivity::clearCache() { void ClearCacheActivity::clearCache() {
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis()); LOG_DBG("CLEAR_CACHE", "Clearing cache...");
// Open .crosspoint directory // Open .crosspoint directory
auto root = SdMan.open("/.crosspoint"); auto root = Storage.open("/.crosspoint");
if (!root || !root.isDirectory()) { if (!root || !root.isDirectory()) {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to open cache directory\n", millis()); LOG_DBG("CLEAR_CACHE", "Failed to open cache directory");
if (root) root.close(); if (root) root.close();
state = FAILED; state = FAILED;
updateRequired = true; updateRequired = true;
@@ -128,14 +128,14 @@ void ClearCacheActivity::clearCache() {
// Only delete directories starting with epub_ or xtc_ // Only delete directories starting with epub_ or xtc_
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) { if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("xtc_"))) {
String fullPath = "/.crosspoint/" + itemName; String fullPath = "/.crosspoint/" + itemName;
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str()); LOG_DBG("CLEAR_CACHE", "Removing cache: %s", fullPath.c_str());
file.close(); // Close before attempting to delete file.close(); // Close before attempting to delete
if (SdMan.removeDir(fullPath.c_str())) { if (Storage.removeDir(fullPath.c_str())) {
clearedCount++; clearedCount++;
} else { } else {
Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str()); LOG_ERR("CLEAR_CACHE", "Failed to remove: %s", fullPath.c_str());
failedCount++; failedCount++;
} }
} else { } else {
@@ -144,7 +144,7 @@ void ClearCacheActivity::clearCache() {
} }
root.close(); root.close();
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount); LOG_DBG("CLEAR_CACHE", "Cache cleared: %d removed, %d failed", clearedCount, failedCount);
state = SUCCESS; state = SUCCESS;
updateRequired = true; updateRequired = true;
@@ -153,7 +153,7 @@ void ClearCacheActivity::clearCache() {
void ClearCacheActivity::loop() { void ClearCacheActivity::loop() {
if (state == WARNING) { if (state == WARNING) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis()); LOG_DBG("CLEAR_CACHE", "User confirmed, starting cache clear");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CLEARING; state = CLEARING;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -164,7 +164,7 @@ void ClearCacheActivity::loop() {
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
Serial.printf("[%lu] [CLEAR_CACHE] User cancelled\n", millis()); LOG_DBG("CLEAR_CACHE", "User cancelled");
goBack(); goBack();
} }
return; return;

View File

@@ -64,15 +64,16 @@ void KOReaderSettingsActivity::loop() {
return; return;
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || // Handle navigation
mappedInput.wasPressed(MappedInputManager::Button::Left)) { buttonNavigator.onNext([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % MENU_ITEMS; selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
updateRequired = true; updateRequired = true;
} });
buttonNavigator.onPrevious([this] {
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
updateRequired = true;
});
} }
void KOReaderSettingsActivity::handleSelection() { void KOReaderSettingsActivity::handleSelection() {

View File

@@ -6,6 +6,7 @@
#include <functional> #include <functional>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
/** /**
* Submenu for KOReader Sync settings. * Submenu for KOReader Sync settings.
@@ -24,6 +25,7 @@ class KOReaderSettingsActivity final : public ActivityWithSubactivity {
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
int selectedIndex = 0; int selectedIndex = 0;

View File

@@ -18,12 +18,12 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
exitActivity(); exitActivity();
if (!success) { if (!success) {
Serial.printf("[%lu] [OTA] WiFi connection failed, exiting\n", millis()); LOG_ERR("OTA", "WiFi connection failed, exiting");
goBack(); goBack();
return; return;
} }
Serial.printf("[%lu] [OTA] WiFi connected, checking for update\n", millis()); LOG_DBG("OTA", "WiFi connected, checking for update");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = CHECKING_FOR_UPDATE; state = CHECKING_FOR_UPDATE;
@@ -32,7 +32,7 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
vTaskDelay(10 / portTICK_PERIOD_MS); vTaskDelay(10 / portTICK_PERIOD_MS);
const auto res = updater.checkForUpdate(); const auto res = updater.checkForUpdate();
if (res != OtaUpdater::OK) { if (res != OtaUpdater::OK) {
Serial.printf("[%lu] [OTA] Update check failed: %d\n", millis(), res); LOG_DBG("OTA", "Update check failed: %d", res);
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FAILED; state = FAILED;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -41,7 +41,7 @@ void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
} }
if (!updater.isUpdateNewer()) { if (!updater.isUpdateNewer()) {
Serial.printf("[%lu] [OTA] No new update available\n", millis()); LOG_DBG("OTA", "No new update available");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = NO_UPDATE; state = NO_UPDATE;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -68,11 +68,11 @@ void OtaUpdateActivity::onEnter() {
); );
// Turn on WiFi immediately // Turn on WiFi immediately
Serial.printf("[%lu] [OTA] Turning on WiFi...\n", millis()); LOG_DBG("OTA", "Turning on WiFi...");
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
// Launch WiFi selection subactivity // Launch WiFi selection subactivity
Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis()); LOG_DBG("OTA", "Launching WifiSelectionActivity...");
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); })); [this](const bool connected) { onWifiSelectionComplete(connected); }));
} }
@@ -116,8 +116,7 @@ void OtaUpdateActivity::render() {
float updaterProgress = 0; float updaterProgress = 0;
if (state == UPDATE_IN_PROGRESS) { if (state == UPDATE_IN_PROGRESS) {
Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.getProcessedSize(), LOG_DBG("OTA", "Update progress: %d / %d", updater.getProcessedSize(), updater.getTotalSize());
updater.getTotalSize());
updaterProgress = static_cast<float>(updater.getProcessedSize()) / static_cast<float>(updater.getTotalSize()); updaterProgress = static_cast<float>(updater.getProcessedSize()) / static_cast<float>(updater.getTotalSize());
// Only update every 2% at the most // Only update every 2% at the most
if (static_cast<int>(updaterProgress * 50) == lastUpdaterPercentage / 2) { if (static_cast<int>(updaterProgress * 50) == lastUpdaterPercentage / 2) {
@@ -190,7 +189,7 @@ void OtaUpdateActivity::loop() {
if (state == WAITING_CONFIRMATION) { if (state == WAITING_CONFIRMATION) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis()); LOG_DBG("OTA", "New update available, starting download...");
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = UPDATE_IN_PROGRESS; state = UPDATE_IN_PROGRESS;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -199,7 +198,7 @@ void OtaUpdateActivity::loop() {
const auto res = updater.installUpdate(); const auto res = updater.installUpdate();
if (res != OtaUpdater::OK) { if (res != OtaUpdater::OK) {
Serial.printf("[%lu] [OTA] Update failed: %d\n", millis(), res); LOG_DBG("OTA", "Update failed: %d", res);
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = FAILED; state = FAILED;
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);

View File

@@ -1,7 +1,7 @@
#include "SettingsActivity.h" #include "SettingsActivity.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <Logging.h>
#include "ButtonRemapActivity.h" #include "ButtonRemapActivity.h"
#include "CalibreSettingsActivity.h" #include "CalibreSettingsActivity.h"
@@ -10,63 +10,13 @@
#include "KOReaderSettingsActivity.h" #include "KOReaderSettingsActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "OtaUpdateActivity.h" #include "OtaUpdateActivity.h"
#include "SettingsList.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"}; const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
namespace {
constexpr int changeTabsMs = 700;
constexpr int displaySettingsCount = 8;
const SettingInfo displaySettings[displaySettingsCount] = {
// Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
{"Dark", "Light", "Custom", "Cover", "None", "Cover + Custom"}),
SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}),
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
{"None", "Contrast", "Inverted"}),
SettingInfo::Enum(
"Status Bar", &CrossPointSettings::statusBar,
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"}),
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}),
SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix),
};
constexpr int readerSettingsCount = 10;
const SettingInfo readerSettings[readerSettingsCount] = {
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}),
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
{"Justify", "Left", "Center", "Right", "Book's Style"}),
SettingInfo::Toggle("Book's Embedded Style", &CrossPointSettings::embeddedStyle),
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled),
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
constexpr int controlsSettingsCount = 4;
const SettingInfo controlsSettings[controlsSettingsCount] = {
// Launches the remap wizard for front buttons.
SettingInfo::Action("Remap Front Buttons"),
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
{"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})};
constexpr int systemSettingsCount = 5;
const SettingInfo systemSettings[systemSettingsCount] = {
SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout,
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"),
SettingInfo::Action("Check for updates")};
} // namespace
void SettingsActivity::taskTrampoline(void* param) { void SettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<SettingsActivity*>(param); auto* self = static_cast<SettingsActivity*>(param);
self->displayTaskLoop(); self->displayTaskLoop();
@@ -76,13 +26,42 @@ void SettingsActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
// Build per-category vectors from the shared settings list
displaySettings.clear();
readerSettings.clear();
controlsSettings.clear();
systemSettings.clear();
for (auto& setting : getSettingsList()) {
if (!setting.category) continue;
if (strcmp(setting.category, "Display") == 0) {
displaySettings.push_back(std::move(setting));
} else if (strcmp(setting.category, "Reader") == 0) {
readerSettings.push_back(std::move(setting));
} else if (strcmp(setting.category, "Controls") == 0) {
controlsSettings.push_back(std::move(setting));
} else if (strcmp(setting.category, "System") == 0) {
systemSettings.push_back(std::move(setting));
}
// Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI
}
// Append device-only ACTION items
controlsSettings.insert(controlsSettings.begin(),
SettingInfo::Action("Remap Front Buttons", SettingAction::RemapFrontButtons));
systemSettings.push_back(SettingInfo::Action("Network", SettingAction::Network));
systemSettings.push_back(SettingInfo::Action("KOReader Sync", SettingAction::KOReaderSync));
systemSettings.push_back(SettingInfo::Action("OPDS Browser", SettingAction::OPDSBrowser));
systemSettings.push_back(SettingInfo::Action("Clear Cache", SettingAction::ClearCache));
systemSettings.push_back(SettingInfo::Action("Check for updates", SettingAction::CheckForUpdates));
// Reset selection to first category // Reset selection to first category
selectedCategoryIndex = 0; selectedCategoryIndex = 0;
selectedSettingIndex = 0; selectedSettingIndex = 0;
// Initialize with first category (Display) // Initialize with first category (Display)
settingsList = displaySettings; currentSettings = &displaySettings;
settingsCount = displaySettingsCount; settingsCount = static_cast<int>(displaySettings.size());
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@@ -136,49 +115,46 @@ void SettingsActivity::loop() {
return; return;
} }
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool changeTab = mappedInput.getHeldTime() > changeTabsMs;
// Handle navigation // Handle navigation
if (upReleased && changeTab) { buttonNavigator.onNextRelease([this] {
selectedSettingIndex = ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1);
updateRequired = true;
});
buttonNavigator.onPreviousRelease([this] {
selectedSettingIndex = ButtonNavigator::previousIndex(selectedSettingIndex, settingsCount + 1);
updateRequired = true;
});
buttonNavigator.onNextContinuous([this, &hasChangedCategory] {
hasChangedCategory = true; hasChangedCategory = true;
selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1); selectedCategoryIndex = ButtonNavigator::nextIndex(selectedCategoryIndex, categoryCount);
updateRequired = true; updateRequired = true;
} else if (downReleased && changeTab) { });
buttonNavigator.onPreviousContinuous([this, &hasChangedCategory] {
hasChangedCategory = true; hasChangedCategory = true;
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; selectedCategoryIndex = ButtonNavigator::previousIndex(selectedCategoryIndex, categoryCount);
updateRequired = true; updateRequired = true;
} else if (upReleased || leftReleased) { });
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount);
updateRequired = true;
} else if (rightReleased || downReleased) {
selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0;
updateRequired = true;
}
if (hasChangedCategory) { if (hasChangedCategory) {
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
switch (selectedCategoryIndex) { switch (selectedCategoryIndex) {
case 0: // Display case 0:
settingsList = displaySettings; currentSettings = &displaySettings;
settingsCount = displaySettingsCount;
break; break;
case 1: // Reader case 1:
settingsList = readerSettings; currentSettings = &readerSettings;
settingsCount = readerSettingsCount;
break; break;
case 2: // Controls case 2:
settingsList = controlsSettings; currentSettings = &controlsSettings;
settingsCount = controlsSettingsCount;
break; break;
case 3: // System case 3:
settingsList = systemSettings; currentSettings = &systemSettings;
settingsCount = systemSettingsCount;
break; break;
} }
settingsCount = static_cast<int>(currentSettings->size());
} }
} }
@@ -188,7 +164,7 @@ void SettingsActivity::toggleCurrentSetting() {
return; return;
} }
const auto& setting = settingsList[selectedSetting]; const auto& setting = (*currentSettings)[selectedSetting];
if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
// Toggle the boolean value using the member pointer // Toggle the boolean value using the member pointer
@@ -205,46 +181,45 @@ void SettingsActivity::toggleCurrentSetting() {
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
} }
} else if (setting.type == SettingType::ACTION) { } else if (setting.type == SettingType::ACTION) {
if (strcmp(setting.name, "Remap Front Buttons") == 0) { auto enterSubActivity = [this](Activity* activity) {
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity(); exitActivity();
enterNewActivity(new ButtonRemapActivity(renderer, mappedInput, [this] { enterNewActivity(activity);
xSemaphoreGive(renderingMutex);
};
auto onComplete = [this] {
exitActivity(); exitActivity();
updateRequired = true; updateRequired = true;
})); };
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "KOReader Sync") == 0) { auto onCompleteBool = [this](bool) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
exitActivity(); exitActivity();
updateRequired = true; updateRequired = true;
})); };
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "OPDS Browser") == 0) { switch (setting.action) {
xSemaphoreTake(renderingMutex, portMAX_DELAY); case SettingAction::RemapFrontButtons:
exitActivity(); enterSubActivity(new ButtonRemapActivity(renderer, mappedInput, onComplete));
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { break;
exitActivity(); case SettingAction::KOReaderSync:
updateRequired = true; enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete));
})); break;
xSemaphoreGive(renderingMutex); case SettingAction::OPDSBrowser:
} else if (strcmp(setting.name, "Clear Cache") == 0) { enterSubActivity(new CalibreSettingsActivity(renderer, mappedInput, onComplete));
xSemaphoreTake(renderingMutex, portMAX_DELAY); break;
exitActivity(); case SettingAction::Network:
enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false));
exitActivity(); break;
updateRequired = true; case SettingAction::ClearCache:
})); enterSubActivity(new ClearCacheActivity(renderer, mappedInput, onComplete));
xSemaphoreGive(renderingMutex); break;
} else if (strcmp(setting.name, "Check for updates") == 0) { case SettingAction::CheckForUpdates:
xSemaphoreTake(renderingMutex, portMAX_DELAY); enterSubActivity(new OtaUpdateActivity(renderer, mappedInput, onComplete));
exitActivity(); break;
enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { case SettingAction::None:
exitActivity(); // Do nothing
updateRequired = true; break;
}));
xSemaphoreGive(renderingMutex);
} }
} else { } else {
return; return;
@@ -283,24 +258,24 @@ void SettingsActivity::render() const {
GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs, GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs,
selectedSettingIndex == 0); selectedSettingIndex == 0);
const auto& settings = *currentSettings;
GUI.drawList( GUI.drawList(
renderer, renderer,
Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth,
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight +
metrics.verticalSpacing * 2)}, metrics.verticalSpacing * 2)},
settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); }, settingsCount, selectedSettingIndex - 1, [&settings](int index) { return std::string(settings[index].name); },
nullptr, nullptr, nullptr, nullptr,
[this](int i) { [&settings](int i) {
const auto& setting = settingsList[i];
std::string valueText = ""; std::string valueText = "";
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { if (settings[i].type == SettingType::TOGGLE && settings[i].valuePtr != nullptr) {
const bool value = SETTINGS.*(settingsList[i].valuePtr); const bool value = SETTINGS.*(settings[i].valuePtr);
valueText = value ? "ON" : "OFF"; valueText = value ? "ON" : "OFF";
} else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { } else if (settings[i].type == SettingType::ENUM && settings[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); const uint8_t value = SETTINGS.*(settings[i].valuePtr);
valueText = settingsList[i].enumValues[value]; valueText = settings[i].enumValues[value];
} else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { } else if (settings[i].type == SettingType::VALUE && settings[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); valueText = std::to_string(SETTINGS.*(settings[i].valuePtr));
} }
return valueText; return valueText;
}); });

View File

@@ -8,47 +8,147 @@
#include <vector> #include <vector>
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
#include "util/ButtonNavigator.h"
class CrossPointSettings; class CrossPointSettings;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
enum class SettingAction {
None,
RemapFrontButtons,
KOReaderSync,
OPDSBrowser,
Network,
ClearCache,
CheckForUpdates,
};
struct SettingInfo { struct SettingInfo {
const char* name; const char* name;
SettingType type; SettingType type;
uint8_t CrossPointSettings::* valuePtr; uint8_t CrossPointSettings::* valuePtr = nullptr;
std::vector<std::string> enumValues; std::vector<std::string> enumValues;
SettingAction action = SettingAction::None;
struct ValueRange { struct ValueRange {
uint8_t min; uint8_t min;
uint8_t max; uint8_t max;
uint8_t step; uint8_t step;
}; };
ValueRange valueRange; ValueRange valueRange = {};
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { const char* key = nullptr; // JSON API key (nullptr for ACTION types)
return {name, SettingType::TOGGLE, ptr}; const char* category = nullptr; // Category for web UI grouping
// Direct char[] string fields (for settings stored in CrossPointSettings)
char* stringPtr = nullptr;
size_t stringMaxLen = 0;
// Dynamic accessors (for settings stored outside CrossPointSettings, e.g. KOReaderCredentialStore)
std::function<uint8_t()> valueGetter;
std::function<void(uint8_t)> valueSetter;
std::function<std::string()> stringGetter;
std::function<void(const std::string&)> stringSetter;
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr, const char* key = nullptr,
const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::TOGGLE;
s.valuePtr = ptr;
s.key = key;
s.category = category;
return s;
} }
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) { static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values,
return {name, SettingType::ENUM, ptr, std::move(values)}; const char* key = nullptr, const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::ENUM;
s.valuePtr = ptr;
s.enumValues = std::move(values);
s.key = key;
s.category = category;
return s;
} }
static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } static SettingInfo Action(const char* name, SettingAction action) {
SettingInfo s;
s.name = name;
s.type = SettingType::ACTION;
s.action = action;
return s;
}
static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange,
return {name, SettingType::VALUE, ptr, {}, valueRange}; const char* key = nullptr, const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::VALUE;
s.valuePtr = ptr;
s.valueRange = valueRange;
s.key = key;
s.category = category;
return s;
}
static SettingInfo String(const char* name, char* ptr, size_t maxLen, const char* key = nullptr,
const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::STRING;
s.stringPtr = ptr;
s.stringMaxLen = maxLen;
s.key = key;
s.category = category;
return s;
}
static SettingInfo DynamicEnum(const char* name, std::vector<std::string> values, std::function<uint8_t()> getter,
std::function<void(uint8_t)> setter, const char* key = nullptr,
const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::ENUM;
s.enumValues = std::move(values);
s.valueGetter = std::move(getter);
s.valueSetter = std::move(setter);
s.key = key;
s.category = category;
return s;
}
static SettingInfo DynamicString(const char* name, std::function<std::string()> getter,
std::function<void(const std::string&)> setter, const char* key = nullptr,
const char* category = nullptr) {
SettingInfo s;
s.name = name;
s.type = SettingType::STRING;
s.stringGetter = std::move(getter);
s.stringSetter = std::move(setter);
s.key = key;
s.category = category;
return s;
} }
}; };
class SettingsActivity final : public ActivityWithSubactivity { class SettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
int selectedCategoryIndex = 0; // Currently selected category int selectedCategoryIndex = 0; // Currently selected category
int selectedSettingIndex = 0; int selectedSettingIndex = 0;
int settingsCount = 0; int settingsCount = 0;
const SettingInfo* settingsList = nullptr;
// Per-category settings derived from shared list + device-only actions
std::vector<SettingInfo> displaySettings;
std::vector<SettingInfo> readerSettings;
std::vector<SettingInfo> controlsSettings;
std::vector<SettingInfo> systemSettings;
const std::vector<SettingInfo>* currentSettings = nullptr;
const std::function<void()> onGoHome; const std::function<void()> onGoHome;

View File

@@ -142,37 +142,24 @@ void KeyboardEntryActivity::handleKeyPress() {
} }
void KeyboardEntryActivity::loop() { void KeyboardEntryActivity::loop() {
// Navigation // Handle navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] {
if (selectedRow > 0) { selectedRow = ButtonNavigator::previousIndex(selectedRow, NUM_ROWS);
selectedRow--;
// Clamp column to valid range for new row
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to bottom row
selectedRow = NUM_ROWS - 1;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (selectedRow < NUM_ROWS - 1) {
selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol; if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to top row
selectedRow = 0;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true; updateRequired = true;
} });
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] {
selectedRow = ButtonNavigator::nextIndex(selectedRow, NUM_ROWS);
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
updateRequired = true;
});
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] {
const int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case // Special bottom row case
@@ -191,20 +178,14 @@ void KeyboardEntryActivity::loop() {
// At done button, move to backspace // At done button, move to backspace
selectedCol = BACKSPACE_COL; selectedCol = BACKSPACE_COL;
} }
updateRequired = true;
return;
}
if (selectedCol > 0) {
selectedCol--;
} else { } else {
// Wrap to end of current row selectedCol = ButtonNavigator::previousIndex(selectedCol, maxCol + 1);
selectedCol = maxCol;
}
updateRequired = true;
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { updateRequired = true;
});
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] {
const int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case // Special bottom row case
@@ -223,18 +204,11 @@ void KeyboardEntryActivity::loop() {
// At done button, wrap to beginning of row // At done button, wrap to beginning of row
selectedCol = SHIFT_COL; selectedCol = SHIFT_COL;
} }
updateRequired = true;
return;
}
if (selectedCol < maxCol) {
selectedCol++;
} else { } else {
// Wrap to beginning of current row selectedCol = ButtonNavigator::nextIndex(selectedCol, maxCol + 1);
selectedCol = 0;
} }
updateRequired = true; updateRequired = true;
} });
// Selection // Selection
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {

View File

@@ -9,6 +9,7 @@
#include <utility> #include <utility>
#include "../Activity.h" #include "../Activity.h"
#include "util/ButtonNavigator.h"
/** /**
* Reusable keyboard entry activity for text input. * Reusable keyboard entry activity for text input.
@@ -65,6 +66,7 @@ class KeyboardEntryActivity : public Activity {
bool isPassword; bool isPassword;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
ButtonNavigator buttonNavigator;
bool updateRequired = false; bool updateRequired = false;
// Keyboard state // Keyboard state

View File

@@ -1,6 +1,7 @@
#include "UITheme.h" #include "UITheme.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <Logging.h>
#include <memory> #include <memory>
@@ -23,12 +24,12 @@ void UITheme::reload() {
void UITheme::setTheme(CrossPointSettings::UI_THEME type) { void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
switch (type) { switch (type) {
case CrossPointSettings::UI_THEME::CLASSIC: case CrossPointSettings::UI_THEME::CLASSIC:
Serial.printf("[%lu] [UI] Using Classic theme\n", millis()); LOG_DBG("UI", "Using Classic theme");
currentTheme = new BaseTheme(); currentTheme = new BaseTheme();
currentMetrics = &BaseMetrics::values; currentMetrics = &BaseMetrics::values;
break; break;
case CrossPointSettings::UI_THEME::LYRA: case CrossPointSettings::UI_THEME::LYRA:
Serial.printf("[%lu] [UI] Using Lyra theme\n", millis()); LOG_DBG("UI", "Using Lyra theme");
currentTheme = new LyraTheme(); currentTheme = new LyraTheme();
currentMetrics = &LyraMetrics::values; currentMetrics = &LyraMetrics::values;
break; break;

View File

@@ -1,7 +1,8 @@
#include "BaseTheme.h" #include "BaseTheme.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Logging.h>
#include <Utf8.h> #include <Utf8.h>
#include <cstdint> #include <cstdint>
@@ -308,10 +309,10 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
// First time: load cover from SD and render // First time: load cover from SD and render
FsFile file; FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
Serial.printf("Rendering bmp\n"); LOG_DBG("THEME", "Rendering bmp");
// Calculate position to center image within the book card // Calculate position to center image within the book card
int coverX, coverY; int coverX, coverY;
@@ -345,7 +346,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
// First render: if selected, draw selection indicators now // First render: if selected, draw selection indicators now
if (bookSelected) { if (bookSelected) {
Serial.printf("Drawing selection\n"); LOG_DBG("THEME", "Drawing selection");
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
} }

View File

@@ -1,7 +1,7 @@
#include "LyraTheme.h" #include "LyraTheme.h"
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -283,7 +283,7 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
// First time: load cover from SD and render // First time: load cover from SD and render
FsFile file; FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) { if (bitmap.parseHeaders() == BmpReaderError::Ok) {
float coverHeight = static_cast<float>(bitmap.getHeight()); float coverHeight = static_cast<float>(bitmap.getHeight());

View File

@@ -3,7 +3,8 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HalDisplay.h> #include <HalDisplay.h>
#include <HalGPIO.h> #include <HalGPIO.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Logging.h>
#include <SPI.h> #include <SPI.h>
#include <builtinFonts/all.h> #include <builtinFonts/all.h>
@@ -27,6 +28,7 @@
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/ButtonNavigator.h"
HalDisplay display; HalDisplay display;
HalGPIO gpio; HalGPIO gpio;
@@ -200,8 +202,8 @@ void enterDeepSleep() {
enterNewActivity(new SleepActivity(renderer, mappedInputManager)); enterNewActivity(new SleepActivity(renderer, mappedInputManager));
display.deepSleep(); display.deepSleep();
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1); LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1);
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis()); LOG_DBG("MAIN", "Entering deep sleep");
gpio.startDeepSleep(); gpio.startDeepSleep();
} }
@@ -254,7 +256,7 @@ void onGoHome() {
void setupDisplayAndFonts() { void setupDisplayAndFonts() {
display.begin(); display.begin();
renderer.begin(); renderer.begin();
Serial.printf("[%lu] [ ] Display initialized\n", millis()); LOG_DBG("MAIN", "Display initialized");
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily); renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
#ifndef OMIT_FONTS #ifndef OMIT_FONTS
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily); renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
@@ -273,7 +275,7 @@ void setupDisplayAndFonts() {
renderer.insertFont(UI_10_FONT_ID, ui10FontFamily); renderer.insertFont(UI_10_FONT_ID, ui10FontFamily);
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily); renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
renderer.insertFont(SMALL_FONT_ID, smallFontFamily); renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
Serial.printf("[%lu] [ ] Fonts setup\n", millis()); LOG_DBG("MAIN", "Fonts setup");
} }
void setup() { void setup() {
@@ -293,8 +295,8 @@ void setup() {
// SD Card Initialization // SD Card Initialization
// We need 6 open files concurrently when parsing a new chapter // We need 6 open files concurrently when parsing a new chapter
if (!SdMan.begin()) { if (!Storage.begin()) {
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); LOG_ERR("MAIN", "SD card initialization failed");
setupDisplayAndFonts(); setupDisplayAndFonts();
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", EpdFontFamily::BOLD)); enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", EpdFontFamily::BOLD));
@@ -304,16 +306,17 @@ void setup() {
SETTINGS.loadFromFile(); SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
UITheme::getInstance().reload(); UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager);
switch (gpio.getWakeupReason()) { switch (gpio.getWakeupReason()) {
case HalGPIO::WakeupReason::PowerButton: case HalGPIO::WakeupReason::PowerButton:
// For normal wakeups, verify power button press duration // For normal wakeups, verify power button press duration
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); LOG_DBG("MAIN", "Verifying power button press duration");
verifyPowerButtonDuration(); verifyPowerButtonDuration();
break; break;
case HalGPIO::WakeupReason::AfterUSBPower: case HalGPIO::WakeupReason::AfterUSBPower:
// If USB power caused a cold boot, go back to sleep // If USB power caused a cold boot, go back to sleep
Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis()); LOG_DBG("MAIN", "Wakeup reason: After USB Power");
gpio.startDeepSleep(); gpio.startDeepSleep();
break; break;
case HalGPIO::WakeupReason::AfterFlash: case HalGPIO::WakeupReason::AfterFlash:
@@ -324,7 +327,7 @@ void setup() {
} }
// 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
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); LOG_DBG("MAIN", "Starting CrossPoint version " CROSSPOINT_VERSION);
setupDisplayAndFonts(); setupDisplayAndFonts();
@@ -362,11 +365,27 @@ void loop() {
renderer.setFadingFix(SETTINGS.fadingFix); renderer.setFadingFix(SETTINGS.fadingFix);
if (Serial && millis() - lastMemPrint >= 10000) { if (Serial && millis() - lastMemPrint >= 10000) {
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(), LOG_INF("MEM", "Free: %d bytes, Total: %d bytes, Min Free: %d bytes", ESP.getFreeHeap(), ESP.getHeapSize(),
ESP.getHeapSize(), ESP.getMinFreeHeap()); ESP.getMinFreeHeap());
lastMemPrint = millis(); lastMemPrint = millis();
} }
// Handle incoming serial commands,
// nb: we use logSerial from logging to avoid deprecation warnings
if (logSerial.available() > 0) {
String line = logSerial.readStringUntil('\n');
if (line.startsWith("CMD:")) {
String cmd = line.substring(4);
cmd.trim();
if (cmd == "SCREENSHOT") {
logSerial.printf("SCREENSHOT_START:%d\n", HalDisplay::BUFFER_SIZE);
uint8_t* buf = display.getFrameBuffer();
logSerial.write(buf, HalDisplay::BUFFER_SIZE);
logSerial.printf("SCREENSHOT_END\n");
}
}
}
// Check for any user activity (button press or release) or active background work // Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis(); static unsigned long lastActivityTime = millis();
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) { if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
@@ -375,7 +394,7 @@ void loop() {
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
if (millis() - lastActivityTime >= sleepTimeoutMs) { if (millis() - lastActivityTime >= sleepTimeoutMs) {
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs); LOG_DBG("SLP", "Auto-sleep triggered after %lu ms of inactivity", sleepTimeoutMs);
enterDeepSleep(); enterDeepSleep();
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
return; return;
@@ -397,8 +416,7 @@ void loop() {
if (loopDuration > maxLoopDuration) { if (loopDuration > maxLoopDuration) {
maxLoopDuration = loopDuration; maxLoopDuration = loopDuration;
if (maxLoopDuration > 50) { if (maxLoopDuration > 50) {
Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration, LOG_DBG("LOOP", "New max loop duration: %lu ms (activity: %lu ms)", maxLoopDuration, activityDuration);
activityDuration);
} }
} }
@@ -408,6 +426,13 @@ void loop() {
if (currentActivity && currentActivity->skipLoopDelay()) { if (currentActivity && currentActivity->skipLoopDelay()) {
yield(); // Give FreeRTOS a chance to run tasks, but return immediately yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else { } else {
delay(10); // Normal delay when no activity requires fast response static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // 3 seconds
if (millis() - lastActivityTime >= IDLE_POWER_SAVING_MS) {
// If we've been inactive for a while, increase the delay to save power
delay(50);
} else {
// Short delay to prevent tight loop while still being responsive
delay(10);
}
} }
} }

View File

@@ -3,14 +3,18 @@
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <Epub.h> #include <Epub.h>
#include <FsHelpers.h> #include <FsHelpers.h>
#include <SDCardManager.h> #include <HalStorage.h>
#include <Logging.h>
#include <WiFi.h> #include <WiFi.h>
#include <esp_task_wdt.h> #include <esp_task_wdt.h>
#include <algorithm> #include <algorithm>
#include "CrossPointSettings.h"
#include "SettingsList.h"
#include "html/FilesPageHtml.generated.h" #include "html/FilesPageHtml.generated.h"
#include "html/HomePageHtml.generated.h" #include "html/HomePageHtml.generated.h"
#include "html/SettingsPageHtml.generated.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
namespace { namespace {
@@ -41,7 +45,7 @@ void clearEpubCacheIfNeeded(const String& filePath) {
// Only clear cache for .epub files // Only clear cache for .epub files
if (StringUtils::checkFileExtension(filePath, ".epub")) { if (StringUtils::checkFileExtension(filePath, ".epub")) {
Epub(filePath.c_str(), "/.crosspoint").clearCache(); Epub(filePath.c_str(), "/.crosspoint").clearCache();
Serial.printf("[%lu] [WEB] Cleared epub cache for: %s\n", millis(), filePath.c_str()); LOG_DBG("WEB", "Cleared epub cache for: %s", filePath.c_str());
} }
} }
@@ -86,7 +90,7 @@ CrossPointWebServer::~CrossPointWebServer() { stop(); }
void CrossPointWebServer::begin() { void CrossPointWebServer::begin() {
if (running) { if (running) {
Serial.printf("[%lu] [WEB] Web server already running\n", millis()); LOG_DBG("WEB", "Web server already running");
return; return;
} }
@@ -96,18 +100,17 @@ void CrossPointWebServer::begin() {
const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running
if (!isStaConnected && !isInApMode) { if (!isStaConnected && !isInApMode) {
Serial.printf("[%lu] [WEB] Cannot start webserver - no valid network (mode=%d, status=%d)\n", millis(), wifiMode, LOG_DBG("WEB", "Cannot start webserver - no valid network (mode=%d, status=%d)", wifiMode, WiFi.status());
WiFi.status());
return; return;
} }
// Store AP mode flag for later use (e.g., in handleStatus) // Store AP mode flag for later use (e.g., in handleStatus)
apMode = isInApMode; apMode = isInApMode;
Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap before begin: %d bytes", ESP.getFreeHeap());
Serial.printf("[%lu] [WEB] Network mode: %s\n", millis(), apMode ? "AP" : "STA"); LOG_DBG("WEB", "Network mode: %s", apMode ? "AP" : "STA");
Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); LOG_DBG("WEB", "Creating web server on port %d...", port);
server.reset(new WebServer(port)); server.reset(new WebServer(port));
// Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors. // Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors.
@@ -117,15 +120,15 @@ void CrossPointWebServer::begin() {
// Note: WebServer class doesn't have setNoDelay() in the standard ESP32 library. // Note: WebServer class doesn't have setNoDelay() in the standard ESP32 library.
// We rely on disabling WiFi sleep for responsiveness. // We rely on disabling WiFi sleep for responsiveness.
Serial.printf("[%lu] [WEB] [MEM] Free heap after WebServer allocation: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after WebServer allocation: %d bytes", ESP.getFreeHeap());
if (!server) { if (!server) {
Serial.printf("[%lu] [WEB] Failed to create WebServer!\n", millis()); LOG_ERR("WEB", "Failed to create WebServer!");
return; return;
} }
// Setup routes // Setup routes
Serial.printf("[%lu] [WEB] Setting up routes...\n", millis()); LOG_DBG("WEB", "Setting up routes...");
server->on("/", HTTP_GET, [this] { handleRoot(); }); server->on("/", HTTP_GET, [this] { handleRoot(); });
server->on("/files", HTTP_GET, [this] { handleFileList(); }); server->on("/files", HTTP_GET, [this] { handleFileList(); });
@@ -148,44 +151,47 @@ void CrossPointWebServer::begin() {
// Delete file/folder endpoint // Delete file/folder endpoint
server->on("/delete", HTTP_POST, [this] { handleDelete(); }); server->on("/delete", HTTP_POST, [this] { handleDelete(); });
// Settings endpoints
server->on("/settings", HTTP_GET, [this] { handleSettingsPage(); });
server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); });
server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); });
server->onNotFound([this] { handleNotFound(); }); server->onNotFound([this] { handleNotFound(); });
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
server->begin(); server->begin();
// Start WebSocket server for fast binary uploads // Start WebSocket server for fast binary uploads
Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort); LOG_DBG("WEB", "Starting WebSocket server on port %d...", wsPort);
wsServer.reset(new WebSocketsServer(wsPort)); wsServer.reset(new WebSocketsServer(wsPort));
wsInstance = const_cast<CrossPointWebServer*>(this); wsInstance = const_cast<CrossPointWebServer*>(this);
wsServer->begin(); wsServer->begin();
wsServer->onEvent(wsEventCallback); wsServer->onEvent(wsEventCallback);
Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); LOG_DBG("WEB", "WebSocket server started");
udpActive = udp.begin(LOCAL_UDP_PORT); udpActive = udp.begin(LOCAL_UDP_PORT);
Serial.printf("[%lu] [WEB] Discovery UDP %s on port %d\n", millis(), udpActive ? "enabled" : "failed", LOG_DBG("WEB", "Discovery UDP %s on port %d", udpActive ? "enabled" : "failed", LOCAL_UDP_PORT);
LOCAL_UDP_PORT);
running = true; running = true;
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); LOG_DBG("WEB", "Web server started on port %d", port);
// Show the correct IP based on network mode // Show the correct IP based on network mode
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); LOG_DBG("WEB", "Access at http://%s/", ipAddr.c_str());
Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort); LOG_DBG("WEB", "WebSocket at ws://%s:%d/", ipAddr.c_str(), wsPort);
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after server.begin(): %d bytes", ESP.getFreeHeap());
} }
void CrossPointWebServer::stop() { void CrossPointWebServer::stop() {
if (!running || !server) { if (!running || !server) {
Serial.printf("[%lu] [WEB] stop() called but already stopped (running=%d, server=%p)\n", millis(), running, LOG_DBG("WEB", "stop() called but already stopped (running=%d, server=%p)", running, server.get());
server.get());
return; return;
} }
Serial.printf("[%lu] [WEB] STOP INITIATED - setting running=false first\n", millis()); LOG_DBG("WEB", "STOP INITIATED - setting running=false first");
running = false; // Set this FIRST to prevent handleClient from using server running = false; // Set this FIRST to prevent handleClient from using server
Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap before stop: %d bytes", ESP.getFreeHeap());
// Close any in-progress WebSocket upload // Close any in-progress WebSocket upload
if (wsUploadInProgress && wsUploadFile) { if (wsUploadInProgress && wsUploadFile) {
@@ -195,11 +201,11 @@ void CrossPointWebServer::stop() {
// Stop WebSocket server // Stop WebSocket server
if (wsServer) { if (wsServer) {
Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis()); LOG_DBG("WEB", "Stopping WebSocket server...");
wsServer->close(); wsServer->close();
wsServer.reset(); wsServer.reset();
wsInstance = nullptr; wsInstance = nullptr;
Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); LOG_DBG("WEB", "WebSocket server stopped");
} }
if (udpActive) { if (udpActive) {
@@ -211,18 +217,18 @@ void CrossPointWebServer::stop() {
delay(20); delay(20);
server->stop(); server->stop();
Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after server->stop(): %d bytes", ESP.getFreeHeap());
// Brief delay before deletion // Brief delay before deletion
delay(10); delay(10);
server.reset(); server.reset();
Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); LOG_DBG("WEB", "Web server stopped and deleted");
Serial.printf("[%lu] [WEB] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap after delete server: %d bytes", ESP.getFreeHeap());
// Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared // Note: Static upload variables (uploadFileName, uploadPath, uploadError) are declared
// later in the file and will be cleared when they go out of scope or on next upload // later in the file and will be cleared when they go out of scope or on next upload
Serial.printf("[%lu] [WEB] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[MEM] Free heap final: %d bytes", ESP.getFreeHeap());
} }
void CrossPointWebServer::handleClient() { void CrossPointWebServer::handleClient() {
@@ -235,13 +241,13 @@ void CrossPointWebServer::handleClient() {
// Double-check server pointer is valid // Double-check server pointer is valid
if (!server) { if (!server) {
Serial.printf("[%lu] [WEB] WARNING: handleClient called with null server!\n", millis()); LOG_DBG("WEB", "WARNING: handleClient called with null server!");
return; return;
} }
// Print debug every 10 seconds to confirm handleClient is being called // Print debug every 10 seconds to confirm handleClient is being called
if (millis() - lastDebugPrint > 10000) { if (millis() - lastDebugPrint > 10000) {
Serial.printf("[%lu] [WEB] handleClient active, server running on port %d\n", millis(), port); LOG_DBG("WEB", "handleClient active, server running on port %d", port);
lastDebugPrint = millis(); lastDebugPrint = millis();
} }
@@ -287,9 +293,14 @@ CrossPointWebServer::WsUploadStatus CrossPointWebServer::getWsUploadStatus() con
return status; return status;
} }
static void sendHtmlContent(WebServer* server, const char* data, size_t len) {
server->sendHeader("Content-Encoding", "gzip");
server->send_P(200, "text/html", data, len);
}
void CrossPointWebServer::handleRoot() const { void CrossPointWebServer::handleRoot() const {
server->send(200, "text/html", HomePageHtml); sendHtmlContent(server.get(), HomePageHtml, sizeof(HomePageHtml));
Serial.printf("[%lu] [WEB] Served root page\n", millis()); LOG_DBG("WEB", "Served root page");
} }
void CrossPointWebServer::handleNotFound() const { void CrossPointWebServer::handleNotFound() const {
@@ -316,19 +327,19 @@ void CrossPointWebServer::handleStatus() const {
} }
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const { void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
FsFile root = SdMan.open(path); FsFile root = Storage.open(path);
if (!root) { if (!root) {
Serial.printf("[%lu] [WEB] Failed to open directory: %s\n", millis(), path); LOG_DBG("WEB", "Failed to open directory: %s", path);
return; return;
} }
if (!root.isDirectory()) { if (!root.isDirectory()) {
Serial.printf("[%lu] [WEB] Not a directory: %s\n", millis(), path); LOG_DBG("WEB", "Not a directory: %s", path);
root.close(); root.close();
return; return;
} }
Serial.printf("[%lu] [WEB] Scanning files in: %s\n", millis(), path); LOG_DBG("WEB", "Scanning files in: %s", path);
FsFile file = root.openNextFile(); FsFile file = root.openNextFile();
char name[500]; char name[500];
@@ -379,7 +390,9 @@ bool CrossPointWebServer::isEpubFile(const String& filename) const {
return lower.endsWith(".epub"); return lower.endsWith(".epub");
} }
void CrossPointWebServer::handleFileList() const { server->send(200, "text/html", FilesPageHtml); } void CrossPointWebServer::handleFileList() const {
sendHtmlContent(server.get(), FilesPageHtml, sizeof(FilesPageHtml));
}
void CrossPointWebServer::handleFileListData() const { void CrossPointWebServer::handleFileListData() const {
// Get current path from query string (default to root) // Get current path from query string (default to root)
@@ -414,7 +427,7 @@ void CrossPointWebServer::handleFileListData() const {
const size_t written = serializeJson(doc, output, outputSize); const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) { if (written >= outputSize) {
// JSON output truncated; skip this entry to avoid sending malformed JSON // JSON output truncated; skip this entry to avoid sending malformed JSON
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(), info.name.c_str()); LOG_DBG("WEB", "Skipping file entry with oversized JSON for name: %s", info.name.c_str());
return; return;
} }
@@ -428,7 +441,7 @@ void CrossPointWebServer::handleFileListData() const {
server->sendContent("]"); server->sendContent("]");
// End of streamed response, empty chunk to signal client // End of streamed response, empty chunk to signal client
server->sendContent(""); server->sendContent("");
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str()); LOG_DBG("WEB", "Served file listing page for path: %s", currentPath.c_str());
} }
void CrossPointWebServer::handleDownload() const { void CrossPointWebServer::handleDownload() const {
@@ -458,12 +471,12 @@ void CrossPointWebServer::handleDownload() const {
} }
} }
if (!SdMan.exists(itemPath.c_str())) { if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found"); server->send(404, "text/plain", "Item not found");
return; return;
} }
FsFile file = SdMan.open(itemPath.c_str()); FsFile file = Storage.open(itemPath.c_str());
if (!file) { if (!file) {
server->send(500, "text/plain", "Failed to open file"); server->send(500, "text/plain", "Failed to open file");
return; return;
@@ -509,8 +522,7 @@ static bool flushUploadBuffer(CrossPointWebServer::UploadState& state) {
esp_task_wdt_reset(); // Reset watchdog after SD write esp_task_wdt_reset(); // Reset watchdog after SD write
if (written != state.bufferPos) { if (written != state.bufferPos) {
Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), state.bufferPos, LOG_DBG("WEB", "[UPLOAD] Buffer flush failed: expected %d, wrote %d", state.bufferPos, written);
written);
state.bufferPos = 0; state.bufferPos = 0;
return false; return false;
} }
@@ -527,7 +539,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
// Safety check: ensure server is still valid // Safety check: ensure server is still valid
if (!running || !server) { if (!running || !server) {
Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis()); LOG_DBG("WEB", "[UPLOAD] ERROR: handleUpload called but server not running!");
return; return;
} }
@@ -564,8 +576,8 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
state.path = "/"; state.path = "/";
} }
Serial.printf("[%lu] [WEB] [UPLOAD] START: %s to path: %s\n", millis(), state.fileName.c_str(), state.path.c_str()); LOG_DBG("WEB", "[UPLOAD] START: %s to path: %s", state.fileName.c_str(), state.path.c_str());
Serial.printf("[%lu] [WEB] [UPLOAD] Free heap: %d bytes\n", millis(), ESP.getFreeHeap()); LOG_DBG("WEB", "[UPLOAD] Free heap: %d bytes", ESP.getFreeHeap());
// Create file path // Create file path
String filePath = state.path; String filePath = state.path;
@@ -574,22 +586,22 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
// Check if file already exists - SD operations can be slow // Check if file already exists - SD operations can be slow
esp_task_wdt_reset(); esp_task_wdt_reset();
if (SdMan.exists(filePath.c_str())) { if (Storage.exists(filePath.c_str())) {
Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str()); LOG_DBG("WEB", "[UPLOAD] Overwriting existing file: %s", filePath.c_str());
esp_task_wdt_reset(); esp_task_wdt_reset();
SdMan.remove(filePath.c_str()); Storage.remove(filePath.c_str());
} }
// Open file for writing - this can be slow due to FAT cluster allocation // Open file for writing - this can be slow due to FAT cluster allocation
esp_task_wdt_reset(); esp_task_wdt_reset();
if (!SdMan.openFileForWrite("WEB", filePath, state.file)) { if (!Storage.openFileForWrite("WEB", filePath, state.file)) {
state.error = "Failed to create file on SD card"; state.error = "Failed to create file on SD card";
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); LOG_DBG("WEB", "[UPLOAD] FAILED to create file: %s", filePath.c_str());
return; return;
} }
esp_task_wdt_reset(); esp_task_wdt_reset();
Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); LOG_DBG("WEB", "[UPLOAD] File created successfully: %s", filePath.c_str());
} else if (upload.status == UPLOAD_FILE_WRITE) { } else if (upload.status == UPLOAD_FILE_WRITE) {
if (state.file && state.error.isEmpty()) { if (state.file && state.error.isEmpty()) {
// Buffer incoming data and flush when buffer is full // Buffer incoming data and flush when buffer is full
@@ -622,8 +634,8 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
if (state.size - lastLoggedSize >= 102400) { if (state.size - lastLoggedSize >= 102400) {
const unsigned long elapsed = millis() - uploadStartTime; const unsigned long elapsed = millis() - uploadStartTime;
const float kbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0; const float kbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0;
Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), state.size, LOG_DBG("WEB", "[UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes", state.size, state.size / 1024.0, kbps,
state.size / 1024.0, kbps, writeCount); writeCount);
lastLoggedSize = state.size; lastLoggedSize = state.size;
} }
} }
@@ -640,10 +652,10 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
const unsigned long elapsed = millis() - uploadStartTime; const unsigned long elapsed = millis() - uploadStartTime;
const float avgKbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0; const float avgKbps = (elapsed > 0) ? (state.size / 1024.0) / (elapsed / 1000.0) : 0;
const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0; const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0;
Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(), LOG_DBG("WEB", "[UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)", state.fileName.c_str(), state.size,
state.fileName.c_str(), state.size, elapsed, avgKbps); elapsed, avgKbps);
Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), LOG_DBG("WEB", "[UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)", writeCount, totalWriteTime,
writeCount, totalWriteTime, writePercent); writePercent);
// Clear epub cache to prevent stale metadata issues when overwriting files // Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = state.path; String filePath = state.path;
@@ -660,10 +672,10 @@ void CrossPointWebServer::handleUpload(UploadState& state) const {
String filePath = state.path; String filePath = state.path;
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += state.fileName; filePath += state.fileName;
SdMan.remove(filePath.c_str()); Storage.remove(filePath.c_str());
} }
state.error = "Upload aborted"; state.error = "Upload aborted";
Serial.printf("[%lu] [WEB] Upload aborted\n", millis()); LOG_DBG("WEB", "Upload aborted");
} }
} }
@@ -708,20 +720,20 @@ void CrossPointWebServer::handleCreateFolder() const {
if (!folderPath.endsWith("/")) folderPath += "/"; if (!folderPath.endsWith("/")) folderPath += "/";
folderPath += folderName; folderPath += folderName;
Serial.printf("[%lu] [WEB] Creating folder: %s\n", millis(), folderPath.c_str()); LOG_DBG("WEB", "Creating folder: %s", folderPath.c_str());
// Check if already exists // Check if already exists
if (SdMan.exists(folderPath.c_str())) { if (Storage.exists(folderPath.c_str())) {
server->send(400, "text/plain", "Folder already exists"); server->send(400, "text/plain", "Folder already exists");
return; return;
} }
// Create the folder // Create the folder
if (SdMan.mkdir(folderPath.c_str())) { if (Storage.mkdir(folderPath.c_str())) {
Serial.printf("[%lu] [WEB] Folder created successfully: %s\n", millis(), folderPath.c_str()); LOG_DBG("WEB", "Folder created successfully: %s", folderPath.c_str());
server->send(200, "text/plain", "Folder created: " + folderName); server->send(200, "text/plain", "Folder created: " + folderName);
} else { } else {
Serial.printf("[%lu] [WEB] Failed to create folder: %s\n", millis(), folderPath.c_str()); LOG_DBG("WEB", "Failed to create folder: %s", folderPath.c_str());
server->send(500, "text/plain", "Failed to create folder"); server->send(500, "text/plain", "Failed to create folder");
} }
} }
@@ -763,12 +775,12 @@ void CrossPointWebServer::handleRename() const {
return; return;
} }
if (!SdMan.exists(itemPath.c_str())) { if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found"); server->send(404, "text/plain", "Item not found");
return; return;
} }
FsFile file = SdMan.open(itemPath.c_str()); FsFile file = Storage.open(itemPath.c_str());
if (!file) { if (!file) {
server->send(500, "text/plain", "Failed to open file"); server->send(500, "text/plain", "Failed to open file");
return; return;
@@ -789,7 +801,7 @@ void CrossPointWebServer::handleRename() const {
} }
newPath += newName; newPath += newName;
if (SdMan.exists(newPath.c_str())) { if (Storage.exists(newPath.c_str())) {
file.close(); file.close();
server->send(409, "text/plain", "Target already exists"); server->send(409, "text/plain", "Target already exists");
return; return;
@@ -800,10 +812,10 @@ void CrossPointWebServer::handleRename() const {
file.close(); file.close();
if (success) { if (success) {
Serial.printf("[%lu] [WEB] Renamed file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); LOG_DBG("WEB", "Renamed file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(200, "text/plain", "Renamed successfully"); server->send(200, "text/plain", "Renamed successfully");
} else { } else {
Serial.printf("[%lu] [WEB] Failed to rename file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); LOG_ERR("WEB", "Failed to rename file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(500, "text/plain", "Failed to rename file"); server->send(500, "text/plain", "Failed to rename file");
} }
} }
@@ -839,12 +851,12 @@ void CrossPointWebServer::handleMove() const {
} }
} }
if (!SdMan.exists(itemPath.c_str())) { if (!Storage.exists(itemPath.c_str())) {
server->send(404, "text/plain", "Item not found"); server->send(404, "text/plain", "Item not found");
return; return;
} }
FsFile file = SdMan.open(itemPath.c_str()); FsFile file = Storage.open(itemPath.c_str());
if (!file) { if (!file) {
server->send(500, "text/plain", "Failed to open file"); server->send(500, "text/plain", "Failed to open file");
return; return;
@@ -855,12 +867,12 @@ void CrossPointWebServer::handleMove() const {
return; return;
} }
if (!SdMan.exists(destPath.c_str())) { if (!Storage.exists(destPath.c_str())) {
file.close(); file.close();
server->send(404, "text/plain", "Destination not found"); server->send(404, "text/plain", "Destination not found");
return; return;
} }
FsFile destDir = SdMan.open(destPath.c_str()); FsFile destDir = Storage.open(destPath.c_str());
if (!destDir || !destDir.isDirectory()) { if (!destDir || !destDir.isDirectory()) {
if (destDir) { if (destDir) {
destDir.close(); destDir.close();
@@ -882,7 +894,7 @@ void CrossPointWebServer::handleMove() const {
server->send(200, "text/plain", "Already in destination"); server->send(200, "text/plain", "Already in destination");
return; return;
} }
if (SdMan.exists(newPath.c_str())) { if (Storage.exists(newPath.c_str())) {
file.close(); file.close();
server->send(409, "text/plain", "Target already exists"); server->send(409, "text/plain", "Target already exists");
return; return;
@@ -893,10 +905,10 @@ void CrossPointWebServer::handleMove() const {
file.close(); file.close();
if (success) { if (success) {
Serial.printf("[%lu] [WEB] Moved file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); LOG_DBG("WEB", "Moved file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(200, "text/plain", "Moved successfully"); server->send(200, "text/plain", "Moved successfully");
} else { } else {
Serial.printf("[%lu] [WEB] Failed to move file: %s -> %s\n", millis(), itemPath.c_str(), newPath.c_str()); LOG_ERR("WEB", "Failed to move file: %s -> %s", itemPath.c_str(), newPath.c_str());
server->send(500, "text/plain", "Failed to move file"); server->send(500, "text/plain", "Failed to move file");
} }
} }
@@ -927,7 +939,7 @@ void CrossPointWebServer::handleDelete() const {
// Check if item starts with a dot (hidden/system file) // Check if item starts with a dot (hidden/system file)
if (itemName.startsWith(".")) { if (itemName.startsWith(".")) {
Serial.printf("[%lu] [WEB] Delete rejected - hidden/system item: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Delete rejected - hidden/system item: %s", itemPath.c_str());
server->send(403, "text/plain", "Cannot delete system files"); server->send(403, "text/plain", "Cannot delete system files");
return; return;
} }
@@ -935,26 +947,26 @@ void CrossPointWebServer::handleDelete() const {
// Check against explicitly protected items // Check against explicitly protected items
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
if (itemName.equals(HIDDEN_ITEMS[i])) { if (itemName.equals(HIDDEN_ITEMS[i])) {
Serial.printf("[%lu] [WEB] Delete rejected - protected item: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Delete rejected - protected item: %s", itemPath.c_str());
server->send(403, "text/plain", "Cannot delete protected items"); server->send(403, "text/plain", "Cannot delete protected items");
return; return;
} }
} }
// Check if item exists // Check if item exists
if (!SdMan.exists(itemPath.c_str())) { if (!Storage.exists(itemPath.c_str())) {
Serial.printf("[%lu] [WEB] Delete failed - item not found: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Delete failed - item not found: %s", itemPath.c_str());
server->send(404, "text/plain", "Item not found"); server->send(404, "text/plain", "Item not found");
return; return;
} }
Serial.printf("[%lu] [WEB] Attempting to delete %s: %s\n", millis(), itemType.c_str(), itemPath.c_str()); LOG_DBG("WEB", "Attempting to delete %s: %s", itemType.c_str(), itemPath.c_str());
bool success = false; bool success = false;
if (itemType == "folder") { if (itemType == "folder") {
// For folders, try to remove (will fail if not empty) // For folders, try to remove (will fail if not empty)
FsFile dir = SdMan.open(itemPath.c_str()); FsFile dir = Storage.open(itemPath.c_str());
if (dir && dir.isDirectory()) { if (dir && dir.isDirectory()) {
// Check if folder is empty // Check if folder is empty
FsFile entry = dir.openNextFile(); FsFile entry = dir.openNextFile();
@@ -962,27 +974,189 @@ void CrossPointWebServer::handleDelete() const {
// Folder is not empty // Folder is not empty
entry.close(); entry.close();
dir.close(); dir.close();
Serial.printf("[%lu] [WEB] Delete failed - folder not empty: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Delete failed - folder not empty: %s", itemPath.c_str());
server->send(400, "text/plain", "Folder is not empty. Delete contents first."); server->send(400, "text/plain", "Folder is not empty. Delete contents first.");
return; return;
} }
dir.close(); dir.close();
} }
success = SdMan.rmdir(itemPath.c_str()); success = Storage.rmdir(itemPath.c_str());
} else { } else {
// For files, use remove // For files, use remove
success = SdMan.remove(itemPath.c_str()); success = Storage.remove(itemPath.c_str());
} }
if (success) { if (success) {
Serial.printf("[%lu] [WEB] Successfully deleted: %s\n", millis(), itemPath.c_str()); LOG_DBG("WEB", "Successfully deleted: %s", itemPath.c_str());
server->send(200, "text/plain", "Deleted successfully"); server->send(200, "text/plain", "Deleted successfully");
} else { } else {
Serial.printf("[%lu] [WEB] Failed to delete: %s\n", millis(), itemPath.c_str()); LOG_ERR("WEB", "Failed to delete: %s", itemPath.c_str());
server->send(500, "text/plain", "Failed to delete item"); server->send(500, "text/plain", "Failed to delete item");
} }
} }
void CrossPointWebServer::handleSettingsPage() const {
sendHtmlContent(server.get(), SettingsPageHtml, sizeof(SettingsPageHtml));
LOG_DBG("WEB", "Served settings page");
}
void CrossPointWebServer::handleGetSettings() const {
auto settings = getSettingsList();
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
server->send(200, "application/json", "");
server->sendContent("[");
char output[512];
constexpr size_t outputSize = sizeof(output);
bool seenFirst = false;
JsonDocument doc;
for (const auto& s : settings) {
if (!s.key) continue; // Skip ACTION-only entries
doc.clear();
doc["key"] = s.key;
doc["name"] = s.name;
doc["category"] = s.category;
switch (s.type) {
case SettingType::TOGGLE: {
doc["type"] = "toggle";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
}
break;
}
case SettingType::ENUM: {
doc["type"] = "enum";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
} else if (s.valueGetter) {
doc["value"] = static_cast<int>(s.valueGetter());
}
JsonArray options = doc["options"].to<JsonArray>();
for (const auto& opt : s.enumValues) {
options.add(opt);
}
break;
}
case SettingType::VALUE: {
doc["type"] = "value";
if (s.valuePtr) {
doc["value"] = static_cast<int>(SETTINGS.*(s.valuePtr));
}
doc["min"] = s.valueRange.min;
doc["max"] = s.valueRange.max;
doc["step"] = s.valueRange.step;
break;
}
case SettingType::STRING: {
doc["type"] = "string";
if (s.stringGetter) {
doc["value"] = s.stringGetter();
} else if (s.stringPtr) {
doc["value"] = s.stringPtr;
}
break;
}
default:
continue;
}
const size_t written = serializeJson(doc, output, outputSize);
if (written >= outputSize) {
LOG_DBG("WEB", "Skipping oversized setting JSON for: %s", s.key);
continue;
}
if (seenFirst) {
server->sendContent(",");
} else {
seenFirst = true;
}
server->sendContent(output);
}
server->sendContent("]");
server->sendContent("");
LOG_DBG("WEB", "Served settings API");
}
void CrossPointWebServer::handlePostSettings() {
if (!server->hasArg("plain")) {
server->send(400, "text/plain", "Missing JSON body");
return;
}
const String body = server->arg("plain");
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, body);
if (err) {
server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str());
return;
}
auto settings = getSettingsList();
int applied = 0;
for (auto& s : settings) {
if (!s.key) continue;
if (!doc[s.key].is<JsonVariant>()) continue;
switch (s.type) {
case SettingType::TOGGLE: {
const int val = doc[s.key].as<int>() ? 1 : 0;
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = val;
}
applied++;
break;
}
case SettingType::ENUM: {
const int val = doc[s.key].as<int>();
if (val >= 0 && val < static_cast<int>(s.enumValues.size())) {
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
} else if (s.valueSetter) {
s.valueSetter(static_cast<uint8_t>(val));
}
applied++;
}
break;
}
case SettingType::VALUE: {
const int val = doc[s.key].as<int>();
if (val >= s.valueRange.min && val <= s.valueRange.max) {
if (s.valuePtr) {
SETTINGS.*(s.valuePtr) = static_cast<uint8_t>(val);
}
applied++;
}
break;
}
case SettingType::STRING: {
const std::string val = doc[s.key].as<std::string>();
if (s.stringSetter) {
s.stringSetter(val);
} else if (s.stringPtr && s.stringMaxLen > 0) {
strncpy(s.stringPtr, val.c_str(), s.stringMaxLen - 1);
s.stringPtr[s.stringMaxLen - 1] = '\0';
}
applied++;
break;
}
default:
break;
}
}
SETTINGS.saveToFile();
LOG_DBG("WEB", "Applied %d setting(s)", applied);
server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)");
}
// WebSocket callback trampoline // WebSocket callback trampoline
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
if (wsInstance) { if (wsInstance) {
@@ -999,7 +1173,7 @@ void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* p
void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
switch (type) { switch (type) {
case WStype_DISCONNECTED: case WStype_DISCONNECTED:
Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num); LOG_DBG("WS", "Client %u disconnected", num);
// Clean up any in-progress upload // Clean up any in-progress upload
if (wsUploadInProgress && wsUploadFile) { if (wsUploadInProgress && wsUploadFile) {
wsUploadFile.close(); wsUploadFile.close();
@@ -1007,21 +1181,21 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
String filePath = wsUploadPath; String filePath = wsUploadPath;
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName; filePath += wsUploadFileName;
SdMan.remove(filePath.c_str()); Storage.remove(filePath.c_str());
Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str()); LOG_DBG("WS", "Deleted incomplete upload: %s", filePath.c_str());
} }
wsUploadInProgress = false; wsUploadInProgress = false;
break; break;
case WStype_CONNECTED: { case WStype_CONNECTED: {
Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num); LOG_DBG("WS", "Client %u connected", num);
break; break;
} }
case WStype_TEXT: { case WStype_TEXT: {
// Parse control messages // Parse control messages
String msg = String((char*)payload); String msg = String((char*)payload);
Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str()); LOG_DBG("WS", "Text from client %u: %s", num, msg.c_str());
if (msg.startsWith("START:")) { if (msg.startsWith("START:")) {
// Parse: START:<filename>:<size>:<path> // Parse: START:<filename>:<size>:<path>
@@ -1046,18 +1220,18 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
if (!filePath.endsWith("/")) filePath += "/"; if (!filePath.endsWith("/")) filePath += "/";
filePath += wsUploadFileName; filePath += wsUploadFileName;
Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(), LOG_DBG("WS", "Starting upload: %s (%d bytes) to %s", wsUploadFileName.c_str(), wsUploadSize,
wsUploadSize, filePath.c_str()); filePath.c_str());
// Check if file exists and remove it // Check if file exists and remove it
esp_task_wdt_reset(); esp_task_wdt_reset();
if (SdMan.exists(filePath.c_str())) { if (Storage.exists(filePath.c_str())) {
SdMan.remove(filePath.c_str()); Storage.remove(filePath.c_str());
} }
// Open file for writing // Open file for writing
esp_task_wdt_reset(); esp_task_wdt_reset();
if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) { if (!Storage.openFileForWrite("WS", filePath, wsUploadFile)) {
wsServer->sendTXT(num, "ERROR:Failed to create file"); wsServer->sendTXT(num, "ERROR:Failed to create file");
wsUploadInProgress = false; wsUploadInProgress = false;
return; return;
@@ -1113,8 +1287,8 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t*
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;
Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), LOG_DBG("WS", "Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)", wsUploadFileName.c_str(), wsUploadSize,
wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); elapsed, kbps);
// Clear epub cache to prevent stale metadata issues when overwriting files // Clear epub cache to prevent stale metadata issues when overwriting files
String filePath = wsUploadPath; String filePath = wsUploadPath;

View File

@@ -1,6 +1,6 @@
#pragma once #pragma once
#include <SDCardManager.h> #include <HalStorage.h>
#include <WebServer.h> #include <WebServer.h>
#include <WebSocketsServer.h> #include <WebSocketsServer.h>
#include <WiFiUdp.h> #include <WiFiUdp.h>
@@ -100,4 +100,9 @@ class CrossPointWebServer {
void handleRename() const; void handleRename() const;
void handleMove() const; void handleMove() const;
void handleDelete() const; void handleDelete() const;
// Settings handlers
void handleSettingsPage() const;
void handleGetSettings() const;
void handlePostSettings();
}; };

Some files were not shown because too many files have changed in this diff Show More