Skip to content

refactor: introduce Panel hierarchy, retire _x3Mode (additive)#15

Draft
jeremydk wants to merge 2 commits into
crosspoint-reader:mainfrom
jeremydk:feat/panel-abstraction
Draft

refactor: introduce Panel hierarchy, retire _x3Mode (additive)#15
jeremydk wants to merge 2 commits into
crosspoint-reader:mainfrom
jeremydk:feat/panel-abstraction

Conversation

@jeremydk
Copy link
Copy Markdown
Member

Motivation

EInkDisplay carries per-device behavior in 130+ scattered if (_x3Mode) branches plus six X3-only state members on the base class. The X3-only SPI helpers (sendCommandDataX3, loadLutBankX3*,
etc.) live alongside X4-only methods. Adding a third panel today requires editing EInkDisplay at ~50 sites. The flag-based dispatch pattern doesn't scale.

Approach

Introduce a Panel hierarchy that owns all per-device knowledge. EInkDisplay becomes a coordinator (framebuffer, SPI bus, pins, Panel delegate). Concrete panels (X4Panel, X3Panel) implement 22 virtuals covering geometry, lifecycle, refresh dispatch, grayscale paths, LUT loading, state-machine hooks, and busy-line polling.

Panel subclasses reach into EInkDisplay's SPI/pin/state privates via a friend class declaration rather than going through a DisplayContext interface. Rationale documented at the top of Panel.h: vtable-elision concerns on the C3 dominate for SPI-bus and busy-poll hot paths, the panel set is bounded (2 today, maybe a small handful ever), same-author maintenance, EInkDisplay and Panel co-evolve. Open to pushback if a reviewer wants the explicit-interface form.

What's in this PR

Panel hierarchy with these virtual concerns:

  • Geometry: displayWidth, displayHeight (plus inline displayWidthBytes, bufferSize derivations)
  • Lifecycle: onBegin, init, deepSleep, postResetDelay, initialFullSyncsAfterBegin, spiClockHz
  • Refresh: displayBuffer, refreshDisplay
  • Grayscale: displayGrayBuffer, grayscaleRevert, displayWindow, copyGrayscaleLsbBuffers, copyGrayscaleMsbBuffers, copyGrayscaleBuffers, writeGrayscalePlaneStrip, cleanupGrayscaleBuffers,
    setCustomLUT
  • State-machine hooks: requestResync, skipInitialResync (default empty; X3 overrides)
  • Low-level: pollBusy

