Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6174cbc613 | ||
|
|
9d147cade9 | ||
|
|
792aab6d97 | ||
|
|
b58b51bfbe | ||
|
|
f42fab1c66 | ||
|
|
c9188a7347 | ||
|
|
e03aa16330 | ||
|
|
3319aa1720 | ||
|
|
3dfbc0383f | ||
|
|
64364bec5d | ||
|
|
556b8aee5b | ||
|
|
b1d1d757ba | ||
|
|
5ba1d5747f | ||
|
|
9fe4dc5e38 | ||
|
|
b1d037569b | ||
|
|
9737cb335c | ||
|
|
98118d6e24 | ||
|
|
fdffc2e5d9 | ||
|
|
a2db43d235 | ||
|
|
63093e606c | ||
|
|
a4ac3b2788 | ||
|
|
35a45f9c1e | ||
|
|
3cc4069faa | ||
|
|
a3b8897977 | ||
|
|
f7f1a6f8bf | ||
|
|
7cad3cfb0e | ||
|
|
e66de575a1 | ||
|
|
bae29fc6ba | ||
|
|
379f49d165 |
@@ -0,0 +1,13 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Scope or Roadmap Question (start here if unsure)
|
||||
url: https://github.com/crosspoint-reader/crosspoint-reader/discussions
|
||||
about: |
|
||||
Not sure if your idea fits CrossPoint's scope? Start a Discussion before filing an issue.
|
||||
See SCOPE.md and ROADMAP.md for what is in, out, and currently paused.
|
||||
- name: Read the Scope document
|
||||
url: https://github.com/crosspoint-reader/crosspoint-reader/blob/master/SCOPE.md
|
||||
about: The authoritative list of what CrossPoint will and will not accept.
|
||||
- name: Read the Roadmap
|
||||
url: https://github.com/crosspoint-reader/crosspoint-reader/blob/master/ROADMAP.md
|
||||
about: Current phase, what is being closed out, and what comes next.
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Feature Request
|
||||
description: Propose a new feature, enhancement, or change to CrossPoint
|
||||
title: "Short, descriptive title of the request"
|
||||
labels: ["enhancement", "needs-scope-review"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to propose a change to CrossPoint!
|
||||
|
||||
**Before you continue, please read [SCOPE.md](../blob/master/SCOPE.md) and [ROADMAP.md](../blob/master/ROADMAP.md).**
|
||||
|
||||
CrossPoint is intentionally narrow. Most rejected proposals are rejected for scope reasons that are already
|
||||
documented. The checklist below exists to save both of us time.
|
||||
|
||||
If you are not sure whether your idea fits, open a [Discussion](../../discussions) first instead of filing
|
||||
this issue.
|
||||
|
||||
- type: checkboxes
|
||||
id: scope-check
|
||||
attributes:
|
||||
label: Scope Self-Check (required)
|
||||
description: Please confirm each of the following. If any are unchecked, your issue will likely be closed.
|
||||
options:
|
||||
- label: I have read SCOPE.md and ROADMAP.md.
|
||||
required: true
|
||||
- label: This is **not** a new theme or theming change (themes are temporarily closed pending the move to SD-loaded themes).
|
||||
required: true
|
||||
- label: This is **not** a new external network connector (sync engine, cloud storage, remote file access, OPDS extensions beyond what exists, or any new "talk to a server" feature).
|
||||
required: true
|
||||
- label: This is **not** an interactive app (game, calculator, notepad), writing/authoring tool, RSS/news/browser feature, media playback feature, or PDF rendering.
|
||||
required: true
|
||||
- label: The stock firmware does **not** already handle this well.
|
||||
required: true
|
||||
- label: No other popular CrossPoint fork already handles this well (or, if one does, I explain below why CrossPoint still needs it).
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem this solves
|
||||
description: What user-facing problem or reading-experience gap does this address? Be concrete.
|
||||
placeholder: e.g., "When reading in landscape, paragraph breaks are inconsistent because..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: stock-gap
|
||||
attributes:
|
||||
label: Why the stock firmware (and other forks) do not already solve this
|
||||
description: Explain which existing solutions you checked and why they fall short. This is the core scope filter.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed change
|
||||
description: A short description of what you would build or change. Focus on user impact; implementation details can come later.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: tradeoffs
|
||||
attributes:
|
||||
label: Memory / flash / complexity cost
|
||||
description: |
|
||||
CrossPoint runs on 380KB of RAM. Roughly how much DRAM, flash, or code complexity does this add?
|
||||
"Don't know" is a valid answer, but please attempt an estimate.
|
||||
placeholder: e.g., "Adds ~2KB flash for the new font tables, no DRAM impact at runtime."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Anything else relevant (links, screenshots, related discussions).
|
||||
validations:
|
||||
required: false
|
||||
@@ -3,16 +3,34 @@
|
||||
* **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.)
|
||||
* **What changes are included?**
|
||||
|
||||
## Scope Check
|
||||
|
||||
CrossPoint is intentionally narrow. See [SCOPE.md](../blob/master/SCOPE.md) and [ROADMAP.md](../blob/master/ROADMAP.md).
|
||||
Please confirm:
|
||||
|
||||
- [ ] I have read SCOPE.md and ROADMAP.md.
|
||||
- [ ] This PR is **not** a new built-in theme (themes are temporarily closed pending the move to SD-loaded themes).
|
||||
- [ ] This PR is **not** a new external network connector (sync engine, cloud storage, remote file access, etc.).
|
||||
- [ ] This PR is **not** an interactive app, writing tool, RSS/news/browser, media playback, or PDF feature.
|
||||
- [ ] The stock firmware does not already handle this well, **and** no other popular CrossPoint fork already does
|
||||
(or, if one does, I explain why CrossPoint still needs it below).
|
||||
- [ ] If this PR touches `freeink-sdk/`, `lib/hal/`, the bootloader, OTA, or recovery code, I have coordinated with
|
||||
the relevant maintainer.
|
||||
|
||||
**If this PR was opened against the previous (broader) scope and was already in flight under Phase 0, link the
|
||||
relevant Discussion or issue so reviewers can see the history.**
|
||||
|
||||
## Additional Context
|
||||
|
||||
* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks,
|
||||
* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks,
|
||||
specific areas to focus on).
|
||||
* Memory / flash impact, if known.
|
||||
|
||||
---
|
||||
|
||||
### AI Usage
|
||||
|
||||
While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it
|
||||
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 >**_
|
||||
|
||||
@@ -91,9 +91,13 @@ jobs:
|
||||
restore-keys: pio-${{ runner.os }}-
|
||||
|
||||
- name: Build CrossPoint
|
||||
# Both envs in ONE pio invocation: a second `pio run` can wipe the whole
|
||||
# .pio/build tree (project-checksum mismatch after the S3 toolchain
|
||||
# installs into the restored cache), deleting the default firmware.bin
|
||||
# before the artifact upload.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pio run | tee pio.log
|
||||
pio run -e default -e sticky | tee pio.log
|
||||
|
||||
- name: Extract firmware stats
|
||||
|
||||
|
||||
@@ -25,3 +25,10 @@ lib/EpdFont/scripts/output/
|
||||
# (worktrees, scheduled-task locks, settings.local, scout CLEANUP.md) out.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
/managed_components
|
||||
/.dummy
|
||||
/CMakeLists.txt
|
||||
/dependencies.lock
|
||||
/sdkconfig.default
|
||||
/sdkconfig.defaults
|
||||
sdkconfig.sticky
|
||||
|
||||
@@ -12,7 +12,7 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
|
||||
|
||||
## What can CrossPoint do?
|
||||
|
||||
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
|
||||
- **Reader engine**: EPUB 2/3 rendering with embedded-style option, image handling, hyphenation, kerning, chapter navigation, footnotes, bookmarks, dictionary lookups ([StarDict](docs/dictionary.md)), go-to-percent, auto page turn, orientation control, focus reading, KOReader progress sync and more.
|
||||
|
||||
- **Various formats**: native handling for `.epub`, `.xtc/.xtch`, `.txt`, and `.bmp`.
|
||||
|
||||
@@ -42,8 +42,6 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
|
||||
|
||||
### Coming soon:
|
||||
|
||||
- Dictionary lookup — inline word lookup without leaving the reader.
|
||||
|
||||
- More themes.
|
||||
|
||||
- Much more! stay tuned.
|
||||
@@ -138,6 +136,7 @@ Conversion runs the firmware repo's `lib/EpdFont/scripts/fontconvert_sdcard.py`
|
||||
- [Web server endpoints](./docs/webserver-endpoints.md)
|
||||
- [Project scope](./SCOPE.md)
|
||||
- [Contributing docs](./docs/contributing/README.md)
|
||||
- [Touch and UI development](./docs/contributing/touch-and-ui.md) - FreeInkUI components for new screens, the touch bridge for existing ones, and build envs for the non-Xteink touch devices
|
||||
|
||||
---
|
||||
|
||||
@@ -160,6 +159,24 @@ cd crosspoint-reader
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### Nix/NixOS
|
||||
|
||||
Nix/NixOS users can enter the development shell with either `nix develop` (flakes) or `nix-shell`:
|
||||
|
||||
```bash
|
||||
nix develop -f nix
|
||||
# or
|
||||
nix-shell nix
|
||||
```
|
||||
|
||||
To flash a connected ESP32-C3 device, enable PlatformIO's udev rules in your NixOS configuration:
|
||||
|
||||
```nix
|
||||
services.udev.packages = with pkgs; [ platformio-core.udev ];
|
||||
```
|
||||
|
||||
After rebuilding the system configuration, reconnect the device or reload udev rules.
|
||||
|
||||
### Build / flash / monitor
|
||||
|
||||
```bash
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# CrossPoint Reader Roadmap
|
||||
|
||||
This roadmap describes how CrossPoint is moving through the tighter scope defined in [SCOPE.md](SCOPE.md). It is
|
||||
intentionally phased: Phase 0 closed out the commitments already in flight before locking down to the stricter
|
||||
"fill gaps the stock firmware leaves" delineator.
|
||||
|
||||
Phases are sequential. We do not start the next phase until the prior one is wrapped or explicitly carried over.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 - Close Out Legacy Scope Items — **COMPLETE**
|
||||
|
||||
**Goal:** Land the work that was already in motion under the prior, broader scope so contributors are not left
|
||||
hanging, and so we enter the stricter phases with a clean slate.
|
||||
|
||||
**Landed in Phase 0:**
|
||||
|
||||
* **RTL support PRs.** The in-flight right-to-left work was reviewed, iterated, and merged.
|
||||
* **Dictionary PR.** The offline dictionary lookup work was reviewed and merged.
|
||||
* **Bookmarks** feature. First-class navigation markers in EPUBs.
|
||||
* ~~**Transparent sleep screens.**~~ Shelved; not picked back up under the stricter phases.
|
||||
|
||||
Phase 0 is closed. The tighter scope in [SCOPE.md](SCOPE.md) is now fully enforced. "But it was on the old roadmap"
|
||||
is not a valid argument for accepting a PR.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 - Consolidation, Footprint, and Multi-Device Support — **IN PROGRESS**
|
||||
|
||||
**Goal:** Reduce memory and flash usage, clean up the codebase, and land the SDK / HAL generalization work so
|
||||
CrossPoint runs cleanly on ESP32-based e-reader hardware beyond Xteink (X3 / X4), including ESP32-S3 class devices.
|
||||
|
||||
**Focus areas:**
|
||||
|
||||
* DRAM and heap fragmentation reduction across the reader core.
|
||||
* Flash footprint reduction (dead code, redundant strings, oversized tables).
|
||||
* Refactors that tighten the HAL / SDK boundary.
|
||||
* Pluggable per-device SDK layers (display, input, storage, battery) and per-device build configuration without
|
||||
forking the reader core.
|
||||
* Documentation for adding a new ESP32 e-reader target.
|
||||
* E-ink driver refinement (ghosting, partial update behavior).
|
||||
|
||||
**Closed during this phase:** new themes built into firmware, new external network connectors (sync engines, cloud
|
||||
storage, remote file access).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 - Languages, Fonts, and Themes
|
||||
|
||||
**Goal:** With the codebase smaller and portable, make reading great in every language: multi-language support,
|
||||
better font support with custom fonts, UI translations, and themes loaded from the SD card instead of consuming
|
||||
flash.
|
||||
|
||||
**Focus areas:**
|
||||
|
||||
* Multi-language reading support (underserved languages, complex script support where realistic on ESP32 hardware).
|
||||
* Better font support and custom fonts.
|
||||
* UI languages and localization.
|
||||
* Moving themes off-firmware to SD-loaded assets (see SCOPE.md Section 6).
|
||||
* **Moving hyphenation files off-firmware.** Hyphenation rules vary per language and the files are large (German
|
||||
alone is ~200KB). Today these eat flash budget that should be available for the reader core. The plan is to build
|
||||
a downloader analogous to the existing font downloader and store the dictionaries on SD / SPIFFS, loading on
|
||||
demand. This unlocks better hyphenation for long-word languages (German, Finnish, Norwegian, etc.) without paying
|
||||
the flash cost up front.
|
||||
|
||||
This phase depends on Phase 1 cleanup landing first; otherwise we generalize a moving target.
|
||||
|
||||
---
|
||||
|
||||
## Out of Roadmap
|
||||
|
||||
The following are explicitly *not* on the roadmap. They may live in other CrossPoint forks; they will not be picked
|
||||
up here:
|
||||
|
||||
* Interactive apps (games, calculators, notepads).
|
||||
* Writing / authoring tools.
|
||||
* Active connectivity features (RSS, news, browsers).
|
||||
* PDF rendering as a first-class format.
|
||||
|
||||
See [SCOPE.md](SCOPE.md) for the full rationale.
|
||||
|
||||
---
|
||||
|
||||
## How This Roadmap Changes
|
||||
|
||||
* Phase boundaries are decided by maintainers, not by individual PRs.
|
||||
* If a phase needs to be extended or an item carried over, that is documented here with a short note.
|
||||
* Proposals for new phases or reordering should go through a Discussion first.
|
||||
@@ -1,62 +1,174 @@
|
||||
# Project Vision & Scope: CrossPoint Reader
|
||||
|
||||
The goal of CrossPoint Reader is to create an efficient, open-source reading experience for the Xteink X4. We believe a
|
||||
dedicated e-reader should do one thing exceptionally well: **facilitate focused reading.**
|
||||
The goal of CrossPoint Reader is to create an efficient, open-source reading experience for ESP32-based e-reader
|
||||
devices. Xteink hardware (X3, X4) is where the project started and remains a primary target, but CrossPoint is
|
||||
explicitly broadening to support the wider ecosystem of small ESP32 e-ink readers. We believe a dedicated e-reader
|
||||
should do one thing exceptionally well: **facilitate focused reading.**
|
||||
|
||||
## 1. Core Mission
|
||||
|
||||
To provide a lightweight, high-performance firmware that maximizes the potential of the X4, prioritizing legibility and
|
||||
usability over "swiss-army-knife" functionality.
|
||||
To provide a lightweight, high-performance firmware that maximizes the potential of ESP32-based e-reader hardware,
|
||||
prioritizing legibility, performance, and usability over "swiss-army-knife" functionality.
|
||||
|
||||
## 2. Scope
|
||||
CrossPoint is **not** a kitchen-sink firmware, and it is **not** Xteink-only. We want clean, maintainable code that
|
||||
the community can build on, and that runs across the range of ESP32 e-reader devices (ESP32-C3, ESP32-S3, and
|
||||
adjacent variants). Every accepted change should make that goal easier, not harder. Device-specific code should live
|
||||
behind the HAL / SDK boundary so the reader core stays portable.
|
||||
|
||||
## 2. Guiding Principle: Fill Gaps the Stock Firmware Leaves
|
||||
|
||||
CrossPoint exists to do the things the stock firmware does poorly or not at all. New work is evaluated against that
|
||||
delineator:
|
||||
|
||||
* **Does the stock firmware already do this well?** If yes, we will not duplicate it.
|
||||
* **Is another popular CrossPoint fork already solving this well?** If yes, we generally defer to that fork rather than
|
||||
fragmenting the ecosystem.
|
||||
* **Does this directly improve the reading experience or the firmware's long-term maintainability?** If no, it is out
|
||||
of scope.
|
||||
|
||||
### Language Priority
|
||||
|
||||
English is the priority language for new features and UI work. Other languages are considered, with preference given
|
||||
to cases where the stock firmware fails (rendering, input, fonts, layout) or where existing CrossPoint forks have not
|
||||
addressed the gap.
|
||||
|
||||
## 3. Current Focus (Until Further Notice)
|
||||
|
||||
We are intentionally narrowing scope to consolidate the codebase as we open it up to more ESP32 e-reader devices.
|
||||
During this period, the priorities are:
|
||||
|
||||
* **Memory footprint:** Reducing DRAM usage and heap fragmentation. The ESP32-C3 is the tightest target and sets the
|
||||
ceiling, but the gains benefit every ESP32 variant we run on.
|
||||
* **Flash footprint:** Trimming binary size to leave room for additional device targets and features.
|
||||
* **Code cleanup:** Refactoring, removing dead code, tightening abstractions, and improving readability.
|
||||
* **Reading experience:** EPUB parsing and rendering, typography, hyphenation, line spacing, font handling, and
|
||||
legibility improvements.
|
||||
|
||||
### Temporarily Closed Areas
|
||||
|
||||
PRs in the following areas will be closed until this notice is lifted. Adding these now makes the cleanup and
|
||||
multi-device work materially harder:
|
||||
|
||||
* **New themes.** The existing theming surface is frozen.
|
||||
* **New external network connectors.** This includes sync engines, cloud storage clients, OPDS extensions beyond what
|
||||
exists, remote file access, and any new "talk to a server" feature.
|
||||
|
||||
If you are unsure whether your idea falls into one of these categories, open a Discussion first.
|
||||
|
||||
## 4. Scope
|
||||
|
||||
### In-Scope
|
||||
|
||||
*These are features that directly improve the primary purpose of the device.*
|
||||
*Features that directly improve the core reading experience or the firmware's maintainability.*
|
||||
|
||||
* **User Experience:** E.g. User-friendly interfaces, and interactions, both inside the reader and navigating the
|
||||
firmware. This includes things like button mapping, book loading, and book navigation like bookmarks.
|
||||
* **Document Rendering:** E.g. Support for rendering documents (primarily EPUB) and improvements to the rendering
|
||||
engine.
|
||||
* **Format Optimization:** E.g. Efficiently parsing EPUB (CSS/Images) and other documents within the device's
|
||||
capabilities.
|
||||
* **Typography & Legibility:** E.g. Custom font support, hyphenation engines, and adjustable line spacing.
|
||||
* **E-Ink Driver Refinement:** E.g. Reducing full-screen flashes (ghosting management) and improving general rendering.
|
||||
* **Library Management:** E.g. Simple, intuitive ways to organize and navigate a collection of books.
|
||||
* **Local Transfer:** E.g. Simple, "pull" based book loading via a basic web-server or public and widely-used standards.
|
||||
* **Language Support:** E.g. Support for multiple languages both in the reader and in the interfaces.
|
||||
* **Reference Tools:** E.g. Local dictionary lookup. Providing quick, offline definitions to enhance comprehension
|
||||
without breaking focus.
|
||||
* **Clock Display (device dependent):**
|
||||
|
||||
| Device | Scope |
|
||||
| -- | -- |
|
||||
| X3 | The X3 uses a dedicated DS3231 RTC, which maintains accurate time across sleep cycles and can be treated as a reliable wall clock. |
|
||||
| X4 | The X4 relies on the ESP32-C3's internal RTC, which drifts significantly during deep sleep. NTP sync could correct this, with an appropriate user experience around connecting to the internet on wake or on demand. This causes some tension with the **Active Connectivity** section below, so please open a discussion about this UX if it's a feature you would find useful. |
|
||||
* **EPUB Rendering & Optimization:** Improvements to the rendering engine, CSS/image handling, and parsing
|
||||
performance.
|
||||
* **Typography & Legibility:** Custom font support, hyphenation, line and paragraph spacing, margins.
|
||||
* **E-Ink Driver Refinement:** Reducing full-screen flashes (ghosting management) and improving general rendering.
|
||||
* **Reading UX:** Bookmarks, progress tracking, button mapping, page navigation, and other in-reader interactions.
|
||||
* **Library Management:** Simple, intuitive ways to organize and navigate a local book collection.
|
||||
* **Local Transfer:** Simple, pull-based loading via the existing web server or widely used standards. New connectors
|
||||
are currently paused (see Section 3).
|
||||
* **Reference Tools:** Local, offline dictionary lookup.
|
||||
* **Memory, Flash, and Code Quality:** Refactors and cleanups that reduce resource use or improve maintainability,
|
||||
even without a user-visible feature.
|
||||
|
||||
### Out-of-Scope
|
||||
|
||||
*These items are rejected because they compromise the device's stability or mission.*
|
||||
*Rejected because they compromise the device's stability, maintainability, or core mission.*
|
||||
|
||||
* **Interactive Apps:** No Notepads, Calculators, or Games. This is a reader, not a PDA.
|
||||
* **Active Connectivity:** No RSS readers, News aggregators, or Web browsers. Background Wi-Fi tasks drain the battery
|
||||
and complicate the single-core CPU's execution.
|
||||
* **Media Playback:** No Audio players or Audio-books.
|
||||
* **Complex Annotation:** No typed out notes. These features are better suited for devices with better input
|
||||
capabilities and more powerful chips.
|
||||
* **Interactive Apps:** No notepads, calculators, or games. These belong in other forks and are not part of
|
||||
CrossPoint's focus.
|
||||
* **Writing / Authoring Tools:** No typed notes, journals, or editors. Input hardware and RAM are wrong for this, and
|
||||
other forks already explore this space.
|
||||
* **Active Connectivity:** No RSS readers, news aggregators, or web browsers. Background Wi-Fi drains the battery and
|
||||
complicates the single-core CPU.
|
||||
* **Media Playback:** No audio players or audiobooks.
|
||||
* **Complex Annotation:** No typed-out notes.
|
||||
* **Duplication of stock firmware behavior** that already works well.
|
||||
* **PDF Rendering:** PDFs are fixed-layout documents, so rendering them requires displaying pages as images rather
|
||||
than reflowable text, resulting in constant panning and zooming that makes for a poor reading experience on e-ink.
|
||||
Out of scope on the current hardware class.
|
||||
|
||||
### In-scope — Technically Unsupported
|
||||
## 5. Idea Evaluation
|
||||
|
||||
*These features align with CrossPoint's goals but are impractical on the current hardware or produce poor UX.*
|
||||
Before proposing a feature, ask:
|
||||
|
||||
* **PDF Rendering:** PDFs are fixed-layout documents, so rendering them requires displaying pages as images rather than reflowable text — resulting in constant panning and zooming that makes for a poor reading experience on e-ink.
|
||||
1. Does the stock firmware already handle this well? We should hit that bar or surpass it.
|
||||
2. Does another popular CrossPoint fork already handle this well? If yes, we usually defer.
|
||||
3. Does it improve the core reading experience, or reduce memory / flash / code complexity?
|
||||
4. Is it in one of the temporarily closed areas (new themes, new network connectors)? If yes, wait.
|
||||
5. Would adding it make the codebase harder to clean up or harder to port to other devices? If yes, rework or defer.
|
||||
|
||||
## 3. Idea Evaluation
|
||||
If the idea passes those filters, it is a candidate. If you are not sure, open a **Discussion** before writing any
|
||||
code.
|
||||
|
||||
While I appreciate the desire to add new and exciting features to CrossPoint Reader, CrossPoint Reader is designed to be
|
||||
a lightweight, reliable, and performant e-reader. Things which distract or compromise the device's core mission will not
|
||||
be accepted. As a guiding question, consider if your idea improve the "core reading experience" for the average user,
|
||||
and, critically, not distract from that reading experience.
|
||||
> **Note to Contributors:** CrossPoint is intentionally narrow. "It would be cool if..." features are not enough; the
|
||||
> bar is "this fixes something the stock firmware does poorly, or it makes the firmware leaner and easier to maintain."
|
||||
|
||||
> **Note to Contributors:** If you are unsure if your idea fits the scope, please open a **Discussion** before you start
|
||||
> coding!
|
||||
## 6. Calls to Action
|
||||
|
||||
These are the areas where contributor help is most valuable right now. If you want to take one of these on, open a
|
||||
Discussion or issue first so we can coordinate.
|
||||
|
||||
### Theme System: Move Themes Off-Firmware
|
||||
|
||||
We want to abstract themes out of the firmware entirely so they no longer consume flash, and instead load from the SD
|
||||
card. This directly supports the current focus on flash footprint and code cleanup.
|
||||
|
||||
* **Status:** [@itsthisjustin](https://github.com/itsthisjustin) plans to take this on eventually but is very open to
|
||||
someone else claiming it sooner.
|
||||
* **Why it matters:** Every built-in theme costs flash that we would rather spend on rendering, fonts, or future
|
||||
device support. SD-loaded themes also let users customize without rebuilding firmware.
|
||||
* **How to claim:** Comment on the relevant Discussion (or open one) before starting.
|
||||
|
||||
### SDK Abstraction: Generalize the Lower Layers for All ESP32 E-Readers
|
||||
|
||||
To support the broader range of ESP32-based e-reader hardware, the SDK layers (display, input, storage, battery,
|
||||
etc.) need to be abstracted away from Xteink-specific assumptions and made pluggable per device.
|
||||
|
||||
* **Status:** [@itsthisjustin](https://github.com/itsthisjustin) is actively working on this.
|
||||
* **How to help:** Coordinate with itsthisjustin before opening PRs that touch `freeink-sdk/` or `lib/hal/` so the
|
||||
work does not collide. Smaller adjacent cleanups (removing hardcoded assumptions, tightening HAL boundaries) are
|
||||
welcome if scoped against the in-flight direction.
|
||||
|
||||
### Identifying Other Stock-Firmware Gaps
|
||||
|
||||
We want help cataloguing things the stock firmware (and other popular CrossPoint forks) handle poorly or not at all,
|
||||
so future work has a clear target list. Particularly interested in:
|
||||
|
||||
* **RTL (right-to-left) text support:** Arabic, Hebrew, Persian, and similar scripts.
|
||||
* **Languages with poor stock and fork coverage:** Especially those that need shaping, complex layout, or
|
||||
non-Latin font work that nobody is handling well today.
|
||||
* **Other gaps:** Rendering edge cases, accessibility issues, input quirks, anything stock does badly and existing
|
||||
forks have not fixed.
|
||||
|
||||
* **Status:** [@uxjulia](https://github.com/uxjulia) is coordinating this effort.
|
||||
|
||||
If you can read or use the device in one of these languages, your feedback (even without code) is genuinely useful.
|
||||
Open a Discussion with concrete examples (screenshots, sample EPUBs, expected vs actual behavior) and we will
|
||||
prioritize from there.
|
||||
|
||||
## 7. Funding and Contributor Sustainability
|
||||
|
||||
CrossPoint uses [Royalty.dev](https://royalty.dev) (yes, a product built by [@itsthisjustin](https://github.com/itsthisjustin))
|
||||
to fund contributors. There has been some tension in the community around this, so the intent is being clarified
|
||||
here directly.
|
||||
|
||||
**Why we do this:**
|
||||
|
||||
* To maintain long-term interest from contributors and maintainers, in direct response to substantial community
|
||||
requests for a way to give back.
|
||||
* To motivate contributors to invest in the *core* project rather than spinning up competing forks.
|
||||
* To help pay for new ESP32 devices so we can port CrossPoint to additional hardware.
|
||||
* To give the project a credible long-term path to sustainability.
|
||||
|
||||
**How it works:**
|
||||
|
||||
* Funds are distributed automatically to contributors based on impact to the codebase and tenure on the project.
|
||||
* Over **$600** was raised in the first few days after opening up funding, which is a signal the demand is real.
|
||||
* The exact scoring methodology is published at <https://app.royalty.dev/transparency>.
|
||||
|
||||
**This is not fixed in stone.** The weighting, eligibility, and distribution rules can be tweaked as we learn what
|
||||
works for this project. If you have concerns or suggestions about how funds are allocated, open a Discussion. The
|
||||
goal is a system that fairly recognizes the people doing the work, not a perfect one on day one.
|
||||
|
||||
+47
-30
@@ -28,8 +28,10 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
|
||||
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
|
||||
- [Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)](#option-a-crosspoint-sync-server-synccrosspointreadercom-default)
|
||||
- [Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)](#option-b-legacy-public-koreader-server-synckoreaderrocks)
|
||||
- [Option C: Self-Hosted Server (Docker Compose)](#option-c-self-hosted-server-docker-compose)
|
||||
- [Syncing While Reading](#syncing-while-reading)
|
||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||
- [Cover settings](#cover-settings)
|
||||
- [Custom images](#custom-images)
|
||||
@@ -261,6 +263,8 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
- "ON" - Vertical space will be added between paragraphs in Reading Mode
|
||||
- "OFF" - Paragraphs will not have vertical space added, but will have first-line indentation
|
||||
|
||||
- **Dictionary**: Select the StarDict dictionary used for word lookups while reading, or "None" to disable lookups. *(Only shown when at least one dictionary folder exists under `/dictionaries/` on the SD card — see [docs/dictionary.md](docs/dictionary.md) for setup and usage.)*
|
||||
|
||||
- **Text Anti-Aliasing**: Whether to show smooth grey edges (anti-aliasing) on text in reading mode. Note this slows down page turns slightly.
|
||||
|
||||
- **Images**: Whether to display embedded images (JPG/PNG) found in EPUB files; options are "ON" (default) or "OFF".
|
||||
@@ -280,6 +284,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
- **Long-press Menu**: Selects the function bound to holding the menu button (Confirm) while reading an EPUB. **Cycles through the available functions** each time the setting is selected — additional functions may be added in future releases, so this is not a binary on/off toggle. A short press of Confirm always opens the reader menu as normal:
|
||||
- "Bookmark" (default) - Hold Confirm (~0.4 second) to drop a bookmark at the current page.
|
||||
- "KOSync" - Hold Confirm (~1 second) to launch KOReader sync directly.
|
||||
- "Dictionary" - Hold Confirm (~0.4 second) to start dictionary word selection on the current page (see [docs/dictionary.md](docs/dictionary.md)).
|
||||
- "Disabled" - Long-press is ignored; only short-press opens the reader menu.
|
||||
|
||||
- **Short Power Button Click**: Controls the effect of a short click of the power button:
|
||||
@@ -297,7 +302,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
|
||||
|
||||
- **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates.
|
||||
|
||||
- **KOReader Sync**: Options for setting up KOReader for syncing book progress.
|
||||
- **KOReader Sync**: Options for setting up KOReader for syncing book progress. **Smart sync** is the default for new configurations and auto-resolves simple push/pull decisions. Existing credential files retain **Ask every time** when migrated; you can switch Sync Behavior at any time if you prefer manual confirmation.
|
||||
|
||||
- **OPDS Servers**: Manage one or more OPDS [(Open Publication Distribution System)](https://en.wikipedia.org/wiki/Open_Publication_Distribution_System) libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below.
|
||||
|
||||
@@ -360,9 +365,37 @@ Behavior notes:
|
||||
CrossPoint can sync reading progress with KOReader-compatible sync servers.
|
||||
It also interoperates with KOReader apps/devices when they use the same server and credentials.
|
||||
|
||||
##### Option A: Free Public Server (`sync.koreader.rocks`)
|
||||
##### Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)
|
||||
|
||||
1. Register a user once (only if needed):
|
||||
When **Sync Server URL** is left empty, CrossPoint uses the free CrossPoint sync server at `https://sync.crosspointreader.com`. It speaks the standard KOReader sync protocol (so KOReader apps can use it too) and additionally stores an exact spine/page position for lossless CrossPoint-to-CrossPoint sync.
|
||||
|
||||
1. On each CrossPoint device:
|
||||
|
||||
- Go to **Settings -> System -> KOReader Sync**.
|
||||
|
||||
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
|
||||
|
||||
- Leave **Sync Server URL** empty (or set it to `https://sync.crosspointreader.com`).
|
||||
|
||||
- On the first device, run **Sign Up** once to create the account directly from the device. On every other device, just run **Authenticate**.
|
||||
|
||||
Accounts are per server. Existing `sync.koreader.rocks` credentials do not exist on the CrossPoint server; either sign up again with the same username/password or use Option B to keep using the legacy server.
|
||||
|
||||
##### Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)
|
||||
|
||||
Use this if you already sync KOReader devices against the official public server.
|
||||
|
||||
1. On each CrossPoint device:
|
||||
|
||||
- Go to **Settings -> System -> KOReader Sync**.
|
||||
|
||||
- Set **Sync Server URL** to `https://sync.koreader.rocks` (required; an empty URL now points at the CrossPoint server instead).
|
||||
|
||||
- Set **Username** and **Password** to your existing KOReader Sync credentials.
|
||||
|
||||
- Run **Authenticate**.
|
||||
|
||||
2. If you do not have an account yet, run **Sign Up** on the device, or register once with curl:
|
||||
|
||||
```bash
|
||||
USERNAME="user"
|
||||
@@ -375,27 +408,9 @@ curl -i "https://sync.koreader.rocks/users/create" \
|
||||
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
|
||||
```
|
||||
|
||||
Already have KOReader Sync credentials? Skip registration; basic sync only requires using the same existing username/password on all devices.
|
||||
|
||||
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
|
||||
|
||||
2. On each CrossPoint device:
|
||||
|
||||
- Go to **Settings -> System -> KOReader Sync**.
|
||||
|
||||
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
|
||||
|
||||
- Set **Sync Server URL** to `https://sync.koreader.rocks`, or leave it empty (both use the same default KOReader sync server).
|
||||
|
||||
- Run **Authenticate**.
|
||||
|
||||
3. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
|
||||
|
||||
- Choose **Apply Remote** to jump to remote progress.
|
||||
|
||||
- Choose **Upload Local** to push current progress.
|
||||
|
||||
##### Option B: Self-Hosted Server (Docker Compose)
|
||||
##### Option C: Self-Hosted Server (Docker Compose)
|
||||
|
||||
1. Start a sync server:
|
||||
|
||||
@@ -468,11 +483,12 @@ If this returns `HTTP 402` with `{"code":2002,"message":"Username is already reg
|
||||
|
||||
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
|
||||
|
||||
5. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**.
|
||||
|
||||
- Choose **Apply Remote** to jump to remote progress.
|
||||
|
||||
- Choose **Upload Local** to push current progress.
|
||||
##### Syncing While Reading
|
||||
|
||||
Once any of the options above is set up, press **Confirm** while reading to open the reader menu, then select **Sync Progress**. Alternatively, set **Settings -> Controls -> Long-press Menu** to **KOSync** and hold Confirm to launch sync directly.
|
||||
|
||||
- With **Sync Behavior** set to **Ask every time**, choose **Apply Remote** to jump to remote progress or **Upload Local** to push current progress.
|
||||
- With **Sync Behavior** set to **Smart sync**, CrossPoint auto-resolves simple cases: upload when no remote progress exists, confirm and leave both unchanged when local and remote progress are already synchronized, upload when local progress is further ahead, or apply remote when remote progress is further ahead.
|
||||
|
||||
### 3.7 Sleep Screen
|
||||
|
||||
@@ -570,7 +586,7 @@ If the device goes to sleep or you close the book while viewing a footnote, the
|
||||
* **Return to Home:** Press the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen.
|
||||
* **Return to Browse Files:** Press and hold the **Back** button to close the book and return to the **[Browse Files](#33-browse-files-screen)** screen.
|
||||
* **Reader Menu:** Press **Confirm** to open the **[Reader Menu](#5-reader-menu)**, which includes chapter navigation, reading options, and more.
|
||||
* **Long-press Confirm (configurable):** Holding **Confirm** runs the function chosen by the **Long-press Menu** setting in **[Controls Settings](#363-controls)** — "Bookmark" (default) drops a bookmark, "KOSync" launches KOReader Sync, "Disabled" does nothing. A short press always opens the Reader Menu.
|
||||
* **Long-press Confirm (configurable):** Holding **Confirm** runs the function chosen by the **Long-press Menu** setting in **[Controls Settings](#363-controls)** — "Bookmark" (default) drops a bookmark, "KOSync" launches KOReader Sync, "Dictionary" starts a word lookup, "Disabled" does nothing. A short press always opens the Reader Menu.
|
||||
|
||||
### Supported Languages
|
||||
|
||||
@@ -592,6 +608,7 @@ Available options include:
|
||||
|
||||
- **Select Chapter** – Open the table of contents to jump to a specific chapter (see [Chapter Selection](#51-chapter-selection) below).
|
||||
- **Footnotes** – Navigate to the footnotes for the current section *(only shown in books that contain footnotes)*.
|
||||
- **Look Up** – Select a word on the current page and show its dictionary definition (see [docs/dictionary.md](docs/dictionary.md)). Requires a dictionary to be selected in **Settings → Reader → Dictionary**.
|
||||
- **Reading Orientation** – Cycle through screen orientations without leaving the reader.
|
||||
- **Auto Turn (Pages Per Minute)** – Cycle through automatic page turn speed options for hands-free reading.
|
||||
- **Go to %** – Jump to a specific position in the book by percentage.
|
||||
|
||||
@@ -7,5 +7,6 @@ It is written for software developers who may be new to embedded development.
|
||||
- [Architecture Overview](./architecture.md)
|
||||
- [Development Workflow](./development-workflow.md)
|
||||
- [Testing and Debugging](./testing-debugging.md)
|
||||
- [Touch and UI Development](./touch-and-ui.md)
|
||||
|
||||
If you are new, start with [Getting Started](./getting-started.md).
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Touch and UI Development
|
||||
|
||||
CrossPoint now runs on touch devices (Seeed Sticky, M5Paper, LilyGo T5) alongside the button-only Xteink X3/X4. Every screen must work with both input styles. There are two supported ways to get there:
|
||||
|
||||
1. **New screens: build them with FreeInkUI components.** Touch hit-testing, tap highlighting, long-press, and button focus navigation come with the component; you never hand-roll coordinate math.
|
||||
2. **Existing screens and in-flight features: use the MappedInputManager touch bridge.** A small set of helpers adds tap/hold/swipe support to hand-rolled rendering without restructuring the activity.
|
||||
|
||||
If you are starting a new activity, use FreeInkUI. If you have a feature branch with a hand-rolled screen already working on buttons, use the bridge; do not rewrite mid-flight.
|
||||
|
||||
---
|
||||
|
||||
## Path 1: New screens with FreeInkUI
|
||||
|
||||
FreeInkUI (`freeink-sdk/libs/ui/FreeInkUI`, namespace `freeink::ui`) is an immediate-mode component library. The core idea:
|
||||
|
||||
- While a component renders, it registers its tappable areas ("hit rects") into the frame's interaction buffer via `Frame::hit(rect, action, value, inputMask, state)`.
|
||||
- Each loop you build an `InputSnapshot` from the input manager and route it against that buffer. If a tap (or mapped button press) lands in a registered rect, the routed `ActionEvent` tells you which action fired and with what value.
|
||||
- Components query `frame.stateFor(action, value)` while painting, so touch-down highlight and focus states render correctly without any per-activity code.
|
||||
|
||||
You get touch, hold highlighting, long-press, minimum touch-target sizing, and orientation-aware coordinates for free. The same action IDs fire from physical buttons, so one code path serves both input styles.
|
||||
|
||||
### Component inventory
|
||||
|
||||
All under `freeink-sdk/libs/ui/FreeInkUI/include/components/`:
|
||||
|
||||
| Category | Components |
|
||||
|---|---|
|
||||
| Controls | `button`, `checkbox`, `slider`, `progress-bar`, `header` |
|
||||
| Lists | `list` (virtualized), `table`, `dropdown`, `radio-group`, `setting-row`, `toggle-row`, `stepper-row` |
|
||||
| Keyboard | `keyboard` (QWERTY/AZERTY/QWERTZ/ES layouts), `key-grid` |
|
||||
| Overlays | `popup`, `option-dialog`, `context-menu`, `message-panel`, `toast` |
|
||||
| Bars | `status-bar`, `tab-bar`, `reader-chrome`, `battery-indicator`, `gesture-bar`, `tap-zones` |
|
||||
| Media | `book-card`, `cover-grid`, `cover-carousel`, `metric-card` |
|
||||
| Text | `text-field`, `text-area` |
|
||||
|
||||
### Integration skeleton
|
||||
|
||||
The in-tree reference is [`src/activities/util/KeyboardEntryActivity.cpp`](../../src/activities/util/KeyboardEntryActivity.cpp); it drives the FreeInkUI keyboard component inside a normal Activity. The shape:
|
||||
|
||||
```cpp
|
||||
#include <FreeInkUIGfxRenderer.h>
|
||||
namespace fui = freeink::ui;
|
||||
|
||||
// Render step: draw the component and (re)register its hit rects.
|
||||
fui::GfxRendererTarget target(renderer); // adapts GfxRenderer to FreeInkUI's DrawTarget
|
||||
target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID);
|
||||
const fui::DeviceContext device = target.deviceContext(); // orientation, safe area, touch sizing
|
||||
|
||||
fui::Frame<48> frame(target, device, fui::InputSnapshot{}, interactions); // 48 = max hit rects
|
||||
fui::KeyboardProps props;
|
||||
props.layout = ¤tLayout();
|
||||
props.keyAction = ACTION_KEY; // one action id; the key is the event value
|
||||
props.inputMask = fui::InputTouch | fui::InputLongPress;
|
||||
fui::keyboard(frame, kbRect, props); // draws AND registers hit rects
|
||||
```
|
||||
|
||||
```cpp
|
||||
// Input step (each loop): feed touch state, act on routed events.
|
||||
int tx, ty, tapX, tapY, hx, hy;
|
||||
const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty);
|
||||
const bool tapped = mappedInput.wasScreenTapped(tapX, tapY);
|
||||
const bool inContact = mappedInput.isScreenTouchHeld(hx, hy);
|
||||
|
||||
const auto result = touchRouter.update(interactions, pressedDown, tx, ty,
|
||||
tapped, tapX, tapY, inContact, millis());
|
||||
if (result.event) {
|
||||
activateValue(result.event.value, result.event.longPress);
|
||||
requestUpdate();
|
||||
} else if (result.activeChanged) {
|
||||
requestUpdate(); // repaint touch-down highlight
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `Frame<N>` is templated on the max number of hit rects; the buffer is stack/static, no heap.
|
||||
- `fui::TouchHoldRouter` (see `components/keyboard/keyboard.h`) synthesizes long-press while the finger is still down and swallows the eventual release. Use it whenever a component distinguishes tap from long-press.
|
||||
- For simpler screens without long-press, `FreeInkApp` / `Screen<N>` (`FreeInkApp.h`) is the ergonomic layer: it owns the interaction buffer, offers a top-to-bottom builder (`takeTop`, footer helpers, etc.), and its `render(input)` call routes the snapshot and dispatches registered action callbacks in one step. `snapshotFrom(input, device)` in `FreeInkUIInputManager.h` builds the `InputSnapshot` with orientation-aware touch mapping.
|
||||
- Everything stays allocation-free and works on the button-only devices unchanged: physical buttons route through the same interaction table via each hit rect's `inputMask`.
|
||||
|
||||
---
|
||||
|
||||
## Path 2: Adding touch to existing hand-rolled screens
|
||||
|
||||
For activities that draw their own rows, menus, and buttons, `MappedInputManager` ([src/MappedInputManager.h](../../src/MappedInputManager.h)) exposes bridge helpers. They all return **logical screen coordinates** (orientation already applied via `GfxRenderer::tapToLogical`); never touch raw normalized panel coordinates or the SDK `InputManager` directly.
|
||||
|
||||
| Helper | Use for |
|
||||
|---|---|
|
||||
| `wasScreenTapped(x, y)` | A completed tap (press + release), with logical coords |
|
||||
| `wasScreenTouchDown(x, y)` | Touch-down (held > 90 ms, not yet released): draw selection highlight |
|
||||
| `isScreenTouchHeld(x, y)` | Live contact position while the finger is down (drag tracking) |
|
||||
| `wasTapInRect(x, y, w, h)` | One-off hit test on a rectangle (a single button, a banner) |
|
||||
| `wasListItemTapped(index, count, selected, listTop, listHeight, hasSubtitle)` | Taps on a standard UITheme list; does the row/paging math for you |
|
||||
| `wasListItemTouchedDown(...)` | Same geometry, touch-down phase (highlight before activate) |
|
||||
| `rowTouch(row, top, rowStep, rowCount, xStart, xEnd, rowHeight)` | Any custom band of equal-height rows; returns `RowTouch::None/Down/Tap` |
|
||||
| `colTouch(col, left, colStep, colCount, yStart, yEnd, colWidth)` | Horizontal button bands (dialogs, prompts) |
|
||||
| `wasSwipe()` | Raw swipe direction if you need one beyond the global gestures |
|
||||
| `hasTouch()` | True when the device has a touch panel (rarely needed; helpers simply never fire without one) |
|
||||
|
||||
### Pattern A: standard themed list
|
||||
|
||||
One call per loop; UITheme owns the row geometry ([EpubReaderBookmarksActivity.cpp:123](../../src/activities/reader/EpubReaderBookmarksActivity.cpp)):
|
||||
|
||||
```cpp
|
||||
int tapped = -1;
|
||||
if (mappedInput.wasListItemTapped(tapped, bookmarks.size(), selectorIndex, listY, listHeight, true)) {
|
||||
selectorIndex = tapped;
|
||||
openBookmark();
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern B: custom rows with hold highlight
|
||||
|
||||
For non-theme row layouts, use `rowTouch` and distinguish `Down` (highlight) from `Tap` (activate), as in [EpubReaderFootnotesActivity.cpp](../../src/activities/reader/EpubReaderFootnotesActivity.cpp):
|
||||
|
||||
```cpp
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount,
|
||||
contentX, contentX + contentWidth);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = scrollOffset + row;
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectedIndex != touched) { selectedIndex = touched; requestUpdate(); }
|
||||
} else { // RowTouch::Tap
|
||||
selectedIndex = touched;
|
||||
activateSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
The `Down` state exists because e-ink repaints are slow: highlight on touch-down gives immediate feedback, activation happens on release.
|
||||
|
||||
### Global gestures: do not reimplement these
|
||||
|
||||
Three gestures are handled once, for every screen. Activities must not add their own edge-swipe handling:
|
||||
|
||||
| Gesture | Trigger | Where it is handled |
|
||||
|---|---|---|
|
||||
| Back | Right-swipe starting in the left 25% of the screen | Folded into `Button::Back`, so the existing `wasPressed(Button::Back)` in your activity already fires |
|
||||
| Home | Up-swipe starting in the bottom 14% | `ActivityManager::loop()`; pops to Home (activities can override via `handleHomeGesture()`) |
|
||||
| Menu | Down-swipe starting in the top 14% | Activities that have a menu check `wasMenuGesture()` themselves (the reader does this) |
|
||||
|
||||
Because the back gesture arrives as `Button::Back`, most button-era activities gain back-swipe support with zero changes. That is the bar to aim for: bridge helpers should make touch an additive layer over the button flow, not a second input state machine.
|
||||
|
||||
### Bridge rules
|
||||
|
||||
- Handle touch in `loop()` next to the existing button handling, one helper call per interaction zone, and `return` after consuming an event (mirrors the button pattern).
|
||||
- Never call the SDK `InputManager` or read GPIO directly; the HAL rule from the main guide applies to touch too.
|
||||
- Coordinates from the helpers are logical and orientation-correct on all four rotations; test at least Portrait and one Landscape mode before PR.
|
||||
- Nothing to clean up in `onExit()`; the helpers are stateless from the activity's point of view.
|
||||
|
||||
---
|
||||
|
||||
## Building and testing on non-Xteink devices
|
||||
|
||||
Each MCU family is its own binary: X3/X4 are ESP32-C3, Sticky and LilyGo T5 are ESP32-S3, M5Paper v1.1 is a classic ESP32. The Sticky env ships in `platformio.ini` (`pio run -e sticky`). Envs for other devices go in **`platformio.local.ini`**, a gitignored file that PlatformIO merges over `platformio.ini` (see `extra_configs`). Create it next to `platformio.ini`; personal envs, ports, and debug flags live there and never get committed.
|
||||
|
||||
Both envs below extend the repo's `[base]`, so they build against the `freeink-sdk` submodule with all the normal deps and scripts.
|
||||
|
||||
### M5Paper v1.1 (classic ESP32, IT8951 panel)
|
||||
|
||||
```ini
|
||||
[env:m5paper_v11]
|
||||
extends = base
|
||||
board = esp32dev
|
||||
board_build.mcu = esp32
|
||||
board_build.flash_mode = qio
|
||||
; CP2104 UART bridge: 921600 drops out on macOS after the stub baud switch
|
||||
upload_speed = 460800
|
||||
build_unflags =
|
||||
${base.build_unflags}
|
||||
; classic ESP32 has UART serial, not USB CDC; Logging.h keys off these
|
||||
-DARDUINO_USB_MODE=1
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_M5PAPER=1
|
||||
; the 63KB 540x960 framebuffer lives in PSRAM (FREEINK_FB_PSRAM auto-on)
|
||||
-DBOARD_HAS_PSRAM
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-m5paper\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2
|
||||
; touch-first device: hide front-button hint labels
|
||||
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
|
||||
; archive-scan-order workaround: without these a full relink drops Wire's i2c symbols
|
||||
-Wl,-u,i2cInit
|
||||
-Wl,-u,i2cSlaveInit
|
||||
```
|
||||
|
||||
### LilyGo T5 S3 (ESP32-S3, controller-less panel via LovyanGFX)
|
||||
|
||||
```ini
|
||||
[env:lilygo_t5s3]
|
||||
extends = base
|
||||
board = esp32-s3-devkitc1-n16r8
|
||||
board_build.mcu = esp32s3
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_LILYGO=1
|
||||
; board injects the parallel-bus pins + PMIC power hooks (BoardT5S3)
|
||||
-DFREEINK_LGFX_EPD_CONFIG=lilygoT5S3LgfxConfig
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-lilygo\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2
|
||||
-DCROSSPOINT_SHOW_BUTTON_HINTS=0
|
||||
lib_deps =
|
||||
${base.lib_deps}
|
||||
; LgfxEpdConfig for the T5 S3 (pins, PCA9535/TPS65185 power sequence)
|
||||
BoardT5S3=symlink://freeink-sdk/libs/hardware/BoardT5S3
|
||||
; LovyanGFX Panel_EPD drives the controller-less ED047TC1 panel
|
||||
m5stack/M5GFX @ 0.2.20
|
||||
```
|
||||
|
||||
Then `pio run -e m5paper_v11 -t upload` (or `-e lilygo_t5s3`). Gotchas worth knowing:
|
||||
|
||||
- **Flash mode matters.** The M5Paper is `qio`; the X4-family standalone envs need `dio`. A wrong flash-mode header boots into a `partition 0 invalid magic number 0xffff` loop even though esptool verified the write.
|
||||
- **One `FREEINK_DEVICE_*` flag per env** selects the board profile (pins, panel, touch controller) from the SDK's `BoardConfig`. See `freeink-sdk/platformio.sample.ini` for reference envs of every supported device.
|
||||
- **Serial logs:** `[base]` does not enable logging; without `-DENABLE_SERIAL_LOG` a non-default env prints nothing.
|
||||
- No touch hardware on your desk? The X4 build still exercises the same code paths through buttons; touch-specific behavior (tap zones, gestures) needs a real device.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Dictionary
|
||||
|
||||
Look up words while reading an EPUB using an offline StarDict dictionary stored on the SD card.
|
||||
|
||||
## Supported Format
|
||||
|
||||
The reader supports **StarDict** dictionaries. When searching for dictionaries online, look for "StarDict format" or files with `.dict`, `.idx`, and `.ifo` extensions.
|
||||
|
||||
A dictionary folder must contain:
|
||||
|
||||
- `.idx` — word index (required, **must be uncompressed** — a `.idx.gz` will not work; decompress it on your computer with `gzip -d` first)
|
||||
- `.dict` or `.dict.dz` — definition data (`.dict.dz` is supported as-is; entries are decompressed on the fly during lookup)
|
||||
- `.ifo` — metadata (optional)
|
||||
|
||||
Not supported: `.syn` synonym files (ignored), dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error), and HTML-formatted definitions render as raw markup rather than styled text.
|
||||
|
||||
## Setting Up a Dictionary
|
||||
|
||||
1. Copy your dictionary folder(s) to `/dictionaries/` on the SD card — one dictionary per folder, e.g. `/dictionaries/webster/webster.idx` + `webster.dict.dz`. A hidden `/.dictionaries/` folder (dot-prefixed) works the same way, for keeping it out of the file browser.
|
||||
2. Open **Settings → Reader → Dictionary** on the device.
|
||||
3. Select a dictionary from the list, or **None** to disable lookups.
|
||||
|
||||
The Dictionary setting only appears when at least one usable dictionary folder exists. Folders containing more than one dictionary (multiple `.idx` stems) are skipped as ambiguous.
|
||||
|
||||
## Looking Up a Word
|
||||
|
||||
Two ways to start a lookup while reading:
|
||||
|
||||
- Open the reader menu (**Confirm**) and choose **Look Up**.
|
||||
- Or set **Settings → Controls → Long-press Menu** to "Dictionary", then hold **Confirm** (~0.4s) on the reading page.
|
||||
|
||||
One word on the page becomes highlighted:
|
||||
|
||||
1. Use **Left/Right** to move between words in reading order, and the side **Up/Down** buttons to jump between lines.
|
||||
2. Press **Confirm** to look up the highlighted word.
|
||||
3. Press **Back** to return to the reader.
|
||||
|
||||
On the very first lookup with a dictionary (and again if the dictionary file changes), the reader shows *"Indexing dictionary…"* while it builds a small `.qidx` sidecar file next to the `.idx`. This takes a few seconds for large dictionaries and makes all subsequent lookups fast. The sidecar can be deleted safely at any time — it will simply be rebuilt.
|
||||
|
||||
### How Lookup Works
|
||||
|
||||
1. **Direct match** — the word is found as-is (case-insensitive) in the dictionary index. Surrounding punctuation is ignored.
|
||||
2. **Stemming** — on a miss, common English word forms are retried automatically: possessives and plurals (`dogs` → `dog`, `stories` → `story`) and verb endings (`walked` → `walk`, `running` → `run`, `making` → `make`).
|
||||
3. **Not found** — a short popup appears and you return to word selection.
|
||||
|
||||
## The Definition Screen
|
||||
|
||||
When a word is found, the definition screen shows the matched headword at the top and the definition text below, with a page counter for long definitions.
|
||||
|
||||
- **Left/Right** or side **Up/Down** — previous / next page
|
||||
- **Back** — return to word selection
|
||||
+1
-1
Submodule freeink-sdk updated: e7d336191b...566fce3d4f
+44
-17
@@ -68,6 +68,22 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c
|
||||
const char* asCStr(const std::string& s) { return s.c_str(); }
|
||||
const char* asCStr(const char* s) { return s; }
|
||||
|
||||
// Keep-if-fits buffer reuse: only reallocate when the needed size exceeds the
|
||||
// current capacity. Freeing + reallocating slightly different sizes every page
|
||||
// turn punches non-coalescing holes in the heap (the freed block rarely fits the
|
||||
// next page's need), eroding the largest contiguous block all session. With
|
||||
// reuse, capacities converge on the book's max page after a few turns and page
|
||||
// turns stop touching the allocator. Only three small instantiations exist
|
||||
// (interval/glyph/byte arrays), so template bloat is negligible.
|
||||
template <typename T, typename CapT>
|
||||
bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) {
|
||||
if (buf && capacity >= needed) return true;
|
||||
delete[] buf;
|
||||
buf = new (std::nothrow) T[needed > 0 ? needed : 1];
|
||||
capacity = buf ? static_cast<CapT>(needed) : 0;
|
||||
return buf != nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SdCardFont::~SdCardFont() { freeAll(); }
|
||||
@@ -83,6 +99,9 @@ void SdCardFont::freeStyleMiniData(PerStyle& s) {
|
||||
s.miniBitmap = nullptr;
|
||||
s.miniIntervalCount = 0;
|
||||
s.miniGlyphCount = 0;
|
||||
s.miniIntervalCapacity = 0;
|
||||
s.miniGlyphCapacity = 0;
|
||||
s.miniBitmapCapacity = 0;
|
||||
freeStyleMiniKern(s);
|
||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||
s.epdFont.data = &s.stubData;
|
||||
@@ -109,6 +128,9 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) {
|
||||
s.miniKernRightEntryCount = 0;
|
||||
s.miniKernLeftClassCount = 0;
|
||||
s.miniKernRightClassCount = 0;
|
||||
s.miniKernLeftCapacity = 0;
|
||||
s.miniKernRightCapacity = 0;
|
||||
s.miniKernMatrixCapacity = 0;
|
||||
}
|
||||
|
||||
void SdCardFont::freeStyleAll(PerStyle& s) {
|
||||
@@ -311,13 +333,13 @@ bool SdCardFont::buildMiniKernMatrix(PerStyle& s, const uint32_t* codepoints, ui
|
||||
if (miniLookupKernClass(s.kernRightClasses, s.header.kernRightEntryCount, codepoints[i]) != 0) miniRightCount++;
|
||||
}
|
||||
|
||||
// Step 4: allocate the three mini buffers. The matrix is <1KB in practice
|
||||
// (<30 × <30 × 1 byte) so fragmentation is a non-issue.
|
||||
// Step 4: size the three mini buffers (reused across pages when they fit; the
|
||||
// per-page sizes vary by a few entries, which as free+realloc churn was punching
|
||||
// non-coalescing holes in the heap every page turn).
|
||||
const uint32_t matrixBytes = static_cast<uint32_t>(numLeft) * numRight;
|
||||
s.miniKernLeftClasses = new (std::nothrow) EpdKernClassEntry[miniLeftCount];
|
||||
s.miniKernRightClasses = new (std::nothrow) EpdKernClassEntry[miniRightCount];
|
||||
s.miniKernMatrix = new (std::nothrow) int8_t[matrixBytes];
|
||||
if (!s.miniKernLeftClasses || !s.miniKernRightClasses || !s.miniKernMatrix) {
|
||||
if (!ensureArrayCapacity(s.miniKernLeftClasses, s.miniKernLeftCapacity, miniLeftCount) ||
|
||||
!ensureArrayCapacity(s.miniKernRightClasses, s.miniKernRightCapacity, miniRightCount) ||
|
||||
!ensureArrayCapacity(s.miniKernMatrix, s.miniKernMatrixCapacity, matrixBytes)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini kern (%u+%u+%u bytes)", miniLeftCount * 3u, miniRightCount * 3u,
|
||||
matrixBytes);
|
||||
freeStyleMiniKern(s);
|
||||
@@ -793,12 +815,19 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
return missed;
|
||||
}
|
||||
|
||||
// Build mini intervals from sorted codepoints
|
||||
freeStyleMiniData(s);
|
||||
// Build mini intervals from sorted codepoints. Reset counts and fall back to the
|
||||
// stub until the rebuild completes, but KEEP the existing buffers (keep-if-fits
|
||||
// reuse) — the free-and-realloc-per-page pattern here was a primary fragmenter.
|
||||
s.miniIntervalCount = 0;
|
||||
s.miniGlyphCount = 0;
|
||||
s.miniKernLeftEntryCount = 0;
|
||||
s.miniKernRightEntryCount = 0;
|
||||
s.miniKernLeftClassCount = 0;
|
||||
s.miniKernRightClassCount = 0;
|
||||
memset(&s.miniData, 0, sizeof(s.miniData));
|
||||
s.epdFont.data = &s.stubData;
|
||||
|
||||
uint32_t intervalCapacity = validCount;
|
||||
s.miniIntervals = new (std::nothrow) EpdUnicodeInterval[intervalCapacity];
|
||||
if (!s.miniIntervals) {
|
||||
if (!ensureArrayCapacity(s.miniIntervals, s.miniIntervalCapacity, validCount)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini intervals for style %u", styleIdx);
|
||||
delete[] mappings;
|
||||
return static_cast<int>(cpCount);
|
||||
@@ -816,15 +845,14 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate mini glyph array
|
||||
s.miniGlyphCount = validCount;
|
||||
s.miniGlyphs = new (std::nothrow) EpdGlyph[s.miniGlyphCount];
|
||||
if (!s.miniGlyphs) {
|
||||
// Mini glyph array (reused across pages when it fits)
|
||||
if (!ensureArrayCapacity(s.miniGlyphs, s.miniGlyphCapacity, validCount)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini glyphs for style %u", styleIdx);
|
||||
delete[] mappings;
|
||||
freeStyleMiniData(s);
|
||||
return static_cast<int>(cpCount);
|
||||
}
|
||||
s.miniGlyphCount = validCount;
|
||||
|
||||
// Build sorted read order for sequential I/O
|
||||
uint32_t* readOrder = new (std::nothrow) uint32_t[validCount];
|
||||
@@ -891,8 +919,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3
|
||||
totalBitmapSize += s.miniGlyphs[i].dataLength;
|
||||
}
|
||||
|
||||
s.miniBitmap = new (std::nothrow) uint8_t[totalBitmapSize > 0 ? totalBitmapSize : 1];
|
||||
if (!s.miniBitmap) {
|
||||
if (!ensureArrayCapacity(s.miniBitmap, s.miniBitmapCapacity, totalBitmapSize)) {
|
||||
LOG_ERR("SDCF", "Failed to allocate mini bitmap (%u bytes) for style %u", totalBitmapSize, styleIdx);
|
||||
delete[] readOrder;
|
||||
delete[] mappings;
|
||||
|
||||
@@ -168,13 +168,22 @@ class SdCardFont {
|
||||
// Stub EpdFontData returned when not prewarmed
|
||||
EpdFontData stubData{};
|
||||
|
||||
// Mini EpdFontData built during prewarm
|
||||
// Mini EpdFontData built during prewarm. Buffers are kept-if-fits across pages
|
||||
// (capacities below track allocated sizes): freeing and reallocating slightly
|
||||
// different sizes on every page turn was a primary heap fragmenter — each page's
|
||||
// freed hole rarely fit the next page's need, so maxAlloc eroded all session.
|
||||
// After a few pages the capacities converge on the book's max and page turns
|
||||
// stop allocating entirely. freeStyleMiniData() still releases everything (and
|
||||
// zeroes capacities) for style eviction / font unload.
|
||||
EpdFontData miniData{};
|
||||
EpdUnicodeInterval* miniIntervals = nullptr;
|
||||
EpdGlyph* miniGlyphs = nullptr;
|
||||
uint8_t* miniBitmap = nullptr;
|
||||
uint32_t miniIntervalCount = 0;
|
||||
uint32_t miniGlyphCount = 0;
|
||||
uint32_t miniIntervalCapacity = 0;
|
||||
uint32_t miniGlyphCapacity = 0;
|
||||
uint32_t miniBitmapCapacity = 0;
|
||||
|
||||
// Per-page mini kern matrix (built by buildMiniKernMatrix on each full
|
||||
// prewarm). miniKernLeftClasses/miniKernRightClasses map ONLY the codepoints
|
||||
@@ -189,6 +198,10 @@ class SdCardFont {
|
||||
uint8_t miniKernLeftClassCount = 0;
|
||||
uint8_t miniKernRightClassCount = 0;
|
||||
int8_t* miniKernMatrix = nullptr;
|
||||
// Kept-if-fits capacities, same rationale as the mini glyph buffers above.
|
||||
uint16_t miniKernLeftCapacity = 0;
|
||||
uint16_t miniKernRightCapacity = 0;
|
||||
uint32_t miniKernMatrixCapacity = 0;
|
||||
|
||||
// The EpdFont whose data pointer we manage
|
||||
EpdFont epdFont{&stubData};
|
||||
|
||||
+56
-4
@@ -256,8 +256,44 @@ void Epub::parseCssFiles() const {
|
||||
return;
|
||||
}
|
||||
|
||||
// Some converters emit one byte-identical stylesheet per chapter (100+ .css
|
||||
// entries), and each parse costs a zip locate plus an SD extract round-trip.
|
||||
// Map every CSS path to its central-directory (CRC32, compressed size) in a
|
||||
// single scan and parse only the first of each identical pair. Rules merge
|
||||
// into one global set, so dropping exact duplicates cannot lose styles. A
|
||||
// path that never matches a directory entry keeps key 0 and always parses.
|
||||
std::vector<uint64_t> dedupKeys(cssFiles.size(), 0);
|
||||
if (cssFiles.size() > 1) {
|
||||
std::unordered_map<std::string, size_t> pathToIndex;
|
||||
pathToIndex.reserve(cssFiles.size());
|
||||
for (size_t i = 0; i < cssFiles.size(); i++) {
|
||||
pathToIndex.emplace(FsHelpers::normalisePath(cssFiles[i]), i);
|
||||
}
|
||||
ZipFile(filepath).enumerateFileEntries([&](std::string_view entryPath, uint32_t crc32, uint32_t compressedSize) {
|
||||
if (!FsHelpers::hasCssExtension(entryPath)) {
|
||||
return;
|
||||
}
|
||||
const auto it = pathToIndex.find(std::string{entryPath});
|
||||
if (it != pathToIndex.end()) {
|
||||
dedupKeys[it->second] = (static_cast<uint64_t>(crc32) << 32) | compressedSize;
|
||||
}
|
||||
});
|
||||
}
|
||||
std::vector<uint64_t> seenKeys;
|
||||
seenKeys.reserve(cssFiles.size());
|
||||
size_t skippedDuplicates = 0;
|
||||
|
||||
// No cache yet - parse CSS files
|
||||
for (const auto& cssPath : cssFiles) {
|
||||
for (size_t cssIndex = 0; cssIndex < cssFiles.size(); cssIndex++) {
|
||||
const auto& cssPath = cssFiles[cssIndex];
|
||||
const uint64_t dedupKey = dedupKeys[cssIndex];
|
||||
if (dedupKey != 0) {
|
||||
if (std::find(seenKeys.begin(), seenKeys.end(), dedupKey) != seenKeys.end()) {
|
||||
skippedDuplicates++;
|
||||
continue;
|
||||
}
|
||||
seenKeys.push_back(dedupKey);
|
||||
}
|
||||
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
|
||||
|
||||
// Check heap before parsing - CSS parsing allocates heavily
|
||||
@@ -312,7 +348,8 @@ void Epub::parseCssFiles() const {
|
||||
LOG_ERR("EBP", "Failed to save CSS rules to cache");
|
||||
}
|
||||
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
|
||||
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files (%zu identical duplicates skipped)", cssParser->ruleCount(),
|
||||
cssFiles.size(), skippedDuplicates);
|
||||
cssParser->clear();
|
||||
}
|
||||
|
||||
@@ -728,14 +765,29 @@ uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size
|
||||
return content;
|
||||
}
|
||||
|
||||
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 bool allowEarlyStop) const {
|
||||
if (itemHref.empty()) {
|
||||
LOG_DBG("EBP", "Failed to read item, empty href");
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string path = FsHelpers::normalisePath(itemHref);
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize);
|
||||
return ZipFile(filepath).readFileToStream(path.c_str(), out, chunkSize, allowEarlyStop);
|
||||
}
|
||||
|
||||
bool Epub::extractItemToFile(const std::string& itemHref, const std::string& destPath) const {
|
||||
HalFile out;
|
||||
if (!Storage.openFileForWrite("EBP", destPath, out)) {
|
||||
return false;
|
||||
}
|
||||
const bool ok = readItemContentsToStream(itemHref, out, 4096);
|
||||
out.flush();
|
||||
out.close();
|
||||
if (!ok) {
|
||||
Storage.remove(destPath.c_str());
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool Epub::getItemSize(const std::string& itemHref, size_t* size) const {
|
||||
|
||||
+4
-1
@@ -59,7 +59,10 @@ class Epub {
|
||||
bool generateThumbBmp(int height) const;
|
||||
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
|
||||
bool trailingNullByte = false) const;
|
||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize,
|
||||
bool allowEarlyStop = false) const;
|
||||
// Extract an item to a file on SD. On failure the partial file is removed.
|
||||
bool extractItemToFile(const std::string& itemHref, const std::string& destPath) const;
|
||||
bool getItemSize(const std::string& itemHref, size_t* size) const;
|
||||
BookMetadataCache::SpineEntry getSpineItem(int spineIndex) const;
|
||||
BookMetadataCache::TocEntry getTocItem(int tocIndex) const;
|
||||
|
||||
@@ -287,7 +287,30 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
effectiveNoSpaceBefore = true;
|
||||
}
|
||||
|
||||
const auto ensureTokenCapacity = [&](const size_t additionalTokens) {
|
||||
if (additionalTokens == 0) return;
|
||||
const size_t requiredSize = words.size() + additionalTokens;
|
||||
if (words.capacity() >= requiredSize) return;
|
||||
|
||||
size_t newCapacity = words.capacity();
|
||||
if (newCapacity < 16) {
|
||||
newCapacity = 16;
|
||||
}
|
||||
while (newCapacity < requiredSize) {
|
||||
newCapacity *= 2;
|
||||
}
|
||||
|
||||
words.reserve(newCapacity);
|
||||
wordStyles.reserve(newCapacity);
|
||||
wordContinues.reserve(newCapacity);
|
||||
wordNoSpaceBefore.reserve(newCapacity);
|
||||
wordIsFocusSuffix.reserve(newCapacity);
|
||||
};
|
||||
|
||||
if (auto breakOffsets = cjkCharacterBreakByteOffsets(word); !breakOffsets.empty()) {
|
||||
// CJK-heavy paragraphs can push hundreds of tiny tokens quickly when CSS toggles
|
||||
// inline styles. Reserve once up front to avoid repeated vector growth reallocations.
|
||||
ensureTokenCapacity(breakOffsets.size() + 1);
|
||||
bool firstToken = true;
|
||||
size_t tokenStart = 0;
|
||||
for (const size_t breakOffset : breakOffsets) {
|
||||
@@ -326,29 +349,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle,
|
||||
|
||||
// --- FOCUS READING LOGIC BELOW ---
|
||||
|
||||
// Pre-reserve capacity to prevent mid-word heap reallocations.
|
||||
size_t maxPossibleNewTokens = word.length();
|
||||
size_t requiredSize = words.size() + maxPossibleNewTokens;
|
||||
|
||||
if (words.capacity() < requiredSize) {
|
||||
// Emulate standard geometric growth (doubling) to ensure we don't reallocate on every word.
|
||||
size_t newCapacity = words.capacity() * 2;
|
||||
|
||||
// Ensure the doubled capacity is actually enough for this specific word
|
||||
if (newCapacity < requiredSize) {
|
||||
newCapacity = requiredSize;
|
||||
}
|
||||
// Set a sensible minimum starting size so the first few words don't trigger tiny reallocations
|
||||
if (newCapacity < 16) {
|
||||
newCapacity = 16;
|
||||
}
|
||||
|
||||
words.reserve(newCapacity);
|
||||
wordStyles.reserve(newCapacity);
|
||||
wordContinues.reserve(newCapacity);
|
||||
wordNoSpaceBefore.reserve(newCapacity);
|
||||
wordIsFocusSuffix.reserve(newCapacity);
|
||||
}
|
||||
// Worst case: a segment boundary on each byte (highly punctuated UTF-8 text).
|
||||
ensureTokenCapacity(word.length());
|
||||
|
||||
// Lambda helper to process and push individual sub-segments of the string
|
||||
// Use std::string_view to avoid heap allocations when slicing
|
||||
|
||||
@@ -17,7 +17,10 @@ namespace {
|
||||
// v30: Arabic shaping changed both drawing and measurement (getTextAdvanceX now
|
||||
// measures the shaped visual text); cached word positions from v29 no longer
|
||||
// match what drawText renders.
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 30;
|
||||
// v32: ImageBlock serializes the book-internal source href after the cache path
|
||||
// (lazy extraction: images are header-probed at build time and extracted on
|
||||
// first render).
|
||||
constexpr uint8_t SECTION_FILE_VERSION = 32;
|
||||
// Written into the version field while a build is in progress; patched to
|
||||
// SECTION_FILE_VERSION only when the build is finalized. An abandoned /
|
||||
// crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
#include "Epub/converters/DirectPixelWriter.h"
|
||||
#include "Epub/converters/ImageDecoderFactory.h"
|
||||
@@ -15,8 +18,16 @@
|
||||
// - uint16_t height
|
||||
// - uint8_t pixels[...] - 2 bits per pixel, packed (4 pixels per byte), row-major order
|
||||
|
||||
ImageBlock::ImageBlock(const std::string& imagePath, int16_t width, int16_t height)
|
||||
: imagePath(imagePath), width(width), height(height) {}
|
||||
ImageBlock::ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height)
|
||||
: imagePath(imagePath), srcPath(srcPath), width(width), height(height) {}
|
||||
|
||||
void* ImageBlock::extractCtx = nullptr;
|
||||
ImageBlock::ExtractFn ImageBlock::extractFn = nullptr;
|
||||
|
||||
void ImageBlock::setExtractor(void* ctx, ExtractFn fn) {
|
||||
extractCtx = ctx;
|
||||
extractFn = fn;
|
||||
}
|
||||
|
||||
bool ImageBlock::imageExists() const { return Storage.exists(imagePath.c_str()); }
|
||||
|
||||
@@ -79,8 +90,113 @@ void rememberImageFailure(const std::string& path) {
|
||||
failedImageHashes[failedImageCount++] = imagePathHash(path);
|
||||
}
|
||||
|
||||
// --- Per-page-render RAM slot for the pixel cache ----------------------------
|
||||
// The tiled grayscale flow re-renders an image page once for the BW
|
||||
// double-refresh and again for every band of both gray planes, and each pass
|
||||
// re-read the whole .pxc off SD (~100 ms for a full-page image, ~13 passes).
|
||||
// Column clipping cannot reduce the SD traffic: the row stride (~100 B) is
|
||||
// smaller than an SD sector, so every sector is touched regardless of the band
|
||||
// window. Instead the first pass loads the payload into RAM and later passes
|
||||
// render from it. Chunked allocation because a single full-image block (up to
|
||||
// 96 KB) rarely fits the fragmented mid-render heap; each chunk is heap-gated
|
||||
// and any failure falls back to the streaming path unchanged. The reader
|
||||
// releases the slot when the page render completes, so nothing stays resident
|
||||
// across page turns.
|
||||
constexpr size_t PXC_CHUNK_SHIFT = 14; // 16 KB chunks
|
||||
constexpr size_t PXC_CHUNK_SIZE = 1u << PXC_CHUNK_SHIFT;
|
||||
constexpr size_t PXC_MAX_CHUNKS = 6; // 96 KB: a full-screen 2bpp image
|
||||
constexpr size_t PXC_HEAP_RESERVE = 24 * 1024;
|
||||
constexpr size_t PXC_MAX_ALLOC_RESERVE = 8 * 1024;
|
||||
// Rows can straddle a chunk boundary; they are reassembled into a stack
|
||||
// buffer. (screenWidth + 3) / 4 caps at 200 B for an 800px panel.
|
||||
constexpr int PXC_MAX_BYTES_PER_ROW = 208;
|
||||
|
||||
std::unique_ptr<uint8_t[]> pxcChunks[PXC_MAX_CHUNKS];
|
||||
uint64_t pxcSlotHash = 0;
|
||||
uint16_t pxcSlotWidth = 0;
|
||||
uint16_t pxcSlotHeight = 0;
|
||||
|
||||
void releasePxcSlot() {
|
||||
for (auto& chunk : pxcChunks) chunk.reset();
|
||||
pxcSlotHash = 0;
|
||||
pxcSlotWidth = 0;
|
||||
pxcSlotHeight = 0;
|
||||
}
|
||||
|
||||
const uint8_t* pxcRowPtr(size_t rowStart, int bytesPerRow, uint8_t* tempRow) {
|
||||
const size_t chunk = rowStart >> PXC_CHUNK_SHIFT;
|
||||
const size_t offset = rowStart & (PXC_CHUNK_SIZE - 1);
|
||||
if (offset + bytesPerRow <= PXC_CHUNK_SIZE) {
|
||||
return pxcChunks[chunk].get() + offset;
|
||||
}
|
||||
const size_t firstPart = PXC_CHUNK_SIZE - offset;
|
||||
memcpy(tempRow, pxcChunks[chunk].get() + offset, firstPart);
|
||||
memcpy(tempRow + firstPart, pxcChunks[chunk + 1].get(), bytesPerRow - firstPart);
|
||||
return tempRow;
|
||||
}
|
||||
|
||||
// cacheFile is positioned just past the header. True when the slot holds the
|
||||
// full pixel payload for this cache path afterward.
|
||||
bool loadPxcSlot(uint64_t cacheHash, HalFile& cacheFile, uint16_t cachedWidth, uint16_t cachedHeight, int bytesPerRow) {
|
||||
releasePxcSlot();
|
||||
if (bytesPerRow > PXC_MAX_BYTES_PER_ROW) {
|
||||
return false;
|
||||
}
|
||||
size_t remaining = (size_t)bytesPerRow * cachedHeight;
|
||||
const size_t chunkCount = (remaining + PXC_CHUNK_SIZE - 1) >> PXC_CHUNK_SHIFT;
|
||||
if (chunkCount == 0 || chunkCount > PXC_MAX_CHUNKS) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0; i < chunkCount; i++) {
|
||||
const size_t want = remaining < PXC_CHUNK_SIZE ? remaining : PXC_CHUNK_SIZE;
|
||||
if (ESP.getFreeHeap() < remaining + PXC_HEAP_RESERVE || ESP.getMaxAllocHeap() < want + PXC_MAX_ALLOC_RESERVE) {
|
||||
releasePxcSlot();
|
||||
return false;
|
||||
}
|
||||
pxcChunks[i] = makeUniqueNoThrow<uint8_t[]>(want);
|
||||
if (!pxcChunks[i] || cacheFile.read(pxcChunks[i].get(), want) != static_cast<int>(want)) {
|
||||
releasePxcSlot();
|
||||
return false;
|
||||
}
|
||||
remaining -= want;
|
||||
}
|
||||
pxcSlotHash = cacheHash;
|
||||
pxcSlotWidth = cachedWidth;
|
||||
pxcSlotHeight = cachedHeight;
|
||||
return true;
|
||||
}
|
||||
|
||||
void renderRowsFromPxcSlot(GfxRenderer& renderer, int x, int y) {
|
||||
const int bytesPerRow = (pxcSlotWidth + 3) / 4;
|
||||
uint8_t tempRow[PXC_MAX_BYTES_PER_ROW];
|
||||
|
||||
DirectPixelWriter pw;
|
||||
pw.init(renderer);
|
||||
|
||||
for (int row = 0; row < pxcSlotHeight; row++) {
|
||||
const uint8_t* rowBuffer = pxcRowPtr((size_t)row * bytesPerRow, bytesPerRow, tempRow);
|
||||
pw.beginRow(y + row);
|
||||
int colStart, colEnd;
|
||||
pw.bandColRange(x, pxcSlotWidth, colStart, colEnd);
|
||||
for (int col = colStart; col < colEnd; col++) {
|
||||
const int byteIdx = col >> 2; // col / 4
|
||||
const int bitShift = 6 - (col & 3) * 2; // MSB first within byte
|
||||
const uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
|
||||
pw.writePixel(x + col, pixelValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x, int y, int expectedWidth,
|
||||
int expectedHeight) {
|
||||
// A later pass of the same page render: the payload is already in RAM, skip
|
||||
// the file entirely.
|
||||
const uint64_t cacheHash = imagePathHash(cachePath);
|
||||
if (pxcSlotHash == cacheHash && pxcSlotWidth != 0) {
|
||||
renderRowsFromPxcSlot(renderer, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
HalFile cacheFile;
|
||||
if (!Storage.openFileForRead("IMG", cachePath, cacheFile)) {
|
||||
return false;
|
||||
@@ -98,12 +214,24 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
|
||||
|
||||
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
|
||||
|
||||
// Read several rows per SD access. A full-page image is re-rendered on every
|
||||
// grayscale strip pass (~14x per page), and a one-row-per-read loop here means
|
||||
// cachedHeight (~728) tiny reads through the storage mutex + SdFat each time —
|
||||
// the dominant cost of displaying an image page. Batching rows into a ~4KB
|
||||
// buffer cuts that to ~20 reads per pass without holding the whole image.
|
||||
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
|
||||
|
||||
// First pass of a page render: try to pull the payload into the RAM slot so
|
||||
// the remaining ~12 passes skip SD entirely.
|
||||
if (loadPxcSlot(cacheHash, cacheFile, cachedWidth, cachedHeight, bytesPerRow)) {
|
||||
renderRowsFromPxcSlot(renderer, x, y);
|
||||
LOG_DBG("IMG", "Cache render complete (payload now in RAM)");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Streaming fallback (slot didn't fit). A failed slot load may have consumed
|
||||
// part of the payload; rewind to just past the header.
|
||||
cacheFile.seek(4);
|
||||
|
||||
// Read several rows per SD access. A one-row-per-read loop here means
|
||||
// cachedHeight (~728) tiny reads through the storage mutex + SdFat; batching
|
||||
// rows into a ~4KB buffer cuts that to ~20 reads per pass without holding the
|
||||
// whole image.
|
||||
int rowsPerRead = 4096 / bytesPerRow;
|
||||
if (rowsPerRead < 1) rowsPerRead = 1;
|
||||
if (rowsPerRead > cachedHeight) rowsPerRead = cachedHeight;
|
||||
@@ -176,6 +304,8 @@ bool ImageBlock::needsDecode() const { return !imageFailedThisSession(imagePath)
|
||||
|
||||
void ImageBlock::clearSessionRenderFailures() { failedImageCount = 0; }
|
||||
|
||||
void ImageBlock::releaseRenderCache() { releasePxcSlot(); }
|
||||
|
||||
void ImageBlock::renderPlaceholder(GfxRenderer& renderer, const int x, const int y) const {
|
||||
renderer.fillRect(x, y, width, height, true);
|
||||
if (width > 2 && height > 2) {
|
||||
@@ -225,6 +355,15 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
return; // Successfully rendered from cache
|
||||
}
|
||||
|
||||
// The build only header-probed the image for dimensions; pull the actual
|
||||
// file out of the book now, on first visit to the page.
|
||||
if (!srcPath.empty() && extractFn && !Storage.exists(imagePath.c_str())) {
|
||||
LOG_DBG("IMG", "Lazy-extracting %s -> %s", srcPath.c_str(), imagePath.c_str());
|
||||
if (!extractFn(extractCtx, srcPath.c_str(), imagePath.c_str())) {
|
||||
LOG_ERR("IMG", "Lazy extraction failed: %s", srcPath.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// No cache - need to decode the image
|
||||
// Check if image file exists
|
||||
HalFile file;
|
||||
@@ -280,6 +419,7 @@ void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
|
||||
|
||||
bool ImageBlock::serialize(HalFile& file) {
|
||||
serialization::writeString(file, imagePath);
|
||||
serialization::writeString(file, srcPath);
|
||||
serialization::writePod(file, width);
|
||||
serialization::writePod(file, height);
|
||||
return true;
|
||||
@@ -287,9 +427,11 @@ bool ImageBlock::serialize(HalFile& file) {
|
||||
|
||||
std::unique_ptr<ImageBlock> ImageBlock::deserialize(HalFile& file) {
|
||||
std::string path;
|
||||
std::string src;
|
||||
serialization::readString(file, path);
|
||||
serialization::readString(file, src);
|
||||
int16_t w, h;
|
||||
serialization::readPod(file, w);
|
||||
serialization::readPod(file, h);
|
||||
return std::unique_ptr<ImageBlock>(new ImageBlock(path, w, h));
|
||||
return std::unique_ptr<ImageBlock>(new (std::nothrow) ImageBlock(path, src, w, h));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
class ImageBlock final : public Block {
|
||||
public:
|
||||
ImageBlock(const std::string& imagePath, int16_t width, int16_t height);
|
||||
ImageBlock(const std::string& imagePath, const std::string& srcPath, int16_t width, int16_t height);
|
||||
~ImageBlock() override = default;
|
||||
|
||||
const std::string& getImagePath() const { return imagePath; }
|
||||
@@ -21,6 +21,21 @@ class ImageBlock final : public Block {
|
||||
void renderPlaceholder(GfxRenderer& renderer, int x, int y) const;
|
||||
static void clearSessionRenderFailures();
|
||||
|
||||
// A page render draws its image up to ~13 times (BW double-refresh plus every
|
||||
// grayscale band pass), and each draw streams the whole .pxc off SD. The
|
||||
// first draw caches the pixel payload in RAM (chunked, heap-gated, falls back
|
||||
// to streaming when it doesn't fit); the reader calls this when the page
|
||||
// render completes so nothing stays resident between pages.
|
||||
static void releaseRenderCache();
|
||||
|
||||
// Lazy extraction hook: the section build only header-probes images for their
|
||||
// dimensions; the file at imagePath is extracted out of the book on first
|
||||
// render, via this callback (function pointer + context, not std::function —
|
||||
// this is render-loop code). Registered by the reader activity that owns the
|
||||
// Epub, cleared on its exit.
|
||||
using ExtractFn = bool (*)(void* ctx, const char* srcPath, const char* destPath);
|
||||
static void setExtractor(void* ctx, ExtractFn fn);
|
||||
|
||||
BlockType getType() override { return IMAGE_BLOCK; }
|
||||
bool isEmpty() override { return false; }
|
||||
|
||||
@@ -30,6 +45,10 @@ class ImageBlock final : public Block {
|
||||
|
||||
private:
|
||||
std::string imagePath;
|
||||
std::string srcPath; // book-internal source href; empty once known-extracted
|
||||
int16_t width;
|
||||
int16_t height;
|
||||
|
||||
static void* extractCtx;
|
||||
static ExtractFn extractFn;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "ImageDimsProbe.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace {
|
||||
// SOFn markers carry the frame dimensions. C4 (DHT), C8 (JPG extension) and
|
||||
// CC (DAC) share the 0xCn range but are not frame headers.
|
||||
bool isJpegSof(const uint8_t marker) {
|
||||
return marker >= 0xC0 && marker <= 0xCF && marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
|
||||
}
|
||||
constexpr uint8_t PNG_SIG[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
} // namespace
|
||||
|
||||
bool ImageDimsProbe::feed(const uint8_t b) {
|
||||
switch (state) {
|
||||
case State::Sniff:
|
||||
if (b == 0xFF) {
|
||||
state = State::JpegSoi;
|
||||
} else if (b == PNG_SIG[0]) {
|
||||
state = State::PngHeader;
|
||||
} else {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
pos = 1;
|
||||
return true;
|
||||
|
||||
case State::PngHeader:
|
||||
// Bytes 1..7: signature; 8..11: IHDR length; 12..15: "IHDR"; 16..23: dims.
|
||||
if (pos < 8) {
|
||||
if (b != PNG_SIG[pos]) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
} else if (pos >= 12 && pos < 16) {
|
||||
if (b != "IHDR"[pos - 12]) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
} else if (pos >= 16 && pos < 20) {
|
||||
width = static_cast<uint16_t>((static_cast<uint32_t>(width) << 8) | b);
|
||||
} else if (pos >= 20 && pos < 24) {
|
||||
height = static_cast<uint16_t>((static_cast<uint32_t>(height) << 8) | b);
|
||||
if (pos == 23) {
|
||||
state = State::Done;
|
||||
pos++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
return true;
|
||||
|
||||
case State::JpegSoi:
|
||||
if (b != 0xD8) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
state = State::JpegFf;
|
||||
return true;
|
||||
|
||||
case State::JpegFf:
|
||||
if (b != 0xFF) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
state = State::JpegMarker;
|
||||
return true;
|
||||
|
||||
case State::JpegMarker:
|
||||
if (b == 0xFF) return true; // fill bytes before a marker are legal
|
||||
if (b == 0x01 || (b >= 0xD0 && b <= 0xD8)) {
|
||||
// TEM / RSTn / SOI: standalone, no length field.
|
||||
state = State::JpegFf;
|
||||
return true;
|
||||
}
|
||||
if (b == 0xD9 || b == 0xDA) {
|
||||
// EOI or SOS before any SOF: no dimensions to be found.
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
sofPending = isJpegSof(b);
|
||||
state = State::JpegLenHi;
|
||||
return true;
|
||||
|
||||
case State::JpegLenHi:
|
||||
segLen = static_cast<uint16_t>(b << 8);
|
||||
state = State::JpegLenLo;
|
||||
return true;
|
||||
|
||||
case State::JpegLenLo:
|
||||
segLen = static_cast<uint16_t>(segLen | b);
|
||||
if (segLen < 2 || (sofPending && segLen < 7)) {
|
||||
state = State::Failed;
|
||||
return false;
|
||||
}
|
||||
if (sofPending) {
|
||||
sofFill = 0;
|
||||
state = State::JpegSof;
|
||||
} else if (segLen == 2) {
|
||||
state = State::JpegFf;
|
||||
} else {
|
||||
skipLeft = static_cast<uint32_t>(segLen) - 2;
|
||||
state = State::JpegSkip;
|
||||
}
|
||||
return true;
|
||||
|
||||
case State::JpegSkip:
|
||||
if (--skipLeft == 0) state = State::JpegFf;
|
||||
return true;
|
||||
|
||||
case State::JpegSof:
|
||||
sofBuf[sofFill++] = b;
|
||||
if (sofFill == 5) {
|
||||
height = static_cast<uint16_t>((sofBuf[1] << 8) | sofBuf[2]);
|
||||
width = static_cast<uint16_t>((sofBuf[3] << 8) | sofBuf[4]);
|
||||
state = State::Done;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case State::Done:
|
||||
case State::Failed:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t ImageDimsProbe::write(const uint8_t b) { return feed(b) ? 1 : 0; }
|
||||
|
||||
size_t ImageDimsProbe::write(const uint8_t* data, const size_t len) {
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
if (!feed(data[i])) return i; // short write: polite early stop
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
bool ImageDimsProbe::getDimensions(ImageDimensions& out) const {
|
||||
if (state != State::Done || width == 0 || height == 0 || width > INT16_MAX || height > INT16_MAX) {
|
||||
return false;
|
||||
}
|
||||
out.width = static_cast<int16_t>(width);
|
||||
out.height = static_cast<int16_t>(height);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
#include <Print.h>
|
||||
|
||||
#include "ImageToFramebufferDecoder.h"
|
||||
|
||||
// Streaming JPEG/PNG header parser: finds image dimensions from the first few
|
||||
// KB of a compressed stream without inflating the whole image. Feed bytes via
|
||||
// the Print interface (e.g. Epub::readItemContentsToStream with
|
||||
// allowEarlyStop=true); write() returns short once the dimensions are known or
|
||||
// the stream is known to be unusable, which the zip layer treats as a polite
|
||||
// early stop rather than an error.
|
||||
//
|
||||
// JPEG: walks marker segments (skipping EXIF/APPn of any size statefully, so
|
||||
// nothing is buffered) until a SOFn frame header yields the dimensions.
|
||||
// PNG: reads the IHDR fields at their fixed offsets (bytes 16..23).
|
||||
class ImageDimsProbe : public Print {
|
||||
public:
|
||||
size_t write(uint8_t b) override;
|
||||
size_t write(const uint8_t* data, size_t len) override;
|
||||
|
||||
// True only when a valid header was found; fills `out`.
|
||||
bool getDimensions(ImageDimensions& out) const;
|
||||
|
||||
private:
|
||||
bool feed(uint8_t b); // returns false once parsing is finished (found or failed)
|
||||
|
||||
enum class State : uint8_t {
|
||||
Sniff, // first byte decides the format
|
||||
PngHeader, // PNG signature + IHDR at fixed offsets
|
||||
JpegSoi, // second SOI byte (0xD8)
|
||||
JpegFf, // expect a 0xFF marker prefix
|
||||
JpegMarker, // marker type byte (0xFF padding allowed)
|
||||
JpegLenHi, // segment length, high byte
|
||||
JpegLenLo, // segment length, low byte
|
||||
JpegSkip, // skipping a non-SOF segment body
|
||||
JpegSof, // collecting the 5 SOF bytes: precision, height(2), width(2)
|
||||
Done,
|
||||
Failed,
|
||||
};
|
||||
State state = State::Sniff;
|
||||
uint32_t pos = 0; // absolute stream offset (PNG fixed-offset parsing)
|
||||
uint32_t skipLeft = 0; // remaining segment bytes to skip
|
||||
uint16_t segLen = 0;
|
||||
bool sofPending = false; // current segment is a SOF frame header
|
||||
uint8_t sofBuf[5] = {0};
|
||||
uint8_t sofFill = 0;
|
||||
uint16_t width = 0;
|
||||
uint16_t height = 0;
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "Epub.h"
|
||||
#include "Epub/Page.h"
|
||||
#include "Epub/converters/ImageDecoderFactory.h"
|
||||
#include "Epub/converters/ImageDimsProbe.h"
|
||||
#include "Epub/converters/ImageToFramebufferDecoder.h"
|
||||
#include "Epub/htmlEntities.h"
|
||||
|
||||
@@ -22,6 +23,17 @@
|
||||
constexpr size_t MIN_SIZE_FOR_POPUP = 10 * 1024; // 10KB
|
||||
constexpr size_t PARSE_BUFFER_SIZE = 1024;
|
||||
|
||||
// This number comes from PR #73
|
||||
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
|
||||
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
|
||||
// memory.
|
||||
// Spotted when reading Intermezzo, there are some really long text blocks in there.
|
||||
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS = 750;
|
||||
|
||||
// When CSS is enabled, flush earlier to save RAM. 320 is still more than enough to build a CJK
|
||||
// page at font size 14
|
||||
constexpr size_t TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS = 320;
|
||||
|
||||
// Hard cap on the number of anchor IDs recorded per chapter. Legitimate navigation
|
||||
// anchors (TOC entries, footnotes, cross-references) rarely exceed a few hundred per
|
||||
// chapter. A runaway count usually means a converter injected machine-generated IDs on
|
||||
@@ -543,28 +555,47 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
}
|
||||
std::string cachedImagePath = self->imageBasePath + std::to_string(self->imageCounter++) + ext;
|
||||
|
||||
// Extract image to cache file
|
||||
HalFile cachedImageFile;
|
||||
bool extractSuccess = false;
|
||||
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
|
||||
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
|
||||
cachedImageFile.flush();
|
||||
cachedImageFile.close();
|
||||
}
|
||||
|
||||
if (extractSuccess) {
|
||||
// Get image dimensions, retrying to absorb SD-card sync latency on slow
|
||||
// cards. Replaces a blanket delay(50) that cost ~50ms on every image, and
|
||||
// closes the silent-drop bug where a single getDimensions failure was fatal.
|
||||
{
|
||||
// Probe the dimensions from the entry's first bytes (early-aborted
|
||||
// inflate, a few KB) instead of extracting the whole image now —
|
||||
// extraction is deferred to the first render of the page (see
|
||||
// ImageBlock's lazy extractor). This is what keeps first-open of an
|
||||
// image-heavy chapter from stalling for seconds per image.
|
||||
ImageDimensions dims = {0, 0};
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||
bool gotDimensions = false;
|
||||
for (int attempt = 0; attempt < 3 && !gotDimensions; attempt++) {
|
||||
if (attempt > 0) {
|
||||
delay(50); // Give a slow SD card time to finish syncing before retrying
|
||||
ImageDimsProbe headerProbe;
|
||||
self->epub->readItemContentsToStream(resolvedPath, headerProbe, 1024, /*allowEarlyStop=*/true);
|
||||
bool gotDimensions = headerProbe.getDimensions(dims);
|
||||
|
||||
if (!gotDimensions) {
|
||||
// No header within the stream (rare) — fall back to extracting the
|
||||
// whole image and probing the file. That can take seconds, so
|
||||
// surface the indexing popup first (single-shot per parser).
|
||||
if (self->popupFn && !self->imagePopupFired) {
|
||||
self->imagePopupFired = true;
|
||||
self->popupFn();
|
||||
}
|
||||
HalFile cachedImageFile;
|
||||
bool extractSuccess = false;
|
||||
if (Storage.openFileForWrite("EHP", cachedImagePath, cachedImageFile)) {
|
||||
extractSuccess = self->epub->readItemContentsToStream(resolvedPath, cachedImageFile, 4096);
|
||||
cachedImageFile.flush();
|
||||
cachedImageFile.close();
|
||||
}
|
||||
if (extractSuccess) {
|
||||
// Retry to absorb SD-card sync latency on slow cards, and to close
|
||||
// the silent-drop bug where a single getDimensions failure was fatal.
|
||||
ImageToFramebufferDecoder* decoder = ImageDecoderFactory::getDecoder(cachedImagePath);
|
||||
for (int attempt = 0; attempt < 3 && !gotDimensions; attempt++) {
|
||||
if (attempt > 0) {
|
||||
delay(50); // Give a slow SD card time to finish syncing before retrying
|
||||
}
|
||||
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to extract image");
|
||||
}
|
||||
gotDimensions = decoder && decoder->getDimensions(cachedImagePath, dims);
|
||||
}
|
||||
|
||||
if (gotDimensions) {
|
||||
LOG_DBG("EHP", "Image dimensions: %dx%d", dims.width, dims.height);
|
||||
|
||||
@@ -711,7 +742,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
self->currentPageNextY += imageMarginTop;
|
||||
|
||||
// Create ImageBlock and add to page
|
||||
auto imageBlock = std::make_shared<ImageBlock>(cachedImagePath, displayWidth, displayHeight);
|
||||
auto imageBlock =
|
||||
std::make_shared<ImageBlock>(cachedImagePath, resolvedPath, displayWidth, displayHeight);
|
||||
if (!imageBlock) {
|
||||
LOG_ERR("EHP", "Failed to create ImageBlock");
|
||||
return;
|
||||
@@ -742,8 +774,6 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
||||
LOG_ERR("EHP", "Failed to get image dimensions");
|
||||
Storage.remove(cachedImagePath.c_str());
|
||||
}
|
||||
} else {
|
||||
LOG_ERR("EHP", "Failed to extract image");
|
||||
}
|
||||
} // isFormatSupported
|
||||
}
|
||||
@@ -1143,24 +1173,28 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
||||
}
|
||||
self->partWordBufferIndex = safeLen;
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
for (int j = 0; j < overflow; j++) {
|
||||
self->partWordBuffer[j] = saved[j];
|
||||
}
|
||||
self->partWordBufferIndex = overflow;
|
||||
} else {
|
||||
self->flushPartWordBuffer();
|
||||
self->nextWordContinues = true;
|
||||
}
|
||||
}
|
||||
|
||||
self->partWordBuffer[self->partWordBufferIndex++] = s[i];
|
||||
}
|
||||
|
||||
// If we have > 750 words buffered up, perform the layout and consume out all but the last line
|
||||
// There should be enough here to build out 1-2 full pages and doing this will free up a lot of
|
||||
// memory.
|
||||
// Spotted when reading Intermezzo, there are some really long text blocks in there.
|
||||
if (self->currentTextBlock->size() > 750) {
|
||||
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
|
||||
// Keep token growth bounded: CSS-heavy spans can fragment text into many tiny
|
||||
// words, so flush earlier when embedded CSS is active. We still keep the
|
||||
// "exclude last line" behavior to preserve paragraph flow across chunks.
|
||||
const size_t blockWordCount = self->currentTextBlock->size();
|
||||
const size_t softFlushThreshold =
|
||||
self->embeddedStyle ? TEXT_BLOCK_SOFT_FLUSH_WORDS_WITH_CSS : TEXT_BLOCK_SOFT_FLUSH_WORDS;
|
||||
if (blockWordCount > softFlushThreshold) {
|
||||
LOG_DBG("EHP", "Text block soft flush (%u words)", static_cast<unsigned>(blockWordCount));
|
||||
const int horizontalInset = self->currentTextBlock->getBlockStyle().totalHorizontalInset();
|
||||
const uint16_t effectiveWidth = (horizontalInset < self->viewportWidth)
|
||||
? static_cast<uint16_t>(self->viewportWidth - horizontalInset)
|
||||
|
||||
@@ -28,6 +28,7 @@ class ChapterHtmlSlimParser {
|
||||
GfxRenderer& renderer;
|
||||
std::function<void(std::unique_ptr<Page>, uint16_t, uint16_t)> completePageFn;
|
||||
std::function<void()> popupFn; // Popup callback
|
||||
bool imagePopupFired = false; // popupFn fired for the first image probe (single-shot)
|
||||
int depth = 0;
|
||||
int skipUntilDepth = INT_MAX;
|
||||
int boldUntilDepth = INT_MAX;
|
||||
|
||||
@@ -213,6 +213,61 @@ static inline void rotateCoordinates(const GfxRenderer::Orientation orientation,
|
||||
}
|
||||
}
|
||||
|
||||
// Output of screenRectToAlignedMemRect: a rectangle in panel-memory
|
||||
// coordinates whose x and width are guaranteed to be multiples of 8 (the
|
||||
// SDK's EInkDisplay::displayWindow alignment requirement). `valid == false`
|
||||
// means the input was empty or fully outside the panel.
|
||||
struct AlignedMemRect {
|
||||
uint16_t x = 0;
|
||||
uint16_t y = 0;
|
||||
uint16_t w = 0;
|
||||
uint16_t h = 0;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
// Translate a screen-coordinate rectangle (the coordinate system used by
|
||||
// fillRect / drawText / the rest of the renderer's public API) into a
|
||||
// panel-memory rectangle suitable for direct framebuffer indexing. Rotates
|
||||
// the rectangle's two opposite corners with rotateCoordinates(), takes the
|
||||
// bounding box (which naturally swaps width/height in Portrait /
|
||||
// PortraitInverted), then snaps the x extent outward to multiples of 8 and
|
||||
// clamps to panel bounds. Precondition: panel dims are multiples of 8 (true
|
||||
// for the 800x480 panel), so clamping cannot re-break alignment.
|
||||
static AlignedMemRect screenRectToAlignedMemRect(GfxRenderer::Orientation orientation, int sx, int sy, int sw, int sh,
|
||||
uint16_t panelWidth, uint16_t panelHeight) {
|
||||
AlignedMemRect out;
|
||||
if (sw <= 0 || sh <= 0) return out;
|
||||
|
||||
int x0, y0, x1, y1;
|
||||
rotateCoordinates(orientation, sx, sy, &x0, &y0, panelWidth, panelHeight);
|
||||
rotateCoordinates(orientation, sx + sw - 1, sy + sh - 1, &x1, &y1, panelWidth, panelHeight);
|
||||
|
||||
const int memXLo = std::min(x0, x1);
|
||||
const int memYLo = std::min(y0, y1);
|
||||
const int memXHi = std::max(x0, x1) + 1; // exclusive upper bound
|
||||
const int memYHi = std::max(y0, y1) + 1;
|
||||
|
||||
// Snap x outward to multiples of 8.
|
||||
int alignedXLo = memXLo & ~0x7; // round down
|
||||
int alignedXHi = (memXHi + 7) & ~0x7; // round up
|
||||
|
||||
if (alignedXLo < 0) alignedXLo = 0;
|
||||
if (alignedXHi > panelWidth) alignedXHi = panelWidth;
|
||||
int clampedYLo = memYLo;
|
||||
int clampedYHi = memYHi;
|
||||
if (clampedYLo < 0) clampedYLo = 0;
|
||||
if (clampedYHi > panelHeight) clampedYHi = panelHeight;
|
||||
|
||||
if (alignedXHi <= alignedXLo || clampedYHi <= clampedYLo) return out;
|
||||
|
||||
out.x = static_cast<uint16_t>(alignedXLo);
|
||||
out.y = static_cast<uint16_t>(clampedYLo);
|
||||
out.w = static_cast<uint16_t>(alignedXHi - alignedXLo);
|
||||
out.h = static_cast<uint16_t>(clampedYHi - clampedYLo);
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
enum class TextRotation { None, Rotated90CW };
|
||||
|
||||
// Shared glyph rendering logic for normal and rotated text.
|
||||
@@ -1451,6 +1506,53 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const
|
||||
display.displayBuffer(refreshMode, fadingFix);
|
||||
}
|
||||
|
||||
void GfxRenderer::displayBufferAsync(const HalDisplay::RefreshMode refreshMode) const {
|
||||
// The async path has no turn-off-screen hook, which the sunlight fading fix
|
||||
// relies on; keep those users on the blocking path.
|
||||
if (fadingFix) {
|
||||
display.displayBuffer(refreshMode, fadingFix);
|
||||
return;
|
||||
}
|
||||
display.displayBufferAsync(refreshMode);
|
||||
}
|
||||
|
||||
void GfxRenderer::waitRefreshComplete() const { display.waitRefreshComplete(); }
|
||||
|
||||
bool GfxRenderer::supportsAsyncRefresh() const { return !fadingFix && display.supportsAsyncRefresh(); }
|
||||
|
||||
size_t GfxRenderer::readFramebufferRegion(int x, int y, int w, int h, uint8_t* dst, size_t dstCapacity) const {
|
||||
if (dst == nullptr || w <= 0 || h <= 0) return 0;
|
||||
|
||||
const AlignedMemRect mem = screenRectToAlignedMemRect(orientation, x, y, w, h, panelWidth, panelHeight);
|
||||
if (!mem.valid) return 0;
|
||||
|
||||
const size_t rowBytes = mem.w / 8; // exact: mem.w is a multiple of 8
|
||||
const size_t needed = rowBytes * mem.h;
|
||||
if (needed > dstCapacity) return 0;
|
||||
|
||||
for (uint16_t row = 0; row < mem.h; ++row) {
|
||||
const uint8_t* srcRow = frameBuffer + (static_cast<uint32_t>(mem.y + row) * panelWidthBytes) + (mem.x / 8);
|
||||
uint8_t* dstRow = dst + (static_cast<size_t>(row) * rowBytes);
|
||||
memcpy(dstRow, srcRow, rowBytes);
|
||||
}
|
||||
return needed;
|
||||
}
|
||||
|
||||
void GfxRenderer::writeFramebufferRegion(int x, int y, int w, int h, const uint8_t* src) {
|
||||
if (src == nullptr || w <= 0 || h <= 0) return;
|
||||
|
||||
const AlignedMemRect mem = screenRectToAlignedMemRect(orientation, x, y, w, h, panelWidth, panelHeight);
|
||||
if (!mem.valid) return;
|
||||
|
||||
const size_t rowBytes = mem.w / 8; // exact: mem.w is a multiple of 8
|
||||
|
||||
for (uint16_t row = 0; row < mem.h; ++row) {
|
||||
const uint8_t* srcRow = src + (static_cast<size_t>(row) * rowBytes);
|
||||
uint8_t* dstRow = frameBuffer + (static_cast<uint32_t>(mem.y + row) * panelWidthBytes) + (mem.x / 8);
|
||||
memcpy(dstRow, srcRow, rowBytes);
|
||||
}
|
||||
}
|
||||
|
||||
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||
const EpdFontFamily::Style style) const {
|
||||
if (!text || maxWidth <= 0) return "";
|
||||
@@ -1564,6 +1666,35 @@ int GfxRenderer::getScreenHeight() const {
|
||||
return panelWidth;
|
||||
}
|
||||
|
||||
void GfxRenderer::tapToLogical(float nx, float ny, int& outX, int& outY) const {
|
||||
int phyX = static_cast<int>(nx * panelWidth);
|
||||
int phyY = static_cast<int>(ny * panelHeight);
|
||||
if (phyX < 0) phyX = 0;
|
||||
if (phyX > panelWidth - 1) phyX = panelWidth - 1;
|
||||
if (phyY < 0) phyY = 0;
|
||||
if (phyY > panelHeight - 1) phyY = panelHeight - 1;
|
||||
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
outX = panelHeight - 1 - phyY;
|
||||
outY = phyX;
|
||||
break;
|
||||
case PortraitInverted:
|
||||
outX = phyY;
|
||||
outY = panelWidth - 1 - phyX;
|
||||
break;
|
||||
case LandscapeClockwise:
|
||||
outX = panelWidth - 1 - phyX;
|
||||
outY = panelHeight - 1 - phyY;
|
||||
break;
|
||||
case LandscapeCounterClockwise:
|
||||
default:
|
||||
outX = phyX;
|
||||
outY = phyY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Translate a logical rect through rotateCoordinates and take the bounding
|
||||
// box of its four corners on the physical panel. Output coords are inclusive
|
||||
// and clamped. Returns false if the rect ends up fully off-panel.
|
||||
|
||||
@@ -134,7 +134,19 @@ class GfxRenderer {
|
||||
// Screen ops
|
||||
int getScreenWidth() const;
|
||||
int getScreenHeight() const;
|
||||
void tapToLogical(float nx, float ny, int& outX, int& outY) const;
|
||||
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||
// Non-blocking refresh: starts the waveform and returns so CPU work (e.g.
|
||||
// grayscale strip rendering) can overlap the panel's refresh time. The
|
||||
// framebuffer must stay untouched until waitRefreshComplete(). Falls back to
|
||||
// a blocking refresh when fadingFix is enabled or the panel lacks deferral
|
||||
// support. See HalDisplay::displayBufferAsync for the baseline contract.
|
||||
void displayBufferAsync(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||
void waitRefreshComplete() const;
|
||||
// True when displayBufferAsync() genuinely overlaps: panel defers and
|
||||
// fadingFix isn't forcing the blocking path. Callers can skip overlap
|
||||
// scaffolding (e.g. whole-plane grayscale buffers) when false.
|
||||
bool supportsAsyncRefresh() const;
|
||||
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
||||
// void displayWindow(int x, int y, int width, int height) const;
|
||||
void invertScreen() const;
|
||||
@@ -189,6 +201,15 @@ class GfxRenderer {
|
||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
||||
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
|
||||
|
||||
// Snapshot / restore a screen-coordinate framebuffer region (byte-aligned in
|
||||
// panel memory). readFramebufferRegion returns the bytes written to dst, or
|
||||
// 0 when the region is empty, offscreen, or exceeds dstCapacity. Pass the
|
||||
// same rectangle to writeFramebufferRegion to restore the saved pixels.
|
||||
// Enables partial-repaint patterns (e.g. moving a selection highlight)
|
||||
// without re-rendering the whole page.
|
||||
size_t readFramebufferRegion(int x, int y, int w, int h, uint8_t* dst, size_t dstCapacity) const;
|
||||
void writeFramebufferRegion(int x, int y, int w, int h, const uint8_t* src);
|
||||
|
||||
// Text
|
||||
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR,
|
||||
BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const;
|
||||
|
||||
@@ -68,6 +68,7 @@ STR_TEXT_AA: "Згладжванне тэксту"
|
||||
STR_SHORT_PWR_BTN: "Кароткае націсканне PWR"
|
||||
STR_ORIENTATION: "Арыентацыя чытання"
|
||||
STR_SIDE_BTN_LAYOUT: "Бакавыя кнопкі"
|
||||
STR_TOUCH_READER_CONTROLS: "Сэнсарнае кіраванне чытаннем"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Арыентаваць пярэднія кнопкі"
|
||||
STR_LONG_PRESS_SKIP: "Доўгае націсканне - змена раздзела"
|
||||
STR_FONT_PREVIEW_TEXT: "У Іўі худы жвавы чорт у зялёнай камізэльцы пабег пад'есці фаршу з юшкай"
|
||||
|
||||
@@ -78,6 +78,7 @@ STR_EOB_CONTINUE_WITH: "Continua amb"
|
||||
STR_SHORT_PWR_BTN: "Pulsació curta del botó d'engegada"
|
||||
STR_ORIENTATION: "Orientació de lectura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
|
||||
STR_TOUCH_READER_CONTROLS: "Controls tàctils del lector"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botons frontals"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Acció en mantenir premut un botó"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||
|
||||
@@ -68,6 +68,7 @@ STR_TEXT_AA: "Vyhlazování textu"
|
||||
STR_SHORT_PWR_BTN: "Krátké stisknutí tlačítka napájení"
|
||||
STR_ORIENTATION: "Orientace čtení"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozvržení bočních tlačítek (čtečka)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dotykové ovládání čtečky"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientovat přední tlačítka"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Chování při dlouhém stisknutí tlačítka"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Skjul"
|
||||
STR_SHORT_PWR_BTN: "Kort tryk på tænd/sluk-knap"
|
||||
STR_ORIENTATION: "Læseretning"
|
||||
STR_SIDE_BTN_LAYOUT: "Knaplayout på siden (læser)"
|
||||
STR_TOUCH_READER_CONTROLS: "Touchkontroller i læser"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientér forreste knapper"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamiento al mantener pulsado el botón"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivado"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Verbergen"
|
||||
STR_SHORT_PWR_BTN: "Korte klik aan/uit-knop"
|
||||
STR_ORIENTATION: "Leesstand"
|
||||
STR_SIDE_BTN_LAYOUT: "Indeling zijknoppen (lezer)"
|
||||
STR_TOUCH_READER_CONTROLS: "Aanraakbediening lezer"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Richt voorste knoppen"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
|
||||
@@ -10,6 +10,7 @@ STR_BROWSE_FILES: "Browse Files"
|
||||
STR_FILE_TRANSFER: "File Transfer"
|
||||
STR_SETTINGS_TITLE: "Settings"
|
||||
STR_CONTINUE_READING: "Continue Reading"
|
||||
STR_RESUME: "Resume"
|
||||
STR_NO_OPEN_BOOK: "No open book"
|
||||
STR_START_READING: "Start reading below"
|
||||
STR_NO_FILES_FOUND: "No files found"
|
||||
@@ -25,6 +26,12 @@ STR_EMPTY_FILE: "Empty file"
|
||||
STR_OUT_OF_BOUNDS: "Out of bounds"
|
||||
STR_LOADING: "Loading..."
|
||||
STR_LOADING_POPUP: "Loading"
|
||||
STR_LOOKUP: "Look Up"
|
||||
STR_DICT_LOOKING_UP: "Looking up..."
|
||||
STR_DICT_INDEXING: "Indexing dictionary..."
|
||||
STR_DICT_NOT_FOUND: "Not found"
|
||||
STR_DICT_NO_DICT_SET: "No dictionary set"
|
||||
STR_DICT_ERROR: "Dictionary error"
|
||||
STR_WIFI_NETWORKS: "Wi-Fi Networks"
|
||||
STR_NO_NETWORKS: "No networks found"
|
||||
STR_NETWORKS_FOUND: "%zu networks found"
|
||||
@@ -81,6 +88,7 @@ STR_EOB_CONTINUE_WITH: "Continue with"
|
||||
STR_SHORT_PWR_BTN: "Short Power Button Click"
|
||||
STR_ORIENTATION: "Reading Orientation"
|
||||
STR_SIDE_BTN_LAYOUT: "Side Button Layout (reader)"
|
||||
STR_TOUCH_READER_CONTROLS: "Touch Reader Controls"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orient front buttons"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
@@ -108,7 +116,15 @@ STR_PASSWORD: "Password"
|
||||
STR_SYNC_SERVER_URL: "Sync Server URL"
|
||||
STR_DOCUMENT_MATCHING: "Document Matching"
|
||||
STR_SEND_METADATA: "Send Document Metadata"
|
||||
STR_SYNC_BEHAVIOR: "Sync Behavior"
|
||||
STR_ASK_EVERY_TIME: "Ask every time"
|
||||
STR_SMART_SYNC: "Smart sync"
|
||||
STR_AUTHENTICATE: "Authenticate"
|
||||
STR_SIGN_UP: "Sign Up"
|
||||
STR_CREATING_ACCOUNT: "Creating account..."
|
||||
STR_ACCOUNT_CREATED: "Account created"
|
||||
STR_SIGNUP_FAILED: "Sign up failed"
|
||||
STR_USERNAME_TAKEN: "Username is already registered"
|
||||
STR_KOREADER_USERNAME: "KOReader Username"
|
||||
STR_KOREADER_PASSWORD: "KOReader Password"
|
||||
STR_FILENAME: "Filename"
|
||||
@@ -154,6 +170,7 @@ STR_PREV_NEXT: "Prev/Next"
|
||||
STR_NEXT_PREV: "Next/Prev"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Bookmark"
|
||||
STR_DICTIONARY: "Dictionary"
|
||||
STR_DISABLED: "Disabled"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
@@ -214,6 +231,7 @@ STR_CONNECT: "Connect"
|
||||
STR_OPEN: "Open"
|
||||
STR_DOWNLOAD: "Download"
|
||||
STR_RETRY: "Retry"
|
||||
STR_TAP_TO_RETRY: "Tap to retry"
|
||||
STR_YES: "Yes"
|
||||
STR_NO: "No"
|
||||
STR_SHOW: "Show"
|
||||
@@ -226,6 +244,9 @@ STR_DIR_RIGHT: "Right"
|
||||
STR_DIR_UP: "Up"
|
||||
STR_DIR_DOWN: "Down"
|
||||
STR_OK_BUTTON: "OK"
|
||||
STR_KEY_SHIFT: "Shift"
|
||||
STR_KEY_MODE_SYMBOLS: "?123"
|
||||
STR_KEY_MODE_ABC: "abc"
|
||||
STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter"
|
||||
STR_FILTER_CONTRAST: "Contrast"
|
||||
STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar"
|
||||
@@ -324,6 +345,7 @@ STR_UPLOAD_LOCAL: "Upload local progress"
|
||||
STR_NO_REMOTE_MSG: "No remote progress found"
|
||||
STR_UPLOAD_PROMPT: "Upload current position?"
|
||||
STR_UPLOAD_SUCCESS: "Progress uploaded!"
|
||||
STR_ALREADY_SYNCED: "Already synced"
|
||||
STR_SYNC_FAILED_MSG: "Sync failed"
|
||||
STR_SAVE_PROGRESS_FAILED: "Could not save progress"
|
||||
STR_SECTION_PREFIX: "Section "
|
||||
@@ -333,6 +355,7 @@ STR_EMBEDDED_STYLE: "Embedded Style"
|
||||
STR_FOCUS_READING: "Focus Reading"
|
||||
STR_OPDS_SERVER_URL: "OPDS Server URL"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Quick-return from footnotes"
|
||||
STR_BACK_SHORT_TO_FILE_BROWSER: "Short Back to File Browser"
|
||||
STR_SET_SLEEP_COVER: "Set Cover"
|
||||
STR_FOOTNOTES: "Footnotes"
|
||||
STR_NO_FOOTNOTES: "No footnotes on this page"
|
||||
|
||||
@@ -68,6 +68,7 @@ STR_TEXT_AA: "Tekstin reunanpehmennys"
|
||||
STR_SHORT_PWR_BTN: "Lyhyt virtapainikkeen painallus"
|
||||
STR_ORIENTATION: "Lukusuunta"
|
||||
STR_SIDE_BTN_LAYOUT: "Sivupainikkeiden asettelu (lukija)"
|
||||
STR_TOUCH_READER_CONTROLS: "Lukijan kosketusohjaus"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Suuntaa etupainikkeet"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Long-press button behavior"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Masquer"
|
||||
STR_SHORT_PWR_BTN: "Appui court alim."
|
||||
STR_ORIENTATION: "Orientation de lecture"
|
||||
STR_SIDE_BTN_LAYOUT: "Boutons latéraux"
|
||||
STR_TOUCH_READER_CONTROLS: "Commandes tactiles lecteur"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienter boutons avant"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé"
|
||||
|
||||
@@ -68,6 +68,7 @@ STR_TEXT_AA: "Schriftglättung"
|
||||
STR_SHORT_PWR_BTN: "An-Taste kurz drücken"
|
||||
STR_ORIENTATION: "Leseausrichtung"
|
||||
STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)"
|
||||
STR_TOUCH_READER_CONTROLS: "Touch-Steuerung beim Lesen"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Aus"
|
||||
@@ -315,6 +316,7 @@ STR_BOOK_S_STYLE: "Buch-Stil"
|
||||
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
||||
STR_FOCUS_READING: "Fokus-Lesen"
|
||||
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
|
||||
STR_BACK_SHORT_TO_FILE_BROWSER: "Kurz zurück drücken zum Datei-Browser"
|
||||
STR_SET_SLEEP_COVER: "Wähle Cover"
|
||||
STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen"
|
||||
STR_FOOTNOTES: "Fußnoten"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "הסתר תמונות"
|
||||
STR_SHORT_PWR_BTN: "לחיצה קצרה על כפתור ההפעלה"
|
||||
STR_ORIENTATION: "כיוון קריאה (מסך)"
|
||||
STR_SIDE_BTN_LAYOUT: "פריסת כפתורי צד (בקריאה)"
|
||||
STR_TOUCH_READER_CONTROLS: "שליטה במגע (קורא)"
|
||||
STR_LONG_PRESS_BEHAVIOR: "פעולת לחיצה ארוכה"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "כבוי"
|
||||
STR_LONG_PRESS_BEHAVIOR_SKIP: "דלג פרק"
|
||||
|
||||
@@ -74,6 +74,7 @@ STR_IMAGES_SUPPRESS: "Elnyomás"
|
||||
STR_SHORT_PWR_BTN: "Rövid bekapcsológomb nyomás"
|
||||
STR_ORIENTATION: "Olvasási irány"
|
||||
STR_SIDE_BTN_LAYOUT: "Oldalsó gomb elrendezés (olvasó)"
|
||||
STR_TOUCH_READER_CONTROLS: "Érintős olvasóvezérlés"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Elülső gombok tájolása"
|
||||
STR_LONG_PRESS_SKIP: "Hosszú nyomás - fejezet ugrás"
|
||||
STR_FONT_PREVIEW_TEXT: "Egy hűtlen vejét fülöncsípő, dühös mexikói úr Wesselényinél mázol Quitóban"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Nascondi"
|
||||
STR_SHORT_PWR_BTN: "Press. breve pul. accensione"
|
||||
STR_ORIENTATION: "Orientamento lettura"
|
||||
STR_SIDE_BTN_LAYOUT: "Pul. laterali (lettore)"
|
||||
STR_TOUCH_READER_CONTROLS: "Controlli touch lettore"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienta pul. frontali"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Press. lunga pul. laterali"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
|
||||
|
||||
@@ -67,6 +67,7 @@ STR_TEXT_AA: "Мәтін сырғытпасы"
|
||||
STR_SHORT_PWR_BTN: "Қуат түймесін қысқа басу"
|
||||
STR_ORIENTATION: "Оқу бағдары"
|
||||
STR_SIDE_BTN_LAYOUT: "Бүйірлік түймелер орналасуы (оқырман)"
|
||||
STR_TOUCH_READER_CONTROLS: "Оқырманның сенсорлық басқаруы"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Алдыңғы түймелерді бағдарлау"
|
||||
STR_LONG_PRESS_SKIP: "Ұзақ басу арқылы тарау өткізу"
|
||||
STR_FONT_PREVIEW_TEXT: "Канагаттандырылмагандыктарыныздан"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Slėpti"
|
||||
STR_SHORT_PWR_BTN: "Trumpas įjungimo pasp."
|
||||
STR_ORIENTATION: "Orientacija"
|
||||
STR_SIDE_BTN_LAYOUT: "Šoniniai mygtukai"
|
||||
STR_TOUCH_READER_CONTROLS: "Lietimo valdikliai skaityklėje"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuoti priekinius mygtukus"
|
||||
STR_LONG_PRESS_SKIP: "Praleisti skyrių (ilgai)"
|
||||
STR_FONT_PREVIEW_TEXT: "Įlinkdama fechtuotojo špaga sublykčiojusi pragręžė apvalų arbūzą"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Pomijaj"
|
||||
STR_SHORT_PWR_BTN: "Krótkie naciśnięcie zasilania"
|
||||
STR_ORIENTATION: "Układ czytania"
|
||||
STR_SIDE_BTN_LAYOUT: "Układ przycisków bocznych"
|
||||
STR_TOUCH_READER_CONTROLS: "Sterowanie dotykowe czytnika"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientuj przednie przyciski"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Funkcja długiego przyciśnięcia"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Wył."
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Ocultar"
|
||||
STR_SHORT_PWR_BTN: "Clique curto botão ligar"
|
||||
STR_ORIENTATION: "Orientação de leitura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposição botões laterais"
|
||||
STR_TOUCH_READER_CONTROLS: "Controles táteis do leitor"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "DESL."
|
||||
|
||||
@@ -18,7 +18,6 @@ STR_NO_CHAPTERS: "Sem capítulos"
|
||||
STR_END_OF_BOOK: "Fim do livro"
|
||||
STR_EMPTY_CHAPTER: "Capítulo vazio"
|
||||
STR_INDEXING: "A indexar"
|
||||
STR_INDEX_FAILED: "Falha ao indexar - livro inválido"
|
||||
STR_MEMORY_ERROR: "Erro de memória"
|
||||
STR_PAGE_LOAD_ERROR: "Erro ao carregar a página"
|
||||
STR_EMPTY_FILE: "Ficheiro vazio"
|
||||
@@ -29,10 +28,7 @@ STR_WIFI_NETWORKS: "Redes Wi-Fi"
|
||||
STR_NO_NETWORKS: "Nenhuma rede encontrada"
|
||||
STR_NETWORKS_FOUND: "%zu redes encontradas"
|
||||
STR_SCANNING: "A procurar..."
|
||||
STR_FINDING_SAVED_WIFI: "A procurar Wi-Fi guardado..."
|
||||
STR_CONNECTING: "A ligar..."
|
||||
STR_CONNECTING_SAVED_WIFI: "A ligar ao Wi-Fi guardado..."
|
||||
STR_SHOW_NETWORKS: "Mostrar"
|
||||
STR_CONNECTED: "Ligado!"
|
||||
STR_CONNECTION_FAILED: "Falha na ligação"
|
||||
STR_FORGET_NETWORK: "Esquecer rede?"
|
||||
@@ -53,8 +49,6 @@ STR_NETWORK_LEGEND: "* = Encriptada | + = Guardada"
|
||||
STR_MAC_ADDRESS: "Endereço MAC:"
|
||||
STR_CHECKING_WIFI: "A verificar o Wi-Fi..."
|
||||
STR_ENTER_WIFI_PASSWORD: "Introduza a palavra-passe do Wi-Fi"
|
||||
STR_ADD_HIDDEN_NETWORK: "Adicionar rede oculta..."
|
||||
STR_ENTER_WIFI_SSID: "Introduza o nome da rede (SSID)"
|
||||
STR_TO_PREFIX: "para "
|
||||
STR_CALIBRE_RECEIVING: "A receber: "
|
||||
STR_CALIBRE_RECEIVED: "Recebido: "
|
||||
@@ -76,8 +70,6 @@ STR_IMAGES: "Imagens"
|
||||
STR_IMAGES_DISPLAY: "Exibição"
|
||||
STR_IMAGES_PLACEHOLDER: "Espaço reservado"
|
||||
STR_IMAGES_SUPPRESS: "Suprimir"
|
||||
STR_EOB_HOME: "Início"
|
||||
STR_EOB_CONTINUE_WITH: "Continuar com"
|
||||
STR_SHORT_PWR_BTN: "Clique curto no botão de energia"
|
||||
STR_ORIENTATION: "Orientação de leitura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposição dos botões laterais (leitor)"
|
||||
@@ -347,6 +339,11 @@ STR_SERVER_NAME: "Nome do servidor"
|
||||
STR_NO_SERVERS: "Nenhum servidor OPDS configurado"
|
||||
STR_DELETE_SERVER: "Eliminar servidor"
|
||||
STR_OPDS_SERVERS: "Servidores OPDS"
|
||||
STR_OPDS_DOWNLOAD_FOLDER: "Pasta de downloads"
|
||||
STR_OPDS_FILENAME_FORMAT: "Formato do nome do ficheiro"
|
||||
STR_FMT_AUTHOR_TITLE: "Autor - Título"
|
||||
STR_FMT_TITLE: "Título"
|
||||
STR_FMT_TITLE_AUTHOR: "Título - Autor"
|
||||
STR_AUTO_TURN_ENABLED: "Virar página automático ativado: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Virar página automático (Páginas por minuto)"
|
||||
STR_MANAGE_FONTS: "Gerir tipos de letra"
|
||||
@@ -392,3 +389,5 @@ STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!"
|
||||
STR_RECOVERY_MODE: "Modo de Recuperação"
|
||||
STR_RECOVERY_MODE_HINT: "Coloque o ficheiro firmware.bin na raiz do cartão SD e selecione-o"
|
||||
STR_ADD_HIDDEN_NETWORK: "Adicionar rede oculta..."
|
||||
STR_ENTER_WIFI_SSID: "Introduza o nome da rede (SSID)"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Suprimare"
|
||||
STR_SHORT_PWR_BTN: "Apăsare scurtă întrerupător"
|
||||
STR_ORIENTATION: "Orientare lectură"
|
||||
STR_SIDE_BTN_LAYOUT: "Aspect butoane laterale (lectură)"
|
||||
STR_TOUCH_READER_CONTROLS: "Comenzi tactile cititor"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientare butoane frontale"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Comportament buton apăsat lung"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Dezactivat"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Скрыть"
|
||||
STR_SHORT_PWR_BTN: "Короткое нажатие PWR"
|
||||
STR_ORIENTATION: "Ориентация чтения"
|
||||
STR_SIDE_BTN_LAYOUT: "Боковые кнопки"
|
||||
STR_TOUCH_READER_CONTROLS: "Сенсорное управление чтением"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Ориентировать передние кнопки"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Долгое нажатие"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Ничего"
|
||||
|
||||
@@ -72,7 +72,8 @@ STR_IMAGES_PLACEHOLDER: "Rezervované miesto"
|
||||
STR_IMAGES_SUPPRESS: "Potlačiť"
|
||||
STR_SHORT_PWR_BTN: "Krátke stlačenie tlačidla napájania"
|
||||
STR_ORIENTATION: "Orientácia čítania"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozloženie bočných tlačidiel (čítačka)"
|
||||
STR_SIDE_BTN_LAYOUT: "Rozloženie bočných tlačidiel (čítačka)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dotykové ovládanie čítačky"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Prispôsobiť predné tlačidlá orientácii"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Správanie pri dlhom stlačení tlačidla"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "VYP"
|
||||
|
||||
@@ -72,6 +72,7 @@ STR_IMAGES_SUPPRESS: "Zatdi"
|
||||
STR_SHORT_PWR_BTN: "Kratek pritisk na gumb za vklop"
|
||||
STR_ORIENTATION: "Orientacija branja"
|
||||
STR_SIDE_BTN_LAYOUT: "Razpored stranskih gumbov"
|
||||
STR_TOUCH_READER_CONTROLS: "Upravljanje bralnika na dotik"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Usmeri sprednje gumbe"
|
||||
STR_LONG_PRESS_SKIP: "Dolgi pritisk za preskok poglavja"
|
||||
STR_FONT_PREVIEW_TEXT: "V kožuščku hudobnega fanta stopiclja mizar"
|
||||
|
||||
@@ -78,6 +78,7 @@ STR_EOB_CONTINUE_WITH: "Continuar con"
|
||||
STR_SHORT_PWR_BTN: "Toque corto del encendido"
|
||||
STR_ORIENTATION: "Orientación"
|
||||
STR_SIDE_BTN_LAYOUT: "Función botones laterales (lector)"
|
||||
STR_TOUCH_READER_CONTROLS: "Controles táctiles del lector"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botones frontales"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Al mantener pulsado un botón"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "No hacer nada"
|
||||
|
||||
@@ -10,6 +10,7 @@ STR_BROWSE_FILES: "Bläddra filer…"
|
||||
STR_FILE_TRANSFER: "Filöverföring"
|
||||
STR_SETTINGS_TITLE: "Inställningar"
|
||||
STR_CONTINUE_READING: "Fortsätt läsa"
|
||||
STR_RESUME: "Återuppta"
|
||||
STR_NO_OPEN_BOOK: "Ingen öppen bok"
|
||||
STR_START_READING: "Börja läsa nedan"
|
||||
STR_NO_FILES_FOUND: "Inga filer hittades"
|
||||
@@ -25,11 +26,20 @@ STR_EMPTY_FILE: "Tom fil"
|
||||
STR_OUT_OF_BOUNDS: "Utanför gränserna"
|
||||
STR_LOADING: "Laddar…"
|
||||
STR_LOADING_POPUP: "Laddar"
|
||||
STR_LOOKUP: "Slå upp"
|
||||
STR_DICT_LOOKING_UP: "Slår upp..."
|
||||
STR_DICT_INDEXING: "Indexerar ordbok..."
|
||||
STR_DICT_NOT_FOUND: "Hittades inte"
|
||||
STR_DICT_NO_DICT_SET: "Ingen ordbok angiven"
|
||||
STR_DICT_ERROR: "Ordboksfel"
|
||||
STR_WIFI_NETWORKS: "Trådlösa nätverk"
|
||||
STR_NO_NETWORKS: "Inga nätverk funna"
|
||||
STR_NETWORKS_FOUND: "%zu nätverk funna"
|
||||
STR_SCANNING: "Scannar…"
|
||||
STR_FINDING_SAVED_WIFI: "Hittar sparade Wi-Fi..."
|
||||
STR_CONNECTING: "Ansluter…"
|
||||
STR_CONNECTING_SAVED_WIFI: "Ansluter till sparat Wi-Fi..."
|
||||
STR_SHOW_NETWORKS: "Visa"
|
||||
STR_CONNECTED: "Ansluten!"
|
||||
STR_CONNECTION_FAILED: "Anslutning misslyckades"
|
||||
STR_FORGET_NETWORK: "Glöm nätverk?"
|
||||
@@ -50,6 +60,8 @@ STR_NETWORK_LEGEND: "* = Krypterad | + = Sparad"
|
||||
STR_MAC_ADDRESS: "MAC-adress:"
|
||||
STR_CHECKING_WIFI: "Kontrollerar trådlöst nätverk…"
|
||||
STR_ENTER_WIFI_PASSWORD: "Skriv in Wi-Fi-lösenord"
|
||||
STR_ADD_HIDDEN_NETWORK: "Lägg till dolt nätverk..."
|
||||
STR_ENTER_WIFI_SSID: "Ange nätverksnamn (SSID)"
|
||||
STR_TO_PREFIX: "till "
|
||||
STR_CALIBRE_RECEIVING: "Tar emot:"
|
||||
STR_CALIBRE_RECEIVED: "Mottaget:"
|
||||
@@ -76,6 +88,7 @@ STR_EOB_CONTINUE_WITH: "Fortsätt med"
|
||||
STR_SHORT_PWR_BTN: "Kort strömknappsklick"
|
||||
STR_ORIENTATION: "Läsrikting"
|
||||
STR_SIDE_BTN_LAYOUT: "Sidoknappslayout (Läsare)"
|
||||
STR_TOUCH_READER_CONTROLS: "Pekkontroller i läsaren"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Rikta främre knappar"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Beteende vid lång knapptryckning"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "AV"
|
||||
@@ -103,7 +116,15 @@ STR_PASSWORD: "Lösenord"
|
||||
STR_SYNC_SERVER_URL: "Synkronisera serveradress"
|
||||
STR_DOCUMENT_MATCHING: "Dokumentmatchning"
|
||||
STR_SEND_METADATA: "Skicka dokumentmetadata"
|
||||
STR_SYNC_BEHAVIOR: "Synkroniseringsbeteende"
|
||||
STR_ASK_EVERY_TIME: "Fråga varje gång"
|
||||
STR_SMART_SYNC: "Smart synkronisering"
|
||||
STR_AUTHENTICATE: "Autentisera "
|
||||
STR_SIGN_UP: "Registrera dig"
|
||||
STR_CREATING_ACCOUNT: "Skapar konto..."
|
||||
STR_ACCOUNT_CREATED: "Konto skapat"
|
||||
STR_SIGNUP_FAILED: "Registreringen misslyckades"
|
||||
STR_USERNAME_TAKEN: "Användarnamnet är redan registrerat"
|
||||
STR_KOREADER_USERNAME: "KOReader användarnamn"
|
||||
STR_KOREADER_PASSWORD: "KOReader lösenord"
|
||||
STR_FILENAME: "Filnamn"
|
||||
@@ -149,6 +170,7 @@ STR_PREV_NEXT: "Förra/Nästa"
|
||||
STR_NEXT_PREV: "Nästa/Förra"
|
||||
STR_KOSYNC: "KOSync"
|
||||
STR_BOOKMARK_OPTION: "Bokmärke"
|
||||
STR_DICTIONARY: "Ordbok"
|
||||
STR_DISABLED: "Inaktiverad"
|
||||
STR_NOTO_SERIF: "Noto Serif"
|
||||
STR_NOTO_SANS: "Noto Sans"
|
||||
@@ -319,6 +341,7 @@ STR_UPLOAD_LOCAL: "Ladda upp lokala framsteg"
|
||||
STR_NO_REMOTE_MSG: "Inga fjärrframsteg funna"
|
||||
STR_UPLOAD_PROMPT: "Ladda upp nuvarande position?"
|
||||
STR_UPLOAD_SUCCESS: "Framsteg uppladdade!"
|
||||
STR_ALREADY_SYNCED: "Redan synkroniserad"
|
||||
STR_SYNC_FAILED_MSG: "Synkronisering misslyckades"
|
||||
STR_SAVE_PROGRESS_FAILED: "Kunde inte spara framsteg"
|
||||
STR_SECTION_PREFIX: "Sektion"
|
||||
@@ -328,6 +351,7 @@ STR_EMBEDDED_STYLE: "Inbäddad stil"
|
||||
STR_FOCUS_READING: "Fokusläsning"
|
||||
STR_OPDS_SERVER_URL: "OPDS-serveradress"
|
||||
STR_PWR_BTN_FOOTNOTE_BACK: "Snabbåtergång från fotnoter"
|
||||
STR_BACK_SHORT_TO_FILE_BROWSER: "Snabb tillbaka till filläsaren"
|
||||
STR_SET_SLEEP_COVER: "Ställ in omslag"
|
||||
STR_FOOTNOTES: "Fotnoter"
|
||||
STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan"
|
||||
@@ -347,6 +371,7 @@ STR_OPDS_FILENAME_FORMAT: "Filnamnsformat"
|
||||
STR_FMT_AUTHOR_TITLE: "Författare - Titel"
|
||||
STR_FMT_TITLE_AUTHOR: "Titel - Författare"
|
||||
STR_FMT_TITLE: "Titel"
|
||||
STR_OPDS_SD_ROOT: "SD-rot"
|
||||
STR_AUTO_TURN_ENABLED: "Automatisk vändning aktiverad: "
|
||||
STR_AUTO_TURN_PAGES_PER_MIN: "Automatisk vändning (sidor per minut)"
|
||||
STR_MANAGE_FONTS: "Hantera teckensnitt"
|
||||
@@ -392,5 +417,3 @@ STR_FIRMWARE_WRITE_FAILED: "Skrivning till firmware misslyckades"
|
||||
STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Stäng inte av!"
|
||||
STR_RECOVERY_MODE: "Återställningsläge"
|
||||
STR_RECOVERY_MODE_HINT: "Placera firmware.bin i SD-kortroten och välj den"
|
||||
STR_ADD_HIDDEN_NETWORK: "Lägg till dolt nätverk..."
|
||||
STR_ENTER_WIFI_SSID: "Ange nätverksnamn (SSID)"
|
||||
|
||||
@@ -67,6 +67,7 @@ STR_TEXT_AA: "Metin Yumuşatma (AA)"
|
||||
STR_SHORT_PWR_BTN: "Kısa Güç Tuşu Tıklaması"
|
||||
STR_ORIENTATION: "Okuma Yönü"
|
||||
STR_SIDE_BTN_LAYOUT: "Yan Tuş Dizilimi (okuyucu)"
|
||||
STR_TOUCH_READER_CONTROLS: "Dokunmatik okuyucu kontrolleri"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Ön düğmeleri yönlendir"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Uzun basma tuş davranışı"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "KAPALI"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Приховати"
|
||||
STR_SHORT_PWR_BTN: "Короткий натиск кн. живл."
|
||||
STR_ORIENTATION: "Орієнтація читання"
|
||||
STR_SIDE_BTN_LAYOUT: "Схема бічних кнопок"
|
||||
STR_TOUCH_READER_CONTROLS: "Сенсорне керування читанням"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Орієнтувати передні кнопки"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Поведінка при довгому настику"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Немає"
|
||||
|
||||
@@ -79,6 +79,7 @@ STR_EOB_CONTINUE_WITH: "Continua amb"
|
||||
STR_SHORT_PWR_BTN: "Pulsació curta del botó d'engegada"
|
||||
STR_ORIENTATION: "Orientació de lectura"
|
||||
STR_SIDE_BTN_LAYOUT: "Disposició botons laterals"
|
||||
STR_TOUCH_READER_CONTROLS: "Controls tàctils del lector"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botons frontals"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Acció en mantindre premut un botó"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "Desactivat"
|
||||
|
||||
@@ -73,6 +73,7 @@ STR_IMAGES_SUPPRESS: "Ẩn đi"
|
||||
STR_SHORT_PWR_BTN: "Nhấn nhanh nút nguồn"
|
||||
STR_ORIENTATION: "Hướng đọc"
|
||||
STR_SIDE_BTN_LAYOUT: "Bố trí nút bên (trình đọc)"
|
||||
STR_TOUCH_READER_CONTROLS: "Điều khiển đọc bằng cảm ứng"
|
||||
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Xoay nút trước theo hướng"
|
||||
STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút"
|
||||
STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT"
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <JPEGDEC.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -169,11 +171,22 @@ constexpr uint32_t FP_ONE = 1UL << 16;
|
||||
// Static file pointer for JPEGDEC open callback.
|
||||
// Safe in single-threaded embedded context; never accessed concurrently.
|
||||
static HalFile* s_jpegFile = nullptr;
|
||||
static uint8_t s_jpegIoSinceYield = 0;
|
||||
|
||||
static void yieldToIdle() { vTaskDelay(1); }
|
||||
|
||||
static void yieldDuringJpegIo() {
|
||||
if (++s_jpegIoSinceYield < 4) return;
|
||||
s_jpegIoSinceYield = 0;
|
||||
yieldToIdle();
|
||||
}
|
||||
|
||||
void* bmpJpegOpen(const char* /*filename*/, int32_t* size) {
|
||||
if (!s_jpegFile || !*s_jpegFile) return nullptr;
|
||||
s_jpegIoSinceYield = 0;
|
||||
s_jpegFile->seek(0);
|
||||
*size = static_cast<int32_t>(s_jpegFile->size());
|
||||
yieldDuringJpegIo();
|
||||
return s_jpegFile;
|
||||
}
|
||||
|
||||
@@ -187,6 +200,7 @@ int32_t bmpJpegRead(JPEGFILE* pFile, uint8_t* pBuf, int32_t len) {
|
||||
int32_t n = f->read(pBuf, len);
|
||||
if (n < 0) n = 0;
|
||||
pFile->iPos += n;
|
||||
yieldDuringJpegIo();
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -194,6 +208,7 @@ int32_t bmpJpegSeek(JPEGFILE* pFile, int32_t pos) {
|
||||
auto* f = reinterpret_cast<HalFile*>(pFile->fHandle);
|
||||
if (!f || !f->seek(pos)) return -1;
|
||||
pFile->iPos = pos;
|
||||
yieldDuringJpegIo();
|
||||
return pos;
|
||||
}
|
||||
|
||||
@@ -236,9 +251,23 @@ struct BmpConvertCtx {
|
||||
std::unique_ptr<FloydSteinbergDitherer> fsDitherer;
|
||||
std::unique_ptr<Atkinson1BitDitherer> atkinson1BitDitherer;
|
||||
|
||||
uint8_t rowsSinceYield;
|
||||
uint8_t blocksSinceYield;
|
||||
bool error;
|
||||
};
|
||||
|
||||
static void yieldDuringDecode(BmpConvertCtx* ctx) {
|
||||
if (++ctx->rowsSinceYield < 8) return;
|
||||
ctx->rowsSinceYield = 0;
|
||||
yieldToIdle();
|
||||
}
|
||||
|
||||
static void yieldDuringDecodeBlock(BmpConvertCtx* ctx) {
|
||||
if (++ctx->blocksSinceYield < 16) return;
|
||||
ctx->blocksSinceYield = 0;
|
||||
yieldToIdle();
|
||||
}
|
||||
|
||||
// Write a fully-assembled output row (grayscale bytes, length outWidth) to BMP
|
||||
static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY) {
|
||||
memset(ctx->bmpRow.get(), 0, ctx->bytesPerRow);
|
||||
@@ -274,6 +303,7 @@ static void writeOutputRow(BmpConvertCtx* ctx, const uint8_t* srcRow, int outY)
|
||||
}
|
||||
|
||||
ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow);
|
||||
yieldDuringDecode(ctx);
|
||||
}
|
||||
|
||||
// Matches the progressive-JPEG smoothing used by JpegToFramebufferConverter, but stays
|
||||
@@ -396,6 +426,7 @@ static void flushScaledRow(BmpConvertCtx* ctx) {
|
||||
|
||||
ctx->bmpOut->write(ctx->bmpRow.get(), ctx->bytesPerRow);
|
||||
ctx->currentOutY++;
|
||||
yieldDuringDecode(ctx);
|
||||
}
|
||||
|
||||
// JPEGDEC draw callback — receives one MCU-width × MCU-height block at a time,
|
||||
@@ -405,6 +436,7 @@ static void flushScaledRow(BmpConvertCtx* ctx) {
|
||||
int bmpDrawCallback(JPEGDRAW* pDraw) {
|
||||
auto* ctx = reinterpret_cast<BmpConvertCtx*>(pDraw->pUser);
|
||||
if (!ctx || ctx->error) return 0;
|
||||
yieldDuringDecodeBlock(ctx);
|
||||
|
||||
const uint8_t* pixels = reinterpret_cast<uint8_t*>(pDraw->pPixels);
|
||||
const int stride = pDraw->iWidth;
|
||||
@@ -599,6 +631,8 @@ bool JpegToBmpConverter::jpegFileToBmpStreamInternal(HalFile& jpegFile, Print& b
|
||||
ctx.smoothScaleY_fp = interpolationStep(ctx.srcHeight, outHeight);
|
||||
ctx.smoothNextOutY = 0;
|
||||
ctx.smoothPrevY = -1;
|
||||
ctx.rowsSinceYield = 0;
|
||||
ctx.blocksSinceYield = 0;
|
||||
ctx.error = false;
|
||||
|
||||
// MCU row buffer: MAX_MCU_HEIGHT rows × decoded srcWidth columns of grayscale
|
||||
|
||||
@@ -5,16 +5,27 @@
|
||||
#include <ObfuscationUtils.h>
|
||||
|
||||
namespace {
|
||||
// Default sync server URL
|
||||
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
||||
// Default sync server URL. crosspoint-sync speaks the full KOSync protocol, so
|
||||
// pointing at any other kosync server (e.g. https://sync.koreader.rocks:443)
|
||||
// still works via the custom server URL setting.
|
||||
constexpr char DEFAULT_SERVER_URL[] = "https://sync.crosspointreader.com";
|
||||
|
||||
// Default before config version 2. Configs saved without a version stamp and an
|
||||
// empty serverUrl were implicitly syncing here — they get pinned on upgrade.
|
||||
constexpr char LEGACY_DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
||||
|
||||
// Bumped when a change to defaults would alter behavior for existing configs.
|
||||
constexpr uint8_t CONFIG_VERSION = 2;
|
||||
} // namespace
|
||||
|
||||
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
||||
doc["cfgVersion"] = CONFIG_VERSION;
|
||||
doc["username"] = getUsername();
|
||||
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
||||
doc["serverUrl"] = getServerUrl();
|
||||
doc["matchMethod"] = static_cast<uint8_t>(getMatchMethod());
|
||||
doc["sendMetadata"] = getSendMetadata();
|
||||
doc["syncBehavior"] = static_cast<uint8_t>(getSyncBehavior());
|
||||
}
|
||||
|
||||
bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||
@@ -26,6 +37,19 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||
setCredentials(user, pass);
|
||||
setServerUrl(doc["serverUrl"] | "");
|
||||
|
||||
// The default server changed in config v2 (sync.koreader.rocks -> crosspoint-sync).
|
||||
// A pre-v2 config with credentials and no explicit URL was actively syncing
|
||||
// against the old default — pin that URL so the upgrade doesn't switch servers
|
||||
// out from under the user. Fresh setups get the new default.
|
||||
const uint8_t cfgVersion = doc["cfgVersion"] | (uint8_t)1;
|
||||
if (cfgVersion < CONFIG_VERSION) {
|
||||
if (getServerUrl().empty() && hasCredentials()) {
|
||||
LOG_DBG("KRS", "Pre-v2 config used the old default server; pinning %s", LEGACY_DEFAULT_SERVER_URL);
|
||||
setServerUrl(LEGACY_DEFAULT_SERVER_URL);
|
||||
}
|
||||
needsResave = true; // stamp cfgVersion so this migration runs once
|
||||
}
|
||||
|
||||
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
||||
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
||||
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
||||
@@ -35,6 +59,18 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
||||
}
|
||||
setSendMetadata(doc["sendMetadata"] | false);
|
||||
|
||||
const JsonVariantConst behaviorValue = doc["syncBehavior"];
|
||||
const bool missingBehavior = behaviorValue.isNull();
|
||||
uint8_t behavior = behaviorValue | static_cast<uint8_t>(KOReaderSyncBehavior::ASK_EVERY_TIME);
|
||||
if (behavior <= static_cast<uint8_t>(KOReaderSyncBehavior::SMART)) {
|
||||
setSyncBehavior(static_cast<KOReaderSyncBehavior>(behavior));
|
||||
needsResave = needsResave || missingBehavior;
|
||||
} else {
|
||||
LOG_DBG("KRS", "Invalid syncBehavior %u in JSON, resetting to ASK_EVERY_TIME", behavior);
|
||||
setSyncBehavior(KOReaderSyncBehavior::ASK_EVERY_TIME);
|
||||
needsResave = true;
|
||||
}
|
||||
|
||||
if (needsResave) {
|
||||
LOG_DBG("KRS", "Resaved KOReader credentials to update format");
|
||||
saveToFile();
|
||||
@@ -105,3 +141,11 @@ void KOReaderCredentialStore::setSendMetadata(bool enabled) {
|
||||
sendMetadata = enabled;
|
||||
LOG_DBG("KRS", "Set send metadata: %s", enabled ? "true" : "false");
|
||||
}
|
||||
|
||||
void KOReaderCredentialStore::setSyncBehavior(KOReaderSyncBehavior behavior) {
|
||||
if (static_cast<uint8_t>(behavior) > static_cast<uint8_t>(KOReaderSyncBehavior::SMART)) {
|
||||
behavior = KOReaderSyncBehavior::ASK_EVERY_TIME;
|
||||
}
|
||||
syncBehavior = behavior;
|
||||
LOG_DBG("KRS", "Set sync behavior: %s", behavior == KOReaderSyncBehavior::SMART ? "Smart" : "Ask");
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ enum class DocumentMatchMethod : uint8_t {
|
||||
BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical)
|
||||
};
|
||||
|
||||
// How manual "Sync Progress" resolves differences after fetching remote progress.
|
||||
enum class KOReaderSyncBehavior : uint8_t {
|
||||
ASK_EVERY_TIME = 0, // Preserve legacy behavior: always show Apply/Upload choices.
|
||||
SMART = 1, // Auto-resolve simple cases using furthest progress.
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton class for storing KOReader sync credentials on the SD card.
|
||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||
@@ -25,6 +31,7 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
|
||||
std::string serverUrl; // Custom sync server URL (empty = default)
|
||||
DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility
|
||||
bool sendMetadata = false; // Send document metadata with progress sync
|
||||
KOReaderSyncBehavior syncBehavior = KOReaderSyncBehavior::SMART;
|
||||
|
||||
// Private constructor for singleton
|
||||
KOReaderCredentialStore() = default;
|
||||
@@ -65,6 +72,10 @@ class KOReaderCredentialStore : public PersistableStore<KOReaderCredentialStore>
|
||||
// Send metadata setting
|
||||
void setSendMetadata(bool enabled);
|
||||
bool getSendMetadata() const { return sendMetadata; }
|
||||
|
||||
// Sync behavior
|
||||
void setSyncBehavior(KOReaderSyncBehavior behavior);
|
||||
KOReaderSyncBehavior getSyncBehavior() const { return syncBehavior; }
|
||||
};
|
||||
|
||||
// Helper macro to access credential store
|
||||
|
||||
@@ -22,7 +22,21 @@ constexpr char DEVICE_ID[] = "crosspoint-reader";
|
||||
// footprint is smaller than mbedTLS's old ~48KB peak, but keep a conservative
|
||||
// floor. Check both total free heap and largest contiguous block so fragmented
|
||||
// heap does not fall through into a failed TLS allocation path.
|
||||
constexpr uint32_t MIN_HEAP_FOR_TLS = 55000;
|
||||
// MEMFIX-PORT: TLS heap gate; portable
|
||||
// Field data (July 2026): launching sync from a reader session lands at
|
||||
// 51.9-58.2 KB free / 42-53 KB maxAlloc after WiFi comes up. wolfSSL handles
|
||||
// allocation failure by returning MEMORY_E (no abort under -fno-exceptions),
|
||||
// so an optimistic attempt degrades to the same clean "sync failed" as the
|
||||
// gate — the gate only needs to keep out states where a doomed handshake
|
||||
// would waste tens of seconds, not guarantee success.
|
||||
//
|
||||
// Free and largest-block have separate requirements: with SP ECC
|
||||
// (WOLFSSL_HAVE_SP_ECC) the handshake's crypto uses fixed 256-bit arrays, so
|
||||
// the largest single TLS allocation is the ~17 KB wolfSSL record buffer, not
|
||||
// a run of fast-math bignums. A handshake was measured succeeding inside a
|
||||
// 43 KB largest block; requiring 50 KB contiguous refused syncs that fit.
|
||||
constexpr uint32_t MIN_FREE_FOR_TLS = 50000;
|
||||
constexpr uint32_t MIN_BLOCK_FOR_TLS = 20000;
|
||||
|
||||
// Apply the shared KOSync auth headers after begin(). x-auth-* is the native
|
||||
// KOSync scheme; Basic auth is added for Calibre-Web-Automated compatibility.
|
||||
@@ -39,9 +53,9 @@ void applyAuthHeaders(freeink::SecureHttpClient& http) {
|
||||
bool insufficientHeap() {
|
||||
const uint32_t freeHeap = ESP.getFreeHeap();
|
||||
const uint32_t maxAllocHeap = ESP.getMaxAllocHeap();
|
||||
if (freeHeap < MIN_HEAP_FOR_TLS || maxAllocHeap < MIN_HEAP_FOR_TLS) {
|
||||
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free, %u max alloc (need %u)", freeHeap,
|
||||
maxAllocHeap, MIN_HEAP_FOR_TLS);
|
||||
if (freeHeap < MIN_FREE_FOR_TLS || maxAllocHeap < MIN_BLOCK_FOR_TLS) {
|
||||
LOG_ERR("KOSync", "Insufficient heap for TLS handshake: %u bytes free (need %u), %u max alloc (need %u)", freeHeap,
|
||||
MIN_FREE_FOR_TLS, maxAllocHeap, MIN_BLOCK_FOR_TLS);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -78,6 +92,43 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
||||
return SERVER_ERROR;
|
||||
}
|
||||
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::createUser() {
|
||||
lastHttpCode = 0;
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
LOG_DBG("KOSync", "No credentials configured");
|
||||
return NO_CREDENTIALS;
|
||||
}
|
||||
|
||||
const std::string url = KOREADER_STORE.getBaseUrl() + "/users/create";
|
||||
LOG_DBG("KOSync", "Creating account: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
|
||||
if (insufficientHeap()) return LOW_MEMORY;
|
||||
|
||||
JsonDocument doc;
|
||||
doc["username"] = KOREADER_STORE.getUsername();
|
||||
doc["password"] = KOREADER_STORE.getMd5Password();
|
||||
std::string body;
|
||||
serializeJson(doc, body);
|
||||
|
||||
freeink::SecureHttpClient http;
|
||||
http.setInsecure();
|
||||
if (!http.begin(url)) {
|
||||
LOG_ERR("KOSync", "Bad URL: %s", url.c_str());
|
||||
return NETWORK_ERROR;
|
||||
}
|
||||
http.addHeader("Accept", "application/vnd.koreader.v1+json");
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
const int httpCode = http.sendRequest("POST", body);
|
||||
http.end();
|
||||
lastHttpCode = httpCode;
|
||||
|
||||
LOG_DBG("KOSync", "Create user response: %d", httpCode);
|
||||
|
||||
if (httpCode <= 0) return NETWORK_ERROR;
|
||||
if (httpCode == 200 || httpCode == 201) return OK;
|
||||
if (httpCode == 402) return USER_EXISTS;
|
||||
return SERVER_ERROR;
|
||||
}
|
||||
|
||||
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
|
||||
KOReaderProgress& outProgress) {
|
||||
lastHttpCode = 0;
|
||||
@@ -124,6 +175,24 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
||||
outProgress.deviceId = doc["device_id"].as<std::string>();
|
||||
outProgress.timestamp = doc["timestamp"].as<int64_t>();
|
||||
|
||||
// Extended crosspoint-sync field; absent on plain kosync servers.
|
||||
outProgress.position.reset();
|
||||
const JsonObjectConst pos = doc["position"].as<JsonObjectConst>();
|
||||
if (!pos.isNull()) {
|
||||
KOReaderRichPosition rich;
|
||||
rich.pctQ = pos["pctQ"].as<uint32_t>();
|
||||
rich.spineIndex = pos["spine"].as<uint16_t>();
|
||||
rich.pageNumber = pos["page"].as<uint16_t>();
|
||||
const uint16_t pages = pos["pages"].as<uint16_t>();
|
||||
rich.totalPages = pages > 0 ? pages : 1;
|
||||
const uint16_t para = pos["para"].as<uint16_t>();
|
||||
if (para > 0) rich.paragraphIndex = para;
|
||||
rich.xpath = pos["xpath"].as<const char*>() ? pos["xpath"].as<const char*>() : "";
|
||||
LOG_DBG("KOSync", "Got rich position: spine=%u page=%u/%u para=%u", rich.spineIndex, rich.pageNumber,
|
||||
rich.totalPages, para);
|
||||
outProgress.position = std::move(rich);
|
||||
}
|
||||
|
||||
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
|
||||
return OK;
|
||||
}
|
||||
@@ -158,6 +227,18 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
|
||||
doc["percentage"] = progress.percentage;
|
||||
doc["device"] = DEVICE_NAME;
|
||||
doc["device_id"] = DEVICE_ID;
|
||||
if (progress.position.has_value()) {
|
||||
// Extended crosspoint-sync field; kosync servers ignore unknown keys.
|
||||
const auto& p = *progress.position;
|
||||
auto pos = doc["position"].to<JsonObject>();
|
||||
pos["pctQ"] = p.pctQ;
|
||||
pos["spine"] = p.spineIndex;
|
||||
pos["page"] = p.pageNumber;
|
||||
pos["pages"] = p.totalPages;
|
||||
if (p.paragraphIndex.has_value()) pos["para"] = *p.paragraphIndex;
|
||||
// Server rejects the whole position object if xpath exceeds 120 bytes.
|
||||
if (!p.xpath.empty() && p.xpath.size() <= 120) pos["xpath"] = p.xpath;
|
||||
}
|
||||
|
||||
std::string body;
|
||||
serializeJson(doc, body);
|
||||
|
||||
@@ -14,17 +14,33 @@ struct KOReaderMetadata {
|
||||
std::string authors; // Author(s) from EPUB metadata
|
||||
};
|
||||
|
||||
/**
|
||||
* Rich CrossPoint position sent alongside progress uploads. Maps 1:1 onto the
|
||||
* crosspoint-sync extended `position` object (see crosspoint-sync docs/API.md).
|
||||
* The official KOSync server ignores unknown fields; crosspoint-sync stores it
|
||||
* so CrossPoint<->CrossPoint sync is lossless instead of xpath-approximated.
|
||||
*/
|
||||
struct KOReaderRichPosition {
|
||||
uint32_t pctQ = 0; // Percentage quantized 0..1,000,000 (authoritative)
|
||||
uint16_t spineIndex = 0; // Spine (chapter) index
|
||||
uint16_t pageNumber = 0; // Page within spine (layout-dependent hint)
|
||||
uint16_t totalPages = 1; // Spine page count (layout-dependent hint)
|
||||
std::optional<uint16_t> paragraphIndex; // Synthetic 1-based paragraph index
|
||||
std::string xpath; // KOReader-style xpath (server cap: 120 bytes)
|
||||
};
|
||||
|
||||
/**
|
||||
* Progress data from KOReader sync server.
|
||||
*/
|
||||
struct KOReaderProgress {
|
||||
std::string document; // Document hash
|
||||
std::string progress; // XPath-like progress string
|
||||
float percentage; // Progress percentage (0.0 to 1.0)
|
||||
std::string device; // Device name
|
||||
std::string deviceId; // Device ID
|
||||
int64_t timestamp; // Unix timestamp of last update
|
||||
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
||||
std::string document; // Document hash
|
||||
std::string progress; // XPath-like progress string
|
||||
float percentage; // Progress percentage (0.0 to 1.0)
|
||||
std::string device; // Device name
|
||||
std::string deviceId; // Device ID
|
||||
int64_t timestamp; // Unix timestamp of last update
|
||||
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
||||
std::optional<KOReaderRichPosition> position; // Optional rich position (crosspoint-sync servers only)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,7 +59,17 @@ struct KOReaderProgress {
|
||||
*/
|
||||
class KOReaderSyncClient {
|
||||
public:
|
||||
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND, LOW_MEMORY };
|
||||
enum Error {
|
||||
OK = 0,
|
||||
NO_CREDENTIALS,
|
||||
NETWORK_ERROR,
|
||||
AUTH_FAILED,
|
||||
SERVER_ERROR,
|
||||
JSON_ERROR,
|
||||
NOT_FOUND,
|
||||
LOW_MEMORY,
|
||||
USER_EXISTS
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticate with the sync server (validate credentials).
|
||||
@@ -51,6 +77,14 @@ class KOReaderSyncClient {
|
||||
*/
|
||||
static Error authenticate();
|
||||
|
||||
/**
|
||||
* Register a new account on the sync server using the stored credentials
|
||||
* (POST /users/create with the MD5 auth key — the server never sees the
|
||||
* plain password).
|
||||
* @return OK on success, USER_EXISTS if the username is taken
|
||||
*/
|
||||
static Error createUser();
|
||||
|
||||
/**
|
||||
* Get reading progress for a document.
|
||||
* @param documentHash The document hash (from KOReaderDocumentId)
|
||||
|
||||
@@ -724,6 +724,59 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<CrossPointPosition> ProgressMapper::fromRichPosition(const std::shared_ptr<Epub>& epub,
|
||||
const KOReaderRichPosition& rich,
|
||||
GfxRenderer& renderer) {
|
||||
const int spineCount = epub->getSpineItemsCount();
|
||||
if (static_cast<int>(rich.spineIndex) >= spineCount) {
|
||||
LOG_DBG("PM", "Rich position spine %u out of range (%d spine items)", rich.spineIndex, spineCount);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
CrossPointPosition result{};
|
||||
result.spineIndex = rich.spineIndex;
|
||||
|
||||
Section tempSection(epub, result.spineIndex, renderer);
|
||||
const auto cachedCount = tempSection.getCachedPageCount();
|
||||
if (!cachedCount || *cachedCount <= 0) {
|
||||
// No local layout for the target spine yet; the percentage/xpath mapping
|
||||
// handles density estimation better than a blind copy of remote pages.
|
||||
LOG_DBG("PM", "Rich position spine %u has no cached page count", rich.spineIndex);
|
||||
return std::nullopt;
|
||||
}
|
||||
result.totalPages = *cachedCount;
|
||||
|
||||
const int remotePages = rich.totalPages > 0 ? rich.totalPages : 1;
|
||||
if (result.totalPages == remotePages) {
|
||||
// Identical layout (same render settings) — the page transfers losslessly.
|
||||
result.pageNumber = std::min<int>(rich.pageNumber, result.totalPages - 1);
|
||||
LOG_DBG("PM", "Rich position exact: spine=%d page=%d/%d", result.spineIndex, result.pageNumber, result.totalPages);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Layout differs; the paragraph LUT is the most accurate anchor we have.
|
||||
if (rich.paragraphIndex.has_value()) {
|
||||
const auto lutPage = tempSection.getPageForParagraphIndex(*rich.paragraphIndex);
|
||||
if (lutPage.has_value()) {
|
||||
result.paragraphIndex = *rich.paragraphIndex;
|
||||
result.hasParagraphIndex = true;
|
||||
result.pageNumber = std::min<int>(*lutPage, result.totalPages - 1);
|
||||
LOG_DBG("PM", "Rich position para %u -> spine=%d page=%d/%d", *rich.paragraphIndex, result.spineIndex,
|
||||
result.pageNumber, result.totalPages);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the intra-spine page fraction.
|
||||
const float intra =
|
||||
(remotePages > 1) ? static_cast<float>(rich.pageNumber) / static_cast<float>(remotePages - 1) : 0.0f;
|
||||
result.pageNumber = std::max(
|
||||
0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1));
|
||||
LOG_DBG("PM", "Rich position scaled: spine=%d remote %u/%d -> page=%d/%d", result.spineIndex, rich.pageNumber,
|
||||
remotePages, result.pageNumber, result.totalPages);
|
||||
return result;
|
||||
}
|
||||
|
||||
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
|
||||
GfxRenderer& renderer, int currentSpineIndex,
|
||||
int totalPagesInCurrentSpine, int fallbackTotalPages) {
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "KOReaderSyncClient.h"
|
||||
|
||||
/**
|
||||
* CrossPoint position representation.
|
||||
*/
|
||||
@@ -65,6 +68,20 @@ class ProgressMapper {
|
||||
GfxRenderer& renderer, int currentSpineIndex = -1,
|
||||
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
|
||||
|
||||
/**
|
||||
* Convert a rich CrossPoint position (downloaded from a crosspoint-sync
|
||||
* server) directly to a CrossPoint position, without XPath approximation.
|
||||
* When the local layout matches the uploader's (same spine page count) the
|
||||
* page transfers losslessly; otherwise the paragraph LUT or the intra-spine
|
||||
* page fraction is used.
|
||||
*
|
||||
* @return The position, or std::nullopt when the rich position cannot be
|
||||
* applied (spine out of range, no section cache) and the caller
|
||||
* should fall back to toCrossPoint().
|
||||
*/
|
||||
static std::optional<CrossPointPosition> fromRichPosition(const std::shared_ptr<Epub>& epub,
|
||||
const KOReaderRichPosition& rich, GfxRenderer& renderer);
|
||||
|
||||
private:
|
||||
/**
|
||||
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#include "Logging.h"
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <esp_rom_sys.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#define MAX_ENTRY_LEN 256
|
||||
@@ -59,9 +62,16 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) {
|
||||
}
|
||||
}
|
||||
va_end(args);
|
||||
#if FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF
|
||||
// IDF/ROM console path for boards monitored over USB-Serial-JTAG, where the
|
||||
// HWCDC `operator bool` reads false under `pio device monitor` and logs would
|
||||
// otherwise be silently dropped (e.g. Sticky).
|
||||
esp_rom_printf("%s", buf);
|
||||
#else
|
||||
if (logSerial) {
|
||||
logSerial.print(buf);
|
||||
}
|
||||
#endif
|
||||
addToLogRingBuffer(buf);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <HardwareSerial.h>
|
||||
#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
||||
#include <HWCDC.h>
|
||||
#endif
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -27,7 +31,13 @@ won't trigger deprecation warnings.
|
||||
#define LOG_LEVEL 0
|
||||
#endif
|
||||
|
||||
#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT
|
||||
static HWCDC& logSerial = Serial;
|
||||
#define LOG_SERIAL_HAS_TX_TIMEOUT 1
|
||||
#else
|
||||
static HardwareSerial& logSerial = Serial;
|
||||
#define LOG_SERIAL_HAS_TX_TIMEOUT 0
|
||||
#endif
|
||||
|
||||
void logPrintf(const char* level, const char* origin, const char* format, ...);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include <HalStorage.h>
|
||||
#include <InflateStream.h>
|
||||
#include <Logging.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -72,6 +74,12 @@ enum PngFilter : uint8_t {
|
||||
PNG_FILTER_PAETH = 4,
|
||||
};
|
||||
|
||||
void yieldDuringDecode(uint8_t& rowsSinceYield) {
|
||||
if (++rowsSinceYield < 8) return;
|
||||
rowsSinceYield = 0;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
|
||||
// Read a big-endian 32-bit value from file
|
||||
bool readBE32(HalFile& file, uint32_t& value) {
|
||||
uint8_t buf[4];
|
||||
@@ -659,6 +667,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
uint8_t rowsSinceYield = 0;
|
||||
|
||||
// Process each scanline
|
||||
for (uint32_t y = 0; y < height; y++) {
|
||||
@@ -710,6 +719,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
|
||||
fsDitherer->nextRow();
|
||||
}
|
||||
bmpOut.write(rowBuffer, bytesPerRow);
|
||||
yieldDuringDecode(rowsSinceYield);
|
||||
} else {
|
||||
// Area-averaging scaling (same as JpegToBmpConverter)
|
||||
for (int outX = 0; outX < outWidth; outX++) {
|
||||
@@ -778,6 +788,7 @@ bool PngToBmpConverter::pngFileToBmpStreamInternal(HalFile& pngFile, Print& bmpO
|
||||
|
||||
bmpOut.write(rowBuffer, bytesPerRow);
|
||||
currentOutY++;
|
||||
yieldDuringDecode(rowsSinceYield);
|
||||
|
||||
nextOutY_srcStart = static_cast<uint32_t>(currentOutY + 1) * scaleY_fp;
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
#include <Bitmap.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
namespace {
|
||||
void yieldDuringThumbnail(uint8_t& rowsSinceYield) {
|
||||
if (++rowsSinceYield < 8) return;
|
||||
rowsSinceYield = 0;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool Xtc::load() {
|
||||
LOG_DBG("XTC", "Loading XTC: %s", filepath.c_str());
|
||||
@@ -380,6 +390,7 @@ bool Xtc::generateThumbBmp(int height) const {
|
||||
const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr;
|
||||
const size_t colBytes = (bitDepth == 2) ? ((pageInfo.height + 7) / 8) : 0;
|
||||
const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0;
|
||||
uint8_t rowsSinceYield = 0;
|
||||
|
||||
for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) {
|
||||
memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1)
|
||||
@@ -471,6 +482,7 @@ bool Xtc::generateThumbBmp(int height) const {
|
||||
|
||||
// Write row (already padded to 4-byte boundary by rowSize)
|
||||
thumbBmp.write(rowBuffer, rowSize);
|
||||
yieldDuringThumbnail(rowsSinceYield);
|
||||
}
|
||||
|
||||
free(rowBuffer);
|
||||
|
||||
@@ -437,7 +437,7 @@ uint8_t* ZipFile::readFileToMemory(const char* filename, size_t* size, const boo
|
||||
return data;
|
||||
}
|
||||
|
||||
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize) {
|
||||
bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t chunkSize, const bool allowEarlyStop) {
|
||||
const ScopedOpenClose zip{*this};
|
||||
if (!zip) return false;
|
||||
|
||||
@@ -469,8 +469,9 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
}
|
||||
|
||||
if (out.write(buffer, dataRead) != dataRead) {
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
free(buffer);
|
||||
if (allowEarlyStop) return true; // sink has what it needs
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
return false;
|
||||
}
|
||||
remaining -= dataRead;
|
||||
@@ -525,7 +526,11 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
||||
|
||||
if (produced > 0) {
|
||||
if (out.write(outputBuffer, produced) != produced) {
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
if (allowEarlyStop) {
|
||||
success = true; // sink has what it needs
|
||||
} else {
|
||||
LOG_ERR("ZIP", "Failed to write all output bytes to stream");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -69,7 +69,10 @@ class ZipFile {
|
||||
// Due to the memory required to run each of these, it is recommended to not preopen the zip file for multiple
|
||||
// These functions will open and close the zip as needed
|
||||
uint8_t* readFileToMemory(const char* filename, size_t* size = nullptr, bool trailingNullByte = false);
|
||||
bool readFileToStream(const char* filename, Print& out, size_t chunkSize);
|
||||
// allowEarlyStop: a short write from `out` is treated as the sink asking to
|
||||
// stop (returns true) instead of a write failure — used by header probes
|
||||
// that only need the first bytes of an entry.
|
||||
bool readFileToStream(const char* filename, Print& out, size_t chunkSize, bool allowEarlyStop = false);
|
||||
|
||||
template <typename F>
|
||||
bool enumerateFilePaths(F&& callback) {
|
||||
@@ -80,6 +83,14 @@ class ZipFile {
|
||||
return true;
|
||||
}
|
||||
|
||||
return enumerateFileEntries([&callback](std::string_view path, uint32_t, uint32_t) { callback(path); });
|
||||
}
|
||||
|
||||
// Callback receives (path, crc32, compressedSize) for each central-directory
|
||||
// entry. Always scans the central directory: the slim-stat cache does not
|
||||
// hold CRCs.
|
||||
template <typename F>
|
||||
bool enumerateFileEntries(F&& callback) {
|
||||
const bool wasOpen = isOpen();
|
||||
if (!wasOpen && !open()) {
|
||||
return false;
|
||||
@@ -103,7 +114,11 @@ class ZipFile {
|
||||
break;
|
||||
}
|
||||
|
||||
file.seekCur(24);
|
||||
file.seekCur(12);
|
||||
uint32_t crc32, compressedSize;
|
||||
file.read(&crc32, 4);
|
||||
file.read(&compressedSize, 4);
|
||||
file.seekCur(4);
|
||||
uint16_t nameLen, m, k;
|
||||
file.read(&nameLen, 2);
|
||||
file.read(&m, 2);
|
||||
@@ -113,7 +128,7 @@ class ZipFile {
|
||||
if (nameLen < sizeof(itemName)) {
|
||||
file.read(itemName, nameLen);
|
||||
itemName[nameLen] = '\0';
|
||||
callback(std::string_view{itemName, nameLen});
|
||||
callback(std::string_view{itemName, nameLen}, crc32, compressedSize);
|
||||
} else {
|
||||
file.seekCur(nameLen);
|
||||
}
|
||||
|
||||
+21
-91
@@ -5,46 +5,11 @@
|
||||
#include <esp_sntp.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
HalClock halClock; // Singleton instance
|
||||
|
||||
// DS3231 register layout (BCD encoded):
|
||||
// 0x00: Seconds (bits 6-4 = tens, bits 3-0 = ones)
|
||||
// 0x01: Minutes (bits 6-4 = tens, bits 3-0 = ones)
|
||||
// 0x02: Hours (bit 6 = 12/24 mode, bits 5-4 = tens, bits 3-0 = ones)
|
||||
|
||||
static uint8_t bcdToDec(uint8_t bcd) { return ((bcd >> 4) * 10) + (bcd & 0x0F); }
|
||||
static uint8_t decToBcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); }
|
||||
|
||||
void HalClock::begin() {
|
||||
if (!gpio.deviceIsX3()) {
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// I2C is already initialised by HalPowerManager::begin() for X3.
|
||||
// Probe the DS3231 by reading the seconds register.
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
LOG_INF("CLK", "DS3231 RTC not found");
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)1);
|
||||
if (Wire.available() < 1) {
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
Wire.read(); // discard — just testing connectivity
|
||||
|
||||
_available = true;
|
||||
LOG_INF("CLK", "DS3231 RTC found");
|
||||
|
||||
// Prime the cache with an initial read
|
||||
uint8_t h, m;
|
||||
getTime(h, m);
|
||||
_available = _sdkRtc.begin();
|
||||
LOG_INF("CLK", _available ? "SDK RTC found" : "RTC not found");
|
||||
}
|
||||
|
||||
bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
|
||||
@@ -57,44 +22,18 @@ bool HalClock::getTime(uint8_t& hour, uint8_t& minute) const {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read 3 bytes starting at register 0x00: seconds, minutes, hours
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
Rtc::DateTime dt;
|
||||
if (!_sdkRtc.now(dt)) {
|
||||
if (!_hasCachedTime) return false;
|
||||
_lastPollMs = now;
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_DS3231, (uint8_t)3);
|
||||
if (Wire.available() < 3) {
|
||||
if (!_hasCachedTime) return false;
|
||||
_lastPollMs = now;
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
}
|
||||
|
||||
Wire.read(); // seconds — not needed
|
||||
const uint8_t rawMin = Wire.read();
|
||||
const uint8_t rawHour = Wire.read();
|
||||
|
||||
_cachedMinute = bcdToDec(rawMin & 0x7F);
|
||||
// Handle 12/24h mode: bit 6 high = 12h mode
|
||||
if (rawHour & 0x40) {
|
||||
// 12h mode: bit 5 = PM, bits 4-0 = hours (1-12)
|
||||
uint8_t h12 = bcdToDec(rawHour & 0x1F);
|
||||
bool pm = rawHour & 0x20;
|
||||
if (h12 == 12) h12 = 0;
|
||||
_cachedHour = pm ? (h12 + 12) : h12;
|
||||
} else {
|
||||
// 24h mode: bits 5-0 = hours (0-23)
|
||||
_cachedHour = bcdToDec(rawHour & 0x3F);
|
||||
}
|
||||
_cachedHour = dt.hour;
|
||||
_cachedMinute = dt.minute;
|
||||
_lastPollMs = now;
|
||||
_hasCachedTime = true;
|
||||
|
||||
hour = _cachedHour;
|
||||
minute = _cachedMinute;
|
||||
return true;
|
||||
@@ -127,28 +66,6 @@ bool HalClock::formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHou
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalClock::writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second) {
|
||||
assert(hour < 24);
|
||||
assert(minute < 60);
|
||||
assert(second < 60);
|
||||
Wire.beginTransmission(I2C_ADDR_DS3231);
|
||||
Wire.write(DS3231_SEC_REG); // Start at register 0x00
|
||||
Wire.write(decToBcd(second)); // 0x00: Seconds
|
||||
Wire.write(decToBcd(minute)); // 0x01: Minutes
|
||||
Wire.write(decToBcd(hour)); // 0x02: Hours (24h mode, bit 6 = 0)
|
||||
if (Wire.endTransmission() != 0) {
|
||||
LOG_ERR("CLK", "Failed to write time to DS3231");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Invalidate cache so next read fetches fresh data
|
||||
_lastPollMs = 0;
|
||||
_cachedHour = hour;
|
||||
_cachedMinute = minute;
|
||||
_hasCachedTime = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalClock::syncFromNTP() {
|
||||
if (!_available) return false;
|
||||
|
||||
@@ -168,8 +85,21 @@ bool HalClock::syncFromNTP() {
|
||||
struct tm timeinfo;
|
||||
gmtime_r(&now, &timeinfo);
|
||||
|
||||
if (writeTimeToRTC(timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec)) {
|
||||
LOG_INF("CLK", "RTC set to %02d:%02d:%02d UTC", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
|
||||
Rtc::DateTime dt;
|
||||
dt.year = static_cast<uint16_t>(timeinfo.tm_year + 1900);
|
||||
dt.month = static_cast<uint8_t>(timeinfo.tm_mon + 1);
|
||||
dt.day = static_cast<uint8_t>(timeinfo.tm_mday);
|
||||
dt.hour = static_cast<uint8_t>(timeinfo.tm_hour);
|
||||
dt.minute = static_cast<uint8_t>(timeinfo.tm_min);
|
||||
dt.second = static_cast<uint8_t>(timeinfo.tm_sec);
|
||||
dt.weekday = static_cast<uint8_t>(timeinfo.tm_wday);
|
||||
if (_sdkRtc.set(dt)) {
|
||||
_lastPollMs = 0;
|
||||
_cachedHour = dt.hour;
|
||||
_cachedMinute = dt.minute;
|
||||
_hasCachedTime = true;
|
||||
LOG_INF("CLK", "RTC set to %04u-%02u-%02u %02u:%02u:%02u UTC", dt.year, dt.month, dt.day, dt.hour, dt.minute,
|
||||
dt.second);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
+5
-9
@@ -1,15 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
#include <Rtc.h>
|
||||
|
||||
class HalClock;
|
||||
extern HalClock halClock; // Singleton
|
||||
|
||||
class HalClock {
|
||||
bool _available = false;
|
||||
mutable Rtc _sdkRtc;
|
||||
mutable uint8_t _cachedHour = 0;
|
||||
mutable uint8_t _cachedMinute = 0;
|
||||
mutable bool _hasCachedTime = false;
|
||||
@@ -18,10 +17,10 @@ class HalClock {
|
||||
static constexpr unsigned long CLOCK_POLL_MS = 10000; // 10 seconds
|
||||
|
||||
public:
|
||||
// Call after gpio.begin() and powerManager.begin() (I2C already initialised for X3)
|
||||
// Call after BoardConfig has selected the active device.
|
||||
void begin();
|
||||
|
||||
// True if the DS3231 RTC is present on this device
|
||||
// True if an RTC is present on this device
|
||||
bool isAvailable() const { return _available; }
|
||||
|
||||
// Get current hour (0-23) and minute (0-59).
|
||||
@@ -35,14 +34,11 @@ class HalClock {
|
||||
// Returns false if RTC is not available.
|
||||
bool formatTime(char* buf, size_t bufSize, uint8_t utcOffsetQuarterHoursBiased = 48, bool use12Hour = false) const;
|
||||
|
||||
// Sync the DS3231 RTC from an NTP server. Requires WiFi to be connected.
|
||||
// Sync the RTC from an NTP server. Requires WiFi to be connected.
|
||||
// Blocks for up to ~5s while waiting for SNTP response.
|
||||
// Returns true if the RTC was successfully updated.
|
||||
//
|
||||
// Debouncing (skip if already synced once) is enforced by the caller, not here,
|
||||
// so the HAL stays free of any app-layer settings dependency.
|
||||
bool syncFromNTP();
|
||||
|
||||
private:
|
||||
bool writeTimeToRTC(uint8_t hour, uint8_t minute, uint8_t second);
|
||||
};
|
||||
|
||||
@@ -65,6 +65,18 @@ void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen)
|
||||
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
|
||||
}
|
||||
|
||||
void HalDisplay::displayBufferAsync(HalDisplay::RefreshMode mode) {
|
||||
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
|
||||
einkDisplay.requestResync(1);
|
||||
}
|
||||
|
||||
einkDisplay.displayBufferAsyncNoShadow(convertRefreshMode(mode));
|
||||
}
|
||||
|
||||
void HalDisplay::waitRefreshComplete() { einkDisplay.waitRefreshComplete(); }
|
||||
|
||||
bool HalDisplay::supportsAsyncRefresh() const { return einkDisplay.supportsAsyncRefresh(); }
|
||||
|
||||
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||
if (gpio.deviceIsX3() && mode == RefreshMode::HALF_REFRESH) {
|
||||
einkDisplay.requestResync(1);
|
||||
|
||||
@@ -39,6 +39,17 @@ class HalDisplay {
|
||||
bool fromProgmem = false) const;
|
||||
|
||||
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
// Non-blocking refresh (shadow-free): starts the panel waveform and returns
|
||||
// while the panel refreshes on its own. The framebuffer must stay untouched
|
||||
// until waitRefreshComplete(), and the caller must rebuild the differential
|
||||
// baseline before the next differential update (the tiled grayscale cleanup
|
||||
// does). Panels without deferral fall back to a blocking refresh.
|
||||
void displayBufferAsync(RefreshMode mode = RefreshMode::FAST_REFRESH);
|
||||
// Block until a pending deferred refresh completes (no-op when none is).
|
||||
void waitRefreshComplete();
|
||||
// True when displayBufferAsync() genuinely overlaps (panel driver defers);
|
||||
// false where it falls back to a blocking refresh.
|
||||
bool supportsAsyncRefresh() const;
|
||||
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||
|
||||
// Power management
|
||||
|
||||
+53
-5
@@ -1,5 +1,6 @@
|
||||
#include <HalGPIO.h>
|
||||
#include <Logging.h>
|
||||
#include <PowerManager.h>
|
||||
#include <Preferences.h>
|
||||
#include <SPI.h>
|
||||
#include <Wire.h>
|
||||
@@ -191,15 +192,20 @@ HalGPIO::DeviceType detectDeviceTypeWithFingerprint() {
|
||||
} // namespace
|
||||
|
||||
void HalGPIO::begin() {
|
||||
inputMgr.begin();
|
||||
#if FREEINK_MCU_C3
|
||||
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
|
||||
|
||||
_deviceType = detectDeviceTypeWithFingerprint();
|
||||
BoardConfig::selectDevice(deviceIsX3() ? BoardConfig::Board::XteinkX3 : BoardConfig::Board::XteinkX4);
|
||||
|
||||
if (deviceIsX4()) {
|
||||
pinMode(BAT_GPIO0, INPUT);
|
||||
pinMode(UART0_RXD, INPUT);
|
||||
}
|
||||
#else
|
||||
_deviceType = DeviceType::X4;
|
||||
#endif
|
||||
inputMgr.begin();
|
||||
}
|
||||
|
||||
void HalGPIO::update() {
|
||||
@@ -225,7 +231,44 @@ unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||
|
||||
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
|
||||
|
||||
bool HalGPIO::hasTouch() const { return inputMgr.hasTouch(); }
|
||||
|
||||
bool HalGPIO::wasTouchTap(float& nx, float& ny) const { return inputMgr.wasTouchTap(nx, ny); }
|
||||
|
||||
bool HalGPIO::wasTouchDown(float& nx, float& ny) const { return inputMgr.wasTouchPressedAt(nx, ny); }
|
||||
|
||||
bool HalGPIO::isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const {
|
||||
return inputMgr.isTouchTapCandidate(nx, ny, heldMs);
|
||||
}
|
||||
|
||||
bool HalGPIO::isTouchHeldAt(float& nx, float& ny) const { return inputMgr.isTouchHeldAt(nx, ny); }
|
||||
|
||||
unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); }
|
||||
|
||||
bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const {
|
||||
return inputMgr.wasSwipe(nxStart, nyStart, nxEnd, nyEnd);
|
||||
}
|
||||
|
||||
bool HalGPIO::wasTouchActivity() const { return inputMgr.wasTouchActivity(); }
|
||||
|
||||
void HalGPIO::setSharedConfirmPowerShortPressEmitsPower(const bool enabled) {
|
||||
InputManager::setSharedConfirmPowerShortPressEmitsPower(enabled);
|
||||
}
|
||||
|
||||
bool HalGPIO::isXteinkDevice() const {
|
||||
return BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX3 ||
|
||||
BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX4;
|
||||
}
|
||||
|
||||
bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPressAllowed) {
|
||||
// Boards without a power button (or M5Paper's latch circuit) cannot verify a
|
||||
// hold; treat the wake as valid.
|
||||
if (BoardConfig::ACTIVE.input.power < 0) {
|
||||
return true;
|
||||
}
|
||||
#if defined(FREEINK_DEVICE_M5PAPER) && FREEINK_DEVICE_M5PAPER
|
||||
return true;
|
||||
#endif
|
||||
if (shortPressAllowed) {
|
||||
// Fast path - no duration check needed
|
||||
return true;
|
||||
@@ -271,8 +314,10 @@ bool HalGPIO::isUsbConnected() const {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// U0RXD/GPIO20 reads HIGH when USB is connected
|
||||
return digitalRead(UART0_RXD) == HIGH;
|
||||
if (BoardConfig::ACTIVE.usbDetect < 0) {
|
||||
return false;
|
||||
}
|
||||
return digitalRead(BoardConfig::ACTIVE.usbDetect) == HIGH;
|
||||
}
|
||||
|
||||
HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
|
||||
@@ -281,8 +326,11 @@ HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
|
||||
|
||||
const bool usbConnected = isUsbConnected();
|
||||
|
||||
if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
|
||||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
|
||||
if (resetReason == ESP_RST_DEEPSLEEP &&
|
||||
(wakeupCause == ESP_SLEEP_WAKEUP_GPIO || wakeupCause == ESP_SLEEP_WAKEUP_EXT1)) {
|
||||
return WakeupReason::PowerButton;
|
||||
}
|
||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) {
|
||||
return WakeupReason::PowerButton;
|
||||
}
|
||||
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
|
||||
|
||||
@@ -58,6 +58,7 @@ class HalGPIO {
|
||||
// Inline device type helpers for cleaner downstream checks
|
||||
inline bool deviceIsX3() const { return _deviceType == DeviceType::X3; }
|
||||
inline bool deviceIsX4() const { return _deviceType == DeviceType::X4; }
|
||||
bool isXteinkDevice() const;
|
||||
|
||||
// Start button GPIO and setup SPI for screen and SD card
|
||||
void begin();
|
||||
@@ -71,6 +72,15 @@ class HalGPIO {
|
||||
bool wasAnyReleased() const;
|
||||
unsigned long getHeldTime() const;
|
||||
unsigned long getPowerButtonHeldTime() const;
|
||||
bool hasTouch() const;
|
||||
bool wasTouchTap(float& nx, float& ny) const;
|
||||
bool wasTouchDown(float& nx, float& ny) const;
|
||||
bool isTouchTapCandidate(float& nx, float& ny, unsigned long& heldMs) const;
|
||||
bool isTouchHeldAt(float& nx, float& ny) const;
|
||||
unsigned long lastTouchHeldMs() const;
|
||||
bool wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const;
|
||||
bool wasTouchActivity() const;
|
||||
void setSharedConfirmPowerShortPressEmitsPower(bool enabled);
|
||||
|
||||
// Verify power button was held long enough after wakeup.
|
||||
// Returns true if verification succeeded, false if device should return to sleep.
|
||||
|
||||
+35
-50
@@ -1,8 +1,11 @@
|
||||
#include "HalPowerManager.h"
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <Logging.h>
|
||||
#include <PowerManager.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_sleep.h>
|
||||
#include <soc/soc_caps.h>
|
||||
|
||||
#include <cassert>
|
||||
|
||||
@@ -11,14 +14,8 @@
|
||||
HalPowerManager powerManager; // Singleton instance
|
||||
|
||||
void HalPowerManager::begin() {
|
||||
if (gpio.deviceIsX3()) {
|
||||
// X3 uses an I2C fuel gauge for battery monitoring.
|
||||
// I2C init must come AFTER gpio.begin() so early hardware detection/probes are finished.
|
||||
Wire.begin(X3_I2C_SDA, X3_I2C_SCL, X3_I2C_FREQ);
|
||||
Wire.setTimeOut(4);
|
||||
_batteryUseI2C = true;
|
||||
} else {
|
||||
pinMode(BAT_GPIO0, INPUT);
|
||||
if (BoardConfig::ACTIVE.batteryAdc >= 0) {
|
||||
pinMode(BoardConfig::ACTIVE.batteryAdc, INPUT);
|
||||
}
|
||||
normalFreq = getCpuFrequencyMhz();
|
||||
modeMutex = xSemaphoreCreateMutex();
|
||||
@@ -61,12 +58,6 @@ void HalPowerManager::setPowerSaving(bool enabled) {
|
||||
}
|
||||
|
||||
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
||||
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||
delay(50);
|
||||
gpio.update();
|
||||
}
|
||||
|
||||
#ifdef ENABLE_SERIAL_LOG
|
||||
// Tear down HWCDC so the host sees a clean disconnect and the peripheral
|
||||
// doesn't hold power domains that interfere with USB-powered GPIO wake.
|
||||
@@ -75,53 +66,47 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||
logSerial.end();
|
||||
#endif
|
||||
|
||||
// Pre-sleep routines from the original firmware
|
||||
// GPIO13 is connected to battery latch MOSFET, we need to make sure it's low during sleep
|
||||
// Note that this means the MCU will be completely powered off during sleep, including RTC
|
||||
constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13;
|
||||
gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(GPIO_SPIWP, 0);
|
||||
esp_sleep_config_gpio_isolate();
|
||||
gpio_deep_sleep_hold_en();
|
||||
gpio_hold_en(GPIO_SPIWP);
|
||||
pinMode(InputManager::POWER_BUTTON_PIN, INPUT_PULLUP);
|
||||
// Arm the wakeup trigger *after* the button is released
|
||||
// Note: this is only useful for waking up on USB power. On battery, the MCU will be completely powered off, so the
|
||||
// power button is hard-wired to briefly provide power to the MCU, waking it up regardless of the wakeup source
|
||||
// configuration
|
||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||
// Enter Deep Sleep
|
||||
esp_deep_sleep_start();
|
||||
#if !SOC_PM_SUPPORT_EXT1_WAKEUP
|
||||
if (gpio.isXteinkDevice() && !gpio.deviceIsX3()) {
|
||||
// X4 GPIO13 is connected to the battery latch MOSFET. Keeping it low powers
|
||||
// the MCU off on battery, while the SDK wake source still handles USB power.
|
||||
constexpr gpio_num_t GPIO_SPIWP = GPIO_NUM_13;
|
||||
gpio_set_direction(GPIO_SPIWP, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(GPIO_SPIWP, 0);
|
||||
gpio_hold_en(GPIO_SPIWP);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Cut the gated peripheral rails (touch/SD/EPD on boards like the Sticky) and
|
||||
// hold the enables off through deep sleep — otherwise the GT911 and SD card
|
||||
// stay powered all through "off" and drain the battery. No-op on boards with
|
||||
// no switched rails (X4/X3). Trade-off: no touch-to-wake; wake is the power
|
||||
// button. Must run after display.deepSleep() so the panel controller gets its
|
||||
// deep-sleep command while its rail is still up (enterDeepSleep() in main.cpp
|
||||
// guarantees that ordering).
|
||||
freeink::PowerManager::powerDownRailsForSleep();
|
||||
|
||||
// Waits for the power button to be physically released (so holding it doesn't
|
||||
// immediately wake the device again), then arms the wake source and sleeps.
|
||||
freeink::PowerManager::deepSleepUntilPowerButton();
|
||||
}
|
||||
|
||||
uint16_t HalPowerManager::getBatteryPercentage() const {
|
||||
if (_batteryUseI2C) {
|
||||
static const BatteryMonitor battery;
|
||||
if (BoardConfig::ACTIVE.batteryGauge.gaugeAddr != 0) {
|
||||
const unsigned long now = millis();
|
||||
if (_batteryLastPollMs != 0 && (now - _batteryLastPollMs) < BATTERY_POLL_MS) {
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
|
||||
// Read SOC directly from I2C fuel gauge (16-bit LE register).
|
||||
// On I2C error, keep last known value to avoid UI jitter/slowdowns.
|
||||
Wire.beginTransmission(I2C_ADDR_BQ27220);
|
||||
Wire.write(BQ27220_SOC_REG);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
_batteryLastPollMs = now;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
Wire.requestFrom(I2C_ADDR_BQ27220, (uint8_t)2);
|
||||
if (Wire.available() < 2) {
|
||||
_batteryLastPollMs = now;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
const uint8_t lo = Wire.read();
|
||||
const uint8_t hi = Wire.read();
|
||||
const uint16_t soc = (hi << 8) | lo;
|
||||
_batteryCachedPercent = soc > 100 ? 100 : soc;
|
||||
_batteryLastPollMs = now;
|
||||
uint16_t percent = 0;
|
||||
if (!battery.readPercentageChecked(percent)) {
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
_batteryCachedPercent = percent;
|
||||
return _batteryCachedPercent;
|
||||
}
|
||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||
|
||||
// smooth the battery %.
|
||||
if (_batteryCachedPercent == 0) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <BatteryMonitor.h>
|
||||
#include <InputManager.h>
|
||||
#include <Logging.h>
|
||||
#include <Wire.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#include <cassert>
|
||||
@@ -18,8 +17,6 @@ class HalPowerManager {
|
||||
int normalFreq = 0; // MHz
|
||||
bool isLowPower = false;
|
||||
|
||||
// I2C fuel gauge configuration for X3 battery monitoring
|
||||
bool _batteryUseI2C = false; // True if using I2C fuel gauge (X3), false for ADC (X4)
|
||||
mutable int _batteryCachedPercent = 0; // Last read battery percentage (0-100)
|
||||
mutable unsigned long _batteryLastPollMs = 0; // Timestamp of last battery read in milliseconds
|
||||
|
||||
@@ -28,7 +25,11 @@ class HalPowerManager {
|
||||
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
|
||||
|
||||
public:
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
#if BOARD_HAS_PSRAM
|
||||
static constexpr int LOW_POWER_FREQ = 80; // MHz
|
||||
#else
|
||||
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||
#endif
|
||||
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
|
||||
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
return;
|
||||
}
|
||||
|
||||
#if !__riscv
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
return;
|
||||
#else
|
||||
for (size_t i = 0; i < MAX_PANIC_STACK_DEPTH; i++) {
|
||||
panicStack[i].sp = 0;
|
||||
}
|
||||
@@ -65,6 +70,7 @@ void IRAM_ATTR __wrap_panic_print_backtrace(const void* frame, int core) {
|
||||
}
|
||||
|
||||
__real_panic_print_backtrace(frame, core);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-93
@@ -4,84 +4,29 @@
|
||||
|
||||
HalTiltSensor halTiltSensor; // Singleton instance
|
||||
|
||||
bool HalTiltSensor::writeReg(uint8_t reg, uint8_t val) const {
|
||||
Wire.beginTransmission(_i2cAddr);
|
||||
Wire.write(reg);
|
||||
Wire.write(val);
|
||||
return Wire.endTransmission() == 0;
|
||||
}
|
||||
|
||||
bool HalTiltSensor::readReg(uint8_t reg, uint8_t* val) const {
|
||||
Wire.beginTransmission(_i2cAddr);
|
||||
Wire.write(reg);
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
return false;
|
||||
}
|
||||
Wire.requestFrom(_i2cAddr, (uint8_t)1);
|
||||
if (Wire.available() < 1) {
|
||||
return false;
|
||||
}
|
||||
*val = Wire.read();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalTiltSensor::readGyro(float& gx, float& gy, float& gz) const {
|
||||
Wire.beginTransmission(_i2cAddr);
|
||||
Wire.write(REG_GX_L); // Start reading at Gyro X Low
|
||||
if (Wire.endTransmission(false) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Wire.requestFrom(_i2cAddr, (uint8_t)6);
|
||||
if (Wire.available() < 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto readInt16 = [&]() -> int16_t {
|
||||
const uint8_t lo = Wire.read();
|
||||
const uint8_t hi = Wire.read();
|
||||
return static_cast<int16_t>((hi << 8) | lo);
|
||||
};
|
||||
|
||||
// If Full Scale is ±512 dps, the scale factor is 32768 / 512 = 64 LSB/dps
|
||||
constexpr float SCALE = 1.0f / 64.0f;
|
||||
gx = readInt16() * SCALE;
|
||||
gy = readInt16() * SCALE;
|
||||
gz = readInt16() * SCALE;
|
||||
Imu::Sample sample;
|
||||
if (!_sdkImu.read(sample)) return false;
|
||||
gx = sample.gx;
|
||||
gy = sample.gy;
|
||||
gz = sample.gz;
|
||||
return true;
|
||||
}
|
||||
|
||||
void HalTiltSensor::begin() {
|
||||
if (!gpio.deviceIsX3()) {
|
||||
_available = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Try primary address, then alternate
|
||||
uint8_t whoami = 0;
|
||||
_i2cAddr = I2C_ADDR_QMI8658;
|
||||
if (!readReg(QMI8658_WHO_AM_I_REG, &whoami) || whoami != QMI8658_WHO_AM_I_VALUE) {
|
||||
_i2cAddr = I2C_ADDR_QMI8658_ALT;
|
||||
if (!readReg(QMI8658_WHO_AM_I_REG, &whoami) || whoami != QMI8658_WHO_AM_I_VALUE) {
|
||||
LOG_ERR("GYR", "QMI8658 IMU not found");
|
||||
_available = false;
|
||||
return;
|
||||
_available = _sdkImu.begin();
|
||||
if (_available) {
|
||||
_initMs = millis();
|
||||
_lastPollMs = millis();
|
||||
// begin() leaves the sensors sampling; stand them by until tilt page turn
|
||||
// actually wakes them, so a disabled IMU doesn't drain the battery.
|
||||
if (!_sdkImu.sleep()) {
|
||||
LOG_ERR("GYR", "IMU standby failed");
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INF("GYR", "QMI8658 IMU found at 0x%02X", _i2cAddr);
|
||||
|
||||
if (!writeReg(REG_CTRL7, CTRL7_DISABLE_ALL) || !writeReg(REG_CTRL3, CTRL3_FS_512DPS | CTRL3_ODR_28HZ) ||
|
||||
!writeReg(REG_CTRL1, CTRL1_BASE | CTRL1_SENSOR_DISABLE)) {
|
||||
LOG_ERR("GYR", "QMI8658 register configuration failed");
|
||||
_available = false;
|
||||
LOG_INF("GYR", "SDK IMU initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
_available = true;
|
||||
_initMs = millis();
|
||||
_lastPollMs = millis();
|
||||
LOG_INF("GYR", "QMI8658 gyro initialized and put to sleep");
|
||||
LOG_ERR("GYR", "SDK IMU not found");
|
||||
}
|
||||
|
||||
bool HalTiltSensor::wake() {
|
||||
@@ -89,21 +34,16 @@ bool HalTiltSensor::wake() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for init to complete before waking
|
||||
if ((millis() - _initMs) < SLEEP_STABILIZE_MS) {
|
||||
if (!_sdkImu.wake()) {
|
||||
LOG_ERR("GYR", "IMU wake failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (writeReg(REG_CTRL1, CTRL1_BASE) && writeReg(REG_CTRL7, CTRL7_GYRO_ENABLE)) {
|
||||
_lastPollMs = millis();
|
||||
_lastTiltMs = millis();
|
||||
_wakeMs = millis();
|
||||
LOG_INF("GYR", "QMI8658 woke up");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERR("GYR", "Failed to wake QMI8658");
|
||||
return false;
|
||||
}
|
||||
_lastPollMs = millis();
|
||||
_lastTiltMs = millis();
|
||||
_wakeMs = millis();
|
||||
_isAwake = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool HalTiltSensor::deepSleep() {
|
||||
@@ -111,20 +51,15 @@ bool HalTiltSensor::deepSleep() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((millis() - _wakeMs) < SLEEP_STABILIZE_MS) {
|
||||
if (!_sdkImu.sleep()) {
|
||||
LOG_ERR("GYR", "IMU sleep failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (writeReg(REG_CTRL7, CTRL7_DISABLE_ALL) && writeReg(REG_CTRL1, CTRL1_BASE | CTRL1_SENSOR_DISABLE)) {
|
||||
// Clear any residual state so it doesn't immediately trigger upon waking
|
||||
clearPendingEvents();
|
||||
_inTilt = false;
|
||||
LOG_INF("GYR", "QMI8658 entered sleep mode");
|
||||
return true;
|
||||
} else {
|
||||
LOG_ERR("GYR", "Failed to put QMI8658 to sleep");
|
||||
return false;
|
||||
}
|
||||
clearPendingEvents();
|
||||
_inTilt = false;
|
||||
_isAwake = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void HalTiltSensor::update(const uint8_t mode, const uint8_t orientation, const bool inReader) {
|
||||
|
||||
+6
-33
@@ -1,9 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
|
||||
#include "HalGPIO.h"
|
||||
#include <Imu.h>
|
||||
|
||||
// TODO: Move enums into new header and share with CrossPointSettings.h
|
||||
namespace CrossPointOrientation {
|
||||
@@ -19,7 +17,7 @@ extern HalTiltSensor halTiltSensor; // Singleton
|
||||
|
||||
class HalTiltSensor {
|
||||
bool _available = false;
|
||||
uint8_t _i2cAddr = 0;
|
||||
mutable Imu _sdkImu;
|
||||
|
||||
// Tilt gesture state machine
|
||||
bool _tiltForwardEvent = false; // Consumed by wasTiltedForward()
|
||||
@@ -37,47 +35,22 @@ class HalTiltSensor {
|
||||
static constexpr unsigned long COOLDOWN_MS = 600; // Minimum ms between triggers
|
||||
static constexpr unsigned long POLL_INTERVAL_MS = 50; // 20 Hz polling
|
||||
static constexpr unsigned long WAKE_STABILIZE_MS = 300; // Ignore readings after wake
|
||||
static constexpr unsigned long SLEEP_STABILIZE_MS = 15; // Sleep turn on/off delay
|
||||
|
||||
mutable unsigned long _lastPollMs = 0;
|
||||
|
||||
// --- QMI8658 registers ---
|
||||
static constexpr uint8_t REG_CTRL1 = 0x02;
|
||||
static constexpr uint8_t REG_CTRL3 = 0x04;
|
||||
static constexpr uint8_t REG_CTRL7 = 0x08;
|
||||
static constexpr uint8_t REG_GX_L = 0x3B;
|
||||
|
||||
// --- Register Bit Flags ---
|
||||
|
||||
// REG_CTRL1 (0x02)
|
||||
static constexpr uint8_t CTRL1_BIG_ENDIAN = (1 << 5); // 0x20: Default state (1 = Big Endian)
|
||||
static constexpr uint8_t CTRL1_AUTO_INC = (1 << 6); // 0x40: Enable address auto-increment
|
||||
static constexpr uint8_t CTRL1_SENSOR_DISABLE = (1 << 0); // 0x01: Power down sensor engine
|
||||
static constexpr uint8_t CTRL1_BASE = CTRL1_AUTO_INC | CTRL1_BIG_ENDIAN; // 0x60
|
||||
|
||||
// REG_CTRL3 (0x04) - Gyro Config
|
||||
static constexpr uint8_t CTRL3_FS_512DPS = (0b101 << 4); // Bits 6:4 = 101
|
||||
static constexpr uint8_t CTRL3_ODR_28HZ = 0b1000; // Bits 3:0 = 1000 (28.025 Hz)
|
||||
|
||||
// REG_CTRL7 (0x08) - Enable
|
||||
static constexpr uint8_t CTRL7_DISABLE_ALL = 0x00;
|
||||
static constexpr uint8_t CTRL7_GYRO_ENABLE = (1 << 1); // Bit 1 = 1
|
||||
|
||||
bool writeReg(uint8_t reg, uint8_t val) const;
|
||||
bool readReg(uint8_t reg, uint8_t* val) const;
|
||||
bool readGyro(float& gx, float& gy, float& gz) const;
|
||||
|
||||
public:
|
||||
// Call after gpio.begin() and powerManager.begin() (I2C already initialised for X3)
|
||||
// Call after BoardConfig has selected the active device.
|
||||
void begin();
|
||||
|
||||
// Enables the QMI8658 internal sensor engine
|
||||
// Enables tilt polling state
|
||||
bool wake();
|
||||
|
||||
// Puts the QMI8658 into a low-power standby state
|
||||
// Puts tilt polling state to sleep
|
||||
bool deepSleep();
|
||||
|
||||
// True if the QMI8658 IMU is present on this device
|
||||
// True if an IMU is present on this device
|
||||
bool isAvailable() const { return _available; }
|
||||
|
||||
// Poll the accelerometer and update tilt gesture state.
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
shell.nix
|
||||
Generated
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-compat": {
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1784356753,
|
||||
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
description = "CrossPoint Reader development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-compat.url = "github:NixOS/flake-compat";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ nixpkgs, ... }:
|
||||
let
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
in
|
||||
{
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
# Detect the project root from wherever the user entered the shell,
|
||||
# so commands work from the repository root or any subdirectory.
|
||||
# Using `git rev-parse` to do so (assuming git is installed
|
||||
# system-wide); user can overwrite this by setting PROJECT_ROOT env.
|
||||
setEnvs = ''
|
||||
PROJECT_ROOT="''${PROJECT_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
||||
export PROJECT_ROOT
|
||||
export PLATFORMIO_CORE_DIR="$PROJECT_ROOT/.cache/platformio"
|
||||
'';
|
||||
fhsEnv = pkgs.buildFHSEnv {
|
||||
name = "crosspoint-reader-shell";
|
||||
|
||||
targetPkgs =
|
||||
pkgs: with pkgs; [
|
||||
python3
|
||||
uv
|
||||
|
||||
# Runtime libraries used by PlatformIO's downloaded ESP32 toolchain binaries.
|
||||
stdenv.cc.cc.lib
|
||||
zlib
|
||||
ncurses
|
||||
];
|
||||
|
||||
profile = ''
|
||||
${setEnvs}
|
||||
export PATH="$PROJECT_ROOT/.venv/bin:$PATH"
|
||||
|
||||
if [ ! -x "$PROJECT_ROOT/.venv/bin/pio" ]; then
|
||||
echo "Creating .venv and installing pioarduino PlatformIO Core..."
|
||||
# Forcing python3 from fhsEnv, otherwise we get the following
|
||||
# exception while running `pip check`
|
||||
# ModuleNotFoundError: No module named 'littlefs'
|
||||
uv venv --python /usr/bin/python3 "$PROJECT_ROOT/.venv" &&
|
||||
uv pip install --python "$PROJECT_ROOT/.venv/bin/python" \
|
||||
-U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip \
|
||||
-r "$PROJECT_ROOT/requirements.txt" ||
|
||||
echo "Failed to install pioarduino PlatformIO Core" >&2
|
||||
fi
|
||||
'';
|
||||
};
|
||||
pio = pkgs.writeShellScriptBin "pio" ''
|
||||
exec ${fhsEnv}/bin/crosspoint-reader-shell -c 'exec pio "$@"' pio "$@"
|
||||
'';
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pio
|
||||
fhsEnv
|
||||
];
|
||||
|
||||
shellHook = setEnvs;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
(import (
|
||||
let
|
||||
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
|
||||
nodeName = lock.nodes.root.inputs.flake-compat;
|
||||
in
|
||||
fetchTarball {
|
||||
url =
|
||||
lock.nodes.${nodeName}.locked.url
|
||||
or "https://github.com/NixOS/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz";
|
||||
sha256 = lock.nodes.${nodeName}.locked.narHash;
|
||||
}
|
||||
) { src = ./.; }).shellNix
|
||||
+84
-4
@@ -13,7 +13,10 @@ framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_speed = 921600
|
||||
check_tool = cppcheck
|
||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||
; missingInclude (project headers) is suppressed alongside missingIncludeSystem: on a
|
||||
; fresh CI checkout cppcheck has no resolved include paths, so it reports every
|
||||
; project header as missing (~400 information-level lines) and fails the job.
|
||||
check_flags = --enable=all --suppress=missingIncludeSystem --suppress=missingInclude --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr
|
||||
check_skip_packages = yes
|
||||
|
||||
board_upload.flash_size = 16MB
|
||||
@@ -35,14 +38,19 @@ build_flags =
|
||||
# Increase PNG scanline buffer to support up to 2048px wide images
|
||||
# Default is (320*4+1)*2=2562, we need more for larger images
|
||||
-DPNG_MAX_BUFFERED_PIXELS=16416
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DFREEINK_NET_WOLFSSL=1
|
||||
-DWOLFSSL_USER_SETTINGS
|
||||
-DWOLFSSL_OPTIONS_H
|
||||
-DWOLFSSL_CLIENT_EXAMPLE
|
||||
-DWOLFSSL_TLS13
|
||||
-DWOLFSSL_SP_RISCV32
|
||||
# MEMFIX-PORT: single-precision ECC (sp_c32.c). Without it every P-256 operation
|
||||
# (TLS 1.3 key_share keygen, ECDHE, ECDSA cert verify) runs on fast-math bignums
|
||||
# that WOLFSSL_SMALL_STACK heap-allocates at FP_MAX_BITS size -- tens of KB of
|
||||
# temporaries, which OOMs (MP_MEM) at the ~50KB free heap a reading session
|
||||
# leaves. SP uses fixed 256-bit arrays: a few KB, and several times faster.
|
||||
# SP_SMALL trades the large precomputed point tables for smaller flash.
|
||||
-DWOLFSSL_HAVE_SP_ECC
|
||||
-DWOLFSSL_SP_SMALL
|
||||
-DHAVE_TLS_EXTENSIONS
|
||||
-DHAVE_SUPPORTED_CURVES
|
||||
-DHAVE_HKDF
|
||||
@@ -63,6 +71,51 @@ board_build.flash_mode = dio
|
||||
board_build.flash_size = 16MB
|
||||
board_build.partitions = partitions.csv
|
||||
|
||||
; MEMFIX-PORT: custom_sdkconfig heap reclamation (~32-37 KB). Rebuilds the
|
||||
; Arduino core libs on first build (slower once, cached after; needs the CMake
|
||||
; pin in platformio.local.ini on macOS).
|
||||
;
|
||||
; If an interrupted rebuild fails with "multiple definition of 'app_main'"
|
||||
; (stale generated scaffold), clean it up with:
|
||||
; rm -rf .dummy CMakeLists.txt sdkconfig.default sdkconfig.defaults .pio/build/default
|
||||
; Do NOT use `git clean -fdX` — it deletes platformio.local.ini.
|
||||
custom_sdkconfig =
|
||||
; Task stack right-sizing from measured high-water marks (heap block map +
|
||||
; per-task stack audit, July 2026): esp_timer used ~0.8 KB of 8 KB across
|
||||
; every capture; the FreeRTOS timer service used ~0.5 KB
|
||||
; of 4 KB. Neither runs TLS or app code. ~7 KB back to the heap.
|
||||
CONFIG_ESP_TIMER_TASK_STACK_SIZE=4096
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=2560
|
||||
; Move the WiFi stack's non-critical hot paths out of IRAM into flash.
|
||||
; On the C3, IRAM and DRAM share one SRAM pool, so the ~25-30 KB this
|
||||
; frees lands directly in the heap — paid for with lower WiFi throughput
|
||||
; during transfers (occasional sync/OTA use, not streaming: acceptable).
|
||||
; IRAM cost is static, so the heap gain applies even with WiFi off.
|
||||
CONFIG_ESP_WIFI_IRAM_OPT=n
|
||||
CONFIG_ESP_WIFI_RX_IRAM_OPT=n
|
||||
; Keep the Arduino wrappers for the removed cloud components (below) out of
|
||||
; the core source list; all other bundled libraries default to enabled.
|
||||
CONFIG_ARDUINO_SELECTIVE_COMPILATION=y
|
||||
CONFIG_ARDUINO_SELECTIVE_RainMaker=n
|
||||
CONFIG_ARDUINO_SELECTIVE_Insights=n
|
||||
; ESP_SR (speech recognition) only exists in the S3 core; its wrapper includes
|
||||
; ESP_I2S.h, which the isolated core rebuild can't resolve. Unused here anyway,
|
||||
; so drop it or the sticky env fails to build.
|
||||
CONFIG_ARDUINO_SELECTIVE_ESP_SR=n
|
||||
|
||||
; Drop unused cloud components from the core rebuild. esp_insights/rainmaker
|
||||
; require embedded server certs the lib builder can't generate
|
||||
; ("https_server.crt.S not found"); this firmware uses none of them.
|
||||
custom_component_remove =
|
||||
espressif/esp_insights
|
||||
espressif/esp_rainmaker
|
||||
espressif/esp_diagnostics
|
||||
espressif/esp_diag_data_store
|
||||
espressif/esp_schedule
|
||||
espressif/esp_rcp_update
|
||||
espressif/esp_secure_cert_mgr
|
||||
espressif/cbor
|
||||
|
||||
extra_scripts =
|
||||
pre:scripts/patch_wolfssl.py
|
||||
pre:scripts/build_html.py
|
||||
@@ -79,6 +132,8 @@ lib_deps =
|
||||
SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager
|
||||
BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig
|
||||
PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager
|
||||
Rtc=symlink://freeink-sdk/libs/hardware/Rtc
|
||||
Imu=symlink://freeink-sdk/libs/hardware/Imu
|
||||
SecureNet=symlink://freeink-sdk/libs/network/SecureNet
|
||||
FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI
|
||||
Icons=symlink://freeink-sdk/libs/assets/Icons
|
||||
@@ -97,6 +152,8 @@ extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
; CROSSPOINT_VERSION is set by scripts/git_branch.py (includes branch + short SHA)
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||
|
||||
@@ -105,6 +162,8 @@ build_flags =
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release builds
|
||||
@@ -113,6 +172,8 @@ build_flags =
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-rc+${sysenv.CROSSPOINT_RC_HASH}\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=1 ; Set log level to info for release candidate builds
|
||||
@@ -121,6 +182,25 @@ build_flags =
|
||||
extends = base
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_X4=1
|
||||
-DFREEINK_DEVICE_X3=1
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-slim\"
|
||||
; serial output is disabled in slim builds to save space
|
||||
-UENABLE_SERIAL_LOG
|
||||
|
||||
; --- Seeed Sticky — ESP32-S3R8, 3.97" 800x480 SSD1677 + GT911 touch -----------
|
||||
; Different MCU family than the C3 envs (one binary per family). The SDK
|
||||
; auto-enables CAP_TOUCH (GT911) and the BQ27220 gauge for this device; PSRAM is
|
||||
; intentionally left off (48KB framebuffer fits in DRAM, same as X4).
|
||||
; pio run -e sticky -t upload
|
||||
[env:sticky]
|
||||
extends = base
|
||||
board = esp32-s3-devkitc1-n16r8
|
||||
board_build.mcu = esp32s3
|
||||
build_flags =
|
||||
${base.build_flags}
|
||||
-DFREEINK_DEVICE_STICKY=1
|
||||
; git_branch.py only injects CROSSPOINT_VERSION for the default env; set it here.
|
||||
-DCROSSPOINT_VERSION=\"${crosspoint.version}-sticky\"
|
||||
-DENABLE_SERIAL_LOG
|
||||
-DLOG_LEVEL=2
|
||||
|
||||
@@ -12,8 +12,12 @@ OVERRIDES = f"""
|
||||
#ifndef HAVE_FFDHE_2048
|
||||
#define HAVE_FFDHE_2048
|
||||
#endif
|
||||
/* MEMFIX-PORT: 8192 handles up to RSA-4096 keys (the public-CA maximum,
|
||||
ISRG Root X1 included) with half the per-bignum heap of 16384: with
|
||||
WOLFSSL_SMALL_STACK each fast-math temp is FP_MAX_BITS/8 * 2 bytes on the
|
||||
heap, and TLS cert verification allocates dozens at once. */
|
||||
#undef FP_MAX_BITS
|
||||
#define FP_MAX_BITS 16384
|
||||
#define FP_MAX_BITS 8192
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ class CrossPointSettings {
|
||||
LIGHT = 1,
|
||||
CUSTOM = 2,
|
||||
COVER = 3,
|
||||
BLANK = 4,
|
||||
COVER_CUSTOM = 5,
|
||||
COVER_CUSTOM = 4,
|
||||
BLANK = 5,
|
||||
QUICK_RESUME = 6,
|
||||
SLEEP_SCREEN_MODE_COUNT
|
||||
};
|
||||
@@ -153,6 +153,7 @@ class CrossPointSettings {
|
||||
LP_MENU_KOSYNC = 0,
|
||||
LP_MENU_DISABLED = 1,
|
||||
LP_MENU_BOOKMARK = 2,
|
||||
LP_MENU_DICTIONARY = 3,
|
||||
LONG_PRESS_MENU_FUNCTION_COUNT
|
||||
};
|
||||
|
||||
@@ -175,6 +176,8 @@ class CrossPointSettings {
|
||||
|
||||
enum TILT_PAGE_TURN { TILT_OFF = 0, TILT_NORMAL = 1, TILT_NVERTED = 2, TILT_PAGE_TURN_COUNT };
|
||||
|
||||
enum TOUCH_READER_CONTROLS { TOUCH_READER_OFF = 0, TOUCH_READER_ON = 1, TOUCH_READER_CONTROLS_COUNT };
|
||||
|
||||
enum QUICK_RESUME_SLEEP_SCREEN {
|
||||
QUICK_RESUME_NEVER = 0,
|
||||
QUICK_RESUME_AFTER_TIMEOUT = 1,
|
||||
@@ -265,16 +268,22 @@ class CrossPointSettings {
|
||||
uint8_t focusReadingEnabled = 0;
|
||||
// SD card font family name (empty = use built-in fontFamily)
|
||||
char sdFontFamilyName[32] = "";
|
||||
// Dictionary folder name under /dictionaries (empty = no dictionary)
|
||||
char dictionaryName[32] = "";
|
||||
// Show hidden files/directories (starting with '.') in the file browser (0 = hidden, 1 = show)
|
||||
uint8_t showHiddenFiles = 0;
|
||||
// Remove a book from the Recent Books list when its End-of-Book screen is reached (0 = off, 1 = on)
|
||||
uint8_t removeReadBooksFromRecents = 0;
|
||||
// Move epub to /Read/ folder on SD card when finished (0 = disabled, 1 = enabled)
|
||||
uint8_t moveFinishedToReadFolder = 0;
|
||||
// Short press Back goes to file browser instead of home (0 = disabled, 1 = enabled)
|
||||
uint8_t backShortToFileBrowser = 0;
|
||||
// Image rendering mode in EPUB reader
|
||||
uint8_t imageRendering = IMAGES_DISPLAY;
|
||||
// Tilt-based page turning (X3 only — requires QMI8658 IMU)
|
||||
uint8_t tiltPageTurn = TILT_OFF;
|
||||
// Touch screen reader zones/gestures on boards with a touch controller.
|
||||
uint8_t touchReaderControls = TOUCH_READER_ON;
|
||||
// Language setting (Language enum index, default 0 = EN)
|
||||
uint8_t language = 0;
|
||||
// Quick Resume: keep current content visible with moon icon instead of showing a static sleep screen.
|
||||
|
||||
@@ -149,6 +149,10 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
||||
if (s.sdFontFamilyName[0] != '\0') {
|
||||
doc["sdFontFamilyName"] = s.sdFontFamilyName;
|
||||
}
|
||||
// Dictionary folder name — uses dynamic getter/setter in SettingsList, save manually
|
||||
if (s.dictionaryName[0] != '\0') {
|
||||
doc["dictionaryName"] = s.dictionaryName;
|
||||
}
|
||||
|
||||
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
@@ -256,6 +260,11 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
||||
if (needsResave) *needsResave = true;
|
||||
}
|
||||
|
||||
// Dictionary folder name — uses dynamic getter/setter in SettingsList, load manually
|
||||
const char* dictName = doc["dictionaryName"] | "";
|
||||
strncpy(s.dictionaryName, dictName, sizeof(s.dictionaryName) - 1);
|
||||
s.dictionaryName[sizeof(s.dictionaryName) - 1] = '\0';
|
||||
|
||||
// Language -- stored as code string for stability across enum reorders.
|
||||
if (doc["language"].is<const char*>()) {
|
||||
s.language = static_cast<uint8_t>(I18n::languageFromCode(doc["language"].as<const char*>()));
|
||||
|
||||
+214
-3
@@ -2,7 +2,11 @@
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "components/UITheme.h"
|
||||
|
||||
bool MappedInputManager::isNavDirectionSwapped() const {
|
||||
// Key the swap on the orientation the screen is *actually* rendered at, not the persisted reader
|
||||
@@ -74,9 +78,209 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
|
||||
namespace {
|
||||
constexpr float LEFT_EDGE_BACK_GESTURE_FRAC_X = 0.25f;
|
||||
constexpr float BOTTOM_EDGE_BACK_GESTURE_FRAC_Y = 0.14f;
|
||||
constexpr float TOP_EDGE_MENU_GESTURE_FRAC_Y = 0.14f;
|
||||
constexpr unsigned long TOUCH_DOWN_SELECT_DELAY_MS = 90;
|
||||
constexpr unsigned long TOUCH_HELD_OVERRIDE_WINDOW_MS = 250;
|
||||
} // namespace
|
||||
|
||||
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
|
||||
bool MappedInputManager::hasTouch() const { return gpio.hasTouch(); }
|
||||
|
||||
void MappedInputManager::rememberTouchHeldTime() const {
|
||||
touchHeldOverrideValid = true;
|
||||
touchHeldOverrideMs = gpio.lastTouchHeldMs();
|
||||
touchHeldOverrideAt = millis();
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasScreenTapped(int& x, int& y) const {
|
||||
float nx = 0.0f;
|
||||
float ny = 0.0f;
|
||||
if (!gpio.wasTouchTap(nx, ny)) return false;
|
||||
renderer.tapToLogical(nx, ny, x, y);
|
||||
rememberTouchHeldTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasScreenTouchDown(int& x, int& y) const {
|
||||
float nx = 0.0f;
|
||||
float ny = 0.0f;
|
||||
unsigned long heldMs = 0;
|
||||
if (!gpio.isTouchTapCandidate(nx, ny, heldMs)) return false;
|
||||
if (heldMs < TOUCH_DOWN_SELECT_DELAY_MS) return false;
|
||||
renderer.tapToLogical(nx, ny, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::isScreenTouchHeld(int& x, int& y) const {
|
||||
// Live contact position while the finger is down (no tap-slop gate) — drag tracking.
|
||||
float nx = 0.0f;
|
||||
float ny = 0.0f;
|
||||
if (!gpio.isTouchHeldAt(nx, ny)) return false;
|
||||
renderer.tapToLogical(nx, ny, x, y);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasTapInRect(const int x, const int y, const int width, const int height) const {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
return wasScreenTapped(tx, ty) && tx >= x && tx < x + width && ty >= y && ty < y + height;
|
||||
}
|
||||
|
||||
bool MappedInputManager::listItemFromPoint(const int x, const int y, int& index, const int itemCount,
|
||||
const int selectedIndex, const int listTop, const int listHeight,
|
||||
const bool hasSubtitle) const {
|
||||
(void)x;
|
||||
if (itemCount <= 0) return false;
|
||||
if (y < listTop || y >= listTop + listHeight) return false;
|
||||
|
||||
const auto& theme = UITheme::getInstance().getTheme();
|
||||
const int rowStep = theme.getListRowStep(hasSubtitle);
|
||||
if (rowStep <= 0) return false;
|
||||
|
||||
const int pageItems = theme.getListPageItems(listHeight, hasSubtitle);
|
||||
if (pageItems <= 0) return false;
|
||||
const int pageStart = std::max(0, selectedIndex / pageItems) * pageItems;
|
||||
const int row = (y - listTop) / rowStep;
|
||||
const int tapped = pageStart + row;
|
||||
if (row < 0 || row >= pageItems || tapped >= itemCount) return false;
|
||||
index = tapped;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasListItemTapped(int& index, const int itemCount, const int selectedIndex, const int listTop,
|
||||
const int listHeight, const bool hasSubtitle) const {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
return wasScreenTapped(tx, ty) &&
|
||||
listItemFromPoint(tx, ty, index, itemCount, selectedIndex, listTop, listHeight, hasSubtitle);
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasListItemTouchedDown(int& index, const int itemCount, const int selectedIndex,
|
||||
const int listTop, const int listHeight, const bool hasSubtitle) const {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
return wasScreenTouchDown(tx, ty) &&
|
||||
listItemFromPoint(tx, ty, index, itemCount, selectedIndex, listTop, listHeight, hasSubtitle);
|
||||
}
|
||||
|
||||
MappedInputManager::RowTouch MappedInputManager::rowTouch(int& row, const int top, const int rowStep,
|
||||
const int rowCount, const int xStart, const int xEnd,
|
||||
const int rowHeight) const {
|
||||
if (rowStep <= 0 || rowCount <= 0) return RowTouch::None;
|
||||
const auto hit = [&](const int x, const int y) {
|
||||
if (x < xStart || x >= xEnd || y < top) return false;
|
||||
const int r = (y - top) / rowStep;
|
||||
if (r >= rowCount) return false;
|
||||
if (rowHeight > 0 && (y - top) % rowStep >= rowHeight) return false;
|
||||
row = r;
|
||||
return true;
|
||||
};
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (wasScreenTouchDown(x, y) && hit(x, y)) return RowTouch::Down;
|
||||
if (wasScreenTapped(x, y) && hit(x, y)) return RowTouch::Tap;
|
||||
return RowTouch::None;
|
||||
}
|
||||
|
||||
MappedInputManager::RowTouch MappedInputManager::colTouch(int& col, const int left, const int colStep,
|
||||
const int colCount, const int yStart, const int yEnd,
|
||||
const int colWidth) const {
|
||||
if (colStep <= 0 || colCount <= 0) return RowTouch::None;
|
||||
const auto hit = [&](const int x, const int y) {
|
||||
if (y < yStart || y >= yEnd || x < left) return false;
|
||||
const int c = (x - left) / colStep;
|
||||
if (c >= colCount) return false;
|
||||
if (colWidth > 0 && (x - left) % colStep >= colWidth) return false;
|
||||
col = c;
|
||||
return true;
|
||||
};
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (wasScreenTouchDown(x, y) && hit(x, y)) return RowTouch::Down;
|
||||
if (wasScreenTapped(x, y) && hit(x, y)) return RowTouch::Tap;
|
||||
return RowTouch::None;
|
||||
}
|
||||
|
||||
bool MappedInputManager::decodeSwipe(int& sx, int& sy, int& ex, int& ey) const {
|
||||
float nxs = 0.0f;
|
||||
float nys = 0.0f;
|
||||
float nxe = 0.0f;
|
||||
float nye = 0.0f;
|
||||
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return false;
|
||||
renderer.tapToLogical(nxs, nys, sx, sy);
|
||||
renderer.tapToLogical(nxe, nye, ex, ey);
|
||||
return true;
|
||||
}
|
||||
|
||||
MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const {
|
||||
int sx = 0;
|
||||
int sy = 0;
|
||||
int ex = 0;
|
||||
int ey = 0;
|
||||
if (!decodeSwipe(sx, sy, ex, ey)) return SwipeDir::None;
|
||||
const int dx = ex - sx;
|
||||
const int dy = ey - sy;
|
||||
if (std::abs(dx) >= std::abs(dy)) {
|
||||
return dx < 0 ? SwipeDir::Left : SwipeDir::Right;
|
||||
}
|
||||
return dy < 0 ? SwipeDir::Up : SwipeDir::Down;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasBackGesture() const {
|
||||
// Back = left-to-right swipe starting near the left edge. Edge-anchored so that
|
||||
// mid-screen horizontal swipes stay available to activities that consume
|
||||
// SwipeDir::Left/Right (e.g. percent selection, image viewer).
|
||||
int sx = 0;
|
||||
int sy = 0;
|
||||
int ex = 0;
|
||||
int ey = 0;
|
||||
if (!decodeSwipe(sx, sy, ex, ey)) return false;
|
||||
const bool hit = sx <= renderer.getScreenWidth() * LEFT_EDGE_BACK_GESTURE_FRAC_X && ex > sx &&
|
||||
std::abs(ex - sx) > std::abs(ey - sy);
|
||||
if (hit) rememberTouchHeldTime();
|
||||
return hit;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasMenuGesture() const {
|
||||
// Downward swipe starting at the top edge (mirror of the bottom-edge home gesture).
|
||||
int sx = 0;
|
||||
int sy = 0;
|
||||
int ex = 0;
|
||||
int ey = 0;
|
||||
if (!decodeSwipe(sx, sy, ex, ey)) return false;
|
||||
const int topEdgeBottom = static_cast<int>(renderer.getScreenHeight() * TOP_EDGE_MENU_GESTURE_FRAC_Y);
|
||||
const bool hit = sy <= topEdgeBottom && ey > sy && std::abs(ey - sy) > std::abs(ex - sx);
|
||||
if (hit) rememberTouchHeldTime();
|
||||
return hit;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasHomeGesture() const {
|
||||
int sx = 0;
|
||||
int sy = 0;
|
||||
int ex = 0;
|
||||
int ey = 0;
|
||||
if (decodeSwipe(sx, sy, ex, ey)) {
|
||||
const int bottomEdgeTop =
|
||||
renderer.getScreenHeight() - static_cast<int>(renderer.getScreenHeight() * BOTTOM_EDGE_BACK_GESTURE_FRAC_Y);
|
||||
if (sy >= bottomEdgeTop && ey < sy && std::abs(ey - sy) > std::abs(ex - sx)) {
|
||||
rememberTouchHeldTime();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasPressed(const Button button) const {
|
||||
if (button == Button::Back && wasBackGesture()) return true;
|
||||
return mapButton(button, &HalGPIO::wasPressed);
|
||||
}
|
||||
|
||||
bool MappedInputManager::wasReleased(const Button button) const {
|
||||
if (button == Button::Back && wasBackGesture()) return true;
|
||||
return mapButton(button, &HalGPIO::wasReleased);
|
||||
}
|
||||
|
||||
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
|
||||
|
||||
@@ -84,7 +288,14 @@ bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||
|
||||
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||
|
||||
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
||||
unsigned long MappedInputManager::getHeldTime() const {
|
||||
if (!gpio.wasAnyPressed() && !gpio.wasAnyReleased() && touchHeldOverrideValid &&
|
||||
millis() - touchHeldOverrideAt <= TOUCH_HELD_OVERRIDE_WINDOW_MS) {
|
||||
return touchHeldOverrideMs;
|
||||
}
|
||||
touchHeldOverrideValid = false;
|
||||
return gpio.getHeldTime();
|
||||
}
|
||||
|
||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||
const char* next) const {
|
||||
|
||||
@@ -7,6 +7,7 @@ class GfxRenderer;
|
||||
class MappedInputManager {
|
||||
public:
|
||||
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward, NavNext, NavPrevious };
|
||||
enum class SwipeDir { None, Left, Right, Up, Down };
|
||||
|
||||
struct Labels {
|
||||
const char* btn1;
|
||||
@@ -21,9 +22,35 @@ class MappedInputManager {
|
||||
bool wasPressed(Button button) const;
|
||||
bool wasReleased(Button button) const;
|
||||
bool isPressed(Button button) const;
|
||||
bool hasTouch() const;
|
||||
bool wasScreenTapped(int& x, int& y) const;
|
||||
bool wasScreenTouchDown(int& x, int& y) const;
|
||||
bool isScreenTouchHeld(int& x, int& y) const;
|
||||
bool wasTapInRect(int x, int y, int width, int height) const;
|
||||
bool wasListItemTapped(int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
|
||||
bool hasSubtitle) const;
|
||||
bool wasListItemTouchedDown(int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
|
||||
bool hasSubtitle) const;
|
||||
|
||||
// Combined touch interaction for a band of equal rows with caller-supplied
|
||||
// geometry — the shared hit-test for lists the theme helpers above do not
|
||||
// cover (custom row heights, option prompts, menus). Down = a held
|
||||
// tap-candidate is on a row (update the selection highlight); Tap = a tap
|
||||
// released on one (activate). rowHeight limits the hit to the top rowHeight
|
||||
// px of each step (0 = the full step, no gap band).
|
||||
enum class RowTouch : uint8_t { None, Down, Tap };
|
||||
RowTouch rowTouch(int& row, int top, int rowStep, int rowCount, int xStart = 0, int xEnd = INT32_MAX,
|
||||
int rowHeight = 0) const;
|
||||
// Horizontal variant for side-by-side button pairs (confirmation prompts).
|
||||
RowTouch colTouch(int& col, int left, int colStep, int colCount, int yStart, int yEnd, int colWidth = 0) const;
|
||||
|
||||
SwipeDir wasSwipe() const;
|
||||
bool wasHomeGesture() const;
|
||||
bool wasMenuGesture() const;
|
||||
bool wasAnyPressed() const;
|
||||
bool wasAnyReleased() const;
|
||||
unsigned long getHeldTime() const;
|
||||
const GfxRenderer& getRenderer() const { return renderer; }
|
||||
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
|
||||
// Returns the raw front button index that was pressed this frame (or -1 if none).
|
||||
int getPressedFrontButton() const;
|
||||
@@ -44,4 +71,14 @@ class MappedInputManager {
|
||||
const GfxRenderer& renderer;
|
||||
|
||||
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||
bool wasBackGesture() const;
|
||||
// Fetch the pending swipe (if any) and map both endpoints to logical screen coords
|
||||
bool decodeSwipe(int& sx, int& sy, int& ex, int& ey) const;
|
||||
bool listItemFromPoint(int x, int y, int& index, int itemCount, int selectedIndex, int listTop, int listHeight,
|
||||
bool hasSubtitle) const;
|
||||
void rememberTouchHeldTime() const;
|
||||
|
||||
mutable bool touchHeldOverrideValid = false;
|
||||
mutable unsigned long touchHeldOverrideMs = 0;
|
||||
mutable unsigned long touchHeldOverrideAt = 0;
|
||||
};
|
||||
|
||||
+78
-3
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <HalClock.h>
|
||||
#include <HalTiltSensor.h>
|
||||
#include <I18n.h>
|
||||
@@ -13,6 +14,7 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "activities/settings/SettingsActivity.h"
|
||||
#include "util/DictionaryRegistry.h"
|
||||
|
||||
// Build the font family setting dynamically. When registry is non-null, SD card fonts
|
||||
// are appended after the built-in fonts. Otherwise only built-in fonts are listed.
|
||||
@@ -90,6 +92,47 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Build the dictionary selection setting dynamically from the folders discovered
|
||||
// under /dictionaries. "None" plus one option per dictionary; the selected folder
|
||||
// name persists in SETTINGS.dictionaryName (saved/loaded manually in
|
||||
// JsonSettingsIO — the generic loop skips dynamic entries).
|
||||
inline SettingInfo buildDictionarySetting(const std::vector<DictionaryEntry>& dictionaries) {
|
||||
std::vector<std::string> folderNames;
|
||||
folderNames.reserve(dictionaries.size());
|
||||
std::transform(dictionaries.begin(), dictionaries.end(), std::back_inserter(folderNames),
|
||||
[](const DictionaryEntry& d) { return d.name; });
|
||||
|
||||
SettingInfo s;
|
||||
s.nameId = StrId::STR_DICTIONARY;
|
||||
s.type = SettingType::ENUM;
|
||||
s.enumStringValues.reserve(folderNames.size() + 1);
|
||||
s.enumStringValues.push_back(I18N.get(StrId::STR_NONE_OPT));
|
||||
s.enumStringValues.insert(s.enumStringValues.end(), folderNames.begin(), folderNames.end());
|
||||
s.category = StrId::STR_CAT_READER;
|
||||
|
||||
s.valueGetter = [folderNames]() -> uint8_t {
|
||||
for (size_t i = 0; i < folderNames.size(); i++) {
|
||||
// Compare within the settings field capacity: an over-long folder name is
|
||||
// stored truncated, and must still match its list entry.
|
||||
if (strncmp(folderNames[i].c_str(), SETTINGS.dictionaryName, sizeof(SETTINGS.dictionaryName) - 1) == 0) {
|
||||
return static_cast<uint8_t>(i + 1);
|
||||
}
|
||||
}
|
||||
return 0; // "None", also when the stored folder no longer exists
|
||||
};
|
||||
|
||||
s.valueSetter = [folderNames](uint8_t v) {
|
||||
if (v == 0 || v > folderNames.size()) {
|
||||
SETTINGS.dictionaryName[0] = '\0';
|
||||
return;
|
||||
}
|
||||
strncpy(SETTINGS.dictionaryName, folderNames[v - 1].c_str(), sizeof(SETTINGS.dictionaryName) - 1);
|
||||
SETTINGS.dictionaryName[sizeof(SETTINGS.dictionaryName) - 1] = '\0';
|
||||
};
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -99,7 +142,8 @@ inline SettingInfo buildFontFamilySetting(const SdCardFontRegistry* registry) {
|
||||
// SdCardFontRegistry is supplied AND has SD card fonts installed, the
|
||||
// font-family entry is replaced in a per-call copy with a registry-aware
|
||||
// version. Callers without SD fonts pay only a vector copy.
|
||||
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr) {
|
||||
inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* registry = nullptr,
|
||||
const std::vector<DictionaryEntry>* dictionaries = nullptr) {
|
||||
static const std::vector<SettingInfo> baseList = [] {
|
||||
std::vector<SettingInfo> v = {
|
||||
// --- Display ---
|
||||
@@ -166,6 +210,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout,
|
||||
{StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV, StrId::STR_DISABLED}, "sideButtonLayout",
|
||||
StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Enum(StrId::STR_TOUCH_READER_CONTROLS, &CrossPointSettings::touchReaderControls,
|
||||
{StrId::STR_STATE_OFF, StrId::STR_STATE_ON}, "touchReaderControls", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Toggle(StrId::STR_FRONT_BTN_FOLLOW_ORIENTATION, &CrossPointSettings::frontButtonFollowOrientation,
|
||||
"frontButtonFollowOrientation", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Enum(StrId::STR_LONG_PRESS_BEHAVIOR, &CrossPointSettings::longPressButtonBehavior,
|
||||
@@ -173,14 +219,16 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION},
|
||||
"longPressButtonBehavior", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Enum(StrId::STR_LONG_PRESS_MENU, &CrossPointSettings::longPressMenuFunction,
|
||||
{StrId::STR_KOSYNC, StrId::STR_DISABLED, StrId::STR_BOOKMARK_OPTION}, "longPressMenuFunction",
|
||||
StrId::STR_CAT_CONTROLS),
|
||||
{StrId::STR_KOSYNC, StrId::STR_DISABLED, StrId::STR_BOOKMARK_OPTION, StrId::STR_DICTIONARY},
|
||||
"longPressMenuFunction", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Enum(
|
||||
StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
|
||||
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_FOOTNOTES},
|
||||
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Toggle(StrId::STR_PWR_BTN_FOOTNOTE_BACK, &CrossPointSettings::pwrBtnFootnoteBack,
|
||||
"pwrBtnFootnoteBack", StrId::STR_CAT_CONTROLS),
|
||||
SettingInfo::Toggle(StrId::STR_BACK_SHORT_TO_FILE_BROWSER, &CrossPointSettings::backShortToFileBrowser,
|
||||
"backShortToFileBrowser", StrId::STR_CAT_CONTROLS),
|
||||
|
||||
// --- System ---
|
||||
SettingInfo::Value(
|
||||
@@ -242,6 +290,14 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
KOREADER_STORE.saveToFile();
|
||||
},
|
||||
"koSendMetadata", StrId::STR_KOREADER_SYNC),
|
||||
SettingInfo::DynamicEnum(
|
||||
StrId::STR_SYNC_BEHAVIOR, {StrId::STR_ASK_EVERY_TIME, StrId::STR_SMART_SYNC},
|
||||
[] { return static_cast<uint8_t>(KOREADER_STORE.getSyncBehavior()); },
|
||||
[](uint8_t v) {
|
||||
KOREADER_STORE.setSyncBehavior(static_cast<KOReaderSyncBehavior>(v));
|
||||
KOREADER_STORE.saveToFile();
|
||||
},
|
||||
"koSyncBehavior", StrId::STR_KOREADER_SYNC),
|
||||
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
|
||||
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount,
|
||||
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
@@ -292,11 +348,30 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
}();
|
||||
|
||||
std::vector<SettingInfo> v = baseList;
|
||||
if (!BoardConfig::hasTouch()) {
|
||||
v.erase(std::remove_if(v.begin(), v.end(),
|
||||
[](const SettingInfo& s) {
|
||||
return s.nameId == StrId::STR_TOUCH_READER_CONTROLS ||
|
||||
s.nameId == StrId::STR_SUNLIGHT_FADING_FIX;
|
||||
}),
|
||||
v.end());
|
||||
}
|
||||
if (BoardConfig::hasTouch()) {
|
||||
v.erase(std::remove_if(v.begin(), v.end(),
|
||||
[](const SettingInfo& s) { return s.nameId == StrId::STR_FRONT_BTN_FOLLOW_ORIENTATION; }),
|
||||
v.end());
|
||||
}
|
||||
if (registry && registry->getFamilyCount() > 0) {
|
||||
auto it = std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_FONT_FAMILY; });
|
||||
if (it != v.end()) {
|
||||
*it = buildFontFamilySetting(registry);
|
||||
}
|
||||
}
|
||||
if (dictionaries && !dictionaries->empty()) {
|
||||
// Insert at the end of the Reader category (just before the first Controls entry).
|
||||
auto it =
|
||||
std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.category == StrId::STR_CAT_CONTROLS; });
|
||||
v.insert(it, buildDictionarySetting(*dictionaries));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -22,3 +22,20 @@ void Activity::startActivityForResult(std::unique_ptr<Activity>&& activity, Acti
|
||||
void Activity::setResult(ActivityResult&& result) { this->result = std::move(result); }
|
||||
|
||||
void Activity::finish() { activityManager.popActivity(); }
|
||||
|
||||
Activity::ListTouchResult Activity::handleListTouch(int& selectedIndex, const int itemCount, const int listTop,
|
||||
const int listHeight, const bool hasSubtitle) {
|
||||
int touched = -1;
|
||||
if (mappedInput.wasListItemTouchedDown(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
|
||||
if (selectedIndex != touched) {
|
||||
selectedIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
return ListTouchResult::Consumed;
|
||||
}
|
||||
if (mappedInput.wasListItemTapped(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
|
||||
selectedIndex = touched;
|
||||
return ListTouchResult::Activated;
|
||||
}
|
||||
return ListTouchResult::None;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ class Activity {
|
||||
virtual bool skipLoopDelay() { return false; }
|
||||
virtual bool preventAutoSleep() { return false; }
|
||||
virtual bool isReaderActivity() const { return false; }
|
||||
virtual bool isHomeActivity() const { return false; }
|
||||
virtual bool handleHomeGesture() { return false; }
|
||||
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
|
||||
|
||||
// Start a new activity without destroying the current one
|
||||
@@ -60,4 +62,16 @@ class Activity {
|
||||
// TODO: remove this in near future
|
||||
void onGoHome(HomeMenuItem item = HomeMenuItem::NONE);
|
||||
void onSelectBook(const std::string& path);
|
||||
|
||||
protected:
|
||||
enum class ListTouchResult : uint8_t {
|
||||
None, // touch did not hit the list
|
||||
Consumed, // touchdown moved the highlight (repaint already requested)
|
||||
Activated // tap landed on a row: selectedIndex is updated, caller activates it
|
||||
};
|
||||
|
||||
// Shared touch handling for selectable list screens: touchdown highlights the
|
||||
// touched row, a tap selects and reports Activated. The caller supplies the
|
||||
// list band and runs its own activate action on Activated.
|
||||
ListTouchResult handleListTouch(int& selectedIndex, int itemCount, int listTop, int listHeight, bool hasSubtitle);
|
||||
};
|
||||
|
||||
@@ -22,12 +22,17 @@
|
||||
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void ActivityManager::begin() {
|
||||
#if defined(configNUM_CORES) && configNUM_CORES > 1
|
||||
constexpr BaseType_t renderTaskCore = 1;
|
||||
#else
|
||||
constexpr BaseType_t renderTaskCore = 0;
|
||||
#endif
|
||||
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&renderTaskHandle, // Task handle
|
||||
0 // Pin to core 0 (PRO_CPU)
|
||||
renderTaskCore // Keep long renders/cover decodes off CPU 0's idle watchdog when available
|
||||
);
|
||||
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
||||
}
|
||||
@@ -61,6 +66,14 @@ void ActivityManager::renderTaskLoop() {
|
||||
|
||||
void ActivityManager::loop() {
|
||||
if (currentActivity) {
|
||||
if (!currentActivity->isHomeActivity() && mappedInput.wasHomeGesture()) {
|
||||
if (currentActivity->handleHomeGesture()) {
|
||||
return;
|
||||
}
|
||||
goHome();
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: do not hold a lock here, the loop() method must be responsible for acquire one if needed
|
||||
currentActivity->loop();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "components/icons/search24.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
#include "util/BookCacheUtils.h"
|
||||
@@ -23,8 +24,22 @@
|
||||
|
||||
namespace {
|
||||
constexpr int PAGE_ITEMS = 23;
|
||||
constexpr int HEADER_Y = 15;
|
||||
constexpr int HEADER_X = 16;
|
||||
constexpr int SEARCH_ICON_SIZE = 24;
|
||||
constexpr int SEARCH_ICON_MARGIN = 14;
|
||||
constexpr int SEARCH_ICON_Y = 15;
|
||||
constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5;
|
||||
constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000;
|
||||
|
||||
Rect searchIconRect(const GfxRenderer& renderer) {
|
||||
return Rect{renderer.getScreenWidth() - SEARCH_ICON_SIZE - SEARCH_ICON_MARGIN, SEARCH_ICON_Y, SEARCH_ICON_SIZE + 8,
|
||||
SEARCH_ICON_SIZE + 8};
|
||||
}
|
||||
|
||||
bool contains(const Rect& rect, const int x, const int y) {
|
||||
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void OpdsBookBrowserActivity::onEnter() {
|
||||
@@ -72,7 +87,9 @@ void OpdsBookBrowserActivity::loop() {
|
||||
}
|
||||
|
||||
if (state == BrowserState::ERROR) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(tx, ty)) {
|
||||
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = tr(STR_LOADING);
|
||||
@@ -97,18 +114,59 @@ void OpdsBookBrowserActivity::loop() {
|
||||
if (state == BrowserState::DOWNLOADING) return;
|
||||
|
||||
if (state == BrowserState::BROWSING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
auto activateSelected = [this] {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
|
||||
}
|
||||
};
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
if (!searchTemplate.empty() && selectorIndex == 0) launchSearch();
|
||||
}
|
||||
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (!searchTemplate.empty() && mappedInput.wasScreenTapped(tx, ty) && contains(searchIconRect(renderer), tx, ty)) {
|
||||
launchSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entries.empty()) {
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, /*top=*/60, /*rowStep=*/30, PAGE_ITEMS);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = selectorIndex / PAGE_ITEMS * PAGE_ITEMS + row;
|
||||
if (touched >= 0 && touched < static_cast<int>(entries.size())) {
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectorIndex != touched) {
|
||||
selectorIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectorIndex = touched;
|
||||
activateSelected();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
|
||||
requestUpdate();
|
||||
@@ -136,7 +194,14 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
|
||||
// Show server name in header if available, otherwise generic title
|
||||
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
|
||||
const int headerRightInset = searchTemplate.empty() ? HEADER_X : (SEARCH_ICON_SIZE + SEARCH_ICON_MARGIN * 2 + 8);
|
||||
const auto clippedHeader =
|
||||
renderer.truncatedText(UI_12_FONT_ID, headerTitle, pageWidth - HEADER_X - headerRightInset, EpdFontFamily::BOLD);
|
||||
renderer.drawText(UI_12_FONT_ID, HEADER_X, HEADER_Y, clippedHeader.c_str(), true, EpdFontFamily::BOLD);
|
||||
if (!searchTemplate.empty()) {
|
||||
const auto rect = searchIconRect(renderer);
|
||||
renderer.drawIcon(Search24Icon.bits, rect.x + 4, rect.y + 4, Search24Icon.w);
|
||||
}
|
||||
|
||||
if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
@@ -149,6 +214,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
if (state == BrowserState::ERROR) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG));
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
|
||||
if (mappedInput.hasTouch()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, tr(STR_TAP_TO_RETRY));
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -20,7 +20,9 @@ void CrashActivity::onEnter() {
|
||||
}
|
||||
|
||||
void CrashActivity::loop() {
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back)) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +205,12 @@ void FileBrowserActivity::loop() {
|
||||
|
||||
const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing;
|
||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved);
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
auto activateSelected = [this] {
|
||||
if (lockNextConfirmRelease) {
|
||||
lockNextConfirmRelease = false;
|
||||
return;
|
||||
@@ -234,6 +238,11 @@ void FileBrowserActivity::loop() {
|
||||
const std::string fullPath = cleanBasePath + entry;
|
||||
|
||||
auto handler = [this, fullPath](const ActivityResult& res) {
|
||||
// The confirmation popup acts on button press; if that button is still
|
||||
// held when we resume, swallow its release so it doesn't also act here
|
||||
// (Back would go up a directory, Confirm would open the selection).
|
||||
lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back);
|
||||
lockNextConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm);
|
||||
if (!res.isCancelled) {
|
||||
LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str());
|
||||
if (removeDirFile(fullPath)) {
|
||||
@@ -273,6 +282,19 @@ void FileBrowserActivity::loop() {
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
int touchSel = static_cast<int>(selectorIndex);
|
||||
const auto listTouch = handleListTouch(touchSel, static_cast<int>(files.size()), contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectorIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -303,6 +325,18 @@ void FileBrowserActivity::loop() {
|
||||
}
|
||||
|
||||
int listSize = static_cast<int>(files.size());
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, listSize] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
|
||||
requestUpdate();
|
||||
|
||||
@@ -168,6 +168,34 @@ void HomeActivity::freeCoverBuffer() {
|
||||
|
||||
void HomeActivity::loop() {
|
||||
const int menuCount = getMenuItemCount();
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
auto activateSelection = [this] {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
return;
|
||||
}
|
||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
buttonNavigator.onNext([this, menuCount] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
||||
@@ -179,31 +207,72 @@ void HomeActivity::loop() {
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
} else {
|
||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) backPressSeen = true;
|
||||
|
||||
// Back is otherwise unused on the home menu: open the most recently read
|
||||
// book directly (recentBooks is most-recent-first and already pruned of
|
||||
// files missing from the SD card). backPressSeen guards against the stale
|
||||
// release of the Back press that closed the previous activity.
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && backPressSeen && !recentBooks.empty()) {
|
||||
onSelectBook(recentBooks[0].path);
|
||||
return;
|
||||
}
|
||||
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (!recentBooks.empty() && mappedInput.wasScreenTouchDown(tx, ty) && tx >= 0 && tx < renderer.getScreenWidth() &&
|
||||
ty >= metrics.homeTopPadding && ty < metrics.homeTopPadding + metrics.homeCoverTileHeight) {
|
||||
if (selectorIndex != 0) {
|
||||
selectorIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recentBooks.empty() &&
|
||||
mappedInput.wasTapInRect(0, metrics.homeTopPadding, renderer.getScreenWidth(), metrics.homeCoverTileHeight)) {
|
||||
selectorIndex = 0;
|
||||
activateSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int menuTop = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset;
|
||||
const int renderedMenuSelection =
|
||||
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size();
|
||||
const int renderedMenuCount =
|
||||
menuCount - (metrics.homeContinueReadingInMenu ? 0 : static_cast<int>(recentBooks.size()));
|
||||
int menuRow = -1;
|
||||
const auto menuTouch = mappedInput.rowTouch(menuRow, menuTop, metrics.menuRowHeight + metrics.menuSpacing,
|
||||
renderedMenuCount, 0, INT32_MAX, metrics.menuRowHeight);
|
||||
if (menuTouch != MappedInputManager::RowTouch::None) {
|
||||
const int touchedIndex =
|
||||
metrics.homeContinueReadingInMenu ? menuRow : menuRow + static_cast<int>(recentBooks.size());
|
||||
if (menuTouch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectorIndex != touchedIndex) {
|
||||
selectorIndex = touchedIndex;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectorIndex = touchedIndex;
|
||||
activateSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelection();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +325,8 @@ void HomeActivity::render(RenderLock&&) {
|
||||
[&menuItems](int index) { return std::string(menuItems[index]); },
|
||||
[&menuIcons](int index) { return menuIcons[index]; });
|
||||
|
||||
const auto labels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
const auto labels = mappedInput.mapLabels(recentBooks.empty() ? "" : tr(STR_RESUME), tr(STR_SELECT), tr(STR_DIR_UP),
|
||||
tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -18,6 +18,9 @@ class HomeActivity final : public Activity {
|
||||
bool hasOpdsServers = false;
|
||||
bool coverRendered = false; // Track if cover has been rendered once
|
||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
||||
// Home can be entered while Back is still held (e.g. leaving Settings with
|
||||
// Back): ignore that stale release until a fresh press is seen here.
|
||||
bool backPressSeen = false;
|
||||
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
||||
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
|
||||
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
|
||||
@@ -77,4 +80,5 @@ class HomeActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isHomeActivity() const override { return true; }
|
||||
};
|
||||
|
||||
@@ -43,6 +43,10 @@ void RecentBooksActivity::onExit() {
|
||||
|
||||
void RecentBooksActivity::loop() {
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
// After a long-press has fired, swallow input until Confirm is physically released
|
||||
// (so the release doesn't also open the book; re-arm only once the button is up).
|
||||
@@ -71,11 +75,34 @@ void RecentBooksActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
int touchSel = static_cast<int>(selectorIndex);
|
||||
const auto listTouch =
|
||||
handleListTouch(touchSel, static_cast<int>(recentBooks.size()), contentTop, contentHeight, true);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectorIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) {
|
||||
LOG_DBG("RBA", "Tapped recent book: %s", recentBooks[selectorIndex].path.c_str());
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoHome();
|
||||
}
|
||||
|
||||
int listSize = static_cast<int>(recentBooks.size());
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, listSize] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "SilentRestart.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/TaskWatchdog.h"
|
||||
|
||||
namespace {
|
||||
constexpr const char* HOSTNAME = "crosspoint";
|
||||
@@ -110,12 +110,12 @@ void CalibreConnectActivity::loop() {
|
||||
LOG_DBG("CAL", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
|
||||
}
|
||||
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
constexpr int MAX_ITERATIONS = 80;
|
||||
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
|
||||
webServer->handleClient();
|
||||
if ((i & 0x07) == 0x07) {
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
}
|
||||
if ((i & 0x0F) == 0x0F) {
|
||||
yield();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
@@ -17,6 +16,7 @@
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/QrUtils.h"
|
||||
#include "util/TaskWatchdog.h"
|
||||
|
||||
namespace {
|
||||
// AP Mode configuration
|
||||
@@ -328,7 +328,7 @@ void CrossPointWebServerActivity::loop() {
|
||||
}
|
||||
|
||||
// Reset watchdog BEFORE processing - HTTP header parsing can be slow
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
|
||||
// Process HTTP requests in tight loop for maximum throughput
|
||||
// More iterations = more data processed per main loop cycle
|
||||
@@ -337,7 +337,7 @@ void CrossPointWebServerActivity::loop() {
|
||||
webServer->handleClient();
|
||||
// Reset watchdog every 32 iterations
|
||||
if ((i & 0x1F) == 0x1F) {
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
}
|
||||
// Yield and check for exit button every 64 iterations
|
||||
if ((i & 0x3F) == 0x3F) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user