One ESP-IDF firmware that drives many e-paper devices from a single codebase, part of the Tesserae self-hosted e-ink dashboard ecosystem. It is a battery-first, deep-sleep client: each wake it connects WiFi, asks a Tesserae server over REST for the current frame, downloads a panel-native buffer, paints it, and goes back to sleep.
The device is a deliberately thin client — it does no on-device image decoding. The server pre-renders each frame into exactly the byte format the panel wants (packed 4-bit Spectra-6, 1-bit mono, or 4-bit grayscale); the device streams those bytes straight to the panel. Adding a new panel is a board header plus one driver, with the shared network / cycle / splash / power stack untouched.
Every panel is driven through the same epd_driver_t vtable; the build selects
one board (and thus one driver) per PlatformIO environment.
| Device | Panel family | Controller | Resolution / depth | Driver | Env |
|---|---|---|---|---|---|
| Seeed reTerminal E1001 | Mono B/W | UC8179 | 800×480, 1bpp | mono_spi |
seeed-reterminal-e1001 |
| Seeed reTerminal E1001 (4-gray build) | 4-level grayscale | UC8179 (register LUTs) | 800×480, 2bpp | mono_spi (EPD_GRAY4) |
seeed-reterminal-e1001-gray |
| Seeed reTerminal E1002 | Spectra-6, single | UC81xx | 800×480, 4bpp | spectra6_spi_single |
seeed-reterminal-e1002 |
| Seeed reTerminal E1003 | Grayscale (10.3") | IT8951 | 1872×1404, 4bpp gray | it8951_gray |
seeed-reterminal-e1003 |
| Seeed reTerminal E1004 | Spectra-6, dual-chip | T133A01 | 1200×1600, 4bpp | spectra6_t133a01_dual |
seeed-reterminal-e1004 |
| Seeed XIAO ePaper Kit — EE02 | Spectra-6, dual-chip | T133A01 | 1200×1600, 4bpp | spectra6_t133a01_dual |
seeed-ee02 |
| TRMNL 7.5" OG DIY Kit | Mono B/W | UC8179 | 800×480, 1bpp | mono_spi |
xiao-epaper-75 |
| XIAO driver board + 7.5" B/W/Red panel (DKE DEPG0750RW / GDEW075Z08 class) | Tri-color BWR | UC8179 | 800×480, 2bpp | mono_spi (EPD_BWR) |
xiao-epaper-75-bwr |
| Seeed XIAO ePaper Display Board — EE04 + 7.5" mono (24-pin) | Mono B/W | UC8179 | 800×480, 1bpp | mono_spi |
seeed-ee04-75 |
| Seeed XIAO ePaper Display Board — EE04 + 7.3" Spectra-6 (50-pin) | Spectra-6, single | UC81xx | 800×480, 4bpp | spectra6_spi_single |
seeed-ee04-73e6 |
| Waveshare ESP32-S3-ePaper-13.3E6 | Spectra-6, dual-controller | UC81xx ×2 | 1200×1600, 4bpp | spectra6_spi_dual |
waveshare-133e6 |
| Waveshare PhotoPainter 7.3" | Spectra-6, single | ED2208-GCA | 800×480, 4bpp | spectra6_spi_single |
waveshare-photopainter-73 |
The four reTerminals, the PhotoPainter, the EE02, and the TRMNL 7.5" kit have been
verified end-to-end on real hardware; the Waveshare 13.3E6 is the seed target and
builds green. The EE04 pair builds green but is not yet hardware-verified
(pin map taken from Seeed_GFX; the EE04 takes one panel on either its 24-pin or
50-pin FPC — flash the env matching the attached panel and set the jumper caps
accordingly). Each board also has a …-selftest env that paints a driver-only
test pattern (colour bars / gray ramp / mono stripes) with no networking — flash
that first when bringing up a new unit.
Reuse is the norm — most boards share an existing driver and differ only in the board header:
- The PhotoPainter shares the E1002's
spectra6_spi_singledriver (its ED2208-GCA init is byte-identical); two board flags tailor it:EPD_ROTATE_180(panel mounted upside-down) andBOARD_HAS_PMIC(panel power + battery from an AXP2101 PMIC over I2C, not a GPIO gate / ADC divider — seesrc/pmic.c). - The XIAO ePaper Kit — EE02 shares the E1004's
spectra6_t133a01_dualdriver (same T133A01 panel), with only a different pin map. - The TRMNL 7.5" OG DIY Kit shares the E1001's
mono_spidriver (same 800×480 mono panel), with its own pin map.
The three XIAO ESP32-S3 boards (PhotoPainter, EE02, TRMNL 7.5") are native-USB
(no CH340), so their console runs on USB-Serial-JTAG via
sdkconfig.usbjtag.defaults — which also frees UART0 (GPIO43/44) on the boards
that route those pins to the panel.
Touch (reTerminal E1003 only). The E1003's onboard GT911 capacitive
digitiser can be enabled per-device from the server (Tesserae >= 0.140.0). It is
a deep-sleep wake source: a tap or swipe wakes the device, which reports the raw
stroke on the frame GET; the server classifies the gesture and repaints in the
same response (no on-device gesture logic). Battery cost: keeping touch armed
holds the GT911 scanning through deep sleep, drawing a few mA continuously, which
materially shortens battery life, so it is best used docked or on USB. Off by
default; a touch-less E1003 is unchanged, and the other seven boards build
byte-identical (all touch code is behind #if BOARD_HAS_TOUCH).
platformio.ini one [env:...] per device (board macro -> driver)
boards/<board>.h per-board pin map, geometry, palette, MCU tier,
device kind, and the selected PANEL_DRIVER_* macro
boards/board.h dispatches on -DTESSERAE_BOARD_* -> the board header
src/panel/
epd_panel.h epd_driver_t vtable {port_init,init,clear,display,
show_color_bars,show_palette_sweep,sleep} + panel_info
registry.c build-time driver selection -> epd_active_driver()
drivers/ one self-contained driver per panel family, each
#if-guarded by its PANEL_DRIVER_* macro
src/
main.c the wake cycle
net_rest.c / rest_config Tesserae REST client + NVS-backed config
image_fetcher / _decoder HTTP frame download + size-validated copy (no decode)
provisioning.c captive-portal setup (AP + DNS + scan + form)
splash.c on-device procedural splashes (logo, portal QR,
connect-status messages), bpp-aware
battery.c board-gated Li-Po telemetry (ADC or PMIC gauge)
pmic.c AXP2101 PMIC over I2C (rails + battery), BOARD_HAS_PMIC
sht4x.c E-Series environment telemetry, BOARD_HAS_SHT4X
wifi_manager.c WiFi STA/AP
Each concrete driver is a faithful port of a proven reference (Waveshare demo,
bitbank2/bb_epaper, or bitbank2/FastEPD), with byte-level
provenance in the source. Panel-specific quirks (dual-CS split, mirror, the
IT8951 load/waveform protocol) live in the driver; the rest of the firmware is
panel-agnostic and talks to the active panel only through the vtable.
boot
-> no WiFi creds / no server URL? -> captive portal -> reboot
-> connect WiFi (STA)
-> no device token? -> discover / register (onboard) -> sleep
-> GET frame (If-None-Match) 304 -> skip paint
204 -> nothing rendered yet
200 -> download the panel-native .bin
-> POST status (battery, rssi, optional temperature/humidity, next_poll_s)
-> WiFi off
-> paint the frame (radio off for the slow refresh)
-> deep sleep for the server-driven interval
The wall clock is taken from each REST response's HTTP Date header (no SNTP),
and an unchanged frame (ETag/304) skips both the download and the paint.
The panel always tells the user where setup stands, so a headless device is never a black box:
- WiFi won't connect or server unreachable (bad URL / server down) — the captive portal stays up and its subtitle says why ("Wi-Fi didn't connect", "Can't reach the server"), so it can be fixed on the spot.
- Reached the server, waiting for admin approval — this is not a failure: the panel shows "Almost done — approve this device in Tesserae" and the device sleeps and retries (it does not reopen the portal).
- Onboarded, no frame yet — paints "Connected! Waiting for your first frame" so setup has clear closure; the frame lands on a later wake.
To avoid re-refreshing the slow panel on every retry, these status splashes paint only on a cold / post-setup boot, not on timer wakes.
The device talks to <server_url>/api/v1/device/:
| Endpoint | Auth | Purpose |
|---|---|---|
POST /discover |
none | Zero-touch onboarding. The admin approves the device in the Tesserae UI; the next discover returns the token (matched by MAC). |
POST /register |
X-Pairing-Code |
Onboarding gated by a pairing code (idempotent). |
GET /<id>/frame |
Bearer + If-None-Match |
Frame metadata + ETag; the .bin is fetched from the returned URL. |
POST /<id>/status |
Bearer |
Telemetry (battery, rssi, ip, fw_version, optional environment); returns next_poll_s (drives the sleep) and config. |
The /status heartbeat JSON includes fw_version, the build's semantic
version with no leading v (for example 1.2.0; untagged builds report
0.0.<build> or 0.0.0-dev). The server compares it against the latest
available build to decide whether an update can be offered.
Wi-Fi OTA: every board env builds with TESSERAE_OTA_CAPABILITY_ENABLED
on an A/B slot layout (partitions_ota.csv; the E1004 has its own identical
table). The heartbeat advertises {"ota": {"schema": 1}}; the server may
answer with a signed descriptor, which the firmware verifies against its
baked-in Ed25519 public key before streaming the image into the inactive slot,
rebooting, and self-confirming (ESP-IDF rollback reverts a bad image). Devices
flashed before their board's OTA-enabled release need a one-time USB or
webflasher migration to the A/B layout; NVS (credentials + registration)
survives when flashing via the catalog's offset-addressed parts.
Seeed reTerminal E1001-E1004 boards also report the onboard SHT4x reading as
temperature_c (degrees Celsius) and humidity_pct (relative humidity
percentage), plus env_sensor: "sht4x". A failed sensor read omits these fields
without interrupting the heartbeat or frame cycle.
Each device reports a kind (TESSERAE_DEVICE_KIND in its board header) that
selects the server-side renderer. The server must produce the exact panel-native
format the firmware expects for that kind:
| Kind | Frame format | Size |
|---|---|---|
waveshare_133e6, seeed_reterminal_e1004, seeed_ee02 |
4bpp packed Spectra-6 | 960000 B |
seeed_reterminal_e1002, waveshare_photopainter_73, seeed_ee04_73e6 |
4bpp packed Spectra-6 | 192000 B |
seeed_reterminal_e1001, xiao_epaper_75, seeed_ee04_75 |
1bpp packed mono (bit 1 = white) | 48000 B |
seeed_reterminal_e1001_gray |
2bpp packed 4-gray (4 px/byte, MSB-first, 0b00=black..0b11=white) | 96000 B |
xiao_epaper_75_bwr |
2bpp packed BWR (4 px/byte, MSB-first, 0=black 1=white 2=red, 3 reserved) | 96000 B |
seeed_reterminal_e1003 |
4bpp packed grayscale (0=black…0xF=white) | 1314144 B |
The PhotoPainter reuses the E1002's 800×480 4bpp format exactly (render normally — the 180° rotation is done on-device, so do not pre-rotate on the server).
Boards with a microSD slot (both Waveshares and all four reTerminals) cache
pre-rendered "deck" pages on the card so a button/touch navigation becomes
wake → SD read → paint (1–2 s, radio off) instead of wake → Wi-Fi → fetch
(4–8 s). Entirely runtime-gated: card present and mountable → the device
advertises "deck_cache": {"schema": 1, "capacity_bytes": …} in register and
status bodies; no card → wire bodies and behaviour are identical to before.
The server binds a deck via GET /api/v1/device/<id>/deck (manifest of pages,
16-hex sha256 digests, byte sizes, TTLs, and button/zone links) and announces
version changes in the status response's "deck": {"version"}; the device
syncs at the tail of a scheduled wake (fetch missing digests, delete orphans)
and reports SD-served pages via deck_page_id/deck_version. Locally served
presses are not sent as button/touch actions. Every cached frame is
verified (exact length + digest, mbedTLS SHA-256) before painting; any
mount/read/parse failure falls back to the network path. Card layout:
/tesserae/decks/<deck_id>/manifest.json + <digest>.bin. Bring-up: the
…-sdtest env runs a mount + write/read/verify round trip over serial.
Touch boards with a partial-refresh panel (currently the E1003 / IT8951)
advertise "overlay": {"schema": 1} and can echo taps and update small value
slots locally in under a second, without a server round trip per interaction.
The server's full frame stays the base layer and source of truth: after each
full paint the firmware fetches GET /frame/overlay/<digest> (targets =
tap-echo rects, slots = value text fields, atlases = pre-rendered glyph
strips) and GET /frame/data?digest=… for pre-formatted value strings
(polled only inside the touch-linger awake window; overlay_values on
/status is treated identically, newest seq wins). Tap echoes invert their
rect with a fast DU partial refresh and never delay the normal stroke
dispatch; text is blit-only from the atlas. After 8 partial refreshes (or on
any new full frame) a GC16 full-quality pass clears ghosting. Specs, atlases
and rect patches are cached on SD keyed by frame digest so a tap that wakes
the device echoes offline. A server without overlay support (404) leaves the
feature fully dormant. Bring-up: the …-overlaytest env runs a synthetic
spec (invert target + digits atlas) with timings on serial.
Requires PlatformIO Core. Build one target:
pio run -e seeed-reterminal-e1002 # or any env from the tableThe first build fetches the ESP-IDF toolchain (a few minutes); later builds take
~15–50 s. Output is under .pio/build/<env>/: firmware.bin (app, flashed at
0x10000) alongside bootloader.bin (0x0) and partitions.bin (0x8000).
Every push is built for all targets in CI (see
.github/workflows/firmware.yml), which stamps an auto-incrementing
0.0.<build> version (starting at 0.0.0) into FW_VERSION and the uploaded
artifact / .bin names. Local builds fall back to 0.0.0-dev. Tagged releases
(.github/workflows/release.yml) instead stamp the release tag's semantic
version with the leading v stripped, so tag v1.2.0 reports FW_VERSION
1.2.0 in the heartbeat while the artifact paths keep the v prefix.
The simplest way to flash a device is the browser flasher at tesserae.ink/flash — no PlatformIO, no esptool, nothing to install. Plug the device in over USB, pick your board and version, and click flash; it talks to the ESP32 directly over WebSerial (use Chrome or Edge). Each release published here is built and uploaded automatically, so the flasher always offers the latest firmware for every target.
One driver caveat: the reTerminals use a WCH CH340 bridge, so on macOS you still need the CH34x driver below for the browser to see the port. The XIAO boards (PhotoPainter, EE02, TRMNL 7.5") are native-USB and need no driver.
Prefer building yourself? Flash the env you built over USB:
The reTerminals flash through an onboard WCH CH340 USB-serial bridge
(not the ESP32-S3 native USB). On macOS install the WCH CH34x DriverKit driver
(WCHSoftGroup/ch34xser_macos)
and enable it under System Settings → General → Login Items & Extensions →
Driver Extensions; the port then appears as /dev/cu.wchusbserial*.
The XIAO ESP32-S3 boards (PhotoPainter, EE02, TRMNL 7.5") have no CH340 — they
flash over the S3's native USB-Serial-JTAG, which enumerates as
/dev/cu.usbmodem* (no driver needed). These boards route the console to
USB-Serial-JTAG, so app logs are visible over that USB port (except the
PhotoPainter, whose console stays on UART0 — use the panel splashes there).
pio run -e <env> -t upload --upload-port /dev/cu.wchusbserial* # reTerminals
pio run -e xiao-epaper-75 -t upload --upload-port /dev/cu.usbmodem* # XIAO boardsIf a native-USB board's port keeps flickering/disappearing (the app deep-sleeps, which drops the USB), force ROM download mode: hold BOOT, unplug, replug while holding BOOT, release — the port then stays steady for flashing.
Or with esptool directly (from the build dir):
esptool --chip esp32s3 --port <PORT> --baud 460800 write-flash --flash-size detect \
0x0 bootloader.bin 0x8000 partitions.bin 0x10000 firmware.bin- Always
--flash-size detect. Never erase-all (-e) on a reflash — it wipes NVS (WiFi creds + device registration). - E1003 only: its 32 MB flash trips esptool's stub loader (
attach_flashfails); flash with--no-stub(the env pins this viaupload_flags). - App logs (and panic backtraces) come out the CH340/UART0 at 115200.
A fresh device (no WiFi creds or no server URL) comes up as a captive-portal setup AP:
- It paints a setup splash (logo, the AP name, the portal URL, a join QR).
- Join the
Tesserae-SetupWiFi AP (scan the QR, or pick from the list; passwordtesserae). - Enter your WiFi and the Tesserae server URL (and an optional pairing code).
- It reboots, onboards over REST, and appears in Tesserae → Settings → Devices for approval. Once approved it fetches and paints frames.
Development shortcut: copy include/secrets.example.h to include/secrets.h
(git-ignored) and set WIFI_DEFAULT_* / REST_DEFAULT_SERVER_URL to skip the
portal while iterating.
boards/<board>.h— pin map, geometry, palette,TESSERAE_DEVICE_MODEL,TESSERAE_DEVICE_KIND, MCU tier, and thePANEL_DRIVER_*macro (reuse an existing driver, or point at a new one).- If it's a new panel family, add
src/panel/drivers/<driver>.c(guarded by itsPANEL_DRIVER_*macro, exporting anepd_driver_t) and an#elifinboards/board.handsrc/panel/registry.c. - Add an
[env:...](+-selftest) inplatformio.iniand to the CI matrix. - Server side: register the device kind + a renderer that emits the panel-native frame format.
The panel hardware facts, pin maps, and init/refresh sequences build on the work of several open-source projects, with thanks:
- usetrmnl/trmnl-firmware — reference firmware for the Seeed reTerminal e-paper devices.
- bitbank2/bb_epaper and bitbank2/FastEPD — the underlying panel/controller drivers (Spectra-6, UC8179 mono, IT8951 grayscale).
- the Waveshare 13.3E6 ESP-IDF demo and the Pimoroni Inky drivers.
- waveshareteam/ESP32-S3-PhotoPainter and aitjcize/esp32-photoframe for the PhotoPainter's ED2208-GCA panel and AXP2101 PMIC bring-up.
Bundled third-party code: the public-domain font8x8, and the MIT
qrcodegen (src/vendor/).
AGPL-3.0-or-later (see LICENSE), matching the sibling
tesserae-device-* repositories.