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

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”})
colors_actualtable or nilThe panel’s measured colours, index-parallel to colors (see below)
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.colors_actual

The colours the panel really shows, as measured — index-parallel to device.colors. nil when the panel has no measured colours configured.

This is deliberately not filled in from device.colors when absent, so a script can tell an uncalibrated panel from one that measures exactly to spec.

⚠️ Use measured colours to decide, never to paint.

A measured value is the right input for a judgement — “is this ink dark enough that I need white text on it?” — because it is what the eye will see. It is the wrong thing to write into a fill or stroke.

Ordinary (non-continuous) content is matched against the official palette, so an official colour like #00FF00 matches at distance zero and comes out as that one ink, flat. A measured value like #0D876B is not an official entry, cannot match exactly, and dithers — the flat block you wanted breaks up into speckle. Paint device.colors[i]; the panel maps the index to its real ink for you.

local shown = device.colors_actual or device.colors

-- DECIDE with the measured colour: pick a foreground that genuinely
-- contrasts on this panel, not one that only contrasts in the spec.
local fg = luminance(shown[i]) < 128 and "#FFFFFF" or "#000000"

-- PAINT with the official colour, so the block pins to a single ink.
local bg = device.colors[i]

screens/builtin/calibration/color is the worked example: its solid patches are filled from device.colors and pick their label colour from device.colors_actual.

device.colors_actual is resolved before this script runs, so it reflects whichever of these applies first: the dev colour-tuning override (or, when rendering via the render_screen MCP tool, its colors_actual argument) > panel.colors_actual in config.yaml > the Measured-Colors header > none.

A script can still go one step further and override what actually gets dithered against by returning its own colors_actual — see below. That return, when present, wins over everything device.colors_actual could have reported: the full chain for a render is script > dev-override / render-opts > panel.colors_actual > measured header > none. A mismatched length anywhere in that chain never fails the render — the offending layer is skipped with a warning and the next one down is tried.

Which palette a pixel is matched against depends on how it is marked. Content inside a data-byonk-tone="continuous" region is matched against the measured colours; everything else is matched against the official ones. So measured colours steer the palette index only for the parts of the document you marked as continuous-tone — see Marking continuous-tone content.

Either way, the PNG that gets sent to a real device is drawn in the nominal palette (colors) — the device itself maps index to physical ink, so sending it nominal colours is correct whichever palette the matching targeted. This split only matters if you’re inspecting the raw PNG bytes; render_screen’s use_actual (see the MCP guide) exists precisely so an authoring agent can instead see what the panel will really look like.

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.max_error       -- 1.0 (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,
  max_error = (device.dither.max_error or 1.0) * 0.5,
  -- noise_scale not returned -> keeps panel/device value
}
FieldTypeDescription
algorithmstring or nilPre-script resolved dither algorithm
max_errornumber 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 four HTTP functions: http_request (full control), http_get (GET shorthand), http_post (POST shorthand), and http_response, which returns the whole reply so a script can check whether the request actually succeeded.

Which to use. http_get and friends return only the body, so a 404 or a 500 arrives looking exactly like data. If the screen should react to a failure — or say what went wrong — use http_response.

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

http_response(url, options?)

Makes a request and returns the whole reply instead of just the body. Takes exactly the same options as http_request.

Unlike the other three, it does not raise when the request fails. A refused connection, a timeout and a 500 are all outcomes the script can inspect and decide about, because what a failure means is the screen’s business, not byonk’s.

local reply = http_response("https://api.example.com/data", { timeout = 15 })

if not reply.ok then
  -- reply.error is set when nothing arrived at all; otherwise it was a bad status.
  error("Could not reach the API: " .. (reply.error or ("HTTP " .. tostring(reply.status))))
end

local data = json_decode(reply.body)

Returns: table

FieldTypeDescription
okbooleantrue only for a 2xx status
statusnumberThe HTTP status, or nil if no reply arrived
bodystringThe response body, or nil if no reply arrived
headerstableResponse headers, with lowercased names
errorstringWhy nothing arrived, or nil if a reply did
from_cachebooleanWhether this came from the cache_ttl cache

