Skip to content

Code patterns

Module loading

Large scripts split code across files and load them with loadfile. At the top level of main.lua a relative path works. Anything loaded later, from a callback, needs an absolute path (see why):

local BASE = "SCRIPTS:/myscript/"
local util = assert(loadfile("lib/util.lua"))()                 -- top level: relative is fine

local function openSetup()
  return assert(loadfile(BASE .. "pages/setup.lua"))()          -- callback: absolute
end

loadfile(path)() re-parses and re-runs the file on every call. Unlike require, nothing is cached: each call creates a new, independent copy of the module's tables and closures. For a module loaded once at boot that's fine. For one loaded every time a page opens, it wastes RAM and CPU, and it's a real bug if the module has load-time side effects.

Self-caching modules

Make modules that may be loaded more than once cache themselves, like require does:

lib/units.lua
local KEY = "myscript.lib.units"
if package.loaded[KEY] then return package.loaded[KEY] end

local units = {}
local NAMES = { [UNIT_VOLT] = "V", [UNIT_AMPERE] = "A" }   -- built once

function units.name(u) return NAMES[u] or "" end

package.loaded[KEY] = units
return units

Self-cache when a module is loaded repeatedly (from a page or menu the pilot opens again and again) and it either costs something to rebuild or has load-time side effects. Don't bother for modules loaded once by a long-lived subsystem.

The subscription leak

If a module registers a handler at load time (subscribes to an event bus, registers a callback), loading it ten times registers ten handlers. None are ever removed, and all fire on every event. This is the most important reason to self-cache:

-- ✗ every loadfile() of this module adds one more handler, forever
bus.subscribe("telemetry", onTelemetry)

Either self-cache the module, or subscribe inside an explicit open() and unsubscribe in close():

local handler

function page.open()
  handler = function(data) ... end
  bus.subscribe("telemetry", handler)
end

function page.close()
  if handler then
    bus.unsubscribe("telemetry", handler)
    handler = nil
  end
end

Release what you cached

A self-cached module lives for the whole session. For large modules that only one tool uses, remove them when the tool closes:

local OWNED = { "myscript.pages.setup", "myscript.lib.parser" }

local function close()
  for _, key in ipairs(OWNED) do package.loaded[key] = nil end
end

Clear in place

-- ✗ allocates a new table; anything holding the old one now sees stale data
cache = {}

-- ✓ empties the same table
local function clearTable(t)
  for k in pairs(t) do t[k] = nil end
end
clearTable(cache)

Replacing a table allocates again on the next use, and breaks every other reference to the old table, a real bug class. For arrays you refill with the same number of items each time, keep a count instead of clearing:

local items, count = {}, 0
local function reset() for i = 1, count do items[i] = nil end; count = 0 end
local function push(v) count = count + 1; items[count] = v end

Change detection

Do work only when inputs change:

local last = {}

local function changed(key, value)
  if last[key] ~= value then
    last[key] = value
    return true
  end
  return false
end

-- in wakeup
if changed("volts", math.floor(v * 10)) then
  widget.voltText = string.format("%.1fV", v)
  lcd.invalidate()
end

Per-instance state

Several copies of the same widget can be on screen at once. Ethos gives each one its own table from create(), so keep instance state there, not in file-level variables:

-- ✗ shared by every instance: two widgets fight over `value`
local value
local function wakeup(widget) value = widget.src:value() end

-- ✓ each instance has its own
local function create() return { src = nil, value = nil } end
local function wakeup(widget) widget.value = widget.src and widget.src:value() end

File-level variables are right for things that really are shared: constants, loaded images used by every instance, lookup tables.

Guard optional APIs

Newer calls don't exist on older firmware. Calling a missing function is an error that stops your script:

if system.getSources then
  sensors = system.getSources(CATEGORY_TELEMETRY_SENSOR)
end

local mem = system.getMemoryUsage and system.getMemoryUsage() or {}

Bounded caches (small LRU)

For caches keyed by something open-ended (file paths, sensor names), cap the size:

local MAX = 16
local cache, order = {}, {}

local function getImage(path)
  local hit = cache[path]
  if hit ~= nil then return hit or nil end      -- false = known missing
  local img = os.stat(path) and lcd.loadBitmap(path) or false
  cache[path] = img
  order[#order + 1] = path
  if #order > MAX then
    cache[table.remove(order, 1)] = nil         -- evict the oldest
  end
  return img or nil
end

An evicted bitmap is freed once nothing else references it. Clear the whole cache on lifecycle events too: model change, theme change, tool close.