One PlatformIO project supports five displays with shared Wi-Fi, HTTP(S), PNG, logging, persistent pagination, and error handling.
| Environment | Display | PNG size | Power between checks |
|---|---|---|---|
seeed-TRMNL-7_5 |
Seeed TRMNL 7.5-inch OG DIY kit | 800 × 480 monochrome | Panel supply off; configurable deep sleep |
T5-ePaper-S3 (default) |
LilyGO 4.7-inch e-paper | 960 × 540 grayscale | Panel off; configurable deep sleep |
guition-JC3248W535 |
Guition 3.5-inch AXS15231B LCD | 480 × 320 colour | LCD and backlight stay on; no deep sleep |
guition-JC4827W543 |
Guition 4.3-inch NV3041A LCD | 480 × 272 colour | LCD and backlight stay on; no deep sleep |
waveshare-ESP32-C6-LCD-1_3 |
Waveshare 1.3-inch ST7789V2 LCD | 240 × 240 colour | LCD and backlight stay on; no deep sleep |
Use seeed-TRMNL-7_5 for the bare Seeed board and monochrome 800 × 480
panel pictured in the Seeed kit guide.
This is the XIAO ESP32-S3 Plus kit, with 16 MB flash and 8 MB OPI PSRAM,
not the XIAO ESP32-C3 panel, reTerminal, or a colour replacement panel.
See the board support notes for sources and hardware checks.
- In the LNbits Gerty extension, select Epaper 800 x 480 (Seeed TRMNL
7.5 inch OG DIY Kit) and save. The companion extension needs the
epaper_800x480profile; older versions only produce other sizes. - Build/upload the environment below, or select Seeed TRMNL OG DIY Kit in the browser installer after publishing a release. On the first switch from TRMNL firmware, choose Erase device to remove the old partition contents. This erases the old firmware's settings.
- Use Connect to configure to save Wi-Fi and the base Gerty pages URL. The Seeed target has no compiled endpoint default. Press RST and reconnect while awake to change settings; USB disconnects during deep sleep.
pio run -e seeed-TRMNL-7_5
pio run -e seeed-TRMNL-7_5 -t upload --upload-port /dev/ttyACM0
pio device monitor -e seeed-TRMNL-7_5 --port /dev/ttyACM0Replace the example port with your board's USB port. To recover a sleeping board, hold BOOT, press and release RST, then release BOOT before flashing. The firmware talks directly to LNbits; no TRMNL account or license is used.
The UC8179 driver displays black/white with ordered dithering for grayscale
content. DITHER=false selects a simple luminance threshold. It uses full
refreshes, powers down the panel between checks, and follows the existing
pagination, retry and scheduled-sleep protocol. Buttons do not advance pages;
RST restarts the device and timer wake drives normal updates.
Failed downloads and PNG decodes preserve the visible image. A checksum-verified copy of the last displayed frame is stored in the display filesystem and reloaded after deep sleep, allowing the thinking badge and error strip to preserve the page even though PSRAM and panel RAM lose their contents. If that cache is unavailable, overlays are skipped and errors are reported over USB. Recovery fetches and redraws even if the revision is unchanged. A BUSY timeout never commits the new revision or normal next-page position.
For a diagnostic feed, generate the matching sample:
uv run tools/test_server.py --host 192.168.1.100 --setup --size 800x480
uv run tools/test_server.py --host 192.168.1.100 --refresh 300Set the GERTY_WAVESHARE_C6 branch of MANIFEST_URL in include/config.h
to a Gerty feed producing 240 × 240 non-interlaced PNGs (up to 8 bits per
channel). It has a separate URL setting from the Guition and LilyGO builds.
The JSON structure and pagination are unchanged, and image URLs are used verbatim.
This ESP32-C6 has 4 MB flash and no PSRAM. Images are downloaded to a temporary LittleFS file (maximum 512 KiB), then Wi-Fi is stopped and the PNG is decoded into a 115,200-byte RGB565 frame. Only a complete, CRC-checked frame reaches the LCD; failed downloads or decodes preserve the displayed image with an error label. The temporary file is removed after each attempt. No SD card is needed. Flash staging incurs a write on each changed image, so prefer longer refresh intervals for feeds that change constantly. There is no touch input or slide animation on this model; it advances on the JSON refresh schedule.
The C6 environment alone uses pinned pioarduino/Arduino 3.x tooling. Its dedicated partition table provides a 2 MiB application and a temporary filesystem, without OTA. Existing S3 environments keep their original toolchain. Wiring follows the Waveshare demo: SPI CLK 7, MOSI 6, MISO 5, LCD CS 14, DC 15, reset 21, backlight 22; SD CS 4 is held high. LCD transfer speed is 40 MHz.
uv tool run --from platformio --with intelhex pio run -e waveshare-ESP32-C6-LCD-1_3 -t upload
uv tool run --from platformio --with intelhex pio device monitor -e waveshare-ESP32-C6-LCD-1_3Select guition-JC4827W543 for the capacitive-touch model: ESP32-S3 N4R8
(4 MB flash, 8 MB OPI PSRAM), native landscape NV3041A display and GT911 touch.
It shares the colour rendering, slide transition, tap-to-advance and always-awake
behaviour of the 3.5-inch board. Set the Guition MANIFEST_URL in
include/config.h to a feed returning 480 × 272 PNGs; 480 × 320 images
are rejected rather than resized. Image URLs are used exactly as returned.
QSPI pins are CS 45, SCK 47, D0 21, D1 48, D2 40, D3 39, at 32 MHz; backlight is GPIO 1. GT911 uses SDA 8, SCL 4, reset 38 and interrupt 3, with address 0x5D selected during reset. The resistive-touch R variant is not supported by this touch driver. Hardware reference: JC4827W543 example.
uv tool run --from platformio --with intelhex pio run -e guition-JC4827W543 -t upload
uv tool run --from platformio --with intelhex pio device monitor -e guition-JC4827W543PNG downloads finish decoding before a visible frame is changed. On these LCDs, failures show an error at bottom right while preserving the rest of the image. Identical errors are not redrawn. Recovery restores the image even when its revision is unchanged.
The Guition uses ESP32-S3, 16 MB flash and 8 MB OPI PSRAM. Its native 320 × 480 AXS15231B QSPI panel is rotated in software to 480 × 320 landscape using Arduino_GFX's canvas. The image remains RGB565 colour; e-paper dithering is not applied. QSPI pins are CS 45, SCK 47, D0 21, D1 48, D2 40, D3 39; backlight is GPIO 1. Touch uses I2C SDA 4, SCL 8, address 0x3B (IRQ 3). Tap anywhere while a page is displayed to request the saved next page immediately. Touches are debounced and holding a finger down triggers only once. Touch is polled between updates; taps during an active download/render are not queued.
Set MANIFEST_URL in include/config.h to the Gerty feed producing 480 × 320
PNGs. The Guition endpoint is set to the local colour feed and the LilyGO keeps its
existing endpoint; Wi-Fi settings are shared. The firmware does not
resize images, change URLs, or add device parameters to the API request.
uv tool run --from platformio --with intelhex pio run -e guition-JC3248W535 -t upload
uv tool run --from platformio --with intelhex pio device monitor -e guition-JC3248W535The LCD build cannot enter deep sleep even if the shared sleep option is changed. It turns Wi-Fi off while waiting, leaves panel/backlight power on, and reconnects for the next check. The current image survives failed requests while powered; after power loss or reset it must download again.
The LilyGO remains the default for commands without -e. In VS Code select
guition-JC3248W535 → Upload under PlatformIO Project Tasks for the new board.
Use -e T5-ePaper-S3 explicitly when uploading to the e-paper board.
- Copy
include/secrets.example.htoinclude/secrets.hand enter your Wi-Fi credentials. This file is ignored by Git. - Set
MANIFEST_URLininclude/config.h. - Install PlatformIO, or use
uv tool run --from platformio --with intelhex pioin place ofpio(includes a dependency needed by the ESP32 tooling). - Run
pio run, thenpio run -t upload, thenpio device monitor.
The example header allows compilation before credentials are configured, but will not connect. HTTPS certificate verification is disabled: traffic is encrypted, but the server's identity is not authenticated. No root certificate or NTP clock synchronization is required. Direct HTTP or HTTPS URLs are required; redirects are rejected.
If no upload port appears, hold BOOT, press/release RST, then release BOOT. Deep sleep disconnects USB, so manual bootloader entry may be needed for upload. Serial monitoring may need reconnecting after each wake.
All device environments use the same logging configuration. Set LOG_LEVEL in
include/config.h and rebuild:
constexpr LogLevel LOG_LEVEL = LogLevel::INFO;NONE: no application logs; on-screen errors still appear.ERROR: failures only.INFO(default): errors, Wi-Fi connection, request URLs/results, image updates, sleep.DEBUG: INFO plus display initialization and PNG format/decode details.
For LilyGO and Seeed, DEEP_SLEEP_ENABLED in include/config.h controls deep sleep.
For LCDs it is forced off and the display backend also forbids deep sleep.
The same refresh/retry intervals apply in either mode. Wi-Fi is turned off
between checks; only the e-paper panel is powered off. Logging level does not
change the sleep setting. When e-paper deep sleep is enabled, USB disconnects, so the
monitor may need reconnecting on wake. Logging allows up to 1.5 seconds for USB
attachment on a reset, but never adds that wait on a timer wake. Arduino library
logging is disabled to avoid unrelated TLS chatter; ROM boot messages are outside
this application setting.
uv tool run --from platformio --with intelhex pio run -t upload
uv tool run --from platformio --with intelhex pio device monitorMANIFEST_URL should be a base pages endpoint, for example:
http://192.168.8.104:5001/gerty/api/v1/gerty/pages/7qYskkyGXQAZnw89ywtLqj
For a local server use the LNbits computer's LAN address, not localhost. LNbits must listen on
its LAN interface (or 0.0.0.0), not only 127.0.0.1, and port 5001 must be
reachable from the ESP32's Wi-Fi network.
The tested API is zero-based: the base URL returns page 0, /1 returns page 1,
and page 7 of 8 returns next_page: 0. On first use the firmware requests the base
URL. After successfully displaying a page, it saves both next_page and
page_count together in ESP32 persistent storage, then requests the saved next
page on the next wake. This survives deep sleep, resets, power loss, and ordinary
firmware uploads. Changing the configured endpoint starts at page zero. Failures retry the same page except HTTP 503: a manifest 503 advances one page
(wrapping with the saved page count), and an image 503 saves the manifest
next_page. The new position is persisted before sleeping. If the count is not
yet known, a manifest 503 probes the following page. The error message and retry
delay still apply. A missing
page (HTTP 404) resets the next attempt to the base endpoint, allowing recovery
when pages are removed. Display duration follows the positive refresh_seconds
value supplied by the extension, without a 30–300 second clamp.
During scheduled sleep hours, either the manifest or image endpoint can return
application/json with schema_version: 1, sleep_mode: true, and a positive
sleep_seconds. All boards enter deep sleep for that duration, including LCD
boards and builds with normal refresh deep sleep disabled. wake_at is only
informational; the firmware does not calculate timezone or daylight-saving
adjustments. Waking starts a fresh manifest request for the same pending page.
LCD boards redraw after waking even if the image revision is unchanged.
Awake manifests may specify sleep_mode: false; omitting it remains compatible
with older servers. Image responses must have Content-Type: image/png;
application/json is parsed as a sleep response before any PNG decoding. Invalid
sleep responses use the normal error retry behavior. Firmware does not add
preview=true; configured endpoints and image URLs must not include that
browser-only sleep bypass.
Image URLs are used exactly as returned by LNbits. The extension must return absolute HTTP(S) URLs reachable from the ESP32; the firmware never rewrites them.
Page metadata (page, page_count, next_page) is optional for compatibility
with the standalone test server. If supplied, all three must be nonnegative
integers, page_count must be positive, and both page indices must be below it.
Protocol checks:
c++ -std=c++11 -I include tests/gerty_protocol_test.cpp -o /tmp/gerty-protocol-test
/tmp/gerty-protocol-testUse your computer's LAN IP, reachable from the device, in place of
192.168.1.100. Both devices must be on a network that permits communication.
Allow incoming port 8443 if your computer asks. Install uv if needed.
uv run tools/test_server.py --host 192.168.8.108 --setup
uv run tools/test_server.py --host 192.168.1.100 --refresh 30Setup generates a one-year self-signed certificate with the correct IP/DNS
identity, its private key, and a diagnostic PNG. The certificate stays on the
server; no certificate needs copying into the firmware. Set
MANIFEST_URL to https://192.168.1.100:8443/manifest.json, then build/upload.
If the computer's address changes, restart the server with the new --host
and rebuild firmware with the new endpoint.
Replace test-server/image.png with a 960 × 540 PNG while serving, or start with
--image /absolute/path/photo.png. The next manifest request computes a new
revision automatically. PNG snapshots keep revisions consistent during edits;
they accumulate under the ignored test-server/ directory. The server exposes
only its manifest and image snapshots, not Wi-Fi settings or the private key.
{
"schema_version": 1,
"image_url": "https://example.com/display.png",
"image_revision": "42",
"refresh_seconds": 300,
"page": 0,
"page_count": 8,
"next_page": 1
}The four original fields are required; page metadata is optional as described above.
Change image_revision whenever image bytes change.
The firmware compares the URL and revision, and retains that identity across
deep sleep. A reset or firmware upload forces a new download. Pagination is written to flash only when the saved state changes;
there is no filesystem image cache, and the e-paper physically retains
its last image without power. It cannot restore an image after a interrupted
physical screen refresh until a later successful fetch.
refresh_seconds is the sleep duration after each successful check;
connection, download, and display time are additional. Zero is rejected.
A changed interval is honoured even when the image is unchanged.
Invalid JSON, TLS errors, download errors, unsupported PNGs, and decode failures
all use the retry schedule above. A successful check resets it.
PNG requirements: exactly the dimensions listed for each board above,
non-interlaced, at most 8 bits per channel,
maximum 2 MiB compressed (512 KiB on Waveshare C6). RGB, RGBA, indexed and grayscale inputs are handled by
PNGdec; transparency is composited onto white. LCD boards display RGB565 colour.
Seeed images are converted to dithered black/white.
LilyGO images are converted to 16-level grayscale with subtle ordered dithering. Set DITHER=false in config.h for
already-dithered/server-quantized artwork. PNG decode and CRC checks finish
before clearing the screen. JSON is limited to 4 KiB. Download bodies are
bounded in memory and time, including chunked transfers.
Build verified with PlatformIO. Local server integration tests cover trusted TLS, downloads with certificate verification disabled, unchanged and changed revisions, immutable image snapshots, and private-file isolation. Run them with:
uv run --with 'pillow>=11,<12' --with 'cryptography>=44,<46' python -m unittest discover -s tests -p '*_test.py'Physical screen output and battery behaviour still require testing on the board:
- Show the sample: confirm readable text, all gray steps, and correct orientation.
- On LilyGO, try a photograph and compare dithering enabled/disabled.
- On Guition, check a 480 × 320 colour PNG with labelled corners and RGB swatches.
- On Guition, wait past a refresh interval: backlight and USB must remain on.
- Leave revision unchanged: confirm “Image unchanged” and no screen flash.
- Replace the PNG: confirm exactly one update, followed by unchanged checks.
- Stop the server or Wi-Fi: an error appears at bottom right; the rest stays intact.
- Serve malformed JSON, a truncated PNG, or a wrong-size image: check the error reason.
- Restore service with the same revision: the image should replace the error box.
- Use a self-signed certificate: the firmware should download successfully.
- Restart: it should refetch. Timer wake: it should retain revision state.
- Measure battery current during sleep and active updates before estimating life.
At 30-second polling, Wi-Fi/TLS and screen updates can dominate battery usage. Start with 300 seconds when possible. This POC has no battery telemetry, OTA, partial refresh, provisioning portal, or low-battery policy.
Uses the official LilyGO S3 driver and board definition,
pinned to commit 50224221fb81a8fcf9068532374b775af84a38d4,
Espressif32 PlatformIO platform 6.12.0, ArduinoJson 7.4.2, and PNGdec 1.1.6.
The decoder buffer is enlarged for 960-pixel RGBA scanlines.
LilyGO's repository is GPL-3.0 licensed; preserve its notices and follow its
licensing terms when distributing firmware.
Guition uses Arduino_GFX 1.4.7.
Display backends live in src/display_lilygo.cpp, src/display_guition.cpp,
src/display_waveshare.cpp and src/display_seeed.cpp; shared image downloading
and page handling remain in src/main.cpp. Seeed uses GxEPD2 1.6.4 with Adafruit
GFX 1.11.11 and BusIO 1.17.0. See the Seeed notes for its
hardware verification requirements.
For maintainer setup, release tagging, Pages deployment and recovery, see the development and release guide.
The web/ directory is a GitHub Pages installer for all five boards. In the
repository settings, select Pages → Build and deployment → GitHub Actions.
Push a new Git tag to build all five firmware images, attach merged .bin files
and SHA256 checksums to a GitHub Release, and deploy the installer. The workflow
can also be run manually. Each successful run replaces the site’s offered version
with the version built by that run; older downloads remain on GitHub Releases.
The site URL is shown in the workflow’s github-pages deployment.
Use desktop Chrome or Edge with a USB data cable:
- Select the exact display model and install firmware. The chip check cannot distinguish the four S3 display models. Leave the installer’s Erase device checkbox unchecked to retain settings.
- If settings were kept, the device can reconnect using them. For first setup, after erasing, or to change settings, close the installation dialog and choose Connect to configure.
- Enter a 2.4 GHz Wi-Fi network, password (blank for an open network), and the LNbits Gerty base pages endpoint. Save; the display restarts and starts using the saved settings.
- Connect again to view or download serial logs. Saved settings confirm storage; logs confirm whether Wi-Fi and the endpoint actually work.
On startup, configured devices show the happy Gerty face while the first image loads. E-paper shows it on a fresh boot or reset; timer wakes keep the existing image. A manual next-page request (a tap on Guition) shows a small thinking badge at the bottom right while connecting and fetching. Startup, scheduled refreshes, and automatic retries do not show the badge. The old corner is restored if the image is unchanged, a request fails, or the server requests sleep; a new page replaces the badge. Before a page is available, devices can show offline, sad, or sleeping for the corresponding state. All 15 expressions and PNGs for each screen size are in assets/expressions.
New release devices show an on-screen setup guide with the installer address and USB configuration steps, and wait for configuration indefinitely. Configured devices start without a setup delay, including local builds with valid compiled settings. To change settings, connect over USB while the device is awake. Configuration is serviced between updates and during Wi-Fi connection attempts. LilyGO and Seeed disconnect USB during deep sleep; press RST without BOOT and reconnect to wake it. If connecting before sleep is difficult, reinstall with Erase device to return to the setup screen (this removes all saved device storage).
Settings are saved atomically in the device’s NVS flash and survive ordinary PlatformIO app-only updates unless flash is erased. Browser installation preserves settings by default by flashing separate firmware segments around NVS. Selecting Erase device erases all device storage; configure again afterwards. They are not stored in the browser or sent to GitHub. They are not encrypted in device flash. Existing HTTPS certificate verification behavior described above remains unchanged. Logs can contain private endpoint URLs; review them before sharing.
Release builds (GERTY_RELEASE=1) ignore secrets.h and compiled endpoint
settings. Developer builds still use the existing defaults when no saved settings
exist. Saved USB settings take precedence. The build script derives flash
parts and settings from PlatformIO and merges a full image with esptool, then
extracts patched segments for the browser without writing over NVS. Full merged
release downloads remain factory images and can overwrite settings.
To build release images locally:
GERTY_RELEASE=1 GERTY_VERSION=local pio run -e T5-ePaper-S3 -e guition-JC3248W535 -e guition-JC4827W543
GERTY_RELEASE=1 GERTY_VERSION=local pio run -e waveshare-ESP32-C6-LCD-1_3
python3 -m http.server 8000 --directory webOpen http://localhost:8000 (localhost supports Web Serial). Public hosting
requires HTTPS. The installer loads the pinned ESP Web Tools 10.1.1 module from
unpkg; internet access is required. Firmware is served from the same Pages site.
Physical USB flashing, persistent settings, screen output and serial reconnects
should be verified on each board before distributing a release.
Run installer checks with node --test tests/web_installer_test.cjs and
python3 -m unittest discover -s tests -p package_firmware_test.py. Avoid running
the S3 and C6 toolchain installations concurrently in a shared PlatformIO home.