Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

FilePurpose
meta.yamlTitle, description, byonk: engine compatibility, default refresh:, and the params: schema
script.luaData-fetch logic
screen.svgTera 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:

PropertyRequiredDescription
screenYesQualified handle/path reference of the screen to display
paramsNoKey-value pairs passed to the Lua script
colorsNoOverride display palette (comma-separated hex RGB, e.g. "#000000,#FFFFFF,#FF0000")
ditherNoDithering algorithm (see Dither Algorithms below)
panelNoPanel profile name (references panels section)
max_errorNoCaps 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_scaleNoBlue noise jitter scale (e.g. 0.6). Controls noise modulation strength.
chroma_clampNoChroma clamp for dithering. Limits chromatic error propagation.
strengthNoError diffusion strength (0.0–2.0, default 1.0). Lower = less dithering texture.
temperature_profileNoRefresh profile sent to the device: default (the default), a or b. See Ghosting below.
maximum_compatibilityNoAsk the device to force a full refresh on every update. No effect on a TRMNL X. See Ghosting.
min_png_bytesNoPad 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-default profile 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-default profile makes the panel run its long clearing sweep before every update instead of every eighth. a and b are therefore identical on an X — expect much heavier flashing, and a longer update.

Notes:

  • default is the value that ghosts. It stays the default so that upgrading changes nothing for devices that are fine.
  • Try a first, then b.
  • c is 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 as default — 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.

AlgorithmValueDescription
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
PropertyRequiredDescription
repoNoSource git repo, as a full URL with a schemehttps://…, 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.
pathNoA 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).
pinNoCommit sha, tag, or branch to fetch. Only meaningful with repo.
tokenNoAuth 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:

  • localSCREENS_DIR, your own writable screen repo.
  • examples — the shipped worked-example screens, seeded once to <SCREENS_DIR>/../examples by default (override with the EXAMPLES_DIR env var). See Installation for the env vars and the seeding-vs-registration precedence note (an explicit screen_repos.examples config entry wins for registration, but seeding always follows EXAMPLES_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

  1. New device connects - Shows the devices.DEFAULT screen with a 10-character registration code
  2. Admin reads code - The code is displayed in 2x5 format on the e-ink screen
  3. Admin adds code to devices - Add the code (hyphenated format) to the devices section
  4. Device refreshes - Now shows the configured screen

Registration screen showing device code

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

PropertyRequiredDescription
enabledNoEnable 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 devices section
  • 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

  1. Device calls GET /api/time to get the server timestamp
  2. Device signs timestamp_ms (8 bytes BE) || public_key (32 bytes) with its Ed25519 private key
  3. Device sends X-Public-Key, X-Signature, X-Timestamp headers along with the normal Access-Token and ID headers
  4. Server verifies the signature and checks the timestamp is within ±60 seconds

Settings

PropertyDefaultDescription
auth_modeapi_keyAuthentication 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

PropertyRequiredDescription
nameYesHuman-readable display name
matchNoExact string match against firmware Board header for auto-detection
widthNoDisplay width in pixels
heightNoDisplay height in pixels
colorsYesOfficial palette colors (comma-separated hex)
colors_actualNoMeasured/actual colors the panel really displays
ditherNoPer-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_clamp was renamed to max_error and is now ignored.

Up to 0.17.x the knob was called error_clamp and it capped the resulting pixel value. Since 0.18.0 it caps the accumulated error, which moved the useful range from around 0.1 to around 1.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_clamp is read, reported at startup with the exact path to edit, and then discarded. Delete the key to take the default, or set max_error if 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:

PrioritySource
1 (highest)Dev UI overrides
2Lua script return values
3Device config (max_error, noise_scale, chroma_clamp, strength)
4Panel 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:

PrioritySource
1 (highest)Dev UI override
2Device config dither
3Screen’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:

PrioritySource
1 (highest)Screen’s Lua refresh_rate (when greater than 0)
2Device 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):

  1. Device config panel — explicit assignment in the devices section
  2. Board header auto-detection — firmware sends a Board header, matched against panel match patterns
  3. 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.

Next Steps