No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-30 12:29:11 -06:00
tests Add weather icon, daily high/low, and multi-day forecast to weather widget 2026-09-29 23:16:14 -06:00
trmnl_display Reverted changes and updated _format_event_time method 2026-09-30 12:29:11 -06:00
.gitignore Initial TRMNL display image generator 2026-09-29 12:08:37 -06:00
config.example.yaml Add weather icon, daily high/low, and multi-day forecast to weather widget 2026-09-29 23:16:14 -06:00
PROJECT.md Initial TRMNL display image generator 2026-09-29 12:08:37 -06:00
pyproject.toml Initial TRMNL display image generator 2026-09-29 12:08:37 -06:00
README.md Add weather icon, daily high/low, and multi-day forecast to weather widget 2026-09-29 23:16:14 -06:00
requirements-dev.txt Initial TRMNL display image generator 2026-09-29 12:08:37 -06:00
requirements.txt Add configurable timezone and normalize calendar events into it 2026-09-29 21:25:31 -06:00

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