Live Demo →

trmnl-lua

A self-hosted TRMNL-compatible e-ink display server, built in LuaJIT. One process, ~15MB RAM, no database.


Why

TRMNL is an e-ink display device that polls a server for images to show. The official server is a full Ruby/Hanami app with PostgreSQL, Valkey, and Sidekiq — a lot of infrastructure for a personal display showing a clock.

This project replaces that stack with something minimal: a single LuaJIT process, ~15MB of RAM at idle, no database, no queue, no background workers. All state lives in a Backblaze B2 bucket. The container is fully stateless.


How It Works

TRMNL device  →  GET /api/setup    →  register, get image_url
              →  GET /api/display  →  poll for next image + refresh interval
              →  GET /images/*.bmp →  fetch rendered BMP (proxied from B2)

On each render:

  1. The plugin's draw() function runs inside LÖVE2D headlessly (offscreen Mesa softpipe)
  2. LÖVE2D saves a PNG
  3. ImageMagick converts PNG → 1-bit 800×480 BMP with Floyd-Steinberg dithering
  4. BMP is uploaded to Backblaze B2 via S3-compatible API with AWS SigV4
  5. Device fetches the BMP through this server's /images/ proxy

Plugin API

Plugins are plain Lua files that use the love.graphics API:

-- plugins/clock.lua
local M = {}
M.refresh_seconds = 60

function M.draw()
  love.graphics.setColor(0, 0, 0)

  local time_font = love.graphics.newFont(108)
  love.graphics.setFont(time_font)
  love.graphics.printf(os.date("%H:%M"), 0, 150, 800, "center")

  local date_font = love.graphics.newFont(30)
  love.graphics.setFont(date_font)
  love.graphics.printf(os.date("%A, %B %d"), 0, 290, 800, "center")
end

return M

Plugins can be stored locally (bundled in the container) or uploaded to B2 under plugins/<name>.lua for hot-reload without redeployment.


HTTP API

MethodPathAuthDescription
GET/api/setupID header (MAC)Register device, get API key + first image
GET/api/displayID + Access-Token headersGet current image URL + refresh interval
POST/api/logAccess-TokenDevice log (written to stdout)
GET/images/:file.bmpBMP proxy (fetched from B2)
GET/healthHealth check → {"ok":true}
GET/demoLive render preview

Tech Stack

ComponentRole
LuaJITRuntime — controls everything
libmicrohttpd (FFI)HTTP server, external polling mode
libcurl (FFI)HTTP client for Backblaze B2
Backblaze B2State (devices.json, screens.json) + rendered BMPs
LÖVE2D (offscreen)Render engine — spawned per render
ImageMagickPNG → 1-bit BMP3 conversion
Mesa softpipeSoftware OpenGL for LÖVE2D headless

Deployment

# Build
sudo docker build -t trmnl-lua:latest .
sudo docker save trmnl-lua:latest | microk8s ctr images import -

# Apply manifests
microk8s kubectl apply -f k8s/trmnl.yaml

# Secret (via Doppler)
doppler run -- sh -c 'microk8s kubectl create secret generic trmnl-b2 -n trmnl \
  --from-literal=B2_ACCESS_KEY_ID="$B2_ACCESS_KEY_ID" \
  --from-literal=B2_SECRET_ACCESS_KEY="$B2_SECRET_ACCESS_KEY" \
  --from-literal=B2_BUCKET="$B2_BUCKET" \
  --from-literal=B2_REGION="$B2_REGION"'

The container image is ~140MB. At idle it uses ~15MB RAM and 0% CPU.


Source

git.mntechstudios.com/forgejo_admin/trmnl-lua