Notes:

  • ok is about the status only. A 404 that returns a helpful JSON error body still has ok = false, and its body is there for you to read.
  • Only successful responses are cached, so an error page is never served as data for the rest of a cache_ttl window.
  • If a screen has no sensible way to carry on, raise. Calling error() makes byonk draw its own error screen on the device, naming the screen and your message, and makes byonk render exit non-zero. That is far more useful than a screen that renders with pieces missing.

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 own folder.

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

Parameters:

NameTypeDescription
pathstringRelative path within the screen’s folder

Returns: string - Binary file contents

Throws: Error if the file cannot be read

Asset location convention:

screens/examples/hello/     # The "hello" screen folder
├── meta.yaml              # Title, description, params
├── script.lua             # Data-fetch logic
├── screen.svg             # Template
├── logo.png               # Asset
└── icon.svg               # Asset

When read_asset("logo.png") is called from this screen’s script.lua, it reads screens/examples/hello/logo.png — a file sitting alongside script.lua in the screen’s own folder.

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.

Image Functions

image_process(bytes, options)

Prepares a photograph for an e-ink panel: decodes it, optionally crops and resizes it, tone-maps it, sharpens it, and re-encodes it as a data: URI ready to drop into an SVG <image href="...">.

An e-ink panel is a low-dynamic-range display with a handful of colours. A photograph sent to it untouched loses its shadows to a black sink, blows its highlights to paper white, and desaturates until nothing reaches a coloured palette entry. These options exist to fix that before dithering ever sees the image.

local photo = http_get("https://example.com/photo.jpg")
local src, w, h = image_process(photo, {
  preset        = "eink",
  palette_aware = true,
  fit           = "cover",
  width         = layout.width,
  height        = layout.height,
})

return { data = { image_src = src, image_w = w, image_h = h } }

Parameters:

NameTypeDescription
bytesstringEncoded image bytes (PNG, JPEG, etc.), e.g. from http_get
optionstable (optional)Geometry, tone and output options (see below)

Returns: string, integer, integer — the data: URI, and the result’s actual width and height in pixels. With fit = "cover" or "stretch" these always equal the width/height you asked for. With fit = "contain" or "none" they can differ — see the fit table below — so use the returned values, not the ones you passed in, when positioning the image in the SVG.

All options are optional. image_process(bytes, {}) decodes and re-encodes without changing anything.

Geometry options:

NameTypeDefaultDescription
croptablenone{ x = ..., y = ..., w = ..., h = ... }, each 0–1, normalised to the decoded image (after EXIF orientation is applied, before resizing). x/y default to 0; w/h are required if crop is given at all. The region must lie within the image or image_process raises an error.
fitstring"cover"How the (possibly cropped) image meets width/height. One of "cover", "contain", "stretch", "none" — see below.
width, heightintegernoneTarget size in pixels, up to 4096 each. Give both, one, or neither — see fit below for what each combination does.

How the four fit modes differ — this is the part a screen author most often gets wrong:

fitBehaviour
cover (default)Fills the width×height box exactly, cropping whatever doesn’t fit. The result is always exactly width×height. Use this for a full-bleed photo.
containScales to fit inside the box, preserving aspect ratio, and crops nothing. The result is not padded up to width×height — one dimension comes out smaller than requested (e.g. asking for 80×48 on a 200×100 source returns 80×40). Read the two return values to find out how big it actually is.
stretchFills the box exactly like cover, but scales each axis independently instead of cropping — the image distorts if the box’s aspect ratio doesn’t match the source’s.
noneIgnores width/height entirely and keeps the (cropped) source’s own pixel size. Set this only when you want the source resolution and are positioning the <image> yourself.

If you give only one of width/height (in any fit mode except none), the other is derived from the source’s aspect ratio.

Photo (tone) options. Order is fixed and not something you control: crop → resize → exposure → white balance → auto-levels/blacks/whites → highlights/shadows → contrast → curve → clarity → vibrance → saturation → grayscale/invert → sharpen. Resizing first is what keeps a 24-megapixel source cheap; sharpening last, at output size, is what makes it mean anything.

OptionRangeEffect
exposure−5…5Stops of exposure, applied in linear light
temperature−100…100Positive is warmer, applied in linear light
tint−100…100Positive is greener, applied in linear light
auto_levelsbooleanStretch the histogram to the full range before the other tone options
blacks, whites−100…100Nudge where the black/white points land
highlights, shadows−100…100Recover the two ends. The most useful pair on e-ink
contrast−100…100S-curve about mid-grey
curve{ {in, out}, ... }Point tone curve, sorted by input, for anything the sliders miss
clarity−100…100Large-radius local contrast. The single option that makes a dithered photo readable
vibrance−100…100Saturation boost weighted toward dull pixels, so muted colours reach a coloured palette entry
saturation−100…100Global saturation
grayscale, invertboolean
sharpen{ amount = 0…100, radius = 0.3…10 }Applied last, at output size. amount defaults to 40 and radius to 1.0 if you set the table but omit one of them
preset"eink" | "none" (default)A tuned base layer: turns on auto_levels, opens up shadows, pulls back highlights, and adds clarity, vibrance and a light sharpen. Any of those fields you set explicitly yourself overrides the preset’s value for that field — the rest of the preset still applies
palette_awarebooleanSee below

There are 17 fields in total on the underlying pipeline (16 tone/geometry options above plus the palette-derived black/white points palette_aware sets internally) — preset = "eink" is a starting point for most of them, not a replacement for the ones you still need to set (fit, width, height).

palette_aware, when true, places the tone-mapped black and white points at the panel’s real darkest and lightest measurable colours instead of pure black/white, so the tone mapping doesn’t spend range the panel can’t show. It looks at device.colors_actual (the panel’s measured colours) first, falling back to device.colors (the configured palette) if the device isn’t calibrated. If neither is available, it does nothing and logs a warning — a screen using it still renders everywhere, just without the adjustment on unconfigured devices.

Output options:

NameTypeDefaultDescription
format"png" | "jpeg""png"Output image format
quality1–10090JPEG quality. Ignored for PNG

Throws: Error if the image can’t be decoded, if crop lies outside the image, if the source exceeds internal size limits (32 MB encoded, 40 megapixels decoded, 4096px per output dimension), or if a tone option is out of range. Wrap in pcall if a screen should survive a bad image:

local ok, src = pcall(function()
  return image_process(photo, { preset = "eink" })
end)
if not ok then
  log_error("image failed: " .. tostring(src))
end

Out-of-range tone values (exposure, temperature, tint, blacks, whites, highlights, shadows, contrast, clarity, vibrance, saturation, sharpen.amount, sharpen.radius) are errors, not silent clamps, and the error message names the field, the value you gave, and the valid range — so exposure = 30 (a typo for 3.0) is caught instead of quietly producing a blown-out image. Unknown fit, preset or format strings are errors too, for the same reason.

A wrong-typed value is different: it is silently ignored, not rejected, matching http_request, qr_svg and the dither options elsewhere in this API. image_process reads each option with Lua’s normal number/string coercion, so exposure = "3.0" works exactly like exposure = 3.0 — but exposure = "abc", width = "twenty", crop = "half" or sharpen = "lots" fail that coercion and are dropped as if you hadn’t set them at all, with no error and no log line. Likewise quality = 300 doesn’t fit in the underlying integer type and silently falls back to the default of 90. If a photo option doesn’t seem to be taking effect, double-check its type before assuming a bug.

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:

-- script.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
}
<!-- screen.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
  colors_actual = { "#0A0A0A", "#E8E6E0", "#A83A30" },  -- Optional: override measured colours
  dither = "atkinson",      -- Optional: dither algorithm
  max_error = 1.0,        -- Optional: cap on accumulated diffusion error
  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 device’s refresh is used, and failing that the screen’s default_refresh from config.

The screen outranks the device here — the opposite of dither. Only the script knows when its own content next changes, so a screen that returns refresh_rate keeps that interval even on a device configured with refresh. Byonk logs when a device’s refresh is displaced this way, so an operator can see why their setting is inert.

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" }
}

colors_actual

FieldTypeDescription
colors_actualtable or nilOptional array of hex RGB strings overriding the measured colours used for dithering, for this render only

This does not change the display palette itself (colors, above) — it changes what the dithering algorithm targets while still emitting that palette. It’s how a screen adapts its own render to a calibration it has computed, or how an author previews one:

return {
  data = { ... },
  colors        = { "#000000", "#FFFFFF", "#FF0000", "#00FF00" },
  colors_actual = { "#0A0A0A", "#E8E6E0", "#A83A30", "#3F7A45" },
}

Must have the same number of entries as the resolved palette (colors, above). If it does not, the render still succeeds: the value is ignored, the next source in the chain is used instead, and a warning is written to the script log. On the authoring path this is visible in the MCP render_screen tool’s log field; on /dev/render the warning goes only to the server’s tracing output — the dev UI receives raw PNG bytes and has no log surface to show it on.

A script that returns colors_actual wins over every other source, including the dev colour-tuning popup. The winning source isn’t rendered anywhere in the dev UI, but it is visible as measured_source in the MCP render_screen tool’s diagnostics, and as a tracing field in server logs — so it’s inspectable rather than mysterious, just not from the dev UI itself. See device.colors_actual above for the full precedence chain and why measured colours steer dithering while the emitted PNG palette can still be the nominal one.

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. Device config dither — set per-device in config.yaml
  3. Script dither — returned in the script result table
  4. Default"atkinson"

The device outranks the screen here. The algorithm suits the panel, not the content, and the operator who set it on the device cannot see a screen replacing it. A screen’s dither still applies on any device that does not name one. When both name one and they differ, the device’s is used and Byonk logs which value was dropped.

-- Use Floyd-Steinberg dithering for a screen that displays images
return {
  data = { image_url = "..." },
  refresh_rate = 3600,
  dither = "floyd-steinberg"
}

font_hinting

FieldTypeDescription
font_hintingtable, false, or nilOverrides how byonk hints this screen’s text

Omit it. Byonk already hints text for you, choosing per render from the panel: mono hinting with 1-bit glyphs on a black-and-white panel, smooth anti-aliased hinting once there are greys. This key is only for overriding that.

return {
  data = { ... },
  refresh_rate = 300,
  font_hinting = {
    engine = "auto",                             -- interpreter | auto | auto_fallback
    target = "mono",                             -- or a table, see below
    variants = {
      ["Crisp Body"] = { font = "Outfit", hinting = { target = "mono" } },
    },
  },
}
  • font_hinting = false turns hinting off entirely.
  • target is "mono", "smooth", "light", "lcd", "vertical_lcd", or a table: { mode = "mono", aliased = false }, or { mode = "light", symmetric = true, preserve_linear_metrics = false }.
  • A directive that only declares variants keeps byonk’s adaptive default; state a target to replace it.
  • A variant is a name you invent for a font hinted a particular way, so one screen can render the same family two ways. Its font must be an installed family and its name must not be — both are checked when the script runs, and a mistake is an error rather than a silently different font.

Errors here are hard errors, unlike the dither knobs above, which ignore a malformed value. A mistyped hinting target would otherwise render as something you never asked for with nothing said about it.

See Font Hinting for the full reference, including the one trap: on a black-and-white panel a variant that opts out of mono hinting is still drawn 1-bit and its stems can drop out. The fix is text-rendering="optimizeLegibility" on those elements — byonk warns when a screen sets this up.

max_error, noise_scale, chroma_clamp, strength

FieldTypeDescription
max_errornumber or nilCaps the accumulated diffusion error a pixel may carry (default 1.0)
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",
  max_error = 1.0,
  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

Time

  • os.time, os.date, os.clock, os.difftime

(For formatting and parsing, prefer byonk’s own time_format and time_parse, which take a timezone.)

Other

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

Not available

A screen script runs in a sandbox. It gets no filesystem, no subprocess and no environment — the only ways out are the HTTP functions above and read_asset, which stays inside the screen’s own folder.

The following are removed and evaluate to nil:

RemovedWhy
the whole io libraryfile access
the whole package libraryrequire still works, but is a byonk resolver limited to the screen’s own repo and byonk-base
dofile, loadfilefile access
loadcompiles arbitrary bytecode, which can escape the VM
os.execute, os.exitrun programs / stop the server
os.getenv, os.setlocaleread the server’s environment / change its global state
os.remove, os.rename, os.tmpnamefile access
debugLua’s introspection escape hatch

This matters because a screen repo is re-fetched on a timer: an upstream change runs new Lua on your server without anyone reading it first.