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
nameNoA friendly name for the device, shown in the dev UI and Home Assistant
paramsNoKey-value pairs passed to the Lua script
colorsNoOverride display palette (comma-separated hex RGB, e.g. "#000000,#FFFFFF,#FF0000"; the leading # is optional)
colors_actualNoThis one display’s measured colors, comma-separated hex RGB and index-parallel to colors. Overrides the panel profile’s calibration, for the one panel that drifted.
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.
refreshNoRefresh interval in seconds; 0 means “unset”. A screen’s own refresh_rate overrides it — see Settings order.
gamutNoGamut-compression block: knee (where compression begins, as a fraction of the reachable chroma limit), amount (0 = none, 1 = full) and max_compression (a cap on the factor).
finalNoThe owner’s vetoes — the same settings as above, but nothing overrides them. See final:.
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.

These are the only keys a device entry accepts (plus the retired error_clamp, which is still read and reported). Anything else — a typo such as dithr:, or a key from another tool — stops byonk at startup with a message naming the device and the key, for example Invalid config.yaml: devices.kitchen: unknown field `dithr` . The same holds inside a device’s gamut: block and inside final: (including final.gamut). A YAML merge key (<<:) inside a device entry is an error as well: it was never applied.

If such a key reaches the file while byonk is running (a hand edit), byonk keeps running on the configuration it loaded, and every change made through the dev UI, the admin API or Home Assistant is refused with HTTP 400 and the same message, leaving your file untouched, until the key is fixed. A write whose own body carries an unknown key is refused with HTTP 422.

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.

A device’s dither: is a default: a screen that names its own algorithm overrides it, because only the script knows what it just drew. To make the device’s choice stick regardless, put it in the device’s final: block — see Settings order.

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

  • local — SCREENS_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 #RRGGBB; the leading # is optional)
colors_actualNoMeasured/actual colors the panel really displays
ditherNoPer-panel dither algorithm and tuning defaults (see below)

Panel Dither Defaults

Panels can carry a default dither algorithm and default tuning values that apply to all devices using that panel. This avoids repeating the same settings 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:
      algorithm: floyd-steinberg   # the panel's default algorithm
      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:

  • algorithm: the panel’s default dither algorithm. It is the panel rung of dither in the settings order: it beats byonk’s atkinson fallback and loses to the device, the screen, the device’s final: block and a live override. Leave it out and the panel picks no algorithm.
  • 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. The algorithm is resolved first; the sub-section used is the one for the algorithm that won, whichever rung chose it. A panel’s own algorithm therefore brings its own sub-section with it, and a screen that picks another algorithm gets that algorithm’s sub-section instead.

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.20.0 and is IGNORED. ...

Algorithm names accept aliases (e.g. jjn for jarvis-judice-ninke).

A panel’s defaults are one rung of the ladder every render setting walks — see Settings order below for the whole of it.

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.

Settings order: defaults and vetoes

Every render setting resolves in one order, and every render path uses it. There is one ladder, highest first:

PrioritySourceWhat it is
1 (highest)OverrideA live dev-UI experiment, or a caller’s explicit request (POST /dev/api/render, the MCP render_screen tool). Never configuration.
2The device’s final: blockThe owner’s veto — “whatever the screen says, on this display, no”.
3The screen’s Lua return valuesThe script is the only participant that can inspect the content it is about to render.
4The device, written directly under its keyThe owner’s default — which a screen may override.
5The panel profileWhat the hardware is, shared by every device of that type.
6 (lowest)FallbackByonk’s own defaults, and the device’s own firmware headers. A hardware self-report is evidence, not policy.

The settings on that ladder are colors, colors_actual, dither, max_error, noise_scale, chroma_clamp, strength, gamut and refresh. For dither, the panel rung is the panel’s dither.algorithm and the fallback is atkinson. For the tuning numbers, the panel rung is the panel dither defaults above and the fallback is the built-in per-algorithm default; for colors the fallback is the firmware’s Colors header and then byonk’s default palette; for refresh there is no panel rung, and the fallback is the screen’s own 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. For the whole ladder at once — every rung’s value, and whether it won, was outranked or was rejected — render through POST /dev/api/render and read its X-Byonk-Resolution header, or ask the device preview for it with GET /api/admin/devices/{key}/preview?resolution.

final: — the owner’s veto

Settings written directly under a device are defaults: a screen may override them, because the script is the only participant that can inspect the content it is about to render. Settings under final: are vetoes: nothing overrides them.

devices:
  "AA:BB:CC:DD:EE:FF":
    screen: local/weather
    panel: trmnl_x

    dither: floyd-steinberg    # a default — a screen may pick its own
    strength: 0.9              # a default
    colors_actual: '#1C5240,#00994D,#D8D8C8,#303030'   # this display's own measurement

    final:
      dither: atkinson         # a veto — no screen overrides this
      refresh: 1800            # this panel runs on a battery

final: accepts the same settings as the base rung — colors, colors_actual, dither, max_error, noise_scale, chroma_clamp, strength, refresh and gamut — and nothing else: an unrecognised key there is a startup error naming the field, so a typo cannot silently do nothing. The settings written directly under a device are just as strict; see Devices Section.

final.refresh is the one way to hold a battery-powered display to an interval the owner chooses: it replaces whatever the screen asked for, in either direction, so a screen that wants a 60-second refresh gets 1800 seconds on the device above.

Through the admin API or the MCP configure_device tool, final: merges field by field — setting final.dither does not wipe final.strength — and a field is removed with one dot: clear: ["dither"] removes the default, clear: ["final.dither"] the veto, clear: ["final"] the whole block. A bare name never touches final:.

Home Assistant. The integration’s Dither control writes final.dither, because what it shows has to be what the device renders. A device last configured through an older integration holds a bare dither: instead, so its Dither entity reads empty until you pick the value again. See the changelog.

Customization & File Locations

See Installation for embedded assets, environment variables, the byonk init command, Docker volume mounts, and file locations.

Next Steps