Files
slate32/README.md

252 lines
12 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 API is kept deliberately in step with
crosspoint-reader's, which runs the same kind of apps on e-ink — see
[docs/lua-api-parity.md](docs/lua-api-parity.md).
## 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 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/slate32.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`. Home is itself an app (`/apps/Home/main.lua`); the firmware only
draws a fallback screen if it cannot start.
## Lua API
Apps define `init(arg)`, which is required, plus optional `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 `sys.setTickInterval(ms)`). Call `sys.back()` to return to the previous route,
which the back button in the status bar does too; an empty history returns to Home.
`init` receives whatever string `sys.launch(path, arg)` passed, or `nil` from the launcher.
`launch` adds the current route to history; `sys.replace(path, arg)` does not. States share
no memory, so one string is the whole handoff; anything structured travels as a Lua literal
the receiver runs through `load()`. An app is named after its directory until
it calls `sys.setAppName("settings - wifi")`, which the status bar picks up on its next tick.
`require` reads from the SD card: `/apps/<name>/?.lua` first, then `/lib/?.lua`.
| Module | Functions |
|---|---|
| `gui` | `getWidth()`, `getHeight()`, `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)`, `getFontHeight()`, `getTextWidth(text)`, `getRotation()`, `setRotation(deg)`, `setFullscreen(on)`, `color(r,g,b)` |
| `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `isTouched()` |
| `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)`, `listDirs(path)` |
| `sys` | `getMillis()`, `delay(ms)`, `back()`, `launch(path, arg)`, `replace(path, arg)`, `getAppName()`, `setAppName(name)`, `getMemory()` -> `free,total,largest`, `isClockSynced()`, `setTickInterval(ms)` |
| `settings` | `getRotation()`, `setRotation(deg)`, `getTheme()`, `setTheme(name)`, `getTimezone()`, `setTimezone(tz)`, `setCalibration(x0,y0,x1,y1)` |
| `wifi` | `scan()` -> `{ssid,rssi,secure}[]`, `connect(ssid,password)`, `getStatus()` -> `{state,ssid,ip,rssi}`, `getLocalIP()`, `isConnected()`, `disconnect()`, `forget()` |
| `log` | `debug(msg)`, `info(msg)`, `error(msg)` (serial) |
Accessors are `getName` / `setName` / `isName`; bare names are actions (`gui.fillRect`)
or pure conversions (`gui.color`, `http.urlencode`). `settings.*` is persisted user
intent; `gui.getRotation()` is the frame actually being drawn, which differs while an
app rotates the panel transiently.
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:
```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 `settings.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 `settings.setRotation()` is safe at any time. `gui.setRotation(degrees)`
changes only the current frame; the firmware restores the saved rotation when an
app exits. The glass itself is always portrait, so a rotated UI is drawn
sideways on it.
```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/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:
```lua
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), `"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. `color`, `bg`, `radius`, `gradient` and the press palette inherit
from the root, so a theme is set once.
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
local function build()
local content = ui.box{pad = 12, gap = 8, rows()}
if not confirming then return ui.screen(ui.box{content}) end
return ui.screen(ui.box{content, ui.confirm{
title = "forget network?", ok = "forget",
on_ok = function() wifi.forget(); confirming = false; build() end,
on_cancel = function() confirming = false; build() 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-component dirty tracking. A custom painter can add
`paint_part(node, id)` and call `node:invalidatePart(id)` to repaint only a self-contained
region; a normal `invalidate()` still supersedes queued regions. For partial pressed
feedback, `on_down` returns the region ID and `invalidate_press(node, id)` invalidates it;
the screen retains that ID through the 80 ms hold. 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.
`/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 (`/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:
```lua
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:
```lua
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).