0c0365fe45
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.
295 lines
15 KiB
Markdown
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).
|