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

Byonk

Bring Your Own Ink - Self-hosted content server for TRMNL e-ink devices

Features

  • Lua Scripting - Fetch data from any API, scrape websites, process JSON - all with simple Lua scripts.
  • SVG Templates - Design pixel-perfect screens using SVG with Tera templating (Jinja2-style syntax).
  • Variable Fonts - Full support for variable font weights via CSS font-variation-settings.
  • Smart Refresh - Scripts control when devices refresh - optimize for fresh data and battery life.
  • Palette-Aware Dithering - Perceptually correct Oklab dithering with two rendering intents (Graphics and Photo), supporting greyscale and color palettes.
  • Device Mapping - Assign different screens to different devices via simple YAML configuration.

Quick Start

# Run with Docker
docker run -d -p 3000:3000 ghcr.io/oetiker/byonk:latest

Or download a pre-built binary for your platform.

Point your TRMNL device to http://your-server:3000 and it will start displaying content.

Default screen

How It Works

flowchart LR
    A[Lua Script] --> B[SVG Template] --> C[Dithering] --> D[TRMNL PNG]
  1. Lua scripts fetch data from APIs or scrape websites
  2. SVG templates render the data into beautiful layouts
  3. Renderer converts SVG to dithered PNG optimized for e-ink
  4. Device displays the content and sleeps until next refresh

Example: Transit Departures

Lua Script fetches real-time data:

local response = http_get("https://transport.opendata.ch/v1/stationboard?station=Olten")
local data = json_decode(response)

return {
  data = { departures = data.stationboard },
  refresh_rate = 60
}

SVG Template renders the display:

<svg viewBox="0 0 800 480">
  {% for dep in departures %}
  <text y="{{ 100 + loop.index0 * 40 }}">
    {{ dep.category }}{{ dep.number }} → {{ dep.to }}
  </text>
  {% endfor %}
</svg>

Result on e-ink display:

Transit departures screen

Next Steps

Installation

Byonk can be installed via Docker container or pre-built binaries. All screens, fonts, and configuration are embedded in the binary, so it works out of the box with zero configuration.

Quick Start

# Just run it - embedded assets work immediately
docker run --pull always -p 3000:3000 ghcr.io/oetiker/byonk:latest

That’s it! The server is running with embedded default screens.

Zero-Config Mode

The simplest way to run Byonk:

docker run -d --pull always \
  --name byonk \
  -p 3000:3000 \
  ghcr.io/oetiker/byonk:latest

This uses embedded screens, fonts, and config - no volumes needed.

Customization Mode

To customize screens and config, mount volumes and set environment variables:

docker run -d --pull always \
  --name byonk \
  -p 3000:3000 \
  -e SCREENS_DIR=/data/screens \
  -e FONTS_DIR=/data/fonts \
  -e CONFIG_FILE=/data/config.yaml \
  -v ./data:/data \
  ghcr.io/oetiker/byonk:latest

On first run with empty directories, Byonk automatically seeds them with embedded defaults.

Available tags:

  • latest - Latest stable release
  • 0 - Latest v0.x release
  • 0.4 - Latest v0.4.x release
  • 0.4.0 - Specific version

Docker Compose

Zero-config:

services:
  byonk:
    image: ghcr.io/oetiker/byonk:latest
    ports:
      - "3000:3000"
    restart: unless-stopped

With customization:

services:
  byonk:
    image: ghcr.io/oetiker/byonk:latest
    ports:
      - "3000:3000"
    environment:
      - SCREENS_DIR=/data/screens
      - FONTS_DIR=/data/fonts
      - CONFIG_FILE=/data/config.yaml
    volumes:
      - ./data:/data  # Empty on first run = auto-seeded
    restart: unless-stopped

Pre-built Binaries

Download the latest release from GitHub Releases.

Available platforms:

  • x86_64-unknown-linux-gnu - Linux (Intel/AMD 64-bit)
  • aarch64-unknown-linux-gnu - Linux (ARM 64-bit, e.g., Raspberry Pi 4)
  • x86_64-apple-darwin - macOS (Intel)
  • aarch64-apple-darwin - macOS (Apple Silicon)
  • x86_64-pc-windows-msvc - Windows

Extract and run:

tar -xzf byonk-*.tar.gz
./byonk

This will show you a short usage message. If you want to directly test the server, try

./byonk serve

By default, Byonk listens on 0.0.0.0:3000 and uses embedded assets.

Extracting Embedded Assets

To customize the embedded screens and config:

# See what's embedded
./byonk init --list

# Extract everything for editing
./byonk init --all

# Extract specific categories
./byonk init --screens
./byonk init --config

Directory Structure (When Customizing)

When using external files (via env vars), Byonk expects:

data/
├── config.yaml          # Device and screen configuration
├── screens/             # Lua scripts and SVG templates
│   ├── default.lua
│   ├── default.svg
│   └── ...
└── fonts/               # Custom fonts (optional)
    └── Outfit-Variable.ttf

Environment Variables

VariableDefaultDescription
BIND_ADDR0.0.0.0:3000Server bind address
CONFIG_FILE(embedded)Path to configuration file
SCREENS_DIR(embedded)Directory containing Lua scripts and SVG templates
FONTS_DIR(embedded)Directory containing font files

When path variables are not set, Byonk uses embedded assets (no filesystem access).

Running as a Service (systemd)

Create /etc/systemd/system/byonk.service:

[Unit]
Description=Byonk Content Server
After=network.target

[Service]
Type=simple
User=byonk
WorkingDirectory=/opt/byonk
ExecStart=/opt/byonk/byonk serve
Environment="BIND_ADDR=0.0.0.0:3000"
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable and start:

sudo systemctl enable byonk
sudo systemctl start byonk

CLI Commands

Status (Default)

Running byonk without arguments shows current configuration:

./byonk

Server

Start the HTTP server:

./byonk serve

Render

Render a screen directly to PNG (useful for testing):

./byonk render --mac "00:00:00:00:00:00" --output test.png

Options:

OptionDescription
-m, --macDevice MAC address (required)
-o, --outputOutput PNG file path (required)
-d, --deviceDevice type: “og” (800x480) or “x” (1872x1404)
-b, --batteryBattery voltage for testing (e.g., 4.12)
-r, --rssiWiFi signal strength for testing (e.g., -67)
-f, --firmwareFirmware version string for testing

Example with all device info:

./byonk render -m "AC:15:18:D4:7B:E2" -o test.png \
  --battery=4.12 --rssi=-67 --firmware="1.2.3"

Note: Use = syntax for negative numbers (e.g., --rssi=-67).

Init

Extract embedded assets for customization:

./byonk init --all        # Extract everything
./byonk init --screens    # Extract screens only
./byonk init --list       # List embedded assets

Verifying Installation

  1. Open http://your-server:3000/health - should return “OK”
  2. Open http://your-server:3000/swagger-ui - shows API documentation
  3. Point a TRMNL device to your server to test

Configuring Your TRMNL Device

To use Byonk with your TRMNL device, configure the device to point to your server instead of the default TRMNL cloud service.

Note: Refer to TRMNL documentation for instructions on configuring a custom server URL.

Next Steps

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 define screens and map devices to them.

Configuration Structure

# Screen definitions
screens:
  transit:
    script: transit.lua        # Lua script in screens/
    template: transit.svg      # SVG template in screens/
    default_refresh: 60        # Fallback refresh rate (seconds)

  weather:
    script: weather.lua
    template: weather.svg
    default_refresh: 900

# Device-to-screen mapping
devices:
  "94:A9:90:8C:6D:18":         # Device MAC address
    screen: transit             # Which screen to display
    params:                     # Parameters passed to Lua script
      station: "Olten, Bahnhof"
      limit: 8

  "AA:BB:CC:DD:EE:FF":
    screen: weather
    params:
      city: "Zurich"

# Default screen for unmapped devices
default_screen: default

Screens Section

Each screen definition has three properties:

PropertyRequiredDescription
scriptYesLua script filename (relative to screens/)
templateYesSVG template filename (relative to screens/)
default_refreshNoFallback refresh rate in seconds (default: 900)

The default_refresh is used when the Lua script returns refresh_rate = 0 or omits it entirely.

Devices Section

Each device entry maps a MAC address to a screen:

PropertyRequiredDescription
screenYesName of the screen definition to use
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)
error_clampNoError clamp for dithering (e.g. 0.08). Limits error diffusion amplitude.
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.

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.

Default Screen

The default_screen specifies which screen to show for devices not listed in the devices section:

default_screen: default

If omitted, unknown devices receive an error response.

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: transit
    params:
      station: "Olten"

How It Works

  1. New device connects - Shows the 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)
screenNoCustom screen for registration (default: uses default_screen)

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: transit
    params:
      station: "Olten"

  # By MAC address (found in logs)
  "AA:BB:CC:DD:EE:FF":
    screen: weather

Custom Registration Screen

The registration code is available to your default screen as device.registration_code and device.registration_code_hyphenated. Your default.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 Lua scripts and SVG templates fresh on every request. You can edit these files without restarting the server.

However, config.yaml is only loaded at startup. Changes to device mappings or screen definitions require a server restart.

Example: Complete Configuration

# Byonk Configuration

screens:
  # Default screen - shows time and a message
  default:
    script: default.lua
    template: default.svg
    default_refresh: 300

  # Public transport departures
  transit:
    script: transit.lua
    template: transit.svg
    default_refresh: 60

  # Room booking display
  floerli:
    script: floerli.lua
    template: floerli.svg
    default_refresh: 900

devices:
  # Kitchen display - bus departures
  "94:A9:90:8C:6D:18":
    screen: transit
    params:
      station: "Olten, Südwest"
      limit: 8

  # Office display - room booking
  "AA:BB:CC:DD:EE:FF":
    screen: floerli
    params:
      room: "Rosa"

  # Lobby display - different bus stop
  "BB:CC:DD:EE:FF:00":
    screen: transit
    params:
      station: "Olten, Bahnhof"
      limit: 6

default_screen: 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:
      error_clamp: 0.1         # flat default for all algorithms
      noise_scale: 5.0
      floyd-steinberg:          # per-algorithm override
        error_clamp: 0.08
        noise_scale: 4.0
      atkinson:
        error_clamp: 0.12

