It compared against gui.*, sys.delay, on_tick and a required init(), none of which exist on either firmware now. The comparison was a stand-in for a shared API; lib/esp32-lua-api is that API, so agreement is enforced by the build rather than recorded in prose.
15 KiB
slate32
A Lua app platform for a cheap ESP32 touchscreen. Apps are plain Lua files on the SD card, launched from an on-screen menu, drawn with a component toolkit and themed from the card. The firmware is the runtime; everything a user sees ships as Lua.
Built for the 4.0" ESP32-32E display (lcdwiki E32R40T): ST7796S 320x480 SPI panel +
XPT2046 resistive touch + microSD. The Lua platform itself — bindings, the widget tree,
app loading — is the lib/esp32-lua-api submodule, shared with crosspoint-reader, which
runs the same apps on e-ink. This repository owns the panel, touch, network and
persistence; a binding changes there, not here.
Build & flash
nix develop # provides pio, make, lua 5.4 and a host compiler
make build # build
make test # host tests (see below)
make upload # flash over USB-C
make monitor # serial logs
src/
main.cpp boot, the runtime loop and the fallback screen
settings.{h,cpp} /settings.lua persistence, read before any lua_State exists
net.{h,cpp} Wi-Fi bring-up and the clock sync that follows it
gfx/ drawing maths, free of Arduino headers so it is testable
host/
lua_host.{h,cpp} owns the Runtime: boot, navigation, touch and the frame loop
providers_*.cpp this board's half of lua/providers.h -- gui, fs, sys, net, ble
lib/esp32-lua-api/ submodule: the Lua runtime, bindings and shared modules
sdcard/ copied to the card: .lua/apps and .lua/lib
test/ host tests, run by `make test`
Copy sdcard/ to the SD card root. The firmware knows one path, /.lua/main.lua, and
that file decides the rest: apps live in /.lua/apps/<name>/main.lua, shared modules in
/.lua/lib, and the status bar is a node it puts above whichever app is mounted. Home is
itself an app; the firmware only draws a fallback screen if it cannot start.
Lua API
/.lua/main.lua returns the table the firmware calls: start(args) mounts an app, and
draw(deltaMs) plus the on_touch* handlers forward events. args is whatever the
previous state passed, or nil at boot, which is how main.lua knows to open the launcher.
An app is a table too, returned from its main.lua: optional init(arg), node()
returning the subtree for the current screen, draw(deltaMs) (~30fps cap), and
on_touch_down/move/up plus on_touch (tap alias, fired on release) for an app that
paints its own surface. Widgets are dispatched by the tree, so an app built from them
defines none of these. Call nav.back() to return to the previous route, which the back
control in the status bar does too; an empty history returns to Home.
Navigation is require "nav", not a firmware call. nav.launch(route, arg) remembers the
current route, nav.replace(route, arg) does not, and nav.back() pops. All three go
through sys.startApp(path, args), which tears the runtime down and starts over: the
firmware keeps no history and no app identity, so nav puts them in the arguments, which
cross as JSON. init receives whatever arg the launching route passed, or nil from the
launcher. An app is named after its directory until it calls nav.setTitle("settings - wifi"), which the status bar picks up on its next tick.
require reads from the SD card: the running app's directory first, then
/.lua/lib/?.lua, both set by main.lua.
Globals are the firmware's contract, declared in lib/esp32-lua-api/lua/api. Modules are
required: cjson from the library, everything else from the card.
| Module | Functions |
|---|---|
screen |
getWidth(), getHeight(), clear(c), fillRect, drawRect, drawLine, drawPixel, drawCircle, fillCircle, roundRect, fillPolygon, drawBmp, drawText, getTextWidth, getFontHeight, getRotation(), setRotation(deg), getTheme(), setTheme(name), color(r,g,b) |
tree |
the widget tree the panel lays out and paints; apps reach it through ui |
touch |
getPoint() -> x,y or nil, getRawPoint() -> raw ADC x,y or nil, isTouched(), setCalibration(x0,y0,x1,y1) |
fs |
exists, fileSize, listFiles, listDirs, mkdir, readFile, readLineAt, remove, removeTree, rename, writeFile |
sys |
getMillis(), startApp(path, args), getMemory() -> free,total,largest, isClockSynced(), getTimezone(), setTimezone(tz), getAPIVersion(), hasFeature(name) |
timer |
after(ms, fn), every(ms, fn), cancel(id) |
wifi |
scan() -> {ssid,rssi,secure}[], connect(ssid,password), getStatus() -> {state,ssid,ip,rssi}, getLocalIP(), isConnected(), disconnect(), forget() |
http, ble, log |
requests, BLE/GATT, and debug/info/error to serial |
cjson |
require "cjson": encode(value), decode(text), null |
nav |
require "nav": launch(route, arg), replace(route, arg), back(), canGoBack(), getTitle(), setTitle(text), getRoute(), getArg(), getDataPath() |
Accessors are getName / setName / isName; bare names are actions (screen.fillRect)
or pure conversions (screen.color, http.urlencode). A setter that needs a follow-up
call is a bug in the setter: screen.setRotation() rotates the panel, saves the choice
and re-clips. The one exception is the theme, since C cannot reload the Lua palette —
apps call ui.setTheme(), which writes through screen.setTheme() and then repaints.
Colors are RGB565 integers; build them with screen.color(r, g, b).
Settings
/settings.lua on the SD card is a Lua table literal, read at boot and rewritten
by the settings app. It is Lua rather than JSON because the firmware already links
Lua, so it needs no parser on either side:
return {
rotation = 90,
theme = "dark",
touch = { x0 = 188, y0 = 232, x1 = 3799, y1 = 3800 },
wifi = { ssid = "network", password = "secret" },
}
Missing file means the built-in defaults are used. apps/Settings walks two
crosshairs and saves the result via touch.setCalibration(), cycles rotation
through 0, 90, 180 and 270 degrees, and scans/selects Wi-Fi networks with an
on-screen password keyboard. A saved network reconnects at boot.
Settings live in C++ because the firmware reads rotation and calibration before any
lua_State exists, and calibration again on every touch. Lua reaches them through the
screen and touch bindings, so there is one writer.
Wi-Fi credentials are Lua-escaped but stored as plaintext on the SD card. Treat the card like any other device containing a saved password.
Rotation never needs a recalibration: calibration is stored in the panel's
rotation-0 frame (320x480 raw ADC space) and the current rotation is applied
afterwards, so screen.setRotation(degrees) is safe at any time. It rotates the panel
and persists the choice in one call; an app that rotates transiently for its own purposes
puts the old value back, which is what touch calibration does. The glass itself is always
portrait, so a rotated UI is drawn sideways on it.
Tests
make test # everything below, non-zero on the first failure
| Test | Covers |
|---|---|
test/round_rect_test.cpp |
corner geometry, coverage and RGB565 blending |
test/keyboard.lua |
key geometry and what a tap enters |
test/settings_calibration.lua |
calibration maths, menu and rotation flows |
test/settings_busy.lua |
the settings app while a scan is in flight |
test/statusbar_dirty.lua |
per-field repaints, the back control, fullscreen |
test/ble.lua |
BLE scanning through the settings app |
make test also runs the submodule's suite, which owns layout geometry
(ui_layout_test.cpp), the shared modules and the generated API check.
The Lua tests run against test/fake_device.lua, the one place the binding surface is
stubbed, and make test refuses to run on anything but Lua 5.4 — the version the
firmware vendors, so the tests cannot pass on a dialect the device will not run.
Drawing rounded surfaces
screen.roundRect(x, y, w, h, radius, bg, top, bottom, border) draws a whole surface in
one pass. Fill and border come from the same signed distance field, so they cannot
disagree at the corners, and edge pixels are anti-aliased by coverage. top/bottom
are gradient stops (pass one for a solid, or nil for no fill) and border may be
nil. Since the panel has no alpha channel, bg is the colour underneath that edge
pixels blend into — pass the surface the shape sits on, not the shape's own fill.
The geometry lives in src/gfx/round_rect.h, free of Arduino headers, because the
previous hand-rolled corner arc was wrong in a way only a pixel test would catch.
UI toolkit (/.lua/lib/ui.lua)
Apps describe nesting and sizes fall out, borrowing CSS block flow and the box model without the cascade:
local ui = require("ui")
local M = {}
function M.node()
return ui.box{pad = 12, gap = 8,
ui.text("settings"),
ui.button{label = "calibrate touch", on_click = calibrate},
}
end
return M
main.lua mounts that with ui.mount(), so nothing here dispatches touches or paints
frames. A screen changes by changing state and calling ui.rebuild(), which runs node()
again; building a node outside a rebuild is refused, because it would reset the arena
under the screen already on the panel.
Sizes are pixels (>= 1), a fraction of the parent's content box (< 1), "fill" for all
of it, or "auto" (the default on the flow axis; the cross axis fills the parent).
Boxes take pad, gap, row, align, justify, border, capture, and at for
absolute placement. An app sizing itself to its frame reads ui.frame() rather than
screen.getHeight(), because layout has not run while a builder is running and the panel
is not the box the app was given — the status bar takes the difference.
Dialogs are not a layer the toolkit manages. A dialog is a node the app includes when its state calls for one, placed absolutely so it covers the flow rather than joining it, and dismissed by rebuilding without it:
function M.node()
local content = ui.box{pad = 12, gap = 8, rows()}
if not confirming then return ui.box{content} end
return ui.box{content, ui.confirm{
title = "forget network?", ok = "forget",
on_ok = function() wifi.forget(); confirming = false; ui.rebuild() end,
on_cancel = function() confirming = false; ui.rebuild() end,
}}
end
Nothing has to survive a rebuild, because the tree is derived from state rather than
mutated alongside it. capture = true makes a component swallow the taps its children
missed, which is what stops a dialog being tapped through; it deliberately has no
on_press, so a stray touch on a resistive panel answers nothing.
dimmed = true on a node darkens it and everything it contains, and ui.confirm opts
its card back out, so a dialog sits lit over dimmed content. There is no alpha and no
framebuffer to blend against: the whole palette is re-derived from seeds mixed toward
black, and the content behind is simply repainted in those colors. Two color roles keep
this honest — bg is the fill a node paints, surface is what sits underneath it, and
anti-aliased edges blend into surface. A rounded card that blends into its own bg
leaves square corners, which only shows once something behind it is a different color.
The toolkit owns press capture (release outside cancels), an 80 ms minimum pressed
duration, touch slop, and per-node dirty tracking. A node is a 16-byte struct in a flat
arena rather than a Lua table, so ui.button{...} hands back an integer id and anything an
app wants to hang off a node lives in a table keyed by that id. Update a live node with
ui.setText(id, text) or ui.invalidate(id); a full screen change is ui.rebuild().
ui.custom{paint = ...} paints itself through the screen bindings, which is the seam
between composition (Lua on the card) and primitives (C++ and a reflash). A painter that
draws its own press feedback sets press_style = false so pressing one part does not
repaint the whole node, and repaints one region from on_down, restoring it in
on_unpress after the 80 ms hold.
Not implemented: scrolling, so a list longer than the screen is unreachable.
/.lua/lib/keyboard.lua provides a single-node, staggered QWERTY keyboard so its keys do not
retain dozens of component tables. It owns shift, number/symbol pages, backspace, per-key
pressed feedback, and release-over-the-same-key activation:
keyboard.new{
value = password,
max_length = 64,
on_change = function(value) password = value end,
on_submit = connect,
}
Themes
A theme is three seed colors and a radius, declared in ui.lua; the rest is derived, so
adding a component never means editing a theme, and a theme cannot state its own contrast
wrongly:
light = { background = {255, 255, 255}, color = {0, 0, 0}, accent = {0, 120, 255}, radius = 6 },
dark = { background = {18, 18, 20}, color = {235, 235, 235}, accent = {166, 118, 255}, radius = 6 },
| Role | Derivation |
|---|---|
background, color, accent |
the seeds |
muted |
color blended 45% toward background, for secondary text |
accentColor |
black or white, whichever the accent's luminance demands |
face, pressedFace |
button fills, from background and accent |
radius |
6, or 0 for mono |
The settings app cycles through the names it finds and stores only the name. Applying one
is ui.setTheme(), which persists through screen.setTheme(), rebuilds the palette and
repaints — apps never call screen.setTheme() directly.
Components get the theme by inheritance, so a themed app names no colors at all.
The division is: inheritance carries context (color, bg — what surface am I on),
while ui.theme carries constants (ui.theme.accent). Read the theme directly only
when drawing outside the component tree, like the calibration crosshair. An explicit value
on any node still wins, which is how a component deviates:
ui.button{label = "delete", pressedFace = DANGER}
The style roles a node may set are color, fill, border, face, pressedFace,
pressedColor, focusColor, radius, font and textStyle. They are sparse and
inherited: a role a node does not set is answered by the nearest ancestor that does, so a
node naming no colours costs nothing.
The C++ Lua-error and SD-failure screens stay hardcoded high contrast, since they can fire when the settings that name the theme are themselves unreadable.
Pin map (fixed by the board)
- Display (VSPI): SCK 14, MOSI 13, MISO 12, CS 15, DC 2, backlight 27, reset = EN
- Touch: shares display SPI, CS 33, IRQ 36
- SD: SCK 18, MOSI 23, MISO 19, CS 5
TFT_eSPI, XPT2046 and SD each grab the SPI controller but share the same wires; only one transfers at a time, which is safe here (see ponytail note in main.cpp).