Custom firmware overlay for TRMNL e-ink displays that replaces the cloud API with GitHub Pages as the image source, using AES-256-CBC encryption.
Instead of fetching images from the TRMNL server, the device downloads encrypted images from a static GitHub Pages site that you control.
- Device wakes from deep sleep, connects to WiFi
- Downloads an encrypted manifest from your GitHub Pages URL
- Decrypts manifest with a pre-shared AES key stored in NVS
- Picks the next screen (round-robin) and downloads the encrypted BMP
- Decrypts in PSRAM, renders on the e-paper display
- Goes back to deep sleep for
refresh_rateseconds
This repo contains only the overlay files — custom code that gets layered on top of the official TRMNL firmware. The upstream firmware lives in the upstream branch (clean snapshots of official releases).
main branch (overlay):
src/ — Custom firmware source (crypto, GitHub client, manifest parser)
include/ — Headers for overlay modules
test/ — Unit tests for crypto
tools/ — Python utilities (key generation, image encryption, manifest builder)
content-template/ — Template for your content repository
platformio.ini — Extended with github_pages build environment
build.sh — Build script that merges upstream + overlay
upstream branch:
(complete TRMNL firmware snapshot, e.g. v1.7.5)
- Python 3 (venv recommended)
pip install platformio esptool
Copy the example secrets file and fill in your values:
cp src/secrets.h.example src/secrets.hThen edit src/secrets.h:
| Define | Description |
|---|---|
GITHUB_PAGES_MANIFEST_URL |
URL to your encrypted manifest file (built with ./tools/update_manifest.py) |
GITHUB_PAGES_IMAGES_BASE |
Base URL for encrypted images (created by ./tools/encrypt_image.py) |
GITHUB_PAGES_AES_KEY_HEX |
64-character hex string of your 256-bit AES key (generate with ./tools/generate_key.py) |
src/secrets.h is listed in .gitignore — never commit real keys or private URLs. The values in secrets.h serve as compile-time defaults; they can be overridden at runtime via NVS (see Device configuration below).
The build script extracts the upstream firmware and layers your overlay files on top in a .build directory:
MacOS/Linux
./build.sh
cd .build
pio run -e github_pages # compile
pio run -e github_pages -t upload # flash to deviceWindows
.\build.bat
cd .build
pio run -e github_pages # compile
pio run -e github_pages -t upload # flash to deviceTo run the unit tests:
cd .build
pio test -e native-crypto # AES-256-CBC decryption
pio test -e native-bmp # BMP normalizationClean up with rm -rf .build.
Screens rendered one after another used to appear as double exposures — the previous city's numbers visible through the new ones.
The cause is in how the panel's two memory planes are used. A partial refresh on this
UC8179 panel uses a register LUT (PSR 0x3f) keyed on the old to new transition,
where "old" is the controller's DTM1 plane. Upstream's display_show_image() writes
only DTM2: its writePlane() defaults to PLANE_BOTH, which skips the second plane
unless the buffer holds two, and an uncompressed BMP buffer holds one. Every cycle then
ends in bbep.sleep(DEEP_SLEEP), which clears the controller's RAM. So DTM1 never
contains the real previous frame, the LUT concludes most pixels need no drive, and the
previous image stays physically on the glass.
It stayed invisible for months because a partial refresh that fails to drive unchanged pixels looks perfect while consecutive images are nearly identical. It became obvious as soon as the playlist rotated between visually different screens.
render_bmp_all_pixels() in src/github_main.cpp fixes it with PLANE_FALSE_DIFF,
which writes the image to DTM2 and its inverse to DTM1 from the same buffer. Every
pixel then differs from its "previous" value, so every pixel is driven a complete
transition — a clean image with no flash. Upstream already does this for
BOARD_XTEINK_X4, and in the PNG path for every temperature profile except this
panel's default, which is why the default path never got it.
display.cpp's bbep has external linkage, so this needs no fork of that file.
| Trigger | Behaviour |
|---|---|
| Every content render | All-pixel transition via PLANE_FALSE_DIFF, no flash |
Every MAX_PARTIALS_BETWEEN_FULL renders (default 36, ~6h at 600s) |
Full flashing refresh, as hygiene |
| Cold boot — power-on, reset, or a fresh flash | Full refresh of the first content image |
| Short button press | Full refresh of the next content image |
A manifest entry named screen_wiper |
SCREEN_WIPE_CYCLES full-white flashes (default 60, 2-3 min), then the next screen |
| Error screens | Already full refreshes; they reset the counter |
Nothing multi-cycle runs unattended on boot. A long wipe draws sustained current, and a wipe that browns the device out on a cold boot would reset straight back into itself; recovery is therefore always explicit. The wipe is also guarded by an NVS marker set before it starts and cleared when it finishes — if the device resets part-way through, the next boot refuses to start another one and says so on the serial log.
Note that MAX_PARTIALS_BETWEEN_FULL counts renders, not time, so changing
refresh_rate changes how often the hygiene flash happens.
To recover a panel that is already badly ghosted, put the device on USB power and add a wiper entry to the manifest:
{ "name": "wipe", "filename": "screen_wiper", "size": 0 }No image is downloaded for it — the device recognises the filename, runs a long
black/white clearing cycle, then shows the next screen in the playlist on the same
wake. Remove the entry once the panel is clean. This mirrors the screen_wiper.png
playlist item upstream firmware 1.8 recognises.
tools/monitor.sh attaches a serial monitor and reattaches on every wake — the board
creates its own USB CDC port, so the port vanishes while it deep sleeps.
./tools/monitor.sh # production build
TRMNL_ENV=github_pages_debug ./tools/monitor.sh # debug buildThe github_pages_debug environment is the same firmware with a 20-second refresh
interval and no periodic full refresh, so a playlist rotation can be watched in
minutes and the all-pixel path is exercised on its own. It also defines
WAIT_FOR_SERIAL, which blocks up to 2s per wake for a monitor to attach and keeps
the CPU awake through panel refreshes so the USB CDC link survives them. Never flash
it for battery use.
Awake time is dominated by WiFi association, two HTTPS downloads and the panel refresh; a normal cycle is around 8-10 seconds. Two things keep that from being worse:
- The MCU light-sleeps while the panel holds BUSY (
display_set_light_sleep(1)). Without it, bb_epaper polls that line with a plaindelay()and the CPU stays at full power for the several seconds every refresh takes. - Nothing blocks waiting for serial or for an NTP sync. Both cost up to two seconds per wake and neither earns it in a battery build — the boot lines cannot be captured anyway (the USB port has not enumerated yet), which is what the end-of-cycle summary is for, and no code here needs the wall clock.
Log calls themselves are nearly free when nothing is attached: USBCDC::write returns
immediately if the host is not connected, so only the string formatting is paid for.
Every cycle ends with a summary line, which is where the boot diagnostics actually get captured — the real boot lines are written before the USB port enumerates:
Cycle summary: screen=01_weather_munich, refresh=all-pixel, max_partials=36,
reset=DEEPSLEEP, battery=4.20 V, partials since full=3
max_partials identifies which build is running. reset names the cause of the reset
that began the current boot, so a crash or brownout is reported on the cycle after it.
| Gesture | Action |
|---|---|
| Short press | Refresh now, with a full flashing refresh |
| Double click | Skip to the next screen in the playlist |
| Hold 5–15s | Reset WiFi credentials |
| Hold > 15s | Factory reset (WiFi + all NVS settings) |
Screens must be 800x480 1-bpp uncompressed BMPs. The header flavour and colour table
polarity do not matter — normalize_bmp() (src/bmp_normalize.cpp) rewrites
BITMAPV4/V5 headers, inverted colour tables and top-down row order into the single
layout the display pipeline can render. Anything else (wrong size, colour depth, or
compression) is rejected with a format error on screen.
On first boot the device starts a WiFi captive portal for network setup. The following NVS preferences must be set:
| Key | Description |
|---|---|
manifest_url |
Full URL to your manifest.enc on GitHub Pages |
aes_key_hex |
64-character hex string of your 256-bit AES key |
images_base |
Base URL for image downloads (e.g. https://user.github.io/content/images/) |
See content-template/README.md for instructions on setting up your content repository with GitHub Actions for automated image generation and encryption.
Quick start:
# Generate an AES key
python tools/generate_key.py
# Encrypt a test image
python tools/encrypt_image.py encrypt input.bmp output.enc --key <hex-key>
# Build the manifest
python tools/update_manifest.py images/ manifest.enc --key <hex-key>When a new TRMNL firmware version is released:
- Download or checkout the new release
- Replace the contents of the
upstreambranch:git checkout upstream # remove old files, copy new release files git add -A git commit -m "trmnl-firmware vX.Y.Z" git checkout main
- Rebuild with
./build.shand test
Targets Seeed Studio XIAO ESP32-S3 PLUS (8MB Flash, 8MB PSRAM). PSRAM is required for image buffering.