Compare commits

...
Author SHA1 Message Date
oreglio f45118c48e feat: add the library text core — unicode fold, filename parse, author keys (#2885) 2026-08-13 22:45:11 +03:00
Foulad f449ce9e7d fix: narrow pr-formatting-check.yml to pull_request_target only (#3006) 2026-08-13 19:52:26 +03:00
Thiago Kenji Okada 48e39eb0ee fix: prevent reader resume position rollback (#2962) 2026-08-13 12:05:01 +00:00
Justin Mitchell 9b1fb712de fix: Guard GPIO13 power control for Xteink C3 boards only (#2998) 2026-08-12 22:06:52 -04:00
Justin Mitchell e715532f90 fix: settings UI not updating after language change & line height padding (#2997) 2026-08-12 21:32:03 -04:00
Brian Pugh 70faa29dac perf: light-sleep idle + refresh downclock — ~3.2x active reading time (X3-measured) (#2525)
## Summary

Reduces active-reading power draw ~3.2x, measured end-to-end on an X3
with a [Nordic
PPK2](https://www.nordicsemi.com/Products/Development-hardware/Power-Profiler-Kit-2)
at the battery terminals (slim build, USB cable removed, 3.8v). Three
independent changes:

1. **Light-sleep between input polls when idle** (the big one): after 1
s of inactivity the main loop replaces its busy `delay(50)` with 50 ms
timer-paced `esp_light_sleep_start()` slices. Poll cadence and input
latency are unchanged — the CPU sleeps between polls instead of spinning
at 10 MHz. Guards: never sleeps while a `HalPowerManager::Lock` is held
(render in flight), WiFi is up, or USB is connected (light sleep kills
the CDC link).
2. **Two-stage idle backoff**: downclock at 500 ms, light sleep at 1 s,
replacing the flat 3 s full-speed window. Renders re-raise the clock via
`Lock` the moment they start, so the full-speed tail only ever served
loop bookkeeping. (The 3 s value in #852 was a placeholder;
responsiveness verified on device against stock.)
3. **Refresh BUSY-wait downclock**: drops the CPU to 10 MHz while
`pollBusy()` polls the BUSY pin (0.3–2 s per refresh), via new optional
hooks in the SDK — **depends on Free-Ink/freeink-sdk/pull/8**. Plus a 1
Hz throttle on the X3's I2C-based USB detection (was polled every loop).

(1+2) reduced average static-page-current from 9.68mA to 3.45mA.
Reducing the battery-monitor polling frequency further reduced it to
2.78mA.

Before merging, we'll need to update the submodule reference.

## Measured results (PPK2, X3)

| Metric | Before | After |
|---|---|---|
| Idle on a static page | 9.68 mA | **2.78 mA** |
| Fast page turn (incl. post-turn behavior) | ~100 mC | **28.98 mC** |
| Post-turn 160 MHz tail | 21.2 mA x 3 s | eliminated |
| Session average @ 30 s/page | ~11.6 mA | **~3.6 mA (~3.2x reading
time)** |
| Deep sleep | 12.8 µA | unchanged |

## Typical-day profiles

Assuming someone spends 30s per page.

- **Light**: 1 h reading, 10 lock/wake cycles, remaining ~23 h deep
sleep
- **Heavy**: 2 h reading, 20 lock/wake cycles, remaining ~22 h deep
sleep
- Per-day cost components: reading 11.6 → 3.6 mA (session avg),
lock+wake cycle
0.113 → 0.099 mAh each (sleep-screen mode), deep sleep 12.8 µA
(unchanged,
  ~0.29 mAh/day)
- Excluded: WiFi sessions, cold chapter indexing, self-discharge

### Days per charge (650mAh)

| Profile | Before | After | Ratio |
|---|---|---|---|
| Light (1 h/day, 10 locks) | 13.0 mAh/day → ~50 days | 4.9 mAh/day →
~133 days | 2.7× |
| Heavy (2 h/day, 20 locks) | 25.7 mAh/day → ~25 days | 9.5 mAh/day →
~68 days | 2.7× |
| Pure active reading | 11.6 mA → ~56 h | 3.6 mA → ~180 h | 3.2× |

### Caveats

- "After" saturates in reality: Li-ion self-discharge (~2–3%/month) is
not modeled
  and becomes the limiter once firmware draw is this low.
- Faster page-turners trend toward ~2.8×, slower readers slightly above
3.2×.
- Frequent locking dilutes the ratio (wake = full cold boot, barely
touched by the
power branch): at 10–20 locks/day, cycles are ~20% of the "after"
budget.
---

### AI Usage

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

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

---------
2026-08-12 20:57:45 -04:00
Leopoldo Pla SempereandJulia Nguyen cb995809ba fix: backport File Transfer guard from CrossInk (#2990)
Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-08-13 00:19:03 +03:00
Uri Tauber f16451c729 feat: render HTML dictionary definitions via EPUB engine (#2836) 2026-08-12 17:17:25 -04:00
Leopoldo Pla SempereandPablo Cabeza 268c27f7d0 feat: add transparent sleep overlays (#2937)
Co-authored-by: Pablo Cabeza <josepablocg@gmail.com>
2026-08-12 23:21:43 +03:00
Uri Tauber b1d53ba810 feat: add orangutan translation (#2992)
Translated by Dr Horace Worblehat, B. Thau, D.M.
2026-08-12 22:18:39 +03:00
46 changed files with 2670 additions and 293 deletions

No files matched your search

+1 -2
View File
@@ -1,7 +1,6 @@
name: "PR Formatting"
on:
pull_request:
pull_request_target:
types:
- opened
@@ -9,7 +8,7 @@ on:
- edited
permissions:
statuses: write
pull-requests: read
jobs:
title-check:
+1 -1
View File
@@ -36,7 +36,7 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f
- OPDS browser with saved servers (up to 8), search, pagination, and direct download
- OTA update checks and installs from GitHub releases
- **Customization**: multiple themes (Classic, Lyra, Lyra Extended, RoundedRaff), sleep screen modes, front/side button remapping, status bar controls, power-button behavior, refresh cadence, and more.
- **Customization**: multiple themes (Classic, Lyra, Lyra Extended, RoundedRaff), sleep screen modes including transparent overlays, front/side button remapping, status bar controls, power-button behavior, refresh cadence, and more.
- **Localization**: 24 UI languages and counting. RTL support.
+14 -4
View File
@@ -186,7 +186,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- "None" - A blank screen
- "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise
- "Quick resume" - The text of the last page read will be displayed on the sleep screen and a moon icon is shown on the edge of the screen. Waking up the device will return to the same page of the opened book. This is useful for quickly resuming reading without waiting for the device to fully wake up and load the book.
- "Transparent" - A transparent overlay image drawn over the current screen; see [Sleep Screen](#37-sleep-screen) below for more information
- **Sleep Screen Cover Mode**: How to display the book cover when "Cover" sleep screen is selected:
- "Fit" (default) - Scale the image down to fit centered on the screen, padding with white borders as necessary
@@ -501,6 +501,7 @@ The **Sleep Screen** setting controls what is displayed when the device goes to
| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. |
| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. |
| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. |
| **Transparent** | A BMP overlay drawn over the current screen. Supports alpha transparency in 32-bit BGRA images and treats white as transparent in regular BMPs. Falls back to **Dark** if no valid overlay image is found. |
| **None** | A blank screen. |
#### Cover settings
@@ -515,12 +516,21 @@ When using **Cover** or **Cover + Custom**, two additional settings apply:
To use custom sleep images, set the sleep screen mode to **Custom** or **Cover + Custom**, then place images on the SD card:
- **Multiple Images (recommended):** Create a `.sleep` directory in the root of the SD card and place any number of `.bmp` images inside. One will be randomly selected each time the device sleeps. (A directory named `sleep` is also accepted as a fallback.)
- **Single Image:** Place a file named `sleep.bmp` in the root directory. This is used as a fallback if no valid images are found in the `.sleep`/`sleep` directory.
- **Single Image:** Place a file named `sleep.bmp` in the root directory. This takes priority over the `.sleep`/`sleep` directories.
#### Transparent overlay images
To use transparent sleep overlays, set the sleep screen mode to **Transparent**, then place BMP files on the SD card:
- **Multiple Images (recommended):** Create a `.sleep-overlay` directory in the root of the SD card and place any number of valid overlay `.bmp` images inside. One will be randomly selected each time the device sleeps. A directory named `sleep-overlay` is also accepted as a fallback.
- **Single Image:** Place a file named `sleep-overlay.bmp` in the root directory. This takes priority over the `.sleep-overlay`/`sleep-overlay` directories.
Transparent overlay files are intentionally separate from normal sleep images. Regular BMP formats supported by CrossPoint are accepted; white pixels leave the existing screen unchanged. For per-pixel alpha transparency, use a 32-bit BGRA BMP with both visible and non-opaque pixels. Opaque white pixels in these alpha images erase the content behind them.
> [!TIP]
> For best results:
>
> - Use uncompressed BMP files with 24-bit color depth
> - For non-transparent **Custom** mode, use uncompressed BMP files with 24-bit color depth.
> - For **Transparent** mode, use a regular BMP for white-as-transparent artwork or an uncompressed 32-bit BGRA BMP for per-pixel alpha.
> - X4: Use a resolution of 480x800 pixels to match the device's screen resolution.
> - X3: Use a resolution of 528x792 pixels to match the device's screen resolution.
+3 -1
View File
@@ -13,7 +13,7 @@ A dictionary folder must contain:
- `.syn` — synonym index (optional; maps alternate spellings and irregular forms to their headword)
- `.ifo` — metadata (optional)
Not supported: 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.
Not supported: dictionaries with 64-bit index offsets (`idxoffsetbits=64` in the `.ifo` — rare, and rejected with an error).
## Setting Up a Dictionary
@@ -49,6 +49,8 @@ On the very first lookup with a dictionary (and again whenever the `.idx` or `.s
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.
HTML dictionaries that declare `sametypesequence=h` use the EPUB text-layout engine for semantic formatting such as headings, bold, italics, lists, and line breaks. Images and CSS styling are ignored. Definitions that are too large or cannot be laid out within the available memory fall back to plain text.
- **Left/Right** or side **Up/Down** — previous / next page
- **Back** — return to word selection
+1
View File
@@ -278,6 +278,7 @@ STR_BOOKMARK_REMOVED: "S'ha eliminat el punt de llibre."
STR_OPDS_BROWSER: "Navegador OPDS"
STR_COVER_CUSTOM: "Portada + Personalitzat"
STR_QUICK_RESUME: "Represa ràpida"
STR_TRANSPARENT: "Transparent"
STR_MENU_RECENT_BOOKS: "Llibres recents"
STR_REMOVE_FROM_RECENTS: "Vols suprimir-lo de Llibres recents?"
STR_NO_RECENT_BOOKS: "No hi ha llibres recents"
+1
View File
@@ -306,6 +306,7 @@ STR_OPDS_BROWSER: "OPDS Browser"
STR_SEARCH: "Search"
STR_COVER_CUSTOM: "Cover + Custom"
STR_QUICK_RESUME: "Quick Resume"
STR_TRANSPARENT: "Transparent"
STR_MENU_RECENT_BOOKS: "Recent Books"
STR_REMOVE_FROM_RECENTS: "Remove from Recent Books?"
STR_NO_RECENT_BOOKS: "No recent books"
+328
View File
@@ -0,0 +1,328 @@
_language_name: "Orangutan"
_language_code: "OOK"
_order: "34"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "OOK"
STR_SLEEPING: "Oooook"
STR_ENTERING_SLEEP: "Ook..."
STR_BROWSE_FILES: "Ook? OOK"
STR_FILE_TRANSFER: "Oook!"
STR_SETTINGS_TITLE: "OOK!"
STR_CONTINUE_READING: "Oooook ook"
STR_RESUME: "OOk!"
STR_NO_OPEN_BOOK: "OOK?"
STR_START_READING: "Ook ook"
STR_NO_FILES_FOUND: "Oook?"
STR_SELECT_CHAPTER: "Ook? Oook OOK!"
STR_NO_CHAPTERS: "Ook..."
STR_END_OF_BOOK: "Oooook."
STR_EMPTY_CHAPTER: "OOk?"
STR_INDEXING: "Oook..."
STR_LOADING: "Ooook..."
STR_LOADING_POPUP: "Oook"
STR_LOOKUP: "Ook? Oook! OOK"
STR_WIFI_NETWORKS: "Wi-Fi OOK"
STR_NO_NETWORKS: "Oook?"
STR_NETWORKS_FOUND: "%zu OOK"
STR_SCANNING: "Oook..."
STR_FINDING_SAVED_WIFI: "Oook Wi-Fi..."
STR_CONNECTING: "Ooook..."
STR_CONNECTING_SAVED_WIFI: "OOk Wi-Fi..."
STR_SHOW_NETWORKS: "OOK"
STR_CONNECTED: "Oook!"
STR_CONNECTION_FAILED: "OOk?"
STR_FORGET_NETWORK: "Oook?"
STR_SAVE_PASSWORD: "OOK?"
STR_PRESS_OK_SCAN: "OOk OK"
STR_JOIN_NETWORK: "Oook!"
STR_CREATE_HOTSPOT: "Ooook"
STR_JOIN_DESC: "Oook Wi-Fi"
STR_HOTSPOT_DESC: "Oooook Wi-Fi"
STR_STARTING_HOTSPOT: "Ooook..."
STR_HOTSPOT_MODE: "OOk!"
STR_CONNECT_WIFI_HINT: "Oook Wi-Fi"
STR_OPEN_URL_HINT: "Oook URL"
STR_SCAN_QR_HINT: "Oook QR:"
STR_CALIBRE_WIRELESS: "Calibre OOK"
STR_NETWORK_LEGEND: "* = OOK | + = OOk"
STR_MAC_ADDRESS: "MAC!"
STR_CHECKING_WIFI: "OOk Wi-Fi..."
STR_ENTER_WIFI_PASSWORD: "Oook Wi-Fi"
STR_ADD_HIDDEN_NETWORK: "Oook..."
STR_ENTER_WIFI_SSID: "OOk (SSID)"
STR_TO_PREFIX: "Oo "
STR_CALIBRE_RECEIVING: "Oook: "
STR_CALIBRE_RECEIVED: "OOK!: "
STR_CAT_DISPLAY: "OOK"
STR_CAT_READER: "OOk"
STR_CAT_CONTROLS: "Ook!"
STR_CAT_SYSTEM: "Oook"
STR_SLEEP_SCREEN: "Oooook"
STR_NIGHT_MODE: "Ook!"
STR_QUICK_RESUME_TIMEOUT: "Oook ook"
STR_SLEEP_COVER_MODE: "Ook ook"
STR_HIDE_BATTERY: "OOk %"
STR_EXTRA_SPACING: "Oook ook"
STR_TEXT_AA: "Ook-ook"
STR_IMAGES: "OOk!"
STR_IMAGES_DISPLAY: "Oook"
STR_IMAGES_PLACEHOLDER: "Ooook"
STR_IMAGES_SUPPRESS: "OOK"
STR_EOB_HOME: "Oook"
STR_EOB_CONTINUE_WITH: "Oook ook"
STR_SHORT_PWR_BTN: "Oooook OOK"
STR_ORIENTATION: "Ook? Oooook OOK"
STR_SIDE_BTN_LAYOUT: "Oook ook"
STR_TOUCH_READER_CONTROLS: "OOk! OOK"
STR_FRONT_BTN_FOLLOW_ORIENTATION: "Oook OOK"
STR_LONG_PRESS_BEHAVIOR: "Oooook OOK"
STR_LONG_PRESS_BEHAVIOR_OFF: "OFF"
STR_LONG_PRESS_BEHAVIOR_SKIP: "Oook!"
STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ook? OOK"
STR_LONG_PRESS_MENU: "Oooook!"
STR_FONT_PREVIEW_TEXT: "Ook ook ook."
STR_FONT_FAMILY: "Oook OOK"
STR_FONT_SIZE: "OOk OOK"
STR_TEXT_SETTINGS: "Oooook OOK: Ook!"
STR_FONT: "OOk!"
STR_SIZE: "OOK"
STR_LAYOUT: "Oook"
STR_ALIGNMENT: "Oooook"
STR_STYLE: "OOk!"
STR_NOT_IN_PREVIEW: "Oook?"
STR_LINE_SPACING: "Oook ook"
STR_SCREEN_MARGIN: "OOk ook"
STR_PARA_ALIGNMENT: "Ook OOK"
STR_HYPHENATION: "Ooook"
STR_TIME_TO_SLEEP: "Oook..."
STR_SHOW_HIDDEN_FILES: "OOk?"
STR_REMOVE_READ_FROM_RECENTS: "Oook OOK"
STR_MOVE_FINISHED_TO_READ: "Ook! OOK"
STR_REFRESH_FREQ: "Oook!"
STR_KOREADER_SYNC: "KOReader OOK"
STR_CHECK_UPDATES: "Oook!"
STR_LANGUAGE: "OOk?"
STR_CLEAR_READING_CACHE: "Oook OOK"
STR_USERNAME: "Oook"
STR_PASSWORD: "OOk!"
STR_SYNC_SERVER_URL: "Oook URL"
STR_DOCUMENT_MATCHING: "Oook OOK"
STR_SEND_METADATA: "Ook! OOK"
STR_SYNC_BEHAVIOR: "OOk ook"
STR_ASK_EVERY_TIME: "Oook?"
STR_SMART_SYNC: "Oook!"
STR_AUTHENTICATE: "Oooook"
STR_SIGN_UP: "OOk!"
STR_KOREADER_USERNAME: "KOReader OOK"
STR_KOREADER_PASSWORD: "KOReader OOk"
STR_FILENAME: "Oook"
STR_KOREADER_AUTH: "KOReader OOK"
STR_DONE: "Oook"
STR_DARK: "OOk!"
STR_LIGHT: "Oook"
STR_CUSTOM: "Ooook"
STR_COVER: "OOk?"
STR_NONE_OPT: "OOk!"
STR_FIT: "OOk"
STR_CROP: "OOK!"
STR_NEVER: "Oook!"
STR_IN_READER: "Ook? OOK"
STR_ALWAYS: "Oooook"
STR_IGNORE: "OOk?"
STR_SLEEP: "Oook!"
STR_PAGE_TURN: "Oook OOK"
STR_FORCE_REFRESH: "Ook! OOK"
STR_PORTRAIT: "Oook"
STR_LANDSCAPE_CW: "Oook OOK"
STR_INVERTED: "OOk!"
STR_ORIENTATION_INVERTED: "Ook 180°"
STR_LANDSCAPE_CCW: "OOk OOK"
STR_PREV_NEXT: "OOk/OOK"
STR_NEXT_PREV: "OOK/OOk"
STR_KOSYNC: "KOSync"
STR_BOOKMARK_OPTION: "Oook!"
STR_DICTIONARY: "Oooook"
STR_DISABLED: "OOk!"
STR_NOTO_SERIF: "Noto OOK"
STR_NOTO_SANS: "Noto OOk"
STR_TIGHT: "OOk!"
STR_NORMAL: "Oook"
STR_WIDE: "OOk!"
STR_JUSTIFY: "Ooook!"
STR_ALIGN_LEFT: "OOk"
STR_CENTER: "Oook"
STR_ALIGN_RIGHT: "OOK!"
STR_PAGES_1: "1 OOK"
STR_PAGES_5: "5 OOk"
STR_PAGES_10: "10 OOK"
STR_PAGES_15: "15 OOk"
STR_PAGES_30: "30 OooK"
STR_UPDATE: "Oook!"
STR_CURRENT_VERSION: "OOK: "
STR_NEW_VERSION: "OOk: "
STR_DOWNLOADING: "Oooook..."
STR_UNNAMED: "OOk?"
STR_HOLD_OPEN_TO_DELETE: "Oook OOK"
STR_NEXT_PAGE: "OOk »"
STR_PREV_PAGE: "« OOK"
STR_NETWORK_PREFIX: "OOk: "
STR_IP_ADDRESS_PREFIX: "IP: "
STR_SD_CARD: "SD OOK"
STR_BACK: "« OOK"
STR_EXIT: "« OOk"
STR_HOME: "« Oook"
STR_SELECT: "OOk!"
STR_SELECTED: "Oooook"
STR_TOGGLE: "Oook!"
STR_TOGGLE_BOOKMARK: "Ook? OOK! Oooook"
STR_CONFIRM: "Oooook"
STR_CANCEL: "OOk?"
STR_CONNECT: "Oooook"
STR_OPEN: "Oook"
STR_DOWNLOAD: "Oooook"
STR_RETRY: "Oook!"
STR_YES: "OOk"
STR_NO: "Oo"
STR_SHOW: "Oook"
STR_HIDE: "OOk!"
STR_STATE_ON: "OK"
STR_STATE_OFF: "OOk"
STR_NOT_SET: "Oook"
STR_DIR_LEFT: "OOk!"
STR_DIR_RIGHT: "OOK!"
STR_DIR_UP: "OK"
STR_DIR_DOWN: "OOk!"
STR_OK_BUTTON: "OK"
STR_KEY_SHIFT: "OOk!"
STR_KEY_MODE_SYMBOLS: "?123"
STR_KEY_MODE_ABC: "abc"
STR_SLEEP_COVER_FILTER: "Oook OOK"
STR_FILTER_CONTRAST: "Oooook"
STR_CUSTOMISE_STATUS_BAR: "Oook OOK"
STR_CHAPTER_PAGE_COUNT: "OOk OOK"
STR_BOOK_PROGRESS_PERCENTAGE: "Oook OOK"
STR_PROGRESS_BAR: "Oook OOK"
STR_PROGRESS_BAR_THICKNESS: "Oook OOK"
STR_PROGRESS_BAR_THIN: "OOk!"
STR_PROGRESS_BAR_MEDIUM: "Oook"
STR_PROGRESS_BAR_THICK: "Oook!"
STR_BOOK: "Oook"
STR_CHAPTER: "OOk!"
STR_EXAMPLE_CHAPTER: "OOk 21"
STR_EXAMPLE_BOOK: "Oook OOK"
STR_PREVIEW: "Oook!"
STR_TITLE: "OOk?"
STR_BATTERY: "Oook"
STR_XTC_STATUS_BAR: "XTC OOK"
STR_BOTTOM: "Oooook"
STR_TOP: "OOk"
STR_CLOCK: "Oook!"
STR_CLOCK_UTC_OFFSET: "Oook OOK"
STR_CLOCK_FORMAT: "OOk OOK"
STR_CLOCK_FORMAT_24H: "24-OOK"
STR_CLOCK_FORMAT_12H: "12-OOk"
STR_CURRENT_TIME: "OOk:"
STR_NEXT_FIELD: "Oook"
STR_CLOCK_SYNC: "OOk OOK"
STR_CLOCK_SYNC_NOW: "Oook OOK"
STR_CLOCK_SYNCING: "Oooook..."
STR_CLOCK_SYNC_OK: "Oook!"
STR_CLOCK_SYNC_FAIL: "OOk?"
STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi OOK"
STR_CLOCK_SYNCED: "Oooook"
STR_UI_THEME: "OOk OOK"
STR_THEME_CLASSIC: "Oook!"
STR_THEME_LYRA: "OOk!"
STR_THEME_ROUNDEDRAFF: "Oook"
STR_THEME_LYRA_EXTENDED: "Oook OOK"
STR_SUNLIGHT_FADING_FIX: "Oook OOK"
STR_REMAP_FRONT_BUTTONS: "Oook OOK"
STR_BOOKMARKS: "Ook! OOK Oooook"
STR_NO_BOOKMARKS: "OOk?"
STR_BOOKMARK_ADDED: "Ook!"
STR_BOOKMARK_REMOVED: "Oook!"
STR_OPDS_BROWSER: "OPDS OOK"
STR_SEARCH: "Oook!"
STR_COVER_CUSTOM: "OOk + OOK"
STR_QUICK_RESUME: "Oook OOK"
STR_MENU_RECENT_BOOKS: "Oook OOK"
STR_REMOVE_FROM_RECENTS: "OOk?"
STR_NO_RECENT_BOOKS: "Oook?"
STR_CALIBRE_DESC: "Calibre OOK"
STR_FORGET_AND_REMOVE: "Oook?"
STR_FORGET_BUTTON: "Oook!"
STR_CALIBRE_STARTING: "Calibre OOK"
STR_CALIBRE_SETUP: "OOk!"
STR_CALIBRE_STATUS: "Oook"
STR_CLEAR_BUTTON: "Oook!"
STR_DEFAULT_VALUE: "OOk OOK"
STR_UNASSIGNED: "OOk?"
STR_ALREADY_ASSIGNED: "Oook OOK"
STR_GO_TO_PERCENT: "Oook! Ook? %"
STR_GO_HOME_BUTTON: "Oook OOK! Oooook"
STR_SYNC_PROGRESS: "Oooook OOK! Ook!"
STR_DELETE_CACHE: "Oook OOK? Oooook!"
STR_DELETE: "Oooook"
STR_CONFIRM_DELETE_BOOKMARK: "Oook?"
STR_DISPLAY_QR: "Oooook OOK QR!"
STR_CHAPTER_PREFIX: "OOk: "
STR_BOOK_PREFIX: "Oook: "
STR_SYNCING_TIME: "Oook..."
STR_FETCH_PROGRESS: "OOk..."
STR_UPLOAD_PROGRESS: "Oooook..."
STR_KOREADER_SETUP_HINT: "KOReader OOK"
STR_PROGRESS_FOUND: "Oook!"
STR_REMOTE_LABEL: "OOk:"
STR_LOCAL_LABEL: "Oook:"
STR_PAGE_OVERALL_FORMAT: "OOk %d, %.2f%%"
STR_PAGE_TOTAL_OVERALL_FORMAT: "Oook %d/%d, %.2f%%"
STR_DEVICE_FROM_FORMAT: "OOk: %s"
STR_APPLY_REMOTE: "Oook OOK"
STR_UPLOAD_LOCAL: "OOk OOK"
STR_UPLOAD_PROMPT: "Oook?"
STR_UPLOAD_SUCCESS: "Oooook!"
STR_ALREADY_SYNCED: "Oook!"
STR_SECTION_PREFIX: "OOk "
STR_UPLOAD: "Oooook"
STR_BOOK_S_STYLE: "Oook OOK"
STR_EMBEDDED_STYLE: "OOk OOK"
STR_FOCUS_READING: "Oook OOK"
STR_OPDS_SERVER_URL: "OPDS OOK"
STR_PWR_BTN_FOOTNOTE_BACK: "Oook OOK"
STR_BACK_SHORT_TO_FILE_BROWSER: "OOk OOK"
STR_SET_SLEEP_COVER: "Oook OOK"
STR_FOOTNOTES: "Ook! Oook OOK"
STR_NO_FOOTNOTES: "Oook?"
STR_LINK: "[Ook]"
STR_SCREENSHOT_BUTTON: "Oook! OOK? Ook!"
STR_SLEEP_TIMER_VALUE_FORMAT: "%u OOK"
STR_SLEEP_NEVER: "Oook!"
STR_ADD_SERVER: "Oook!"
STR_SERVER_NAME: "OOk OOK"
STR_DELETE_SERVER: "Oook OOK"
STR_OPDS_SERVERS: "OPDS OOK"
STR_OPDS_DOWNLOAD_FOLDER: "Oook OOK"
STR_OPDS_FILENAME_FORMAT: "OOk OOK"
STR_FMT_AUTHOR_TITLE: "OOk - OOK"
STR_FMT_TITLE_AUTHOR: "OOK - OOk"
STR_FMT_TITLE: "Oook"
STR_OPDS_SD_ROOT: "SD OOK"
STR_AUTO_TURN_ENABLED: "OOk: "
STR_AUTO_TURN_PAGES_PER_MIN: "Oooook OOK (Ook!)"
STR_MANAGE_FONTS: "Oook OOK"
STR_FONT_BROWSER: "OOk OOK"
STR_LOADING_FONT_LIST: "Oooook..."
STR_NO_FONTS_AVAILABLE: "Oook?"
STR_FONT_INSTALLED: "Oook!"
STR_INSTALLED: "OOk!"
STR_DOWNLOAD_ALL: "Oook OOK"
STR_UPDATE_ALL: "OOk OOK"
STR_UPDATE_AVAILABLE: "Oook!"
STR_TILT_PAGE_TURN: "Oook OOK"
STR_SD_FIRMWARE_UPDATE: "SD Oooook Ooook Ooooook"
STR_STEP_HINT_FRONT: "Ooook Ooooook:"
STR_STEP_HINT_SIDE: "OOK Oook:"
+1
View File
@@ -303,6 +303,7 @@ STR_OPDS_BROWSER: "Navegador OPDS"
STR_SEARCH: "Buscar"
STR_COVER_CUSTOM: "Portada + Pers."
STR_QUICK_RESUME: "Reanudación rápida"
STR_TRANSPARENT: "Transparente"
STR_MENU_RECENT_BOOKS: "Libros recientes"
STR_REMOVE_FROM_RECENTS: "¿Eliminar de Libros recientes?"
STR_NO_RECENT_BOOKS: "No hay libros recientes"
+1
View File
@@ -283,6 +283,7 @@ STR_BOOKMARK_REMOVED: "S'ha eliminat el punt de llibre."
STR_OPDS_BROWSER: "Navegador OPDS"
STR_COVER_CUSTOM: "Portada + Personalitzat"
STR_QUICK_RESUME: "Represa ràpida"
STR_TRANSPARENT: "Transparent"
STR_MENU_RECENT_BOOKS: "Llibres recents"
STR_REMOVE_FROM_RECENTS: "Vols eliminar-lo de Llibres recents?"
STR_NO_RECENT_BOOKS: "No hi ha llibres recents"
+346
View File
@@ -0,0 +1,346 @@
#include "LibraryText.h"
#include <Utf8.h>
#include <algorithm>
#include <array>
#include <cstring>
namespace library {
namespace {
// Letters with no canonical decomposition, plus the punctuation that would
// otherwise become a space and break word shapes. Everything here is a distinct
// letter in Unicode, so no amount of NFD gets us the ASCII form.
struct CharMap {
uint32_t cp;
const char* replacement;
};
constexpr CharMap EXPLICIT_MAP[] = {
{0x00D8, "o"}, // Ø
{0x00F8, "o"}, // ø — "Søren" folds to "soren", not "nesb"
{0x00C6, "ae"}, // Æ
{0x00E6, "ae"}, // æ
{0x0152, "oe"}, // Œ
{0x0153, "oe"}, // œ
{0x00DF, "ss"}, // ß
{0x0141, "l"}, // Ł
{0x0142, "l"}, // ł
{0x00D0, "d"}, // Ð
{0x00F0, "d"}, // ð
{0x0110, "d"}, // Đ
{0x0111, "d"}, // đ
{0x00DE, "th"}, // Þ
{0x00FE, "th"}, // þ
{0x0131, "i"}, // ı
{0x0027, "'"}, // ' — kept, not turned into a space, so "O'Brien" stays one word
{0x2019, "'"}, // ’ — and so the curly form folds to the same thing
{0x2018, "'"}, // ‘
};
const char* explicitMapping(const uint32_t cp) {
for (const auto& e : EXPLICIT_MAP) {
if (e.cp == cp) return e.replacement;
}
return nullptr;
}
// Reverse lookup in the NFC table: given a precomposed codepoint, return the
// base letter it was composed from, or 0.
//
// The table is sorted by (base, mark) so this is a linear scan rather than a
// binary search. It runs once per non-ASCII character at index-build time and
// over a short query per keystroke, never over the whole index, so the scan is
// not on any hot path. Reusing the generated table costs no extra flash — it is
// already linked for utf8ComposeNfc().
// Fully decompose: é -> e, and the rare doubly-accented forms (ế -> ê -> e).
uint32_t stripDiacritics(uint32_t cp) {
for (int guard = 0; guard < 4; guard++) {
const uint32_t base = utf8DecomposedBase(cp);
if (base == 0) break;
cp = base;
}
return cp;
}
bool isAsciiAlnum(const uint32_t cp) {
return (cp >= '0' && cp <= '9') || (cp >= 'a' && cp <= 'z') || (cp >= 'A' && cp <= 'Z');
}
void appendLowerAscii(const uint32_t cp, std::string& out) {
out.push_back(static_cast<char>(cp >= 'A' && cp <= 'Z' ? cp - 'A' + 'a' : cp));
}
// Articles stripped from the head of sort and search keys. Display text never
// goes through this.
constexpr const char* ARTICLES[] = {"the ", "a ", "an ", "le ", "la ", "les ", "l'", "un ",
"une ", "de ", "du ", "des ", "der ", "die ", "das ", "el ",
"los ", "las ", "il ", "lo ", "gli ", "i ", "o ", "os "};
// Views into `folded`, not copies: the caller keeps that string alive for as
// long as the tokens, and a std::string per token costs an allocation each plus
// 24 bytes of stack apiece -- 288 B for the twelve, over the 256 B this repo
// asks callers to justify.
void splitTokens(std::string_view folded, std::string_view* out, size_t maxTokens, size_t& count) {
count = 0;
size_t i = 0;
while (i < folded.size() && count < maxTokens) {
while (i < folded.size() && folded[i] == ' ') i++;
const size_t start = i;
while (i < folded.size() && folded[i] != ' ') i++;
if (i > start) out[count++] = folded.substr(start, i - start);
}
}
} // namespace
std::string fold(const std::string_view text, const bool stripArticle) {
std::string out;
out.reserve(text.size());
const auto* cursor = reinterpret_cast<const unsigned char*>(text.data());
const auto* end = cursor + text.size();
bool pendingSpace = false;
while (cursor < end) {
// The shared decoder stops on NUL and every std::string source is
// NUL-terminated, but fold's contract is string_view — and a view may end
// mid-sequence. Refuse to decode a lead byte whose continuation bytes lie
// past the end rather than trusting whatever sits there.
const unsigned char lead = *cursor;
const ptrdiff_t promised = lead < 0x80 ? 1
: (lead >> 5) == 0x06 ? 2
: (lead >> 4) == 0x0E ? 3
: (lead >> 3) == 0x1E ? 4
: 1;
if (promised > end - cursor) break;
const uint32_t cp = utf8NextCodepoint(&cursor);
if (cp == 0) break;
if (utf8IsCombiningMark(cp)) continue; // already-decomposed input
const char* mapped = explicitMapping(cp);
if (mapped != nullptr) {
if (pendingSpace && !out.empty()) out.push_back(' ');
pendingSpace = false;
out.append(mapped);
continue;
}
const uint32_t base = stripDiacritics(cp);
if (isAsciiAlnum(base)) {
if (pendingSpace && !out.empty()) out.push_back(' ');
pendingSpace = false;
appendLowerAscii(base, out);
continue;
}
// Everything else — punctuation, symbols, unmapped scripts — separates
// words. Deferring the space keeps runs collapsed and drops trailing ones.
if (!out.empty()) pendingSpace = true;
}
if (stripArticle) {
for (const char* article : ARTICLES) {
const size_t len = strlen(article);
if (out.size() > len && out.compare(0, len, article) == 0) {
out.erase(0, len);
break;
}
}
}
return out;
}
std::string cleanPersonName(const std::string_view author) {
std::string out;
out.reserve(author.size());
int depth = 0;
for (size_t i = 0; i < author.size(); i++) {
const char c = author[i];
if (c == '[' || c == '(') {
depth++;
continue;
}
if (c == ']' || c == ')') {
if (depth > 0) depth--;
continue;
}
if (c == ';') break; // secondary authors
if (depth > 0) continue;
if (c == '_') {
// "Herbert G_ Wells" — the underscore stands in for a full stop the
// filesystem would not take. Between letters it is an abbreviation dot;
// at the end of a word it is just noise.
const bool betweenLetters = i > 0 && i + 1 < author.size() &&
isalpha(static_cast<unsigned char>(author[i - 1])) &&
isalpha(static_cast<unsigned char>(author[i + 1]));
out.push_back(betweenLetters ? '.' : ' ');
continue;
}
out.push_back(c);
}
while (!out.empty() &&
(out.back() == ' ' || out.back() == ',' || out.back() == '-' || out.back() == '.' || out.back() == '_')) {
out.pop_back();
}
size_t start = 0;
while (start < out.size() && out[start] == ' ') start++;
out.erase(0, start);
// Collapse the space runs left behind by the removals.
std::string collapsed;
collapsed.reserve(out.size());
bool space = false;
for (const char c : out) {
if (c == ' ') {
space = true;
continue;
}
if (space && !collapsed.empty()) collapsed.push_back(' ');
space = false;
collapsed.push_back(c);
}
// "Austen, Jane" is the same person as "Jane Austen", and publishers use both.
// The spelling vote cannot settle it — with one book per author there is no
// majority — so the inverted form is turned round here instead. Only a single
// comma qualifies: "Smith, John, Jr." and lists of several authors are left
// exactly as they are rather than being scrambled.
const size_t comma = collapsed.find(',');
if (comma != std::string::npos && collapsed.find(',', comma + 1) == std::string::npos) {
std::string_view last(collapsed.data(), comma);
std::string_view first(collapsed.data() + comma + 1, collapsed.size() - comma - 1);
while (!first.empty() && first.front() == ' ') first.remove_prefix(1);
while (!last.empty() && last.back() == ' ') last.remove_suffix(1);
if (!first.empty() && !last.empty()) {
std::string swapped;
swapped.reserve(collapsed.size());
swapped.append(first);
swapped.push_back(' ');
swapped.append(last);
return swapped;
}
}
return collapsed;
}
std::string authorKey(const std::string_view author) {
// Drop bracketed spans ("George Sand [Sand, George]") and everything after
// a multi-author separator.
std::string cleaned;
cleaned.reserve(author.size());
int depth = 0;
for (const char c : author) {
if (c == '[' || c == '(') {
depth++;
continue;
}
if (c == ']' || c == ')') {
if (depth > 0) depth--;
continue;
}
if (c == ';') break;
if (depth == 0) cleaned.push_back(c);
}
const std::string folded = fold(cleaned);
constexpr size_t MAX_TOKENS = 12;
std::string_view tokens[MAX_TOKENS];
size_t count = 0;
splitTokens(folded, tokens, MAX_TOKENS, count);
// Initials carry no identity and appear inconsistently ("Herbert G Wells" vs
// "Herbert Wells"), so they must not change the key.
size_t kept = 0;
for (size_t i = 0; i < count; i++) {
if (tokens[i].size() > 1) tokens[kept++] = tokens[i];
}
std::sort(tokens, tokens + kept);
std::string key;
for (size_t i = 0; i < kept; i++) {
if (!key.empty()) key.push_back(' ');
key.append(tokens[i]);
}
// Truncate on bytes, not on a token boundary. Sorting puts a short forename
// first, so a whole-token cut would reduce "Wollstonecraft, Mary" to the key
// "alex" and merge every Alex in the library; the byte cut keeps
// "mary wollsto", which stays a prefix of the full key and discriminates.
if (key.size() > AUTHOR_KEY_MAX_BYTES) key.resize(AUTHOR_KEY_MAX_BYTES);
while (!key.empty() && key.back() == ' ') key.pop_back();
return key;
}
// fold() keeps the apostrophe, which is right for sorting — "L'Eneide" belongs
// under L. For searching it is wrong: in French the word worth typing is the one
// AFTER the apostrophe, so "eneide" must reach "L'Eneide" and "cote" must
// reach "d'a cote". Treating it as a word boundary here leaves the sort untouched.
bool isWordBreak(const char c) { return c == ' ' || c == '\''; }
bool matchesQuery(const std::string_view haystack, const std::string_view needle) {
if (needle.empty()) return true;
// Walk the query one word at a time, and for each one scan the book's words for
// a prefix hit. Both strings are at most a couple of hundred bytes and this
// runs once per book per keypress, so a plain scan is cheaper than anything
// that would need building first.
size_t qs = 0;
while (qs < needle.size()) {
while (qs < needle.size() && isWordBreak(needle[qs])) qs++;
if (qs >= needle.size()) break;
size_t qe = qs;
while (qe < needle.size() && !isWordBreak(needle[qe])) qe++;
const std::string_view word = needle.substr(qs, qe - qs);
bool found = false;
size_t hs = 0;
while (hs < haystack.size() && !found) {
while (hs < haystack.size() && isWordBreak(haystack[hs])) hs++;
if (hs >= haystack.size()) break;
if (haystack.compare(hs, word.size(), word) == 0) {
found = true;
break;
}
while (hs < haystack.size() && !isWordBreak(haystack[hs])) hs++;
}
if (!found) return false;
qs = qe;
}
return true;
}
std::string surnameKey(const std::string_view displayAuthor) {
const std::string folded = fold(displayAuthor);
if (folded.empty()) return {};
size_t lastStart = std::string::npos;
size_t lastEnd = folded.size();
size_t i = folded.size();
while (i > 0) {
i--;
if (folded[i] != ' ') {
if (lastStart == std::string::npos) lastEnd = i + 1;
lastStart = i;
} else if (lastStart != std::string::npos) {
break;
}
}
if (lastStart == std::string::npos) return {};
std::string key;
key.reserve(folded.size() + 1);
key.append(folded, lastStart, lastEnd - lastStart);
// The given names follow, so two people sharing a surname stay in a stable,
// readable order rather than whichever the disk walk happened to produce.
if (lastStart > 0) {
key.push_back(' ');
key.append(folded, 0, lastStart);
while (!key.empty() && key.back() == ' ') key.pop_back();
}
return key;
}
} // namespace library
+111
View File
@@ -0,0 +1,111 @@
#pragma once
// Text normalisation for the Library index: the fold used for search and sort
// keys, and the author key that merges spellings of one person.
//
// Nothing here reads a filename. Titles and authors come from the book's own
// metadata; a book whose metadata carries no title falls back to its filename
// stem, unparsed, and one with no author is grouped under Unknown.
//
// Pure functions over UTF-8, no hardware and no allocation beyond the returned
// strings, so the whole unit is host-testable (test/library_text).
//
// Design notes that are easy to get wrong and were measured on a real card
// (docs/superpowers/specs/2026-08-05-addendum-a-findability.md, A2.1-A2.8):
//
// * The fold DECOMPOSES. `utf8ComposeNfc()` goes the other way, so a fold
// built on it passes on a card holding only decomposed text and then mangles
// the first precomposed file to arrive from Windows or Calibre. Both forms
// must produce the same output, and the host tests assert exactly that.
// * Some letters have no canonical decomposition at all — U+00F8 (ø) is a
// distinct letter, not o-with-stroke — so a decompose-only fold turns
// "Søren" into "Sren". Those need an explicit map.
// * The author KEY sorts its tokens, so for identity purposes name order
// stops mattering and no First/Last guess is needed there. Display-side
// helpers do use narrow rules — cleanPersonName inverts a single-comma
// "Last, First", surnameKey takes the last word — because showing
// "Austen, Jane" and "Jane Austen" as two people is worse than the guess.
#include <cstdint>
#include <string>
#include <string_view>
namespace library {
// Longest author key written into an index record. Sized so a key fits the
// record's fixed field; measured to collide 0 times over a 69-book library.
inline constexpr size_t AUTHOR_KEY_MAX_BYTES = 12;
// Casefold and strip diacritics for matching and sorting.
//
// Maps a handful of letters that have no canonical decomposition, decomposes the
// rest and drops combining marks, lowercases ASCII alphanumerics, and turns
// everything else into a single space. Space runs collapse and the result is
// trimmed. Apostrophes survive as ASCII '\'' so "O'Malley" and "L'\xC3\x89n\xC3\xA9ideé"
// keep their shape.
//
// `stripArticle` additionally removes one leading article ("the ", "le ", "la ",
// ...) — correct for sort keys and search text, wrong for anything displayed.
std::string fold(std::string_view text, bool stripArticle = false);
// Tidy a person's name for DISPLAY, without reordering it.
//
// Drops bracketed spans ("George Sand [Sand, George]"), everything after a
// multi-author separator, and the trailing underscores and punctuation that
// exporters leave behind ("Lu Xun_", "Herbert G_ Wells"). An underscore
// between letters becomes a full stop, since that is what it replaced in a name
// a filesystem refused to hold.
//
// A single-comma "Austen, Jane" IS turned round into "Jane Austen" — with one
// book per author the spelling vote has no majority to settle it, so the comma
// is the one signal acted on here. Multi-comma names ("Smith, John, Jr.") and
// author lists are left exactly as written. Harmonising the several spellings
// of one person is done by picking the most common one that actually occurs,
// which needs the whole library and so belongs to the index build.
std::string cleanPersonName(std::string_view author);
// Order-insensitive identity for one person, at most AUTHOR_KEY_MAX_BYTES.
//
// Drops bracketed spans and everything after ';' (multi-author separator), folds,
// drops single-character tokens (initials), sorts the remaining tokens and joins
// them. "Lu, Xun", "Xun, Lu" and "Lu Xun [Xun, Lu]" all
// collapse to one key. Truncation is on BYTES, not a token boundary: the sort
// puts a short forename first, so a whole-token cut would reduce
// "Wollstonecraft, Mary" to "alex" and merge every Alex in the library; the byte
// cut keeps "mary wollsto", still a prefix of the full key.
std::string authorKey(std::string_view author);
// Does a book match what has been typed so far?
//
// Both sides are already folded — accents stripped, case dropped, punctuation
// turned to spaces — so "eneide" finds "L'Énéide" and "eluard" finds
// "Éluard". `haystack` is the record's stored fold; `needle` is the query put
// through the same fold.
//
// Every query word must PREFIX some word of the book. That is the rule that fits
// the hardware: with no partial refresh, each keypress costs a full ~185 ms panel
// repaint, so the reader wants to stop typing as early as possible. "dar mat"
// — six keys — finds "Wuthering Heights", where a plain substring test would demand the
// whole of one word and give nothing for the effort of a second.
//
// An empty query matches everything, so the list is the unfiltered shelf before
// the first key is pressed.
bool matchesQuery(std::string_view haystack, std::string_view needle);
// Ordering key for a shelf sorted by author: surname first, then the rest.
// "Herman Melville" becomes "melville herman", so the shelf reads C where a library
// would put it.
//
// Deliberately NOT the same key as authorKey(). That one sorts a name's words so
// that "Victor Hugo" and "Hugo Victor" hash alike and are recognised as one person;
// it is a GROUPING key and would be wrong to order by. This is derived from the
// DISPLAY name instead, which is safe because the spelling vote has already made
// every book by one author show the same name — so a group cannot split across
// two places on the shelf.
//
// The last word is taken as the surname. That is right for the western names on
// this card and wrong for some others, which is a limit worth stating rather than
// hiding: a single word name simply keys on itself.
std::string surnameKey(std::string_view displayAuthor);
} // namespace library
+8
View File
@@ -23,6 +23,14 @@ uint32_t utf8ComposePair(const uint32_t base, const uint32_t mark) {
}
} // namespace
uint32_t utf8DecomposedBase(const uint32_t cp) {
if (cp < 0x00C0) return 0; // no precomposed Latin letter below this
for (const auto& e : kUtf8ComposeTable) {
if (e.composed == cp) return e.base;
}
return 0;
}
std::string utf8ComposeNfc(const std::string& in) {
// Fast path: NFC composition can only change text that contains a combining
// diacritical mark U+0300-036F (UTF-8 lead byte 0xCC or 0xCD). Plain ASCII and
+12
View File
@@ -18,6 +18,18 @@ void utf8TruncateChars(std::string& str, size_t numChars);
// stored in NFD (e.g. some EPUB chapter titles) otherwise renders broken.
std::string utf8ComposeNfc(const std::string& in);
// The base letter a precomposed codepoint decomposes to, or 0 when there is
// none ("é" -> "e", but "ø" -> 0: it is a letter in its own right, not
// o-with-stroke). Lives here rather than in a caller because the compose table
// is a ~5 KB static array in the header: a second includer is a second copy in
// flash.
//
// A linear scan, unlike utf8ComposePair's binary search above it: the table is
// sorted by (base, mark), which this lookup searches against the grain. Adding
// a second table sorted by composed would cost more flash than sharing this one
// saves.
uint32_t utf8DecomposedBase(uint32_t cp);
// Truncate a raw char buffer to the last complete UTF-8 codepoint boundary.
// Returns the new length (<= len). If the buffer ends mid-sequence, the
// incomplete trailing bytes are excluded.
+4
View File
@@ -93,6 +93,10 @@ bool HalDisplay::isInverted() const { return einkDisplay.isInverted(); }
void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
void HalDisplay::setBusyWaitSliceHook(bool (*sliceHook)(int8_t busyPin, uint8_t busyLevel)) {
einkDisplay.setBusyWaitSliceHook(sliceHook);
}
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
uint8_t* HalDisplay::lendFrameBufferStorage(uint32_t* sizeOut) { return einkDisplay.lendBuildStorage(sizeOut); }
+4
View File
@@ -61,6 +61,10 @@ class HalDisplay {
// Power management
void deepSleep();
// Install the slice hook that replaces the BUSY poll delay on proven-long
// waits (see EpdBus::setBusyWaitSliceHook for the contract)
void setBusyWaitSliceHook(bool (*sliceHook)(int8_t busyPin, uint8_t busyLevel));
// Access to frame buffer
uint8_t* getFrameBuffer() const;
+45 -2
View File
@@ -6,6 +6,7 @@
#include <Wire.h>
#include <XteinkDetect.h>
#include <esp_sleep.h>
#include <soc/usb_serial_jtag_struct.h>
// Global HalGPIO instance
HalGPIO gpio;
@@ -140,11 +141,44 @@ void HalGPIO::begin() {
void HalGPIO::update() {
inputMgr.update();
const bool connected = isUsbConnected();
updateUsbState(millis());
}
void HalGPIO::updateUsbState(const unsigned long now) {
// SOF-based host-link sampling (see the member comment). A cheap register
// read, so it runs at its own short cadence on both devices and is never
// behind the I2C throttle below — a fresh enumeration must cancel light
// sleep within a poll or two, or the next slice kills the CDC link again.
if (sofLastSampleMs == 0 || now - sofLastSampleMs >= SOF_SAMPLE_MS) {
const auto sof = static_cast<uint16_t>(USB_SERIAL_JTAG.fram_num.sof_frame_index);
usbSofActive = (sof != lastSofFrameIndex);
lastSofFrameIndex = sof;
sofLastSampleMs = now;
}
// Throttle the X3's I2C-based USB detection; see USB_POLL_X3_MS. First call
// (usbLastPollMs == 0) always polls so boot state is correct. The combined
// verdict below is still recomputed every call so a SOF-detected attach is
// not held back by the throttle window.
if (usbLastPollMs == 0 || !deviceIsX3() || now - usbLastPollMs >= USB_POLL_X3_MS) {
usbLastPollMs = now;
usbElectricalConnected = isUsbElectricalConnected();
}
const bool connected = usbSofActive || usbElectricalConnected;
usbStateChanged = (connected != lastUsbConnected);
lastUsbConnected = connected;
}
void HalGPIO::pollUsbState() {
// Wait out the SOF sample floor so the comparison sees a real frame delta:
// two reads inside one USB frame compare equal and read as "no host".
const unsigned long elapsed = millis() - sofLastSampleMs;
if (sofLastSampleMs != 0 && elapsed < SOF_SAMPLE_MS) {
delay(SOF_SAMPLE_MS - elapsed);
}
updateUsbState(millis());
}
bool HalGPIO::wasUsbStateChanged() const { return usbStateChanged; }
bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); }
@@ -157,6 +191,8 @@ bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleas
bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
bool HalGPIO::isDebouncePending() const { return inputMgr.isDebouncePending(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
unsigned long HalGPIO::getPowerButtonHeldTime() const { return inputMgr.getPowerButtonHeldTime(); }
@@ -244,9 +280,16 @@ bool HalGPIO::verifyPowerButtonWakeup(uint16_t requiredDurationMs, bool shortPre
}
bool HalGPIO::isUsbConnected() const {
// Recent SOF activity means an enumerated host regardless of what the
// electrical check says (false at boot until update() has sampled twice).
return usbSofActive || isUsbElectricalConnected();
}
bool HalGPIO::isUsbElectricalConnected() const {
if (deviceIsX3()) {
// X3: infer USB/charging via BQ27220 Current() register (0x0C, signed mA).
// Positive current means charging.
// Positive current means charging. Misses a data-only cable and a full
// battery — the SOF check in update() covers those.
for (uint8_t attempt = 0; attempt < 2; ++attempt) {
int16_t currentMa = 0;
if (X3GPIO::readBQ27220CurrentMA(&currentMa)) {
+47
View File
@@ -45,6 +45,35 @@ class HalGPIO {
bool lastUsbConnected = false;
bool usbStateChanged = false;
unsigned long usbLastPollMs = 0;
bool usbElectricalConnected = false; // last result of the per-device electrical/charge check
// X3 USB detection is a BQ27220 I2C read (~0.3-1 ms of awake CPU per call);
// polled every loop it costs a few percent of the light-sleep idle floor for
// nothing. At >=1 s intervals the energy cost is unmeasurable (~µC/s), so 1 s
// is chosen for prompt plug/unplug UX (battery icon, light-sleep USB guard /
// CDC recovery). X4 detection is a single digitalRead and stays per-loop.
static constexpr unsigned long USB_POLL_X3_MS = 1000;
// USB-Serial-JTAG SOF activity, sampled by update(): the host sends a SOF
// frame every 1 ms while the bus is enumerated, so a frame index that moved
// between two samples means a live host link. Catches what the charge-based
// X3 check misses: a data-only cable, and any cable once the battery is full
// (charge current ~0). Samples must be >SOF_SAMPLE_MS apart — update() can be
// called back-to-back (inner input loops), and adjacent reads would compare
// equal and flicker the verdict.
uint16_t lastSofFrameIndex = 0;
unsigned long sofLastSampleMs = 0;
bool usbSofActive = false;
static constexpr unsigned long SOF_SAMPLE_MS = 10;
// Per-device electrical/charge-inference USB check (fresh read; X3 = BQ27220
// charge current over I2C, X4 = VBUS-driven level on GPIO20).
bool isUsbElectricalConnected() const;
// Shared body of update()/pollUsbState(): SOF sampling + throttled electrical
// check + combined-verdict edge tracking.
void updateUsbState(unsigned long now);
public:
enum class DeviceType : uint8_t { X4, X3 };
@@ -76,6 +105,11 @@ class HalGPIO {
bool wasAnyPressed() const;
bool wasReleased(uint8_t buttonIndex) const;
bool wasAnyReleased() const;
// True while a raw button-state change is still inside the debounce window.
// The idle loop polls fast while this is set so the confirming sample lands
// ~10 ms after the first; at the 50 ms light-sleep cadence a short tap can
// otherwise appear in a single sample and never commit (dropped press).
bool isDebouncePending() const;
unsigned long getHeldTime() const;
unsigned long getPowerButtonHeldTime() const;
bool hasTouch() const;
@@ -108,6 +142,19 @@ class HalGPIO {
// Check if USB is connected
bool isUsbConnected() const;
// Sample USB state without a full input update. Called during setup() BEFORE
// the first e-ink refresh: the boot paint happens before loop() ever runs
// update(), so without this the light-sleep slice guards see the unsampled
// default ("no USB") and sleeping through the boot refresh kills a live CDC
// link (charge-based X3 detection also reads false whenever the battery is
// full). Two calls >=SOF_SAMPLE_MS apart establish the SOF verdict; the
// method itself waits out the floor if called too soon after the last sample.
void pollUsbState();
// USB state as sampled by the last update() call.
// Prefer this in per-loop polling: isUsbConnected() performs a fresh I2C read on X3.
bool isUsbConnectedCached() const { return lastUsbConnected; }
// Returns true once per edge (plug or unplug) since the last update()
bool wasUsbStateChanged() const;
+175 -7
View File
@@ -4,6 +4,7 @@
#include <Logging.h>
#include <PowerManager.h>
#include <WiFi.h>
#include <driver/gpio.h>
#include <esp_sleep.h>
#include <soc/soc_caps.h>
@@ -13,13 +14,21 @@
HalPowerManager powerManager; // Singleton instance
// GPIO13 must stay high during light sleep on the C3 Xteink boards: it controls
// the X4 battery latch and the X3 SD power rail. Other boards use it for
// unrelated signals, including the X4 Pro display chip select.
static constexpr gpio_num_t XTEINK_C3_GPIO13 = GPIO_NUM_13;
void HalPowerManager::begin() {
if (BoardConfig::ACTIVE.batteryAdc >= 0) {
pinMode(BoardConfig::ACTIVE.batteryAdc, INPUT);
}
mainLoopTask = xTaskGetCurrentTaskHandle(); // Arduino runs setup() on the loop task
normalFreq = getCpuFrequencyMhz();
modeMutex = xSemaphoreCreateMutex();
assert(modeMutex != nullptr);
sleepMutex = xSemaphoreCreateMutex();
assert(sleepMutex != nullptr);
}
void HalPowerManager::setPowerSaving(bool enabled) {
@@ -67,13 +76,19 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
#endif
#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);
if (gpio.isXteinkDevice()) {
// GPIO13 gates the battery MOSFET on both Xteink C3 boards; driving it low
// is the battery power-off (the SDK wake source still handles USB power).
// Unlike upstream, X3 is included: lightSleep() pad-holds the latch HIGH
// persistently while running (the IDF flash-leakage workaround would drop
// it mid-slice, hard-powering the device off), so the implicit low that
// upstream's X3 relies on never happens here — release the hold and drive
// the latch low explicitly. The hold_en survives deep sleep via the SDK's
// deepSleep() (esp_sleep_config_gpio_isolate + gpio_deep_sleep_hold_en).
gpio_hold_dis(XTEINK_C3_GPIO13);
gpio_set_direction(XTEINK_C3_GPIO13, GPIO_MODE_OUTPUT);
gpio_set_level(XTEINK_C3_GPIO13, 0);
gpio_hold_en(XTEINK_C3_GPIO13);
}
#endif
@@ -91,6 +106,159 @@ void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
freeink::PowerManager::deepSleepUntilPowerButton();
}
bool HalPowerManager::lightSleep(const HalGPIO& gpio) const {
// A performance Lock means a render (or similar) task is mid-flight; light sleep
// freezes the whole chip, so it would stall that task.
// Note: like setPowerSaving(), read without the mutex — stale in either
// direction is acceptable: a stale lock delays sleeping by one 50 ms slice;
// a Lock acquired after this check freezes that task for at most one slice
// (timer wake; RAM/peripheral state retained), which is cheaper than
// serializing Lock ctor/dtor against the entire sleep window.
if (currentLockMode != None) {
return false;
}
// Light sleep drops a WiFi association and kills an enumerated USB-CDC link.
if (WiFi.getMode() != WIFI_MODE_NULL || gpio.isUsbConnectedCached()) {
return false;
}
// Serialize wake-source arming with the render task's BUSY-wait slice (see
// sleepMutex declaration for the failure mode this prevents).
xSemaphoreTake(sleepMutex, portMAX_DELAY);
// Timer wake keeps the button/tilt poll cadence identical to the delay() it
// replaces: the front/side buttons are ADC resistor-ladder inputs whose
// pressed levels sit mid-rail, invisible to a digital GPIO wake, so they
// must be polled. The power button (a true GPIO) is ALSO armed, as a level
// wake at its pressed level: committing a press needs two update() samples
// >=5 ms apart, so at the timer-only 50 ms cadence a tap shorter than a
// slice could land in one sample — or none — and be dropped entirely
// (field-reported as unreliable short-press sleep). The wake is only an
// early poll: update()'s debounce still decides whether it was a real
// press, so a misread around the sleep transition costs one extra wake
// blip, never a phantom press. Idle cost is zero — the pin only holds its
// pressed level while a finger is on it.
esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(LIGHT_SLEEP_SLICE_MS) * 1000ULL);
const int8_t powerPin = BoardConfig::ACTIVE.input.power;
if (powerPin >= 0) {
gpio_wakeup_enable(static_cast<gpio_num_t>(powerPin),
BoardConfig::ACTIVE.input.powerActiveHigh ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL);
esp_sleep_enable_gpio_wakeup();
}
// The C3 flash-leakage workaround pulls its DIO-unused SPIWP pad low during
// light sleep. Hold GPIO13 high only on the X3/X4 boards that use that pad as
// a power control; on other boards GPIO13 may be a bus signal.
if (gpio.isXteinkDevice()) {
gpio_set_level(XTEINK_C3_GPIO13, 1);
gpio_set_direction(XTEINK_C3_GPIO13, GPIO_MODE_OUTPUT);
gpio_hold_en(XTEINK_C3_GPIO13);
}
const esp_err_t err = esp_light_sleep_start();
// Disarm immediately: an armed timer wake persists across sleep calls and would
// carry over into startDeepSleep(), waking the device on USB power after 50 ms.
// gpio_wakeup_disable() clears only the wake-enable bit — the pin's LEVEL
// interrupt type survives it. A leftover level type is live ammunition for
// any later-installed GPIO ISR service: an asserted level feeding the
// shared service with no per-pin handler registered re-enters the ISR
// forever and livelocks the CPU. Clear the type explicitly.
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
if (powerPin >= 0) {
gpio_wakeup_disable(static_cast<gpio_num_t>(powerPin));
gpio_set_intr_type(static_cast<gpio_num_t>(powerPin), GPIO_INTR_DISABLE);
}
xSemaphoreGive(sleepMutex);
if (err != ESP_OK) {
// e.g. ESP_ERR_SLEEP_REJECT — we did not actually sleep
LOG_DBG("PWR", "Light sleep rejected: %d", static_cast<int>(err));
return false;
}
return true;
}
bool HalPowerManager::onEinkBusyWaitSlice(const int8_t busyPin, const uint8_t busyLevel) {
// Same exclusions as lightSleep(): light sleep drops a WiFi association and
// kills an enumerated USB-CDC link. No LOG here — this runs ~50x/s mid-refresh.
if (WiFi.getMode() != WIFI_MODE_NULL || gpio.isUsbConnectedCached()) {
return false;
}
// Mid-debounce: committing needs a second sample, so let the SDK's short poll
// delay run instead of halting the chip. Same guard lightSleep() gets in loop().
// Unsynchronized like the checks above; stale costs at most one slice.
if (gpio.isDebouncePending()) {
return false;
}
xSemaphoreTake(sleepMutex, portMAX_DELAY);
// Wake the instant BUSY leaves its active level (refresh complete). Level
// wake (not edge) means an already-completed refresh returns immediately
// instead of sleeping a full slice. The timer bound only exists so the main
// loop gets scheduling windows to keep sampling the ADC-ladder buttons.
const auto pin = static_cast<gpio_num_t>(busyPin);
gpio_wakeup_enable(pin, busyLevel == HIGH ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL);
// Also wake the instant the power button is pressed, so the main loop's poll
// sees even sub-slice taps mid-refresh (same rationale and safety argument
// as lightSleep(): the wake is an early poll, never a synthesized press).
const int8_t powerPin = BoardConfig::ACTIVE.input.power;
if (powerPin >= 0) {
gpio_wakeup_enable(static_cast<gpio_num_t>(powerPin),
BoardConfig::ACTIVE.input.powerActiveHigh ? GPIO_INTR_HIGH_LEVEL : GPIO_INTR_LOW_LEVEL);
}
esp_sleep_enable_gpio_wakeup();
esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(BUSY_SLEEP_SLICE_MS) * 1000ULL);
// Preserve the C3 Xteink GPIO13 power control during this sleep slice without
// reconfiguring GPIO13 on boards where it is a bus signal.
if (gpio.isXteinkDevice()) {
gpio_set_level(XTEINK_C3_GPIO13, 1);
gpio_set_direction(XTEINK_C3_GPIO13, GPIO_MODE_OUTPUT);
gpio_hold_en(XTEINK_C3_GPIO13);
}
const esp_err_t err = esp_light_sleep_start();
// Disarm everything armed above; an armed source persisting into
// startDeepSleep() would wake the device on USB power (see lightSleep()).
// As in lightSleep(): also clear the LEVEL interrupt types, which
// gpio_wakeup_disable() leaves behind (see the livelock note there).
esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
gpio_wakeup_disable(pin);
gpio_set_intr_type(pin, GPIO_INTR_DISABLE);
if (powerPin >= 0) {
gpio_wakeup_disable(static_cast<gpio_num_t>(powerPin));
gpio_set_intr_type(static_cast<gpio_num_t>(powerPin), GPIO_INTR_DISABLE);
}
xSemaphoreGive(sleepMutex);
if (err != ESP_OK) {
return false; // e.g. ESP_ERR_SLEEP_REJECT — fall back to the SDK's poll delay
}
// Yield until the equal-priority main loop finishes an iteration, not just one
// tick: one tick is less awake CPU than an iteration needs, so we would re-halt
// the chip mid-poll. Capped so a loop parked on SD I/O can't hold the panel awake.
const uint32_t seenIterations = mainLoopIterations;
// Skipped entirely when the loop cannot make progress and the yield would just
// burn the cap awake: parked in requestUpdateAndWait() on this very render;
// never started (setup-time paints, iterations still 0); or this task IS the
// loop task (direct displayBuffer calls from setup()/loop() busy-wait here).
const bool loopCanPoll = !mainLoopBlocked && seenIterations != 0 && xTaskGetCurrentTaskHandle() != mainLoopTask;
unsigned yieldTicks = 0;
if (loopCanPoll) {
for (; yieldTicks < SLICE_YIELD_MAX_TICKS && mainLoopIterations == seenIterations; ++yieldTicks) {
vTaskDelay(1);
}
}
return true;
}
uint16_t HalPowerManager::getBatteryPercentage() const {
static const BatteryMonitor battery;
if (BoardConfig::ACTIVE.batteryGauge.gaugeAddr != 0) {
+65 -2
View File
@@ -23,6 +23,26 @@ class HalPowerManager {
enum LockMode { None, NormalSpeed };
LockMode currentLockMode = None;
SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode
// Bumped by the main loop, read by the render task (see onEinkBusyWaitSlice).
// Single writer, naturally aligned: the 32-bit access is atomic on RISC-V.
volatile uint32_t mainLoopIterations = 0;
// Cap on how long one slice may stay awake waiting for that iteration.
// Productive yields measure ~0.4 ms, so two ticks is ample headroom.
static constexpr unsigned SLICE_YIELD_MAX_TICKS = 2;
// Set while the main loop is blocked in requestUpdateAndWait(). Captured in
// begin(), which Arduino runs on the loop task, so the handle compare below
// ignores any other task that might wait on a render.
TaskHandle_t mainLoopTask = nullptr;
volatile bool mainLoopBlocked = false;
// Serializes wake-source arm -> esp_light_sleep_start() -> disarm across the
// two sleepers (main-loop lightSleep() and the render task's BUSY-wait slice).
// Without it, lightSleep()'s deliberately unsynchronized lock-mode read could
// let both tasks interleave arming/disarming: the loser's disarm-all can strip
// the winner's wake sources and leave it sleeping with none armed.
SemaphoreHandle_t sleepMutex = nullptr;
public:
#if BOARD_HAS_PSRAM
@@ -30,8 +50,20 @@ class HalPowerManager {
#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
// Two-stage idle backoff. Renders re-raise the clock via Lock regardless, so the
// full-speed window only needs to cover rapid consecutive input (avoids clock
// thrash); polling stays at 100 Hz until light sleep takes over the cadence.
static constexpr unsigned long IDLE_DOWNCLOCK_MS = 500; // full speed -> LOW_POWER_FREQ
static constexpr unsigned long IDLE_LIGHT_SLEEP_MS = 1000; // 100 Hz polling -> light sleep
static constexpr unsigned long BATTERY_POLL_MS = 1500; // ms
static constexpr unsigned long LIGHT_SLEEP_SLICE_MS = 50; // ms
// Timer bound for one BUSY-wait light-sleep slice. The refresh end itself
// wakes exactly via GPIO level wake on the BUSY pin; the timer only bounds
// how coarse main-loop button sampling gets during a long refresh.
static constexpr unsigned long BUSY_SLEEP_SLICE_MS = 20; // ms
void begin();
@@ -42,6 +74,37 @@ class HalPowerManager {
// Should be called inside main loop() to handle the currentLockMode
void startDeepSleep(HalGPIO& gpio) const;
// Light-sleep the CPU for LIGHT_SLEEP_SLICE_MS (timer wake; buttons are polled on
// wake at the same cadence as the delay() this replaces). Returns false WITHOUT
// sleeping when unsafe: a performance Lock is held (render in flight), WiFi is
// active, or USB is connected (light sleep kills the CDC link). The caller must
// fall back to delay() in that case.
bool lightSleep(const HalGPIO& gpio) const;
// BUSY-wait slice hook (EpdBus::setBusyWaitSliceHook): light-sleep the chip
// for up to BUSY_SLEEP_SLICE_MS while the panel refreshes autonomously,
// waking early the moment the BUSY pin leaves `busyLevel`. Returns false
// WITHOUT sleeping when unsafe (WiFi active, USB connected, or sleep
// rejected) — the SDK then falls back to its plain poll delay. Called from
// the render task; safe by construction (the waiter IS the locked task).
// Leaves the CPU clock alone: the chip is asleep for most of the wait, so a
// downclock buys nothing and only slows the main loop's awake windows.
bool onEinkBusyWaitSlice(int8_t busyPin, uint8_t busyLevel);
// Call at the end of every main-loop iteration; paces the slice hook's yield.
// Read-then-assign, not ++: C++20 deprecates increment on a volatile lvalue.
void noteMainLoopIteration() { mainLoopIterations = mainLoopIterations + 1; }
// Bracket a blocking wait on the render task (requestUpdateAndWait). While the
// main loop is parked there it cannot poll, so the slice hook skips its yield
// instead of burning the full cap awake once per slice for nothing.
void noteRenderWaitBegin() {
if (xTaskGetCurrentTaskHandle() == mainLoopTask) mainLoopBlocked = true;
}
void noteRenderWaitEnd() {
if (xTaskGetCurrentTaskHandle() == mainLoopTask) mainLoopBlocked = false;
}
// Get battery percentage (range 0-100)
uint16_t getBatteryPercentage() const;
Loaded 20 of 46 files, more files were not shown because too many files have changed in this diff. Show more