Pitfalls¶
Bugs that cost real projects real time. Each one is cheap to avoid once you know about it.
collectgarbage("setpause") without a value¶
collectgarbage("setpause") -- ✗ sets the pause to 0: the GC runs continuously
collectgarbage("setpause", 120) -- ✓ returns the PREVIOUS pause, not the new one
The setter returns the old value, so reading it back to "check" what you set gives the wrong number. Keep the value you passed and log that.
Unbounded recursion¶
Every Lua-to-Lua call uses C stack. On a radio, running out of C stack does not raise a catchable Lua error: the stack pointer walks into neighbouring memory and the watchdog resets the radio into emergency mode. (Ethos then restores the model from its RAM backup.)
The usual cause is an event system where handler A publishes an event whose handler publishes back to A. Guard any re-entrant dispatch with a depth limit:
local depth, MAX_DEPTH = 0, 8
local function publish(topic, ...)
if depth >= MAX_DEPTH then error("publish depth exceeded: " .. topic) end
depth = depth + 1
for _, h in ipairs(handlers[topic] or {}) do
local ok, err = pcall(h, ...)
if not ok then print("handler error: " .. tostring(err)) end
end
depth = depth - 1
end
system.getMemoryUsage().mainStackAvailable reports free main-stack bytes. Track its minimum over a session, not single readings: the moment that matters is the worst one.
Relative paths in callbacks¶
Relative paths resolve against your script's folder only while main.lua loads. From a callback, io.open("settings.lua", "w") writes into whichever script Ethos loaded last, or fails with Lua::path is not set! in the console. This was reproduced in the simulator: a tool's close() saved its settings into another widget's folder. Use SCRIPTS:/<folder>/... for anything that runs after init(). Details in The Lua environment.
os.mkdir is not recursive¶
os.mkdir("LOGS:/myscript") fails with No such file or directory if LOGS: itself doesn't exist yet, which is the case on a fresh radio or simulator. Create each level:
if not os.stat("LOGS:") then os.mkdir("LOGS:") end
if not os.stat("LOGS:/myscript") then os.mkdir("LOGS:/myscript") end
Nil handles¶
These can all return nil, and calling a method on nil stops your script:
| Call | Returns nil when |
|---|---|
system.getSource(...) |
No such source yet (telemetry not discovered, renamed sensor) |
model.createSensor(...) |
Creation failed (retry later, throttled) |
lcd.loadBitmap(path) / lcd.loadMask(path) |
File missing or can't be decoded |
form.openWaitDialog(...) |
Observed during page reloads |
io.open(path) |
File missing or no permission |
model.getTimer(name) |
No such timer |
Using drawing calls outside paint¶
lcd.draw* only works inside paint. Calling it from wakeup draws nothing (or errors). Compute in wakeup, then lcd.invalidate().
Stale module state after redeploy¶
Modules cached in package.loaded stay cached until the script restarts. In the simulator, after copying new files, restart the simulator, or you'll keep testing the old code.
Saved settings from older versions¶
A widget saved by version 1 of your script doesn't have version 2's new settings: storage.read("newKey") returns nil. Always default (storage.read("k") or default). See Saving settings.
Duplicate telemetry sensors¶
Creating a DIY sensor without first checking for an existing one (by appId) adds a duplicate every boot. Look up first, create only if missing, and keep the object.
Widget keys over 7 characters¶
registerWidget, registerTask and registerSource keys are limited to 7 characters. Keep them short, unique, and never change them after release: saved models refer to widgets by key.
Comparing floats for change¶
if v ~= last on a noisy float fires every tick. Compare what you display (rounded or quantized), as in Saving CPU.
Shadowed globals¶
Assigning without local creates a global shared by every script on the radio:
Globals are also slower to access than locals. In hot code, cache library functions in locals: local floor = math.floor.