mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-08-13 14:21:57 -07:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f45118c48e | ||
|
|
f449ce9e7d | ||
|
|
48e39eb0ee | ||
|
|
9b1fb712de | ||
|
|
e715532f90 | ||
|
|
70faa29dac | ||
|
|
cb995809ba | ||
|
|
f16451c729 | ||
|
|
268c27f7d0 | ||
|
|
b1d53ba810 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
-1
Submodule freeink-sdk updated: 83d49345d8...57f05e80c0.
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
@@ -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(¤tMa)) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user