The http table copies crosspoint-reader's signatures exactly -- get/head/delete/post/ patch returning (body|nil, status), download taking maxBytes/expectedSize/sha256, the same 50000 byte body cap and the same -1 for a request that never left the device -- so a script that talks to a server runs on either firmware. docs/lua-api-parity.md records that, and every other place the two APIs agree, differ for a reason, or differ because nobody noticed. Two crosspoint behaviours are deliberately not copied. It reinterprets a string in argument 2 of a GET as a request body, which turns a mistyped headers table into a silent protocol error. More seriously it calls setInsecure() on every request, so TLS is encrypted but unauthenticated on the very path a firmware update would use; this verifies against the root bundle already sitting in the framework, and the emulator confirms expired.badssl.com is refused while a wrong sha256 deletes the file. Downloading exposed two failures worth naming. A 2KB read buffer on the stack tripped the loop task's canary because a TLS handshake had already spent it, and the hand-rolled read loop spun forever on a stream that stopped producing -- HTTPClient's own writeToStream handles both, so the loop is gone and the loop task gets 16KB. scripts/gen_lua_stubs.py generates stubs/esp32lcd.lua in the same LuaLS format crosspoint uses, reading annotations off the luaL_Reg tables so a module's docs sit with its registration. make test runs --check, which crosspoint's copy never wired up.
esp32-lcd
Lua-app firmware for the 4.0" ESP32-32E display (lcdwiki E32R40T): ST7796S 320x480 SPI panel + XPT2046 resistive touch + microSD. Apps are plain Lua files on the SD card, launched from an on-screen menu. Inspired by crosspoint-reader's plugin system.
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 runtime loop and the fallback screen
settings.{h,cpp} /settings.lua persistence
gfx/ drawing maths, free of Arduino headers so it is testable
lua/
lua_app.{h,cpp} app lifecycle: the lua_State, callbacks, teardown
bindings.h shared internals for the binding files
bindings/ one file per Lua table: gui, sys, input, fs, wifi
module_loader SD-backed require, loadfile and dofile
sdcard/ copied to the card: apps/ and lib/
test/ host tests, run by `make test`
scripts/ gen_lua_stubs.py, which writes stubs/esp32lcd.lua
stubs/ generated LuaLS definitions; point your editor here
docs/ lua-api-parity.md, the crosspoint-reader comparison
Copy sdcard/ to the SD card root: apps live in /apps/<name>/main.lua and shared
Lua modules in /lib. The launcher is itself an app (/apps/launcher/main.lua); the
firmware only draws a fallback screen if it cannot start.
Lua API
Apps define optional callbacks: setup(), draw() (~30fps cap), on_touch_down(x, y),
on_touch_up(x, y), on_touch(x, y) (tap alias, fired on release), and on_tick()
(enabled by setting TICK_MS). Call sys.exit() to return to the launcher.
require reads from the SD card: /apps/<name>/?.lua first, then /lib/?.lua.
| Module | Functions |
|---|---|
gui |
width(), height(), clear(color), fillRect(x,y,w,h,c), drawRect(x,y,w,h,c), fillCircle(x,y,r,c,bg), drawLine(x1,y1,x2,y2,c), drawText(text,x,y,fg,bg), roundRect(x,y,w,h,radius,bg,top,bottom,border), fontHeight(), textWidth(text), setRotation(0-3), color(r,g,b) |
input |
getTouch() -> x,y or nil, getRawTouch() -> raw ADC x,y or nil, touched() |
fs |
readFile(path), writeFile(path, data), exists(path), listFiles(path), listDirs(path) |
sys |
millis(), exit(), launch(path), setCalibration(x0,y0,x1,y1), getRotation(), setRotation(deg), getTheme(), setTheme(name) |
wifi |
scan() -> {ssid,rssi,secure}[], connect(ssid,password), status() -> {state,ssid,ip,rssi}, forget() |
log |
info(msg) (serial) |
Colors are RGB565 integers; build them with gui.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 sys.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.
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 sys.setRotation() is safe at any time. gui.setRotation(0-3)
changes only the current frame; the launcher restores the saved rotation when an
app exits. The glass itself is always portrait, so a rotated UI is drawn
sideways on it.
make test # everything below, non-zero on the first failure
| Test | Covers |
|---|---|
test/round_rect_test.cpp |
corner geometry, coverage and RGB565 blending |
test/ui_layout.lua |
layout rects, hit testing, press capture |
test/ui_theme.lua |
palette derivation and inheritance |
test/settings_calibration.lua |
calibration maths, menu and wifi flows |
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
gui.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 (/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 screen = ui.screen(ui.box{pad = 12, gap = 8, color = BLACK, bg = WHITE,
ui.text("settings"),
ui.button{label = "calibrate touch", on_press = calibrate},
})
function draw() screen:draw() end
function on_touch_down(x, y) screen:down(x, y) end
function on_touch_up(x, y) screen:up(x, y) end
Sizes are pixels (>= 1), a fraction of the parent's content box (< 1), or "auto"
(the default on the flow axis; the cross axis fills the parent). Boxes take pad,
gap, row, align and at for absolute placement. color, bg, radius,
gradient and the press palette inherit from the root, so a theme is set once.
The toolkit owns press capture (release outside cancels), an 80 ms minimum pressed
duration, touch slop, and per-component dirty tracking. Any table with measure,
place, draw and hit drops into the tree, so custom components need no buy-in.
Not implemented: scrolling, so a list longer than the screen is unreachable.
Themes (/lib/theme.lua)
A theme is three seed colors; ui.lua derives the rest, so adding a component never
means editing a theme, and a theme cannot state its own contrast wrongly:
return {
light = {bg = {255, 255, 255}, fg = {0, 0, 0}, accent = {0, 120, 255}},
midnight = {bg = {12, 14, 30}, fg = {220, 225, 240}, accent = {255, 120, 0}},
}
| Role | Derivation |
|---|---|
bg, fg, accent |
the seeds |
muted |
fg blended 45% toward bg, for secondary text |
accent_fg |
black or white, whichever the accent's luminance demands |
face, face_pressed |
button gradients, from bg and accent |
radius |
6 |
Any derived role can be pinned in the theme file (radius = 0, muted = {120,120,120}).
The settings app cycles through the names it finds, and stores only the name.
Components get the theme by inheritance, so a themed app names no colors at all.
The division is: inheritance carries context (self.color, self.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", press_bg = DANGER}
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).