Skip to content

E1004 / dual-controller panels: pin bb_epaper upstream, replace the fork shim with a runtime-gated bufferless module - #147

Merged
jonasniesner merged 5 commits into
OpenDisplay:mainfrom
davelee98:feat/e1004
Aug 10, 2026
Merged

E1004 / dual-controller panels: pin bb_epaper upstream, replace the fork shim with a runtime-gated bufferless module#147
jonasniesner merged 5 commits into
OpenDisplay:mainfrom
davelee98:feat/e1004

Conversation

@davelee98

@davelee98 davelee98 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Why

esp32-s3-E1004 pinned a fork of bb_epaper (limengdu, PR bitbank2#32) for T133A01 support. That support has since landed upstream in 5dccfbb, so the fork pin can go.

Repinning on its own is not safe, and this is the important part: the E1004 code was guarded by #ifdef BBEP_T133A01 — a bb_epaper library define, not a firmware one. Upstream does not define it, so moving the pin compiled every E1004 path out of the binary while pio run -e esp32-s3-E1004 still reported SUCCESS. Firmware that cannot drive the panel, with no build error and no log line.

This PR repins, deletes the fork shim, and replaces it with a module that cannot fail that way.

What changed

Pin + env — all envs pin bitbank2/bb_epaper.git#5dccfbb. The [env] URL was previously unpinned, so CI and local builds each got whatever HEAD was at install time; locally that was c651b2a, while the source already carried a workaround for 71f6e70 (see epdAlignCustomPartialRamMode) — a commit not in the installed library. Pinning closes that divergence rather than creating one.

The esp32-s3-E1004 env is deleted. It was the same hardware as esp32-s3-N32R8-extuart, and the panel is selected at runtime by panel_ic_type. CI matrix 12 → 11.

New src/split_panel.{h,cpp} — gated at runtime on bbep.iFlags & BBEP_SPLIT_BUFFER, with static_asserts that turn a future library rename into a compile error on every target instead of a silent no-op.

Still bufferless. Upstream's own dual-CS writer (bbepWriteImage4bppDual, via bbepWritePlane) requires a 960 KB ucScreen; the data phase is driven from the library's own primitives instead — cs_mode = CMD_CS_NONE, manual chip-select, one DTM per controller, chunked bbepWriteData. That is the same idiom bbepWriteImage4bppDual uses internally; only the byte source differs. The other three phases need no help: the init sequence carries CMD_CS1/CMD_CS1_CS2 opcodes, and bbepRefresh/bbepSleep broadcast DRF/POFF off the SPLIT_BUFFER flag.

Keying on the flag rather than a panel id also covers EP81_SPECTRA_1024x576 (wire 0x2B), which previously took the generic path and drove only its left controller.

Bugs fixed rather than carried over

  • Run length was computed in a uint16_t, so a chunk of ≥65536 bytes truncated to 0 and spun forever in a loop with no watchdog feed
  • Truncated and overrun frames were dropped silently and still refreshed as success; splitPanelCloseFrame now returns a verdict and both refresh sites honour it
  • No bound in the raw write path, and no busy gate before streaming
  • waitforrefresh had an E1004 shortcut assuming bbepRefresh waited. True of the fork, false upstream — it could report success before BUSY even asserted and let the rail be cut mid-refresh
  • No POFF after refresh: the C bbepRefresh issues DRF and returns; only the unused C++ BBEPAPER::refresh wrapper powers down
  • cs_pin_2 was absent from configureDisplayPinsLowPower(), leaving the second chip select driven HIGH into an unpowered controller at rail cut

Also drops three branches that are now dead: the || splitPanelUsed() in both partial-update NACKs (bwgbry_split reports 4 bpp, so the bpp test already excludes them) and the forced REFRESH_FULL (the Spectra6 table rows carry a NULL pInitFast, so bbepRefresh falls back to pInitFull and both modes emit an identical sequence).

Also

  • esp32-s3-N32R8-extuart-debug bench env, mirroring the N16R8 pair. Not in default_envs or firmware-targets.json, like the other -debug envs.
  • CLAUDE.md env counts corrected (12 → 11, 11 → 10).

Cost

~1.6 KB flash per target, no RAM. All 11 CI targets build, plus all 16 local envs.

Hardware verification

Full image push cycle verified on an E1004. That exercises the parts that could not be settled by inspection:

  • the dual-CS data phase and the crossing from CS1 to CS2 at the half-plane boundary
  • the init sequence bbepRefresh re-sends after the data write. It contains no EPD_RESET and ends with PON + CCSET, so it acts as the pre-DRF preamble rather than a reset — confirmed not to disturb the streamed RAM. Worth noting the fork solved the same problem the opposite way, suppressing re-init, because its init table began with a reset; the two libraries are not interchangeable here by inspection.
  • the refresh wait and POFF sequencing that replaced the stale waitforrefresh shortcut

Not verified: the 8.1" EP81_SPECTRA_1024x576 (wire 0x2B). It now takes this path off the BBEP_SPLIT_BUFFER flag rather than the generic single-CS path, where it drove only its left controller. That is a fix on paper, but it is an untested behaviour change and no such unit was available. A reviewer with one should exercise it before this merges, or say so and I will gate it back to the E1004 panel id only.

…04 env

bb_epaper has no releases, so the unpinned `[env]` URL meant CI and local
builds each got whatever HEAD was at install time. Locally that was c651b2a
while the source already carried a workaround for 71f6e70 (see
epdAlignCustomPartialRamMode) -- a commit that was not in the installed
library. Pinning closes that divergence rather than creating one.

5dccfbb is "Added support for the Seeed reTerminal E1004 and its 13.3
Spectra6 1200x1600 panel", which replaces the limengdu fork pin the E1004
env carried for PR bitbank2#32.

The esp32-s3-E1004 env goes with it: it was the same hardware as
esp32-s3-N32R8-extuart, and the panel is selected at runtime by
panel_ic_type, not at build time. CI matrix drops 12 -> 11 targets.

NOTE: E1004 hardware built from esp32-s3-N32R8-extuart now runs a 9-bit
(512 B) zlib window instead of 15-bit. That is a hard reject at the zlib
header for any stream declaring a larger window (od_zlib_stream.c:641),
not a slower decode -- deliberate, but worth knowing if a client ever
compressed with wbits=15 for this target.
Replaces the E1004 shim in display_service.cpp with src/split_panel.{h,cpp}.

The shim was guarded by `#ifdef BBEP_T133A01` -- a *bb_epaper* define from
the limengdu fork, not a firmware one. Repinning to upstream removed the
define, so all 12 guarded regions compiled to nothing while
`pio run -e esp32-s3-E1004` still reported SUCCESS: firmware that could not
drive the panel, with no error and no log line. The new module is gated at
runtime on `bbep.iFlags & BBEP_SPLIT_BUFFER`, so panel support cannot
disappear with a library change, and static_asserts turn a future rename
into a compile error on every target.

Still bufferless: upstream's own dual-CS writer (bbepWriteImage4bppDual, via
bbepWritePlane) needs a 960 KB ucScreen, so the data phase is driven from the
library's own primitives instead -- cs_mode = CMD_CS_NONE, manual chip-select,
one DTM per controller, chunked bbepWriteData. Same idiom the library uses
internally; only the byte source differs. The other three phases need no help:
the init sequence carries CMD_CS1/CMD_CS1_CS2 opcodes, and bbepRefresh/
bbepSleep broadcast DRF/POFF off the SPLIT_BUFFER flag.

