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.
This commit is contained in:
2026-08-01 17:58:11 -04:00
parent d338dfe3e8
commit 8045faddb4
13 changed files with 83 additions and 45 deletions
+1 -1
View File
@@ -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/<name>/?.lua` first, then `/lib/?.lua`.
+1 -2
View File
@@ -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. |
+1 -1
View File
@@ -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",
+1 -2
View File
@@ -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)
+3 -3
View File
@@ -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})
+1 -2
View File
@@ -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
+9 -12
View File
@@ -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 <HTTPClient.h>
#include <SD.h>
@@ -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);
+31 -3
View File
@@ -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
+2 -2
View File
@@ -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;
+10 -14
View File
@@ -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<uint32_t>(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);
+6 -1
View File
@@ -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);
+8 -1
View File
@@ -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
+9 -1
View File
@@ -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