Skip to content
 
 

Latest commit

 

History

18 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

trmnl-github

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.

How it works

  1. Device wakes from deep sleep, connects to WiFi
  2. Downloads an encrypted manifest from your GitHub Pages URL
  3. Decrypts manifest with a pre-shared AES key stored in NVS
  4. Picks the next screen (round-robin) and downloads the encrypted BMP
  5. Decrypts in PSRAM, renders on the e-paper display
  6. Goes back to deep sleep for refresh_rate seconds

Repository structure

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)

Prerequisites

  • Python 3 (venv recommended)
  • pip install platformio esptool

Configure secrets

Copy the example secrets file and fill in your values:

cp src/secrets.h.example src/secrets.h

Then 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).

Build

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 device

Windows

.\build.bat

cd .build
pio run -e github_pages            # compile
pio run -e github_pages -t upload  # flash to device

To run the unit tests:

cd .build
pio test -e native-crypto   # AES-256-CBC decryption
pio test -e native-bmp      # BMP normalization

Clean up with rm -rf .build.

Display refresh and ghosting

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.

Debugging on hardware

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 build

The 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.

Power

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 plain delay() 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.

Button gestures

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)

Image requirements

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.

Device configuration

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/)

Content setup

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>

Updating upstream

When a new TRMNL firmware version is released:

  1. Download or checkout the new release
  2. Replace the contents of the upstream branch:
    git checkout upstream
    # remove old files, copy new release files
    git add -A
    git commit -m "trmnl-firmware vX.Y.Z"
    git checkout main
  3. Rebuild with ./build.sh and test

Hardware

Targets Seeed Studio XIAO ESP32-S3 PLUS (8MB Flash, 8MB PSRAM). PSRAM is required for image buffering.

About

TRMNL e-ink device firmware to work with github io pages

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages