Configuration
Byonk embeds all screens, fonts, and configuration in the binary itself. This means you can run Byonk with zero configuration - it works out of the box.
For customization, Byonk uses a YAML configuration file to map devices to screens and to register screen repos.
Screens live in screen repos, not config entries
There is no screens: block in config.yaml. A screen is a folder inside a screen
repo — a directory tree with a byonk-screens.yaml manifest at its root, where every
folder containing a meta.yaml is a screen. Each screen folder holds three fixed-name files:
| File | Purpose |
|---|---|
meta.yaml | Title, description, byonk: engine compatibility, default refresh:, and the params: schema |
script.lua | Data-fetch logic |
screen.svg | Tera SVG template |
Byonk auto-discovers these screens; you reference one by its handle/path ref (e.g.
examples/swiss-departure-board). A minimal default + calibration/* set ships in the
embedded byonk-builtin screen repo; worked examples like swiss-departure-board and
hello ship separately in the embedded examples screen repo. See
Your First Screen for how to author one, and
Admin API for the screen repo/screen listing endpoints.
Configuration Structure
# Device-to-screen mapping
devices:
"94:A9:90:8C:6D:18": # Device MAC address
screen: examples/swiss-departure-board # handle/path screen ref
params: # Parameters passed to script.lua
station: "Olten, Bahnhof"
limit: 8
"AA:BB:CC:DD:EE:FF":
screen: examples/hello
params:
name: "Zurich"
# Reserved key: shown to every un-onboarded or unassigned device
DEFAULT:
screen: byonk-builtin/default
# Optional: register additional screen repos (see below)
screen_repos:
byonk-builtin: {} # the embedded built-in screen repo
Devices Section
Each device entry maps a MAC address to a screen:
| Property | Required | Description |
|---|---|---|
screen | Yes | Qualified handle/path reference of the screen to display |
params | No | Key-value pairs passed to the Lua script |
colors | No | Override display palette (comma-separated hex RGB, e.g. "#000000,#FFFFFF,#FF0000") |
dither | No | Dithering algorithm (see Dither Algorithms below) |
panel | No | Panel profile name (references panels section) |
max_error | No | Caps how much accumulated dithering error one pixel may carry (e.g. 1.0, the default). Lower values suppress error diffusion; very low values make saturated areas render flat. |
noise_scale | No | Blue noise jitter scale (e.g. 0.6). Controls noise modulation strength. |
chroma_clamp | No | Chroma clamp for dithering. Limits chromatic error propagation. |
strength | No | Error diffusion strength (0.0–2.0, default 1.0). Lower = less dithering texture. |
temperature_profile | No | Refresh profile sent to the device: default (the default), a or b. See Ghosting below. |
maximum_compatibility | No | Ask the device to force a full refresh on every update. No effect on a TRMNL X. See Ghosting. |
min_png_bytes | No | Pad the served image up to this many bytes. Only useful on a TRMNL X; see Ghosting. |
Ghosting
A faint image staying visible under the current one has two different causes, and only one of them is a settings problem. Tell them apart first:
- Refresh residue — the ghost is the screen shown a moment ago, and it changes as the content changes. The panel is refreshing too gently. The settings below help.
- Burn-in — the ghost is an image the panel held for days or weeks, and it stays put no matter what is drawn over it. No setting fixes this. See Burn-in below.
Both look the same at a glance, and both show up in mid-greys while staying invisible in solid black and solid white — those two are reached by any refresh, while the intermediate levels are the ones left half-set.
temperature_profile
devices:
"1C:DB:D4:66:5B:50":
screen: examples/hello
temperature_profile: a
What this does depends on which panel is listening:
- TRMNL OG, Gen2 and the DIY kits pick their refresh waveform from look-up
tables indexed by temperature, because cold particles need longer, stronger
drive pulses. A non-
defaultprofile selects a different waveform, making the device drive harder and flash more than the room temperature calls for. - TRMNL X has no such table. Its firmware uses the value as a yes/no
switch: any non-
defaultprofile makes the panel run its long clearing sweep before every update instead of every eighth.aandbare therefore identical on an X — expect much heavier flashing, and a longer update.
Notes:
defaultis the value that ghosts. It stays the default so that upgrading changes nothing for devices that are fine.- Try
afirst, thenb. cis not accepted, even though it appears in TRMNL’s own documentation. Device firmware up to and including 1.8.14 never implemented it and silently treats it asdefault— which turns the extra clearing back off. Byonk refuses it and logs a warning rather than letting that happen unseen.- On a TRMNL X this needs device firmware 1.8.4 or newer.
maximum_compatibility
devices:
"94:A9:90:8C:6D:18":
maximum_compatibility: true
Asks the device to disable fast refresh and use a full-waveform refresh on every update. Updates flash visibly and take longer, and the firmware drops 2-bit support.
This does nothing on a TRMNL X. That model has no partial-refresh path to
switch off — it already does a full update every time — and its firmware
ignores the flag. Leave it unset there and use temperature_profile instead.
Unset is not the same as false: unset omits the field entirely and leaves the
device on its own default, whereas false would actively ask for fast refresh.
min_png_bytes
devices:
"1C:DB:D4:66:5B:50":
min_png_bytes: 102401
A TRMNL X chooses how carefully it renders greys from the size in bytes of the image it downloaded. Above 102 400 bytes it uses a 38-pass grey table; below it, a 9-pass one. Byonk’s images are usually far smaller than that, so an X never reaches the better table on its own.
Setting min_png_bytes pads the served image with a comment block that
decoders ignore, so the picture is unchanged to the pixel and only the byte
count grows. Use 102401 to just clear the threshold. Each update then takes
noticeably longer.
Values above 750 000 bytes are capped, and a warning says so. That is the largest image a TRMNL X accepts — asking for more would produce an image the device refuses, leaving the screen blank.
Pointless on any other model: their firmware refuses images above 90 000 bytes, so the larger table cannot be reached at all.
Burn-in
An e-ink panel that shows the same image for days or weeks keeps a trace of it. Pigment particles that sit in one position gradually stick to the capsule wall, and charge builds up in the material around the held pattern. The result is a bias in the panel itself rather than leftover ink from the last refresh, so driving the next refresh harder does not remove it — the settings above will not help, however far you push them.
Two ways to tell it apart from ordinary refresh residue: the ghost is an old image rather than the previous one, and it survives even when the panel visibly flashes black and white several times before drawing.
It usually fades, slowly, if the panel is made to swing fully between black and white many times — hours of cycling, not one refresh. Deep cases never clear completely. A warm room helps, because the particles move more freely.
The way to avoid it is to keep the picture moving. A screen whose pixels are nearly identical for weeks is what causes this, so prefer content that changes, and avoid leaving a device on one static screen indefinitely.
MAC Address Format
- Use uppercase letters with colons:
"94:A9:90:8C:6D:18" - The MAC address must be quoted (it’s a YAML string)
Parameters
The params section can contain any YAML values:
params:
# Strings
station: "Olten, Bahnhof"
# Numbers
limit: 8
temperature_offset: -2.5
# Booleans
show_delays: true
# Lists
rooms:
- "Rosa"
- "Flora"
These are available in Lua as the global params table:
local station = params.station or "Default Station"
local limit = params.limit or 10
Dither Algorithms
The dither option selects which dithering algorithm to use. All algorithms perform color matching in perceptually uniform Oklab space and process pixels in gamma-correct linear RGB.
| Algorithm | Value | Description |
|---|---|---|
| Atkinson (default) | "atkinson" | Error diffusion (75% propagation). Good general-purpose default. |
| Atkinson Hybrid | "atkinson-hybrid" | Hybrid propagation: 100% achromatic, 75% chromatic. Fixes color drift on chromatic palettes. |
| Floyd-Steinberg | "floyd-steinberg" | Error diffusion with blue noise jitter. Smooth gradients, good general-purpose. |
| Jarvis-Judice-Ninke | "jarvis-judice-ninke" or "jjn" | Wide 12-neighbor kernel. Least oscillation on sparse chromatic palettes. |
| Sierra | "sierra" | 10-neighbor kernel. Good balance of quality and speed. |
| Sierra Two-Row | "sierra-two-row" | 7-neighbor kernel. Lighter weight than full Sierra. |
| Sierra Lite | "sierra-lite" | 3-neighbor kernel. Fastest error diffusion. |
| Stucki | "stucki" | Wide 12-neighbor kernel similar to JJN. |
| Burkes | "burkes" | 7-neighbor kernel. Good balance of speed and quality. |
For most screens, the default "atkinson" works well. Use "atkinson-hybrid" for chromatic palettes where Atkinson shows color drift. Use "floyd-steinberg" for photographic content. For sparse chromatic palettes (e.g. black/white/red/yellow), try "jarvis-judice-ninke" or "sierra" to reduce oscillation artifacts.
The Reserved DEFAULT Device
devices reserves one key, DEFAULT, whose screen is shown to any device that
isn’t listed elsewhere in devices — either because it hasn’t been onboarded yet
(new devices show their registration code on this screen while waiting to be
claimed) or because it’s registered but has no screen assigned. It’s a qualified
handle/path ref, set the same way as any other device’s screen:
devices:
DEFAULT:
screen: byonk-builtin/default
If devices.DEFAULT is omitted, byonk falls back to its embedded
byonk-builtin/default screen — a code-level fallback that always resolves, so
there’s no configuration state that leaves a device with nothing to show.
Screen Repos Section
Screens are distributed as screen repos. The screen_repos: block maps a short handle to
a screen repo source. The embedded byonk-builtin screen repo is always available; register
additional screen repos by repo and pin:
screen_repos:
byonk-builtin: {} # embedded built-in (always present)
weather: { repo: https://github.com/acme/screens, pin: v1.4.0 }
weather-beta: { repo: https://github.com/acme/screens, pin: v2.0.0 } # same repo, different pin
private: { repo: https://github.com/acme/secret, pin: v1.0.0, token: ${GITHUB_TOKEN} }
drafts: { path: /data/drafts } # writable local directory
| Property | Required | Description |
|---|---|---|
repo | No | Source git repo, as a full URL with a scheme — https://…, git://…, ssh://…, scp-style git@host:owner/repo, or file:///path for a local repo. A schemeless value like github.com/acme/screens is rejected (it would otherwise be read as a local path). Mutually exclusive with path. Omit both for the embedded built-in. |
path | No | A writable local directory to register as this handle, as an alternative to a git-fetched repo. Mutually exclusive with repo. Unlike repo-backed screen repos, path-backed ones can be written to (see Screen Authoring). |
pin | No | Commit sha, tag, or branch to fetch. Only meaningful with repo. |
token | No | Auth token for private repos (redacted in read APIs). Only meaningful with repo. |
A screen ref’s first segment is the handle: weather/forecast resolves the forecast screen
in the weather screen repo. Registering the same repo under two handles at different pins
lets you run two versions side by side.
Auto-registered local and examples handles
Two handles auto-register from filesystem paths, without needing a
screen_repos: entry — unless you add one yourself, which always wins:
local—SCREENS_DIR, your own writable screen repo.examples— the shipped worked-example screens, seeded once to<SCREENS_DIR>/../examplesby default (override with theEXAMPLES_DIRenv var). See Installation for the env vars and the seeding-vs-registration precedence note (an explicitscreen_repos.examplesconfig entry wins for registration, but seeding always followsEXAMPLES_DIR/the derived default, not the configured path).
See Screen Authoring for how the built-in, example, and your-own-screens layers fit together, and how to fork a read-only screen into a writable one.
Device Registration
Byonk supports optional device registration for enhanced security. When enabled, new devices must be explicitly approved before they can display content.
registration:
enabled: true
devices:
# Register using the code shown on the device screen
"ABCDE-FGHJK":
screen: examples/swiss-departure-board
params:
station: "Olten"
How It Works
- New device connects - Shows the
devices.DEFAULTscreen with a 10-character registration code - Admin reads code - The code is displayed in 2x5 format on the e-ink screen
- Admin adds code to devices - Add the code (hyphenated format) to the
devicessection - Device refreshes - Now shows the configured screen

Note: The registration code is derived from the device’s API key via a hash function. This means:
- Devices keep their existing API key (including TRMNL-issued keys) - no WiFi reset required
- The same API key always produces the same registration code
- The config shows only the derived code, not the actual API key
Registration Settings
| Property | Required | Description |
|---|---|---|
enabled | No | Enable device registration (default: true) |
There is no separate registration screen setting — the screen shown to a new,
unregistered device is the same devices.DEFAULT screen described in
The Reserved DEFAULT Device above.
Registration Code Format
- 10 uppercase letters displayed in 2 rows of 5:
A B C D E/F G H J K - Written in config as hyphenated:
"ABCDE-FGHJK" - Uses unambiguous letters only (excludes I, L, O)
- Can be used interchangeably with MAC addresses in the
devicessection - Deterministic: same API key always produces the same code
Example
registration:
enabled: true
devices:
# By registration code (read from device screen)
"ABCDE-FGHJK":
screen: examples/swiss-departure-board
params:
station: "Olten"
# By MAC address (found in logs)
"AA:BB:CC:DD:EE:FF":
screen: examples/hello
Custom Registration Screen
The registration code is available to the devices.DEFAULT screen as device.registration_code and device.registration_code_hyphenated. That screen’s screen.svg can conditionally show it:
{% if device.registration_code %}
<text>Register: {{ device.registration_code_hyphenated }}</text>
{% endif %}
See Device Mapping for more details.
Authentication Mode
Byonk supports optional Ed25519 cryptographic authentication for devices. When enabled, devices use Ed25519 signatures instead of plain API keys.
auth_mode: ed25519 # or "api_key" (default)
The auth_mode setting controls what /api/setup tells devices. The /api/display endpoint always accepts both authentication methods, so existing devices continue to work during migration.
Ed25519 Flow
- Device calls
GET /api/timeto get the server timestamp - Device signs
timestamp_ms (8 bytes BE) || public_key (32 bytes)with its Ed25519 private key - Device sends
X-Public-Key,X-Signature,X-Timestampheaders along with the normalAccess-TokenandIDheaders - Server verifies the signature and checks the timestamp is within ±60 seconds
Settings
| Property | Default | Description |
|---|---|---|
auth_mode | api_key | Authentication mode advertised to devices (api_key or ed25519) |
Hot Reloading
Byonk loads a screen’s script.lua and screen.svg fresh on every request. You can edit
those files without restarting the server.
However, config.yaml is only loaded at startup. Changes to device mappings, the screen repo
registry, or other settings require a server restart (or use the Admin API,
which hot-reloads after writes).
Example: Complete Configuration
# Byonk Configuration
devices:
# Kitchen display - bus departures
"94:A9:90:8C:6D:18":
screen: examples/swiss-departure-board
params:
station: "Olten, Südwest"
limit: 8
# Office display - room booking (webscrape example)
"AA:BB:CC:DD:EE:FF":
screen: examples/webscrape
params:
room: "Rosa"
# Lobby display - different bus stop
"BB:CC:DD:EE:FF:00":
screen: examples/swiss-departure-board
params:
station: "Olten, Bahnhof"
limit: 6
# Reserved key: shown to every un-onboarded or unassigned device
DEFAULT:
screen: byonk-builtin/default
Panels Section
Panel profiles define the physical characteristics and measured colors of your e-ink displays. They are used for accurate dithering — the ditherer models what the panel really displays, producing better output.
panels:
trmnl_og_4grey:
name: "TRMNL OG (4-grey)"
match: "trmnl_og_4grey"
width: 800
height: 480
colors: "#000000,#555555,#AAAAAA,#FFFFFF"
colors_actual: "#383838,#787878,#B8B8B0,#D8D8C8"
trmnl_og_4clr:
name: "TRMNL OG (4-color)"
match: "trmnl_og_4clr"
width: 800
height: 480
colors: "#000000,#FFFFFF,#FF0000,#FFFF00"
colors_actual: "#303030,#D0D0C8,#C04040,#D0D020"
Panel Properties
| Property | Required | Description |
|---|---|---|
name | Yes | Human-readable display name |
match | No | Exact string match against firmware Board header for auto-detection |
width | No | Display width in pixels |
height | No | Display height in pixels |
colors | Yes | Official palette colors (comma-separated hex) |
colors_actual | No | Measured/actual colors the panel really displays |
dither | No | Per-panel dither tuning defaults (see below) |
Panel Dither Defaults
Panels can carry default dither tuning values that apply to all devices using that panel. This avoids repeating the same tuning in every device config entry.
panels:
trmnl_og_4clr:
name: "TRMNL OG (4-color)"
colors: "#000000,#FFFFFF,#FF0000,#FFFF00"
colors_actual: "#303030,#D0D0C8,#C04040,#D0D020"
dither:
max_error: 1.0 # flat default for all algorithms
noise_scale: 5.0
floyd-steinberg: # per-algorithm override
max_error: 0.8
noise_scale: 4.0
atkinson:
max_error: 1.2
The dither section supports:
- Flat keys (
max_error,noise_scale,chroma_clamp,strength): default values for all algorithms - Algorithm sub-sections: per-algorithm overrides that take priority over flat defaults
Resolution within a panel: per-algorithm value > flat default > None.
error_clampwas renamed tomax_errorand is now ignored.Up to 0.17.x the knob was called
error_clampand it capped the resulting pixel value. Since 0.18.0 it caps the accumulated error, which moved the useful range from around0.1to around1.0. A pre-0.18.0 value still parses under the new meaning and still renders — flat, with saturated areas collapsing to a single ink.Because the name could not keep its old meaning, it changed with it.
error_clampis read, reported at startup with the exact path to edit, and then discarded. Delete the key to take the default, or setmax_errorif you have retuned it. The warning looks like:WARN panels.reterminal_e1004.dither.sierra-lite.error_clamp: 0.11 — `error_clamp` was removed in 0.18.0 and is IGNORED. ...
Algorithm names accept aliases (e.g. jjn for jarvis-judice-ninke).
The overall tuning priority chain is:
| Priority | Source |
|---|---|
| 1 (highest) | Dev UI overrides |
| 2 | Lua script return values |
| 3 | Device config (max_error, noise_scale, chroma_clamp, strength) |
| 4 | Panel dither defaults |
| 5 (lowest) | Built-in per-algorithm defaults |
That chain is for the tuning values. Choosing the algorithm puts the device above the screen instead:
| Priority | Source |
|---|---|
| 1 (highest) | Dev UI override |
| 2 | Device config dither |
| 3 | Screen’s Lua dither |
| 4 (lowest) | atkinson |
The algorithm suits the panel rather than the content, and an operator who
sets it on the device cannot see a screen replacing it. A screen’s dither
still applies on any device that does not name one; when both do and they
differ, Byonk logs which value was dropped.
The refresh interval goes the other way, because only the script knows when its own content next changes:
| Priority | Source |
|---|---|
| 1 (highest) | Screen’s Lua refresh_rate (when greater than 0) |
| 2 | Device config refresh |
| 3 (lowest) | The screen’s meta.yaml refresh, or 900 seconds |
Byonk logs when a device’s refresh is displaced by a screen, so an operator
can see why their setting is inert.
Panel Assignment
Panels are assigned to devices in three ways (highest priority first):
- Device config
panel— explicit assignment in thedevicessection - Board header auto-detection — firmware sends a
Boardheader, matched against panelmatchpatterns - None — firmware palette header or system defaults
devices:
"ABCDE-FGHJK":
screen: examples/swiss-departure-board
panel: trmnl_og_4grey # explicit panel assignment
When a panel has colors_actual, the ditherer uses these measured values to model what the display really shows. Use dev mode to calibrate and find the right measured colors for your panel.
Customization & File Locations
See Installation for embedded assets, environment variables,
the byonk init command, Docker volume mounts, and file locations.