252 lines
12 KiB
Markdown
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).
|