The dither section supports:

  • Flat keys (error_clamp, 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.

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 (error_clamp, noise_scale, chroma_clamp, strength)
4Panel dither defaults
5 (lowest)Built-in per-algorithm defaults

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

Dev Mode

Byonk includes a development mode that provides a web-based device simulator with live reload capabilities, making it easier to develop and test screens.

Starting Dev Mode

# Start with dev mode enabled
byonk dev

# With external screens directory for live reload
SCREENS_DIR=./screens byonk dev

Once started, open your browser to http://localhost:3000/dev to access the device simulator.

Dev Mode Screenshot

Features

Device Simulator

The simulator displays your rendered screens in a visual frame resembling a TRMNL device. You can:

  • Select a screen from the dropdown (populated from config.yaml and auto-discovered screens)
  • Select a device to auto-load its configured screen, parameters, panel, and dither settings
  • Simulate device context: battery voltage, WiFi RSSI, and time override
  • View the rendered PNG exactly as it would appear on the device
  • Pixel inspector: hover over the image to see a magnified view

Live Reload

When SCREENS_DIR is set to an external directory, the dev mode watches for changes to .lua and .svg files. When you save a file:

  1. The file watcher detects the change
  2. An event is sent to connected browsers via Server-Sent Events (SSE)
  3. The screen automatically re-renders with the latest code

Custom Parameters

The dev UI includes a JSON editor for passing custom parameters to your Lua scripts. These are available in your script via the params table.

Error Display

Errors are displayed in a console below the device preview, including Lua syntax/runtime errors, template errors, and render failures.

Display Calibration

Dev mode provides tools for calibrating dithering to match your physical display. Changes made in the dev UI are synced live to the actual device — what you tune is what the device shows.

Dither Algorithm Selection

The dither dropdown lets you try all 9 algorithms on your content:

  • atkinson (default) — Atkinson error diffusion (75% propagation)
  • atkinson-hybrid — Atkinson with hybrid propagation (100% achromatic / 75% chromatic)
  • floyd-steinberg — Floyd-Steinberg with blue noise jitter
  • jarvis-judice-ninke — wide 12-neighbor kernel
  • sierra, sierra-two-row, sierra-lite — Sierra family
  • stucki — wide 12-neighbor kernel similar to JJN
  • burkes — 7-neighbor kernel, good balance of speed and quality

Dither Tuning Controls

The Render Options panel exposes three tuning parameters:

ControlEffect
Error clampLimits how much error is diffused. Lower values (0.05–0.1) reduce oscillation in smooth gradients.
Noise scaleControls blue noise jitter strength. Higher values break “worm” artifacts more aggressively.
Chroma clampLimits chromatic error propagation. Prevents color bleeding on chromatic palettes.

Color Calibration

Click any actual-color swatch to open the HSL adjustment popup. Adjust hue, saturation, and lightness with live preview to match what your panel really displays. The adjusted colors_actual string can be copied to config.yaml.

Live Device Sync

When you select a device entry and adjust dither algorithm, tuning parameters, or measured colors, changes are synced to the production /api/display handler. The physical device picks up the new settings on its next refresh.

Calibrator Screen

Byonk ships a built-in calibrator screen designed specifically for display calibration. Assign it to your device temporarily while tuning:

devices:
  "ABCDE-FGHJK":
    screen: calibrator
    panel: my_panel
    dither: atkinson

The calibrator shows everything you need to evaluate dithering quality:

  • White-to-color gradients for each palette color — reveals error diffusion artifacts, oscillation, and color bleeding
  • Full hue sweep at 100% saturation — shows how the ditherer maps arbitrary colors to your limited palette
  • Test photo — real-world image to judge overall photo reproduction
  • Solid color patches with hex labels — compare what the panel actually displays against the expected color values

Use the calibrator on your physical device while adjusting tuning in dev mode — the live sync means every change you make is immediately visible on the display.

Calibration Workflow

  1. Assign the calibrator screen to your device in config.yaml
  2. Select your device in dev mode — this loads its screen, panel, and dither settings
  3. Choose a dither algorithm that works well for your content type
  4. Adjust tuning parameters (error_clamp, noise_scale, chroma_clamp, strength) until the preview looks good
  5. Calibrate measured colors by clicking actual-color swatches and adjusting HSL to match the solid patches on the physical display
  6. Verify on device — changes sync automatically; wait for the next device refresh
  7. Commit to config — copy the values to config.yaml and switch back to your normal screen:
panels:
  my_panel:
    name: "My Panel"
    colors: "#000000,#FFFFFF,#FF0000,#FFFF00"
    colors_actual: "#303030,#D0D0C8,#C04040,#D0D020"  # from dev mode calibration

devices:
  "ABCDE-FGHJK":
    screen: gphoto
    panel: my_panel
    dither: floyd-steinberg
    error_clamp: 0.08   # from dev mode tuning
    noise_scale: 0.5    # from dev mode tuning

Tuning values can also be set per-script in the Lua return table — see Lua API.

Configuration

Dev mode uses the same environment variables as the normal server:

VariableDescriptionDefault
BIND_ADDRServer bind address0.0.0.0:3000
SCREENS_DIRExternal screens directory (enables live reload)(embedded)
FONTS_DIRExternal fonts directory(embedded)
CONFIG_FILEExternal config file(embedded)

Example Workflow

  1. Extract embedded assets to work with:

    byonk init --all
    
  2. Start dev mode with external screens:

    SCREENS_DIR=./screens CONFIG_FILE=./config.yaml byonk dev
    
  3. Open http://localhost:3000/dev in your browser

  4. Select the screen you want to work on

  5. Edit your Lua script or SVG template — changes appear automatically

  6. Use the calibration tools to tune dithering for your panel

  7. Check the console below the preview if something goes wrong

Differences from Production

Dev mode includes a few differences from the production byonk serve command:

  • Additional /dev/* routes for the simulator UI
  • File watching enabled (when using external SCREENS_DIR)
  • No content caching — always renders fresh content
  • More verbose logging by default
  • Tuning and color overrides are session-only (reset on server restart)

Architecture Overview

Byonk is designed as a content server that bridges dynamic data sources with e-ink displays. This page explains how the system is structured and how requests flow through it.

System Overview

flowchart LR
    Display[TRMNL Display]

    subgraph Server[Byonk Server]
        Router[HTTP Router]
        Registry[(Device Registry)]
        Cache[(Content Cache)]
        Lua[Lua Runtime]
        Template[Template Service]
        Renderer[SVG Renderer]
    end

    Display --> Router
    Router --> Registry
    Router --> Cache
    Router --> Lua
    Lua --> Template
    Template --> Renderer

Core Components

HTTP Router

The entry point for all device requests. Built with Axum, it handles:

  • Device registration (/api/setup)
  • Content requests (/api/display, /api/image/:id)
  • Logging (/api/log)
  • API documentation (/swagger-ui)

Device Registry

Stores device information in memory:

  • MAC address to API key mapping
  • Device metadata (firmware version, model, battery level)
  • Last seen timestamps

Note: The current implementation uses an in-memory store. Device registrations are lost on restart. The architecture supports adding database persistence in the future.

Content Cache

Stores rendered content between the display and image requests:

  • Caches rendered SVG documents by content hash
  • Enables content change detection via hash comparison
  • Allows devices to skip unchanged content

Content Pipeline

The heart of Byonk - orchestrates content generation:

  1. Looks up screen configuration for the device
  2. Executes Lua script with device parameters
  3. Renders SVG template with script data
  4. Converts SVG to PNG with dithering

Lua Runtime

Executes Lua scripts in a sandboxed environment:

  • HTTP client for fetching external data
  • JSON/HTML parsing utilities
  • Time functions
  • Logging

Template Service

Renders SVG templates using Tera:

  • Jinja2-style syntax
  • Custom filters (truncate, format_time)
  • Fresh loading on each request (hot reload)

SVG Renderer

Converts SVG to PNG optimized for e-ink:

  • Uses resvg for rendering
  • Loads custom fonts from fonts/ directory
  • Palette-aware dithering via eink-dither engine (Oklab color matching, two rendering intents)
  • Outputs optimized PNG (greyscale or indexed, depending on palette)

Request Flow

The device-server interaction happens in three phases:

Phase 1: Device Registration

sequenceDiagram
    participant Device as E-ink Display
    participant Router as HTTP Router
    participant Registry as Device Registry

    Device->>+Router: GET /api/setup
    Router->>Registry: lookup/create device
    Registry-->>Router: api_key
    Router-->>-Device: {api_key, friendly_id}
    Note right of Device: Store api_key

Phase 2: Content Generation

sequenceDiagram
    participant Device
    participant Router
    participant Lua
    participant API as External API
    participant Template
    participant Cache

    Device->>+Router: GET /api/display
    Router->>+Lua: execute script
    Lua->>+API: http_get(url)
    API-->>-Lua: JSON data
    Lua-->>-Router: {data, refresh_rate}
    Router->>+Template: render SVG with data
    Template-->>-Router: SVG document
    Router->>Cache: store SVG + hash
    Router-->>-Device: {image_url, filename, refresh_rate}
    Note right of Device: filename is content hash

Phase 3: Image Rendering

sequenceDiagram
    participant Device
    participant Router
    participant Cache
    participant Renderer

    Device->>+Router: GET /api/image/:id
    Router->>Cache: get cached SVG
    Cache-->>Router: SVG document
    Router->>+Renderer: convert to PNG
    Renderer-->>-Router: dithered PNG
    Router-->>-Device: PNG image
    Note right of Device: Display and sleep

Request Details

PhaseEndpointPurpose
1. SetupGET /api/setupDevice registers, receives API key
2. DisplayGET /api/displayRuns Lua script, renders SVG, caches it, returns image URL with content hash
3. ImageGET /api/image/:hashConverts cached SVG to PNG, returns image

Phase 2 (content generation):

  1. Load and execute Lua script with params and device context
  2. Script fetches external data via http_get()
  3. Render SVG template with script data
  4. Cache rendered SVG with content hash
  5. Return image URL and filename (content hash) to device

The filename field contains a hash of the rendered SVG content. This allows TRMNL devices to detect when content has actually changed, even if the same screen is configured.

Phase 3 (image rendering):

  1. Look up cached SVG by content hash
  2. Convert SVG to PNG with palette-aware dithering
  3. Return PNG to device

Technology Stack

ComponentTechnology
Web frameworkAxum
Async runtimeTokio
Scriptingmlua (Lua 5.4)
TemplatingTera
SVG renderingresvg (patched for variable fonts)
HTTP clientreqwest
HTML parsingscraper

Design Principles

Fresh Loading

Lua scripts and SVG templates are loaded from disk on every request. This enables:

  • Live editing during development
  • No restart needed for content changes
  • Simple deployment (just copy files)

Blocking Isolation

CPU-intensive operations run in a blocking task pool:

  • Lua HTTP requests
  • SVG rendering
  • Image encoding

This prevents blocking the async event loop.

Graceful Degradation

If content generation fails, devices receive an error screen rather than nothing. The error message helps debugging while keeping the device functional.

Security Model

Content-Based URLs

Image URLs use content hashes instead of signatures:

  • URL path contains SHA-256 hash of rendered content
  • Same content always produces the same URL
  • No expiration - content is immutable by hash

No Authentication Required

The /api/setup endpoint is open - any device can register. This matches TRMNL’s design where devices self-register.

Script Sandboxing

Lua scripts run in a controlled environment:

  • Only exposed functions are available
  • No filesystem access
  • No arbitrary code execution

Content Pipeline

The content pipeline is how Byonk transforms data into images for e-ink displays. This page explains each stage in detail.

Pipeline Overview

flowchart TD
    A[Lua Script] -->|JSON data| B[SVG Template]
    B -->|SVG document| C[Cache]
    C -->|cached SVG| D[Renderer]
    D -->|dithered pixels| E[E-ink PNG]
StageInputProcessingOutput
Lua ScriptAPI endpoints, paramsFetch data, parse JSON/HTMLStructured data
SVG TemplateData + device contextTera templating, layoutSVG document
CacheSVG documentHash content, storeCached SVG + content hash
RendererCached SVGRasterize, dither to palettePixel buffer
E-ink PNGPixel bufferEncode as greyscale or indexed PNGPalette PNG

Content Change Detection

TRMNL devices use the filename field in the /api/display response to detect content changes. Byonk computes a SHA-256 hash of the rendered SVG content and returns it as the filename. This means:

  • Same content = same filename: If your Lua script returns identical data and the template produces the same SVG, the device knows nothing changed
  • Changed content = new filename: Any change in the rendered SVG (data, template, or device context) produces a new hash

This is why template rendering happens during /api/display rather than /api/image - the hash must be known before the device decides whether to fetch the image.

Stage 1: Lua Script Execution

Lua scripts fetch and process data from external sources.

Input

The script receives a global params table from config.yaml:

# config.yaml
devices:
  "94:A9:90:8C:6D:18":
    screen: transit
    params:
      station: "Olten, Bahnhof"
      limit: 8
-- In your script
local station = params.station  -- "Olten, Bahnhof"
local limit = params.limit      -- 8

Processing

Scripts can:

  • Fetch HTTP data: APIs, web pages, JSON endpoints
  • Parse content: JSON decoding, HTML scraping
  • Transform data: Filter, sort, calculate
local response = http_get("https://api.example.com/data")
local data = json_decode(response)

local filtered = {}
for _, item in ipairs(data.items) do
  if item.active then
    table.insert(filtered, item)
  end
end

Output

Scripts must return a table with two fields:

return {
  data = {
    -- Any structure - passed to template
    title = "My Screen",
    items = filtered,
    updated_at = time_format(time_now(), "%H:%M")
  },
  refresh_rate = 300  -- Seconds until next update
}

Refresh Rate

The refresh_rate controls when the device fetches new content:

  • Low values (30-60s): Real-time data (transit, stocks)
  • Medium values (300-900s): Regular updates (weather, calendar)
  • High values (3600+s): Static content

Tip: Calculate refresh rates dynamically. For transit, refresh after the next departure:

local seconds_until_departure = departure_time - time_now()
return {
  data = departures,
  refresh_rate = seconds_until_departure + 30
}

Stage 2: Template Rendering

SVG templates use Tera syntax (similar to Jinja2).

Input

The template receives a structured context with three namespaces:

Template Namespaces

NamespaceSourceDescription
data.*Lua script data returnYour script’s output
device.*Device headersBattery voltage, RSSI
params.*config.yamlDevice-specific params

Device Context Variables

These are automatically available under device.* (when reported by the device):

VariableTypeDescription
device.battery_voltagefloatBattery voltage (e.g., 4.12)
device.rssiintegerWiFi signal strength in dBm (e.g., -65)
<!-- Show battery voltage in header -->
<text x="780" y="30" text-anchor="end">
  {% if device.battery_voltage %}{{ device.battery_voltage | round(precision=2) }}V{% endif %}
</text>

Note: Device info is also available in Lua scripts via the device global table.

Syntax

Variables:

<text>{{ data.title }}</text>
<text>{{ data.user.name }}</text>
<text>{{ device.battery_voltage }}V</text>
<text>{{ params.station }}</text>

Loops:

{% for item in data.items %}
<text y="{{ 100 + loop.index0 * 30 }}">{{ item.name }}</text>
{% endfor %}

Conditionals:

{% if data.error %}
<text fill="red">{{ data.error }}</text>
{% else %}
<text>All good!</text>
{% endif %}

Built-in Filters

FilterUsageDescription
truncate{{ data.text | truncate(length=30) }}Truncate with ellipsis
format_time{{ data.ts | format_time(format="%H:%M") }}Format Unix timestamp
length{{ data.items | length }}Get array/object length

Output

A complete SVG document ready for rendering.

Stage 3: SVG to PNG Conversion

The renderer converts SVG to a PNG optimized for e-ink displays.

Font Handling

  1. Custom fonts from fonts/ directory (loaded first)
  2. System fonts as fallback
  3. Variable fonts supported via CSS font-variation-settings
<style>
  .title {
    font-family: Outfit;
    font-variation-settings: "wght" 700;
  }
</style>

Scaling

SVGs are scaled to fit the display while maintaining aspect ratio:

  • TRMNL OG: 800 × 480 pixels
  • TRMNL X: 1872 × 1404 pixels

The image is centered if the aspect ratio doesn’t match exactly.

Palette-Aware Dithering

E-ink displays support a limited color palette (typically 4 grey levels, but also color palettes like black/white/red/yellow). Dithering creates the illusion of more shades by distributing quantization error to neighboring pixels.

Byonk uses the eink-dither engine which performs color matching in the perceptually uniform Oklab color space and processes pixels in gamma-correct linear RGB. This produces more accurate color reproduction than naive RGB-space dithering.

Dither Algorithms

Byonk supports 9 dithering algorithms, selectable per-device or per-script via the dither option:

AlgorithmValueBest for
Atkinson (default)"atkinson"General-purpose, good for small palettes
Atkinson Hybrid"atkinson-hybrid"Chromatic palettes (fixes color drift)
Floyd-Steinberg"floyd-steinberg"General-purpose, smooth gradients
Jarvis-Judice-Ninke"jarvis-judice-ninke"Sparse chromatic palettes (least oscillation)
Sierra"sierra"Good quality/speed balance
Sierra Two-Row"sierra-two-row"Lighter weight error diffusion
Sierra Lite"sierra-lite"Fastest error diffusion
Stucki"stucki"Wide kernel similar to JJN
Burkes"burkes"Good balance of speed and quality

All error diffusion algorithms use blue noise jitter to break “worm” artifacts. Color matching is performed in perceptually uniform Oklab space with gamma-correct linear RGB processing.

Set the dither mode per-device in config.yaml:

devices:
  "ABCDE-FGHJK":
    screen: gphoto
    dither: photo

Or per-script by returning dither in the Lua result table:

return {
  data = { ... },
  refresh_rate = 300,
  dither = "photo"
}

The priority chain is: dev UI override > script dither > device config dither > default (graphics).

Dither Tuning

Fine-tune dithering behavior with these parameters, settable at multiple levels:

ParameterDescriptionTypical range
error_clampLimits error diffusion amplitude. Lower values reduce oscillation.0.05 – 0.5
noise_scaleBlue noise jitter scale. Higher values break worm artifacts more aggressively.0.3 – 1.0
chroma_clampLimits chromatic error propagation. Prevents color bleeding.0.5 – 5.0
strengthScales diffused error before propagation. 0.0 = no diffusion, 1.0 = standard.0.0 – 2.0

Use dev mode to find optimal values interactively, then commit them to your panel profile, device config, or Lua script for production use.

Priority chain: dev UI override > script return > device config > panel dither defaults > algorithm defaults.

Panel dither defaults are especially useful because optimal tuning is usually tied to the color palette (which is tied to the panel). Set them once in the panel profile and every device using that panel inherits good defaults. See Panel Dither Defaults for the config format.

Output Format

The final PNG format is chosen automatically based on the palette:

  • Grey palette (≤4 colors): Native 2-bit greyscale PNG (4 pixels per byte)
  • Grey palette (5-16 colors): Native 4-bit greyscale PNG (2 pixels per byte)
  • Color palette: Indexed PNG with PLTE chunk (bit depth chosen by palette size)
  • Size validated against device limits (90KB for OG, 750KB for X)

Error Handling

If any stage fails, Byonk generates an error screen:

<svg>
  <rect fill="white" stroke="red" stroke-width="5"/>
  <text>Error: Failed to fetch data</text>
  <text>Will retry in 60 seconds</text>
</svg>

This ensures:

  • Device always receives valid content
  • Error is visible for debugging
  • Automatic retry on next refresh

Performance Considerations

What’s Fast

  • Lua script execution (milliseconds)
  • Template rendering (milliseconds)
  • Simple SVG rendering (10-50ms)

What’s Slower

  • HTTP requests (network dependent)
  • Complex SVG with many elements (100-500ms)
  • Large images or gradients

Optimization Tips

  1. Minimize HTTP calls - Cache data in script if possible
  2. Simplify SVG - Fewer elements = faster rendering
  3. Avoid gradients - They’re converted to dithered patterns anyway
  4. Use appropriate refresh rates - Don’t refresh more often than needed

Device Mapping

Byonk allows you to show different content on different TRMNL devices. This page explains how devices are identified, registered, and mapped to screens.

How Devices Are Identified

Each TRMNL device has a unique MAC address that identifies it. This address is sent in the ID header with every request:

ID: 94:A9:90:8C:6D:18

Byonk uses this MAC address to:

  1. Register new devices
  2. Look up existing device configuration
  3. Map devices to screens

Device Registration Flow

sequenceDiagram
    participant Device
    participant Byonk

    Device->>Byonk: GET /api/setup<br/>Headers: ID, FW-Version, Model
    Byonk-->>Device: {api_key, friendly_id}
    Note right of Device: Store api_key

    Device->>Byonk: GET /api/display<br/>Headers: Access-Token, ID
    Byonk-->>Device: {image_url, refresh_rate}

Setup Response

{
  "status": 200,
  "api_key": "a1b2c3d4e5f6...",
  "friendly_id": "abc123def456"
}
  • api_key: Authentication token for subsequent requests
  • friendly_id: Human-readable identifier (12 hex characters)

Configuration-Based Mapping

Devices are mapped to screens in config.yaml:

devices:
  "94:A9:90:8C:6D:18":
    screen: transit
    params:
      station: "Olten, Bahnhof"

  "AA:BB:CC:DD:EE:FF":
    screen: weather
    params:
      city: "Zurich"

default_screen: default

Lookup Order

When a device requests content:

  1. Exact MAC match - Check if MAC is in devices section
  2. Default screen - Use default_screen if no match
  3. Error - Return error if no default configured

MAC Address Format

MAC addresses in config must be:

  • Uppercase: "94:A9:90:8C:6D:18" not "94:a9:90:8c:6d:18"
  • Colon-separated: "94:A9:90:8C:6D:18" not "94-A9-90-8C-6D-18"
  • Quoted: YAML requires quotes for strings with colons

Device Parameters

See Configuration — Parameters for details on parameter types and usage.

Finding Your Device’s MAC Address

The MAC address is shown:

  1. In Byonk logs when the device connects:

    INFO Device registered device_id="94:A9:90:8C:6D:18"
    
  2. On the device during setup (check TRMNL documentation)

  3. In your router’s connected devices list

Default Screen

The default_screen provides a fallback for:

  • Devices not yet configured
  • New devices during testing
  • Backup if config is incorrect
default_screen: default

If no default_screen is set and a device isn’t in the config, it receives an error response.

Auto-Registration

Byonk automatically registers new devices on their first /api/setup call:

  1. Generates a random API key (32-character hex string)
  2. Derives a registration code from the key
  3. Stores device in registry

No pre-configuration is needed - just add the device to config.yaml to assign a custom screen.

Device Registration (Security Feature)

For enhanced security, Byonk supports device registration — requiring new devices to be explicitly approved before showing content.

See Configuration — Device Registration for full setup instructions, registration code format, custom registration screens, and migration notes.

Multiple Screens per Device?

Currently, each device shows one screen. However, you can create a “dashboard” screen that combines multiple data sources:

-- dashboard.lua
local weather = fetch_weather()
local transit = fetch_transit()
local calendar = fetch_calendar()

return {
  data = {
    weather = weather,
    transit = transit,
    calendar = calendar
  },
  refresh_rate = 300
}

Device Metadata

Byonk tracks additional device information from request headers:

HeaderDescription
FW-VersionFirmware version
ModelDevice model (og, x)
Battery-VoltageBattery level
RSSIWiFi signal strength
Width, HeightDisplay dimensions

This metadata is stored in the device registry and can be used for:

  • Debugging connectivity issues
  • Monitoring battery levels
  • Adapting content to device model

Persistence

Warning: The current implementation stores device registrations in memory. Registrations are lost on server restart.

Devices will automatically re-register on their next request, but any collected metadata is lost.

Future versions may add database persistence for device data.

Tutorial

This tutorial series will teach you how to create custom screens for your TRMNL device using Byonk. You’ll learn:

  1. Your First Screen - Create a simple “Hello World” screen
  2. Lua Scripting - Fetch data from APIs and process it
  3. SVG Templates - Design beautiful layouts
  4. Advanced Topics - HTML scraping, dynamic refresh, error handling

Prerequisites

Before starting, make sure you have:

  • Byonk installed and running
  • A text editor for writing Lua and SVG files
  • Basic familiarity with programming concepts

Example Screens

Byonk comes with several example screens you can learn from:

Default Screen

A simple clock display showing time and date.

screens/default.lua   - Script
screens/default.svg   - Template

Transit Departures

Real-time public transport departures from Swiss OpenData.

screens/transit.lua   - Fetches from transport.opendata.ch API
screens/transit.svg   - Displays departure list with colors

Room Booking (Floerli)

Scrapes a web page to show room availability.

screens/floerli.lua   - HTML scraping example
screens/floerli.svg   - Shows current/upcoming bookings

Display Color Test

Demonstrates the display palette colors available on e-ink.

screens/graytest.lua  - Adapts to device palette
screens/graytest.svg  - Shows palette color swatches and dithering test

Quick Reference

File Locations

TypeLocation
Lua scriptsscreens/*.lua
SVG templatesscreens/*.svg
Configurationconfig.yaml
Custom fontsfonts/

Workflow

  1. Create a Lua script and SVG template in screens/
  2. Define a screen in config.yaml
  3. Assign the screen to a device
  4. Test by refreshing your device or checking /swagger-ui

Tip: Lua scripts and SVG templates are loaded fresh on every request. Just save your changes and refresh!

Ready to Start?

Head to Your First Screen to create your first custom display!

Your First Screen

Let’s create a simple screen that displays a greeting and the current time. This will introduce you to the basic workflow of creating Byonk screens.

Step 0: Set Up Your Workspace

Byonk embeds all assets in the binary. To customize screens, you must set environment variables pointing to external directories.

For binary users:

# Set paths and start server (auto-seeds empty directories)
export SCREENS_DIR=./screens
export CONFIG_FILE=./config.yaml
byonk serve

For Docker users:

docker run -d --pull always -p 3000:3000 \
  -e SCREENS_DIR=/data/screens \
  -e CONFIG_FILE=/data/config.yaml \
  -v ./data:/data \
  ghcr.io/oetiker/byonk

On first run, empty directories are automatically populated with defaults. You can then edit the files in screens/ and config.yaml.

Tip: Keep the server running in a terminal. Lua scripts and SVG templates are reloaded on every request - just save and refresh!

Step 1: Create the Lua Script

Create a new file screens/hello.lua:

-- Hello World screen
-- Displays a greeting with the current time

local now = time_now()

return {
  data = {
    greeting = "Hello, World!",
    time = time_format(now, "%H:%M:%S"),
    date = time_format(now, "%A, %B %d, %Y")
  },
  refresh_rate = 60  -- Refresh every minute
}

What this does:

  • time_now() gets the current Unix timestamp
  • time_format() formats it into readable strings
  • The returned data table is passed to the template
  • refresh_rate tells the device to check back in 60 seconds

Step 2: Create the SVG Template

Create a new file screens/hello.svg:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <style>
    .greeting {
      font-family: Outfit, sans-serif;
      font-size: 48px;
      font-weight: 700;
      fill: black;
    }
    .time {
      font-family: Outfit, sans-serif;
      font-size: 72px;
      font-weight: 700;
      fill: black;
    }
    .date {
      font-family: Outfit, sans-serif;
      font-size: 24px;
      font-weight: 400;
      fill: #555;
    }
    .footer {
      font-family: Outfit, sans-serif;
      font-size: 14px;
      font-weight: 400;
      fill: #999;
    }
  </style>

  <!-- White background -->
  <rect width="800" height="480" fill="white"/>

  <!-- Greeting -->
  <text class="greeting" x="400" y="120" text-anchor="middle">
    {{ data.greeting }}
  </text>

  <!-- Large time display -->
  <text class="time" x="400" y="260" text-anchor="middle">
    {{ data.time }}
  </text>

  <!-- Date below -->
  <text class="date" x="400" y="320" text-anchor="middle">
    {{ data.date }}
  </text>

  <!-- Footer -->
  <text class="footer" x="400" y="450" text-anchor="middle">
    My first Byonk screen!
  </text>
</svg>

Template features used:

  • {{ data.variable }} - Inserts values from the Lua script’s data table
  • CSS styling for fonts and colors
  • text-anchor="middle" for centered text

Step 3: Add the Screen to Configuration

Edit config.yaml to add your new screen:

screens:
  # ... existing screens ...

  hello:
    script: hello.lua
    template: hello.svg
    default_refresh: 60

Step 4: Assign to a Device

Still in config.yaml, assign the screen to your device:

devices:
  "YOUR:MAC:AD:DR:ES:S0":
    screen: hello
    params: {}

Replace YOUR:MAC:AD:DR:ES:S0 with your device’s actual MAC address.

Tip: Check the Byonk server logs when your device connects - the MAC address is printed there.

Step 5: Test It

  1. Restart Byonk (config.yaml changes require restart)

  2. Check the API at http://localhost:3000/swagger-ui:

    • Use the /api/display endpoint with your device’s MAC
    • You’ll get an image URL with a content hash
    • Open that URL to see your screen!
  3. Or wait for your device to refresh automatically

Adding Parameters

Let’s make the greeting customizable. Update your files:

screens/hello.lua:

local now = time_now()

-- Get name from params, default to "World"
local name = params.name or "World"

return {
  data = {
    greeting = "Hello, " .. name .. "!",
    time = time_format(now, "%H:%M:%S"),
    date = time_format(now, "%A, %B %d, %Y")
  },
  refresh_rate = 60
}

config.yaml:

devices:
  "YOUR:MAC:AD:DR:ES:S0":
    screen: hello
    params:
      name: "Alice"

Now your screen will say “Hello, Alice!” instead of “Hello, World!”.

Adding a QR Code

Let’s add a QR code to the screen that links to documentation. QR codes are useful for providing quick access to related content.

Update screens/hello.lua:

local now = time_now()
local name = params.name or "World"

return {
  data = {
    greeting = "Hello, " .. name .. "!",
    time = time_format(now, "%H:%M:%S"),
    date = time_format(now, "%A, %B %d, %Y"),
    -- Generate a QR code anchored to bottom-right corner with 10px margin
    qr_code = qr_svg("https://www.youtube.com/watch?v=dQw4w9WgXcQ", {
      anchor = "bottom-right",
      right = 10,
      bottom = 10,
      module_size = 4
    })
  },
  refresh_rate = 60
}

Update screens/hello.svg to include the QR code:

<!-- Add before the closing </svg> tag -->

<!-- QR Code - use 'safe' filter to render SVG -->
{{ data.qr_code | safe }}

The qr_svg() function generates pixel-aligned QR codes optimized for e-ink displays. Use anchor to specify which corner, and top/left/right/bottom for margins from that edge:

AnchorMargin options
top-lefttop, left
top-righttop, right
bottom-leftbottom, left
bottom-rightbottom, right
center(centered on screen)

All options:

qr_svg("https://example.com", {
  anchor = "bottom-right", -- Which corner (default: "top-left")
  right = 10,              -- Margin from right edge in pixels
  bottom = 10,             -- Margin from bottom edge in pixels
  module_size = 4,         -- QR "pixel" size (default: 4, recommended: 3-6)
  ec_level = "M",          -- Error correction: L/M/Q/H (default: M)
  quiet_zone = 4           -- QR quiet zone in modules (default: 4)
})

Tip: Use the | safe filter in templates to render SVG content without escaping.

Understanding the Result

Your screen should look like this:

Hello World screen

Troubleshooting

Screen shows error

Check the Byonk logs for script errors:

byonk serve
# Look for ERROR or WARN lines

Template variables not replaced

Make sure your Lua script returns a data table with the expected keys:

return {
  data = {
    greeting = "Hello"  -- Must match {{ greeting }} in template
  },
  refresh_rate = 60
}

Device not updating

  • Check that the device MAC in config matches exactly (uppercase, with colons)
  • Verify the device is pointing to your Byonk server
  • Check device WiFi connectivity

Real-World Example: Transit Departures

Here’s what a more complex screen looks like - the built-in transit departure display:

Transit departures screen

This screen demonstrates:

  • Fetching live data from an API
  • Processing JSON responses
  • Dynamic refresh rates (updates after each bus departs)
  • Styled table layout with alternating rows
  • Color-coded line badges

Check out screens/transit.lua and screens/transit.svg in the Byonk source for the complete implementation.

What’s Next?

Now that you have a basic screen working, learn more about:

Lua Scripting

Lua scripts are the data engine of Byonk screens. They fetch, process, and transform data before it’s rendered by the template. This guide covers all the APIs available to your scripts.

Script Structure

Every Lua script must return a table with data and refresh_rate:

-- Optional: Use params from config.yaml
local my_param = params.some_key or "default"

-- Your logic here
local result = do_something()

-- Required: Return data for template
return {
  data = {
    -- Passed to SVG template
  },
  refresh_rate = 300  -- Seconds until next refresh
}

Parameters

Device-specific parameters are available via the global params table:

# config.yaml
devices:
  "94:A9:90:8C:6D:18":
    screen: weather
    params:
      city: "Zurich"
      units: "metric"
-- In your script
local city = params.city        -- "Zurich"
local units = params.units      -- "metric"
local missing = params.other    -- nil (not defined)

-- Always provide defaults
local limit = params.limit or 10

HTTP Requests

http_get(url)

Fetches a URL and returns the response body as a string.

local response = http_get("https://api.example.com/data")

Error handling:

local ok, response = pcall(function()
  return http_get("https://api.example.com/data")
end)

if not ok then
  log_error("Request failed: " .. tostring(response))
  return {
    data = { error = "Failed to fetch data" },
    refresh_rate = 60
  }
end

URL encoding:

local city = "Zürich, Schweiz"
local encoded = city:gsub(" ", "%%20"):gsub(",", "%%2C")
local url = "https://api.example.com/city?name=" .. encoded

JSON

json_decode(string)

Parses a JSON string into a Lua table.

local response = http_get("https://api.example.com/data")
local data = json_decode(response)

-- Access fields
local name = data.name
local items = data.items
local first = data.items[1]  -- Lua arrays are 1-indexed!

json_encode(table)

Converts a Lua table to a JSON string.

local data = { name = "test", values = {1, 2, 3} }
local json_str = json_encode(data)
-- '{"name":"test","values":[1,2,3]}'

HTML Parsing

For scraping web pages, Byonk provides CSS selector-based HTML parsing.

html_parse(html)

Parses an HTML string and returns a document object.

local html = http_get("https://example.com")
local doc = html_parse(html)

doc:select(selector)

Queries elements using CSS selectors. Returns an elements collection.

local links = doc:select("a.nav-link")
local rows = doc:select("table.data tr")
local header = doc:select("h1")

doc:select_one(selector)

Returns only the first matching element (or nil).

local title = doc:select_one("title")
if title then
  log_info("Page title: " .. title:text())
end

elements:each(fn)

Iterates over matched elements.

local items = {}
doc:select("ul.list li"):each(function(el)
  table.insert(items, {
    text = el:text(),
    link = el:attr("href")
  })
end)

element:text()

Gets the inner text content.

local heading = doc:select_one("h1")
local text = heading:text()  -- "Welcome to Example"

element:attr(name)

Gets an attribute value.

local link = doc:select_one("a")
local href = link:attr("href")   -- "https://..."
local class = link:attr("class") -- "nav-link"

element:html()

Gets the inner HTML.

local div = doc:select_one("div.content")
local inner_html = div:html()

Example: Scraping a Table

local html = http_get("https://example.com/data")
local doc = html_parse(html)

local rows = {}
doc:select("table tbody tr"):each(function(row)
  local cells = {}
  row:select("td"):each(function(cell)
    table.insert(cells, cell:text())
  end)

  if #cells >= 2 then
    table.insert(rows, {
      name = cells[1],
      value = cells[2]
    })
  end
end)

return {
  data = { rows = rows },
  refresh_rate = 900
}

Time Functions

time_now()

Returns the current Unix timestamp (seconds since 1970).

local now = time_now()  -- e.g., 1703672400

time_format(timestamp, format)

Formats a timestamp into a string using strftime patterns.

local now = time_now()

time_format(now, "%H:%M")        -- "14:32"
time_format(now, "%H:%M:%S")     -- "14:32:05"
time_format(now, "%Y-%m-%d")     -- "2024-12-27"
time_format(now, "%A")           -- "Friday"
time_format(now, "%B %d, %Y")    -- "December 27, 2024"

Common format codes:

CodeDescriptionExample
%HHour (24h)14
%MMinute32
%SSecond05
%YYear2024
%mMonth12
%dDay27
%AWeekday nameFriday
%BMonth nameDecember
%aShort weekdayFri
%bShort monthDec

time_parse(string, format)

Parses a date string into a Unix timestamp.

local ts = time_parse("2024-12-27 14:30", "%Y-%m-%d %H:%M")

Logging

Write messages to the Byonk server logs.

log_info("Processing request for station: " .. station)
log_warn("API returned empty response")
log_error("Failed to parse JSON: " .. err)

Logs appear in the server output:

INFO script=true: Processing request for station: Olten
WARN script=true: API returned empty response
ERROR script=true: Failed to parse JSON: unexpected token

Complete Example: Transit API

Here’s a real-world example fetching transit data:

-- transit.lua - Fetch public transport departures

local station = params.station or "Olten"
local limit = params.limit or 8

log_info("Fetching departures for: " .. station)

-- URL encode the station name
local encoded = station:gsub(" ", "%%20"):gsub(",", "%%2C")
local url = "https://transport.opendata.ch/v1/stationboard"
      .. "?station=" .. encoded
      .. "&limit=" .. limit

-- Fetch with error handling
local ok, response = pcall(function()
  return http_get(url)
end)

if not ok then
  log_error("API request failed: " .. tostring(response))
  return {
    data = {
      station = station,
      error = "Failed to fetch departures",
      departures = {}
    },
    refresh_rate = 60
  }
end

-- Parse JSON
local json = json_decode(response)

-- Transform data for template
local departures = {}
local now = time_now()

for i, dep in ipairs(json.stationboard or {}) do
  local departure_time = dep.stop and dep.stop.departure or ""
  local hour, min = departure_time:match("T(%d+):(%d+)")

  table.insert(departures, {
    time = hour and (hour .. ":" .. min) or "??:??",
    line = (dep.category or "") .. (dep.number or ""),
    destination = dep.to or "Unknown",
    delay = dep.stop and dep.stop.delay or 0
  })
end

-- Calculate smart refresh rate
local refresh_rate = 300
if #departures > 0 and json.stationboard[1].stop then
  local first_dep = json.stationboard[1].stop.departureTimestamp
  if first_dep then
    local seconds_until = first_dep - now
    refresh_rate = math.max(30, math.min(seconds_until + 30, 900))
  end
end

log_info("Found " .. #departures .. " departures, refresh in " .. refresh_rate .. "s")

return {
  data = {
    station = json.station and json.station.name or station,
    departures = departures,
    updated_at = time_format(now, "%H:%M")
  },
  refresh_rate = refresh_rate
}

Tips & Best Practices

Always Handle Errors

local ok, result = pcall(function()
  return http_get(url)
end)

if not ok then
  return { data = { error = "..." }, refresh_rate = 60 }
end

Provide Default Values

local limit = params.limit or 10
local show_delays = params.show_delays or true

Log for Debugging

log_info("Params: " .. json_encode(params))
log_info("Fetched " .. #items .. " items")

Keep It Simple

Scripts run on every request. Avoid:

  • Complex computations
  • Multiple HTTP requests when one will do
  • Parsing more data than needed

Use Smart Refresh Rates

Don’t refresh more often than necessary:

-- Real-time data: 30-60 seconds
-- Regular updates: 300-900 seconds
-- Static content: 3600+ seconds

Next Steps

SVG Templates

SVG templates define the visual layout of your screens. They use Tera templating syntax to insert data from your Lua scripts.

Template Basics

A Byonk SVG template is a standard SVG file with Tera expressions:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <rect width="800" height="480" fill="white"/>

  <text x="400" y="240" text-anchor="middle" font-size="24">
    {{ message }}
  </text>
</svg>

Key points:

  • Set viewBox to 0 0 800 480 for TRMNL OG (or 0 0 1872 1404 for TRMNL X)
  • Always include width and height attributes
  • Use {{ variable }} to insert values from Lua

Display Dimensions

DeviceWidthHeightAspect Ratio
TRMNL OG8004805:3
TRMNL X187214044:3

Byonk automatically scales your SVG to fit the display, but matching the aspect ratio gives the best results.

Variables

Template Namespaces

Variables in templates are organized into four namespaces:

NamespaceSourceExample
data.*Lua script return valuedata.title, data.items
device.*Device info (battery, signal)device.battery_voltage, device.rssi
params.*Config params from config.yamlparams.station, params.limit
layout.*Pre-computed layout valueslayout.width, layout.grey_count

Device Variables

These are automatically available under device.*:

VariableTypeDescription
device.macstringDevice MAC address (e.g., “AC:15:18:D4:7B:E2”)
device.battery_voltagefloat or nilBattery voltage (e.g., 4.12)
device.rssiinteger or nilWiFi signal strength in dBm (e.g., -65)
device.modelstring or nilDevice model (“og” or “x”)
device.firmware_versionstring or nilFirmware version string
device.widthinteger or nilDisplay width in pixels (800 or 1872)
device.heightinteger or nilDisplay height in pixels (480 or 1404)
<!-- Display battery and signal in header -->
<text class="status" x="780" y="25" text-anchor="end">
  {% if device.battery_voltage %}{{ device.battery_voltage | round(precision=2) }}V{% endif %}
  {% if device.rssi %} · {{ device.rssi }}dBm{% endif %}
</text>

<!-- Responsive layout based on device dimensions -->
{% if device.width == 1872 %}
  <!-- TRMNL X layout (1872x1404) -->
{% else %}
  <!-- TRMNL OG layout (800x480) -->
{% endif %}

Note: Some device variables may be nil if the device doesn’t report them. Always use {% if device.variable %} to check before using.

Layout Variables

Pre-computed layout values are available under layout.*. These mirror the layout table available in Lua scripts:

VariableTypeDescription
layout.widthintegerDisplay width in pixels (default 800)
layout.heightintegerDisplay height in pixels (default 480)
layout.scalefloatScale factor relative to 800×480 base
layout.center_xintegerHorizontal center (width / 2)
layout.center_yintegerVertical center (height / 2)
layout.marginintegerStandard margin (20px × scale)
layout.margin_smintegerSmall margin (10px × scale)
layout.margin_lgintegerLarge margin (40px × scale)
layout.colorsarrayDisplay color palette (hex strings)
layout.color_countintegerNumber of colors in palette (default 4)
layout.grey_countintegerNumber of grey levels in palette (default 4)

This is useful for conditional logic in SVG templates without needing Lua to pass the values through — for example, the components/hinting.svg include uses layout.grey_count to switch between mono and smooth font hinting.

Basic Interpolation

<text>{{ data.title }}</text>
<text>{{ data.user.name }}</text>
<text>{{ data.items[0].label }}</text>

Filters

Apply filters to modify values:

<!-- Truncate long text -->
<text>{{ data.description | truncate(length=50) }}</text>

<!-- Format timestamp (uses UTC) -->
<text>{{ data.updated_at | format_time(format="%H:%M") }}</text>

<!-- Get length -->
<text>{{ data.items | length }} items</text>

Tip: The format_time template filter uses UTC timezone. For local time formatting, use time_format() in your Lua script and pass the pre-formatted string to the template.

Default Values

<text>{{ data.title | default(value="Untitled") }}</text>

Control Flow

Conditionals

{% if data.error %}
  <text fill="red">Error: {{ data.error }}</text>
{% else %}
  <text>All systems operational</text>
{% endif %}

Comparisons

{% if data.count > 0 %}
  <text>{{ data.count }} items</text>
{% elif data.count == 0 %}
  <text>No items</text>
{% endif %}

{% if data.status == "active" %}
  <circle fill="green" r="10"/>
{% endif %}

Boolean Checks

{% if data.is_online %}
  <text fill="green">Online</text>
{% endif %}

{% if not data.items %}
  <text>No data available</text>
{% endif %}

Loops

Basic Loop

{% for item in data.items %}
  <text y="{{ 100 + loop.index0 * 30 }}">{{ item.name }}</text>
{% endfor %}

Loop Variables

VariableDescription
loop.indexCurrent iteration (1-indexed)
loop.index0Current iteration (0-indexed)
loop.firstTrue on first iteration
loop.lastTrue on last iteration

Positioning with Loops

{% for dep in data.departures %}
  <!-- Calculate Y position based on index -->
  <text y="{{ 80 + loop.index0 * 40 }}">
    {{ dep.time }} - {{ dep.destination }}
  </text>
{% endfor %}

Conditional Styling in Loops

{% for item in data.items %}
  <!-- Alternating row backgrounds -->
  {% if loop.index0 is odd %}
    <rect y="{{ 100 + loop.index0 * 40 }}" width="800" height="40" fill="#f5f5f5"/>
  {% endif %}

  <text y="{{ 125 + loop.index0 * 40 }}">{{ item.name }}</text>
{% endfor %}

Empty State

{% if data.items | length > 0 %}
  {% for item in data.items %}
    <text>{{ item.name }}</text>
  {% endfor %}
{% else %}
  <text fill="#999">No items found</text>
{% endif %}

Styling

Inline Styles

<text x="20" y="40"
      font-family="sans-serif"
      font-size="24"
      font-weight="bold"
      fill="black">
  {{ data.title }}
</text>

CSS in Style Block

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
  <style>
    .title { font-family: sans-serif; font-size: 32px; font-weight: bold; }
    .subtitle { font-family: sans-serif; font-size: 18px; fill: #666; }
    .highlight { fill: #333; font-weight: bold; }
  </style>

  <text class="title" x="20" y="40">{{ data.title }}</text>
  <text class="subtitle" x="20" y="70">{{ data.subtitle }}</text>
</svg>

Variable Fonts

Byonk supports variable fonts via CSS font-variation-settings:

<style>
  .light { font-family: Outfit; font-variation-settings: "wght" 300; }
  .regular { font-family: Outfit; font-variation-settings: "wght" 400; }
  .bold { font-family: Outfit; font-variation-settings: "wght" 700; }
</style>

Note: Place custom font files (e.g., Outfit-Variable.ttf) in the fonts/ directory.

Colors and Palettes

E-ink displays support a limited color palette. The default 4-grey OG palette is #000000, #555555, #AAAAAA, #FFFFFF, but color displays may have palettes like #000000, #FFFFFF, #FF0000, #FFFF00. The display palette is available in Lua via layout.colors.

The palette follows a priority chain: Lua script colors return > device config colors > firmware Colors header > system default. You can override the palette per-device in config.yaml:

devices:
  "ABCDE-FGHJK":
    screen: transit
    colors: "#000000,#FFFFFF,#FF0000"

Or per-script by returning colors in the Lua result table (see Lua API: colors).

Dithering Mode

Byonk supports two dithering modes via the dither option:

  • graphics (default) — Blue noise ordered dithering, best for UI content
  • photo — Atkinson error diffusion, best for photographs

Set per-device in config.yaml or per-script in the Lua return table (see Lua API: dither and Content Pipeline: Rendering Intents).

Using Palette Colors

For the cleanest output, use colors from the display palette directly. Byonk will dither any color to the nearest palette color, but exact palette matches are preserved without dithering.

-- In your Lua script
local colors = layout.colors  -- e.g., {"#000000", "#555555", "#AAAAAA", "#FFFFFF"}
<!-- Use palette colors for crisp rendering -->
{% for swatch in data.swatches %}
<rect x="{{ swatch.x }}" width="{{ swatch.width }}" height="100" fill="{{ swatch.color }}"/>
{% endfor %}

Testing Display Colors

The included graytest screen adapts to the device palette and shows all available colors as swatches with gradient and dithering tests.

4-grey palette (TRMNL OG default):

Display color test — 4 grey

6-color palette (color e-ink display):

Display color test — 6 color

The default screen also adapts to the palette:

Default screen — 6 color

Avoid

  • Gradients - Convert to dithered patterns (may look noisy)
  • Subtle color differences - May become indistinguishable on limited palettes
  • Colors not in palette - Will be dithered to nearest match

Font Rendering for E-ink

Byonk uses a patched version of resvg for SVG rendering, which adds font hinting support with custom CSS properties for fine-tuning. Getting these right makes a big difference on e-ink displays, where there are few (or no) gray levels to smooth out font edges.

Available Properties

PropertyValuesDefaultDescription
-resvg-hinting-targetsmooth, monosmoothmono for 1-bit displays, smooth for displays with gray levels
-resvg-hinting-modenormal, light, lcd, vertical-lcdnormalHinting strength. normal = strongest grid-fitting, light = softer
-resvg-hinting-engineauto-fallback, auto, nativeauto-fallbackauto uses FreeType’s auto-hinter (more consistent), native uses the font’s built-in hints
-resvg-hinting-symmetrictrue, falsetrueSymmetric rasterization. false can improve consistency at small sizes
-resvg-hinting-preserve-linear-metricstrue, falsefalsetrue forces uniform glyph spacing
shape-renderingauto, crispEdges, geometricPrecisionautocrispEdges disables anti-aliasing on shapes and lines
text-renderingauto, optimizeSpeed, optimizeLegibility, geometricPrecisionautoHint for text rendering quality

1-bit display (black & white only):

text {
  -resvg-hinting-target: mono;
  -resvg-hinting-mode: normal;
  -resvg-hinting-engine: auto;
  -resvg-hinting-symmetric: false;
  -resvg-hinting-preserve-linear-metrics: true;
  shape-rendering: crispEdges;
}

4 gray levels:

text {
  -resvg-hinting-target: smooth;
  -resvg-hinting-mode: normal;
  -resvg-hinting-engine: auto;
  shape-rendering: crispEdges;
}

16 gray levels:

text {
  -resvg-hinting-target: smooth;
  -resvg-hinting-mode: light;
  -resvg-hinting-engine: auto;
}

Adaptive Hinting

The components/hinting.svg include automatically applies the right hinting settings based on the display’s grey levels:

<style>
  text {
    {% include "components/hinting.svg" %}
  }
</style>

On black-and-white displays (grey_count <= 2) it enables mono hinting with crispEdges; on displays with more grey levels it uses smooth hinting. All built-in screens use this component.

Hinting Demo Screen

The built-in hintdemo screen provides a visual comparison of all hinting engine and target combinations in a 3×3 grid:

  • Columns: mono, normal (smooth), light (smooth)
  • Rows: auto engine, native engine, no hinting

Enable it in your config.yaml to see how different settings affect text rendering at various font sizes on your actual display.

Hinting demo screen

Tips

  • Use the auto engine. The FreeType auto-hinter applies a consistent algorithm to all glyphs. Native font hints vary in quality and can produce inconsistent letterforms (e.g., the same letter rendering differently at the same size).
  • Choose font sizes that land on whole pixel boundaries. Fractional pixel heights cause glyphs to snap to the grid differently, producing inconsistent shapes.
  • shape-rendering: crispEdges eliminates anti-aliased edges on lines and rectangles — important when there are few gray levels to work with.
  • Test on your actual display. Optimal settings depend on the font, font size, and display capabilities. The presets above are starting points.

Bitmap Fonts

Byonk ships with X11 bitmap fonts converted to TTF files. These contain embedded bitmap strikes — pre-rendered glyphs at specific pixel sizes — which produce perfectly crisp text on e-ink displays without any hinting artifacts.

Available Families

Proportional fonts:

FamilyStylesPixel Sizes
X11HelvRegular, Bold, Oblique, BoldOblique8, 10, 11, 12, 14, 17, 18, 20, 24, 25, 34
X11LuSansRegular, Bold, Oblique, BoldOblique8–34 (13 sizes)
X11LuTypeRegular, Bold8–34 (13 sizes)
X11TermRegular, Bold14, 18

Fixed-width fonts (grouped by cell width):

FamilyStylesPixel Sizes
X11Misc5xRegular6, 7, 8
X11Misc6xRegular, Bold, Oblique9, 10, 12, 13
X11Misc7xRegular, Bold, Oblique13, 14
X11Misc8xRegular, Bold, Oblique13, 16
X11Misc9xRegular, Bold15, 18
X11Misc10xRegular20
X11Misc12xRegular24

Usage

Set font-family to the family name and font-size to a pixel size that matches a bitmap strike. The renderer automatically selects the closest strike:

<text font-family="X11Helv" font-size="14">Proportional text</text>
<text font-family="X11Helv" font-size="14" font-weight="700">Bold</text>
<text font-family="X11Misc7x" font-size="13">Fixed width</text>
<text font-family="X11Misc7x" font-size="13" font-style="oblique">Fixed oblique</text>

For sizes without an exact bitmap strike, autotraced scalable outlines are used as fallback — but these won’t look as clean as the native bitmap sizes.

Bitmap Font Demo Screen

The built-in fontdemo-bitmap screen showcases all sizes and styles for a given font family. Configure it with the font_prefix parameter:

screens:
  fontdemo-bitmap:
    params:
      font_prefix: X11Helv   # or X11LuSans, X11LuType, X11Term, X11Misc

This renders each available size and style combination as a labeled line, useful for picking the right font and size for your screen.

Bitmap font demo - X11Helv

Bitmap font demo - X11Misc

Layout Patterns

Header + Content

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
  <!-- Header bar -->
  <rect width="800" height="70" fill="black"/>
  <text x="30" y="48" fill="white" font-size="28">{{ data.title }}</text>
  <text x="770" y="48" fill="#aaa" font-size="14" text-anchor="end">{{ data.time }}</text>

  <!-- Content area -->
  <g transform="translate(0, 70)">
    <!-- Your content here, Y coordinates start at 0 -->
  </g>
</svg>

Grid Layout

{% for item in data.items %}
  {% set col = loop.index0 % 3 %}
  {% set row = loop.index0 // 3 %}

  <rect x="{{ col * 266 }}" y="{{ 80 + row * 100 }}"
        width="260" height="90" fill="#f0f0f0" rx="5"/>
  <text x="{{ col * 266 + 130 }}" y="{{ 130 + row * 100 }}"
        text-anchor="middle">{{ item.name }}</text>
{% endfor %}

Two Columns

<!-- Left column -->
<text x="30" y="100">Left content</text>

<!-- Divider -->
<line x1="400" y1="80" x2="400" y2="450" stroke="#ccc"/>

<!-- Right column -->
<text x="430" y="100">Right content</text>

Dynamic Styling

Conditional Colors

{% for item in data.items %}
  <text fill="{% if item.is_urgent %}red{% else %}black{% endif %}">
    {{ item.name }}
  </text>
{% endfor %}

Dynamic Classes

<text class="{% if data.count > 100 %}highlight{% else %}normal{% endif %}">
  {{ data.count }}
</text>

Status Indicators

{% if data.status == "online" %}
  <circle cx="20" cy="20" r="8" fill="green"/>
{% elif data.status == "warning" %}
  <circle cx="20" cy="20" r="8" fill="orange"/>
{% else %}
  <circle cx="20" cy="20" r="8" fill="red"/>
{% endif %}

Common Patterns

Truncating Long Text

<text>
  {% if data.title | length > 30 %}
    {{ data.title | truncate(length=30) }}
  {% else %}
    {{ data.title }}
  {% endif %}
</text>

Formatted Numbers

Use Lua to format numbers before passing to template:

-- In Lua script
return {
  data = {
    temperature = string.format("%.1f°C", temp),
    price = string.format("$%.2f", amount)
  }
}

Time-Based Styling

-- In Lua script
local hour = tonumber(time_format(time_now(), "%H"))
return {
  data = {
    is_night = hour < 6 or hour > 20
  }
}
<rect width="800" height="480" fill="{% if is_night %}#333{% else %}white{% endif %}"/>

Debugging Templates

Show Raw Data

<!-- Temporarily add this to see all data -->
<text x="10" y="460" font-size="10" fill="#999">
  Debug: {{ data.items | length }} items
</text>

Check for Missing Data

{% if not data.title %}
  <text fill="red">ERROR: title is missing!</text>
{% endif %}

Template Errors

If your template has a syntax error, Byonk will display an error screen with the message. Check the server logs for details.

Embedding Images

Byonk supports embedding images in your SVG templates. You can include PNG, JPEG, GIF, WebP, and SVG files.

Asset Directory Structure

Place your screen assets in a subdirectory matching your screen name:

screens/
├── hello.lua         # Script at top level
├── hello.svg         # Template at top level
└── hello/            # Assets for "hello" screen
    ├── logo.png
    ├── icon.svg
    └── background.jpg

Method 1: Direct in SVG (Automatic Resolution)

Simply reference images by filename in your SVG template. Byonk automatically resolves relative paths to the screen’s asset directory and embeds them as data URIs:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
  <!-- This automatically loads screens/hello/logo.png -->
  <image x="10" y="10" width="64" height="64" href="logo.png"/>

  <text x="100" y="50">{{ data.greeting }}</text>
</svg>

Supported image formats:

  • PNG (.png)
  • JPEG (.jpg, .jpeg)
  • GIF (.gif)
  • WebP (.webp)
  • SVG (.svg)

Notes:

  • Paths are relative to the screen’s asset directory
  • URLs starting with data:, http://, or https:// are left unchanged
  • Missing images log a warning but don’t break rendering

Method 2: Via Lua (For Dynamic Images)

For more control, use read_asset() and base64_encode() in your Lua script:

screens/hello.lua:

local icon = read_asset("icon.png")

return {
    data = {
        greeting = "Hello World!",
        icon_src = "data:image/png;base64," .. base64_encode(icon)
    },
    refresh_rate = 3600
}

screens/hello.svg:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
  <image x="10" y="10" width="64" height="64" href="{{ data.icon_src }}"/>
  <text x="100" y="50">{{ data.greeting }}</text>
</svg>

This method is useful when you need to:

  • Conditionally include images
  • Fetch images from external URLs
  • Process or transform image data

Background Images

To use a full-screen background image:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <!-- Background image -->
  <image x="0" y="0" width="800" height="480" href="background.png" preserveAspectRatio="xMidYMid slice"/>

  <!-- Content on top -->
  <text x="400" y="240" text-anchor="middle" fill="white" font-size="32">
    {{ data.title }}
  </text>
</svg>

Tips for background images:

  • Use preserveAspectRatio="xMidYMid slice" to cover the entire area
  • Consider e-ink limitations: high-contrast images work best
  • Keep file sizes reasonable for fast rendering

Template Reusability

Byonk supports Tera’s template inheritance and includes for reusable components.

Directory Structure

Place reusable templates in special directories:

screens/
├── layouts/              # Base templates for {% extends %}
│   └── base.svg
├── components/           # Reusable snippets for {% include %}
│   ├── header.svg
│   ├── footer.svg
│   ├── hinting.svg
│   └── status_bar.svg
├── myscreen.lua
└── myscreen.svg

Template Inheritance (extends)

Create base layouts that define the overall structure with replaceable blocks:

screens/layouts/base.svg:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <rect width="100%" height="100%" fill="white"/>

  <!-- Header -->
  <rect x="0" y="0" width="100%" height="60" fill="black"/>
  <text x="20" y="40" fill="white" font-size="24" font-weight="bold">
    {% block title %}BYONK{% endblock %}
  </text>
  {% block header_extra %}{% endblock %}

  <!-- Content area -->
  <g transform="translate(0, 60)">
    {% block content %}{% endblock %}
  </g>

  {% block footer %}{% endblock %}
</svg>

screens/myscreen.svg:

{% extends "layouts/base.svg" %}

{% block title %}My Screen{% endblock %}

{% block content %}
<text x="400" y="200" text-anchor="middle" font-size="32">
  {{ data.message }}
</text>
{% endblock %}

{% block footer %}
<text x="400" y="460" text-anchor="middle" fill="#999" font-size="12">
  Updated: {{ data.updated_at }}
</text>
{% endblock %}

Key points:

  • Use {% extends "layouts/filename.svg" %} at the start of your template
  • Define blocks with {% block name %}...{% endblock %}
  • Child templates override parent blocks
  • Unoverridden blocks use the parent’s default content

Template Includes

Include reusable components in your templates:

screens/components/header.svg:

<rect x="0" y="0" width="100%" height="60" fill="black"/>
<text x="20" y="40" fill="white" font-size="24" font-weight="bold">
  {{ title | default(value="BYONK") }}
</text>
{% if updated_at %}
<text x="780" y="40" text-anchor="end" fill="#aaa" font-size="16">
  {{ updated_at }}
</text>
{% endif %}

screens/myscreen.svg:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480" width="800" height="480">
  <rect width="800" height="480" fill="white"/>

  <!-- Include the header component -->
  {% include "components/header.svg" %}

  <!-- Main content -->
  <g transform="translate(0, 60)">
    <text x="400" y="200" text-anchor="middle">{{ data.message }}</text>
  </g>

  <!-- Include the footer component -->
  {% include "components/footer.svg" %}
</svg>

Key points:

  • Use {% include "components/filename.svg" %} to insert a component
  • Included templates have access to all variables in the current context
  • Components work well for headers, footers, status bars, and other repeated elements

Built-in Components

Byonk includes several ready-to-use components:

ComponentDescription
components/header.svgHeader bar with title and optional timestamp
components/footer.svgFooter with timestamp and optional text
components/hinting.svgAdaptive font hinting (mono for BW, smooth for greyscale)
components/status_bar.svgWiFi and battery indicators

Combining Extends and Includes

You can use both in the same template:

{% extends "layouts/base.svg" %}

{% block title %}Dashboard{% endblock %}

{% block header_extra %}
{% include "components/status_bar.svg" %}
{% endblock %}

{% block content %}
<text x="400" y="200" text-anchor="middle">{{ data.message }}</text>
{% endblock %}

Next Steps

Advanced Topics

This guide covers advanced techniques for building robust Byonk screens.

Error Handling

Graceful HTTP Failures

Always wrap HTTP requests in pcall:

local function fetch_data(url)
  local ok, response = pcall(function()
    return http_get(url)
  end)

  if not ok then
    log_error("HTTP request failed: " .. tostring(response))
    return nil, response
  end

  return response, nil
end

-- Usage
local data, err = fetch_data("https://api.example.com/data")
if err then
  return {
    data = { error = "Could not fetch data" },
    refresh_rate = 60  -- Retry in 1 minute
  }
end

JSON Parsing Errors

local function safe_json_decode(str)
  local ok, result = pcall(function()
    return json_decode(str)
  end)

  if not ok then
    log_error("JSON parse error: " .. tostring(result))
    return nil
  end

  return result
end

Template Error Display

When errors occur, display them helpfully:

return {
  data = {
    has_error = true,
    error_message = "API returned invalid response",
    error_details = "Expected JSON, got HTML",
    retry_in = 60
  },
  refresh_rate = 60
}
{% if has_error %}
  <rect x="20" y="20" width="760" height="440" fill="white" stroke="red" stroke-width="4" rx="10"/>
  <text x="400" y="200" text-anchor="middle" font-size="24" fill="red">{{ error_message }}</text>
  <text x="400" y="240" text-anchor="middle" font-size="16" fill="#666">{{ error_details }}</text>
  <text x="400" y="300" text-anchor="middle" font-size="14" fill="#999">Retrying in {{ retry_in }} seconds...</text>
{% else %}
  <!-- Normal content -->
{% endif %}

HTML Scraping Techniques

Handling Missing Elements

local function safe_text(element)
  if element then
    return element:text()
  end
  return ""
end

local title = safe_text(doc:select_one("h1"))

Complex Table Parsing

local function parse_table(doc, selector)
  local rows = {}

  doc:select(selector .. " tr"):each(function(row)
    local cells = {}
    row:select("td, th"):each(function(cell)
      table.insert(cells, cell:text():match("^%s*(.-)%s*$"))  -- Trim whitespace
    end)

    if #cells > 0 then
      table.insert(rows, cells)
    end
  end)

  return rows
end

local data = parse_table(doc, "table.schedule")
-- Returns: { {"9:00", "Meeting"}, {"10:00", "Call"}, ... }
local function get_detail_page(doc, selector)
  local link = doc:select_one(selector)
  if not link then return nil end

  local href = link:attr("href")
  if not href then return nil end

  -- Handle relative URLs
  if href:sub(1, 1) == "/" then
    href = "https://example.com" .. href
  end

  return http_get(href)
end

Handling Pagination

local all_items = {}
local page = 1

while true do
  local url = "https://example.com/list?page=" .. page
  local html = http_get(url)
  local doc = html_parse(html)

  local items_found = 0
  doc:select(".item"):each(function(el)
    table.insert(all_items, el:text())
    items_found = items_found + 1
  end)

  -- Stop if no items or we have enough
  if items_found == 0 or #all_items >= 50 then
    break
  end

  page = page + 1

  -- Safety limit
  if page > 10 then break end
end

Dynamic Refresh Rates

Time-Based Refresh

local now = time_now()
local hour = tonumber(time_format(now, "%H"))

local refresh_rate
if hour >= 6 and hour < 22 then
  -- Daytime: refresh frequently
  refresh_rate = 300
else
  -- Night: refresh less often
  refresh_rate = 3600
end

Event-Based Refresh

-- Refresh when the next event starts
local next_event_time = events[1].timestamp
local seconds_until = next_event_time - time_now()

-- Refresh 30 seconds after event starts (to show updated state)
local refresh_rate = math.max(30, seconds_until + 30)

-- Cap at reasonable maximum
refresh_rate = math.min(refresh_rate, 3600)

Adaptive Refresh

-- Refresh more often if data is stale
local last_update = data.updated_timestamp
local age = time_now() - last_update

if age > 600 then
  -- Data is stale, refresh soon
  refresh_rate = 60
else
  -- Data is fresh, normal refresh
  refresh_rate = 300
end

Data Transformation

Sorting

-- Sort by time
table.sort(items, function(a, b)
  return a.timestamp < b.timestamp
end)

-- Sort alphabetically
table.sort(items, function(a, b)
  return a.name < b.name
end)

Filtering

local active = {}
for _, item in ipairs(items) do
  if item.status == "active" then
    table.insert(active, item)
  end
end

Limiting

local limit = params.limit or 10
local limited = {}
for i = 1, math.min(#items, limit) do
  table.insert(limited, items[i])
end

Grouping

local by_category = {}
for _, item in ipairs(items) do
  local cat = item.category or "Other"
  if not by_category[cat] then
    by_category[cat] = {}
  end
  table.insert(by_category[cat], item)
end

Working with Dates

Relative Time

local function relative_time(timestamp)
  local diff = timestamp - time_now()

  if diff < 0 then
    return "past"
  elseif diff < 60 then
    return "now"
  elseif diff < 3600 then
    return math.floor(diff / 60) .. " min"
  elseif diff < 86400 then
    return math.floor(diff / 3600) .. " hr"
  else
    return math.floor(diff / 86400) .. " days"
  end
end

Date Comparison

local today_start = time_parse(time_format(time_now(), "%Y-%m-%d"), "%Y-%m-%d")
local today_end = today_start + 86400

local todays_events = {}
for _, event in ipairs(events) do
  if event.timestamp >= today_start and event.timestamp < today_end then
    table.insert(todays_events, event)
  end
end

Timezone Handling

-- time_format uses local timezone
-- For UTC, parse the offset from API responses

local function parse_iso_date(str)
  -- "2024-12-27T14:30:00+01:00"
  local y, m, d, h, min, s = str:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)")
  if y then
    return time_parse(
      string.format("%s-%s-%s %s:%s:%s", y, m, d, h, min, s),
      "%Y-%m-%d %H:%M:%S"
    )
  end
  return nil
end

Performance Optimization

Minimize HTTP Requests

-- Bad: Multiple requests
local weather = json_decode(http_get("https://api.example.com/weather"))
local news = json_decode(http_get("https://api.example.com/news"))
local stocks = json_decode(http_get("https://api.example.com/stocks"))

-- Better: Combined endpoint if available
local dashboard = json_decode(http_get("https://api.example.com/dashboard"))

Early Exit

-- Check for errors early
if not params.api_key then
  return {
    data = { error = "Missing API key" },
    refresh_rate = 3600
  }
end

Limit Data Processing

-- Only process what you need
local limit = params.limit or 10
for i, item in ipairs(json.items) do
  if i > limit then break end
  -- Process item
end

Embedding Remote Images

You can fetch images via HTTP and embed them in SVG templates using http_get() and base64_encode(). The pattern is: fetch the image, base64-encode it, construct a data URI, and pass it to the template.

Lua Script

-- Fetch a remote image and embed it as a data URI
local ok, image_bytes = pcall(function()
  return http_get("https://example.com/photo.png", {
    cache_ttl = 3600  -- Cache for 1 hour to avoid re-fetching
  })
end)

if not ok then
  log_error("Failed to fetch image: " .. tostring(image_bytes))
  return {
    data = { error = "Could not load image" },
    refresh_rate = 60
  }
end

local image_src = "data:image/png;base64," .. base64_encode(image_bytes)

return {
  data = {
    image_src = image_src,
    title = "My Screen"
  },
  refresh_rate = 900
}

SVG Template

<image x="100" y="50" width="200" height="200" href="{{ data.image_src }}"/>

Tip: Use cache_ttl on the http_get call to avoid re-fetching the image on every device refresh. This is especially important for large images or rate-limited servers.

Google Photos Album Display

The built-in gphoto screen demonstrates fetching images from a shared Google Photos album using HTML scraping (no OAuth required).

Setup

  1. Open Google Photos and create or select an album
  2. Click ShareGet link to create a shared link
  3. Copy the URL (e.g., https://photos.app.goo.gl/ABC123...)

Configuration

# config.yaml
devices:
  "XX:XX:XX:XX:XX:XX":
    screen: gphoto
    params:
      album_url: "https://photos.app.goo.gl/YOUR_ALBUM_ID"
      show_status: true      # Show battery/signal overlay
      refresh_rate: 1800     # 30 minutes (default: 3600)

How It Works

The script scrapes the shared album HTML page to extract lh3.googleusercontent.com image URLs, then:

  1. Selects a random image from the album
  2. Appends size parameters (=w{width}-h{height}-no) to request device-sized images
  3. Fetches and base64-encodes the image for embedding in SVG
  4. Caches album HTML for 1 hour and images for 24 hours

This approach works because Google’s shared album pages embed image URLs directly in the HTML, even though the Photos API sharing features were deprecated in March 2025.

Testing Strategies

Test with Swagger UI

  1. Open http://localhost:3000/swagger-ui
  2. Use /api/display with a test MAC address
  3. Copy the image URL and open in browser
  4. Iterate on your script and template

Log Intermediate Values

log_info("Params: " .. json_encode(params))
log_info("Fetched " .. #items .. " items")
log_info("First item: " .. json_encode(items[1]))

Create Test Screens

# config.yaml
devices:
  "TE:ST:00:00:00:01":
    screen: myscreen
    params:
      test_mode: true
      mock_data: true
if params.test_mode then
  -- Use mock data for testing
  return {
    data = {
      items = {
        { name = "Test Item 1" },
        { name = "Test Item 2" }
      }
    },
    refresh_rate = 30
  }
end

-- Normal data fetching

Real-World Example: Room Booking

This example combines many advanced techniques:

-- floerli.lua - Room booking display

local room_name = params.room or "Rosa"
local base_url = params.url or "https://floerli-olten.ch"

log_info("Fetching bookings for room: " .. room_name)

-- Fetch and parse
local ok, html = pcall(function()
  return http_get(base_url .. "/index.cgi?rm=calendar")
end)

if not ok then
  log_error("Failed to fetch calendar: " .. tostring(html))
  return {
    data = {
      room = room_name,
      error = "Could not load calendar",
      bookings = {}
    },
    refresh_rate = 60
  }
end

local doc = html_parse(html)

-- Find room column index
local room_columns = {
  Flora = 1, Salon = 2, ["Küche"] = 3, Bernsteinzimmer = 4,
  Rosa = 5, Clara = 6, Cosy = 7, Sofia = 8
}
local col = room_columns[room_name] or 5

-- Parse table
local bookings = {}
local now = time_now()
local current_hour = tonumber(time_format(now, "%H"))

doc:select("table.calendar tr"):each(function(row)
  local time_cell = row:select_one("td:first-child")
  local room_cell = row:select_one("td:nth-child(" .. (col + 1) .. ")")

  if time_cell and room_cell then
    local time_str = time_cell:text()
    local hour = tonumber(time_str:match("^(%d+)"))

    if hour and hour >= current_hour then
      local booking_text = room_cell:text():match("^%s*(.-)%s*$")
      local is_free = (booking_text == "" or booking_text == "frei")

      table.insert(bookings, {
        time = time_str,
        title = is_free and nil or booking_text,
        is_free = is_free
      })
    end
  end
end)

-- Calculate refresh: at top of next hour
local minutes_until_next_hour = 60 - tonumber(time_format(now, "%M"))
local refresh_rate = math.max(60, minutes_until_next_hour * 60)

return {
  data = {
    room = room_name,
    bookings = bookings,
    current_booking = bookings[1],
    upcoming = { table.unpack(bookings, 2, 6) },
    updated_at = time_format(now, "%H:%M")
  },
  refresh_rate = refresh_rate
}

Next Steps

HTTP API Reference

Bring Your Own Server API for TRMNL e-ink devices

Version: 0.1.0

Overview

Byonk provides a REST API for TRMNL device communication. The API handles device registration, content delivery, and logging.

EndpointDescription
GET /api/setupDevice registration
GET /api/displayGet display content URL
GET /api/image/{hash}.pngGet rendered PNG by content hash
POST /api/logSubmit device logs
GET /healthHealth check

Display

GET /api/display

Get display content for a device

Returns JSON with an image_url that the device should fetch separately. The firmware expects status=0 for success (not HTTP 200).

Parameters

NameInRequiredDescription
IDheaderYesDevice MAC address
Access-TokenheaderYesAPI key from /api/setup
WidthheaderNoDisplay width in pixels (default: 800)
HeightheaderNoDisplay height in pixels (default: 480)
Refresh-RateheaderNoCurrent refresh rate in seconds
Battery-VoltageheaderNoBattery voltage
RSSIheaderNoWiFi signal strength
FW-VersionheaderNoFirmware version
ModelheaderNoDevice model (‘og’ or ‘x’)
BoardheaderNoBoard identifier (e.g., ‘trmnl_og_4clr’)
ColorsheaderNoDisplay palette as comma-separated hex RGB (e.g., ‘#000000,#FFFFFF,#FF0000,#FFFF00’). Defaults to 4-grey palette if absent.

Responses

200: Display content available

{
  "filename": "string",
  "firmware_url": null,
  "image_url": null,
  "refresh_rate": 0,
  "reset_firmware": true,
  "special_function": null,
  "status": 0,
  "temperature_profile": null,
  "update_firmware": true
}

400: Missing required header

404: Device not found

GET /api/image/{hash}.png

Get rendered PNG image by content hash

Returns the actual PNG image data rendered from SVG with dithering applied. The content hash is provided in the /api/display response and ensures clients can detect when content has changed.

Parameters

NameInRequiredDescription
hashpathYesContent hash from /api/display response

Responses

200: PNG image

404: Content not found (cache miss or invalid hash)

500: Rendering error

Logging

POST /api/log

Submit device logs

Devices send diagnostic logs when they encounter errors or issues.

Parameters

NameInRequiredDescription
IDheaderYesDevice MAC address
Access-TokenheaderYesAPI key from /api/setup

Request Body

{
  "logs": [null]
}

Responses

200: Logs received successfully

{
  "message": "string",
  "status": 0
}

Device

GET /api/setup

Register a new device or retrieve existing registration

The device sends its MAC address and receives an API key for future requests.

Parameters

NameInRequiredDescription
IDheaderYesDevice MAC address (e.g., ‘AA:BB:CC:DD:EE:FF’)
FW-VersionheaderYesFirmware version (e.g., ‘1.7.1’)
ModelheaderYesDevice model (‘og’ or ‘x’)

Responses

200: Device registered successfully

{
  "api_key": null,
  "friendly_id": null,
  "image_url": null,
  "message": null,
  "status": 0
}

400: Missing required header

Lua API Reference

This page documents all functions available to Lua scripts in Byonk.

Global Variables

params

A table containing device-specific parameters from config.yaml.

local station = params.station  -- From config.yaml
local limit = params.limit or 10  -- With default

Type: table

device

A table containing device information (when available).

-- Check battery level
if device.battery_voltage and device.battery_voltage < 3.3 then
  log_warn("Low battery: " .. device.battery_voltage .. "V")
end

-- Check signal strength
if device.rssi and device.rssi < -80 then
  log_warn("Weak WiFi signal: " .. device.rssi .. " dBm")
end

-- Responsive layout based on device type
if device.width == 1872 then
  -- TRMNL X layout
else
  -- TRMNL OG layout
end

Fields:

FieldTypeDescription
macstringDevice MAC address (e.g., “AC:15:18:D4:7B:E2”)
battery_voltagenumber or nilBattery voltage (e.g., 4.12)
rssinumber or nilWiFi signal strength in dBm (e.g., -65)
modelstring or nilDevice model (“og” or “x”)
firmware_versionstring or nilFirmware version string
widthnumber or nilDisplay width in pixels (800 or 1872)
heightnumber or nilDisplay height in pixels (480 or 1404)
boardstring or nilBoard identifier (e.g., “trmnl_og_4clr”)
colorstable or nilDisplay palette as hex RGB strings (e.g., {“#000000”, “#FFFFFF”})
dithertablePre-script resolved dither tuning (see below)

Type: table

Note: Device fields may be nil if the device doesn’t report them. Always check before using.

device.dither

The device.dither sub-table contains the pre-script resolved dither tuning values (panel defaults merged with device config). Scripts can read these to make selective adjustments rather than setting everything blindly.

-- Read current tuning
local algo = device.dither.algorithm       -- "floyd-steinberg" (resolved algorithm)
local ec = device.dither.error_clamp       -- 0.08 (from panel/device config)
local ns = device.dither.noise_scale       -- 4.0
local cc = device.dither.chroma_clamp      -- nil (not set)
local st = device.dither.strength          -- 1.0 (default)

-- Selectively override: halve the error clamp, keep everything else
return {
  data = { ... },
  refresh_rate = 300,
  error_clamp = (device.dither.error_clamp or 0.1) * 0.5,
  -- noise_scale not returned -> keeps panel/device value
}
FieldTypeDescription
algorithmstring or nilPre-script resolved dither algorithm
error_clampnumber or nilError diffusion clamp (from device config / panel)
noise_scalenumber or nilBlue noise jitter scale
chroma_clampnumber or nilChromatic error clamp
strengthnumber or nilError diffusion strength (0.0–2.0, default 1.0)

layout

A table containing pre-computed responsive layout values. These values are automatically calculated based on the device dimensions, making it easy to create screens that work on both TRMNL OG (800×480) and TRMNL X (1872×1404).

-- Use pre-computed values directly
local margin = layout.margin        -- pixel-aligned margin
local center = layout.center_x      -- screen center X

-- Access display palette
local colors = layout.colors         -- {"#000000", "#555555", "#AAAAAA", "#FFFFFF"}
local count = layout.color_count     -- 4
local greys = layout.grey_count      -- 4 (colors where R=G=B)

Fields:

FieldTypeDescriptionDefault (OG)Example (X)
widthintegerDevice width in pixels8001872
heightintegerDevice height in pixels4801404
scalenumberScale factor: min(width/800, height/480)1.02.34
center_xintegerHorizontal center: floor(width/2)400936
center_yintegerVertical center: floor(height/2)240702
colorstableDisplay palette as hex RGB strings{“#000000”,“#555555”,“#AAAAAA”,“#FFFFFF”}16 grey values
color_countintegerNumber of palette colors416
grey_countintegerNumber of grey levels (colors where R=G=B)416
marginintegerStandard margin: floor(20 * scale)2046
margin_smintegerSmall margin: floor(10 * scale)1023
margin_lgintegerLarge margin: floor(40 * scale)4093

Type: table

Note: All margin values are pre-floored for pixel-aligned positioning.

fonts

A table of all available font families and their faces. Keyed by family name, each value is an array of face records.

-- List all font families
for family, faces in pairs(fonts) do
  print(family)  -- "X11Helv", "TerminusTTF", "Outfit", ...
end

-- Query a specific family
for _, face in ipairs(fonts["X11Helv"]) do
  print(face.style)           -- "Normal", "Italic", "Oblique"
  print(face.weight)          -- 400 (number)
  print(face.stretch)         -- "Normal", "Condensed", ...
  print(face.monospaced)      -- true/false
  print(face.post_script_name)-- "X11Helv"
  -- Bitmap strike sizes (sorted ppem values), empty for outline-only fonts
  for _, ppem in ipairs(face.bitmap_strikes) do
    print(ppem)               -- 8, 10, 11, 12, ...
  end
end

Face fields:

FieldTypeDescription
stylestring"Normal", "Italic", or "Oblique"
weightnumberCSS-style weight (100–900, 400 = normal, 700 = bold)
stretchstring"Normal", "Condensed", "Expanded", etc.
monospacedbooleanWhether the face is monospaced
post_script_namestringPostScript name of the face
bitmap_strikestableSorted array of available bitmap ppem sizes (empty if none)

Type: table

Layout Helper Functions

These functions help scale values appropriately for different device resolutions.

scale_font(value)

Scales a font size value by the layout scale factor. Returns a float to preserve precision for font rendering.

local title_size = scale_font(48)    -- 48.0 on OG, 112.32 on X
local body_size = scale_font(24)     -- 24.0 on OG, 56.16 on X

Parameters:

NameTypeDescription
valuenumberBase font size (designed for 800×480)

Returns: number - Scaled font size (float)

scale_pixel(value)

Scales a pixel value by the layout scale factor and floors the result for pixel-aligned positioning.

local header_y = scale_pixel(70)     -- 70 on OG, 163 on X
local icon_size = scale_pixel(32)    -- 32 on OG, 74 on X

Parameters:

NameTypeDescription
valuenumberBase pixel value (designed for 800×480)

Returns: integer - Scaled and floored pixel value

greys(levels)

Generates a grey palette with the specified number of levels. Useful for creating gradients or color swatches that match the device’s grey level capability.

-- Generate palette matching device capability
local palette = greys(layout.grey_levels)

for i, entry in ipairs(palette) do
  print(entry.value)       -- 0-255 grey value
  print(entry.color)       -- "#000000" to "#ffffff"
  print(entry.text_color)  -- "#ffffff" for dark, "#000000" for light
end

Parameters:

NameTypeDescription
levelsintegerNumber of grey levels (typically 4 or 16)

Returns: table - Array of palette entries

Palette entry fields:

FieldTypeDescription
valueintegerGrey value from 0 (black) to 255 (white)
colorstringHex color string (e.g., “#808080”)
text_colorstringContrasting text color (“#ffffff” or “#000000”)

Example with 4 levels:

local palette = greys(4)
-- palette[1] = {value=0,   color="#000000", text_color="#ffffff"}
-- palette[2] = {value=85,  color="#555555", text_color="#ffffff"}
-- palette[3] = {value=170, color="#aaaaaa", text_color="#000000"}
-- palette[4] = {value=255, color="#ffffff", text_color="#000000"}

Example: Responsive Screen

Here’s how to create a screen that works on both TRMNL OG and TRMNL X:

-- Before (manual boilerplate):
local width = device and device.width or 800
local height = device and device.height or 480
local scale = math.min(width / 800, height / 480)
local font_size = math.floor(48 * scale)  -- Wrong: shouldn't floor fonts
local header_y = math.floor(70 * scale)   -- Correct: pixel-aligned

-- After (using helpers):
local font_size = scale_font(48)     -- Preserves precision for fonts
local header_y = scale_pixel(70)     -- Pixel-aligned position
local margin = layout.margin         -- Pre-computed pixel margin
local colors = layout.colors                 -- Display palette colors

HTTP Functions

Byonk provides three HTTP functions: http_request (full control), http_get (GET shorthand), and http_post (POST shorthand).

http_request(url, options?)

Core HTTP function with full control over the request method and options.

-- GET request (default)
local response = http_request("https://api.example.com/data")

-- POST with JSON body
local response = http_request("https://api.example.com/users", {
  method = "POST",
  json = { name = "Alice", email = "alice@example.com" }
})

-- PUT request with headers
local response = http_request("https://api.example.com/users/123", {
  method = "PUT",
  headers = { ["Authorization"] = "Bearer " .. params.token },
  json = { name = "Alice Updated" }
})

-- DELETE request
local response = http_request("https://api.example.com/users/123", {
  method = "DELETE",
  headers = { ["Authorization"] = "Bearer " .. params.token }
})

Parameters:

NameTypeDescription
urlstringThe URL to fetch
optionstable (optional)Request options (see below)

Options:

NameTypeDefaultDescription
methodstring“GET”HTTP method: “GET”, “POST”, “PUT”, “DELETE”, “PATCH”, “HEAD”
paramstablenoneQuery parameters (automatically URL-encoded)
headerstablenoneKey-value pairs of HTTP headers
bodystringnoneRequest body as string
jsontablenoneRequest body as JSON (auto-serializes, sets Content-Type)
basic_authtablenoneBasic auth: { username = "...", password = "..." }
timeoutnumber30Request timeout in seconds
follow_redirectsbooleantrueWhether to follow HTTP redirects
max_redirectsnumber10Maximum number of redirects to follow
danger_accept_invalid_certsbooleanfalseAccept self-signed/expired certificates (insecure!)
ca_certstringnonePath to CA certificate PEM file for server verification
client_certstringnonePath to client certificate PEM file for mTLS
client_keystringnonePath to client private key PEM file for mTLS
cache_ttlnumbernoneCache response for N seconds (LRU cache, max 100 entries)

Returns: string - The response body

Throws: Error if the request fails

JSON option details:

The json option supports complex nested structures. Tables with sequential integer keys (starting at 1) become JSON arrays; tables with string keys become JSON objects. Use bracket syntax for keys with spaces or special characters:

http_post("https://api.example.com/data", {
  json = {
    -- Nested objects and arrays
    users = {
      { name = "Alice", tags = {"admin", "user"} },
      { name = "Bob", roles = { level = 2, active = true } }
    },
    -- Keys with spaces or special characters
    ["Content-Type"] = "application/json",
    ["my key with spaces"] = "works fine",
    -- Mixed types
    count = 42,
    enabled = true,
    optional = nil  -- becomes JSON null
  }
})

http_get(url, options?)

Convenience wrapper for GET requests. Same as http_request with method = "GET".

-- Simple usage
local response = http_get("https://api.example.com/data")

-- With query parameters (auto URL-encoded)
local response = http_get("https://api.example.com/search", {
  params = {
    query = "hello world",  -- becomes ?query=hello%20world&limit=10
    limit = 10
  }
})

-- With authentication header
local response = http_get("https://api.example.com/data", {
  headers = { ["Authorization"] = "Bearer " .. params.api_token }
})

-- With basic auth
local response = http_get("https://api.example.com/data", {
  basic_auth = { username = params.user, password = params.pass }
})

-- Accept self-signed certificates (for internal APIs)
local response = http_get("https://internal.example.com/data", {
  danger_accept_invalid_certs = true
})

-- Use custom CA certificate for server verification
local response = http_get("https://internal.example.com/data", {
  ca_cert = "/path/to/ca.pem"
})

-- Mutual TLS (mTLS) with client certificate
local response = http_get("https://secure-api.example.com/data", {
  ca_cert = "/path/to/ca.pem",
  client_cert = "/path/to/client.pem",
  client_key = "/path/to/client-key.pem"
})

-- Cache response for 5 minutes (300 seconds)
-- Useful for APIs with rate limits or data that doesn't change frequently
local response = http_get("https://api.weather.com/current", {
  params = { city = "Zurich" },
  cache_ttl = 300  -- Cache for 5 minutes
})

Response Caching:

The cache_ttl option enables response caching with LRU (Least Recently Used) eviction:

  • Responses are cached in memory for the specified number of seconds
  • Cache key is based on URL, method, params, headers, and body
  • Maximum 100 cached entries; oldest entries are evicted when full
  • Cache is shared across all script executions
  • Useful for reducing API calls to rate-limited services or slow APIs
-- First call fetches from API, subsequent calls within 60s use cache
local data = http_get("https://api.example.com/data", { cache_ttl = 60 })

http_post(url, options?)

Convenience wrapper for POST requests. Same as http_request with method = "POST".

-- POST with JSON body
local response = http_post("https://api.example.com/data", {
  json = { key = "value", count = 42 }
})

-- POST with form-like body
local response = http_post("https://api.example.com/data", {
  headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
  body = "key=value&count=42"
})

-- POST with authentication
local response = http_post("https://api.example.com/data", {
  headers = { ["Authorization"] = "Bearer " .. params.token },
  json = { action = "update" }
})

Example with error handling:

local ok, response = pcall(function()
  return http_get("https://api.example.com/data", {
    headers = { ["Authorization"] = "Bearer " .. params.token }
  })
end)

if not ok then
  log_error("Request failed: " .. tostring(response))
end

JSON Functions

json_decode(str)

Parses a JSON string into a Lua table.

local data = json_decode('{"name": "Alice", "age": 30}')
print(data.name)  -- "Alice"

Parameters:

NameTypeDescription
strstringJSON string to parse

Returns: table - The parsed JSON as a Lua table

Notes:

  • JSON arrays become 1-indexed Lua tables
  • JSON null becomes Lua nil

json_encode(table)

Converts a Lua table to a JSON string.

local json = json_encode({name = "Bob", items = {1, 2, 3}})
-- '{"name":"Bob","items":[1,2,3]}'

Parameters:

NameTypeDescription
tabletableLua table to encode

Returns: string - JSON representation

Notes:

  • Tables with sequential integer keys become arrays
  • Tables with string keys become objects

HTML Parsing Functions

html_parse(html)

Parses an HTML string and returns a document object.

local doc = html_parse("<html><body><h1>Hello</h1></body></html>")

Parameters:

NameTypeDescription
htmlstringHTML string to parse

Returns: Document - Parsed document object

Document Methods

doc:select(selector)

Queries elements using a CSS selector.

local links = doc:select("a.nav-link")
local items = doc:select("ul > li")

Parameters:

NameTypeDescription
selectorstringCSS selector

Returns: Elements - Collection of matching elements

Supported selectors:

  • Tag: div, a, span
  • Class: .classname
  • ID: #idname
  • Attribute: [href], [data-id="123"]
  • Combinators: div > p, ul li, h1 + p
  • Pseudo-classes: :first-child, :nth-child(2)

doc:select_one(selector)

Returns only the first matching element.

local title = doc:select_one("h1")
if title then
  print(title:text())
end

Parameters:

NameTypeDescription
selectorstringCSS selector

Returns: Element or nil - First matching element

Elements Methods

elements:each(fn)

Iterates over all elements in the collection.

doc:select("li"):each(function(el)
  print(el:text())
end)

Parameters:

NameTypeDescription
fnfunctionCallback receiving each element

Element Methods

element:text()

Gets the inner text content.

local heading = doc:select_one("h1")
local text = heading:text()  -- "Welcome"

Returns: string - Text content

element:attr(name)

Gets an attribute value.

local link = doc:select_one("a")
local href = link:attr("href")  -- "https://..."
local class = link:attr("class")  -- "nav-link" or nil

Parameters:

NameTypeDescription
namestringAttribute name

Returns: string or nil - Attribute value

element:html()

Gets the inner HTML.

local div = doc:select_one("div.content")
local inner = div:html()  -- "<p>Paragraph</p><p>Another</p>"

Returns: string - Inner HTML

element:select(selector)

Queries descendants of this element.

local table = doc:select_one("table.data")
local rows = table:select("tr")

Parameters:

NameTypeDescription
selectorstringCSS selector

Returns: Elements - Matching descendants

element:select_one(selector)

Returns first matching descendant.

local row = doc:select_one("tr")
local first_cell = row:select_one("td")

Parameters:

NameTypeDescription
selectorstringCSS selector

Returns: Element or nil

Time Functions

time_now()

Returns the current Unix timestamp.

local now = time_now()  -- e.g., 1703672400

Returns: number - Unix timestamp (seconds since 1970)

time_format(timestamp, format)

Formats a timestamp into a string using the server’s local timezone.

local now = time_now()
time_format(now, "%H:%M")      -- "14:32"
time_format(now, "%Y-%m-%d")   -- "2024-12-27"
time_format(now, "%A, %B %d")  -- "Friday, December 27"

Parameters:

NameTypeDescription
timestampnumberUnix timestamp
formatstringstrftime format string

Returns: string - Formatted date/time

Format codes:

CodeDescriptionExample
%YYear (4 digit)2024
%yYear (2 digit)24
%mMonth (01-12)12
%dDay (01-31)27
%HHour 24h (00-23)14
%IHour 12h (01-12)02
%MMinute (00-59)32
%SSecond (00-59)05
%AWeekday nameFriday
%aWeekday shortFri
%BMonth nameDecember
%bMonth shortDec
%pAM/PMPM
%ZTimezoneCET
%%Literal %%

time_parse(str, format)

Parses a date string into a Unix timestamp.

local ts = time_parse("2024-12-27 14:30", "%Y-%m-%d %H:%M")

Parameters:

NameTypeDescription
strstringDate string to parse
formatstringstrftime format string

Returns: number - Unix timestamp

Note: Uses local timezone for interpretation.

Asset Functions

read_asset(path)

Reads a file from the current screen’s asset directory.

-- From hello.lua, reads screens/hello/logo.png
local logo_bytes = read_asset("logo.png")

Parameters:

NameTypeDescription
pathstringRelative path within the screen’s asset directory

Returns: string - Binary file contents

Throws: Error if the file cannot be read

Asset directory convention:

screens/
├── hello.lua         # Script at top level
├── hello.svg         # Template at top level
└── hello/            # Assets for "hello" screen
    ├── logo.png
    └── icon.svg

When read_asset("logo.png") is called from hello.lua, it reads screens/hello/logo.png.

Example: Embedding an image in data:

local logo = read_asset("logo.png")
local logo_b64 = base64_encode(logo)

return {
    data = {
        logo_src = "data:image/png;base64," .. logo_b64
    },
    refresh_rate = 3600
}

base64_encode(data)

Encodes binary data (string) to a base64 string.

local encoded = base64_encode(raw_bytes)

Parameters:

NameTypeDescription
datastringBinary data to encode

Returns: string - Base64-encoded string

Example: Creating a data URI from a local asset:

local image_data = read_asset("icon.png")
local data_uri = "data:image/png;base64," .. base64_encode(image_data)

Example: Embedding a remote image:

local image_bytes = http_get("https://example.com/photo.png", { cache_ttl = 3600 })
local image_src = "data:image/png;base64," .. base64_encode(image_bytes)

See Embedding Remote Images for a complete example with error handling.

URL Encoding Functions

url_encode(str)

URL-encodes a string for safe use in URLs (query parameters, path segments).

local encoded = url_encode("hello world")  -- "hello%20world"
local station = url_encode("Zürich, HB")   -- "Z%C3%BCrich%2C%20HB"

Parameters:

NameTypeDescription
strstringString to URL-encode

Returns: string - URL-encoded string

Example: Building a URL with special characters:

local station = params.station  -- "Zürich, HB"
local url = "https://api.example.com/departures?station=" .. url_encode(station)
-- Result: https://api.example.com/departures?station=Z%C3%BCrich%2C%20HB

Note: When using the params option in http_get/http_request, parameters are automatically URL-encoded. Use url_encode only when building URLs manually.

url_decode(str)

Decodes a URL-encoded string.

local decoded = url_decode("hello%20world")  -- "hello world"
local station = url_decode("Z%C3%BCrich%2C%20HB")  -- "Zürich, HB"

Parameters:

NameTypeDescription
strstringURL-encoded string to decode

Returns: string - Decoded string

Throws: Error if the string contains invalid UTF-8 after decoding

QR Code Functions

qr_svg(data, options)

Generates a pixel-aligned QR code as an SVG fragment for embedding in templates. Uses anchor-based positioning with edge margins, so you don’t need to calculate the QR code size.

-- Position QR code in bottom-right corner with 10px margins
local qr = qr_svg("https://example.com", {
  anchor = "bottom-right",
  right = 10,
  bottom = 10,
  module_size = 4
})

-- Centered QR code
local qr = qr_svg("https://example.com", {
  anchor = "center",
  module_size = 5
})

-- Top-left with custom margins
local qr = qr_svg("https://example.com", {
  anchor = "top-left",
  left = 20,
  top = 20,
  module_size = 4,
  ec_level = "H"
})

Parameters:

NameTypeDescription
datastringContent to encode (URL, text, etc.)
optionstablePositioning and rendering options (see below)

Options:

NameTypeDefaultDescription
anchorstring“top-left”Which corner to anchor: “top-left”, “top-right”, “bottom-left”, “bottom-right”, “center”
topinteger0Margin from top edge in pixels (for top-* anchors)
leftinteger0Margin from left edge in pixels (for *-left anchors)
rightinteger0Margin from right edge in pixels (for *-right anchors)
bottominteger0Margin from bottom edge in pixels (for bottom-* anchors)
module_sizeinteger4Size of each QR module in pixels (recommended: 3-6)
ec_levelstring“M”Error correction level: “L” (7%), “M” (15%), “Q” (25%), “H” (30%)
quiet_zoneinteger4QR quiet zone in modules

Anchor and margin combinations:

AnchorRelevant margins
top-lefttop, left
top-righttop, right
bottom-leftbottom, left
bottom-rightbottom, right
center(centered, margins ignored)

Returns: string - SVG fragment (<g> element with <rect> elements)

Throws: Error if QR code generation fails or if an invalid anchor is specified.

Example in template:

-- hello.lua
return {
  data = {
    -- QR code anchored to bottom-right with 10px margin
    qr_code = qr_svg("https://www.youtube.com/watch?v=dQw4w9WgXcQ", {
      anchor = "bottom-right",
      right = 10,
      bottom = 10,
      module_size = 4
    })
  },
  refresh_rate = 3600
}
<!-- hello.svg -->
{{ data.qr_code | safe }}

Notes:

  • Screen dimensions are automatically read from device.width and device.height (defaults to 800x480)
  • Use integer values for margins and module_size for crisp rendering on e-ink displays
  • Module size 3-6 pixels works well for 800x480 displays
  • Higher error correction allows the QR code to remain scannable even if partially obscured

Logging Functions

log_info(message)

Logs an informational message.

log_info("Processing request for: " .. station)

Parameters:

NameTypeDescription
messagestringMessage to log

Server output:

INFO script=true: Processing request for: Olten

log_warn(message)

Logs a warning message.

log_warn("API response was empty")

Parameters:

NameTypeDescription
messagestringMessage to log

log_error(message)

Logs an error message.

log_error("Failed to parse response: " .. err)

Parameters:

NameTypeDescription
messagestringMessage to log

Script Return Value

Every script must return a table with this structure:

return {
  data = {
    -- Any data structure
    -- Available in template as data.*
    title = "My Title",
    items = { ... }
  },
  refresh_rate = 300,       -- Seconds until next refresh
  skip_update = false,      -- Optional: skip rendering, just check back later
  colors = { "#000000", "#FFFFFF", "#FF0000" },  -- Optional: override display palette
  dither = "atkinson",      -- Optional: dither algorithm
  preserve_exact = true,    -- Optional: preserve exact palette matches (default: true)
  error_clamp = 0.08,       -- Optional: error diffusion clamp
  noise_scale = 0.6,        -- Optional: blue noise jitter scale
  chroma_clamp = 2.0,       -- Optional: chromatic error clamp
  strength = 0.8,           -- Optional: error diffusion strength (default 1.0)
}

data

FieldTypeDescription
datatableData passed to the Tera template under data.* namespace

The data table can contain any Lua values:

  • Strings, numbers, booleans
  • Nested tables (become objects)
  • Arrays (1-indexed tables with sequential keys)

In templates, access this data with the data. prefix:

<text>{{ data.title }}</text>
{% for item in data.items %}...{% endfor %}

refresh_rate

FieldTypeDescription
refresh_ratenumberSeconds until device should refresh

Guidelines:

  • 30-60: Real-time data (transit, stocks)
  • 300-900: Regular updates (weather, calendar)
  • 3600+: Static or slow-changing content

If refresh_rate is 0 or omitted, the screen’s default_refresh from config is used.

colors

FieldTypeDescription
colorstable or nilOptional array of hex RGB color strings to override the display palette

When colors is returned by a script, it takes the highest priority in the color palette chain:

  1. Script colors (strongest) — returned in the script result table
  2. Device config colors — set per-device in config.yaml
  3. Firmware Colors header — sent by device hardware
  4. System default#000000,#555555,#AAAAAA,#FFFFFF
-- Force a 3-color palette for this screen
return {
  data = { ... },
  refresh_rate = 300,
  colors = { "#000000", "#FFFFFF", "#FF0000" }
}

dither

FieldTypeDescription
ditherstring or nilOptional dithering algorithm

Controls the dithering algorithm used when converting SVG to e-ink PNG. Available values:

ValueAlgorithmDescription
"atkinson" (default)AtkinsonError diffusion (75% propagation)
"atkinson-hybrid"Atkinson Hybrid100% achromatic / 75% chromatic propagation
"floyd-steinberg"Floyd-SteinbergGeneral-purpose error diffusion
"jarvis-judice-ninke"JJNWide kernel, least oscillation
"sierra"Sierra10-neighbor error diffusion
"sierra-two-row"Sierra Two-Row7-neighbor error diffusion
"sierra-lite"Sierra LiteFastest error diffusion
"stucki"StuckiWide 12-neighbor kernel similar to JJN
"burkes"Burkes7-neighbor, good balance of speed and quality

The dither mode follows a priority chain:

  1. Dev UI override (strongest) — set in dev mode
  2. Script dither — returned in the script result table
  3. Device config dither — set per-device in config.yaml
  4. Default"atkinson"
-- Use Floyd-Steinberg dithering for a screen that displays images
return {
  data = { image_url = "..." },
  refresh_rate = 3600,
  dither = "floyd-steinberg"
}

preserve_exact

FieldTypeDescription
preserve_exactboolean or nilWhether to preserve exact palette color matches (default: true)

When true (default), pixels that exactly match a palette color are kept as-is without dithering. This preserves sharp edges for text, lines, and borders. Set to false to force all pixels through the dithering pipeline.

return {
  data = { ... },
  refresh_rate = 300,
  preserve_exact = false  -- force all pixels through dithering
}

error_clamp, noise_scale, chroma_clamp, strength

FieldTypeDescription
error_clampnumber or nilLimits error diffusion amplitude (e.g. 0.08)
noise_scalenumber or nilBlue noise jitter scale (e.g. 0.6)
chroma_clampnumber or nilLimits chromatic error propagation (e.g. 2.0)
strengthnumber or nilError diffusion strength multiplier (0.0 = no diffusion, 1.0 = standard, default)

Fine-tune dithering behavior per-script. These override device config and panel default values but are overridden by dev UI settings.

Priority chain: dev UI > script return > device config > panel dither defaults > algorithm defaults.

Use dev mode to interactively find good values, then set them here or in the panel dither defaults for production use.

-- Tuned values for a photo screen on a 4-color panel
return {
  data = { ... },
  refresh_rate = 3600,
  dither = "floyd-steinberg",
  error_clamp = 0.08,
  noise_scale = 0.5,
  strength = 0.8
}

skip_update

FieldTypeDescription
skip_updatebooleanIf true, don’t update the display - just tell device to check back later

When skip_update is true:

  • No new image is rendered
  • The device keeps its current display content
  • The device will check back after refresh_rate seconds

This is useful when your data source hasn’t changed:

-- Check if data has changed since last update
local cached_hash = get_data_hash()
local current_data = fetch_data()
local new_hash = compute_hash(current_data)

if cached_hash == new_hash then
  -- No changes - tell device to check back in 5 minutes
  return {
    data = {},
    refresh_rate = 300,
    skip_update = true
  }
end

-- Data changed - render new content
return {
  data = current_data,
  refresh_rate = 300,
  skip_update = false  -- or just omit it
}

Note: When skip_update is true, the data table is ignored since no rendering occurs.

Standard Lua Functions

Byonk uses Lua 5.4. Standard library functions available include:

String

  • string.format, string.sub, string.find
  • string.match, string.gmatch, string.gsub
  • string.upper, string.lower, string.len

Table

  • table.insert, table.remove
  • table.sort, table.concat
  • ipairs, pairs

Math

  • math.floor, math.ceil, math.abs
  • math.min, math.max
  • math.random

Other

  • tonumber, tostring, type
  • pcall (for error handling)

Not available: File I/O, OS functions, network (except http_get)