Keying on the flag rather than a panel id also covers EP81_SPECTRA_1024x576
(wire 0x2B), which previously took the generic path and drove only its left
controller.

Fixed rather than carried over from the shim:
  - run length computed in uint16_t, so a >=65536-byte chunk truncated to 0
    and spun forever in a loop with no watchdog feed
  - truncated/overrun frames dropped silently and still refreshed as success;
    splitPanelCloseFrame now returns a verdict and both refresh sites honour it
  - no bound in the raw write path, and no busy gate before streaming
  - waitforrefresh's E1004 shortcut assumed bbepRefresh waited. True of the
    fork, false upstream -- it could report success before BUSY even asserted
    and let the rail be cut mid-refresh
  - no POFF after refresh: the C bbepRefresh issues DRF and returns, and only
    the unused C++ BBEPAPER::refresh wrapper powers down

Also drops three branches that are now dead: the `|| splitPanelUsed()` in both
partial-update NACKs (bwgbry_split reports 4 bpp, so the bpp test already
excludes them) and the forced REFRESH_FULL (the Spectra6 table rows carry a
NULL pInitFast, so bbepRefresh falls back to pInitFull and both modes emit an
identical sequence).

boot_screen keeps its two half-passes -- it already emits in the sink's wire
order, so it just feeds it, and painting half a frame at a time still avoids
rasterising 1200x1600 twice.

