Custom TRMNL e-ink server in LuaJIT
- HTML 54.1%
- Lua 39%
- Shell 6.6%
- Dockerfile 0.3%
main.lua loads curl_multi before curl, causing ffi.cdef to fail with "declaration specifier expected" if types aren't yet declared. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| api | ||
| examples | ||
| ffi | ||
| k8s | ||
| plugins | ||
| public | ||
| renderer | ||
| scripts | ||
| test | ||
| .env.example | ||
| BUGS.md | ||
| CHANGELOG.md | ||
| cog.toml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| json.lua | ||
| log.lua | ||
| main.lua | ||
| metrics.lua | ||
| README.md | ||
| render.lua | ||
| router.lua | ||
| s3.lua | ||
| state.lua | ||
| VERSION | ||
| VISION.md | ||
trmnl-lua
A self-hosted TRMNL-compatible e-ink display server, built in LuaJIT. One process, ~15MB RAM, no database.
Live Demo · API Reference · Docs
Stack
| Component | Role |
|---|---|
| LuaJIT | Runtime — controls everything |
| libmicrohttpd (FFI) | HTTP server |
| libcurl (FFI) | HTTP client for S3-compatible storage |
| S3-compatible storage | State + rendered images (Backblaze B2 recommended) |
| LÖVE2D (offscreen) | Render engine |
| ImageMagick | PNG → 1-bit BMP3 |
| Mesa softpipe | Software OpenGL for headless LÖVE2D |
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
S3_ACCESS_KEY_ID |
Yes | — | S3 access key ID |
S3_SECRET_ACCESS_KEY |
Yes | — | S3 secret access key |
S3_BUCKET |
Yes | — | Bucket name |
S3_REGION |
Yes | — | Region (e.g. us-west-004 for B2, auto for R2) |
S3_ENDPOINT |
Yes | — | S3 endpoint URL (e.g. https://s3.us-west-004.backblazeb2.com) |
S3_PUBLIC_URL |
No | — | Base URL for public downloads (e.g. CDN or https://f004.backblazeb2.com/file/my-bucket) |
ADMIN_KEY |
No | — | Bearer token for admin API; endpoints return 503 if unset |
PORT |
No | 8080 |
HTTP server listen port |
SERVER_URL |
No | http://localhost:8080 |
Base URL embedded in image links returned to devices |
TRMNL_WIDTH |
No | 800 |
Render canvas width in pixels |
TRMNL_HEIGHT |
No | 480 |
Render canvas height in pixels |
Plugin API
Plugins are plain Lua files using the love.graphics API:
-- plugins/clock.lua
local M = {}
M.refresh_seconds = 60
function M.draw(W, H)
love.graphics.setFont(love.graphics.newFont(96))
love.graphics.setColor(0, 0, 0)
love.graphics.printf(os.date("%H:%M"), 0, 160, W, "center")
end
return M
Plugins are stored in S3 under plugins/<name>.lua and hot-reloaded without redeployment.
Quick Start
git clone https://git.mntechstudios.com/forgejo_admin/trmnl-lua
cd trmnl-lua && cp .env.example .env # fill in S3 credentials
docker compose up
Set your TRMNL device's custom server URL to http://your-host:8080. It registers automatically on first boot.
Deployment (microk8s)
sudo docker build -t trmnl-lua:latest .
sudo docker save trmnl-lua:latest | microk8s ctr images import -
microk8s kubectl apply -f k8s/trmnl.yaml
Releasing
This project uses Conventional Commits and cocogitto for automated semantic versioning.
Commit format
<type>[optional scope]: <description>
feat: new feature → minor bump (0.X.0)
fix: bug fix → patch bump (0.0.X)
chore: maintenance/tooling → no bump
refactor: code restructuring → no bump
perf: performance change → no bump
docs: documentation only → no bump
feat!: breaking change → major bump (X.0.0)
Cutting a release
# Preview what version would be bumped to
cog bump --dry-run
# Bump version, generate CHANGELOG.md, tag, and push
cog bump --auto
cog bump --auto reads commits since the last tag, determines the semver bump, updates CHANGELOG.md, commits it, creates an annotated tag, then pushes both to origin (via post_bump_hooks in cog.toml).
Checking commit hygiene
# Lint commits since last release
cog check -l
# Pretty conventional commit log
cog log