BYOS TRMNL Server in FastAPI
This is a self-hosted FastAPI backend that emulates the TRMNL cloud so e-paper devices can fetch fresh images and metadata from your local network.
It is loosely based on a Flask implementation by @ohAnd, rewritten (nearly) from scratch to use FastAPI, async I/O, and a plugin-driven architecture for rendering various charts and images, prioritizing greyscale output suitable for later firmware versions but allowing you to force 1-bit BMP for legacy devices on a per-item basis
The server maintains device/playlists in SQLite, renders plugin-driven charts/photos into BMP/PNG assets, and exposes /api/display plus legacy-compatible endpoints expected by the firmware.
It also tries too hard โฆ
BYOS TRMNL Server in FastAPI
This is a self-hosted FastAPI backend that emulates the TRMNL cloud so e-paper devices can fetch fresh images and metadata from your local network.
It is loosely based on a Flask implementation by @ohAnd, rewritten (nearly) from scratch to use FastAPI, async I/O, and a plugin-driven architecture for rendering various charts and images, prioritizing greyscale output suitable for later firmware versions but allowing you to force 1-bit BMP for legacy devices on a per-item basis
The server maintains device/playlists in SQLite, renders plugin-driven charts/photos into BMP/PNG assets, and exposes /api/display plus legacy-compatible endpoints expected by the firmware.
It also tries too hard to do image color grading and dithering to improve the appearance of photos and complex graphics on e-ink panels, which is a rabbit hole I fell into.
Non-Goals
- Full feature parity with the official TRMNL cloud โ this is a lightweight server for personal use, not a 1:1 clone of the official backend.
- Advanced security features โ while SSL is supported (but disabled by default to save battery), user authentication, multi-user support, and other advanced features are out of scope. This is intended to be deployed behind a reverse proxy.
- Extensive plugin library โ only a few example plugins are provided; users are encouraged to write their own.
- Web dashboard for management โ a minimal static UI is included for previewing plugin outputs and doing basic playlist management, but no full-featured admin panel.
- Browser-based rendering โ all image generation is done server-side using Python libraries to minimize system requirements.
Highlights
- FastAPI core โ
trmnl_server/main.pyhosts the HTTP API, static assets under/web, and middleware-level request logging. - Plugin rendering pipeline โ classes in
plugins/generate images (always 1-bit and 2-bit) using Pillow, httpx, pandas, etc. Just output an image and the server handles (configurable) dithering, grading, and persistence. - Device + playlist persistence โ SQLAlchemy models in
models.pykeep per-device rotation state, playlists, logs, and battery samples invar/db/trmnl.db. - Autodiscovered plugin scheduler โ background workers keep assets fresh; see Plugins & registry for discovery rules and toggles.
- Firmware compatibility โ
/api/displayalways returns a singleimage_urlplus a changingfilenametoken so ESP32-based firmware knows when to refresh. - Batteries-included tooling โ
Makefilewrapsmake serve(launch FastAPI viapython -m trmnl_server) andmake test(pytest). Plugins can be previewed via helper scripts under the repo root. - Color grading + dithering โ "color" grading and multiple dithering algorithms are available to improve image quality on e-ink panels, and you can force specific playlist entries to use 1-bit BMP output if needed.
Deployment
I am deploying this with kata, a Docker-based service manager I wrote, but any method that can run a FastAPI app will work.
Running Locally
git clone https://github.com/rcarmo/python-fastapi-trmnl-server.git
cd trmnlServer
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
make serve
The server logs which port it binds to (default SERVER_PORT=4567). Point your TRMNL device at http://<server_ip>:<port>.
Useful commands:
make serveโ start FastAPI using the current working directory as the runtime root.SERVER_PORT=8081 make serveโ override port for quick tests.make serve ARGS=/path/to/workdirโ run the server against a different working directory (var/contents plus SSL/generated assets) without touching your source tree.python -m trmnl_server --list-pluginsโ print the plugin registry (names + defaults) and exit.python -m trmnl_server --run-plugin WeatherPlugin --plugin-output /tmp --plugin-arg image_root=/pathโ run a single plugin once for debugging with optional keyword arguments.make testโ runpytest(tests/test_rotation.py,tests/test_plugins.py,tests/test_weather.py).
Configuration
All settings come from environment variables:
SERVER_PORT,ENABLE_SSLโ networking defaults (4567/False out of the box, setENABLE_SSL=truewhen you need TLS).IMAGE_PATH,REFRESH_TIME,DITHERING_MODEโ rendering and dithering behaviour.PHOTO_GRADING_ENABLEDโ enable/disable photographic grading for image-heavy plugins (default: true).EINK_TONE_POINTS,EINK_TONE_GAMMAโ optional grayscale response compensation points/gamma for panel-space quantization.BATTERY_MAX_VOLTAGE,BATTERY_MIN_VOLTAGE,TIME_ZONEโ telemetry scaling.SETUP_API_KEY,SETUP_FRIENDLY_ID,SETUP_MESSAGEโ/api/setuppayload fields.ASSETS_ROOT,STATIC_ROOT,GENERATED_ROOTโ relative directories (inside the working dir) for dashboard assets and generated BMP/PNG output (defaults:web,web, andvar/generated).CALIBRATION_PLUGIN_ENABLEDโ set tofalseto remove calibration plugins from the registry and skip generating calibration assets.
Whenever a setting is changed via the /settings/* endpoints, the new value is written to SQLite (table config_entries). On startup, config.py loads environment variables first (highest precedence) and then applies any persisted entries that are not overridden by the environment, so API-driven tweaks survive restarts without fighting SERVER_PORT=... overrides in your shell.
Not all settings are exposed in the Web UI (yet); refer to config.py for the full list.
Runtime artefacts live under var/ inside your chosen working directory:
var/db/trmnl.dbโ SQLite database plus future state.var/logs/โ reserved for future log sinks.var/generated/โ plugin BMP/PNG output served via/generated/*.var/ssl/โ self-signed certs generated automatically if SSL is enabled.
FastAPI creates these directories during startup if they are missing, and .gitignore keeps var/ out of version control.
Plugins & registry
- Plugins are auto-discovered from
trmnl_server/plugins/by the scheduler; any class inheritingPluginBasewithAUTO_REGISTER=Trueis registered. - Set
AUTO_REGISTER = Falseon a plugin class to opt it out of the registry. - Set
CALIBRATION_PLUGIN_ENABLED=false(ENV or/settings) to remove all calibration plugins from the registry and skip generating calibration assets. python -m trmnl_server --list-pluginsshows the active registry;--run-plugin <Name>respects these toggles.
API + Static Surface
| Path | Description |
|---|---|
GET /api/display | Main firmware endpoint: returns image_url, filename, refresh hints, and playlist metadata. |
POST /api/log | Device log ingestion recorded via SQLAlchemy. |
POST /api/battery | Battery + RSSI samples persisted to BatteryStatus. |
GET /image/screen.bmp / screen1.bmp | Alternating BMP endpoints to break caches. |
GET /image/grayscale.png / grayscale1.png | Optional grayscale preview for firmware that supports it. |
GET /web/* | Static dashboard assets (HTML/JS/CSS/fonts and fallback imagery). |
GET /generated/* | Runtime plugin output (BMP/PNG) served as-is. |
The UI under web/ shows plugin output previews and rotation metadata; templates in templates/ are used by specific plugins (e.g., weather renderer).