All 11 CI targets build (incl. esp32-wrover-e-N4R8, absent from default_envs).
Cost ~1.6 KB flash per target, no RAM.

UNVERIFIED ON HARDWARE. A clean build proves linkage, not function -- that is
exactly what the old gate got wrong. Open bench questions: whether the init
sequence bbepRefresh re-sends leaves streamed RAM intact (it has no EPD_RESET,
so it should), whether holding CS across the boot screen's rasterisation is
tolerated, and the 8.1" behaviour change above.
Debug-level logging on the E1004's hardware. Mirrors
esp32-s3-N16R8-extuart-debug: extends the parent env and adds
-DOD_LOG_LEVEL=OD_LOG_DEBUG, nothing else.

Deliberately absent from default_envs and .github/firmware-targets.json, as
both existing -debug envs are -- this is a bench build, not a shipped target,
so CI stays at 11 and the release matrix is unchanged.

Worth having on this env specifically: -extuart routes logging to GPIO43/44
and prints no panic backtrace over USB, so debug logs are the only running
commentary during dual-controller panel bring-up.

+12,116 B flash, +16 B RAM over the parent (in line with the N16R8 pair's
+12,492). All 16 envs build.
configureDisplayPinsLowPower() drove cs_pin, clk, data, dc and reset low on
every pwrmgm(false) but left cs_pin_2 out, so on dual-controller panels the
second chip select stayed a driven output at whatever level the last frame
left it (HIGH) while the rail went away -- the one panel pin still able to
source into an unpowered controller through its protection diodes.

Guarded separately rather than added to the pins[] array: that loop skips only
0xFF, and an unconfigured cs_pin_2 reads 0 on most devices, which would drive
GPIO0 -- a strapping pin -- low on the way down. The 0/0xFF guard matches what
the auxPins loop below already does.

Keyed on the pin being configured, not on splitPanelUsed(): main.cpp has no
business knowing the panel class, and a configured cs_pin_2 means a second
controller regardless.

Scope: this closes the pin state at rail cut, not through deep sleep. Nothing
calls gpio_hold_en on the panel bus (only the power-latch pin is held), so
every one of these pads still reverts at sleep entry. That is a separate,
board-wide question needing the schematic or an ammeter.
CI builds 11 environments, not 12, and default_envs lists 10, not 11 --
esp32-s3-E1004 is gone (same hardware as esp32-s3-N32R8-extuart; the panel is
selected at runtime by panel_ic_type). The esp32-wrover-e-N4R8 caveat is
unchanged: it still ships and is still absent from default_envs.
@davelee98
davelee98 requested a review from jonasniesner as a code owner August 9, 2026 22:42
@jonasniesner
jonasniesner merged commit 20807f3 into OpenDisplay:main Aug 10, 2026
12 checks passed
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.

2 participants