velxio/docs/wiki/epaper-emulation.md

15 KiB
Raw Blame History

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_VRAM and 0x26 WRITE_RED_VRAM only become visible after 0x20 MASTER_ACTIVATION.
  • BUSY pin: driven HIGH for refreshMs (default 50 ms) after every activation, then back LOW. Real hardware is 15 s; the default is shrunk for snappy testing. Bump it via the refreshMs property.
  • 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:

  1. Sizes RAM to the longer side both ways so a transposed layout isn't truncated.
  2. 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 of setRotation(1) when native == transpose. Orientation is detected by byte width (nw_bytes == ceil(W/8) vs ceil(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'): 0x26 is the additive red plane (red wins on compose, red RAM inits to 0x00 "no red").
  • B/W: 0x26 is 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

  1. Open /examples and pick ePaper 1.54" Hello — Arduino Uno.
  2. 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.
  3. Try the 2.9" ESP32 Weather example next — that path goes through the backend SSD168x slave (epaper_update events 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's Serial.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.
  • controllerFamily must match the GxEPD2 driver class. A UC8179 / UC8159c panel decoded as ssd168x (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) and pytest test/test_epaper (23). Tri-colour cases construct the decoder with palette: 'bwr' / is_bwr=True.

Roadmap (deferred)

Feature Why not yet
Tri-colour SSD168x (B/W/R) shippedepaper-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.
UC8159c 5.65" 7-colour ACeP shippedepaper-5in65-7c panel kind. New decoder family, same hook + Web Component.
UC8179 7.5" full driver shippedepaper-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.