Files
slate32/README.md
T
evan 0c0365fe45 docs: drop the crosspoint-reader parity comparison
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.
2026-08-05 17:11:13 -04:00

295 lines
15 KiB
Markdown

# 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
```sh
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
`require`d: `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:
```lua
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
```sh
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:
```lua
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:
```lua
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:
```lua
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:
```lua
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:
```lua
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).