- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| tests | ||
| trmnl_display | ||
| .gitignore | ||
| config.example.yaml | ||
| PROJECT.md | ||
| pyproject.toml | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
TRMNL Display
Generates a PNG image for a TRMNL e-ink display from Home Assistant (calendar, weather) and Vikunja (tasks) data, using a percentage-based row/column layout, and posts it to a TRMNL webhook.
Setup
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt # or requirements-dev.txt to also run tests
cp config.example.yaml config.yaml
$EDITOR config.yaml
config.yaml is gitignored. Secrets can either go directly in the file or
be referenced as ${ENV_VAR}, which is substituted from the environment at
load time (handy for keeping tokens out of the file, e.g. in a systemd unit
or cron environment).
Timezone
Set top-level timezone to an IANA name (e.g. "America/Edmonton") if the
machine running this isn't already in the timezone you want "now"/"today"
computed in. It's shared by everything that cares: the clock widget, and
the calendar widget's day queries and event normalization (calendars can
each report events in a different offset -- every fetched event is
converted into this one consistent timezone). Defaults to the system's
local timezone if omitted.
Getting a TRMNL webhook URL
In the TRMNL dashboard: Plugins > Webhook Image > Add to my plugins.
Copy the private webhook URL it gives you into webhook.url. That URL is
itself the secret — treat it like a password. (webhook.auth_token is
optional and only needed for self-hosted BYOS servers that expect a bearer
token; TRMNL's own Webhook Image plugin doesn't need one.)
Home Assistant
Create a Long-Lived Access Token under your HA profile page and set
home_assistant.base_url / home_assistant.token. Needed for the
calendar and weather widgets.
Vikunja
Create an API token under Vikunja Settings > API Tokens and set
vikunja.base_url / vikunja.token. Needed for the tasks widget.
Usage
# Render and post to the configured webhook
python -m trmnl_display --config config.yaml
# Render only, save a local preview, skip posting (useful while designing a layout)
python -m trmnl_display --config config.yaml --output preview.png --no-post
Run on a schedule (cron, systemd timer, etc.) matching how often you want the display to refresh. Note TRMNL's Webhook Image plugin currently rate limits to 12 uploads/hour.
Layout
Layout is a list of rows, each with a height (percent of its parent) and
a list of columns, each with a width (percent of its parent). A column
is either a leaf (a widget) or a container that splits further (nested
rows). Percentages within a single rows or columns list should sum to
~100.
layout:
- height: 20
columns:
- width: 100
widget: {type: clock, options: {format: "%H:%M"}}
- height: 80
columns:
- width: 60
widget: {type: calendar, options: {entity_ids: [calendar.home, calendar.work]}}
- width: 40
rows:
- height: 50
columns: [{width: 100, widget: {type: weather, options: {entity_id: weather.home}}}]
- height: 50
columns: [{width: 100, widget: {type: tasks, options: {}}}]
See config.example.yaml for a full working example.
Built-in widgets
| type | data source | key options |
|---|---|---|
text |
none | text, font, size, align |
clock |
none | format (strftime), font, size, align -- uses top-level timezone |
weather |
Home Assistant | entity_id, show_icon, show_high_low, show_forecast + forecast_days |
calendar |
Home Assistant | entity_ids (list), days_ahead (default 1 = today only), max_events |
tasks |
Vikunja | project_ids (list, optional -- omit for all projects), max_tasks |
weather's show_high_low and show_forecast both fetch the daily
forecast via Home Assistant's weather.get_forecasts service action (the
old per-entity forecast state attribute was deprecated). show_icon
draws a condition glyph from the Weather Icons set (codepoints
U+E300-U+E3E3) via icon_font -- this only renders as an actual icon with
a font that includes those glyphs, such as a
Nerd Font; with a plain font it'll just be
blank/missing-glyph.
Screen / image options
screen:
width: 800 # TRMNL OG is 800x480
height: 480
bit_depth: "1" # "1" = 1-bit black/white, "L" = 8-bit grayscale
dither: true # Floyd-Steinberg dithering on conversion to 1-bit
background: 255 # 255 = white, 0 = black
Extending
Add a widget: subclass Widget in trmnl_display/widgets/ (implement
fetch_data and/or render), then register it in
trmnl_display/widgets/__init__.py's WIDGET_REGISTRY under the type
name used in layout YAML. See trmnl_display/widgets/base.py for the
interface.
Add a data source: add a client module under trmnl_display/sources/
(see home_assistant.py / vikunja.py for the pattern — plain data in,
plain dataclasses out), wire it into Sources in
trmnl_display/widgets/base.py and build_sources() in
trmnl_display/main.py.
Widgets and sources are deliberately decoupled: a source client knows
nothing about rendering, and a widget's render never makes network calls
(data is fetched up front in fetch_data) — this keeps both easy to unit
test in isolation.
Testing
pip install -r requirements-dev.txt
pytest
Tests mock all external HTTP (Home Assistant, Vikunja, and the webhook)
with responses, so the suite runs fully offline.
Project layout
trmnl_display/
config.py # YAML config loading + validation
tz.py # shared timezone resolution (config name -> tzinfo)
webhook.py # POST rendered PNG to the TRMNL webhook
main.py # CLI entrypoint / orchestration
sources/
home_assistant.py # HA REST API client (weather, calendar)
vikunja.py # Vikunja REST API client (tasks)
render/
layout.py # row/column % layout -> pixel rects
canvas.py # draws widgets onto a PIL image, handles bit depth/dither
fonts.py # font loading/caching with fallback
text_utils.py # word-wrap helper shared by widgets
widgets/
base.py # Widget interface + Sources container
text.py, weather.py, calendar.py, tasks.py
tests/
config.example.yaml