velxio/docs/wiki/epaper-emulation.md

275 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)~~ | **✅ 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. |
| ~~UC8159c 5.65" 7-colour ACeP~~ | **✅ shipped** — `epaper-5in65-7c` panel kind. New decoder family, same hook + Web Component. |
| ~~UC8179 7.5" full driver~~ | **✅ 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](https://www.seeedstudio.com/13-3inch-Six-Color-eInk-ePaper-Display-with-1200x1600-Pixels-p-6569.html)) | 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](../../test/test_epaper/autosearch/) — the
pre-implementation research notes covering Seeed catalog,
controllers, SPI protocol, library compatibility, SVG layouts, and
the phased plan.
- [Custom chips (ESP32 backend runtime)](./custom-chips-esp32-backend-runtime.md)
— the same backend-runs-the-peripheral pattern that the ePaper SSD168x
slave mirrors.