Lua app SDK
WADAMESH runs small Lua apps. They live on the device as a single .lua file, appear in the app drawer next to the built-ins, and can be installed and updated from the Store over the air.
What this is
An app is one Lua chunk. The firmware gives it a screen, an input stream, read access to the mesh, a little key/value storage and an HTTP fetch. That is deliberately the whole surface — roughly thirty functions, all under the wada. table.
LVGL is not exposed. Binding a UI toolkit wholesale would freeze us out of ever changing it, and would hand every app enough rope to wedge the device. Instead there is a small set of widgets we own and can keep stable across firmware versions.
Two of the apps that ship in the Store — RF Monitor and Airtime — are written against exactly this API and nothing else. They are the reference implementations; read them if a doc paragraph is ambiguous.
API v1 is read-only where the mesh is concerned. An app can see contacts, the packet log and radio statistics. It cannot transmit. That line is deliberate: a buggy or hostile app must not be able to put packets on the air in your name.
Your first app
Save this as hello.lua:
local n = 0
function on_start()
wada.ui.label("Hello from Lua", 16)
status = wada.ui.label("tick 0", 12)
status:color(wada.ui.colors.sub)
wada.timer.every(1000)
end
function on_tick()
n = n + 1
status:set("tick " .. n)
end
function on_input(ev)
if ev.type == "key" then
wada.sys.toast("you pressed " .. tostring(ev.name))
end
end
Copy it to /apps/hello.lua on the device's SD card (or internal storage on boards without a card), open the app drawer, and it is there. No manifest is required for a file you side-load yourself — see Install yours.
App lifecycle
Define the callbacks you need as globals. All four are optional.
| Callback | When it runs |
|---|---|
| on_start() | Once, when the app opens. Build your UI here. |
| on_tick() | On the cadence set by wada.timer.every(ms). Never faster than 33 ms. |
| on_input(ev) | For every touch, key or trackball event. See wada.input. |
| on_stop() | Once, when the app closes. Persist anything you care about here. |
Every callback runs inside a guarded call on the UI thread. If your code raises an error the app is closed with a toast rather than taking the firmware down with it — but it does mean a slow callback stalls the whole UI, so keep them short. There is an instruction budget: an app that spins forever is stopped.
Widgets you create in on_start are destroyed for you when the app closes. You do not free anything.
wada.ui
Widgets are created in order, top to bottom, on the app's page. Every constructor returns a handle with methods you can call later.
| Call | Returns / does |
|---|---|
| wada.ui.label(text, size) | A text line. size is a class, not a pixel height: 12, 14 or 16. |
| wada.ui.button(text, fn) | A tappable button; fn is called with no arguments. |
| wada.ui.canvas(w, h) | A drawing surface. See the canvas methods below. |
| wada.ui.chart(points) | A line chart sized to the page. The Airtime and RF Monitor primitive. |
| wada.ui.scroll(on) | Allow the page to scroll when content is taller than the screen. |
| wada.ui.text_h(size) | Line height in pixels for a size class — use it to lay out a canvas. |
| wada.ui.colors | Theme table: text, sub, bg, accent, good, bad. |
Label handle
| lbl:set(text) | Replace the text. |
| lbl:color(c) | Set the colour, e.g. wada.ui.colors.accent. |
| lbl:pos(x, y) | Place it explicitly instead of in flow. |
| lbl:width(px) | Fix the width; longer text wraps instead of running off the panel. |
Canvas handle
| cv:fill(c) | Flood the whole canvas. |
| cv:rect(x, y, w, h, c) | Filled rectangle. |
| cv:line(x1, y1, x2, y2, c) | Line. |
| cv:circle(x, y, r, c) | Filled circle. |
| cv:text(x, y, s, c, size) | Draw a string. |
| cv:pos(x, y) | Move the canvas itself. |
Chart handle
| ch:push(v) | Append a point, scrolling the series. |
| ch:fill(t) | Replace every point from a table. |
| ch:range(min, max) | Fix the Y range instead of autoscaling. |
| ch:axis(ticks, gutter) | Draw Y-axis labels; gutter reserves space for them. |
| ch:pos(x, y) | Move the chart. |
Screens differ a lot — 240 px portrait on a Heltec V4, 320 px landscape on a T-Deck, and a tall high-DPI panel on a T-Display P4. Ask wada.sys.board() for the real width and height rather than hardcoding a layout, and prefer wada.ui.text_h() over assuming a font is so many pixels tall.
wada.input
Input arrives through on_input(ev). The event is a table; ev.type tells you which kind it is.
| ev.type | Fields |
|---|---|
| "touch" | ev.x, ev.y in page coordinates. |
| "key" | ev.name — the key label on boards with a keyboard. |
| "dir" | ev.dir — one of up, down, left, right, select. Trackball, D-pad and swipes all arrive here. |
Handle "dir" if you want your app to work on every board. A touch-only board sends swipes as directions, and a keyboard board sends its navigation keys the same way, so one branch covers both.
wada.mesh
Read-only in API v1.
| wada.mesh.contacts() | Array of {name, type, ago_s, lat, lon}. |
| wada.mesh.rx_log() | Recent packets: {ago_ms, type, rssi, snr, hops}. The RF Monitor feed. |
| wada.mesh.stats() | {rssi, noise, rx_air_s, tx_air_s, rx_pkts, rx_err, tx_budget_ms, rx_events, rx_dropped, tx_pkts, freq, bw, sf, duty_pct}. |
| wada.mesh.self() | This node: {name, lat, lon}. |
These are snapshots taken when you call them, not live views — call again on each tick to refresh.
wada.net
| wada.net.http_get(url, cb) | Fetch a URL. cb(body) runs when it lands, or with nil on failure. |
The fetch is asynchronous and runs on the firmware's existing network worker, so it does not block the UI. Your callback runs on the UI thread once the body is in memory.
Plain HTTP only, and that is not an oversight. After Wi-Fi associates there is not enough free internal memory on the smaller boards for a TLS handshake — mbedTLS wants around 30 KB and roughly 5 KB is free. If you need an HTTPS source, put a small proxy in front of it, the way the map tiles do. Responses are capped (64 KB by default).
wada.store
| wada.store.get(key) | Read a string, or nil. |
| wada.store.set(key, value) | Write a string. |
Keys are namespaced per app, so two apps cannot collide. Keep it small — the budget is about 2 KB per app, and the underlying store silently drops oversized values. High scores and settings, not logs.
Writes hit flash. Do them in on_stop() or on a real user action, never on every tick.
wada.sys
| wada.sys.millis() | Milliseconds since boot. |
| wada.sys.board() | {w, h, touch, keyboard, trackball, gps} — size and capabilities. |
| wada.sys.toast(msg) | Brief on-screen message. |
| wada.sys.random(n) | Integer in 1..n. |
wada.timer
| wada.timer.every(ms) | Call on_tick every ms. Clamped to 33 ms minimum. |
| wada.timer.stop() | Stop ticking. |
One timer per app. Calling every again changes the interval rather than adding a second timer.
App format
On the device an app is one or two files on the active storage root:
/apps/<id>.lua the code
/apps/<id>.json the manifest (optional for side-loaded apps)
The manifest is what the Store and the drawer read:
{
"id": "airtime",
"name": "Airtime",
"version": "1.3",
"min_api": 1,
"icon": "A",
"description": "Duty cycle and airtime budget.",
"boards": ["*"]
}
| id | Lowercase, no spaces. Must match the filename. |
| min_api | Refuse to run on firmware older than this API version. |
| icon | A single character shown in the drawer tile and Store card. |
| boards | ["*"] for everything, or a list of board ids to restrict to. |
Install your own
Drop a bare .lua file into /apps/ on the SD card and it shows up in the drawer — no manifest needed. The filename becomes the name. That is the fast loop while you are writing something.
Apps you side-load appear in the Store under Your own apps, where you can remove them again. A long press on the drawer tile also offers to remove.
On boards without an SD card the same path lives on internal storage.
Publish to the Store
The Store is served as static files, so publishing is a pull request against the firmware repository:
- Add
deploy/apps/<id>/<version>/<id>.luaand the matching.json. - Add an entry to
deploy/apps/apps.json.
Version paths are immutable — publishing 1.1 never rewrites 1.0. Devices compare the catalog version against what they have installed and offer Update when they differ, so bumping the version in both places is the whole release process.
Apps in the catalog are reviewed before they are merged. Keep them small and keep them readable.
Sandbox limits
Apps run in a restricted environment. These are removed: io, os, require, dofile, and loading new chunks at runtime. Available: math, string, table, and the usual pairs, ipairs, select, pcall, tostring, tonumber.
Memory comes from a capped pool in PSRAM, so an app that allocates without bound fails its own allocation rather than starving the radio or the UI. There is an instruction budget on every callback for the same reason.
None of this makes a hostile app safe, which is why the catalog is curated. It makes an honest app that has a bug survivable: it gets closed, and the device keeps carrying traffic.