# 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//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).