Skip to content

Repository files navigation

TRMNL HSL departure board plugin

The HSL departure board rendered on a TRMNL full-view layout

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.

How it works

                        ┌────────────────────────────────────────────┐
 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.

Prerequisites

  • 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).

Quick start (local)

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:4001

Then:

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 layout

kotisaarenkatu 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.

Tests and checks

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 test

The Clojure code is linted with clj-kondo and checked for formatting with cljfmt:

clj-kondo --lint src views test
cljfmt check src views test

The 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_hsl

All 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 install

CI 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-latest

Configuration

Environment variables

Variables 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

Boards: which stops, which routes

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-ids are Digitransit GTFS stop IDs (e.g. HSL:1230410).
  • :hidden-routes/:show-routes are keyed by stop ID and take a list of route keys of the form "<routeGtfsId>:<shortName>:<headsign>:<directionId>". Use :hidden-routes to drop noise (e.g. a line that also stops elsewhere on your board) and :show-routes to 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.

Templates

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.

TRMNL plugin setup

  1. In the TRMNL dashboard, add a Private Plugin with the Polling strategy. Add one plugin per board.

  2. 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).

  3. 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 }}
  4. Force a refresh and check the device.

Deployment

The server is a plain HTTP process; run it under any supervisor and put a reverse proxy in front for TLS and access control.

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.

Run as a service

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 start

Health checks

GET /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.

Acknowledgements

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.

Licence

MIT. See LICENCE.

About

A live public transport departure board for a TRMNL e-ink display, showing the next departures from your chosen Helsinki Regional Transport Authority (HSL) stops.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages