From 8045faddb4ee91c2ea1884ae0cbf63d3c119b7d7 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sat, 1 Aug 2026 17:58:11 -0400 Subject: [PATCH] refactor: replace the TICK_MS global with app.setTickInterval() A magic global that the runtime reads once at startup could not be changed later, gave no feedback when misspelled, and was a second spelling of a mechanism the sibling firmware already had. app.setTickInterval(ms) clamps to 33..3600000, takes 0 to stop, and errors when on_tick() is not defined -- by the time init() runs the chunk body has finished, so a missing callback is a typo rather than a race. Also drops the code comments pointing at the other repo. Where the two APIs agree or differ belongs in docs/lua-api-parity.md; a comment beside a constant explaining that another firmware picked the same number is noise a reader here cannot act on. --- README.md | 2 +- docs/lua-api-parity.md | 3 +-- scripts/gen_lua_stubs.py | 2 +- sdcard/apps/hello/main.lua | 3 +-- sdcard/apps/launcher/main.lua | 6 +++--- sdcard/apps/settings/main.lua | 3 +-- src/lua/bindings/http.cpp | 21 +++++++++------------ src/lua/bindings/sys.cpp | 34 +++++++++++++++++++++++++++++++--- src/lua/bindings/wifi.cpp | 4 ++-- src/lua/lua_app.cpp | 24 ++++++++++-------------- src/lua/lua_app.h | 7 ++++++- stubs/esp32lcd.lua | 9 ++++++++- test/fake_device.lua | 10 +++++++++- 13 files changed, 83 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index ed3e4aa..edc9661 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ firmware only draws a fallback screen if it cannot start. Apps define `init()`, 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 setting `TICK_MS`). Call `sys.exit()` to return to the launcher. +(enabled by `app.setTickInterval(ms)`). Call `sys.exit()` to return to the launcher. `require` reads from the SD card: `/apps//?.lua` first, then `/lib/?.lua`. diff --git a/docs/lua-api-parity.md b/docs/lua-api-parity.md index 0e55702..cf727e8 100644 --- a/docs/lua-api-parity.md +++ b/docs/lua-api-parity.md @@ -12,7 +12,7 @@ Compared against crosspoint-reader at `src/util/lua/LuaBindings*.cpp`. `fs.listDirs`, `fs.listFiles`, `fs.exists`, `fs.readFile`, `fs.writeFile`, `gui.width`, `gui.height`, `gui.fillRect`, `gui.drawRect`, `gui.drawLine`, `sys.millis`, `sys.delay`, `sys.exit`, `log.debug/info/error`, the whole `http` table, -and the `init()` / `draw()` / `on_tick()` callbacks. `init()` is required in both, so a +`app.setTickInterval`, and the `init()` / `draw()` / `on_tick()` callbacks. `init()` is required in both, so a misspelled entry point is an error rather than an app that quietly draws nothing. ## Deliberate differences @@ -38,7 +38,6 @@ Each of these is the same concept spelled two ways. Fixing them means changing o | Concern | crosspoint-reader | esp32-lcd | Suggested resolution | |---|---|---|---| -| Tick interval | `app.setTickInterval(ms)` | global `TICK_MS` | Pick one mechanism. | | `wifi.status()` | returns a **string** | returns a **table** of state/ssid/ip/rssi | Same name, incompatible types — the sharpest edge here. This repo added `isConnected()` and `localIP()` so the crosspoint idioms work either way. | | `wifi.connect()` | no arguments, uses stored credentials | `(ssid, password)`, saves them | Both are wanted: a no-argument reconnect and an explicit join. | | `fs.readFile` cap | 50000 bytes | 65536 bytes | Arbitrary in both. | diff --git a/scripts/gen_lua_stubs.py b/scripts/gen_lua_stubs.py index 99a5b0e..11524ad 100644 --- a/scripts/gen_lua_stubs.py +++ b/scripts/gen_lua_stubs.py @@ -123,7 +123,7 @@ def render(modules): "-- Callbacks an app may define as globals:", "-- init() once, before the first draw (required)", "-- draw() every 33 ms while the app runs", - "-- on_tick() every TICK_MS, when that global is set", + "-- on_tick() at the interval app.setTickInterval() asked for", "-- on_touch_down(x, y) finger down", "-- on_touch_up(x, y) finger up", "-- on_touch(x, y) tap, fired on release like a click", diff --git a/sdcard/apps/hello/main.lua b/sdcard/apps/hello/main.lua index 97565bd..470544b 100644 --- a/sdcard/apps/hello/main.lua +++ b/sdcard/apps/hello/main.lua @@ -1,12 +1,11 @@ local ui = require("ui") -TICK_MS = 1000 - local seconds = 0 -- Draws straight to the panel rather than through components, so it reads the theme. local theme = ui.theme function init() + app.setTickInterval(1000) gui.clear(theme.bg) gui.drawText("hello from sd card", 10, 10, theme.fg, theme.bg) gui.drawText("touch the screen", 10, 30, theme.muted, theme.bg) diff --git a/sdcard/apps/launcher/main.lua b/sdcard/apps/launcher/main.lua index 53a3d38..dbc9832 100644 --- a/sdcard/apps/launcher/main.lua +++ b/sdcard/apps/launcher/main.lua @@ -3,9 +3,8 @@ local ui = require("ui") local screen local clock --- Twice a second, so the seconds digit never visibly lags. WiFi and SNTP run in the --- background; until the clock means something the placeholder holds its width. -TICK_MS = 500 +-- Until the clock means something the placeholder holds its width; WiFi and SNTP run +-- in the background. local PLACEHOLDER = "--:--:--" -- Rows are interactive containers rather than plain buttons, so each can carry a @@ -20,6 +19,7 @@ local function appRow(name) end function init() + app.setTickInterval(500) -- twice a second, so the seconds digit never visibly lags -- Fixed width: drawText paints an opaque background only under its own glyphs, so a -- shorter label would leave the tail of the previous one on screen. clock = ui.text(PLACEHOLDER, {w = gui.textWidth(PLACEHOLDER), color = ui.theme.muted}) diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index 0f4f6cc..95dae9f 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -1,7 +1,5 @@ local ui = require("ui") -TICK_MS = 50 - local INSET = 30 local screen, message @@ -242,6 +240,7 @@ function buildKeyboard() end function init() + app.setTickInterval(50) -- calibration samples the raw panel between draws buildMenu() end diff --git a/src/lua/bindings/http.cpp b/src/lua/bindings/http.cpp index 2a59423..406834d 100644 --- a/src/lua/bindings/http.cpp +++ b/src/lua/bindings/http.cpp @@ -1,10 +1,7 @@ -// HTTP client. Signatures match crosspoint-reader's `http` table exactly, so a script -// that talks to a server runs on either device; see docs/lua-api-parity.md. -// -// Unlike crosspoint, certificates are verified against the root bundle already -// embedded in the framework. Nothing here lets a script turn that off: the signatures -// have no options table to hang it on, and the one caller that would want it most -- -// a firmware update -- is the one that can least afford an unauthenticated peer. +// HTTP client. Certificates are verified against the root bundle already embedded in +// the framework, and nothing here lets a script turn that off: the signatures have no +// options table to hang it on, and the one caller that would want it most -- a firmware +// update -- is the one that can least afford an unauthenticated peer. #include #include @@ -19,7 +16,7 @@ extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_star namespace { -constexpr size_t MAX_RESPONSE = 50000; // matches crosspoint's cap, so limits behave alike +constexpr size_t MAX_RESPONSE = 50000; constexpr uint32_t TIMEOUT_MS = 60000; constexpr int REDIRECT_LIMIT = 5; constexpr size_t DOWNLOAD_CHUNK = 2048; @@ -64,8 +61,8 @@ void applyHeaders(lua_State* L, int index, HTTPClient& http) { } // get/head/delete take headers in slot 2; post/patch take a body there and headers in 3. -// crosspoint reinterprets a string in slot 2 as a body for every method, which turns a -// mistyped header table into a silent protocol error, so this rejects it instead. +// A string in slot 2 of a bodyless method is rejected rather than reinterpreted, since +// a mistyped headers table would otherwise become a silent protocol error. int request(lua_State* L, const char* method, bool bodyExpected) { const char* url = luaL_checkstring(L, 1); const char* body = ""; @@ -122,8 +119,8 @@ int fail(lua_State* L, const char* message) { return 2; } -// Only the keys crosspoint accepts, and rejecting the rest: a misspelled `sha256` that -// was quietly ignored would report a verified download that verified nothing. +// Unknown keys are rejected: a misspelled `sha256` that was quietly ignored would +// report a verified download that verified nothing. bool readOptions(lua_State* L, int index, uint32_t& maxBytes, uint32_t& expectedSize, String& sha256, const char*& error) { luaL_checktype(L, index, LUA_TTABLE); diff --git a/src/lua/bindings/sys.cpp b/src/lua/bindings/sys.cpp index bb44f02..99812ad 100644 --- a/src/lua/bindings/sys.cpp +++ b/src/lua/bindings/sys.cpp @@ -96,12 +96,32 @@ static int logAt(lua_State* L, const char* level) { return 0; } +// Rejected rather than ignored when on_tick is absent: the chunk body has already run +// by the time init() calls this, so a missing callback is a typo, not a race. +static int l_app_setTickInterval(lua_State* L) { + lua_Integer requested = luaL_checkinteger(L, 1); + if (requested <= 0) { + app(L)->setTickInterval(0); + return 0; + } + lua_getglobal(L, "on_tick"); + bool hasTick = lua_isfunction(L, -1); + lua_pop(L, 1); + if (!hasTick) return luaL_error(L, "app.setTickInterval() requires on_tick()"); + + uint32_t interval = requested < (lua_Integer)LuaApp::MIN_TICK_MS ? LuaApp::MIN_TICK_MS + : requested > (lua_Integer)LuaApp::MAX_TICK_MS ? LuaApp::MAX_TICK_MS + : (uint32_t)requested; + app(L)->setTickInterval(interval); + return 0; +} + static int l_log_debug(lua_State* L) { return logAt(L, "debug"); } static int l_log_info(lua_State* L) { return logAt(L, "info"); } static int l_log_error(lua_State* L) { return logAt(L, "error"); } -// Parity with crosspoint: sleeping is the one thing a script cannot express itself, -// since the runtime owns the loop. +// Sleeping is the one thing a script cannot express itself, since the runtime owns +// the loop. static int l_sys_delay(lua_State* L) { delay(luaL_checkinteger(L, 1)); return 0; @@ -155,7 +175,15 @@ void registerSys(lua_State* L) { luaL_newlib(L, lib); lua_setglobal(L, "sys"); - // Too small to deserve its own translation unit. + // These two are too small to deserve their own translation units. + static const luaL_Reg appLib[] = { + // --- Sets how often on_tick() runs. Errors when on_tick is not defined. + // @param intervalMs integer 0 stops ticking; anything else is clamped to 33..3600000. + {"setTickInterval", l_app_setTickInterval}, + {nullptr, nullptr}}; + luaL_newlib(L, appLib); + lua_setglobal(L, "app"); + static const luaL_Reg logLib[] = { // --- Writes a debug line to the serial log. // @param message string diff --git a/src/lua/bindings/wifi.cpp b/src/lua/bindings/wifi.cpp index 90db6bd..4b7bfdf 100644 --- a/src/lua/bindings/wifi.cpp +++ b/src/lua/bindings/wifi.cpp @@ -85,8 +85,8 @@ static int l_wifi_status(lua_State* L) { return 1; } -// Parity with crosspoint, whose wifi.status() returns a bare string and so needs these -// as separate calls. Here they are shorthands for fields status() already carries. +// Shorthands for two fields status() already carries, for scripts that want one answer +// without unpacking a table. static int l_wifi_isConnected(lua_State* L) { lua_pushboolean(L, WiFi.status() == WL_CONNECTED); return 1; diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index 62a9ae4..6c96546 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -91,6 +91,9 @@ bool LuaApp::takeFailure() { bool LuaApp::load(const char* path) { closeState(); + // Cleared per app: the interval outlives the app that set it, so a ticking app + // followed by one that never ticks would keep calling a nil global. + tickIntervalMs = 0; // The launcher tap may still be down; swallow that gesture's release. lastTouched = true; ignoreRelease = true; @@ -109,29 +112,22 @@ bool LuaApp::load(const char* path) { return false; } if (!callGlobal(path)) return false; // run chunk body - // Required, as on crosspoint-reader: an app whose entry point is misspelled would - // otherwise start, draw nothing, and give no hint why. + // An app whose entry point is misspelled would otherwise start, draw nothing, and + // give no hint why. if (!hasGlobal("init")) { fail("Missing init()"); return false; } lua_getglobal(state, "init"); if (!callGlobal("init")) return false; - // Cleared first: the interval outlives the app that set it, so a ticking app - // followed by one without on_tick would otherwise keep calling a nil global. - tickIntervalMs = 0; - if (hasGlobal("on_tick")) { - tickIntervalMs = DEFAULT_TICK_MS; - lua_getglobal(state, "TICK_MS"); - if (lua_isinteger(state, -1)) { - tickIntervalMs = std::max(lua_tointeger(state, -1), DRAW_INTERVAL_MS); - } - lua_pop(state, 1); - nextTickMs = millis() + tickIntervalMs; - } return running(); } +void LuaApp::setTickInterval(uint32_t intervalMs) { + tickIntervalMs = intervalMs; + nextTickMs = millis() + intervalMs; +} + void LuaApp::registerBindings() { registerGui(state); registerSys(state); diff --git a/src/lua/lua_app.h b/src/lua/lua_app.h index ac0c072..ce2d64f 100644 --- a/src/lua/lua_app.h +++ b/src/lua/lua_app.h @@ -28,6 +28,12 @@ class LuaApp { // can leave the message on screen long enough to read. bool takeFailure(); + // Set from Lua by app.setTickInterval(); 0 stops the ticks. + void setTickInterval(uint32_t intervalMs); + + static constexpr uint32_t MIN_TICK_MS = 33; // no point ticking faster than a draw + static constexpr uint32_t MAX_TICK_MS = 3600000; // an hour + // Panel geometry in its rotation-0 frame; calibration is stored in this space // so rotating the UI never needs a recalibration. static constexpr int16_t PANEL_W = 320; @@ -57,7 +63,6 @@ class LuaApp { uint32_t nextDrawMs = 0; static constexpr uint32_t DRAW_INTERVAL_MS = 33; - static constexpr uint32_t DEFAULT_TICK_MS = 100; // used when on_tick exists without TICK_MS void registerBindings(); bool callGlobal(const char* name, int nargs = 0); diff --git a/stubs/esp32lcd.lua b/stubs/esp32lcd.lua index 5ab4ca1..c96ef5f 100644 --- a/stubs/esp32lcd.lua +++ b/stubs/esp32lcd.lua @@ -4,6 +4,13 @@ -- Point your editor's Lua language server at this file to get completion for the -- firmware API inside sdcard/apps and sdcard/lib. +---@class applib +app = {} + +--- Sets how often on_tick() runs. Errors when on_tick is not defined. +---@param intervalMs integer 0 stops ticking; anything else is clamped to 33..3600000. +function app.setTickInterval(intervalMs) end + ---@class fslib fs = {} @@ -299,7 +306,7 @@ function wifi.forget() end -- Callbacks an app may define as globals: -- init() once, before the first draw (required) -- draw() every 33 ms while the app runs --- on_tick() every TICK_MS, when that global is set +-- on_tick() at the interval app.setTickInterval() asked for -- on_touch_down(x, y) finger down -- on_touch_up(x, y) finger up -- on_touch(x, y) tap, fired on release like a click diff --git a/test/fake_device.lua b/test/fake_device.lua index f5bbd72..aae05bf 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -13,6 +13,7 @@ local device = { rotation = 0, theme = "light", clockSynced = false, + tickInterval = 0, timezone = "UTC0", raw = nil, -- pending raw touch reading, {x, y} or nil networks = {}, -- what wifi.scan() returns @@ -99,7 +100,14 @@ function device.install() forget = function() device.connected = nil return saved() end, } - log = {info = function() end} + app = { + setTickInterval = function(ms) + assert(ms == 0 or on_tick, "setTickInterval without on_tick") + device.tickInterval = ms + end, + } + + log = {debug = function() end, info = function() end, error = function() end} return device end