Firmware needs to know whether a back control would go anywhere, and the history that answers it belongs to the runtime.
ESP32 Lua API
A future shared contract for Lua applications running on the ESP32 firmwares in
../slate32 and ../crosspoint-reader.
Lua declarations and shared modules live under lua/; the vendored interpreter and shared C/C++
runtime live under native/. LANDSCAPE.md records how the contract differs from
the current firmwares. Runtime bindings remain authoritative until this repository is wired into
their tests.
Layout
lua/
api/core/ Required namespace contracts, generated
api/features/ Optional touch and buttons contracts, generated
lib/ Executable modules shipped to /.lua/lib, handwritten
test/ Lua host tests
native/
include/lua/ Public shared-runtime C++ headers
src/bindings/ Annotated Lua bindings, one file per namespace
src/node/ Shared widget-tree painting over GuiProvider
src/runtime/ Lua state, provider wiring, and timer dispatch
src/vendor/lua/ Vendored Lua interpreter implemented in C
test/ Native host tests
Every firmware implements all files under lua/api/core/. sys.hasFeature(name) declares
optional features; claiming one guarantees every API and behavior in its matching file. Features
compose, so a device may expose both touch and buttons. Display technology is not a feature:
an e-ink GuiProvider flattens a gradient the way gui.color() quantizes to grayscale, and the
firmware owns publication and waveform policy on every panel.
The contract is the app-facing Lua API, not the provider C++ interface. Shared binding
registrations carry LuaLS annotations; tools/gen_api.py mirrors them into lua/api/. Generated
files are committed for editors and checked for drift by make test, so a namespace is documented
by the code that registers it, including the callbacks in core/runtime.lua and the feature files,
which are generated from the Runtime::call* sites that fire them.
Nothing under lua/api/ ever runs: it is ---@meta for editors and the drift check. lua/lib/
is the opposite -- real modules that ship to the SD card, so composition like ui.lua and
hints.lua changes without a reflash.
Firmware supplies the interfaces in native/include/lua/providers.h and nothing else: the
bindings, argument validation, timer identity, and the node tree are shared. A null feature
provider is how sys.hasFeature() answers false, and its namespace additions are simply never
registered.
The declarations are a clean target, not the intersection of today's APIs. Existing apps and
firmwares migrate to it without compatibility aliases. Safe filesystem mutation, node, app
navigation, module loading, and ble are core even where a firmware does not implement them yet.
Every app may use require; modules resolve from its app directory and /.lua/lib. This repository
owns portable shared modules such as ui.lua; firmware-specific modules stay with their firmware.
/.lua/
apps/<AppId>/main.lua
apps/<AppId>/<Subapp>/main.lua
data/<AppId>/
lib/<module>.lua
The runtime owns app loading: Runtime::startApp() opens a fresh lua_State, points require at
the app directory and /.lua/lib, runs the chunk, and calls init(arg). sys.launch/replace/
back only record intent, because swapping the state inside a callback would free the VM that is
still executing; the firmware calls applyPendingNavigation() between batches. History, the
launcher fallback, app identity, and sys.hasFeature() all live there too, which is why
SysProvider is down to millis, memory, and isClockSynced.
sys.launch("Settings/Calibration") resolves the nested main.lua; only top-level apps appear in
the launcher. The first path component is the immutable app ID, so every Settings route shares
sys.getAppDataPath() and /.lua/data/Settings. Package updates do not touch persistent data.
draw(deltaMs) is an optional frame loop called once after init() and then at most 30 FPS,
best effort. The host passes monotonic elapsed milliseconds (0 on the first frame). Timers take
Lua callbacks and return cancellation handles.
The firmware decides whether an event reaches an app at all -- jitter filtering, chrome, and
debouncing are its business -- and Runtime::call* decides what the app sees. A release fires
on_touch_up then the on_touch alias, and a button release fires on_button_up then
on_button, so the ordering is identical on every device. Only a failed init() stops an app;
every other callback logs through LogProvider and carries on. Calling a feature callback without
its provider is a wiring bug and says so.
The firmware commits dirty display content after callback batches and owns e-ink waveform
policy; apps do not refresh the panel manually. Apps are fully trusted with the complete core API.
Theme persistence and application belong to shared ui.lua, not the firmware settings binding.
The native library vendors Lua 5.4.8 from GitHub tag v5.4.8 and compiles it with LUA_32BITS.
Command-line and upstream test entry points are excluded; luaconf.h carries one documented guard
that lets the build flag select 32-bit number mode.
Shared UI
Portable apps normally use the declarative ui.lua toolkit; node remains the low-level escape
hatch. The baseline constructors are screen, box, spacer, text, label, button,
custom, and confirm. A screen accepts both touch and physical-button input.
Widgets expose three input-agnostic callbacks:
ui.button{
on_enter = function(id, x, y) end,
on_exit = function(id, x, y) end,
on_click = function(id, x, y) end,
}
Touch-down enters; moving outside exits and moving back enters again; release exits before a
release-inside click. Directional focus enters and exits nodes; confirmation clicks. Coordinates
are present only for touch. There is no hover API. Built-in buttons usually need only on_click;
enter/exit exist for custom visuals.
Apps forward input through screen:down(x, y), move(x, y), up(x, y), and
button(name, pressed). These return whether the toolkit handled the event, leaving back and page
buttons available to the app.
The selected theme name is global in /.lua/theme. Shared ui.lua atomically persists and applies
getTheme, setTheme, and themeNames; it also owns palette application, focus handling, and
input dispatch. Hardware-specific widgets stay outside core UI.
Development
nix develop
make api # regenerate LuaLS declarations from shared binding annotations
make test # generated-file, Lua module, interpreter, and runtime checks
The shell provides Lua 5.4 for module tests; native checks compile the vendored Lua 5.4.8 with the
embedded 32-bit number configuration. native/test/runtime_test.cpp drives every namespace against
the fakes in native/test/fake_providers.h, so a binding's marshalling is asserted without a board.
Current Sources
- LCD:
../slate32/stubs/slate32.lua - E-ink:
../crosspoint-reader/data/lua/crosspoint.lua - Existing comparison:
../slate32/docs/lua-api-parity.md