A live public transport departure board for a TRMNL e-ink display, showing the next departures from your chosen HSL (Helsinki Regional Transport Authority) stops.
A small always-on HTTP server fetches departures from the Digitransit GraphQL API, reshapes them and serves ready-to-render markup.
The server is written in Clojure on Babashka with a single bb binary,
no build step and no external dependencies to resolve.
┌────────────────────────────────────────────┐
Digitransit GraphQL ──▶│ bb server (http-kit) :4001 │
(api.digitransit.fi) │ in-process TTL cache (atom) │
│ GET /api/trmnl/<board> → {markup, …} │
│ GET /preview/<board> → full HTML (dev) │
│ GET /health → status JSON │
└────────────────────▲───────────────────────┘
│ polls every N min
reverse proxy (TLS + IP allowlist) ──┤
│
TRMNL cloud → e-ink device
TRMNL's Polling strategy has the TRMNL cloud fetch a URL on a schedule. This
server renders the board itself and returns it as the four layout fields
TRMNL expects: markup, markup_half_horizontal, markup_half_vertical and
markup_quadrant. All the logic lives in this repo, not in the TRMNL editor.
The server hosts multiple boards, each identified by a URL slug and served at
/api/trmnl/<slug>. Configure one Private Plugin per board.
- A TRMNL device with the ability to add a Private Plugin. The implementation has been tested on a TRMNL X (1872×1404).
- The Babashka (
bb) runtime. - A Digitransit API subscription key. Register at the
Digitransit API portal, create a
subscription and copy the key to
.env(see below). - Somewhere to run the server reachable by the TRMNL cloud. A reverse proxy for TLS and access control is strongly recommended (see below).
git clone https://github.com/pvalkone/trmnl-hsl.git
cd trmnl-hsl
cp .env.example .env
$EDITOR .env # Paste in your DIGITRANSIT_KEY
bb serve # Starts the server on http://localhost:4001Then:
curl -s localhost:4001/health # Lists all configured boards
curl -s localhost:4001/api/trmnl/kotisaarenkatu | jq 'keys' # Returns the four markup keys
open http://localhost:4001/preview/kotisaarenkatu # Eyeball the full board in a browser
open http://localhost:4001/preview/kotisaarenkatu/quadrant # ...or a specific device layoutkotisaarenkatu is the board slug (a key in boards, see below).
/preview/<slug> renders the full layout; /preview/<slug>/<layout> renders a
specific one, where <layout> is full, half_horizontal, half_vertical or
quadrant, the same four layouts TRMNL requests as markup fields.
While iterating on the templates or render code, run bb dev instead of
bb serve: it watches src/ and views/ and reloads on save, so edits show on
the next request without a restart.
The data transform (board.clj) and the alert/pluralisation
logic (render.clj) are covered by unit tests in test/hsl/.
Run the tests with bb test.
The rendered markup is also guarded by golden-file snapshot tests
(snapshot_test.clj): a fixed fixture board is
rendered through each template and compared against the committed snapshots in
test/hsl/snapshots/. After an intentional change,
regenerate and review the diff:
UPDATE_SNAPSHOTS=1 bb testThe Clojure code is linted with clj-kondo and checked for formatting with cljfmt:
clj-kondo --lint src views test
cljfmt check src views testThe shell scripts are checked with shellcheck and shfmt:
shellcheck run.sh deploy/freebsd/trmnl_hsl
shfmt -i 2 -ci -sr -d run.sh deploy/freebsd/trmnl_hslAll four checks above also run as a local prek
hook from .pre-commit-config.yaml. With prek and
those four tools on your PATH, enable the hook once to run them on every
commit:
prek installCI runs the same hooks on every pull request (see
.github/workflows/run-checks.yml). To run
the workflow locally with act:
act pull_request -P ubuntu-24.04=catthehacker/ubuntu:act-latestVariables are read from the real environment first, then an .env file (see
.env.example):
| Variable | Required | Default | Description |
|---|---|---|---|
DIGITRANSIT_KEY |
yes | - | Your Digitransit subscription key |
PORT |
no | 4001 |
The HTTP port to listen on |
CACHE_TTL_MS |
no | 60000 |
How long a fetched board is reused before refetching |
Everything about what the boards show lives in
src/hsl/config.clj. boards maps a URL slug to a board
definition; each slug is served at /api/trmnl/<slug> and /preview/<slug>
.
Add a board by adding a key; there is no per-board setup beyond this map.
Each board has a two-column layout; each column lists a set of stops and splits its row budget evenly across them:
{"my-board" ; URL slug: served at /api/trmnl/my-board
{:title "My board title" ; Shown in the bottom status bar
:number-of-departures 20 ; How many upcoming departures to fetch per stop
:columns
{:left {:rows 12 ; Total departure rows for this column
:stop-ids ["HSL:1230410" "HSL:1210405"]
:hidden-routes {}}
:right {:rows 9
:stop-ids ["HSL:1240118" "HSL:1230109"]
;; Denylist: drop these route patterns at a given stop
:hidden-routes {"HSL:1240118" ["HSL:4717:717:Rautatientori:1"]}
;; Allowlist: at these stops, show only the listed patterns
:show-routes {"HSL:1230109" ["HSL:1071:71:Malmi:0"]}
;; Optional: override the heading for a stop (e.g. to tell apart
;; several stops that share a Digitransit name)
:stop-names {"HSL:1240118" "Kumpulan kampus (länteen)"
"HSL:1230109" "Kumpulan kampus (itään)"}}}}}stop-idsare Digitransit GTFS stop IDs (e.g.HSL:1230410).:hidden-routes/:show-routesare keyed by stop ID and take a list of route keys of the form"<routeGtfsId>:<shortName>:<headsign>:<directionId>". Use:hidden-routesto drop noise (e.g. a line that also stops elsewhere on your board) and:show-routesto pin a stop to only the lines you care about.:stop-names(optional) is keyed by stop ID and overrides the heading a stop renders under. Departures group by heading, so stops that share a Digitransit name (e.g. three "Tekniikan museo" stops on different streets) need distinct overrides to list separately.
Finding stop IDs and route keys: the easiest way is to hit /preview (or
/api/trmnl) with the stop in :stop-ids and no filters. Every departure's line,
destination and direction are visible, so you can read off the exact route key to
filter.
To find a stop ID, search the stop on the HSL Journey Planner or query it by name via the Digitransit Routing API.
The HTML lives in views/full.html (the main board) and
views/compact.html (a compact stand-in used for the
smaller half/quadrant layouts). They're Selmer
templates and use TRMNL's design framework classes
(.layout, .columns, .table, .title, .label, .title_bar etc.).
On the device TRMNL wraps this markup in its screen/view shell and supplies
the framework CSS. views/preview.html recreates that shell
for the browser: it embeds the layout's partial (full.html, or compact.html
for the smaller layouts), loads the framework stylesheet and picks the
high-density screen--v2 profile, then applies the requested view--<layout>
class, so /preview/<slug>/<layout> renders the way the
plugin looks on the device instead of as bare, unstyled markup.
-
In the TRMNL dashboard, add a Private Plugin with the Polling strategy. Add one plugin per board.
-
Set the Polling URL to your server's
/api/trmnl/<slug>endpoint (through your reverse proxy; e.g.https://board.example.com/api/trmnl/kotisaarenkatu). -
Set each layout's markup to the matching response field.
Layout Markup Full {{ markup }}Half Horizontal {{ markup_half_horizontal }}Half Vertical {{ markup_half_vertical }}Quadrant {{ markup_quadrant }} -
Force a refresh and check the device.
The server is a plain HTTP process; run it under any supervisor and put a reverse proxy in front for TLS and access control.
The polling URL is fetched by the TRMNL cloud, so it must be reachable from the Internet. Rather than expose the server openly, restrict it to TRMNL's published egress IPs, available at https://trmnl.com/api/ips.
An example FreeBSD (rc.d) service script is in
deploy/freebsd/trmnl_hsl, with a matching
newsyslog config in
deploy/freebsd/trmnl_hsl.conf to rotate the
log. To install them:
install -m 755 deploy/freebsd/trmnl_hsl /usr/local/etc/rc.d/trmnl_hsl
install -m 644 deploy/freebsd/trmnl_hsl.conf /usr/local/etc/newsyslog.conf.d/trmnl_hsl.conf
sysrc trmnl_hsl_enable=YES
service trmnl_hsl startGET /health returns per-board status JSON. It responds 200 while at least
one board is serving and 503 otherwise (e.g. a bad key, an upstream that
was down at start-up).
A board is serving when it has loaded at least once (has_board) and its most
recent refetch didn't fail (refresh_failing).
On startup every board is fetched once, so /health is accurate within a
second or two of a restart rather than reporting degraded until the first
poll. After that, refetches are lazy: an old cached_at just means a board
hasn't been requested recently.
Architecture inspired by
heikkiv/trmnl-nordpool.
Departure data © Digitransit, licenced CC BY 4.0.
Mode and alert icons from Material Symbols, licenced under the Apache Licence 2.0.
MIT. See LICENCE.