Concrete panels:

  • X4Panel: SSD1677, 800×480, 40 MHz SPI, single-phase active-HIGH busy wait
  • X3Panel: UC81xx, 792×528, 16 MHz SPI, 50ms post-reset settle, 2 initial full-syncs at begin, full resync state machine, two-phase active-LOW busy wait with sawLow early-return, post-full settle pass (preserved from fix(x3): clear post-full ghosting on the first differential refresh #14)

Data co-located with consumers:

  • X3Constants.h / X4Constants.h: opcode constants (constexpr; were #define)
  • X3Luts.{h,cpp} / X4Luts.{h,cpp}: LUT bank data (extern-linked)

EInkDisplay becomes:

  • 1,813 → 392 lines (-78%)
  • Zero _x3Mode references (flag retired)
  • 1 X3-named method remaining (setDisplayX3(), now deprecated; see below)
  • Pure coordinator: framebuffer, SPI bus, pins, panel delegate

Migration shim for downstream consumers:

  • New constructor: EInkDisplay(std::unique_ptr, sclk, mosi, cs, dc, rst, busy)
  • Legacy pin-only constructor still works (delegates to the new one with X4Panel as default)
  • setDisplayX3() marked [[deprecated]] with the migration recipe in the message
  • Downstream firmware gets a compile warning, no break; migration is optional and can happen at the consumer's pace

Dropped:

  • #ifndef X3_USE_X4_INIT escape (defined nowhere; documented for re-introduction inside X3Panel::init if a maintainer wants it back)

What's NOT in this PR (deliberately deferred)

  • setDisplayX3() removal — separate breaking-change PR, coordinated with downstream consumers (firmware migrates first; SDK removes the shim after)
  • Vestigial x3Panel() downcast helper — goes with setDisplayX3()
  • The X3_DISPLAY_WIDTH/HEIGHT/MAX_BUFFER_SIZE compile-time constants on EInkDisplay's public header (used for static framebuffer sizing today); they migrate when the static-buffer story has a
    Panel-aware shape

Dependencies

Built on top of #14 (X3 entry-ghost fix). Slice 0 of this branch is 6e89dcc. The post-full settle pass introduced in #14 is preserved as part of X3Panel::displayBuffer. When #14 lands, the
base auto-rebases to master and the diff stays clean.

Filed as a draft while #14 is in review. Ready to mark ready-for-review once #14 merges.

Testing

Hardware-verified on both X3 and X4 throughout. Every behavior migration was flashed to both panels and the boot + refresh + EpubReader render flow exercised before moving to the next.

X4:

  • Device detect X4, framebuffer 48000 bytes
  • SSD1677 init via X4Panel::init (CMD_SOFT_RESET 2ms / AUTO_WRITE_BW_RAM 5ms / AUTO_WRITE_RED_RAM 5ms)
  • SPI at 40 MHz via X4Panel::spiClockHz()
  • Wake-from-off half=1553ms; fast refreshes 418/419ms via X4Panel::displayBuffer + refreshDisplay
  • Grayscale alternation (fast=62ms with custom LUT) via setCustomLUT + displayGrayBuffer

X3:

  • Device detect X3, framebuffer 52272 bytes (792×528/8)
  • SPI at 16 MHz via X3Panel::spiClockHz(), post-reset 50ms settle applied
  • onBegin armed _x3InitialFullSyncsRemaining=2
  • UC81xx init via X3Panel::init
  • Full sequence: X3_OEM_FULL + X3_PON (126ms) + X3_DRF (926ms) + X3_DRF(post-full settle) (378ms; fix(x3): clear post-full ghosting on the first differential refresh #14 fix firing through new dispatch)
  • Conditional resync chain: X3_OEM_COND 1/1 + X3_DRF(cond) (455ms)
  • Fast refreshes 379ms

Zero ERR/assert/abort/panic on either device. Timings byte-identical to pre-refactor at every checkpoint.

Reviewer suggested reading order

  1. Panel.h — the virtual surface + the "Design: why friend, not an interface" comment block (the most opinionated call in this PR)
  2. X4Panel.cpp — smaller of the two impls; shows the friend-access pattern
  3. X3Panel.cpp — the X3 state machine + LUT plumbing
  4. EInkDisplay.cpp — how thin the coordinator becomes (392 lines, mostly 1-line delegators)

The opcode/LUT headers (X3Constants.h, X4Constants.h, X3Luts.h, X4Luts.h) are pure data moves from EInkDisplay.cpp; not interesting to read line-by-line.

On X3 (UC81xx) with anti-aliasing off, the first fast/half differential
after a full refresh garbled, bleeding the prior content: opening the
reader menu or the first page turn after entering a book, and the first
turn after the periodic full-refresh cadence. The controller's post-full
state corrupts the next differential; it is not a DTM1-content problem
(promoting that op to a half did not help, a full did).

After any X3 full, spend that corrupt slot with a no-op fast of the
just-displayed frame. DTM1 and DTM2 both hold it, so nothing visibly
changes, but the controller is left in the post-fast state and the
caller's next differential renders clean.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 826fdf3a-a015-40be-9182-af577fba93d0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jeremydk jeremydk force-pushed the feat/panel-abstraction branch from 4fe0057 to 52170b7 Compare May 25, 2026 04:13
jeremydk added a commit to jeremydk/community-sdk that referenced this pull request May 25, 2026
Port of the original feat/eink-idle-hook commit (9df4199) onto the
Panel-virtualized SDK. Same public API and contract; the call sites
moved because pollBusy is no longer a single method on EInkDisplay.

waitForRefresh / waitWhileBusy poll the BUSY line in a 1 ms delay loop
for 400-700 ms per typical page turn. That window is CPU-idle and a
natural slot for amortizing background work the reader is otherwise
forced to run after displayBuffer returns (chunked next-chapter
indexing, for example).

setIdleHook installs a C-style void(void*) callback + context; the
polling loops invoke it after each 1 ms poll. The hook is a function
pointer (not std::function) to keep binary cost flat across SDK
consumers, and fires on whatever task drove the wait into the SDK
(the render task in the existing reader architecture).

Caller contract: the hook must return within ~50-100 ms (it delays
BUSY detection by exactly that much), and must be safe to call from
the calling task. Returning immediately when there is no work to do
(one cheap pointer check) is the common case.

Differences vs the original commit:
  * Storage stays on EInkDisplay (single static slot for the whole SDK
    — installing a second hook replaces the first; same as pre-refactor).
  * setIdleHook / tickIdleHook are public static methods on EInkDisplay.
    tickIdleHook is the per-loop tick point that each panel's pollBusy
    invokes; it's `static inline` so the null-hook check is one
    pointer compare with no call overhead.
  * X4Panel::pollBusy gets 1 tick call (single-phase HIGH-wait).
    X3Panel::pollBusy gets 2 tick calls (two-phase HIGH→LOW→HIGH wait).
    Total 3 call sites vs 3 in the pre-refactor monolithic pollBusy —
    identical poll cadence.

Built on top of the Panel-abstraction branch (PR crosspoint-reader#15). Image +60 bytes
for the 3 tick call sites, the static slot, and the setter body.

Hardware verified both panels:
  * X4: half=1554ms, fast=418/419ms, EpubReader render cycle clean.
    Timings identical to pre-hook (the null hook check is a no-op).
  * X3: full=926ms, post-full settle=417ms, conditional resync 455ms,
    grayscale (gc bank) 2346ms, fast=378ms. Same.
Zero ERR/assert/abort on either device.
EInkDisplay was a runtime-switched monolith — a `_x3Mode` bool plus a
fistful of `if (_x3Mode) ...` branches scattered across init, refresh,
display, grayscale, and busy-poll code paths. Adding a third panel
would have meant editing every one of those branches. This refactor
introduces an abstract `Panel` base class with concrete X3Panel and
X4Panel subclasses; EInkDisplay owns a `unique_ptr<Panel>` and
dispatches device-specific work through 18 virtual methods.

What's in this commit:

- **Panel hierarchy**: new `Panel.h` with abstract base + X4Panel
  declaration; `X3Panel.h` for the X3-specific helper surface that's
  too large to inline. Concrete impls live in `X3Panel.cpp` /
  `X4Panel.cpp` alongside their controller-specific LUT banks
  (`X3Luts.cpp` / `X4Luts.cpp`) and command opcodes
  (`X3Constants.h` / `X4Constants.h`).

- **Panel selection at construction**: new
  `EInkDisplay(unique_ptr<Panel>, sclk, mosi, cs, dc, rst, busy)`
  ctor. Legacy pin-only ctor still works (defaults to X4Panel for
  backcompat); `setDisplayX3()` is preserved as a `[[deprecated]]`
  shim that swaps the panel in place so existing firmware can
  migrate at its own pace.

- **Friend-based access**: each Panel subclass is a `friend class`
  of EInkDisplay so device-specific code can reach into SPI/pin
  state directly. The alternative (a DisplayContext interface) was
  evaluated and rejected — see the design note at the top of
  `Panel.h` for the four-point rationale (hot-path perf without
  whole-program LTO, bounded panel set, same-author maintenance,
  EInkDisplay and Panel co-evolve).

- **Scoped-enum API**: `RefreshMode` and `GrayPlane` promoted from
  C-style `enum` to `enum class : uint8_t`. The underlying-type pin
  locks any cache structure holding these enums at one byte (vs the
  4-byte `int` default). Use sites qualify as
  `RefreshMode::FULL_REFRESH` / `GrayPlane::GRAY_PLANE_LSB` etc.
  Value names kept (no rename to `Full` / `Half` / `Fast`) to
  minimize churn at call sites; cosmetic rename can be a separate
  later pass.

- **X3 state machine on X3Panel**: the resync flags (`_x3RedRamSynced`,
  `_x3InitialFullSyncsRemaining`, `_x3ForceFullSyncNext`,
  `_x3ForcedConditionPassesNext`, `_x3GrayState`) move to X3Panel so
  X4 builds carry none of it. `requestResync()` / `skipInitialResync()`
  on EInkDisplay become thin forwarders to `_panel->...`.

Net: EInkDisplay shrinks from 1813 lines to ~390. Adding a third
panel from here is a new TU + a friend declaration; no edits to
EInkDisplay's dispatch.

Hardware-verified on both X3 (port 101) and X4 (port 2101).
@jeremydk jeremydk force-pushed the feat/panel-abstraction branch from 52170b7 to 5d532e8 Compare May 25, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant