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

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, grayscale, ditherPixel buffer
E-ink PNGPixel bufferQuantize to 4 levels, encode2-bit 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.

Grayscale Conversion

Colors are converted to grayscale using the ITU-R BT.709 formula:

Y = 0.2126 × R + 0.7152 × G + 0.0722 × B

This matches human perception of brightness.

Stage 4: Blue-Noise Dithering

E-ink displays only support 4 gray levels. Dithering creates the illusion of more shades.

Byonk uses blue-noise-modulated error diffusion dithering, an improved algorithm that reduces visible “worm” artifacts while preserving sharp edges for UI content.

How It Works

The algorithm improves upon standard Floyd-Steinberg in three ways:

  1. Serpentine scanning: Alternates row direction (left-to-right, then right-to-left) to reduce directional artifacts
  2. Blue noise modulation: Adds subtle randomness to the quantization threshold, breaking up repetitive patterns
  3. Energy-preserving error: Error is computed from the un-noised value to maintain correct brightness
For each pixel:
  1. Get blue noise value for this position (64×64 tiled pattern)
  2. Add noise offset to pixel value (modulates threshold)
  3. Quantize to nearest level: [0, 85, 170, 255]
  4. Calculate error from ORIGINAL value (not noised)
  5. Distribute error to neighbors (direction depends on row):

     Left-to-right:      Right-to-left:
         X   7/16        7/16   X
     3/16 5/16 1/16      1/16 5/16 3/16

Why Blue Noise?

Standard Floyd-Steinberg can produce visible “worm” patterns in mid-gray areas. Blue noise has a frequency distribution that appears random to the eye but lacks low-frequency components, producing more pleasing results on e-ink displays.

Gray Levels

LevelRGB ValueAppearance
0(0, 0, 0)Black
1(85, 85, 85)Dark gray
2(170, 170, 170)Light gray
3(255, 255, 255)White

Output Format

The final PNG is:

  • 2-bit indexed color (4 colors in palette)
  • 4 pixels per byte for compact 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