15 KiB
ePaper / e-Ink emulation in Velxio
Velxio emulates GxEPD2-driven ePaper panels across three controller
families — Solomon Systech SSD168x (mono + B/W/Red), UltraChip
UC8159c (5.65" 7-colour ACeP) and UltraChip UC8179 / GD7965 (7.5"
mono) — on AVR, RP2040 and ESP32 boards. One component, one Web Component,
parameterised by panelKind; the decoder is selected from the panel's
controllerFamily.
Status: every gallery example renders correctly on the boards it fits on (B/W, B/W/Red, and 7-colour ACeP). See "Decoder internals" and the sections after it for the rotation / paging / tri-colour / BUSY details, and "Roadmap" for what's still deferred.
Supported panels
panelKind (metadata id) |
Size | Resolution | Palette | Controller IC | AVR Uno? | RP2040? | ESP32? |
|---|---|---|---|---|---|---|---|
epaper-1in54-bw |
1.54" | 200×200 | B/W | SSD1681 | ✅ paged | ✅ | ✅ |
epaper-2in13-bw |
2.13" | 250×122 | B/W | SSD1675A / IL3897 | ✅ paged | ✅ | ✅ |
epaper-2in13-bwr |
2.13" | 250×122 | B/W/R | SSD1680 (3-colour) | ⚠️ tight | ✅ | ✅ |
epaper-2in9-bw |
2.9" | 296×128 | B/W | SSD1680 | ⚠️ tight | ✅ | ✅ |
epaper-2in9-bwr |
2.9" | 296×128 | B/W/R | SSD1680 (3-colour) | ❌ flash | ✅ | ✅ |
epaper-4in2-bw |
4.2" | 400×300 | B/W | SSD1683 / UC8176 | ❌ flash | ✅ | ✅ |
epaper-7in5-bw |
7.5" | 800×480 | B/W | UC8179 / GD7965 | ❌ | ⚠️ tight | ✅ |
epaper-5in65-7c |
5.65" | 600×448 | ACeP 7-colour | UC8159c | ❌ flash | ⚠️ tight | ✅ |
Three of those panels use a non-SSD168x controller and have their own
decoder; EPaperPart.ts (browser) and esp32_worker.py (ESP32) both pick
the decoder from cfg.controllerFamily:
controllerFamily |
Panels | Browser decoder | ESP32 worker slave |
|---|---|---|---|
ssd168x |
1.54 / 2.13 / 2.9 / 4.2" (B/W + B/W/R) | SSD168xDecoder.ts |
Ssd168xEpaperSlave |
uc8159c |
5.65" ACeP 7-colour | UC8159cDecoder.ts |
Uc8159cEpaperSlave |
uc8179 |
7.5" 800×480 mono | Uc8179Decoder.ts |
Uc8179EpaperSlave |
The 7.5" panel was originally mislabeled
controllerFamily: 'ssd168x'and rendered blank — GxEPD2_750_T7 is a UC8179, whose 0x10/0x13 DTM stream the SSD168x decoder ignores. Setting it to'uc8179'+ adding the decoder fixed it.
"AVR ⚠️ tight" means the GxEPD2 paged build fits but Adafruit_GFX font selection matters; "❌" means the binary blows past 32 KB at any sane config.
Wiring (every panel — same pinout)
┌──────────────┐
│ ePaper │
│ │
│ GND VCC │ GND → board GND
│ │ VCC → board 3V3
│ SCK SDI │ SCK → SPI clock (SCK)
│ │ SDI → SPI MOSI
│ CS DC │ CS → any digital
│ │ DC → any digital (LOW = command)
│ RST BUSY │ RST → any digital (active LOW)
│ │ BUSY → any digital (input — HIGH while refreshing)
└──────────────┘
Pin order in GxEPD2 constructors: (CS, DC, RST, BUSY). Use those
exact pin numbers in the sketch.
How the emulator works
There are three rendering paths, picked automatically at runtime by
EPaperPart.attachEvents() based on which simulator owns the board:
| Board family | Decoder location | Plumbing |
|---|---|---|
| AVR (Uno / Nano / Mega) | Browser, frontend/src/simulation/displays/SSD168xDecoder.ts |
Hooks simulator.spi.onByte. CS / DC / RST tracked via pinManager.onPinChange. |
| RP2040 (Pico / Pico W) | Browser, same decoder | Hooks rp2040.spi[bus].onTransmit. Same pin tracking. |
| ESP32 family | Backend, backend/app/services/esp32_spi_slaves.py::Ssd168xEpaperSlave |
Worker subprocess decodes SPI synchronously inside the QEMU thread; emits epaper_update WS event with the latched framebuffer (base64 palette buffer). |
For all three paths:
- Latched RAM: pixels written via
0x24 WRITE_BLACK_VRAMand0x26 WRITE_RED_VRAMonly become visible after0x20 MASTER_ACTIVATION. - BUSY pin: driven HIGH for
refreshMs(default 50 ms) after every activation, then back LOW. Real hardware is 1–5 s; the default is shrunk for snappy testing. Bump it via therefreshMsproperty. - Auto-RAM-window auto-increment honours
0x11 DATA_ENTRY_MODE(default 0x03 = X+, Y+, X-first).
Decoder internals (SSD168x)
The SSD168x decoder is implemented three times and they must stay byte-for-byte identical — change one, change all three:
frontend/src/simulation/displays/SSD168xDecoder.ts(TypeScript, AVR/RP2040)backend/app/services/esp32_spi_slaves.py::Ssd168xEpaperSlave(Python, ESP32 worker)test/test_epaper/ssd168x_decoder.py(the Python golden reference / spec)
test_ssd168x_protocol.py + ssd168x-decoder.test.ts replay the same byte
streams through them and assert identical framebuffers.
Native-window compose, then rotate
GxEPD2 pre-rotates in software (Adafruit_GFX) and writes the controller's
native RAM. For a panel used in landscape via setRotation(1) the native
RAM is the transpose of the display (e.g. a 296×128 panel's controller RAM
is 128×296). The decoder therefore:
- Sizes RAM to the longer side both ways so a transposed layout isn't truncated.
- Composes in the native active window (the bytes the firmware actually
wrote, set via
0x44/0x45), then rotates to the display orientation — identity when native == display, the inverse ofsetRotation(1)when native == transpose. Orientation is detected by byte width (nw_bytes == ceil(W/8)vsceil(H/8)), because a non-multiple-of-8 native width (the 2.13" panel is 122 px → padded to 128) breaks a naive pixel compare.
Limitation: the stream looks identical for rotation 0 vs 2 and 1 vs 3, so the decoder can only disambiguate 0/1 (which every shipped example uses).
Paged drivers → window union
GxEPD2 with page height < panel (firstPage()/nextPage()) writes the frame
in horizontal strips, setting a partial RAM window per page. Compose must
use the union of every window since the last flush (the _win_*_set flags
reset on 0x20), or only the last strip renders — the all-white-paged-panel
bug (1.54" Uno, 4.2" Pico, 7.5" ESP32).
RAM Y-counter wraps at the window
The RAM address counter wraps at the active-window boundary like real
hardware. GxEPD2_3C (tri-colour) writes the 0x24 plane then the 0x26
plane without re-seeking the counter between them, relying on this wrap; a
decoder that runs Y past the window end drops every 0x26 byte and the red
layer goes white.
B/W vs B/W/Red (is_bwr)
- Tri-colour (
palette: 'bwr'):0x26is the additive red plane (red wins on compose, red RAM inits to 0x00 "no red"). - B/W:
0x26is a second mono plane (some controllers, e.g. GDEY029T94, mirror the image there) — red RAM inits to 0xFF and a pixel is white only if both planes say white.
The browser passes isBwr = cfg.palette === 'bwr'; the worker derives it from
panel_kind containing 'bwr'.
UltraChip decoders (UC8159c / UC8179)
The UltraChip family is a different protocol from SSD168x: big register-setup
commands during init, then a linear DTM image stream and a 0x12 refresh
(the flush opcode, vs SSD168x's 0x20).
| UC8159c (5.65" ACeP) | UC8179 (7.5" mono) | |
|---|---|---|
| Pixels | 2 px/byte, 3-bit palette (7 colours) | 8 px/byte, 1 bpp (0xFF = white) |
| Image command | 0x10 DTM1 (linear, no window) |
0x13 DTM2 "current" (0x10 = ignored "previous") |
| Window | none — flat native raster | 0x90 partial window (9 bytes, pixel coords MSB-first), framed by 0x91/0x92 |
| Rotation | none (writes native raster for any rotation) | none — data lands at absolute pixel coords in the window, so compose == RAM |
| Refresh / flush | 0x12 |
0x12 |
Both produce a Frame the existing paintFrame() renders (the UC8179 reuses
the SSD168x 0=black / 1=white palette; the UC8159c uses ACEP_PALETTE_RGB).
BUSY polarity is per controller family
GxEPD2 busy-waits inside _PowerOn()/_InitDisplay(), before any frame is
sent, so the worker seeds the BUSY pin to the panel's idle level at
registration (esp32_worker.py, the _init_sensors path):
| Family | Idle level | Busy level |
|---|---|---|
| SSD168x | LOW | HIGH |
| UltraChip (uc8159c, uc8179) | HIGH | LOW |
Get this wrong and GxEPD2's first busy-wait never satisfies → a 10 s "Busy
Timeout!" on every refresh (the original 7.5" symptom). The ePaper panels
register through the _init_sensors path, not the runtime sensor_attach
twin (whose BUSY was hardcoded and whose epaper_update emit was the old
double-data shape — both left as dead-but-fixed code).
WS plumbing (ESP32 path)
The worker emits epaper_update events to the frontend flat — fields at
the top level, NOT nested under 'data' — because the backend's
qemu_callback (simulation.py) re-wraps the post-type payload under
'data'. A nested 'data' here double-wraps, the frontend reads
msg.data.data.component_id (undefined), EPaperPart bails on
id !== componentId, and the panel never renders (the long-standing
"ESP32 ePaper is blank" bug). Every other worker event is flat for the same
reason.
Library compatibility matrix
| Library | AVR | RP2040 | ESP32 | Notes |
|---|---|---|---|---|
| GxEPD2 (Jean-Marc Zingg) | ✅ (panel must fit flash) | ✅ | ✅ | The de-facto Arduino library; every example is GxEPD2 (BW / 3C / 7C). |
| Adafruit_EPD | ❌ RAM | ✅ | ✅ | Should work — the SSD168x command set is identical. |
ESPHome waveshare_epaper |
n/a | n/a | ✅ | Generates the same SPI traffic as Adafruit_EPD; tested on real hardware. |
The example sketches use GxEPD2. Both GxEPD2 and Adafruit GFX Library are auto-installed by the editor when you load any example
that lists them in libraries: [...].
Try it
- Open
/examplesand pick ePaper 1.54" Hello — Arduino Uno. - Hit Run. After ~1 s the panel "refreshes" (you'll see the BUSY shimmer overlay) and the 1.54" canvas shows "Velxio / ePaper / OK!" in black on the off-white paper background.
- Try the 2.9" ESP32 Weather example next — that path goes through
the backend SSD168x slave (
epaper_updateevents arriving over the WebSocket), not the in-browser decoder. Same UX from the user's POV.
Debugging gotchas
- ESP32 ePaper render is SLOW. It's a per-byte SPI stream; the 7.5"
800×480 pushes ~96 KB/refresh and takes a while.
display.init(..., true, ...)also does an early clear-to-white refresh first, so the first flush is blank — reading the canvas/log too early looks like "it renders white". Wait for the image flush (the panel actually changing, or the sketch'sSerial.println("frame done")). - Restart the backend between ESP32 sims. The lcgamboa QEMU keeps singleton SPI state; a second Run in the same backend can corrupt it. For a clean measurement: restart backend → reload page → Run once.
controllerFamilymust match the GxEPD2 driver class. A UC8179 / UC8159c panel decoded asssd168x(or vice-versa) renders blank — the command sets don't overlap.- Tests (must stay green):
cd frontend && npx vitest run src/__tests__/ssd168x-decoder.test.ts(15) andpytest test/test_epaper(23). Tri-colour cases construct the decoder withpalette: 'bwr'/is_bwr=True.
Roadmap (deferred)
| Feature | Why not yet |
|---|---|
✅ shipped — epaper-2in13-bwr, epaper-2in9-bwr panel kinds. The decoder's red RAM plane (0x26 WRITE_RED_VRAM) was always there; we just enabled it for the SSD1680 3-colour panels. |
|
✅ shipped — epaper-5in65-7c panel kind. New decoder family, same hook + Web Component. |
|
✅ shipped — epaper-7in5-bw now uses the dedicated Uc8179Decoder / Uc8179EpaperSlave (controllerFamily uc8179) instead of being mis-decoded as SSD168x. |
|
| Other UC81xx panels (UC8176 4.2" alt) | The SSD168x driver covers the 4.2" GxEPD2_420 path today (it emits SSD168x-compatible traffic). Only matters if someone picks a panel whose GxEPD2 class strictly emits UC81xx commands a current decoder doesn't model. |
| E Ink Spectra 6 13.3" 1200×1600 (Seeed 6569) | Reverse-engineered command set; ship after Phase 1 proves the scaffold and we can capture real SPI traces from a Seeed EE02. |
| IT8951 Carta panels | Different protocol entirely (SPI packet stream). |
LUT (0x32) waveform validation |
Accept silently; never validate. Real panels sometimes send vendor-specific LUTs that aren't worth fingerprinting. |
| Real-time partial-window refresh | Phase 1 always does a full-frame refresh. |
| MicroPython on Pi Pico W | Phase 2.5 — no current epaper MicroPython driver tested in Velxio. |
Code map
| File | Purpose |
|---|---|
frontend/src/simulation/displays/SSD168xDecoder.ts |
SSD168x browser decoder (TS port of the golden spec) |
frontend/src/simulation/displays/UC8159cDecoder.ts |
UC8159c (5.65" ACeP 7-colour) browser decoder |
frontend/src/simulation/displays/Uc8179Decoder.ts |
UC8179 (7.5" mono) browser decoder |
frontend/src/simulation/displays/EPaperPanels.ts |
Per-panel geometry + controllerFamily assignments |
frontend/src/components/velxio-components/EPaperElement.ts |
<velxio-epaper> Web Component |
frontend/src/components/velxio-components/EPaper.tsx |
Thin React wrapper |
frontend/src/simulation/parts/EPaperPart.ts |
attachEvents factory — branches AVR / RP2040 / ESP32 + picks the decoder by controllerFamily |
frontend/src/data/examples-displays-epaper.ts |
gallery examples |
frontend/public/components-metadata.json |
picker entries (category: displays) |
backend/app/services/esp32_spi_slaves.py |
Ssd168xEpaperSlave / Uc8159cEpaperSlave / Uc8179EpaperSlave for the ESP32 path |
backend/app/services/esp32_worker.py |
dispatches the slave by controller_family; seeds the BUSY idle level; emits epaper_update |
test/test_epaper/ssd168x_decoder.py |
Golden Python SSD168x decoder (the spec) |
test/test_epaper/test_ssd168x_protocol.py |
pure-Python protocol tests |
frontend/src/__tests__/ssd168x-decoder.test.ts |
Vitest port of the same tests |
test/test_epaper/sketches/ |
canonical "hello world" sketches |
See also
- Research dossier — the pre-implementation research notes covering Seeed catalog, controllers, SPI protocol, library compatibility, SVG layouts, and the phased plan.
- Custom chips (ESP32 backend runtime) — the same backend-runs-the-peripheral pattern that the ePaper SSD168x slave mirrors.