# FreeInk SDK — Full Documentation > FreeInk is an MIT-licensed, hardware-independent C++ SDK for ESP32-class e-paper readers. It hides e-paper controllers, waveforms and board wiring behind injectable interfaces, so one firmware can drive many panels. Libraries are self-contained PlatformIO dependencies you add à la carte; a single binary can carry several boards and pick one at runtime. This file concatenates every documentation page from https://freeink.org/docs for LLM consumption. --- # Overview > What FreeInk is, why it exists, and how the pieces fit together. Group: Getting started · URL: https://freeink.org/docs/overview FreeInk is a hardware-independent SDK for building e-paper reader firmware. It abstracts every device-specific detail — display controller, waveforms and LUTs, GPIOs, bus speeds, input style, touch, frontlight, audio, LEDs and on-board sensors — behind small, injectable interfaces, so the firmware calls one generic API and gets device-specific behavior. Adding a new device means adding *data* — a board profile plus a driver config — not editing the generic code. One codebase drives many panels, and a new board is a profile and a config, not a rewrite. ## Drop-in compatible FreeInk is drop-in compatible with firmware written against the original `EInkDisplay` / `InputManager` / `BatteryMonitor` / `SDCardManager` / `BoardConfig` API. Switching to FreeInk is a matter of repointing the library path — `#include ` and the `EInkDisplay` type keep working through a compatibility shim. Already running CrossPoint? It builds the X3 + X4 ESP32-C3 binary against this SDK with **no source changes**. See [PlatformIO setup](https://freeink.org/docs/installation). ## Why it's built this way - **Nothing device-specific is hardcoded in generic code.** GPIOs come from the `EInkDisplay` constructor and `BoardConfig`; SPI clocks have a controller default and a board override; waveforms, booster values, scan direction and refresh temperatures are injected through a driver config struct. - **New devices are data, not code.** A new board fills in values; the generic driver consumes them. See [Adding a device](https://freeink.org/docs/adding-a-device). - **Composable builds.** A binary is composed along two axes — devices and capabilities — so each build stays as tight as the hardware allows. See [Build composition](https://freeink.org/docs/build-composition). ## Credit & lineage FreeInk is an MIT-licensed **re-architecture derived from** the OpenX4 E-Paper Community SDK (`open-x4-epaper/community-sdk`, MIT) and its contributors — in particular CidVonHighwind for the original `EInkDisplay` driver and the X3/X4 waveform work, and the community device ports. The register sequences and waveform LUTs for the SSD1677 and UC8253 panels are **carried over and adapted** from that project, not reverse-engineered independently, so the community's panel tuning is preserved. What FreeInk changes is the **structure**, not the panel work: where the upstream interleaves every device in one monolithic driver, FreeInk splits each controller into a standalone, compile-time-selectable driver behind a stable facade, with per-device behavior supplied as injectable config. It is **not a fork** and has no build-time or runtime dependency on the upstream — but the inherited waveforms are the upstream's, and the re-architecture itself is comparatively new code that has had less multi-person field testing than the upstream. Full attribution lives in [NOTICE](https://github.com/Free-Ink/freeink-sdk/blob/main/NOTICE). > **License** > > FreeInk is distributed under the **MIT License** — open source and permissive. Use, modify and ship closed-source or commercial derivatives freely. Commercial use is welcome and completely free; if FreeInk powers a product you sell, please consider [sponsoring the project](https://app.royalty.dev/Free-Ink/freeink-sdk). --- # Quickstart > Drive an e-paper panel with FreeInk in a few minutes. Group: Getting started · URL: https://freeink.org/docs/quickstart Get an e-paper panel drawing with FreeInk. This walks through a minimal PlatformIO project for a single device; for the full configuration with every device env, see [PlatformIO setup](https://freeink.org/docs/installation). > **Prerequisites** > > A PlatformIO install (CLI or the VS Code extension), a checkout of the [FreeInk SDK](https://github.com/Free-Ink/freeink-sdk), and a supported board — this example targets the **Xteink X4** (ESP32-C3, SSD1677). ## 1. Add the libraries FreeInk libraries are linked as `symlink` `lib_deps` pointing at your SDK checkout. The names match the original SDK so existing includes keep working: ```platformio.ini [env:xteink_x4] platform = espressif32 board = esp32-c3-devkitm-1 framework = arduino build_flags = -DFREEINK_DEVICE_X4 lib_deps = BoardConfig=symlink://path/to/freeink-sdk/libs/hardware/BoardConfig EInkDisplay=symlink://path/to/freeink-sdk/libs/display/FreeInkDisplay InputManager=symlink://path/to/freeink-sdk/libs/hardware/InputManager BatteryMonitor=symlink://path/to/freeink-sdk/libs/hardware/BatteryMonitor SDCardManager=symlink://path/to/freeink-sdk/libs/hardware/SDCardManager ``` The display libs depend on `BoardConfig`; `SdFat` is pulled in automatically as a dependency of `SDCardManager`. ## 2. Initialize the display The firmware constructs `EInkDisplay` from the active board profile and calls `begin()`, which selects the right panel driver. GPIOs come from `BoardConfig::ACTIVE` — nothing is hardcoded. ```cpp #include #include using namespace freeink; // Pins, geometry and controller all come from the active board profile — // the constructor takes the display SPI pins (sclk, mosi, cs, dc, rst, busy). EInkDisplay display( BoardConfig::ACTIVE.display.sclk, BoardConfig::ACTIVE.display.mosi, BoardConfig::ACTIVE.display.cs, BoardConfig::ACTIVE.display.dc, BoardConfig::ACTIVE.display.rst, BoardConfig::ACTIVE.display.busy ); void setup() { display.begin(); // selects the SSD1677 driver for the X4 display.clearScreen(0xFF); // white // draw a 1-bpp bitmap into the framebuffer: // display.drawImage(logo, x, y, w, h, /*fromProgmem=*/true); display.displayBuffer(FULL_REFRESH); // FULL_REFRESH / HALF_REFRESH / FAST_REFRESH } void loop() {} ``` > **Text rendering lives above the SDK** > > FreeInk draws bitmaps into the framebuffer (`clearScreen`, `drawImage`) and pushes them with `displayBuffer`; fonts and text layout are the firmware's job. The shape to remember: construct from the board profile, `begin()`, draw into the framebuffer, then `displayBuffer()` with a refresh mode. See the [EInkDisplay reference](https://freeink.org/docs/lib-display) for the full surface. ## 3. Build and flash ```bash pio run -e xteink_x4 -t upload ``` ## Next steps 1. Understand the layering — read [Architecture](https://freeink.org/docs/architecture). 2. Tune which devices and capabilities your binary carries in [Build composition](https://freeink.org/docs/build-composition). 3. Bringing up a board that isn't in the matrix? Follow [Adding a device](https://freeink.org/docs/adding-a-device). --- # Sticky reader starter > Build a minimal EPUB reader: All Books list, paged reader, and edge gestures. Group: Getting started · URL: https://freeink.org/docs/sticky A minimal Sticky reader starter: scan EPUBs from the SD card, render an All Books list with FreeInkUI, open a book with FreeInkBook, and use FreeInkApp for screen transitions and input routing. > **What this starter intentionally leaves out** > > No covers, tabs, settings, sleep screen, metadata cache, folder navigation, font picker, progress persistence, or real TOC list. The goal is the smallest SDK-native reader loop. ## 1. Project setup Vendor the [FreeInk SDK](https://github.com/Free-Ink/freeink-sdk) beside your firmware: ```bash git submodule add https://github.com/Free-Ink/freeink-sdk.git freeink-sdk git submodule update --init --recursive ``` The important dependencies are `FreeInkUI` and `FreeInkApp` for the UI shell, `FreeInkBook` for EPUB layout/cache/rendering, and `SDCardManager`for the card. The reader path in this tutorial uses the same SDK stack you use in a real app: - `book::Book` opens the EPUB package and exposes metadata, TOC, manifest, and spine. - `book::ChapterLayout::layout()` paginates one spine item into cache records. - `book::PageCacheWriter` receives those pages during layout. - `book::PageCacheReader` reopens cached pages for fast page turns. - `book::PageRenderer` renders the current cached page into the framebuffer. - `ui::tapZones()` provides invisible reader hit zones. - `ui::readerChrome()` draws reader status chrome over the page. ```platformio.ini [env:sticky] platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip framework = arduino board = esp32-s3-devkitc1-n16r8 board_build.mcu = esp32s3 board_build.flash_mode = qio board_build.psram_type = opi board_upload.flash_size = 16MB build_flags = -std=gnu++17 -DFREEINK_DEVICE_STICKY=1 -DEINK_DISPLAY_SINGLE_BUFFER_MODE=1 -DBOARD_HAS_PSRAM -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 lib_deps = BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig EInkDisplay=symlink://freeink-sdk/libs/display/FreeInkDisplay InputManager=symlink://freeink-sdk/libs/hardware/InputManager SDCardManager=symlink://freeink-sdk/libs/hardware/SDCardManager FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI FreeInkBook=symlink://freeink-sdk/libs/book/FreeInkBook ``` ## 2. Storage adapters FreeInkBook does not know about SD cards. The app supplies a `book::BookSource` for random-access reads and a `book::CacheStorage` for page-cache files. Put this in`src/BookStorageAdapters.h`. ```cpp #pragma once #include #include class SdBookSource : public freeink::book::BookSource { public: bool open(const char* path) { file_ = SdMan.open(path, O_RDONLY); return file_ && (size_ = file_.fileSize()) > 0; } void close() { if (file_) file_.close(); } int32_t readAt(uint64_t offset, void* dst, uint32_t len) override { if (!file_ || !file_.seekSet(offset)) return -1; return file_.read(dst, len); } uint64_t size() const override { return size_; } private: FsFile file_; uint64_t size_ = 0; }; class SdCacheStorage : public freeink::book::CacheStorage { public: void setDir(const char* dir) { snprintf(dir_, sizeof(dir_), "%s", dir); SdMan.ensureDirectoryExists(dir_); } bool exists(const char* name) override { return SdMan.exists(path(name)); } bool remove(const char* name) override { return SdMan.remove(path(name)); } int64_t fileSize(const char* name) override { FsFile f = SdMan.open(path(name), O_RDONLY); if (!f) return -1; const int64_t size = f.fileSize(); f.close(); return size; } int32_t readAt(const char* name, uint32_t offset, void* dst, uint32_t len) override { FsFile f = SdMan.open(path(name), O_RDONLY); if (!f || !f.seekSet(offset)) return -1; const int32_t n = f.read(dst, len); f.close(); return n; } bool beginWrite(const char* name) override { snprintf(commitPath_, sizeof(commitPath_), "%s/%s", dir_, name); write_ = SdMan.open(path("_tmp.fibp"), O_WRONLY | O_CREAT | O_TRUNC); return static_cast(write_); } bool write(const void* data, uint32_t len) override { return write_ && write_.write(data, len) == len; } bool endWrite() override { if (!write_) return false; write_.close(); SdMan.remove(commitPath_); return SdMan.rename(path("_tmp.fibp"), commitPath_); } private: const char* path(const char* name) { snprintf(pathBuf_, sizeof(pathBuf_), "%s/%s", dir_, name); return pathBuf_; } char dir_[96] = "/BookCache"; char pathBuf_[192]; char commitPath_[192]; FsFile write_; }; ``` ## 3. FreeInkApp shell FreeInkApp owns the screen function, action routing, tap flash, and transition invalidation. Screens are plain functions that receive `App::ScreenType&` and lay themselves out with the FreeInkUI screen builder. ```cpp #include #include #include #include #include #include #include #include #include #include #include #include #include "BookStorageAdapters.h" using namespace freeink; using book::BookStatus; enum : ui::ActionId { ActionOpenBook = 1, ActionPageNext, ActionPagePrev, ActionBackToReader, ActionBackToLibrary, }; enum class Screen : uint8_t { Library, Reader, Menu }; using App = ui::FreeInkApp<48, 16>; EInkDisplay display( BoardConfig::ACTIVE.display.sclk, BoardConfig::ACTIVE.display.mosi, BoardConfig::ACTIVE.display.cs, BoardConfig::ACTIVE.display.dc, BoardConfig::ACTIVE.display.rst, BoardConfig::ACTIVE.display.busy); InputManager input; ui::DisplayTarget* target = nullptr; App* app = nullptr; Screen screen = Screen::Library; void libraryScreen(App::ScreenType& s, void*); void readerScreen(App::ScreenType& s, void*); void menuScreen(App::ScreenType& s, void*); void goToPage(Screen next, bool initialPaint = false) { float sx0, sy0, sx1, sy1; while (input.popSwipe(sx0, sy0, sx1, sy1)) { // Drop gestures completed on the previous screen. } screen = next; app->clearTapFlash(); switch (next) { case Screen::Library: app->setScreen(libraryScreen, nullptr, ui::RefreshHint::None); break; case Screen::Reader: app->setScreen(readerScreen, nullptr, ui::RefreshHint::None); break; case Screen::Menu: app->setScreen(menuScreen, nullptr, ui::RefreshHint::None); break; } if (initialPaint) app->invalidate(ui::RefreshHint::Full); else app->invalidateTransition(); } ``` ## 4. Library and reader state The starter keeps the library as a fixed array of `ui::ListItem` rows and opens one chapter at a time through `book::PageCacheReader`. The page-cache filename comes from`book::pageCacheName()`, and cache invalidation comes from `book::layoutGenerationHash()`. ```cpp static constexpr int kMaxBooks = 64; char bookPaths[kMaxBooks][160]; char bookTitles[kMaxBooks][64]; ui::ListItem bookItems[kMaxBooks]; int bookCount = 0; uint16_t bookTop = 0; uint16_t bookVisibleRows = 0; int16_t selectedBook = 0; uint8_t* bookBuf = nullptr; uint8_t* scratchBuf = nullptr; uint8_t* indexBuf = nullptr; ui::BitmapBookFont builtinFont; book::FontChain fonts; struct ReaderSession { SdBookSource source; SdCacheStorage cache; book::Arena bookArena; book::Arena scratch; book::Arena indexArena; book::Book bk; book::LayoutParams params; book::PageCacheReader reader; char cacheDir[96]; char cacheName[64]; // PageCacheReader borrows this name while open. uint16_t spineIndex = 0; uint32_t pageInChapter = 0; bool open = false; bool begin(const char* path) { end(); bookArena.init(bookBuf, 512 * 1024); scratch.init(scratchBuf, 512 * 1024); indexArena.init(indexBuf, 64 * 1024); if (!source.open(path)) return false; if (bk.open(source, bookArena, scratch) != BookStatus::Ok) return false; const uint32_t id = book::ZipCatalog::hashPath(path); snprintf(cacheDir, sizeof(cacheDir), "/BookCache/%08x", id); cache.setDir(cacheDir); params = book::LayoutParams{}; params.pageWidth = target->logicalWidth(); params.pageHeight = target->logicalHeight(); params.marginLeft = params.marginRight = 24; params.marginTop = params.marginBottom = 24; params.baseSizePx = 18; params.lineSpacingPct = 120; params.font = &fonts; open = ensureChapter(0) == BookStatus::Ok; return open; } uint32_t generation() const { return book::layoutGenerationHash(params, /*fontFingerprint=*/1); } BookStatus ensureChapter(uint16_t nextSpine) { if (nextSpine >= bk.spineCount()) return BookStatus::NotFound; spineIndex = nextSpine; pageInChapter = 0; const uint32_t hash = generation(); if (!book::pageCacheName(spineIndex, hash, cacheName, sizeof(cacheName))) { return BookStatus::IoError; } indexArena.reset(); BookStatus st = reader.open(cache, cacheName, hash, indexArena); if (st == BookStatus::Ok) return st; const book::ManifestItem* item = bk.spineItem(spineIndex); const book::ZipEntry* entry = item ? bk.zip().find(item->href) : nullptr; if (!item || !entry) return BookStatus::NotFound; const size_t mark = scratch.mark(); book::PageCacheWriter writer; if (!writer.begin(cache, cacheName, hash, scratch)) { scratch.release(mark); return BookStatus::IoError; } uint32_t totalChars = 0; st = book::ChapterLayout::layout( source, bk.zip(), *entry, item->href, params, scratch, writer, nullptr, &totalChars); writer.setTotalChars(totalChars); if (st == BookStatus::Ok && !writer.finish()) st = BookStatus::IoError; scratch.release(mark); if (st != BookStatus::Ok) return st; indexArena.reset(); return reader.open(cache, cacheName, hash, indexArena); } bool turn(int dir) { if (!open) return false; if (dir > 0 && pageInChapter + 1 < reader.pageCount()) { ++pageInChapter; return true; } if (dir > 0 && spineIndex + 1 < bk.spineCount()) { return ensureChapter(spineIndex + 1) == BookStatus::Ok; } if (dir < 0 && pageInChapter > 0) { --pageInChapter; return true; } if (dir < 0 && spineIndex > 0 && ensureChapter(spineIndex - 1) == BookStatus::Ok) { pageInChapter = reader.pageCount() > 0 ? reader.pageCount() - 1 : 0; return true; } return false; } void renderCurrent(const book::FrameTarget& frame) { if (!open) return; const size_t mark = scratch.mark(); book::Page page{}; if (reader.readPage(pageInChapter, scratch, &page) == BookStatus::Ok) { book::PageRenderer::renderText(page, fonts, frame, nullptr); book::PageRenderer::renderImages(page, source, bk.zip(), scratch, frame); } scratch.release(mark); } void end() { source.close(); open = false; } } session; ``` ## 5. FreeInkUI screens The library, reader, and menu are real FreeInkApp screens. Use the screen builder for layout (`s.header()`, `s.body()`, `s.navHeader()`) and FreeInkUI components for interactive surfaces. ```cpp bool isEpubFile(const char* name) { const size_t len = strlen(name); return len > 5 && strcasecmp(name + len - 5, ".epub") == 0; } bool isHiddenOrSystemDir(const char* name) { return name == nullptr || name[0] == 0 || name[0] == '.' || strcasecmp(name, "BookCache") == 0 || strcasecmp(name, "System Volume Information") == 0 || strcasecmp(name, "fonts") == 0 || strcasecmp(name, "sleep") == 0 || strcasecmp(name, "screenshots") == 0 || strcasecmp(name, "themes") == 0; } void scanBooksIn(const char* dirPath, uint8_t depth) { if (bookCount >= kMaxBooks) return; const bool root = dirPath[0] == '/' && dirPath[1] == 0; FsFile dir = SdMan.open(dirPath, O_RDONLY); if (!dir || !dir.isDirectory()) { if (dir) dir.close(); return; } for (FsFile f = dir.openNextFile(); f && bookCount < kMaxBooks; f = dir.openNextFile()) { char name[128]; f.getName(name, sizeof(name)); if (f.isDirectory()) { if (depth < 6 && !isHiddenOrSystemDir(name)) { char child[160]; snprintf(child, sizeof(child), "%s%s%s", dirPath, root ? "" : "/", name); f.close(); scanBooksIn(child, static_cast(depth + 1)); continue; } f.close(); continue; } f.close(); if (name[0] == '.' || !isEpubFile(name)) continue; snprintf(bookPaths[bookCount], sizeof(bookPaths[bookCount]), "%s%s%s", dirPath, root ? "" : "/", name); snprintf(bookTitles[bookCount], sizeof(bookTitles[bookCount]), "%.*s", static_cast(strlen(name) - 5), name); bookItems[bookCount] = ui::ListItem{}; bookItems[bookCount].label = bookTitles[bookCount]; bookItems[bookCount].actionValue = static_cast(bookCount); ++bookCount; } if (dir) dir.close(); } void scanBooks() { bookCount = 0; for (int i = 0; i < kMaxBooks; ++i) bookItems[i] = ui::ListItem{}; scanBooksIn("/", 0); } void libraryScreen(App::ScreenType& s, void*) { s.header("All Books"); ui::Rect listRect = s.body().inset({0, 10, 0, 10}); ui::ListProps list; list.items = bookItems; list.count = bookCount; list.selectedIndex = selectedBook; list.topIndex = bookTop; list.action = ActionOpenBook; list.labelText = s.theme().bodyText; list.rowStyles = ui::selectedPlainListRowStyles(); list.rowHeight = s.theme().rowHeight; list.scrollIndicator = true; bookVisibleRows = ui::listVisibleRows(listRect, list.rowHeight, list.rowGap); ui::list(s.frame(), listRect, list); } void readerScreen(App::ScreenType& s, void*) { // The visible page is composited in loop() after FreeInkApp registers these // hit zones. Reader chrome is also drawn after the page so text cannot // overwrite it. const ui::Rect body = s.body(); const int16_t third = static_cast(body.width / 3); const ui::TapZone zones[3] = { {ui::Rect{body.x, body.y, third, body.height}, ActionPagePrev}, {ui::Rect{static_cast(body.x + third), body.y, third, body.height}, ActionBackToReader}, {ui::Rect{static_cast(body.x + 2 * third), body.y, static_cast(body.width - 2 * third), body.height}, ActionPageNext}, }; ui::TapZonesProps taps; taps.zones = zones; taps.count = 3; taps.swipeLeft = ActionPageNext; taps.swipeRight = ActionPagePrev; taps.back = ActionBackToLibrary; ui::tapZones(s.frame(), body, taps); } void menuScreen(App::ScreenType& s, void*) { s.navHeader("Reader Menu", ActionBackToReader, ui::BitmapRef{}, nullptr, ui::EdgesNone); s.centeredText("Swipe up from the bottom edge to resume."); } void drawReaderChromeOverlay() { ui::InteractionBuffer<1> interactions; ui::InputSnapshot input; ui::Frame<1> frame(*target, app->device(), input, interactions, app->assets()); char pageLabel[24]; snprintf(pageLabel, sizeof(pageLabel), "%lu/%lu", static_cast(session.pageInChapter + 1), static_cast(session.reader.pageCount())); ui::ReaderChromeProps chrome; chrome.showTop = false; chrome.bottom.title = "Reader"; chrome.bottom.trailing = pageLabel; chrome.bottom.text = app->theme().smallText; chrome.bottom.fillBackground = true; ui::readerChrome(frame, frame.safeRect(), chrome); } ``` ## 6. Actions, setup, and render loop Action handlers only mutate state and invalidate or transition screens. For the reader,`app->render()` registers the FreeInkUI input zones, then the app clears the visual framebuffer, renders the cached EPUB page, draws reader chrome over it, and starts the async panel refresh. The explicit `display.clearScreen(0xFF)` in the reader branch matters:`book::PageRenderer` draws ink but does not clear old glyph pixels. ```cpp void onOpenBook(const ui::ActionEvent& e, void*) { selectedBook = e.value; if (selectedBook >= 0 && selectedBook < bookCount && session.begin(bookPaths[selectedBook])) { goToPage(Screen::Reader); } } void onPageTurn(const ui::ActionEvent& e, void*) { if (session.turn(e.action == ActionPageNext ? 1 : -1)) { app->invalidate(ui::RefreshHint::Fast); } } void onBackToReader(const ui::ActionEvent&, void*) { goToPage(Screen::Reader); } void onBackToLibrary(const ui::ActionEvent&, void*) { session.end(); goToPage(Screen::Library); } void setup() { BoardConfig::holdPowerRails(); BoardConfig::releaseSdRail(); delay(10); SdMan.begin(); display.begin(); input.begin(); input.beginAsync(/*taskPriority=*/2, /*pollMs=*/10); static ui::DisplayTarget displayTarget( display.getFrameBuffer(), display.getDisplayWidth(), display.getDisplayHeight(), display.getDisplayWidthBytes(), ui::Orientation::Portrait); static App application(displayTarget, displayTarget.deviceContext()); target = &displayTarget; app = &application; app->setClearColor(ui::Color::White); bookBuf = static_cast(ps_malloc(512 * 1024)); scratchBuf = static_cast(ps_malloc(512 * 1024)); indexBuf = static_cast(ps_malloc(64 * 1024)); fonts.add(&builtinFont); app->on(ActionOpenBook, onOpenBook); app->on(ActionPageNext, onPageTurn); app->on(ActionPagePrev, onPageTurn); app->on(ActionBackToReader, onBackToReader); app->on(ActionBackToLibrary, onBackToLibrary); scanBooks(); goToPage(Screen::Library, /*initialPaint=*/true); } void loop() { float sx0, sy0, sx1, sy1; while (input.popSwipe(sx0, sy0, sx1, sy1)) { const ui::Point a = ui::touchToLogical(app->device(), sx0, sy0); const ui::Point b = ui::touchToLogical(app->device(), sx1, sy1); const int16_t dx = static_cast(b.x - a.x); const int16_t dy = static_cast(b.y - a.y); const bool vertical = abs(dy) > abs(dx); if (screen == Screen::Reader && vertical && a.y <= app->device().height * 14 / 100 && dy > 0) { goToPage(Screen::Menu); break; } if (screen != Screen::Library && vertical && a.y >= app->device().height * 86 / 100 && dy < 0) { if (screen == Screen::Menu) goToPage(Screen::Reader); else { session.end(); goToPage(Screen::Library); } break; } if (screen == Screen::Library && vertical && bookVisibleRows > 0 && bookCount > bookVisibleRows) { const uint16_t step = bookVisibleRows > 1 ? bookVisibleRows - 1 : 1; const uint16_t maxTop = static_cast(bookCount - bookVisibleRows); bookTop = dy < 0 ? min(bookTop + step, maxTop) : (bookTop > step ? bookTop - step : 0); app->invalidate(ui::RefreshHint::Fast); } } float nx, ny; while (input.popTouchTap(nx, ny)) { const ui::Point p = ui::touchToLogical(app->device(), nx, ny); ui::InputSnapshot tap; tap.touchReleased = true; tap.touchX = p.x; tap.touchY = p.y; app->route(tap); } static ui::RefreshHint pending = ui::RefreshHint::None; if (app->invalidated()) { app->render(); if (screen == Screen::Reader && session.open) { // PageRenderer only inks the glyph/image pixels present on this page; it // does not erase pixels from the previous page. Start every reader frame // from white before compositing the cached page. display.clearScreen(0xFF); book::FrameTarget frame{ display.getFrameBuffer(), static_cast(display.getDisplayWidth()), static_cast(display.getDisplayHeight()), static_cast(display.getDisplayWidthBytes()), book::FrameFormat::Mono1Dithered, book::FrameRotation::Portrait, }; session.renderCurrent(frame); drawReaderChromeOverlay(); } const ui::RefreshHint hint = app->lastRenderRefreshHint(); if (static_cast(hint) > static_cast(pending)) pending = hint; } if (pending != ui::RefreshHint::None && !display.refreshBusy()) { ui::presentAsync(display, pending); pending = ui::RefreshHint::None; } } ``` ## 7. Gesture contract Keep edge gestures strict. The bottom-home gesture only triggers when the swipe starts in the bottom 14% of the screen, so ordinary list scrolling does not exit the screen. The menu gesture is the mirror: top-edge downward swipe, starting in the top 14% of the reader surface. ## 8. E-paper refresh policy Page turns use `ui::RefreshHint::Fast`, just like the full reader app. Ghost prevention is handled below the reader: `ui::presentAsync(display, hint)` calls`FreeInkDisplay::displayBufferAsync()`, which supplies the panel driver with the previous displayed frame. On differential panels, that previous-frame baseline is what keeps fast reader turns from smearing. The first boot paint is still `Full`, and each panel driver may promote or clear internally when its controller requires it. ## 9. Where to grow from here Keep the first version boring. A reader gets painful when navigation, caching, and power behavior are added too late, so grow the starter in this order: - **Persist reading position.** Write a tiny progress record beside the page cache, for example `/BookCache//progress.bin`, with the current spine, character offset, font size, and percentage. Save after page turns and before leaving the reader. - **Cache library metadata.** Parse each EPUB once for title, author, and cover href, then store that in `/BookCache`. The list should render from cached metadata and only fall back to filenames when metadata is missing. - **Add a real reader menu.** Replace the placeholder menu with a scrollable TOC list. Use the book TOC when present, fall back to spine items when it is not, and keep the bottom-edge swipe as the fast path back to the page. - **Make opening feel responsive.** If a first open needs to paginate, draw a FreeInkUI toast or popup before indexing. Kick off the panel refresh, then do CPU/SD work while the e-paper update is in flight. - **Improve the library one surface at a time.** Add folder navigation before covers if you expect large SD cards. Add covers later, cache decoded thumbnails, and keep the All Books path as a simple list so it stays fast. - **Add settings after the core loop is solid.** Start with font size, line height, margins, orientation, and embedded CSS. Store settings in one small binary or JSON file and make every setting invalidate the page cache only when it changes layout. - **Support the hardware buttons.** Map side buttons to page up/down in the reader, list scrolling in the library/menu, and power to a sleep screen plus deep sleep. Keep touch and buttons using the same action handlers where possible. - **Watch memory early.** EPUB parsing, page layout, image decoding, and UI buffers all compete for RAM. Prefer fixed-size arenas, cache files on SD, and one decoded cover buffer per visible item instead of keeping the whole library hot. --- # PlatformIO setup > Add the FreeInk libraries to a PlatformIO project. Group: Getting started · URL: https://freeink.org/docs/installation FreeInk integrates with PlatformIO. Two sample configs in the repository are ready to copy — one for greenfield projects and one for dropping the SDK into an existing CrossPoint checkout. ## Sample configurations - [`platformio.sample.ini`](https://github.com/Free-Ink/freeink-sdk/blob/main/platformio.sample.ini) — a complete, ready-to-copy configuration. It mirrors the toolchain and flags verified against the CrossPoint firmware and includes per-device build envs (`xteink`, `xteink_x4`, `m5paper`, `m5paper_official`, `delink`, `murphy`, `m5paper_v11`) wired with the right `FREEINK_DEVICE_*` flags. - [`platformio.crosspoint.sample.ini`](https://github.com/Free-Ink/freeink-sdk/blob/main/platformio.crosspoint.sample.ini) — mirrors the exact working CrossPoint setup. ## The minimum: symlinked lib_deps At minimum, add the libraries you need as symlink `lib_deps`. The names match the original SDK, so existing firmware compiles unchanged: ```platformio.ini lib_deps = BoardConfig=symlink://path/to/freeink-sdk/libs/hardware/BoardConfig EInkDisplay=symlink://path/to/freeink-sdk/libs/display/FreeInkDisplay InputManager=symlink://path/to/freeink-sdk/libs/hardware/InputManager BatteryMonitor=symlink://path/to/freeink-sdk/libs/hardware/BatteryMonitor SDCardManager=symlink://path/to/freeink-sdk/libs/hardware/SDCardManager ; optional: PowerManager=symlink://path/to/freeink-sdk/libs/hardware/PowerManager FrontlightManager=symlink://path/to/freeink-sdk/libs/hardware/FrontlightManager SecureNet=symlink://path/to/freeink-sdk/libs/network/SecureNet ``` `#include ` and the `EInkDisplay` type keep working via the compat shim. The display libs depend on `BoardConfig`; `SdFat` is pulled in automatically as a dependency of `SDCardManager`. ## Already on CrossPoint? Drop `platformio.crosspoint.sample.ini` into the CrossPoint repo as `platformio.local.ini`, point the paths at your FreeInk SDK checkout, and: ```bash pio run -e default ``` builds the X3 + X4 ESP32-C3 binary against this SDK with **no source changes** — the compat shim preserves every include path and class name. ## Arduino startup linker workaround If a CrossPoint local override hits a final-link error for undefined `app_main` and `loopTaskHandle`, add the Arduino startup object to that env's `build_flags`: ```platformio.ini -Wl,.pio/build/default/FrameworkArduino/main.cpp.o ``` This is a PlatformIO/pioarduino archive-order quirk: Arduino's startup symbols live in `FrameworkArduino/main.cpp.o`, and some local env overrides don't pull that member from `libFrameworkArduino.a` before ESP-IDF asks for `app_main`. Change `default` in the path if your env name differs. The CrossPoint sample includes the same note. > **Picking devices** > > Which devices a binary supports is controlled by `-DFREEINK_DEVICE_*` build flags, and capabilities by `-DFREEINK_CAP_*`. See [Build composition](https://freeink.org/docs/build-composition) for the full flag matrix. --- # Architecture > The facade, panel drivers, the bus, and board config. Group: Concepts · URL: https://freeink.org/docs/architecture The firmware calls one generic API. Underneath, a facade owns the framebuffer and geometry and delegates every panel operation to a per-controller driver, which talks to the panel over a shared bus helper. Device specifics are injected, never hardcoded. ``` firmware ─calls─▶ EInkDisplay (alias of freeink::FreeInkDisplay, the facade) │ owns framebuffer + geometry, selects a driver at begin() ▼ PanelDriver (interface) ┌───────────┼───────────────┬───────────────┐ Ssd1677Driver Uc8253X3Driver Ed2208M5Driver Uc8253MurphyDriver (X4/de-link) (X3) (M5) (Murphy) │ native controllers share ▼ EpdBus (SPI/GPIO framing, BUSY polarity, reset, mirror) External-bus drivers (M5OfficialDriver, LgfxEpdDriver/LilyGo, It8951Driver/M5Paper) own their own bus and report usesExternalBus(), so the facade leaves EpdBus down. ``` ## FreeInkDisplay — the facade Exposed to firmware as `EInkDisplay`, the facade owns the framebuffer(s) and geometry and delegates every panel operation to a `PanelDriver`. It preserves the full public API, including: - The `FULL_REFRESH` / `HALF_REFRESH` / `FAST_REFRESH` modes. - The grayscale / anti-aliased dual-plane path — `copyGrayscaleBuffers` → `displayGrayBuffer`, `writeGrayscalePlaneStrip`. ## PanelDriver — one per controller Each controller has one driver implementation, in its own file. A driver owns its register sequences and cross-call state, and takes its waveforms, LUTs and tunables as an injected **config** (e.g. `Ssd1677Config`, `Uc8253X3Config`) — so per-device tuning is data, not code. A driver doesn't even have to emit raw SPI; it can wrap a third-party display library. See [Adding a device](https://freeink.org/docs/adding-a-device). ## EpdBus — the shared bus helper A shared SPI/GPIO helper, parameterized by SPI clock and BUSY polarity. Each controller sets a default, overridable per board via `BoardConfig::ACTIVE.displaySpiHz`. It handles framing, BUSY polling, reset and mirroring. ## BoardConfig — the device description The one compile-time-selected description of a device: pins, geometry, controller, input style, touch, frontlight and audio. ## Nothing device-specific is hardcoded in generic code GPIOs come from the `EInkDisplay` constructor (firmware passes `BoardConfig::ACTIVE.display.*`) and from `BoardConfig`. SPI clocks have a controller default and a board override. Waveforms, LUTs, booster values, scan direction and refresh temperatures are injected via the driver config struct. A new device fills in values; the generic driver consumes them. --- # Build composition > Compose a binary along two axes: devices × capabilities. Group: Concepts · URL: https://freeink.org/docs/build-composition A FreeInk build is composed along two axes: the **devices** a binary supports and the **capabilities** compiled into it. Capabilities default on when an included device needs them, so each binary stays as tight as the hardware allows. ## Devices `-DFREEINK_DEVICE_` declares which hardware the binary supports. Each device pulls in its panel driver, adds its board profile to the runtime registry, and turns on its default capabilities. You can compose any set that shares an MCU family — a build targets exactly one of ESP32-C3, ESP32-S3 or classic ESP32, and mixing families is a compile error. | Pass | Result | | --- | --- | | `-DFREEINK_DEVICE_X4` | X4 only — links just SSD1677 (tightest) | | `-DFREEINK_DEVICE_X3 -DFREEINK_DEVICE_X4` | X3 and X4 in one C3 binary, runtime-selected via `setDisplayX3()` | | `-DFREEINK_DEVICE_DELINK` | de-link (S3, SSD1677 + frontlight) | | `-DFREEINK_DEVICE_M5` | M5 PaperColor (S3, ED2208 + color) | | `-DFREEINK_DEVICE_MURPHY` | Murphy M3 (S3, UC8253 + touch + frontlight) | | `-DFREEINK_DEVICE_LILYGO` | LilyGo T5 S3 (S3, ED047TC1 raw-parallel EPD via LovyanGFX) | | `-DFREEINK_DEVICE_M5PAPER` | M5Paper v1.1 (classic ESP32, IT8951E + GT911 touch) | | `-DFREEINK_DEVICE_STICKY` | Sticky (S3, SSD1677 800×480 + GT911 touch + PDM mic + sensor suite) | | *(none)* | **compile error** — a build must select at least one device | Multiple different-pinout devices on one MCU are runtime-selected: `ACTIVE` defaults to a compile-time default and the consumer calls `BoardConfig::selectDevice(...)` after its own detection. For X3/X4, the SDK now ships the canonical detector — [XteinkDetect](https://freeink.org/docs/lib-detect)'s `selectXteinkDevice()` I²C-fingerprints the X3-only peripherals and selects the profile for you. Every SDK library compiles cleanly on **all three** MCU families — ESP32-C3, ESP32-S3 and the classic ESP32 — so only a consumer's own layer can block a multi-MCU build by hardcoding chip-specific code. See [MCU portability](https://freeink.org/docs/mcu-portability). ## Capabilities `-DFREEINK_CAP_` gates feature *code* to keep binaries tight. Each defaults on when an included device needs it; force with `=0` / `=1`. | Flag | Gates | Default | | --- | --- | --- | | `FREEINK_CAP_TOUCH` | capacitive touch decoder (InputManager) | on if a device has touch | | `FREEINK_CAP_FRONTLIGHT` | PWM frontlight (FrontlightManager) | on if a device has a frontlight | | `FREEINK_CAP_COLOR` | color panel code | on for M5 | | `FREEINK_CAP_AUDIO` | WAV-over-I2S audio (AudioManager: ES8388 / ES8311 codec) | on for Murphy M3 and M5 PaperColor | | `FREEINK_CAP_LED` | addressable RGB LEDs (LedManager) | on for M5 PaperColor | | `FREEINK_CAP_BUZZER` | LEDC PWM tone buzzer (Buzzer) | on for Sticky and Murphy M3 | | `FREEINK_CAP_MIC` | PDM microphone capture (Microphone) | on for Sticky | | `FREEINK_CAP_RTC` | real-time clock (Rtc: PCF8563 / DS3231) | on for X3 and Sticky | | `FREEINK_CAP_TEMP_HUMIDITY` | SHT40 temperature + humidity (EnvironmentSensor) | on for Sticky | | `FREEINK_CAP_IMU` | 6-axis IMU (Imu: LSM6DS3TR-C / QMI8658) | on for X3 and Sticky | | `FREEINK_CAP_NET_TLS13` | wolfSSL TLS 1.3 (≡ `FREEINK_NET_WOLFSSL`) | off | | `FREEINK_CAP_BLE_HID_HOST` | BLE HID host ([BleKeyboardHost](https://freeink.org/docs/lib-ble); add a NimBLE `lib_dep`, ESP32-C3/S3 only) | off | ## Other flags | Flag | Effect | | --- | --- | | `-DFREEINK_DISPLAY_FLIPPED` | (or `-DFLIPPED`) back-compat alias for `BoardProfile.orientation = MIRROR_Y` on SSD1677 | | `-DFREEINK_SD_SDMMC=1` | use the native 4-bit SDMMC backend (needs `-DUSE_BLOCK_DEVICE_INTERFACE=1`); auto-on for de-link | | `-DFREEINK_BATTERY_I2C_GAUGE=1` | compile the I²C fuel-gauge backend (BQ27220/BQ25896); auto-on for X3, LilyGo and Sticky. Gauge-vs-ADC is then runtime per profile, so X3 (gauge) + X4 (ADC) coexist in one binary | | `-DFREEINK_M5_OFFICIAL=1` | M5 PaperColor only: use the M5Unified + M5GFX vendor backend instead of the native ED2208 driver (M5GFX owns the bus) | | `-DFREEINK_M5_DARK_FAST_REFRESH=1` | M5 PaperColor only: render a dark, inverted UI on fast (interrupted) refreshes — the upstream community-SDK "dark hack" (logical white written as controller black). Default `0` keeps the native light "paper" UI, where the cut-off waveform leaves logical-white pixels yellow. Complete waveforms stay truthful either way | | `-DEINK_DISPLAY_SINGLE_BUFFER_MODE=1` | single framebuffer (uses controller RAM as the previous frame) | | `-DFREEINK_FB_PSRAM=1` | place the facade framebuffer(s) in PSRAM heap (`MALLOC_CAP_SPIRAM`, allocated in `begin()`) instead of static DRAM `.bss`; auto-on for M5Paper, off everywhere else. Needs `-DBOARD_HAS_PSRAM` | | `-DFREEINK_NET_WOLFSSL=1` | enable the wolfSSL TLS 1.3 transport in `SecureNet` | Panel **orientation / mirroring is per-board data, not a flag**: set `BoardProfile.orientation` to `NO_FLIP`, `MIRROR_X`, `MIRROR_Y` or `ROTATE_180`. The SSD1677 driver applies it in hardware (mirrorX via RAM column addressing, mirrorY via gate scan). 90° / 270° need a software transpose and are a follow-up. See [Adding a device](https://freeink.org/docs/adding-a-device). **Power-enable rails and the I²C bus are board data too.** A profile can name an active-high `powerEnable` GPIO for the panel (`DisplayPins`), the SD card rail (`SdPins`), the touch controller (`TouchConfig`) or the mic (`MicConfig`); the SDK raises each at `begin()` with a settle delay, and a profile that leaves it unassigned just skips it. On multi-bus SoCs (ESP32-S3), a profile picks `Wire` vs `Wire1` per peripheral via an `i2cBus` field, so the Sticky keeps its touch controller and its sensor cluster on separate physical buses. None of this is a build flag — it's data in [BoardConfig](https://freeink.org/docs/lib-board). **Framebuffer placement.** The facade's framebuffer(s) sit in static DRAM `.bss` by default — fastest, and the panel sizes fit comfortably on the C3/S3 parts (the largest, 960×540, is ~63 KB). M5Paper v1.1 is the exception: the classic ESP32 shares its ~300 KB of DRAM with the IDF/WiFi stacks, so that 63 KB framebuffer overflows internal RAM. `FREEINK_FB_PSRAM` defaults on there and heap-allocates the framebuffer in PSRAM once, in `begin()`, with a DRAM fallback. DRAM is faster than cache-backed PSRAM and the buffer is touched heavily during composition, so it stays off elsewhere — but any DRAM-tight build (e.g. a feature-heavy LilyGo T5 S3) can opt in with `-DFREEINK_FB_PSRAM=1`. > **One binary, two devices** > > X3 and X4 share the ESP32-C3 and a pinout, so a single firmware binary drives both: it carries both board profiles and picks one at runtime via `setDisplayX3()`. The rules for when devices can share a binary are covered in [Adding a device](https://freeink.org/docs/adding-a-device). --- # Supported devices > The device matrix, refresh behavior and touch support. Group: Concepts · URL: https://freeink.org/docs/devices FreeInk ships drivers and board profiles for the following devices. Related boards can share a single firmware build, detected and configured at runtime; new controllers slot in as a standalone driver behind the facade. ## Device matrix | Device | MCU | Controller | Panel | | --- | --- | --- | --- | | Xteink X4 | ESP32-C3 | SSD1677 | 800×480 B/W + 4-level gray | | Xteink X4 Pro | ESP32-S3 | SSD1677 / UC8179 | 800×480 B/W, GT911 touch, warm/cool frontlight, PCF8563 RTC, CW2017 gauge, SDMMC SD, USB MSC | | Xteink X3 | ESP32-C3 | UC8253 / UC8279 | 792×528 B/W + 4-level gray, BQ27220 I²C gauge, DS3231 RTC, QMI8658 IMU | | de-link | ESP32-S3 | SSD1677 | 800×480 B/W + gray, frontlight, SDMMC SD | | M5Stack PaperColor | ESP32-S3 | ED2208 | 400×600 Spectra-6 color, built-in speaker (ES8311 + AW8737A amp), 2× RGB LEDs | | Murphy M3 | ESP32-S3 | UC8253 | 240×416 B/W, CHSC6x touch, PWM frontlight | | Murphy M4 | ESP32-S3 | SSD1677 | 800×480 B/W, FT6336U touch, 5-key nav, warm/cool frontlight, SDMMC SD, ADC battery | | LilyGo T5 S3 | ESP32-S3 | ED047TC1 (raw parallel) | 960×540 16-gray, GT911 touch, backlight, I²C gauge | | M5Paper v1.1 | ESP32 (classic) | IT8951E | 540×960 16-gray ED047TC1, GT911 touch, GPIO35 ADC battery | | Sticky | ESP32-S3 | SSD1677 | 3.97" 800×480 B/W, GT911 touch, PDM mic, RTC + temp/humidity + IMU, BQ27220 gauge, buzzer | | M5 Paper Mono | ESP32-S3 | SSD1677 | 800×480 B/W + 3-gray, FT6336 touch, frontlight, PDM mic, buzzer, RGB LED, RX8130 RTC, SDMMC SD | | M5 PaperS3 | ESP32-S3 | ED047TC1 (raw parallel) | 4.7" 960×540 16-gray, GT911 touch-only, buzzer, BM8563 RTC, SPI SD | X3 and X4 share the ESP32-C3 and a pinout, so a single firmware binary drives both — it carries both board profiles (`XTEINK_X4` and `XTEINK_X3`) and picks one at runtime via `setDisplayX3()`, which swaps the active profile and driver. Distinct-MCU boards build their own binary, selected with a board macro. A build targets exactly one of **three MCU families** — ESP32-C3 (X3/X4), ESP32-S3 (X4 Pro, de-link, PaperColor, Murphy M3/M4, LilyGo, Sticky, Paper Mono, PaperS3) or classic ESP32 (M5Paper v1.1) — and `BoardConfig` rejects mixing families at compile time. The **Xteink X4 Pro** is a distinct ESP32-S3 device, not the C3 X4 — its own `XTEINK_X4_PRO` profile (16 MB flash, 8 MB PSRAM), built with `-DFREEINK_DEVICE_X4PRO=1`. It reuses the X4's 800×480 SSD1677 panel and OTP waveform, and adds GT911 touch, a dual warm/cool frontlight, a PCF8563-compatible RTC, a CW2017 fuel gauge, native 1-bit SDMMC storage, and host transfer over USB — as mass storage or a serial transport. The panel controller **varies by production batch** — original units carry the SSD1677, newer ones a UC8179 (an UltraChip part on the same glass and pinout) — so the firmware fingerprints the live display bus at boot and promotes to the matching driver via [XteinkDetect](https://freeink.org/docs/lib-detect)'s `applyXteinkDisplayController()` before `begin()`. The **M5Stack Paper Mono** (PaperS3) is an ESP32-S3 board on the same 800×480 SSD1677 glass, built with `-DFREEINK_DEVICE_PAPERMONO=1`. Its own `PaperMonoDriver` runs **host-authored 111-byte LUTs** instead of the stock OTP set: binary UI and Fast reader paints use the panel's non-flashing internal waveform, while balanced book pages get a single target-coded W/G/B **3-gray** activation with a white-biased per-page top-up that erases a little residue on every turn rather than letting ghosts accumulate. On-board an `M5IOE1` I²C IO expander and an `M5PM1` PMIC switch the EPD, frontlight (AW9967 boost driver) and microSD rails, so those GPIO fields stay unassigned and a consumer board-support library (`PaperMonoBoard.h` / `M5Ioe1.h`) supplies the power hooks. FT6336 capacitive touch, a PDM mic, a passive buzzer, a discrete RGB LED, an RX8130 RTC and native SDMMC storage round out the profile. de-link reuses the X4's SSD1677 panel on an ESP32-S3, adding a warm/cool frontlight and **native 4-bit SDMMC storage**. SdFat can't drive SDIO, so FreeInk mounts a volume on an esp-idf SDMMC block device (auto-enabled via `FREEINK_SD_SDMMC`) — see [Build composition](https://freeink.org/docs/build-composition). Its panel orientation is set in the board profile rather than at compile time, so an upside-down PCB just sets `ROTATE_180` and the driver mirrors in hardware. The **LilyGo T5 S3** is a different display class: its ED047TC1 is a raw 960×540 16-gray parallel EPD with no on-glass controller, so FreeInk drives it through **LovyanGFX's `Panel_EPD`** (bundled in `m5stack/M5GFX`) rather than emitting raw SPI. The `LgfxEpdDriver` reports `usesExternalBus()` and holds an 8-bit grayscale canvas in PSRAM; the B/W and 16-gray paths both push that sprite at the requested waveform. The `BoardConfig::LILYGO_T5S3` profile carries its geometry, GT911 touch, PWM backlight and BQ27220/BQ25896 I²C battery gauge. A dedicated `BoardT5S3` support library now fills the board-level gaps — it drives the PCA9535 I²C IO expander (the user button) and the TPS65185 EPD PMIC, exposes mutex-guarded I²C access, and supplies the board-injected `LgfxEpdConfig` + power hooks. See [Adding a device](https://freeink.org/docs/adding-a-device) for the external-library driver pattern. The **Murphy M3** (CrowPanel 3.7″) pairs its UC8253 with a 90° hardware rotation: the controller is a 240×416 portrait panel held landscape, so the facade owns a 416×240 framebuffer and the `Uc8253MurphyDriver` rotates each plane into controller RAM on write. It loads dual waveform banks — a full 3-phase (ghost-clearing) LUT and a destination-drive-only fast LUT — and promotes a fast refresh to a full one every few refreshes to keep ghosting in check. CHSC6x touch, a PWM frontlight, an ES8388-compatible I2S audio codec (driven by [AudioManager](https://freeink.org/docs/lib-audio)) and a battery ADC on GPIO9 (read through [BatteryMonitor](https://freeink.org/docs/lib-battery)) round out the board. The **Murphy M4** is a larger ESP32-S3 sibling of the M3: it drops the small UC8253 for the X4-class **SSD1677** on a 800×480 GDEQ0426T82 panel (landscape glass mounted in a portrait housing; a software rotation is pending). It swaps the M3's CHSC6x for **FT6336U** capacitive touch, keeps a five-key nav cluster, and adds a **dual warm/cool frontlight** plus native 4-bit SDMMC storage, with battery read off an ADC divider. There's no audio codec on this one — the buzzer/codec fields stay unassigned. The **M5Paper v1.1** is FreeInk's first **classic ESP32** target — a third MCU family alongside the C3 and S3 boards. Its 540×960 16-gray ED047TC1 sits behind an on-glass **IT8951E** controller, so FreeInk drives it with a **hand-rolled IT8951 driver** (`It8951Driver`) that owns its own SPI bus (`usesExternalBus()`). It loads frames by packing the 1-bpp framebuffer into the IT8951's 4-bpp image buffer on the fly and auto-rotates the landscape framebuffer onto the portrait panel. It drives the controller's native waveform modes — `GC16` for a full clearing refresh, `DU` for fast B/W page turns, and `DU4` for 4-level grayscale updates without a full-area flash — and the full anti-aliased grayscale path runs here too, reconstructing the base plus LSB/MSB planes into the IT8951's native 16-level format. A configurable `ghostClearInterval` periodically promotes a differential (DU/DU4) refresh to a GC16 clear, so residue doesn't accumulate during navigation without any firmware intervention. GT911 touch and a GPIO35 ADC battery read complete the board. Its only physical buttons are a 3-position rotary wheel: the two sides map to `BTN_UP` / `BTN_DOWN` for page navigation and the push is `BTN_CONFIRM`, which doubles as the power/wake button (it sits on an RTC GPIO, so it drives the `ext1` deep-sleep wakeup). Back/Left/Right come from the touch panel. The **M5Stack PaperS3** is the S3 successor to the M5Paper v1.1: the same 960×540 16-gray ED047TC1 glass but with **no IT8951** — the S3 drives the panel directly over the 8-bit parallel bus, the same display class as the LilyGo T5 S3, so it shares the `LgfxEpd` driver ([LovyanGFX](https://freeink.org/docs/adding-a-device) `Panel_EPD` via `m5stack/M5GFX`). Unlike the LilyGo there's no PMIC or IO expander — the EPD rails are plain GPIOs that `Bus_EPD`'s stock power sequence drives itself, so the `BoardPaperS3` support library carries real pins and no power hooks. There are **no firmware-readable buttons**: the single side button feeds a PMS150G power-latch chip, so all navigation is **GT911 touch** (tap zones and gestures are firmware policy), and power-off pulses a GPIO rather than releasing a latch. A BM8563 RTC (whose alarm line wakes the latch), an ADC battery read and a LEDC buzzer round it out; the on-board BMI270 IMU isn't a supported `ImuType` yet, so it's left out of the profile. The **Sticky** (Seeed) reuses the X4-class **SSD1677** driver for its 3.97″ 800×480 B/W panel (its 24-pin FPC needs vendor full/fast update sequences and border tracking, supplied as driver config), with GT911 touch. Beyond the display it carries a whole peripheral suite, each behind its own opt-in library: a **PDM microphone** ([Microphone](https://freeink.org/docs/lib-mic)), a **PCF8563 RTC** ([Rtc](https://freeink.org/docs/lib-rtc)), an **SHT40** temperature/humidity sensor ([EnvironmentSensor](https://freeink.org/docs/lib-env)), an **LSM6DS3TR-C** 6-axis IMU ([Imu](https://freeink.org/docs/lib-imu)), a **BQ27220** I²C fuel gauge ([BatteryMonitor](https://freeink.org/docs/lib-battery)), a LEDC **buzzer** ([Buzzer](https://freeink.org/docs/lib-buzzer)), and an SPI MicroSD that shares the display bus. It is the SDK's first **multi-bus I²C** board: the GT911 touch controller sits on `Wire`, while the gauge and the whole sensor cluster share `Wire1`, kept apart so neither stalls the other. Power-enable GPIOs gate the panel, touch controller, SD rail and mic rail independently (the board profile names each pin; the SDK raises them at `begin()`). Its GT911 is mounted rotated, corrected SDK-side by the touch profile's `swapXY` / `flipX` / `flipY` flags. ## M5Stack PaperColor refresh behavior The PaperColor is natively a **six-color (Spectra 6), full-refresh** panel: a complete OTP waveform takes **~15 s** — unusable for reading. To get reading-compatible speeds, FreeInk's native driver **interrupts the refresh at ~340 ms**. The colors settle in order with white settling last, so cutting off early leaves logical-white pixels **yellow** rather than settled white — and FreeInk's default light "paper" UI embraces that, drawing dark text on the warm yellow ground for a fast, high-contrast monochrome image. Passing `-DFREEINK_M5_DARK_FAST_REFRESH=1` instead selects the upstream community-SDK "dark hack" (logical white written as the controller's black), giving an inverted black-background UI on fast refreshes. Either way, a true white background / full color requires running the complete waveform (`requestCompleteWaveformNextRefresh()`), which settles truthfully. The board also carries a **built-in speaker** — an ES8311 codec into an AW8737A amp, driven by [AudioManager](https://freeink.org/docs/lib-audio) — and **two RGB LEDs** via [LedManager](https://freeink.org/docs/lib-led). Its rails, battery charging and LEDs all hang off one PMIC (see below). > **DC balance — schedule periodic complete waveforms** > > E-paper waveforms are DC-balanced only when they run to completion; the interrupted path leaves a small net charge on every pixel each refresh. That charge **accumulates** — over hours of interrupted-only operation the panel visibly darkens and color intensity fades (the driver's every-6th-refresh full-panel pass is itself interrupted, so it clears geometric ghosting, not charge). Consumers must periodically promote a refresh to the complete waveform via `requestCompleteWaveformNextRefresh()` — roughly hourly works well — timed around their own UX, since the complete waveform blocks for ~15 s. Two backends are selectable for this device: - **Native ED2208 (default)** — the fast interrupted-refresh path above. - **M5 official** (`-DFREEINK_M5_OFFICIAL=1`) — wraps M5's own M5Unified + M5GFX stack for users who prefer the vendor path (slower, but standard). This pulls the M5 libraries only on that env; M5GFX owns the bus (`usesExternalBus()`). ## Power management (M5PM1) The PaperColor's rails, battery charging and RGB LEDs all hang off one PMIC — the **M5PM1** (a PY32L020) on the board's internal I²C bus. Two FreeInk modules drive it (the ED2208 display driver and [LedManager](https://freeink.org/docs/lib-led)), and they share one physical config register, so the register map, bus init and boot power policy live in a single **header-only driver** (`M5Pm1.h`) rather than private copies that drift apart. Both libraries pick it up through their existing `BoardConfig` dependency. The PMIC's `PWR_CFG` register **auto-clears on every reset**, so the display driver re-establishes the board's standing power state at each boot: - **Battery charging on** (`CHG_EN`) — the PM1 only charges the 1250 mAh cell when this bit is set, and regulates the curve itself (charges only while USB is present, stops at full), so asserting it unconditionally is safe. Without it the battery never tops up over USB. - **5 V boost off** (`BOOST_EN`) — the Grove/5VINOUT boost is unused on this board. - **RGB rail off + PM1 NeoPixel engine disabled** — the 3.3 V LDO that feeds the WS2812 chain (`LDO_EN`) is owned by LedManager and raised lazily only while an LED is lit; the PM1's own built-in NeoPixel engine is switched off so the ESP owns the chain. Left on, that engine renders its own status pixel — the **stuck green LED** seen at boot — even while the ESP sleeps, and the state survives a USB reflash. The same PMIC also reports power telemetry, which [BatteryMonitor](https://freeink.org/docs/lib-battery) surfaces (auto-detected, no flag): battery voltage and percentage from `VBAT`, plus external-power presence on the DC input and the bidirectional USB-C rail. It has no charge-phase bit, so charging state stays unknown on this board. ## Capacitive touch Touch is implemented for three controllers (gated by `FREEINK_CAP_TOUCH`): - **CHSC6x** (Murphy M3) — IRQ-driven, ported from the upstream driver. - **GT911** (X4 Pro, LilyGo T5 S3, M5Paper v1.1, PaperS3 and Sticky) — raw register reads plus the reset/address dance; LilyGo runs it in IRQ mode, the others poll. Its capacitive home key is surfaced via `wasHomeKeyPressed()`. On the button-less PaperS3 it's the *only* input, so paging and navigation come entirely from tap zones and gestures. - **FT6336 / FT6336U** (M5 Paper Mono, Murphy M4) — register-compatible with the FT5x06 family, with init retry for a slow power-up. It reports a portrait frame, so the profile swaps it into the panel-native landscape frame and flips to follow the mounted display. The InputManager exposes `hasTouch` / `isTouchPressed` / `wasTouchPressed` / `wasTouchReleased` / `getTouchPoint`, plus tap, swipe and activity edges (see the [InputManager reference](https://freeink.org/docs/lib-input)). GT911 boards set their `TouchConfig` in the board profile (e.g. `BoardConfig::LILYGO_T5_PRO_GT911`). **Digitizer mounting is corrected SDK-side.** A panel whose touch sensor is rotated or mirrored relative to the glass sets `swapXY`, `flipX` and `flipY` in its `TouchConfig` (the Sticky's portrait sensor on a landscape panel sets all three), and the raw range fields describe the *post-swap* axes — so the InputManager hands back panel-native coordinates and the app's orientation mapping follows rotation automatically. A `TouchConfig.powerEnable` pin lets a board gate the touch controller's power rail, raised before reset/probe. --- # Libraries overview > Reference for the FreeInk libraries: APIs, defaults and build flags. Group: Libraries · URL: https://freeink.org/docs/api The SDK is a set of self-contained libraries, each its own PlatformIO dependency — add the ones your device needs ([PlatformIO setup](https://freeink.org/docs/installation)). Everything lives in `namespace freeink`; the legacy type names (`EInkDisplay` and friends) are preserved by the compatibility shim. Signatures track the SDK headers under `libs/`. For exact types and defaults, read the header for each library — see [Repository layout](https://freeink.org/docs/repository-layout). > [EInkDisplayThe facade: framebuffer, geometry, refresh modes and grayscale.](https://freeink.org/docs/lib-display)[InputManagerButtons plus capacitive touch (CHSC6x, GT911).](https://freeink.org/docs/lib-input)[BatteryMonitorADC, BQ27220 I²C fuel gauge, or M5PM1 PMIC — one API.](https://freeink.org/docs/lib-battery)[SDCardManagerSdFat-over-SPI or native 4-bit SDMMC, one API.](https://freeink.org/docs/lib-sd)[FrontlightManagerPWM frontlight with warm/cool control.](https://freeink.org/docs/lib-frontlight)[AudioManagerWAV playback through an I2S codec (ES8388 / ES8311).](https://freeink.org/docs/lib-audio)[LedManagerAddressable RGB LEDs: color, brightness, non-blocking flashes.](https://freeink.org/docs/lib-led)[BuzzerLEDC PWM square-wave tones on a passive buzzer.](https://freeink.org/docs/lib-buzzer)[MicrophonePDM microphone capture to 16-bit PCM.](https://freeink.org/docs/lib-mic)[RtcPCF8563 / DS3231 real-time clock over I²C.](https://freeink.org/docs/lib-rtc)[EnvironmentSensorSHT40 temperature + humidity over I²C.](https://freeink.org/docs/lib-env)[ImuLSM6DS3TR-C 6-axis accelerometer + gyroscope.](https://freeink.org/docs/lib-imu)[PowerManagerPortable deep-sleep wake-on-power-button.](https://freeink.org/docs/lib-power)[MemoryManagerOn-demand RAM reclaim via priority-ordered cache sinks.](https://freeink.org/docs/lib-memory)[RecoveryBootBoot-time OTA recovery escape hatch (button combo).](https://freeink.org/docs/lib-recovery)[SecureNetOpt-in wolfSSL TLS 1.3 transport.](https://freeink.org/docs/networking)[BleKeyboardHostBLE HID host for keyboards, page turners and remotes.](https://freeink.org/docs/lib-ble)[BoardConfigBoard profiles and the runtime-active device.](https://freeink.org/docs/lib-board)[XteinkDetectRuntime X3/X4 and display-controller detection via bus fingerprinting.](https://freeink.org/docs/lib-detect)[FreeInkUIOptional immediate-mode UI framework for e-paper.](https://freeink.org/docs/lib-ui)[Component galleryPrebuilt components, previewed from the real 1-bit renders.](https://freeink.org/docs/lib-ui-components)[Iconsfreeink::Icon format, vendored Lucide set, and a generator.](https://freeink.org/docs/lib-icons)[FreeInkBookA complete EPUB reading engine — layout, cache, fonts, i18n text.](https://freeink.org/docs/lib-book) --- # EInkDisplay > The display facade: framebuffer, geometry, refresh and grayscale. Group: Libraries · URL: https://freeink.org/docs/lib-display The display facade — `freeink::FreeInkDisplay`, aliased to `EInkDisplay`. It owns the framebuffer and geometry and delegates to a panel driver selected at `begin()`. See [Architecture](https://freeink.org/docs/architecture) for how the facade, drivers and bus fit together. Construct it from the active board's display SPI pins: ```cpp FreeInkDisplay(int8_t sclk, int8_t mosi, int8_t cs, int8_t dc, int8_t rst, int8_t busy); ``` ## Lifecycle & panel selection | Member | Description | | --- | --- | | `begin()` | Initialize the bus and select the panel driver for the active profile. | | `setDisplayX3()` | Switch to the X3 profile + UC8253 driver (before begin(), on a C3 X3/X4 binary). | | `setDisplayM5PaperColor()` | Switch to the M5 PaperColor profile + ED2208 driver. | | `requestCompleteWaveformNextRefresh()` | M5 only: run the next refresh’s OTP waveform to completion (one-shot). | | `deepSleep()` | Power the panel down. | ## Geometry | Member | Description | | --- | --- | | `getDisplayWidth() / getDisplayHeight()` | Active panel dimensions in pixels. | | `getDisplayWidthBytes()` | Row stride in bytes. | | `getBufferSize()` | Framebuffer size in bytes. | | `DISPLAY_WIDTH, DISPLAY_HEIGHT, BUFFER_SIZE, …` | Compile-time constants (plus X3_* variants). | ## Drawing into the framebuffer | Member | Description | | --- | --- | | `clearScreen(uint8_t color = 0xFF)` | Fill the buffer (0xFF = white). | | `drawImage(data, x, y, w, h, fromProgmem = false)` | Blit a 1-bpp bitmap. | | `drawImageTransparent(data, x, y, w, h, fromProgmem = false)` | Blit, skipping background pixels (icons). | | `setFramebuffer(const uint8_t* bwBuffer)` | Replace the B/W buffer wholesale. | | `getFrameBuffer()` | Pointer to the active framebuffer. | | `swapBuffers()` | Swap the double-buffered framebuffers. | ## Refresh Refresh modes: `FULL_REFRESH` / `HALF_REFRESH` / `FAST_REFRESH`. | Member | Description | | --- | --- | | `displayBuffer(mode = FAST_REFRESH, turnOffScreen = false)` | Push the framebuffer to the panel. | | `displayWindow(x, y, w, h, turnOffScreen = false)` | Partial update of a region. | | `refreshDisplay(mode = FAST_REFRESH, turnOffScreen = false)` | Refresh without rewriting the buffer. | | `requestResync(uint8_t settlePasses = 0)` | X3: one-shot full resync on next update. | | `skipInitialResync()` | Skip the first-update resync. | | `setFastRefreshCutoffMs(uint16_t ms) / fastRefreshCutoffMs()` | M5 PaperColor: tune the interrupted-refresh cutoff in ms (0 = driver default). The cut now anchors to the BUSY falling edge — when the drive actually starts — so the timing is deterministic and sweepable on a live panel. | | `displayBufferAsync(mode = FAST_REFRESH)` | Non-blocking refresh: push the frame, start the waveform, and return (~25 ms) while the panel refreshes from its own RAM (~0.3–2 s). The framebuffer is free to redraw immediately — so the loop keeps polling input instead of stalling. In single-buffer mode it lazily allocates one shadow buffer as the differential baseline (falls back to blocking if that fails). | | `refreshBusy() → bool` | True while an async refresh is still running on the panel. | | `syncPendingAsync()` | Block until a pending async refresh completes (no-op when none). Every blocking display call runs it first. | | `displayBufferAsyncNoShadow(mode = FAST_REFRESH)` | Async refresh that skips the single-buffer shadow allocation (identical to displayBufferAsync in dual-buffer mode) — for tight-RAM single-buffer builds. | | `triggerDisplay(mode) / completeDisplay() · triggerDisplayAsync(mode) / finishDisplayAsync()` | Split a refresh into start + finish so the app can overlap other work with the panel drive (the X4 / CrossPoint trigger-complete pattern). isRefreshPending() is true while any deferred refresh is in flight. | ## Grayscale / anti-aliased | Member | Description | | --- | --- | | `copyGrayscaleBuffers(lsb, msb)` | Load both gray planes. | | `copyGrayscaleLsbBuffers(lsb) / copyGrayscaleMsbBuffers(msb)` | Load one plane. | | `writeGrayscalePlaneStrip(plane, rows, yStart, numRows)` | Stream a row band to controller RAM (plane = GRAY_PLANE_LSB/MSB). | | `supportsStripGrayscale()` | Whether the active driver supports strip streaming. | | `displayGrayBuffer(turnOffScreen = false, lut = nullptr, factoryMode = false)` | Push the gray planes. | | `displayGrayscaleBase(fallback = HALF_REFRESH, turnOffScreen = false)` | Display the framebuffer as the base frame under a grayscale overlay. On X3 this fires the OEM differential base pass; other panels fall back to a normal refresh in the fallback mode. | | `preconditionGrayscale() / preconditionGrayscale(x, y, w, h)` | X3: fire the settle pass (full or windowed) that leaves pixels receptive to a weak grayscale nudge before an anti-aliased refresh. | | `cleanupGrayscaleBuffers(bwBuffer) / grayscaleRevert()` | Clean up after an anti-aliased refresh. | | `setCustomLUT(bool enabled, lutData = nullptr)` | Install / restore a custom waveform LUT (VCOM-safe). A board injects its own grayscale LUT through its driver config — custom LUT is the supported path now that the OTP gray4 mode has been removed. | ### Orientation Panel mount orientation is not a display call — it comes from `BoardProfile.orientation` (`NO_FLIP` / `MIRROR_X` / `MIRROR_Y` / `ROTATE_180`) and the SSD1677 driver applies it in hardware. See [BoardConfig](https://freeink.org/docs/lib-board) and [Adding a device](https://freeink.org/docs/adding-a-device). ## Framebuffer memory The facade owns the write and previous-frame framebuffers. They can be freed and restored so a memory-tight phase (a transient web UI, chapter compilation) can reclaim the ~100 KB of PSRAM: | Member | Description | | --- | --- | | `releaseBuffers() / reallocBuffers()` | Free both framebuffers back to the heap, then bring them back (white) when needed — for a transient session that reclaims the ~100 KB. After reallocBuffers() the caller must fully redraw; it returns false if the heap can’t supply the buffers (display then unusable). | | `releaseSecondaryBuffer() / reallocSecondaryBuffer() / hasSecondaryBuffer()` | Free only the previous-frame buffer (~48–52 KB); B/W and fast differential refresh keep working (the driver re-seeds RAM when prev is null), but grayscale AA is unavailable until it’s reallocated. | | `borrowSecondaryBuffer(size_t* size) → uint8_t* / returnSecondaryBuffer()` | Lend the secondary buffer’s memory to the app as scratch without freeing it (drops to single-buffer semantics). The block never enters the heap, so returning it can’t fail and can’t fragment — unlike release/realloc. | | `syncWriteBufferFromActive()` | Copy the just-displayed frame back into the write buffer, so you can patch a few regions and re-display instead of fully re-rendering. No-op in single-buffer mode. | | `cleanupGrayscaleWithPreviousBuffer()` | Restore the B/W baseline after a grayscale refresh, using the active buffer (falls back when the secondary is released). | ## BUSY-wait hooks A refresh blocks for ~0.3–2 s while the CPU only polls the panel's BUSY pin. Optional hooks let firmware apply its own power policy for that window without the SDK knowing it: | Member | Description | | --- | --- | | `setBusyWaitHooks(begin, end)` | Plain function-pointer pair fired around a long wait (begin fires once a wait exceeds ~20 ms, so short command waits don’t pay) — e.g. drop the CPU clock, then restore. | | `setBusyWaitSliceHook(fn)` | Once a wait is proven long, replaces the poll delay with your hook (receives the BUSY pin + level) so firmware can sleep through the refresh instead of busy-polling. | --- # InputManager > Buttons and capacitive touch behind one object. Group: Libraries · URL: https://freeink.org/docs/lib-input Buttons plus optional capacitive touch behind one object. Call `update()` each loop, then query edge/level state. ## Buttons | Member | Description | | --- | --- | | `begin() / update()` | Initialize; sample inputs once per loop. | | `isPressed(buttonIndex)` | Level: button currently held. | | `wasPressed(buttonIndex) / wasReleased(buttonIndex)` | Edge: press / release since last update. | | `wasAnyPressed() / wasAnyReleased()` | Any-button edge. | | `getState() / getButtonName(i)` | Raw button bitmask; human name for a button. | | `isDebouncePending() → bool` | True while a raw state change is still inside the debounce window (a change commits after two matching samples). Slow-polling hosts (a sleep-sliced idle loop) should re-poll quickly while this is set, or a press shorter than the poll period is dropped. | | `BTN_BACK, BTN_CONFIRM, BTN_LEFT, BTN_RIGHT, BTN_UP, BTN_DOWN, BTN_POWER` | Button index constants. | ## Touch Gated by `FREEINK_CAP_TOUCH`; inert on boards without a touch controller. Two controllers are supported — CHSC6x and GT911 (either IRQ-driven or polled per board). The InputManager returns **panel-native** coordinates and the app owns rotation; a rotated or mirrored digitizer is corrected SDK-side by the board profile's `TouchConfig` (`swapXY` / `flipX` / `flipY`). See [Supported devices](https://freeink.org/docs/devices) for per-board touch details. | Member | Description | | --- | --- | | `hasTouch()` | Whether the active board has a touch controller. | | `getTouchPoint() → TouchPoint{ valid, x, y }` | Current touch point. | | `isTouchPressed()` | Level: touched now. | | `isTouchHeldAt(float& nx, float& ny) → bool` | True while a touch is down, writing the current contact position (no tap-slop gate, so it follows a moving finger) — for drag interactions like sliders. The caller owns any threshold/hysteresis. | | `wasTouchPressed() / wasTouchReleased()` | Edge: touch down / up. | | `wasTouchActivity() → bool` | Edge: any touch press or release happened this frame — the touch analogue of wasAnyPressed(), for resetting idle/sleep timers. False on non-touch boards. | | `wasTouchTap(float& nx, float& ny) → bool` | Edge: a tap gesture released this frame, returning the normalized panel-native touch-down position (not the lift point — the reported centroid drifts 10–20 px as a finger rolls off, so routing to touch-down keeps small targets like steppers accurate). The app maps the coords to its logical frame. False (outputs untouched) if no release this frame or no touch HW. | | `wasTouchPressedAt(float& nx, float& ny) → bool` | Press-edge analogue of wasTouchTap: true on the frame a touch begins, returning the normalized touch-down position — so a control can highlight under the finger on press, then activate on release. | | `wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) → bool` | Edge: a flick released this frame (contact moved ≥60 px within 700 ms), returning normalized start (touch-down) and end (release) positions. A swipe also raises wasTouchTap(); check wasSwipe() first to disambiguate. The app maps both points and takes the dominant axis for direction. | | `lastTouchHeldMs() → unsigned long` | Duration of the last touch contact, latched on release — a raw primitive for an app-side tap-vs-long-press policy. 0 with no touch HW. | | `wasHomeKeyPressed()` | Edge: GT911 capacitive home key pressed (status bit 0x10). Always false on controllers without one. | ## Background polling On e-paper a slow refresh blocks the main loop, so a button press that lands mid-refresh is lost. Optional FreeRTOS-backed polling decouples input from rendering: a task samples the buttons on its own and queues each press edge, and the app drains them after the refresh. When async polling is active the app must **not** call `update()` / `wasPressed()` — the task owns the edge state; drain with `popPress()` instead. | Member | Description | | --- | --- | | `beginAsync(taskPriority = 2, pollMs = 15, queueLen = 32)` | Spawn the polling task; it latches each press (a BTN_* index) into an internal queue. No-op if already started. | | `popPress(uint8_t& button) → bool` | Pop the next queued button index into button. False when nothing is pending (or async polling was never started). | | `popTouchTap(float& nx, float& ny) → bool` | Pop the next queued touch tap (async polling latches taps too), returning its normalized panel-native position. So a tap that lands mid-refresh survives and drains here — the touch analogue of popPress. | ## Xteink button ladder On the Xteink X3/X4 the six buttons are resistor dividers multiplexed onto two ADC pins (Back/Confirm/Left/Right on group 1, Up/Down on group 2). A button-test or calibration screen can read the raw ladder to spot a drifted divider whose voltage no longer lands in the band the firmware expects — visible from the raw value regardless of how it classifies. | Member | Description | | --- | --- | | `readButtonAdc(ButtonAdcSample& g1, ButtonAdcSample& g2)` | Synchronously sample both button-group ADC pins (safe alongside async polling). Boards without the ladder report raw = -1, button = -1. | | `ButtonAdcSample { pin, raw, button }` | The GPIO sampled, its raw analogRead() value, and the classified BTN_* index (-1 = no band matched). | | `setSharedConfirmPowerShortPressEmitsPower(bool)` | For boards that wire OK/confirm and power/wake to one GPIO (e.g. Sticky): default a short click emits CONFIRM and a hold (≥400 ms) emits POWER; flip short clicks to POWER for a “short power click sleeps” option. | --- # BatteryMonitor > ADC, BQ27220 I²C fuel gauge, or M5PM1 PMIC — one API. Group: Libraries · URL: https://freeink.org/docs/lib-battery Battery state behind one API, across three backends: an ADC gauge (default), an I²C fuel gauge, or the M5PM1 PMIC. The backend is chosen from the board profile, so construction and the public methods are identical everywhere. ```cpp BatteryMonitor(uint8_t adcPin, float dividerMultiplier = 2.0f, int8_t chargeStatusPin = PIN_NONE); ``` The **ADC** backend reads a divided LiPo voltage off an ADC pin; an optional charge-status pin (MCP73832 `/STAT`, active-LOW) drives `isCharging()`. The **I²C fuel-gauge** backend (`-DFREEINK_BATTERY_I2C_GAUGE=1`) reads SoC / voltage / charge from the gauge selected by `GaugeType` — a **BQ27220** (+ optional BQ25896 charger) on X3 and the LilyGo T5 S3, or a **CW2017** on the X4 Pro (which re-uploads its 80-byte BATINFO profile if the gauge has lost it) — and ignores the ADC pin/divider. Config comes from `BoardConfig::ACTIVE.batteryGauge`, and gauge-vs-ADC is chosen at *runtime* (gauge address non-zero), so X3 (gauge) and X4 (ADC) work from one C3 binary. The **M5PM1** backend is **auto-detected** on the M5 [PaperColor](https://freeink.org/docs/devices) (no flag) — the same PMIC that owns the board's rails also reports battery state over the internal I²C bus. It reads `VBAT` for voltage and percentage, and watches both the DC input rail (`VIN`) and the bidirectional USB-C rail (`5VINOUT`) for external power. The PM1 is a PY32 MCU emulating an I²C slave, so reads are bursts with a 500 µs settle between the pointer write and the data phase (skip it and the slave serves stale samples — e.g. VBAT at 150 mV). It exposes no separate charge-phase bit, so `charging` stays unknown there. ## API | Member | Description | | --- | --- | | `readStatus() → Status` | Read every battery field the active board can report, in one call. Per-field validity flags distinguish a valid false/zero from unsupported or failed I/O (see below). | | `readPercentage()` | Estimated charge, 0–100. | | `readPercentageChecked(uint16_t& out) → bool` | Like readPercentage(), but returns false on a transient I²C-gauge / PMIC failure and leaves out unchanged (caller keeps its last good value). The ADC path always succeeds. | | `readMillivolts() / readVolts()` | Battery voltage in mV / volts (ADC paths account for the divider). | | `isCharging()` | Charge state: the ADC charge-status pin, or a charger IC (BQ25896 CHRG_STAT), or — on a gauge board with no charger IC (e.g. X3) — the sign of the gauge’s Current() (BQ27220, positive = charging). | | `percentageFromMillivolts(mv)` | Static mV → percentage curve. | ## Status `readStatus()` returns a unified `Status` that spans every backend. Because some boards can't report some fields — and an I²C read can fail transiently — each value carries a `…Known` validity flag, so a valid `false` / `0` is never confused with unsupported or failed I/O. `supported` is `false` when the board profile has no battery telemetry path at all. | Member | Description | | --- | --- | | `supported` | The board has a battery-telemetry path. | | `percentage / percentageKnown` | Estimated charge 0–100, and whether it was read. | | `millivolts / millivoltsKnown` | Battery voltage in mV, and whether it was read. | | `charging / chargingKnown` | Active-charge state, and whether it is reportable (unknown on the PM1 — no charge-phase bit). | | `externalPower / externalPowerKnown` | Whether a USB/DC supply is present (PM1: VIN or 5VINOUT above threshold, or the PWR_SRC report). | | `pm1VinMv / pm1VinOutMv / pm1PowerSource` | Raw M5PM1 diagnostics — DC input mV, USB-C rail mV, and the PWR_SRC field; −1 on non-PM1 boards or failed I/O. | --- # SDCardManager > SdFat-over-SPI or native 4-bit SDMMC, one API. Group: Libraries · URL: https://freeink.org/docs/lib-sd SD storage with an app-friendly wrapper plus the raw `FsFile` API. Two interchangeable backends sit behind one `FsVolume&` seam — SdFat-over-SPI (default) and a native 4-bit SDMMC block device (`FREEINK_SD_SDMMC`, e.g. de-link). Both hand back ordinary `FsFile` objects, so the API below is identical for either. ## API | Member | Description | | --- | --- | | `begin() / ready()` | Mount the card; report mount state. | | `listFiles(path = "/", maxFiles = 200) → vector` | Directory listing. | | `readFile(path) → String` | Read a whole file (empty on failure). | | `readFileToStream(path, out, chunkSize = 256)` | Stream a file to any Print. | | `readFileToBuffer(path, buffer, bufferSize, maxBytes = 0)` | Read into a fixed buffer. | | `writeFile(path, content)` | Write a String to a file. | | `exists / remove / rename / mkdir / rmdir / ensureDirectoryExists` | Filesystem operations. | | `open(path, oflag = O_RDONLY) → FsFile` | Raw SdFat handle for streaming. | | `sdTotalBytes() → uint64_t` | Total card capacity, cached at begin(). 0 if not mounted. | | `sdUsedBytes() → uint64_t` | Used space, cached with a 20 s TTL (the FAT free-cluster scan is too slow to run every frame). 0 if not mounted / unknown. | ## Backends SdFat can't drive SDIO, so boards wired for 4-bit SDMMC (de-link) mount a plain `FsVolume` on a native esp-idf SDMMC block device. Enable it with `-DFREEINK_SD_SDMMC=1` (auto-on for de-link) plus `-DUSE_BLOCK_DEVICE_INTERFACE=1`. The board's SDMMC wiring comes from `BoardProfile.sdmmc`. See [Build composition](https://freeink.org/docs/build-composition). --- # FrontlightManager > PWM frontlight with warm/cool control. Group: Libraries · URL: https://freeink.org/docs/lib-frontlight PWM frontlight with warm/cool control (e.g. de-link). Gated by `FREEINK_CAP_FRONTLIGHT`; inert on boards without a frontlight. | Member | Description | | --- | --- | | `begin()` | Set up the PWM channel. | | `on() / off()` | Toggle the light. | | `setBrightness(uint8_t percent)` | Brightness 0–100. | | `setColorTemperature(uint8_t warmPercent)` | Warm/cool mix 0–100. | | `present() / brightness()` | Whether a frontlight exists; current brightness. | --- # AudioManager > WAV playback through an I2S codec (Murphy M3 ES8388, M5 PaperColor ES8311). Group: Libraries · URL: https://freeink.org/docs/lib-audio WAV (16-bit PCM) playback through the I2S codec described by `BoardConfig::ACTIVE.audio`. Gated by `FREEINK_CAP_AUDIO`, which defaults on for the Murphy M3 (an ES8388-compatible stereo codec) and the M5 PaperColor (an ES8311 mono codec driving an AW8737A speaker amp), and off elsewhere; inert on boards with no audio path. Playback runs in a dedicated FreeRTOS task, so `play()` returns immediately. With `loop=true` the source is rewound and replayed until `stop()` — the alarm use case. The WAV source is a pair of callbacks rather than a `FILE` / Stream, so the SDK stays storage-agnostic: firmware can serve bytes from LittleFS, SD or a PROGMEM array with one API. ## API | Member | Description | | --- | --- | | `begin() → bool` | Bring up the codec + enable pin. False when the active board has no audio path (treat audio as absent). | | `present() → bool` | Whether the active board has an audio path. | | `setVolume(uint8_t percent)` | Output volume 0–100, mapped onto the active codec’s volume registers (ES8388 OUT1/OUT2 pairs, or the single ES8311 DAC register). | | `play(const WavSource& source, bool loop) → bool` | Start WAV playback (16-bit PCM, mono/stereo, 8–48 kHz). Stops any current playback first; unmutes the DAC, primes the I2S line with silence, then raises the speaker amp (priming first avoids an audible amp pop); loop replays until stop(). | | `playBuffer(const uint8_t* data, size_t len, bool loop) → bool` | Convenience: play from a memory buffer (e.g. an embedded default sound). | | `stop() / isPlaying()` | Stop playback (drops the amp and mutes the DAC so nothing residual reaches the output); query whether the task is streaming. | | `powerDown()` | Full power-down: stop, drop the speaker amp, and cut the codec rail (ES8388 CHIPPOWER off). begin() restores it. | ## WavSource The source is two callbacks — `read` copies up to `len` bytes and returns the count (0 = EOF, <0 = error); `seek` does an absolute seek from the start of the WAV for chunk walking and loop rewind (return `false` if unsupported, in which case loop and header re-parse fail). ```cpp AudioManager audio; if (audio.begin()) { // false if the board has no codec audio.setVolume(70); audio.playBuffer(alarmWav, alarmWavLen, /*loop=*/true); // ... later ... audio.stop(); } ``` ## Codecs Two control codecs are supported, selected per board by `AudioConfig::output` — all codec-specific register sequences live in `AudioManager`, and the pins/addresses live in the board profile, so nothing audio-specific is hardcoded in generic code: | Member | Description | | --- | --- | | `I2sEs8388 — Murphy M3` | ES8388-compatible stereo codec at I²C 0x10 on the shared touch bus, I2S master. The register contract was recovered from the OEM firmware. No separate amp-enable pin. | | `I2sEs8311 — M5 PaperColor` | ES8311 mono codec at I²C 0x18 on the system bus, mirroring M5Unified’s speaker bring-up. The codec derives its MCLK from BCLK (no MCLK line), so one init is sample-rate-agnostic. A separate AW8737A speaker amp (ampEnable / SPK_EN) is raised only while playing. | See [BoardConfig](https://freeink.org/docs/lib-board) for the `AudioConfig` fields (including `ampEnable`) and [Build composition](https://freeink.org/docs/build-composition) for the capability flag. --- # LedManager > Addressable RGB LEDs: color, brightness and non-blocking flashes. Group: Libraries · URL: https://freeink.org/docs/lib-led Color, brightness and non-blocking flashes for a board's addressable RGB LEDs, described by `BoardConfig::ACTIVE.leds`. Gated by `FREEINK_CAP_LED`, which defaults on for the M5 PaperColor (two GRB LEDs on GPIO21) and the M5 Paper Mono, and off elsewhere; inert on boards with no LEDs. The API is deliberately small and independent of M5Unified. The driver **bit-bangs a WS2812 / SK6812-compatible 800 kHz signal** directly on the data GPIO with cycle-accurate timing (`T0H` ≈ 350 ns, `T1H` ≈ 700 ns, 1.25 µs bit cell), so interrupts are masked for only ~60 µs per `show()` with two LEDs. The timing-critical path runs from `IRAM` over a byte stream computed before interrupts are masked, so cache contention can't corrupt a frame. Colors are stored unscaled; `setBrightness()` applies a global scale at write time. ## API | Member | Description | | --- | --- | | `begin() → bool` | Enable the LED power rail (if any) and initialize the data line. False when the active board has no LEDs. | | `present() → bool` | Whether the active board has an LED path. | | `count() → uint8_t` | Number of LEDs on the active board. | | `setBrightness(uint8_t) / brightness()` | Global 0–255 brightness scale applied at write time; stored colors stay full-range. | | `setColor(uint8_t index, LedColor) / setAll(LedColor)` | Set one LED, or every LED, in the buffer (call show() to push). | | `color(uint8_t index) → LedColor` | Read back a buffered color. | | `show() / clear()` | Push the buffer to the physical LEDs; clear() blanks the buffer and shows it. | | `flash(LedColor, count=1, onMs=120, offMs=120)` | Start a non-blocking blink sequence (saves the current colors first). | | `update()` | Advance any in-progress flash; call once per loop(). | | `isFlashing() / stopFlash(bool restore=true)` | Query/stop a flash; restore returns the saved colors. | ## LedColor A plain `{ r, g, b }` struct (0–255 each) with named helpers — `LedColor::red()`, `green()`, `blue()`, `white()`, `yellow()`, `cyan()`, `magenta()`, `black()` — plus `LedColor::rgb(r, g, b)`. The board profile's `colorOrder` (GRB or RGB) decides the wire order, so app code always works in RGB. ## Non-blocking flashes `flash()` snapshots the current colors, blinks the requested color a number of times, then restores the snapshot on completion (or on `stopFlash(true)`). Nothing blocks — `update()` advances the sequence each loop, so a status blink rides alongside normal rendering. ```cpp LedManager leds; if (leds.begin()) { // false if the board has no LEDs leds.setBrightness(64); leds.setAll(LedColor::blue()); leds.show(); leds.flash(LedColor::green(), 3); // blink 3× then restore blue } void loop() { leds.update(); // advances non-blocking flashes } ``` ## Wiring and power The board profile carries the data pin, LED count, color order and a `pmicRgbPower` flag. On the PaperColor the two LEDs sit behind the **M5PM1 PMIC's 3.3 V RGB LDO rail**, which `LedManager` powers **lazily**: the rail stays off until the first lit frame and drops again once every LED is black, so dark LEDs draw nothing. `begin()` lights nothing — it **evicts the PM1's own NeoPixel engine** (which otherwise drives a status pixel onto the same chain — the classic stuck-green LED — and survives a USB reflash) and forces the rail down. See [BoardConfig](https://freeink.org/docs/lib-board) for the `LedConfig` fields and [Build composition](https://freeink.org/docs/build-composition) for the capability flag. The M5 Paper Mono takes a different path (`paperMonoDiscrete`): a single **discrete RGB LED** whose channels are split across the M5PM1's LED output (red) and the M5IOE1 I²C expander's IO8 / IO9 (green / blue) rather than a WS2812-style data chain, so `colorOrder` is unused and the color is set by driving those pins through the board-support library. --- # Buzzer > LEDC PWM square-wave tones on a passive buzzer. Group: Libraries · URL: https://freeink.org/docs/lib-buzzer Square-wave tones on a passive buzzer via the ESP32 LEDC PWM, on the `audio.buzzer` pin in the board profile. Gated by `FREEINK_CAP_BUZZER`, which defaults on for the [Sticky](https://freeink.org/docs/devices), Murphy M3, M5 Paper Mono and M5 PaperS3, and off elsewhere. This is a **tone device**, not a PCM codec — it's independent of [AudioManager](https://freeink.org/docs/lib-audio), and a board can carry both (the Murphy M3 has an ES8388 codec *and* a buzzer). `tone()` with a duration blocks and self-stops; with no duration it runs until `noTone()`. ## API | Member | Description | | --- | --- | | `begin() → bool` | Attach LEDC PWM to the buzzer pin. False when the board has no buzzer. | | `present() → bool` | Whether the buzzer initialized. | | `tone(uint32_t freqHz, uint32_t durationMs = 0)` | Play a square-wave tone. durationMs > 0 blocks then stops; 0 runs continuously until noTone(). | | `beep()` | A short default beep (2 kHz, 80 ms). | | `noTone()` | Silence a continuous tone. | | `end()` | Release the LEDC channel and pin. begin() re-attaches. | ```cpp Buzzer buzzer; if (buzzer.begin()) { buzzer.beep(); // short confirmation blip buzzer.tone(880, 200); // an A5 for 200 ms } ``` See [BoardConfig](https://freeink.org/docs/lib-board) for the `audio.buzzer` pin and [Build composition](https://freeink.org/docs/build-composition) for the capability flag. --- # Microphone > PDM microphone capture to 16-bit PCM. Group: Libraries · URL: https://freeink.org/docs/lib-mic PDM microphone capture to 16-bit PCM, described by `BoardConfig::ACTIVE.mic`. Gated by `FREEINK_CAP_MIC`, which defaults on for the [Sticky](https://freeink.org/docs/devices) and M5 Paper Mono, and off elsewhere; inert on boards with no microphone. It wraps the ESP-IDF `i2s_pdm` RX driver: `begin()` raises the mic power rail, settles, and starts the PDM clock; `read()` pulls mono 16-bit samples out of the I2S FIFO. The SDK stays storage-agnostic — the caller owns whatever it does with the samples (ring buffer, WAV file, on-device wake-word). ## API | Member | Description | | --- | --- | | `begin(uint32_t sampleRate = 16000) → bool` | Power the mic rail and start PDM RX at the given rate. False when the active board has no mic or init fails. | | `present() → bool` | Whether the active board has a mic and begin() succeeded. | | `read(int16_t* dst, size_t maxSamples, uint32_t timeoutMs = 100) → int` | Read up to maxSamples mono 16-bit PCM samples; blocks up to timeoutMs. Returns the count (0 = timeout, <0 = error / not begun). | | `end()` | Stop RX and drop the mic rail. begin() restarts it. | | `sampleRate() → uint32_t` | The active sample rate. | ```cpp Microphone mic; if (mic.begin(16000)) { // false if the board has no mic int16_t buf[256]; int n = mic.read(buf, 256); // mono 16-bit PCM // ... consume n samples ... mic.end(); } ``` Pins (PDM clock out, data in, and an active-polarity power-enable) come from the `MicConfig` in the board profile, so nothing mic-specific is hardcoded in generic code. See [BoardConfig](https://freeink.org/docs/lib-board) and [Build composition](https://freeink.org/docs/build-composition). --- # Rtc > PCF8563 / DS3231 real-time clock over I²C. Group: Libraries · URL: https://freeink.org/docs/lib-rtc Wall-clock time from a real-time clock over I²C, described by `BoardConfig::ACTIVE.sensors`. Three chips are supported, selected per board by `RtcType`: the **PCF8563** (Sticky, plus the BM8563-compatible part on the X4 Pro and M5 PaperS3), the **DS3231** (Xteink X3) and the **RX8130** (M5 Paper Mono). Gated by `FREEINK_CAP_RTC`, which defaults on for the [X3](https://freeink.org/docs/devices), Sticky, X4 Pro, Paper Mono and PaperS3, and off elsewhere; inert on boards with no RTC. `begin()` brings up the I²C bus and quiets the chip's clock output; `now()` and `set()` read and write a `DateTime`. The driver handles each chip's register encoding and its oscillator-stopped flag, so a `now()` that returns false means the clock was never set (or browned out) rather than a silent bad time. ## API | Member | Description | | --- | --- | | `begin() → bool` | Bring up the bus, disable CLKOUT. False when the board has no RTC or it doesn’t ACK. | | `present() → bool` | Whether the RTC initialized. | | `now(DateTime& out) → bool` | Read the current time. False on I²C error or if the oscillator reports stopped (never set / low voltage). | | `set(const DateTime& dt) → bool` | Set the clock. False on I²C error. | | `adjust(int32_t seconds, DateTime* out = nullptr) → bool` | Shift the running clock by a signed number of seconds, calendar-correct across midnight/month/year (e.g. a time-zone change: adjust(deltaMinutes * 60)). Optionally returns the new time. False if the RTC is absent or I/O fails. | | `DateTime { year, month, day, hour, minute, second, weekday }` | Full year (e.g. 2026), 1-based month/day, 24h time, weekday 0=Sunday. | ```cpp Rtc rtc; if (rtc.begin()) { Rtc::DateTime t; if (rtc.now(t)) { // ... use t.year / t.month / t.hour ... } } ``` The PCF8563 shares the sensor I²C bus with the temperature/humidity sensor and IMU. On multi-bus SoCs the board profile picks `Wire` or `Wire1` via `SensorsConfig.i2cBus` — on the Sticky the sensor cluster sits on a second bus, away from the touch controller. See [BoardConfig](https://freeink.org/docs/lib-board). --- # EnvironmentSensor > SHT40 temperature + humidity over I²C. Group: Libraries · URL: https://freeink.org/docs/lib-env Temperature and relative humidity from a Sensirion SHT40 over I²C, described by `BoardConfig::ACTIVE.sensors`. Gated by `FREEINK_CAP_TEMP_HUMIDITY`, which defaults on for the [Sticky](https://freeink.org/docs/devices) and off elsewhere; inert on boards with no sensor. `read()` runs a single high-precision measurement, validates the SHT40's per-value CRC-8, converts the raw 16-bit codes to physical units (°C and %RH, humidity clamped to 0–100), and returns false on an I²C error or a CRC mismatch — so a successful read is a checked read. ## API | Member | Description | | --- | --- | | `begin() → bool` | Soft-reset and probe the sensor. False when the board has no sensor or it doesn’t ACK. | | `present() → bool` | Whether the sensor initialized. | | `read(float& tempC, float& humidityPct) → bool` | Single-shot high-precision measurement with CRC validation. False on I²C error or CRC mismatch. | ```cpp EnvironmentSensor env; if (env.begin()) { float tempC, rh; if (env.read(tempC, rh)) { // ... use tempC and rh ... } } ``` The SHT40 shares the sensor I²C bus with the [RTC](https://freeink.org/docs/lib-rtc) and [IMU](https://freeink.org/docs/lib-imu); the board profile selects the bus via `SensorsConfig.i2cBus` on multi-bus SoCs. See [BoardConfig](https://freeink.org/docs/lib-board). --- # Imu > LSM6DS3TR-C 6-axis accelerometer + gyroscope. Group: Libraries · URL: https://freeink.org/docs/lib-imu 6-axis motion (accelerometer + gyroscope) over I²C, described by `BoardConfig::ACTIVE.sensors`. Two chips are supported, selected per board by `ImuType`: the ST **LSM6DS3TR-C** (Sticky) and the **QMI8658** (Xteink X3). Gated by `FREEINK_CAP_IMU`, which defaults on for the [X3](https://freeink.org/docs/devices) and Sticky, and off elsewhere; inert on boards with no IMU. `begin()` checks the `WHO_AM_I` id and configures accel + gyro for the board's chip (LSM6DS3TR-C at 104 Hz, ±2 g / ±245 dps; QMI8658 at 28 Hz, ±2 g / ±512 dps). `read()` returns one `Sample` already scaled to physical units — acceleration in g and angular rate in degrees/second — so the app gets usable values regardless of which part is fitted. ## API | Member | Description | | --- | --- | | `begin() → bool` | Probe WHO_AM_I and configure accel + gyro. False when the board has no IMU or the part doesn’t identify. | | `present() → bool` | Whether the IMU initialized. | | `read(Sample& out) → bool` | Read one accel + gyro sample. False on I²C error. | | `sleep() / wake() → bool` | Power the sensor down / back up. Config is retained, so wake() resumes sampling without a full begin(). | | `Sample { ax, ay, az, gx, gy, gz }` | Acceleration in g; angular rate in °/s. | ```cpp Imu imu; if (imu.begin()) { Imu::Sample s; if (imu.read(s)) { // ... s.ax/ay/az (g), s.gx/gy/gz (deg/s) ... } } ``` Fixed output rate per chip, no dynamic rate selection. The IMU shares the sensor I²C bus with the [RTC](https://freeink.org/docs/lib-rtc) and [EnvironmentSensor](https://freeink.org/docs/lib-env); the board profile picks the bus via `SensorsConfig.i2cBus` on multi-bus SoCs. See [BoardConfig](https://freeink.org/docs/lib-board). --- # PowerManager > Portable deep-sleep wake-on-power-button. Group: Libraries · URL: https://freeink.org/docs/lib-power Portable deep-sleep wake-on-power-button. It picks the SoC-correct wakeup source at compile time — RTC `ext1` on Xtensa (S3/S2, classic ESP32) vs the `gpio` source on RISC-V (C3/C6/H2) — and reads the wake pin + polarity from `BoardConfig::ACTIVE.input`, so consumers write no chip-specific power code. All methods are static. | Member | Description | | --- | --- | | `armPowerButtonWakeup() → bool` | Arm wake-on-power-button (SoC-correct source + active pin/polarity). False if the board has no power pin. | | `armWakeOnPins(uint64_t gpioMask, bool wakeLow = true)` | Arm wake on an arbitrary set of GPIOs (a touch INT, a second button, an IO-expander INT) using the SoC-correct source. Pins must be RTC-capable on ext1 (Xtensa) parts. | | `waitForPowerButtonRelease()` | Poll the power GPIO until released, so sleep isn’t cancelled by a still-held press. | | `deepSleep()` | Isolate floating GPIOs, then enter deep sleep. Does not return (chip resets on wake). | | `deepSleepUntilPowerButton()` | Convenience: wait for release, arm wakeup, then deep sleep. | | `powerDownRailsForSleep()` | Drive every assigned power-rail enable in the profile (display / SD / touch / mic) OFF and latch it with gpio_hold, so gated rails stay off through deep sleep instead of draining milliamps. No-op on boards with no gated rails (X3/X4). Call after the display’s deep-sleep command and before deepSleep(). Cutting the touch rail forfeits touch-to-wake. | ## Usage ```cpp // before — C3-only, breaks on S3: esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); esp_deep_sleep_start(); // after — MCU-portable: freeink::PowerManager::armPowerButtonWakeup(); esp_deep_sleep_start(); ``` It compiles on both targets — the `gpio` branch links in the C3 build, the `ext1` branch in the S3 build. For the why and a consumer porting checklist, see [MCU portability](https://freeink.org/docs/mcu-portability). --- # MemoryManager > On-demand RAM reclaim via priority-ordered cache sinks. Group: Libraries · URL: https://freeink.org/docs/lib-memory An on-demand RAM-reclaim helper: a small, priority-ordered registry of evictable **cache sinks** plus heap reporting, over the ESP-IDF heap-capabilities allocator. It lets a consumer free memory on demand — a control-center "clear caches" / "boost" action — or under pressure, without the SDK needing to know what any given app caches. Any component that holds a rebuildable RAM cache — rendered pages, decoded images, glyph atlases, parsed-document buffers, PSRAM pools — registers a `CacheSink` once. A sink is a name, a **priority** (lower is evicted first, so cheap-to-rebuild caches go low), and an `evict(bytesRequested)` callback that frees memory and returns the bytes it released (`bytesRequested == 0` means "free everything"). `MemoryManager` is a singleton, so sinks can register from anywhere and any code can trigger a reclaim. ```cpp #include using freeink::MemoryManager; // Register once (e.g. in each cache owner's begin()): MemoryManager::instance().registerSink({"font.glyphs", 20, [&](size_t n){ return glyphs.evict(n); }}); MemoryManager::instance().registerSink({"render.pages", 40, [&](size_t n){ return pages.evict(n); }}); MemoryManager::instance().registerSink({"image.decode", 30, [&](size_t n){ return imgPool.evict(n); }}); ``` ## Reclaim | Member | Description | | --- | --- | | `registerSink(CacheSink) ` | Register a rebuildable cache by name, priority (lower = evicted first) and an evict(n) callback. Re-registering a name replaces the existing sink. Up to kMaxSinks (12) sinks. | | `clearCaches(size_t bytes) → size_t` | Walk the sinks lowest-priority first, passing each the remaining shortfall and stopping once bytes are freed (bytes = 0 purges all). Returns the total freed. | | `boost(&before, &after) → size_t` | Purge everything and return the honest, heap-measured free delta — the number you show the user ("Freed 1.8 MB"). Momentary, touches no NVS. | ## Reporting For a "memory" read-out, or to gate work on available RAM. `MemPool` is `Internal` / `Psram` / `Default`. | Member | Description | | --- | --- | | `freeBytes(MemPool) → size_t` | Free bytes in the pool (Psram returns 0 without PSRAM). | | `largestFreeBlock(MemPool) → size_t` | Biggest contiguous free block — what a single allocation can actually claim. | | `minEverFree(MemPool) → size_t` | The min-ever-free low-water mark since boot. | ```cpp // "Boost": purge everything and get the heap-measured free delta: size_t before = 0, after = 0; size_t freed = MemoryManager::instance().boost(&before, &after); // Or free just enough to satisfy a target, lowest-priority caches first: MemoryManager::instance().clearCaches(256 * 1024); // free ~256 KB; 0 = purge all ``` > **It measures, it doesn't estimate** > > `boost()` wraps `clearCaches()` with a before/after heap measurement, so the number you display is what the allocator actually reclaimed, not what the sinks estimated. It is a purely momentary RAM operation and touches no NVS. Logs under `[MEM]` with `-DENABLE_SERIAL_LOG`. The pattern mirrors the cache-sink manager e-reader firmware typically hand-rolls: a set of rebuildable caches asked to shrink, measured against free heap. Pair it with [FreeInkBook](https://freeink.org/docs/lib-book)'s page cache and image pools as the registered sinks. ## Pressure watermarks Instead of reclaiming only on an explicit user action, a build can arm soft/hard watermarks and let the manager relieve pressure automatically — evicting lowest-priority sinks first, and purging outright at the hard line. | Member | Description | | --- | --- | | `setWatermarks(uint8_t softPct = 60, uint8_t hardPct = 75)` | Arm soft/hard used-bytes watermarks as a percentage of the internal pool, recording what was already in use. Call once at the end of app init. Defaults mirror common e-reader firmware. | | `pressure() → MemPressure` | Current internal-pool pressure against the watermarks — None / Soft / Hard. Always None before setWatermarks(). | | `relievePressure() → size_t` | At Soft, evict sinks until used drops back under the soft line; at Hard, additionally purge every sink. Cheap no-op at None — safe to call periodically or before a large allocation. Returns bytes freed. | | `ensureFree(size_t bytes, MemPool) → bool` | Evict sinks until at least bytes are free in the pool (or all sinks are spent). Returns whether the target was met. | ## Static task stacks and arenas Two allocation helpers keep short-lived, large allocations from fragmenting the internal heap. **Task-stack slots** lend a named, preallocated stack (+ TCB) to `xTaskCreateStatic()`, so bring-up tasks (Wi-Fi / radio, OTA, sync) don't repeatedly carve 4–8 KB holes; the buffer is kept across release for reuse. **Bump arenas** back phase-scoped scratch (layout, image decode) that is freed all at once. | Member | Description | | --- | --- | | `acquireTaskStack(slot, owner, stackBytes) → TaskStack` | Borrow a named internal-RAM stack + TCB. Allocated on first acquire, reused after release (a larger later acquire reallocates, only while released). Returns {nullptr,…} if the slot is owned, the table is full, or allocation fails; owner is for logging. | | `releaseTaskStack(slot)` | Return a slot once the borrowing task is deleted. The buffer stays allocated for the next acquire. | | `arenaCreate(bytes, MemPool) → int / arenaAlloc(id, bytes, align) / arenaReset(id)` | A bump arena for allocations freed all at once: create returns an id (−1 on failure), alloc bump-allocates (nullptr when exhausted, no per-allocation free), reset empties it. | --- # RecoveryBoot > Boot-time OTA recovery escape hatch (button combo). Group: Libraries · URL: https://freeink.org/docs/lib-recovery A boot-time recovery hatch. Call `recovery::checkBootCombo()` as the very first thing in `setup()` of every firmware you want to be escapable; holding **Back + Up** at reset repoints OTA at slot 0 and reboots into the recovery firmware. The stock Xteink (and most ESP32) second-stage bootloader can't read buttons — it just boots whatever `otadata` selects. So "hold a combo at reset to fall back to recovery" can only be honoured by the firmware that actually boots. This library is that check, made shareable: the recovery flasher, the editor, the reader and so on each call it first, so any of them can bail out to the escape hatch. ## API | Member | Description | | --- | --- | | `checkBootCombo()` | Read the recovery combo and, if held, switch otadata to OTA slot 0 and reboot. Returns immediately (no reboot) in every other case. | ```cpp #include void setup() { freeink::recovery::checkBootCombo(); // FIRST — Back+Up at reset → recovery // ... normal firmware init ... } ``` It is always safe to call unconditionally and early — it does nothing unless **all** of: the combo is pressed, OTA slot 0 holds a valid app image, and the caller isn't already running from slot 0 (so inside the recovery firmware it's a no-op). When it does act it reboots and never returns. The convention is that the recovery / escape-hatch firmware lives in **OTA slot 0** (`ota_0`, the default upload offset `0x10000`). The otadata switch is self-contained and skips `esp_image_verify`, so it works with patched Xteink images. > **What it can't do** > > It can't escape a firmware that crashes in ROM or early SDK init *before* this call is reached. A corrupt app *image* is still caught for free — the bootloader falls back to the other OTA slot on its own. Truly unconditional GPIO recovery would need a custom second-stage bootloader, which the recovery firmware deliberately never reflashes. --- # SecureNet > Opt-in wolfSSL TLS 1.3 transport. Group: Libraries · URL: https://freeink.org/docs/networking `SecureNet` brings its own TLS stack — wolfSSL compiled from source — so the reader can reach TLS-1.3-only servers that the platform's stubbed mbedTLS can't. ## The problem The precompiled mbedTLS in the ESP-IDF / pioarduino package ships TLS 1.3 as empty stubs, so `WiFiClientSecure` / `esp_http_client` cannot reach TLS-1.3-only servers — e.g. KOSync at `kosync.ak-team.com:3042`, where the handshake fails with `-0x7780`. A `-D` flag can't change a precompiled `.a`, and a from-source ESP-IDF rebuild is a heavier path. ## The solution `SecureNet` bundles **wolfSSL compiled from source**, which supports TLS 1.3 + PSA and bypasses system mbedTLS entirely. It exposes two pieces: - `freeink::SecureClient` — an Arduino `Client` doing TLS 1.3 over `WiFiClient`, with a **TLS 1.2 fallback** for servers that are intolerant of a 1.3 handshake. - `freeink::SecureHttpClient` — a **standalone** HTTPS client (`GET` / `POST` / `PUT` with custom headers and a buffered response body; handles Content-Length, chunked and connection-close framing). It's *deliberately not* a wrapper over Arduino `HTTPClient` — that binds a `NetworkClient`, and `SecureClient` is a plain `Client` running wolfSSL over its own transport — but it keeps the familiar call shape (`begin()` / `addHeader()` / `GET()` / `getString()`, plus `setInsecure()` / `setCACert()`). `SecureHttpClient` has grown into a real HTTP/1.1 client: **streaming** responses (`GET(onData, shouldAbort)` hands the body back in chunks instead of buffering it), **connection keep-alive** across requests (`setReuse(true)`),** opt-in redirect following** (`setFollowRedirects(maxHops)`, with `setAllowRedirectDowngrade()` for https→http), **HTTP Basic auth** (`setBasicAuth(user, pass)`), a custom `setUserAgent()` sent on every request, and a `setProgressCallback()` (return false to abort a long download). ## Enabling it It's opt-in: `-DFREEINK_NET_WOLFSSL=1` plus a wolfSSL source `lib_dep`. With the flag off it compiles to an inert stub, so the rest of the SDK builds without wolfSSL. ```platformio.ini build_flags = -DFREEINK_NET_WOLFSSL=1 lib_deps = SecureNet=symlink://path/to/freeink-sdk/libs/network/SecureNet ; + a wolfSSL source library dependency ``` > **Capability flag** > > `FREEINK_CAP_NET_TLS13` is equivalent to `FREEINK_NET_WOLFSSL` in the [capability matrix](https://freeink.org/docs/build-composition). Both default off. --- # BleKeyboardHost > BLE HID host for keyboards, page turners and remotes. Group: Libraries · URL: https://freeink.org/docs/lib-ble A Bluetooth Low Energy **HID host**: it pairs with and connects to one BLE HID peripheral at a time (central role) and hands firmware translated key events — for keyboards, page turners, remote buttons and similar devices that expose the HID service (`0x1812`). The class keeps the `BleKeyboardHost` name; new code uses the `BleHid` accessor (`BleKbd` remains as an alias). > **BLE only** > > The ESP32-C3 / S3 has no Bluetooth Classic (BR/EDR) radio, so Classic-only HID devices cannot connect. The library is also ESP32-C3/S3-only — it needs a BLE radio. ## Enabling it It's an opt-in **capability**, gated on `FREEINK_CAP_BLE_HID_HOST` (default off). When off it links stub bodies and pulls in **no** BLE code, so disabled builds stay lean. Turn it on with the flag *and* add the NimBLE stack to `lib_deps` (it's intentionally not a hard dependency). The older `FREEINK_CAP_BLE_KEYBOARD` flag still maps to it. See [Build composition](https://freeink.org/docs/build-composition). ``` build_flags = -DFREEINK_CAP_BLE_HID_HOST=1 -DFREEINK_BLE_HID_SHOW_UNNAMED_DEVICES=0 ; hide anonymous non-HID advertisers -DCONFIG_BT_NIMBLE_ROLE_CENTRAL=1 ; central-only, 1 connection -DCONFIG_BT_NIMBLE_MAX_CONNECTIONS=1 lib_deps = BleKeyboardHost=symlink://freeink-sdk/libs/network/BleKeyboardHost h2zero/NimBLE-Arduino@^2.3.8 ``` ## API | Member | Description | | --- | --- | | `begin(const char* name)` | Init the NimBLE central + bonding and load NVS bonds. | | `poll()` | Drive auto-reconnect and key auto-repeat; call each loop. | | `popKey(KeyEvent& ev) → bool` | Drain the next translated key. ev.special is a SpecialKey (PageDown, arrows…); ev.ch is printable input. | | `startScan(uint32_t ms) / deviceCount() / device(i)` | Scan for peripherals; each DiscoveredDevice carries addr, name, rssi, hid, connectable (and hasName). | | `connect(addr) / isConnected()` | Async connect; isConnected() flips when the link is ready. | | `releaseScanResults()` | Reclaim scan RAM once connected. | | `pairedCount() / paired(i) / forget(addr)` | Enumerate and remove stored bonds. | | `takePairingPasskey()` | When a peripheral requires passkey pairing, returns the six-digit code to show the user. | Pairing defaults to **Just Works** (bonded, no MITM) — page turners and remotes usually have no input or display, and mandatory MITM makes them reject pairing. Firmware that specifically needs host-display keyboard pairing opts in with `-DFREEINK_BLE_HID_REQUIRE_MITM=1`, and then reads the six-digit code via `takePairingPasskey()`. ```cpp #include void setup() { BleHid.begin("FreeInk"); } void loop() { BleHid.poll(); freeink::KeyEvent ev; while (BleHid.popKey(ev)) { if (ev.special == freeink::SpecialKey::PageDown) { /* page turner */ } else if (ev.ch) { /* printable keyboard input */ } } } ``` ## How it works **Scan** is an active scan that upserts every advertiser into a fixed array and records the HID service UUID; HID is validated at connect time, so devices that hide their name or services in scan-response / extended-advertising fragments still appear in the pairing UI. **Connect** runs on a dedicated FreeRTOS task: connect → discover HID → bond (`secureConnection()`) → switch to **Report Protocol** mode → subscribe to Input reports, falling back to Boot Keyboard Input (`0x2A22`) for boot-only devices. **Reports** are normalized to `[mod][k0..k5]`, diffed against the previous report, and translated (US QWERTY HID usages) into `KeyEvent`s — page turners' arrow / page-up/down usages arrive as `SpecialKey`. Gamepad-style remotes (a HID button bitfield that only clears the pressed bit on release) are decoded without phantom double-presses. Since HID sends one report per state change, `poll()` **synthesizes auto-repeat** for a held key after an initial delay. ## Memory All storage is fixed-capacity — discovered devices (`kMaxDiscovered` = 24), bonds (`kMaxBonds` = 4), and the key ring (`kKeyQueueLen` = 16) — with one active connection and no `std::vector` / heap in the hot path. Configure NimBLE central-only, single-connection to keep its static footprint small on the C3. --- # BoardConfig > Board profiles and the runtime-active device. Group: Libraries · URL: https://freeink.org/docs/lib-board The compile-time board descriptions and the runtime-active profile. Drivers read pins, geometry and capabilities from `BoardConfig::ACTIVE` — nothing device-specific is hardcoded in generic code. See [Architecture](https://freeink.org/docs/architecture). ## API | Member | Description | | --- | --- | | `ACTIVE` | The runtime-active BoardProfile (defaults to DEFAULT_DEVICE). | | `selectDevice(Board which) → bool` | Set ACTIVE to a compiled-in device; false if not included. | | `XTEINK_X4, XTEINK_X4_PRO, XTEINK_X3, DE_LINK, M5STACK_PAPER_COLOR, MURPHY_M3, MURPHY_M4, LILYGO_T5S3, M5PAPER_V11, STICKY, PAPER_MONO, M5PAPER_S3` | Built-in board profiles. | | `BoardProfile.orientation` | Panel mount transform — NO_FLIP / MIRROR_X / MIRROR_Y / ROTATE_180 (applied in hardware by SSD1677). | | `BoardProfile.sdmmc` | SdmmcPins for 4-bit SDMMC boards; busWidth 0 = use SPI/SdFat. | | `BoardProfile.uiScale` | Per-device UI scale multiplier (1.0 default; touch boards like Sticky bump it for finger-sized chrome). Read by the app/theme layer. | | `BoardProfile.viewableInsets` | ViewableInsets — panel rows/columns the bezel physically overlaps (top/right/bottom/left, native portrait frame), so firmware keeps content out of them. Defaulted to the historical X4-tuned value; a measured board overrides it. | | `holdPowerRails()` | Assert the profile’s power-latch pins (PWR_HOLD / PWR_LOCK). Battery-latched boards (e.g. Sticky) must call this first thing in setup() or they power off when the user releases the button. No-op on boards without a latch. | | `releaseSdRail()` | Rescue an SD power rail a previous firmware’s sleep left gpio-held off — required before first display use where SD shares the display SPI bus (an unpowered card clamps the lines). SDCardManager::begin() does it too. | | `hasTouch() / hasPwmFrontlight() / hasAudio()` | Capability queries for the active board. | | `isDeLink() / isMurphyM3() / isM5StackPaperColor() / isM5PaperV11()` | Identity queries. | | `LILYGO_T5_PRO_GT911` | Ready-made GT911 touch config (used by the LilyGo T5 S3 profile). | ## Peripheral config Each peripheral library reads a small config struct off `ACTIVE`, so nothing peripheral-specific is hardcoded in generic code: | Member | Description | | --- | --- | | `TouchConfig` | Controller, pins, raw axis ranges, swapXY / flipX / flipY digitizer correction, and an optional powerEnable rail. See InputManager. | | `MicConfig` | PDM mic: input type, clock/data pins, and an active-polarity enable pin. See Microphone. | | `SensorsConfig` | Shared I²C sensor bus (SDA/SCL/Hz) + the RTC / temp-humidity / IMU addresses (0 = absent), driving Rtc / EnvironmentSensor / Imu. | | `AudioConfig.buzzer` | LEDC PWM pin for a passive buzzer (Buzzer), separate from the I2S codec fields. | | `DisplayPins.powerEnable / SdPins.powerEnable` | Active-high rails for the panel and SD card, raised at begin() with a settle delay (PIN_UNASSIGNED = always powered). | | `i2cBus (gauge & sensors)` | On multi-bus SoCs (ESP32-S3), select Wire (0) or Wire1 (1) per peripheral so e.g. touch and sensors stay on separate physical buses. | Adding a board means adding a profile here, not editing generic code — see [Adding a device](https://freeink.org/docs/adding-a-device). For runtime device selection across one MCU, see [Build composition](https://freeink.org/docs/build-composition). --- # XteinkDetect > Runtime X3/X4 and display-controller detection via bus fingerprinting. Group: Libraries · URL: https://freeink.org/docs/lib-detect Runtime Xteink X3/X4 detection. The X3 and X4 are two board profiles compiled into one ESP32-C3 binary; this library supplies the canonical I²C fingerprint so a dual X3/X4 firmware picks the right one before bringing up the display and SD card. X3 and X4 share a pinout but differ in panel controller (X3 = UC8253 792×528, X4 = SSD1677 800×480) and battery backend, so the running firmware must select a profile at boot. The SDK leaves detection to the consumer by design — but rather than have every dual app reinvent the fingerprint, `XteinkDetect` ships the known-good one. See [Build composition](https://freeink.org/docs/build-composition) for how one binary carries both profiles. ## Board fingerprint (X3 vs X4) | Member | Description | | --- | --- | | `detectXteinkIsX3() → bool` | Run the X3 I²C fingerprint and return true for an X3. Leaves the bus released and the probe pins back in INPUT mode; safe to call before any other bring-up. | | `detectXteinkVerdict(&score1, &score2) → XteinkVerdict` | The same probe with a three-way verdict — X3Confirmed / X4Confirmed / Inconclusive — plus the per-pass chip-hit scores (0–3) for diagnostics. Inconclusive means the passes disagreed or saw a stray ACK: treat it as an X4, but don’t persist a flaky first boot. | | `selectXteinkDevice() → bool` | Convenience: run the fingerprint, point BoardConfig::ACTIVE at the matching profile via selectDevice(), and return whether an X3 was detected. On a confirmed X3 it also runs the display-controller probe, selecting the UC8279 sibling profile when that silicon is fingerprinted. | ## Display-controller sibling probe Newer production runs swap the default controller for an UltraChip sibling that shares the UC81xx KW-mode command set: the X3's `UC8253` → `UC8279d`, and the **X4 Pro's** `SSD1677` → `UC8179`. Same board, glass and pinout — only the silicon differs, so the running firmware fingerprints the live display bus and selects the matching driver before [FreeInkDisplay::begin()](https://freeink.org/docs/lib-display). | Member | Description | | --- | --- | | `detectXteinkDisplayController(verBytes[5], &flg) → DisplayControllerVerdict` | Board-agnostic probe: reads VER (0x70) / FLG (0x71) over a bit-banged half-duplex 4-wire SPI on BoardConfig::ACTIVE’s display pins, after a reset pulse. A matching UC81xx signature across two passes → Uc81xxConfirmed, else PrimaryAssumed. Works on any Xteink profile, including the S3 X4 Pro where the X3 I²C probe would be unsafe. | | `detectX3DisplayController(verBytes[5], &flg) → X3DisplayVerdict` | The X3-specific variant (hard-coded X3 C3 pinout): Uc8253Assumed / Uc8279Confirmed / Inconclusive. | | `applyXteinkDisplayController() → bool` | Resolve the panel controller from the live bus probe and, on a confirmed sibling, promote BoardConfig::ACTIVE.displayController (SSD1677 → UC8179, UC8253 → UC8279) so begin() picks the matching driver. Returns true iff promoted. The OEM NVS screenType is read only for diagnostics — the live probe is the ground truth, since a cross-unit flash can name the wrong panel. | | `getXteinkDisplayProbeDiag() → const XteinkDisplayProbeDiag&` | Snapshot of the most recent probe (VER/FLG bytes, verdict, whether it promoted, and up to 48 bytes of controller MTP) for firmware to persist somewhere retrievable without serial access — e.g. a file on the SD card of a locked unit. | ```cpp #include // Before SDCardManager::begin() and FreeInkDisplay::begin(), so both read the // right profile: if (freeink::selectXteinkDevice()) { display.setDisplayX3(); // detected an X3 } freeink::applyXteinkDisplayController(); // promote to the UC81xx sibling if present display.begin(); ``` ## How it fingerprints Detection probes the **X3-only I²C peripherals** on `SDA=20 / SCL=0` — the BQ27220 fuel gauge (`0x55`), DS3231 RTC (`0x68`) and QMI8658 IMU (`0x6B` / `0x6A`). The X4 has none of them, so two passes that each score **≥ 2 hits** confirm an X3; anything else is treated as an X4 — the conservative default. Call it before any other hardware bring-up, then hand off to [FreeInkDisplay](https://freeink.org/docs/lib-display) and [SDCardManager](https://freeink.org/docs/lib-sd). --- # FreeInkUI > Optional immediate-mode UI framework for e-paper. Group: Libraries · URL: https://freeink.org/docs/lib-ui A memory-bounded, **immediate-mode** UI layer for e-paper firmware. It sits above a display driver and an input source while staying independent of any one application: apps plug in their own renderer, fonts, icons, themes, localization strings and screen state through small adapter interfaces. Think "Tailwind for e-ink" without a web-style runtime. FreeInkUI (`libs/ui/FreeInkUI`) is **optional** — it ships as its own library and the rest of the SDK builds without it. It is freestanding C++17 with no Arduino or ESP-IDF dependency, so the layout, routing, focus and virtualization logic run in plain host unit tests (`libs/ui/FreeInkUI/test/host/run.sh`). It now ships a **built-in renderer** (`DisplayTarget`) and a bundled font, so it draws a complete UI into a FreeInkDisplay framebuffer with no external graphics library; bridging to an app's own drawing stack is optional. ## Design constraints - No heap allocation by default — fixed-capacity interaction tables. - Borrowed strings and asset pointers; app-owned state and props. - Virtualized lists/grids instead of one node per item. - No file IO and no JSON in the UI layer — the app parses themes and resolves assets and hands in the in-memory shapes. - Touch, GPIO buttons, focus navigation and gestures all route to semantic action IDs — components never reference physical button names or board pins. ## Core flow Each frame you build an input snapshot, wrap a draw target, lay out fixed slots, render components into the slot rects, then call `finish()` to route the input through the interactions registered during the render pass. ```cpp freeink::ui::InteractionBuffer<32> interactions; freeink::ui::InputSnapshot input = readInput(); // Built-in renderer: draws straight into FreeInkDisplay's framebuffer, no deps. freeink::ui::DisplayTarget draw(display.getFrameBuffer(), display.getDisplayWidth(), display.getDisplayHeight(), display.getDisplayWidthBytes()); freeink::ui::DeviceContext device = draw.deviceContext(); freeink::ui::Frame<32> ui(draw, device, input, interactions); freeink::ui::Stack<3> screen(ui.safeRect(), freeink::ui::Axis::Column, 0); screen.fixed(statusBarHeight); screen.flex(1); screen.fixed(controlBarHeight); screen.layout(); statusBar(ui, screen.rect(0), props); pageRenderer.renderInto(screen.rect(1)); // app-drawn content slot controlBar(ui, screen.rect(2), props); if (auto event = ui.finish()) { handleAction(event.action, event.value); } ``` The app owns persistent state — selected row, focused index, scroll offset, clock buffers, reading statistics, visibility flags. The UI runtime only adds transient focus/active state when it resolves styles. `Stack` handles compile-time-known splits; for layouts driven by parsed data, `FreeInkUILayout.h` adds a dynamic counterpart — `layoutLinear()` splits a rect into a row or column from slot lengths supplied at runtime, and `layoutTree()` does the same for a nested `LayoutNode` tree, both deterministic with no heap or retained widget state. The [visual builder](#visual-builder)'s generated scaffolds build on it. ## Rendering The built-in `DisplayTarget` (`FreeInkUIDisplayTarget.h`) is a self-contained `DrawTarget` that writes directly into a 1-bpp framebuffer — the same layout `FreeInkDisplay::getFrameBuffer()` hands back — with **no external graphics library**, so the exact same render runs in firmware and in host unit tests. It draws in **logical coordinates and rotates each pixel into the panel's native framebuffer** at draw time, so you lay out a screen in the orientation you intend with no separate rotated buffer: the four-argument constructor takes the panel's native dimensions and defaults a landscape-native panel (the X3/X4) to `Portrait` so a held-tall reader reads upright; pass an explicit `Orientation` to the five-argument overload to override, and `deviceContext()` carries that orientation so touch mapping agrees. It bundles a **Noto Sans bitmap font** across eight font slots; point a slot (or all of them) at your own `BitmapFont` with `setFont()`, generated from any TTF/OTF by `tools/gen_font.py` (1-bpp, or multi-bpp for anti-aliased glyphs). A glyph the bitmap font is missing (Hangul, CJK book titles) can fall back to a TrueType face via the `TtfGlyphSource` bridge (`FreeInkUIBookFont.h`, shared with [FreeInkBook](https://freeink.org/docs/lib-book)). On a 1-bit panel the four UI grays (`Black` / `DarkGray` / `LightGray` / `White`) are reproduced with an ordered Bayer dither. Bridging to an app's own drawing stack is optional: the header-only `GfxRendererTarget` adapter (below) compiles only where a CrossPoint `GfxRenderer` is on the include path, for firmwares that already own a text/bidi pipeline. New apps just use `DisplayTarget`. ## FreeInkApp — screen builder The core flow above is the raw immediate-mode loop. Most firmware starts a level up, with `FreeInkApp` (`FreeInkApp.h`) — an ergonomic, still allocation-free wrapper that owns the interaction buffer, dispatches semantic actions to callbacks, and gives each screen a `Screen` builder with a typed method for **every** component (`header()`, `status()`, `button()`, `list()`, `toggleRow()`, `slider()`, `table()`, `footer()`, …) plus `takeTop()` / `takeBottom()` / `spacer()` / `body()` for custom bands. Each builder call takes an optional `LayoutAnchor` (`Top` default or `Bottom`), so a region anchors to the bottom of the remaining content — footers and bottom bars with no manual math — and `HeaderProps` / `FooterProps` style the chrome. Each `render()` re-runs your screen function and returns an `ActionEvent`; it also records a `RefreshHint` (`None` / `Fast` / `Full` / `Clean`) so firmware picks the e-paper refresh mode — the app never pushes pixels itself. ```cpp using App = freeink::ui::FreeInkApp<32, 16>; void homeScreen(App::ScreenType& screen, void* user) { auto& state = *static_cast(user); screen.header("Library"); const freeink::ui::FooterAction footer[] = { {.label = "Open", .action = ActionOpen}, {.label = "Back", .action = ActionBack}, }; screen.footer(footer, 2); screen.list(state.books, 2, state.selected, ActionOpen); } App app(target, target.deviceContext()); // target = a DisplayTarget app.setScreen(homeScreen, &state); app.on(ActionOpen, handleOpen, &state); // each loop: freeink::ui::ActionEvent event = app.render(readInputSnapshot()); if (app.lastRenderRefreshHint() != freeink::ui::RefreshHint::None) { display.displayBuffer(/* map the hint to FULL / FAST */); } ``` It also ships the chrome and helpers multi-screen apps otherwise hand-roll: `screen.navHeader(title, backAction, backIcon)` draws a sub-screen's back button + centered title + rule — with an optional right-aligned `rightLabel` (a live clock or count), a trailing action **button** (`trailingLabel` / `trailingAction`, e.g. a "Save") in its place, and a `borderEdges` flag to drop the divider. `screen.centeredText("Scanning…")` centers a one-line message for empty/loading states, `takeRow(anchor, height)` reserves a row band whose inter-row gap scales with the height, and a `button(props, rect)` overload places a themed button at an explicit rect for layouts the row cadence can't express. Because frames **don't clear the target** on their own, call `app.setClearColor(ui::Color::White)` once so each paint starts from a white canvas. For snappy navigation, `app.invalidateTransition()` requests a fast partial refresh on a screen change and promotes to a full one every Nth transition (`setTransitionFullEvery(n)`) to clear ghosting; FreeInkApp also sizes its default theme metrics to the target's real font line height, so larger fonts don't clip rows. By default `setTheme()` keeps a per-app copy of the tokens; when every screen shares one theme, `setThemeRef(&tokens)` points at caller-owned tokens instead, saving the ~1.5 KB per-app copy — the cost that matters on small heaps with several live screens. > **Text entry — don't cast key ids to char** > > The SDK owns keyboard editing in `ui::KeyboardEntry`: `attach()` a caller buffer, route the keyboard's key/shift/mode/delete actions to its methods, and it handles the shift/symbol layers, layout-correct UTF-8 append and multi-byte backspace. Key actions report a stable **id** in `ActionEvent::value` — ASCII keys their code point, localized keys (é, ñ, ß, and every Cyrillic / Hebrew glyph) ids above 1000 — so casting the value straight to `char` corrupts non-ASCII layouts. Insert through `KeyboardEntry` (or `keyboardKeyText()`) instead — it appends layout-correct UTF-8 and, with a script-switch key, tracks the active `KeyboardLayoutId`. Right-to-left is the renderer's job; the Hebrew layout inserts code points in logical order. ## Visual builder The SDK ships a self-contained **local visual builder** — a small web app that designs screens by hand and emits a `FreeInkApp` screen function. It edits the same JSON schema `tools/gen_screen.py` consumes, loads its component palette from the [gallery manifest](https://freeink.org/docs/lib-ui-components), and — crucially — renders its preview through the **real C++ `DisplayTarget`** path (returned as SVG), so what you arrange matches what the panel draws rather than a browser approximation. ``` python3 libs/ui/FreeInkUI/tools/builder/server.py # then open http://127.0.0.1:8088/ ``` Pick a device profile and portrait/landscape orientation, drag components in, anchor regions to the top or bottom, then **export the JSON schema** or **Generate C++** to get a ready-to-compile screen function. It's a design-time tool only — there's no firmware runtime cost, and the generated firmware still receives plain static C++. ## Actions, not hardware Interactive components register semantic actions and an `inputMask`. The same component can be selectable by touch, GPIO/focus, side buttons or gestures depending on the mask and the device's capabilities. ```cpp button(ui, rect, { .label = tr(STR_SELECT), .action = ActionSelect, .inputMask = freeink::ui::InputTouch | freeink::ui::InputFocus | freeink::ui::InputConfirm, }); ``` Touch hit areas are declarative and decoupled from the visual rect, composed in one place (`ensureMinTouchRect`): `minTouchSize` center-expands a small target to a comfortable minimum, `ButtonProps.hitPadding` extends a button's tap band per edge so adjacent controls (a stepper's `−` / `+`) get contiguous, non-overlapping bands instead of overlapping centered expansion, and any hit rect within ~12 px of a screen edge snaps to the bezel — eliminating the dead zone between an edge control and the physical border (Fitts's law). The visual rect is unchanged by all three. **Swipes route the same way.** The app detects a flick with [InputManager](https://freeink.org/docs/lib-input)'s `wasSwipe()`, picks the dominant axis (`swipeDirection()` classifies a start/end pair into a `SwipeDir`), sets `InputSnapshot.swipeLeft` / `swipeRight`, and components that opt in with the `InputSwipeLeft` / `InputSwipeRight` mask bits fire their action — so a list can be paged by swipe and by GPIO with one declaration. Tap and swipe use **separate slop thresholds**, so a slightly-dragged tap still registers while a real flick is classified as a swipe. **Long-press** is a first-class input: build the frame's snapshot with `snapshotFrom(input, device, withLongPress)`, and a component that opts in with the `InputLongPress` mask fires on the hold rather than the tap (the keyboard uses this for per-key alternates). For scrolled menus, the `ListNav` helper owns the selection/viewport state a list otherwise makes every screen hand-roll — `selected` / `top` / measured `visibleRows`, with `scrollBy()`, `follow()` (pull the viewport the minimum to keep the selection visible) and `syncToProps()` (measure rows, clamp, write into `ListProps` right before `list()`). `drawListScrollIndicator()` paints the matching dithered scrollbar. ## Built-in components A set of immediate-mode components, deliberately not tied to any application's screen structure. Apps draw app-specific content (a book page, a cover image) directly into a slot rect the layout hands back. Each lives in its own header under `components/` (with `FreeInkUICore.h` for the shared types), included together via ``. Every one is previewed from its real 1-bit render in the [component gallery](https://freeink.org/docs/lib-ui-components). | Component | What it covers | | --- | --- | | `button` | Themed, state-styled, any input source via inputMask. | | `settingRow / toggleRow / stepperRow / radioGroup` | Settings-screen rows: label + value, an on/off switch, a −/+ stepper (drawn as centered strokes), and a single-choice group. | | `checkbox / slider / dropdown` | A label + checkable box, a continuous value slider (dithered track + knob; stepperRow covers discrete steps), and a dropdown that opens an app-owned selection (optionally a two-line settingRow layout with the current selection as a subtitle). | | `table` | A rows × columns cell grid with grid lines, an optional header row and per-cell styles. | | `statusBar` | Measured leading/trailing clusters + centered title with cluster-aware fallback; built-in progress bar; doubles as a top/bottom page overlay. | | `tabBar` | Pill or underline-style tabs with an optional divider, per-tab icons and a disabled state. | | `list` | Virtualized rows; fill/outline/pill styles plus Underline/Triangle selection markers; hug-content pill rows; section headers; an optional per-row subtitle beneath the label (which wraps to its own line count); vertically-centered row content; and dynamic per-row height so a wrapped multi-line label or subtitle grows its row instead of clipping. | | `keyGrid / keyboard / textField` | A KeyKind key grid with glyph art, a data-driven on-screen keyboard (built-in QWERTY / AZERTY / QWERTZ / Spanish layouts, four ЙЦУКЕН Cyrillic layouts — Russian, Ukrainian, Belarusian, Kazakh — and a Hebrew RTL layout, Shift + symbols, a localized OK label, per-key long-press alternates shown as a corner hint, an optional script-switch key for apps that reach more than one script, and an optional prepended number row; qwertyKeyboard is the QWERTY wrapper), and a single-line field with a chunk-measured cursor for long URLs/passphrases (masking stays app-side) plus an optional selection highlight — a [selStart, selEnd) byte range drawn as a dithered band behind the text so 1-bit glyphs stay legible without inverting. | | `textArea` | A multi-line scrollable writing canvas (the editor body). The app owns the text buffer and caret offset; it word-wraps, draws the window of lines from topLine, and an optional caret. textAreaMeasure() / textAreaTopLineFor() keep the caret on screen, mirroring lists. | | `readerChrome / tapZones` | Reader surfaces: top/bottom reading chrome (title + progress label/bar) and page tap zones (prev / menu / next) with swipe routing. | | `bookCard / coverGrid` | Library surfaces: a cover + title/author/meta + progress row, and a cover-art grid for visual selection (a fixed array, or a CoverGridItemProvider callback that supplies items lazily by index). | | `optionDialog / popup / messagePanel / toast / contextMenu` | Overlays: a titled option dialog (caption + multi-line headline + body), a bare popup panel (PopupProps sets size and alignment) with an optional dithered scrim, an empty/error/loading message panel with retry, a static e-paper-safe toast, and a long-press command menu. | | `metricCard / progressBar` | Statistics value/label cells and horizontal bar charts (minFill keeps tiny values visible). | | `batteryIndicator` | Battery glyph; triangle-built lightning bolt while charging, or an app-supplied icon. | | `header / gestureBar` | Section headers (with an optional leading back button for sub-screen nav chrome — see navHeader) and button-hint bars. | | `coverCarousel` | Lays out a prev/center/next cover row (distinct center/side sizing, optional wrap at the edges) with selection chrome and tap/swipe/prev-next routing; returns slots[3] and the app renders cover art into each slot.content rect, so image decoding and frame caching stay app-owned. | ## Virtualized lists `list` never creates a node per item. The app owns the full item array plus scroll state; the component lays out, draws and registers interactions only for the rows that fully fit, and draws a right-edge scroll indicator when the list overflows. ```cpp const uint16_t visible = freeink::ui::listVisibleRows(rect, theme.rowHeight); topIndex = freeink::ui::listTopIndexFor(selectedIndex, topIndex, visible, count); freeink::ui::ListProps props; props.items = items; props.count = count; // total items, not just visible ones props.topIndex = topIndex; // first row drawn at the top of the rect props.selectedIndex = selectedIndex; props.action = ActionOpenBook; freeink::ui::list(ui, rect, props); ``` `listTopIndexFor` scrolls the window the minimal amount to keep the selection visible and clamps to range, so GPIO up/down navigation gets correct scrolling for free. A set of selection helpers own the index math so apps don't re-derive it per input source, each returning whether the index changed (so the app only redraws on a real move): `listClampedIndex(index, count)` clamps to range; `listSelectIndex(sel, requested, count)` jumps to a row (a tap or gesture); `listMoveIndex(sel, delta, count)` steps with wraparound (GPIO up/down); `listPageIndex(sel, deltaPages, count, pageItems)` moves by a screenful without wrapping. ## Styling and themes Every interactive component resolves a `StyleSet` — one `BoxStyle` (background, foreground, border, radius, corner mask) per interaction state. Rounded looks are first-class: `BoxStyle.radius` applies to fills and borders, `tabBar` renders filled pill tabs, and `ListProps.hugContents` shrinks selection pills to the label width — no custom drawing code. Defaults are **border-free** for a cleaner 1-bit look; `plainStyles(foreground = black)` is a one-call `StyleSet` for unstyled text, and `outlinedButtonStyles(radius)` gives outlined rounded buttons — drop either into `theme.button` via `app.setTheme()` to restyle every button at once. Theme ownership is split deliberately: **the SDK owns the in-memory types** (`ThemeTokens`, `ThemeDocument`, `StyleSet`, `AssetResolver`); **apps own JSON and storage parsing**. FreeInkUI never reads files, parses JSON or allocates — a firmware parses its theme files (with whatever JSON library and caching it already has) into `ThemeTokens` plus its own extension structs, then renders from those. App-only features live under `extensions.`, so multiple firmwares can share one theme file. ## Dark mode Whole-UI inversion is one call at the draw-target level rather than per component. `InvertedDrawTarget` wraps any `DrawTarget` and flips every color drawn through it — black↔white, light↔dark gray, dithers included — so component defaults, theme styles and app-drawn chrome all invert together. ```cpp freeink::ui::DisplayTarget real(display.getFrameBuffer(), display.getDisplayWidth(), display.getDisplayHeight(), display.getDisplayWidthBytes()); freeink::ui::InvertedDrawTarget target(real, settings.darkMode); freeink::ui::Frame<32> ui(target, device, input, interactions); // ... render exactly as in light mode ... ``` Flip `target.setEnabled(...)` from a setting and the next frame renders inverted; when disabled the wrapper is a pure passthrough. The screen clear stays app-owned (clear to black when inverted). ## Rotation and scaling Whole-screen rotation is inherited from the renderer: layout happens in logical coordinates, `DeviceContext.orientation` reports the active orientation, and the adapter maps to panel space. Per-element rotation (side-bezel button hints) rides on `TextStyle.rotation` for labels and the `rotation` parameter of `DrawTarget::bitmap()` for icons (`CW90`, `R180`, `CCW90`). Touch follows along: orientation mapping is SDK-owned, so `touchToLogical()` converts normalized panel-native portrait coordinates into the logical frame for any orientation (with `flipX` / `flipY` for mirrored panel mounting, a per-board property), and no app re-derives the transform by hand. Smaller displays scale through **layout, not transforms** — rects and flex splits adapt, themes override sizes per device, and font slots bind smaller font ids, since fractional glyph scaling produces mush on a 1-bit panel. The board profile carries a `uiScale` multiplier ([BoardConfig](https://freeink.org/docs/lib-board)) the app folds into its theme metrics and minimum touch sizes — a big finger-driven touch panel like the Sticky bumps chrome and fonts up, a button-driven e-ink reader leaves it at 1.0. Bitmaps do scale: every `BitmapMode` (Center, Stretch, Contain, Cover, Tile, TileX, TileY) runs through a shared nearest-neighbor sampler, and [icons](https://freeink.org/docs/lib-icons) ship per size so a scaled-up UI gets a genuinely higher-resolution asset rather than a blocky upscale. ## Adapters FreeInkUI itself has no dependencies, and the built-in `DisplayTarget` renderer (above) needs no bridge at all. These optional header-only adapters wire it to an app's existing stacks instead, and only compile in firmwares that include them. | Adapter | Bridges | | --- | --- | | `FreeInkUIGfxRenderer.h` | `GfxRendererTarget`, a `DrawTarget` over the `GfxRenderer` drawing library: dither-mapped colors, per-corner rounded rects, and text measurement/truncation/wrapping through the renderer's own pipeline. `deviceContext()` derives screen size and orientation. | | `FreeInkUIInputManager.h` | Builds the per-frame `InputSnapshot` from the SDK's [InputManager](https://freeink.org/docs/lib-input) via `snapshotFrom(inputManager)`. `ButtonBindings` overrides the default UP/DOWN→focus, LEFT/RIGHT→prev/next, CONFIRM/BACK mapping per board. An orientation-aware `snapshotFrom(input, device, flipX, flipY)` overload returns taps already mapped to the logical frame. | Writing your own `DrawTarget` is small: implement the drawing primitives over your renderer (fills with per-corner radius masks, `line()`, `triangle()`, `text()`, `bitmap()`). You do **not** have to write text layout — `layoutText()` is an SDK-owned algorithm (greedy word wrap, hard `\n` breaks, character breaking for over-wide words, ellipsis truncation, alignment and vertical centering) built only on your `measureText`, so a target's `text()` reduces to drawing the emitted single-line runs. Targets that already have a native bidi/kerning-aware wrapping pipeline (like the `GfxRenderer` adapter) keep using their own. So existing fonts, bidi, localization and page rendering stay where they are — FreeInkUI does not translate strings (apps pass already-localized, borrowed strings) and large image decoding stays app/renderer-owned. ## FreeInkUI vs LVGL [LVGL](https://lvgl.io/docs/latest/) is the broader general-purpose embedded GUI stack — a retained object tree, themes, animation/timer machinery, flex/grid layout, many widgets, and broad display/input portability (including real 1-bpp monochrome). The case for FreeInkUI isn't that LVGL can't do e-paper; it's that FreeInkUI is **smaller, more direct, and opinionated around FreeInk e-paper firmware**: it draws straight into `FreeInkDisplay` with fixed-capacity routing and no heap-owned object tree, and ships reader-specific surfaces out of the box. | Reach for LVGL when… | Reach for FreeInkUI when… | | --- | --- | | You need a mature retained GUI toolkit — broad widget coverage, animations, themes, flex/grid layout. | You want a small immediate-mode layer with fixed-capacity routing and no heap-owned object tree. | | Your product targets many display technologies, or already has an LVGL driver/input stack. | Your product is centered on FreeInk e-paper boards — partial/full refresh, ghosting, reader chrome, tap zones, board capabilities. | | You need LVGL extras: calendar, chart, meter, spinner, image decoders, IME, demos. | You need reader surfaces out of the box: book cards, cover grids/carousels, status chrome, tap zones, e-ink-safe dialogs, generated 1-bit previews. | | You can pay the RAM / code cost of a general GUI runtime. | You want borrowed strings/assets, freestanding C++17, host tests, static screen generation, and predictable per-frame drawing. | ### LVGL widget parity For teams mapping from LVGL, the equivalent FreeInkUI surface in e-paper-specific names. “Primitive” means the operation lives directly on `DrawTarget`; otherwise FreeInkUI offers a reader-focused component rather than a generic clone. | LVGL widget family | FreeInkUI coverage | | --- | --- | | Base object / container | Frame, Stack, Screen, FreeInkApp — immediate-mode rather than retained objects | | Label | Primitive: DrawTarget::text; used by header, statusBar, rows, dialogs | | Image / canvas / line | Primitive: bitmap, fill, stroke, line, triangle; DisplayTarget renders into the 1-bit framebuffer | | Button | button, gestureBar, FooterAction / FooterProps | | Button matrix / keyboard | keyGrid, keyboard, qwertyKeyboard — built-in Latin (QWERTY, AZERTY, QWERTZ, Spanish), Cyrillic (Russian, Ukrainian, Belarusian, Kazakh) and Hebrew (RTL) layouts, with a script-switch key | | Checkbox | checkbox | | Switch | toggleRow | | Slider | slider; discrete setting changes use stepperRow | | Bar / progress | progressBar, reader/status progress | | Text area | textField + keyboard / qwertyKeyboard; intentionally simple, app owns the editing buffer and text insertion | | Dropdown / roller / select | dropdown, radioGroup, contextMenu | | List / menu | list, settingRow, contextMenu | | Tabview / tileview / window | tabBar, readerChrome, app-level Screen composition | | Table | table | | Message box / dialog | popup, toast, messagePanel, optionDialog | | Chart / meter | metricCard, progressBar; generic chart/meter widgets are not first-class yet | | Calendar / spinner / arc / animation extras | Not first-class; add as app components when a specific e-paper product needs them | | E-reader / library surfaces | tapZones, readerChrome, bookCard, coverGrid, coverCarousel, batteryIndicator | > **Adopting it incrementally** > > Port one screen of chrome first — a status bar + content slot + control bar split is a good proof of pipeline — then move shared surfaces screen-by-screen (headers, lists, button hints, popups, keyboards). Each port deletes the hand-rolled layout code it replaces. App-specific page rendering (book text layout, image decoding) stays app-owned: FreeInkUI hands the app a computed slot rect and the app renders into it. See the [full FreeInkUI guide](https://github.com/Free-Ink/freeink-sdk/blob/main/docs/freeink-ui.md). --- # Component gallery > Prebuilt FreeInkUI components, previewed from the real 1-bit renders. Group: Libraries · URL: https://freeink.org/docs/lib-ui-components FreeInkUI's prebuilt components and screen surfaces. Every preview here is **generated from the real C++ components** through the native 1-bit framebuffer renderer ([DisplayTarget](https://freeink.org/docs/lib-ui)) — not drawn by hand — so what you see is what the panel draws. See [FreeInkUI](https://freeink.org/docs/lib-ui) for the layout, theming and input model behind them. ## Screens Composite screens assembled from the components below — the kinds of surfaces a reader firmware builds with the `FreeInkApp` screen builder. > Settings and controlsReader screen controlsLibrary and book surfacesOverlays, dialogs and keyboard ## Controls and settings > `button`Themed, state-styled action — selectable by touch, focus, button or gesture via inputMask. ## Input and navigation > `textField`Single-line input with a chunk-measured cursor for long strings. ## Reader and status > `statusBar`Measured leading/trailing clusters + centered title; doubles as a page overlay. ## Library surfaces > `bookCard`A cover + title / author / meta + progress row for library lists. ## Overlays and dialogs > `contextMenu`A long-press / menu-button command list. ## Layout > `header`A section header (or nav header — a leading back button + centered title) with an optional bottom rule. --- # Icons > freeink::Icon format, vendored Lucide set, and a generator. Group: Libraries · URL: https://freeink.org/docs/lib-icons A 1-bpp icon format with baked-in alignment metadata, the full [Lucide](https://lucide.dev) SVG set vendored as source, and a generator that turns any of them into ready-to-draw C structs — crisp at any UI scale, vertically centered on text with no per-icon tweaking, correct in every orientation. ## The freeink::Icon format ```cpp struct Icon { uint16_t w, h; int16_t opticalCenterY; // row of the artwork's center of mass const uint8_t* bits; // rows top-to-bottom, (w+7)/8 bytes each, MSB-first; // bit 1 = transparent, bit 0 = black (drawn) }; ``` **Not pre-rotated.** The renderer maps logical → panel coordinates itself, so one asset is correct in all four orientations — draw it through an orientation-aware blit (e.g. CrossPoint's `GfxRenderer::drawIcon(const freeink::Icon&, x, y)`, which routes each pixel through `drawPixel`). **`opticalCenterY` is measured from the art**, so asymmetric icons (a clock, a wifi fan, arrows) center on a line of text without hand-nudging — put the icon's optical center on the text's optical center: ```cpp // textTop is where the text is drawn; the renderer supplies the text's optical // center offset from the font's real x-height (no guessed ascender fractions). const int textCenter = textTop + renderer.getTextVisualCenterOffset(fontId); renderer.drawIcon(icon, x, textCenter - icon.opticalCenterY); ``` ## Generating icons Don't bake the whole library into flash — generate only what you use. List the icons you want in a manifest (`alias = lucide-name`) and run the generator: ``` # icons.txt settings = settings recent = clock transfer = arrow-down-up wifi = wifi python libs/assets/Icons/tools/gen_icons.py \ --manifest icons.txt \ --svgdir libs/assets/Icons/lucide/icons \ --sizes 24,32,40,48 \ --out generated_icons.h ``` This emits, per icon per size, a `static const freeink::Icon icon__` with its bits and optical center precomputed. Pick the size nearest your scaled target at runtime so a scaled-up UI gets a genuinely higher-resolution asset instead of a blocky upscale. The generator needs `rsvg-convert` (librsvg) and Pillow. ## Browsing the set Lucide is vendored as a **git submodule** at `libs/assets/Icons/lucide` (run `git submodule update --init` to fetch it). All 1735 names live in `libs/assets/Icons/lucide/icons/*.svg` — reference any by filename (minus `.svg`) in a manifest. Lucide is MIT-licensed. ## In FreeInkUI [FreeInkUI](https://freeink.org/docs/lib-ui) components take icons as borrowed asset references and draw them through the draw target's `bitmap()` primitive, which carries the same `CW90` / `R180` / `CCW90` rotation parameter as the rest of the UI — so icons rotate with side-bezel chrome and follow whole-screen orientation for free. --- # FreeInkBook > A complete EPUB reading engine: layout, cache, fonts, international text. Group: Libraries · URL: https://freeink.org/docs/lib-book A complete **EPUB reading engine** (`libs/book/FreeInkBook`): it turns an EPUB on external storage into typeset, cached, tappable pages on an e-paper panel — container parsing, CSS, layout, pagination, fonts, images and links. Freestanding C++17 with no Arduino or ESP-IDF dependency; every byte of working memory is caller-supplied, and the whole pipeline runs (and is regression-tested) on a desktop host. ## The four rules Everything follows from four rules, each enforced by host tests rather than convention: - **Never a DOM.** Chapters parse as a stream of SAX events feeding a layout state machine, so RAM is O(paragraph + page) — layout peak memory is byte-identical between a 1 KB chapter and a 19,000-page omnibus. - **Arenas only.** All memory comes from caller-sized bump allocators that reset wholesale at book/chapter/page boundaries. No `free()`, so no fragmentation; exhaustion returns a status, never aborts. - **Layout once, render many.** Pagination runs once per (book, settings) generation and serializes compact page records. A page turn is one small read + a glyph blit — no ZIP, no XML, no layout, ~2 KB of scratch. - **Host-testable end to end.** Test binaries cover container, layout, cache and fonts — including CI-asserted memory ceilings and O(1) proofs. ## Pipeline | Stage | What it does | | --- | --- | | Container | ZIP catalog + streaming inflate; OPF metadata / manifest / spine; nav + NCX TOC; encryption.xml parsing that tells font obfuscation from real DRM (obfuscated-font-only books still render; only true DRM → Encrypted). | | CSS | A tolerant subset cascade: element / .class selectors, ~15 properties, inline style="", chapter