WADAMESH Lua SDK Back to site

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.

API v1 Lua 5.4

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.

An app cannot put packets on the air behind your back. Reading the mesh — contacts, the packet log, radio statistics — is free. Transmitting is not: wada.mesh.send exists, but the first call from a given app stops and asks you, by name, and a refusal is remembered. Same for reading your incoming messages. See Permissions.

Your first app

Save this as hello.lua:

local app = {}
local status
local n = 0

function app.on_open(w, h)
  wada.ui.label("Hello from Lua", 6, 4, 16, wada.ui.colors.text)
  status = wada.ui.label("tick 0", 6, 26, 12, wada.ui.colors.sub)
  wada.timer.every(1000)
end

function app.on_tick()
  n = n + 1
  status:set("tick " .. n)
end

function app.on_input(ev)
  if ev.type == "key" then
    wada.sys.toast("you pressed " .. tostring(ev.key))
  end
end

return app

The last line matters. An app is a table you build and return; the firmware calls the on_* fields on that table. Callbacks defined as plain globals are never found, so the app opens to a blank page with no error. If nothing happens, check for return app first.

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

Build a table, put the callbacks you need on it, and return it from the file. All of them are optional.

CallbackWhen it runs
app.on_open(w, h)Once, when the app opens. Build your UI here. w / h are the usable body size in pixels — use them instead of assuming a screen size, since the boards differ.
app.on_tick()On the cadence set by wada.timer.every(ms). Never faster than 33 ms.
app.on_input(ev)For every touch, key or trackball event. See wada.input.
app.on_message(m)An incoming mesh message, if the user granted the app the read permission. See Permissions.
app.on_close()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_open 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.

CallReturns / does
wada.ui.label(text, x, y, size, color)A text line placed at x, y. size is a class, not a pixel height: 12, 14 or 16. color takes a wada.ui.colors value.
wada.ui.button(text, x, y, w, h, fn)A tappable button at x, y sized w × h; 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.colorsTheme 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.typeFields
"touch"ev.x, ev.y in page coordinates.
"key"ev.key — a one-character string for printable keys ("w"), or a name for the rest: up, down, left, right, enter, esc, backspace. ev.code carries the raw value. Boards with a keyboard only; the key that closes the app is never delivered, so an app cannot trap you inside it.
"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}.
wada.mesh.send(channel, text) extSend to a channel by name. Returns ok, err. Needs the send permission — the first call prompts the user. err is "denied" if they refused, "too fast" inside the 5 second floor, "too long" past 180 characters.

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 app.on_close() or on a real user action, never on every tick.

wada.fs ext

A private folder per app, for things too big or too structured for wada.store — a log, a track, a cache. Paths are plain names inside your own folder: an app cannot name a file outside it, and cannot see another app's files. .. and absolute paths are rejected rather than sanitised.

CallReturns / does
wada.fs.read(name)File contents as a string, or nil.
wada.fs.write(name, data)Replace a file. Returns ok, err.
wada.fs.append(name, data)Append. Returns ok, err.
wada.fs.list()Array of {name, size}.
wada.fs.remove(name)Delete a file.

Writes are rate-limited to roughly one per second and files are capped at 32 KB. A write inside the window returns false, "too fast" — that is the normal answer, not a failure, so check it. The limit is not arbitrary: on a board with no card this lands on the same internal flash the mesh uses, and an app looping on writes would stall the whole device.

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.sys.epoch()Unix time in seconds, or nil when the clock has not been set yet (no GPS fix and no NTP). Always handle the nil.
wada.sys.datetime(){year, month, day, hour, min, sec, wday} in local time. wday is 0 for Sunday.
wada.sys.beep()Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off.
wada.sys.caps(){sdk_ext, keyboard, touch, sd}. Check sdk_ext before using anything marked ext below.
wada.sys.battery() ext{mv, pct, charging}.
wada.sys.gps() ext{lat, lon, sats}, or nil with no fix — which is the normal indoor case, so handle it.

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.

Permissions

Two things an app can ask for reach past its own window, so they are not granted by installing it:

PermissionUnlocks
Sendwada.mesh.send — transmitting in your name.
Read messagesapp.on_message — seeing messages as they arrive.

They are separate: granting one does not grant the other. The prompt names the app and appears on the first attempt, not at install time, so you are asked at the moment it is obvious what the app wants it for. A refusal is remembered — the app does not get to ask again on a loop — and the call simply returns false, "denied", which a well-written app should handle rather than break on.

Settings → App permissions lists every installed app and what it holds, and revokes with one switch. Granting happens where an app asks; that page is for review and for taking it back.

Write for the denial. The user can say no, or revoke later. Treat a permitted send as the lucky path, not the assumption.

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":  ["*"]
}
idLowercase, no spaces. Must match the filename.
min_apiRefuse to run on firmware older than this API version.
iconA 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>.lua and 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.

The extended calls are not on every board. Anything marked ext above — wada.fs, wada.mesh.send, sys.battery, sys.gps — needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call wada.sys.caps().sdk_ext and degrade gracefully rather than assuming; an app that hard-depends on them without checking simply errors out on the small boards.

Rate limits are part of the contract, not a rainy-day guard: wada.fs writes are about one per second with a 32 KB file cap, and wada.mesh.send has a 5 second floor and a 180-character limit. They return false, "too fast" rather than throwing, and hitting them is expected — handle it.

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.