feat(lua): pass an argument to init() and let apps name themselves

sys.launch(path, arg) carries a string to the next app's init(arg), nil when there is
none. States share no memory, so one string is the whole handoff; anything structured
travels as a Lua literal the receiver loads. This is what a screen split across apps
needs to say "collect a password for this network".

sys.setAppName() retitles the status bar, defaulting to the directory name as before. A
setter rather than a declared constant, so one app can retitle per screen. The bar needs
no new invalidation path for it -- the name joins rotation and theme in the cache key --
and clips a name wide enough to reach the memory slot.
This commit is contained in:
2026-08-02 12:02:09 -04:00
parent 118e04e340
commit 021b94fca2
10 changed files with 129 additions and 28 deletions
+15 -4
View File
@@ -37,12 +37,20 @@ Settings live in C++ (`src/settings.h`) because the firmware reads rotation and
before any `lua_State` exists, and calibration again on every touch. Lua reaches them through
bindings so there is one writer.
## Apps
One `lua_State` per app, closed on exit, which is why the heap returns to the same shape
after every launch instead of fragmenting. Apps hand off with `sys.launch(path, arg)` and
receive the string as `init(arg)` -- states share no memory, so a string is the whole
handoff. `sys.setAppName()` retitles the bar for a screen within an app.
## Status Bar
`sdcard/lib/statusbar.lua` paints the top strip; the firmware only clips apps out of it and
calls `draw(home)` on a timer. **Invalidation is entirely Lua's**: `draw()` compares each
field against what it last painted and keys the whole cache on `gui.getRotation()` and
`ui.themeName`. A new app gets a fresh `lua_State`, so an empty cache already means "repaint
field against what it last painted and keys the whole cache on `gui.getRotation()`,
`ui.themeName` and `sys.getAppName()`. Adding a field that can change means adding a term
to that key, never a flag on the C++ side. A new app gets a fresh `lua_State`, so an empty cache already means "repaint
everything".
The one change Lua cannot observe is an app that painted over the bar while fullscreen —
@@ -55,8 +63,11 @@ Full instructions in `.pi/skills/test-e32r40t-firmware/SKILL.md`. Two things tha
- The launcher logs `[lua] home ready`, not "launcher ready". It is `/apps/home`, an app like
any other.
- `wait-frame` is an e-ink command and hangs on this board. Use `wait-idle SECONDS` between
captures, and `wait-log` for everything semantic.
- `wait-frame` is an e-ink command and hangs on this board, and `sleep` is not a command at
all -- a script using either stops silently. Use `wait-idle SECONDS` between captures and
`wait-log` for everything semantic.
- Captures round-trip slower than a fast UI transition, so a screen that appears for under a
second is unreliable to photograph. Assert those on the host instead.
Pure Lua logic belongs in `test/*.lua` against `test/fake_device.lua`, which is the single
definition of the binding surface for host tests. Renaming a binding means editing that file.
+7 -2
View File
@@ -42,11 +42,16 @@ draws a fallback screen if it cannot start.
## Lua API
Apps define `init()`, which is required, plus optional `draw()` (~30fps cap), `on_touch_down(x, y)`,
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.exit()` to return home, which the back
button in the status bar does too.
`init` receives whatever string `sys.launch(path, arg)` passed, or `nil` from the launcher.
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 |
@@ -54,7 +59,7 @@ button in the status bar does too.
| `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)`, `exit()`, `launch(path)`, `getAppName()`, `getMemory()` -> `free,total`, `isClockSynced()`, `setTickInterval(ms)` |
| `sys` | `getMillis()`, `delay(ms)`, `exit()`, `launch(path, arg)`, `getAppName()`, `setAppName(name)`, `getMemory()` -> `free,total`, `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) |
+11 -4
View File
@@ -78,9 +78,11 @@ function M.draw(home)
local signalX = clockX - GAP - SIGNAL_W
local memX = signalX - GAP - memW
-- Rotation moves every slot and the theme recolors them; a new app gets a fresh Lua
-- state, so an empty cache already means "repaint everything".
local key = gui.getRotation() .. "|" .. ui.themeName
-- Rotation moves every slot, the theme recolors them, and an app may rename itself at
-- any time. A new app gets a fresh Lua state, so an empty cache already means "repaint
-- everything".
local name = sys.getAppName()
local key = gui.getRotation() .. "|" .. ui.themeName .. "|" .. name
if key ~= shown.key then
shown = {key = key}
gui.fillRect(0, 0, w, BAR_H, theme.bg)
@@ -90,7 +92,12 @@ function M.draw(home)
drawHome(theme)
nameX = BAR_H + PAD
end
gui.drawText(sys.getAppName(), nameX, textY, theme.fg, theme.bg)
-- Clipped rather than overrun: the name is the one field an app controls, and the
-- memory slot sits directly after it.
while #name > 1 and nameX + gui.getTextWidth(name) > memX - GAP do
name = name:sub(1, -2)
end
gui.drawText(name, nameX, textY, theme.fg, theme.bg)
end
local mem = memPercent()
+22 -2
View File
@@ -5,6 +5,9 @@
#include "../bindings.h"
#include "../lua_app.h"
static constexpr size_t MAX_APP_NAME = 32;
static constexpr size_t MAX_LAUNCH_ARG = 256;
static int l_sys_millis(lua_State* L) {
lua_pushinteger(L, millis());
return 1;
@@ -21,11 +24,24 @@ static int l_sys_appName(lua_State* L) {
}
static int l_sys_launch(lua_State* L) {
app(L)->requestLaunch(luaL_checkstring(L, 1));
const char* arg = lua_isnoneornil(L, 2) ? nullptr : luaL_checkstring(L, 2);
if (arg && strlen(arg) > MAX_LAUNCH_ARG) return luaL_error(L, "launch argument too long");
app(L)->requestLaunch(luaL_checkstring(L, 1), arg);
app(L)->requestExit();
return 0;
}
// Renames the running app for the status bar, which is the only thing that reads it. Kept
// as a setter rather than a constant the chunk declares: a screen that retitles itself
// ("settings" to "settings - wifi") needs it to change after init().
static int l_sys_setAppName(lua_State* L) {
size_t length;
const char* name = luaL_checklstring(L, 1, &length);
if (!length || length > MAX_APP_NAME) return luaL_error(L, "app name must be 1 to 32 bytes");
app(L)->setName(String(name, length));
return 0;
}
// Lua's os.time()/os.date() work off the same system clock, so this is the only
// binding a clock UI needs: it says whether that clock means anything yet.
static int l_sys_clockSynced(lua_State* L) {
@@ -85,7 +101,7 @@ void registerSys(lua_State* L) {
{"delay", l_sys_delay},
// --- Ends this app and returns home.
{"exit", l_sys_exit},
// --- Directory name of the running app, for example "settings".
// --- Name of the running app, its directory name until sys.setAppName() changes it.
// @return string
{"getAppName", l_sys_appName},
// --- Sets how often on_tick() runs. Errors when on_tick is not defined.
@@ -93,7 +109,11 @@ void registerSys(lua_State* L) {
{"setTickInterval", l_sys_setTickInterval},
// --- Ends this app and starts another one.
// @param path string Absolute path to the app's main.lua.
// @param arg string|nil Passed to the new app's init(); at most 256 bytes.
{"launch", l_sys_launch},
// --- Renames the running app in the status bar.
// @param name string 1 to 32 bytes.
{"setAppName", l_sys_setAppName},
// --- Free and total heap, in bytes.
// @return integer Free bytes.
// @return integer Total bytes.
+19 -3
View File
@@ -107,7 +107,7 @@ bool LuaApp::takeFailure() {
return value;
}
bool LuaApp::load(const char* path, bool isHome) {
bool LuaApp::load(const char* path, bool isHome, const char* arg) {
closeState();
this->isHome = isHome;
homeArmed = false;
@@ -149,7 +149,12 @@ bool LuaApp::load(const char* path, bool isHome) {
return false;
}
lua_getglobal(state, "init");
if (!callGlobal("init")) return false;
if (arg) {
lua_pushstring(state, arg);
} else {
lua_pushnil(state); // pushed either way, so init(arg) reads the same in both cases
}
if (!callGlobal("init", 1)) return false;
return running();
}
@@ -220,7 +225,11 @@ void LuaApp::registerBindings() {
// here would free the VM that is still executing.
void LuaApp::requestExit() { exitRequested = true; }
void LuaApp::requestLaunch(const char* path) { pendingLaunch = path; }
void LuaApp::requestLaunch(const char* path, const char* arg) {
pendingLaunch = path;
pendingArg = arg ? arg : "";
pendingArgSet = arg != nullptr;
}
String LuaApp::takePendingLaunch() {
String path = pendingLaunch;
@@ -228,6 +237,13 @@ String LuaApp::takePendingLaunch() {
return path;
}
String LuaApp::takePendingArg() {
String arg = pendingArg;
pendingArg = "";
pendingArgSet = false;
return arg;
}
void LuaApp::closeState() {
if (state) {
lua_close(state);
+14 -4
View File
@@ -16,11 +16,15 @@ class LuaApp {
~LuaApp();
// `isHome` marks the app the host falls back to, which needs no way back to itself.
bool load(const char* path, bool isHome = false);
// `arg` reaches the app as init()'s only parameter, or nil when there is none: states
// share no memory, so a string is the whole handoff between one app and the next.
bool load(const char* path, bool isHome = false, const char* arg = nullptr);
void loop();
// Directory name of the running app ("settings"), for the status bar.
// What the status bar shows. Defaults to the app's directory name ("settings") until
// the app renames itself with sys.setAppName().
const String& name() const { return appName; }
void setName(const String& name) { appName = name; }
// Lets an app own the whole panel: no viewport, no status bar. The touch calibration
// needs the physical edges; almost nothing else should.
@@ -30,9 +34,11 @@ class LuaApp {
// leaves the previous viewport metrics behind.
void applyViewport();
// Set by sys.launch(); the host loop reads it once the app has torn down.
void requestLaunch(const char* path);
// Set by sys.launch(); the host loop reads both once the app has torn down.
void requestLaunch(const char* path, const char* arg);
String takePendingLaunch();
String takePendingArg();
bool hasPendingArg() const { return pendingArgSet; }
bool running() const { return state != nullptr && !exitRequested; }
void requestExit();
@@ -62,6 +68,10 @@ class LuaApp {
bool exitRequested = false;
bool failed = false;
String pendingLaunch;
// Tracked apart from the string: an app may legitimately pass "", which is not the same
// as being launched with no argument at all.
String pendingArg;
bool pendingArgSet = false;
bool lastTouched = false;
bool ignoreRelease = false;
bool fullscreen = false;
+17 -5
View File
@@ -31,6 +31,10 @@ LuaApp app(tft, touch);
bool halted = false;
String nextApp;
// Carried beside the path, because sys.launch()'s argument has to outlive the state that
// passed it: the sender is torn down before the receiver exists.
String nextArg;
bool nextArgSet = false;
// Last resort only: the UI lives in Lua, so this exists purely to explain why
// nothing else could run.
@@ -46,12 +50,12 @@ void fallbackScreen(const char* message) {
halted = true;
}
void startApp(const String& path) {
void startApp(const String& path, const char* arg) {
// Full panel until the app's own status bar module reports its height in load().
tft.setRotation(settings.rotationIndex()); // apps may have rotated the frame
tft.resetViewport(); // load() re-clips once the new app's bar reports its height
Serial.printf("launching %s free=%u largest=%u\n", path.c_str(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
if (app.load(path.c_str(), path == HOME)) return;
if (app.load(path.c_str(), path == HOME, arg)) return;
if (path == HOME) fallbackScreen("home failed to start");
}
@@ -70,7 +74,7 @@ void setup() {
}
settings.load(); // absent file keeps the built-in defaults
net::begin();
startApp(HOME);
startApp(HOME, nullptr);
}
void loop() {
@@ -83,15 +87,23 @@ void loop() {
if (app.running()) {
app.loop();
if (!app.running()) nextApp = app.takePendingLaunch();
if (!app.running()) {
nextApp = app.takePendingLaunch();
nextArgSet = app.hasPendingArg();
nextArg = app.takePendingArg();
}
} else {
// An app that died left its message on screen, and home is about to paint over it.
// Serial alone is no help to anyone holding the device.
if (app.takeFailure()) delay(ERROR_HOLD_MS);
String path = nextApp.length() > 0 ? nextApp : String(HOME);
bool hasArg = nextApp.length() > 0 && nextArgSet;
String arg = nextArg;
nextApp = "";
startApp(path);
nextArg = "";
nextArgSet = false;
startApp(path, hasArg ? arg.c_str() : nullptr);
}
// Hands the core to the idle task instead of spinning between polls: touch reads are
+7 -2
View File
@@ -270,7 +270,7 @@ function sys.delay(ms) end
--- Ends this app and returns home.
function sys.exit() end
--- Directory name of the running app, for example "settings".
--- Name of the running app, its directory name until sys.setAppName() changes it.
---@return string
function sys.getAppName() end
@@ -280,7 +280,12 @@ function sys.setTickInterval(intervalMs) end
--- Ends this app and starts another one.
---@param path string Absolute path to the app's main.lua.
function sys.launch(path) end
---@param arg string? Passed to the new app's init(); at most 256 bytes.
function sys.launch(path, arg) end
--- Renames the running app in the status bar.
---@param name string 1 to 32 bytes.
function sys.setAppName(name) end
--- Free and total heap, in bytes.
---@return integer Free bytes.
+3 -2
View File
@@ -27,7 +27,7 @@ local device = {
totalHeap = 320000,
connected = nil, -- last wifi.connect()
exited = false,
launched = nil,
launched = nil, -- last sys.launch(), {path, arg}
saveFails = false, -- make every persisting call report failure
}
@@ -64,13 +64,14 @@ function device.install()
getMillis = function() return device.now end,
exit = function() device.exited = true end,
getAppName = function() return device.appName end,
setAppName = function(name) device.appName = name end,
getMemory = function() return device.freeHeap, device.totalHeap end,
isClockSynced = function() return device.clockSynced end,
setTickInterval = function(ms)
assert(ms == 0 or on_tick, "setTickInterval without on_tick")
device.tickInterval = ms
end,
launch = function(path) device.launched = path end,
launch = function(path, arg) device.launched = {path = path, arg = arg} end,
}
settings = {
+14
View File
@@ -47,6 +47,20 @@ device.theme = "dark"
ui.reloadTheme()
assert(has(paint(), device.appName), "a theme change repaints the whole bar")
-- An app renaming itself is a bar change like any other, with no invalidation call.
sys.setAppName("settings - wifi")
assert(has(paint(), "settings - wifi"), "a renamed app repaints the whole bar")
assert(#paint() == 0, "and settles again afterwards")
-- A name wide enough to reach the memory slot is clipped, not drawn over it.
sys.setAppName(string.rep("w", 60))
local clipped
for _, label in ipairs(paint()) do
if label:sub(1, 1) == "w" then clipped = label end
end
assert(clipped and #clipped < 60, "an over-long name is clipped, got " .. #(clipped or ""))
sys.setAppName("test")
-- Leaving fullscreen is the one change draw() cannot see: the app painted over the bar
-- while nothing about the bar's own state moved.
device.theme = "light"