Files
Kaj SchittecatandClaude Opus 5 f49a950e30 lua: wada.ui.clear(), so an app can have more than one screen
The SDK documented building widgets in on_open() and gave no way to take them
down, so an app with a second screen drew it on top of the first. pisti87 hit
this building a multi-screen app and had been working around it with buttons;
he also noted that ui.list() has clear() while the page it sits on does not.

The reason this is not just lv_obj_clean() is the handles. WidgetUd holds a raw
lv_obj_t*, guarded only by `if (u->obj)`, so a Lua variable still referring to a
label from the screen just cleared would sail past that check into freed memory.
Every handle now records the generation it was created in, clear() bumps the
generation, and the accessors null the pointer of any handle from an older one.
The null guard that every method already has then turns a stale call into a
no-op. Given the day this codebase has had with use-after-frees, a clear() that
invited one would not have been worth shipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:02:15 +02:00

637 lines
64 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WADAMESH — Lua app SDK</title>
<meta name="description" content="Write your own apps for WADAMESH in Lua. The wada.* API reference: ui, input, mesh, net, store, sys and timer, plus the app format and how to publish to the Store.">
<link rel="icon" href="wadamesh-badge.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#ffffff; --bg2:#f4f6f2; --ink:#15181e; --mut:#5d6770;
--line:#e5e8e2; --teal:#15b6a6; --teal-ink:#0e8d80;
color-scheme:light;
}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Inter,sans-serif; background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased}
a{color:var(--teal-ink); text-decoration:none}
.top{position:sticky; top:0; z-index:30; display:flex; align-items:center; gap:14px; padding:12px 22px;
background:rgba(255,255,255,.88); backdrop-filter:saturate(1.2) blur(8px); border-bottom:1px solid var(--line)}
.top .word{font-weight:700; letter-spacing:2px; font-size:1.05rem; color:var(--ink)}
.top .word .t{color:var(--teal)}
.top .tag{font-family:'JetBrains Mono',monospace; font-size:.66rem; letter-spacing:.18em; text-transform:uppercase; color:var(--mut); border:1px solid var(--line); border-radius:100px; padding:4px 9px}
.top .sp{flex:1}
.top .home{display:inline-flex; align-items:center; gap:7px; color:var(--mut); font-size:.86rem; border:1px solid var(--line); border-radius:100px; padding:6px 13px; background:#fff; transition:background .15s,color .15s,border-color .15s}
.top .home:hover{color:var(--teal-ink); background:var(--bg2); border-color:#bfe6e0}
.top .home svg{width:16px;height:16px}
.shell{max-width:1200px; margin:0 auto; display:grid; grid-template-columns:220px 1fr; gap:34px; padding:0 22px}
nav.toc{position:sticky; top:64px; align-self:start; height:calc(100vh - 64px); overflow:auto; padding:26px 0 40px}
nav.toc .grp{font-family:'JetBrains Mono',monospace; font-size:.68rem; letter-spacing:.13em; text-transform:uppercase; color:var(--mut); margin:20px 0 7px}
nav.toc .grp:first-child{margin-top:0}
nav.toc a{display:block; padding:6px 12px; border-radius:9px; color:#46525c; font-size:.92rem; border-left:2px solid transparent}
nav.toc a:hover{background:var(--bg2); color:var(--ink)}
nav.toc a.on{color:var(--teal-ink); background:rgba(21,182,166,.09); border-left-color:var(--teal); font-weight:600}
main{padding:30px 0 90px; min-width:0}
.hero h1{font-size:clamp(1.9rem,4.4vw,2.6rem); letter-spacing:-.5px; font-weight:750}
.hero p{color:var(--mut); max-width:64ch; margin-top:10px; font-size:1.05rem}
.hero .meta{display:flex; gap:8px; flex-wrap:wrap; margin-top:16px}
.chip{display:inline-flex; align-items:center; gap:6px; font-size:.8rem; color:#3a444d; border:1px solid var(--line); border-radius:100px; padding:5px 11px; background:#fff}
.chip svg{width:14px;height:14px;color:var(--teal)}
section.doc{padding:36px 0; border-top:1px solid var(--line); scroll-margin-top:74px}
.doc h2{font-size:1.5rem; font-weight:730; letter-spacing:-.3px; display:flex; align-items:center; gap:10px}
.doc h2 .ico{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:none; background:rgba(21,182,166,.12);color:var(--teal)}
.doc h2 .ico svg{width:18px;height:18px}
.row{display:grid; grid-template-columns:minmax(0,1fr) 372px; gap:34px; align-items:start; margin-top:16px}
@media(max-width:980px){.row{grid-template-columns:1fr}}
.prose{color:#3c454d; min-width:0}
.prose p{margin:0 0 12px}
.prose h3{font-size:1.02rem; margin:18px 0 6px; color:var(--ink)}
.prose ul{margin:4px 0 12px; padding-left:18px}
.prose li{margin:4px 0}
.prose b{color:var(--ink)}
.prose code{font-family:'JetBrains Mono',monospace; font-size:.85em; background:var(--bg2); border:1px solid var(--line); border-radius:6px; padding:1px 6px}
.shot{position:sticky; top:74px}
.cap{text-align:center; font-family:'JetBrains Mono',monospace; font-size:.68rem; color:var(--mut); margin-top:10px; letter-spacing:.04em}
/* T-Deck frame: the real screenshot + a generated QWERTY keyboard (added in JS) */
.device{width:344px; margin:0 auto; background:#16191d; border-radius:16px; padding:12px 12px 9px; border:1px solid #e9ece7; box-shadow:0 24px 60px -30px rgba(21,24,30,.5)}
.device img.screen{display:block; width:320px; height:240px; border-radius:8px}
.kbd{margin:9px auto 2px; display:flex; flex-direction:column; gap:4px; width:312px}
.kr{display:flex; gap:4px; justify-content:center}
.key{flex:1; max-width:28px; height:15px; background:#23282e; border-radius:4px; display:grid; place-items:center; font:8px/1 system-ui; color:#7d8893}
.key.wide{max-width:140px; flex:4}
.gallery{display:grid; grid-template-columns:repeat(auto-fill,minmax(232px,1fr)); gap:20px; margin-top:18px}
.mini{background:#16191d; border-radius:12px; padding:7px; border:1px solid #e9ece7}
.mini img{display:block; width:100%; height:auto; border-radius:6px}
.mini .cap{margin-top:6px}
.webgrid{display:grid; grid-template-columns:repeat(auto-fill,minmax(178px,1fr)); gap:18px; margin-top:22px}
figure.phone{margin:0; background:#16191d; border-radius:20px; padding:8px; border:1px solid #e9ece7; box-shadow:0 18px 44px -26px rgba(21,24,30,.5)}
figure.phone img{display:block; width:100%; height:auto; border-radius:13px}
figure.phone figcaption{text-align:center; font-family:'JetBrains Mono',monospace; font-size:.66rem; color:var(--mut); margin-top:8px; letter-spacing:.04em}
.subh{font-size:1.06rem; font-weight:700; color:var(--ink); margin:30px 0 2px}
/* Settings-page detail cards: real screenshot + what the page does + its controls */
.sgrp{font-family:'JetBrains Mono',monospace; font-size:.7rem; letter-spacing:.14em; text-transform:uppercase; color:var(--mut); margin:30px 0 0; padding-top:4px}
.scards{display:grid; grid-template-columns:repeat(auto-fill,minmax(290px,1fr)); gap:20px; margin-top:13px}
.scard{border:1px solid var(--line); border-radius:14px; background:#fff; padding:13px; display:flex; flex-direction:column}
.scard .thumb{background:#16191d; border-radius:9px; padding:6px}
.scard .thumb img{display:block; width:100%; height:auto; border-radius:5px}
.scard .thumb.ph{aspect-ratio:4/3; display:grid; place-items:center; border:1px dashed #39424b; padding:0}
.scard .thumb.ph span{font-family:'JetBrains Mono',monospace; font-size:.6rem; letter-spacing:.12em; color:#7d8893; text-transform:uppercase}
.scard h3{font-size:1.04rem; margin:12px 0 2px; color:var(--ink); display:flex; align-items:center; gap:8px; flex-wrap:wrap}
.scard h3 .tag{font-family:'JetBrains Mono',monospace; font-size:.58rem; letter-spacing:.05em; text-transform:uppercase; color:var(--teal-ink); background:rgba(21,182,166,.1); border-radius:5px; padding:2px 6px; font-weight:600}
.scard .lede{color:var(--mut); font-size:.9rem; margin:2px 0 9px}
.scard ul{margin:0; padding-left:17px}
.scard li{margin:4px 0; font-size:.89rem; color:#3c454d; line-height:1.5}
.scard li b{color:var(--ink); font-weight:600}
/* Caution callout (amber) */
.callout{display:flex; gap:11px; margin-top:18px; padding:13px 15px; border-radius:11px; background:#fff7ed; border:1px solid #f6dcb8}
.callout .cico{flex:none; width:20px; height:20px; color:#c2740b; margin-top:1px}
.callout .cico svg{width:20px; height:20px}
.callout b{display:block; color:#7c4a03; margin-bottom:3px; font-size:.94rem}
.callout p{margin:0; color:#7a5320; font-size:.9rem; line-height:1.55}
footer{border-top:1px solid var(--line); padding:26px 22px; text-align:center; color:var(--mut); font-size:.85rem}
@media(max-width:980px){
.shell{grid-template-columns:1fr; gap:0}
nav.toc{position:static; height:auto; border-bottom:1px solid var(--line); padding:14px 0; display:flex; gap:6px; overflow:auto; white-space:nowrap}
nav.toc .grp{display:none} nav.toc a{border-left:0; flex:none}
.shot{position:static; margin:0 auto 6px}
}
.prose pre{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:14px 0;font:13px/1.6 "JetBrains Mono",ui-monospace,Menlo,Consolas,monospace}
.prose pre code{background:none;padding:0;font-size:inherit}
.api{width:100%;border-collapse:collapse;margin:12px 0 18px;font-size:.92rem;display:block;overflow-x:auto}
.api th,.api td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
.api th{font-weight:700;white-space:nowrap}
.api td:first-child{white-space:nowrap;font-family:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;font-size:.86rem;color:var(--teal-ink)}
.note{background:var(--bg2);border-left:3px solid var(--teal);border-radius:0 8px 8px 0;padding:12px 16px;margin:16px 0}
.note p{margin:0}
</style>
</head>
<body>
<div class="top">
<a class="word" href="index.html">WADA<span class="t">MESH</span></a>
<span class="tag">Lua SDK</span>
<span class="sp"></span>
<a class="home" href="index.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg> Back to site</a>
</div>
<div class="shell">
<nav class="toc" id="toc">
<div class="grp">Start here</div>
<a href="#intro">What this is</a>
<a href="#hello">Your first app</a>
<a href="#lifecycle">App lifecycle</a>
<div class="grp">Reference</div>
<a href="#ui">wada.ui</a>
<a href="#input">wada.input</a>
<a href="#mesh">wada.mesh</a>
<a href="#net">wada.net</a>
<a href="#store">wada.store</a>
<a href="#fs">wada.fs</a>
<a href="#audio">wada.audio</a>
<a href="#sd">wada.sd</a>
<a href="#sys">wada.sys</a>
<a href="#timer">wada.timer</a>
<a href="#discover">Discovery</a>
<a href="#map">wada.map</a>
<a href="#geo">wada.geo</a>
<a href="#packets">on_packet</a>
<a href="#timers">Timers</a>
<a href="#budget">Instruction budget</a>
<a href="#crypto">wada.crypto</a>
<a href="#perms">Permissions</a>
<div class="grp">Shipping</div>
<a href="#format">App format</a>
<a href="#install">Install yours</a>
<a href="#publish">Publish to the Store</a>
<a href="#sandbox">Sandbox limits</a>
</nav>
<main>
<div class="hero">
<h1>Lua app SDK</h1>
<p>WADAMESH runs small Lua apps. They live on the device as a single <code>.lua</code> file, appear in the app drawer next to the built-ins, and can be installed and updated from the Store over the air.</p>
<div class="meta"><span class="chip">API v1</span> <span class="chip">Lua 5.4</span></div>
</div>
<section class="doc" id="intro">
<h2>What this is</h2>
<div class="row"><div class="prose">
<p>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 <code>wada.</code> table.</p>
<p>LVGL is <b>not</b> 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.</p>
<p>Two of the apps that ship in the Store — <b>RF Monitor</b> and <b>Airtime</b> — are written against exactly this API and nothing else. They are the reference implementations; read them if a doc paragraph is ambiguous.</p>
<div class="note"><p><b>An app cannot put packets on the air behind your back.</b> Reading the mesh — contacts, the packet log, radio statistics — is free. Transmitting is not: <code>wada.mesh.send</code> 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 <a href="#perms">Permissions</a>.</p></div>
</div></div>
</section>
<section class="doc" id="hello">
<h2>Your first app</h2>
<div class="row"><div class="prose">
<p>Save this as <code>hello.lua</code>:</p>
<pre><code>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</code></pre>
<div class="note"><p><b>The last line matters.</b> An app is a table you build and <code>return</code>; the firmware calls the <code>on_*</code> 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 <code>return app</code> first.</p></div>
<p>Copy it to <code>/apps/hello.lua</code> 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 <a href="#install">Install yours</a>.</p>
</div></div>
</section>
<section class="doc" id="lifecycle">
<h2>App lifecycle</h2>
<div class="row"><div class="prose">
<p>Build a table, put the callbacks you need on it, and <code>return</code> it from the file. All of them are optional.</p>
<table class="api">
<tr><th>Callback</th><th>When it runs</th></tr>
<tr><td>app.on_open(w, h)</td><td>Once, when the app opens. Build your UI here. <code>w</code>&nbsp;/&nbsp;<code>h</code> are the usable body size in pixels — use them instead of assuming a screen size, since the boards differ.</td></tr>
<tr><td>app.on_tick()</td><td>On the cadence set by <code>wada.timer.every(ms)</code>. Never faster than 33&nbsp;ms.</td></tr>
<tr><td>app.on_input(ev)</td><td>For every touch, key or trackball event. See <a href="#input">wada.input</a>.</td></tr>
<tr><td>app.on_message(m)</td><td>An incoming message. <code>m.kind</code> is <code>"channel"</code>, <code>"dm"</code> or <code>"room"</code>, alongside <code>m.channel</code>, <code>m.sender</code> and <code>m.text</code>. Channel traffic needs the channel-read permission; DMs and room posts need the private-read one. See <a href="#perms">Permissions</a>.</td></tr>
<tr><td>app.on_close()</td><td>Once, when the app closes. Persist anything you care about here.</td></tr>
</table>
<p>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 <a href="#budget">instruction budget</a> on every callback, so an app that spins forever is stopped rather than taking the device with it.</p>
<p>Widgets you create in <code>on_open</code> are destroyed for you when the app closes. You do not free anything.</p>
</div></div>
</section>
<section class="doc" id="ui">
<h2>wada.ui</h2>
<div class="row"><div class="prose">
<p><b>Colours.</b> <code>wada.ui.colors</code> carries the firmware's palette: <code>bg</code> (the page ground — black, the same as the rest of the interface), <code>panel</code> (a raised surface to lift a card or an instrument off that ground), <code>text</code>, <code>sub</code>, <code>accent</code>, <code>good</code>, <code>bad</code>. Use them rather than your own hex so an app looks like part of the device.</p>
</div></div>
<div class="row"><div class="prose">
<p>Widgets are created in order, top to bottom, on the app's page. Every constructor returns a handle with methods you can call later.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.ui.label(text, x, y, size, color)</td><td>A text line placed at <code>x, y</code>. <code>size</code> is a class, not a pixel height: 12, 14 or 16. <code>color</code> takes a <code>wada.ui.colors</code> value.</td></tr>
<tr><td>wada.ui.button(text, x, y, w, h, fn)</td><td>A tappable button at <code>x, y</code> sized <code>w &times; h</code>; <code>fn</code> is called with no arguments.</td></tr>
<tr><td>wada.ui.text_w(text [, size])</td><td>Rendered width of <code>text</code> in pixels, for the given size class.</td></tr>
<tr><td>wada.ui.text_lines(text, width [, size])</td><td>How many lines <code>text</code> wraps to inside <code>width</code>. Counts explicit newlines too. <b>Use this when you lay out your own rows.</b> <code>label:width()</code> turns wrapping on, so a line longer than the screen becomes two while a fixed <code>y</code> step still advances by one, and the next row is drawn on top of it. This is the single most common way an app's layout goes wrong; it happened to the SDK Test app's channel list.</td></tr>
<tr><td>wada.ui.clear()</td><td>Remove every widget from the app's page, leaving the page itself. This is how you build an app with more than one screen: clear, then create the next screen's widgets in the same place you built the first. <b>Any handle you still hold from before the clear is dead</b> &mdash; calling a method on it does nothing rather than crashing, but it will not come back, so re-create and re-assign. Older firmware has no <code>ui.clear</code>: write <code>local clear = ui.clear or function() end</code> if you need to run on both.</td></tr>
<tr><td>wada.ui.list(x, y, w, h)</td><td>A scrollable list of selectable rows &mdash; the "pick one of N" widget. Methods: <code>add(text [, fn])</code> returning the row index, <code>set(i, text)</code>, <code>color(i, c)</code>, <code>select(i)</code> (highlights <i>and</i> scrolls it into view), <code>selected()</code>, <code>count()</code>, <code>clear()</code>, <code>pos(x, y)</code>. Rows are real buttons, so keyboard and trackball navigation walks them on touchless boards without you doing anything.</td></tr>
<tr><td>wada.ui.input(title, initial, cb)</td><td>A modal text field. <code>cb(text)</code> on OK, <code>cb(nil)</code> on cancel or on the dialog being closed any other way &mdash; exactly one call, always, so an app that disabled itself while waiting always gets to re-enable. Uses the firmware's own dialog and keyboard, so it behaves identically on a touchscreen, on the Tanmatsu's physical keyboard and on a trackball board. One prompt at a time.</td></tr>
<tr><td>wada.ui.canvas(w, h)</td><td>A drawing surface. See the canvas methods below.</td></tr>
<tr><td>wada.ui.chart(points)</td><td>A line chart sized to the page. The Airtime and RF Monitor primitive.</td></tr>
<tr><td>wada.ui.scroll(on)</td><td>Allow the page to scroll when content is taller than the screen.</td></tr>
<tr><td>wada.ui.text_h(size)</td><td>Line height in pixels for a size class — use it to lay out a canvas.</td></tr>
<tr><td>wada.ui.colors</td><td>Theme table: <code>text</code>, <code>sub</code>, <code>bg</code>, <code>accent</code>, <code>good</code>, <code>bad</code>.</td></tr>
</table>
<h3>Label handle</h3>
<table class="api">
<tr><td>lbl:set(text)</td><td>Replace the text.</td></tr>
<tr><td>lbl:color(c)</td><td>Set the colour, e.g. <code>wada.ui.colors.accent</code>.</td></tr>
<tr><td>lbl:pos(x, y)</td><td>Place it explicitly instead of in flow.</td></tr>
<tr><td>lbl:width(px, [align])</td><td>Fix the width; longer text wraps instead of running off the panel. Optional <code>"center"</code> / <code>"right"</code> aligns the text inside that width (default left).</td></tr>
</table>
<h3>Canvas handle</h3>
<table class="api">
<tr><td>cv:fill(c)</td><td>Flood the whole canvas.</td></tr>
<tr><td>cv:rect(x, y, w, h, c)</td><td>Filled rectangle.</td></tr>
<tr><td>cv:line(x1, y1, x2, y2, c)</td><td>Line.</td></tr>
<tr><td>cv:circle(x, y, r, c)</td><td>Filled circle.</td></tr>
<tr><td>cv:text(x, y, s, c, size)</td><td>Draw a string.</td></tr>
<tr><td>cv:pos(x, y)</td><td>Move the canvas itself.</td></tr>
</table>
<h3>Chart handle</h3>
<table class="api">
<tr><td>ch:push(v)</td><td>Append a point, scrolling the series.</td></tr>
<tr><td>ch:fill(t)</td><td>Replace every point from a table.</td></tr>
<tr><td>ch:range(min, max)</td><td>Fix the Y range instead of autoscaling.</td></tr>
<tr><td>ch:axis(ticks, gutter)</td><td>Draw Y-axis labels; <code>gutter</code> reserves space for them.</td></tr>
<tr><td>ch:pos(x, y)</td><td>Move the chart.</td></tr>
</table>
<div class="note"><p>Screens differ a lot — 240&nbsp;px portrait on a Heltec V4, 320&nbsp;px landscape on a T-Deck, and a tall high-DPI panel on a T-Display P4. Ask <code>wada.sys.board()</code> for the real width and height rather than hardcoding a layout, and prefer <code>wada.ui.text_h()</code> over assuming a font is so many pixels tall.</p></div>
</div></div>
</section>
<section class="doc" id="input">
<h2>wada.input</h2>
<div class="row"><div class="prose">
<p>Input arrives through <code>on_input(ev)</code>. The event is a table; <code>ev.type</code> tells you which kind it is.</p>
<table class="api">
<tr><th>ev.type</th><th>Fields</th></tr>
<tr><td>"touch"</td><td><code>ev.x</code>, <code>ev.y</code> in page coordinates.</td></tr>
<tr><td>"key"</td><td><code>ev.key</code> — a one-character string for printable keys (<code>"w"</code>), or a name for the rest: <code>up</code>, <code>down</code>, <code>left</code>, <code>right</code>, <code>enter</code>, <code>esc</code>, <code>backspace</code>. <code>ev.code</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.</td></tr>
<tr><td>"dir"</td><td><code>ev.dir</code> — one of <code>up</code>, <code>down</code>, <code>left</code>, <code>right</code>, <code>select</code>. Trackball, D-pad and swipes all arrive here.</td></tr>
</table>
<p>Handle <code>"dir"</code> 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.</p>
</div></div>
</section>
<section class="doc" id="mesh">
<h2>wada.mesh</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.mesh.contacts([offset], [limit])</td><td>Array of <code>{name, pubkey, type, ago_s, lat, lon, lat_e6, lon_e6}</code>. <code>pubkey</code> is the first 4 bytes as 8 hex characters &mdash; the same short form the rest of the interface uses, and what lines a contact up with a discovery hit. Returns at most <code>limit</code> entries (default 100, maximum 250) starting at <code>offset</code>, because every entry is an eight-field table and building thousands in one call would exhaust the app heap. On a device with more contacts than that, page through them: ask for 100, then 100 from offset 100, and so on.</td></tr>
<tr><td>wada.mesh.contact_count()</td><td>How many contacts the device holds, so you can page through <code>contacts()</code> without calling it repeatedly to find where the list ends.</td></tr>
<tr><td>wada.mesh.rx_log()</td><td>Recent packets, newest first: <code>{ago_ms, type, rssi, snr, hops, route, len}</code>, plus whatever identity the frame actually carried &mdash; see below. The RF Monitor feed.</td></tr>
<tr><td>wada.mesh.stats()</td><td><code>{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}</code>.</td></tr>
<tr><td>wada.mesh.self()</td><td>This node: <code>{name, pubkey, lat, lon, lat_e6, lon_e6}</code>.</td></tr>
<tr><td>wada.mesh.discovered()</td><td>Everything that has answered a probe this session: <code>{pubkey, name, type, rssi, snr, their_snr, hops, direct, first_ms_ago, last_ms_ago, heard}</code>. <code>name</code> is present only if the responder is already one of your contacts. See <a href="#discover">discovery</a>.</td></tr>
<tr><td>wada.mesh.discover_clear()</td><td>Forget every hit so far. A survey calls this between locations, or the next sample inherits nodes you heard a street back.</td></tr>
<tr><td>wada.mesh.discover([types]) <span class="chip">ext</span></td><td>Broadcast a discovery probe. Returns the scan tag, or <code>false, err</code>. Needs the <b>discovery-probe</b> permission and has a 15&nbsp;second floor. See <a href="#discover">discovery</a>.</td></tr>
<tr><td>wada.mesh.send(channel, text) <span class="chip">ext</span></td><td>Post to a channel by name, public or private. Returns <code>ok, err</code>. Needs the channel-send permission; the first call prompts the user. <code>err</code> is <code>"permission denied"</code> if they refused, <code>"too fast"</code> inside the 5&nbsp;second floor, <code>"bad length"</code> past 180 characters.</td></tr>
<tr><td>wada.mesh.send_dm(to, text) <span class="chip">ext</span></td><td>Send a direct message, or post to a room server. <code>to</code> is a contact <b>name</b> as it appears in <code>wada.mesh.contacts()</code>. A room is a contact too, so one call covers both and the second return is <code>"sent"</code> or <code>"room"</code>. Needs the <b>private</b> send permission, which is separate from channels.</td></tr>
<tr><td>wada.mesh.channels()</td><td>Array of the channel names configured on this device, so you can find the private ones rather than being told their names. Names only: the channel secret is never exposed to Lua.</td></tr>
</table>
<p>These are snapshots taken when you call them, not live views — call again on each tick to refresh.</p>
<h3>Who sent a packet</h3>
<p>MeshCore only puts a real identity on the wire in one place: an <b>advert</b>, whose payload opens with the sender's public key. Those entries in <code>rx_log()</code> carry <code>pubkey</code>, 8 hex characters, and they are the ones worth logging &mdash; an advert both names a node and proves it was audible from where you are.</p>
<p>Addressed frames (text, requests, responses, path replies) carry one-byte destination and source hashes instead, exposed as <code>dst</code> and <code>src</code>, two hex characters each. Two characters collide easily, so treat them as a hint and never as an identity. Everything else is anonymous on the wire and gets neither field. Nothing here is inferred: if a field is absent, the frame did not contain it.</p>
<h3 id="node-types">Node types</h3>
<p>The <code>type</code> field everywhere, and the filter <code>discover()</code> takes, use these. They are on the <code>wada.mesh</code> table so you never have to hardcode the wire numbers.</p>
<table class="api">
<tr><td>wada.mesh.NODE_CHAT</td><td><code>1</code> &mdash; a companion / chat node.</td></tr>
<tr><td>wada.mesh.NODE_REPEATER</td><td><code>2</code></td></tr>
<tr><td>wada.mesh.NODE_ROOM</td><td><code>3</code> &mdash; a room server.</td></tr>
<tr><td>wada.mesh.NODE_SENSOR</td><td><code>4</code></td></tr>
</table>
</div></div>
</section>
<section class="doc" id="discover">
<h2>Discovery, and writing a coverage survey</h2>
<div class="row"><div class="prose">
<p><code>wada.mesh.discover()</code> broadcasts a zero-hop request that every node in earshot answers. That is a different thing from listening, and the difference is the whole reason it exists: a reply <b>proves</b> the link works from exactly where you are standing. Listening only tells you what happened to transmit while you were there, so a node that stays quiet is indistinguishable from a node you cannot reach.</p>
<p>The call returns immediately. Replies arrive over the next few seconds and accumulate in <code>wada.mesh.discovered()</code>, so the shape of a survey loop is: probe, wait a few seconds, read, <code>discover_clear()</code>, move.</p>
<h3>Both directions of the link</h3>
<p>Each hit carries <code>snr</code> (how well <b>you</b> heard <b>them</b>) and <code>their_snr</code> (how well <b>they</b> heard <b>you</b>, reported back inside the reply). These are rarely equal, because their antenna, height and transmit power are not yours. The asymmetry is the most useful thing a survey can record: <i>"I can hear the repeater but it cannot hear me"</i> is a different fact from <i>"no coverage"</i>, and no amount of passive listening will tell you which one you are looking at.</p>
<p><code>hops</code> is <code>0</code> when the reply came straight back &mdash; <code>direct</code> is that same test as a boolean. A hit with <code>hops &gt; 0</code> reached you through a repeater, so it says something about the mesh but nothing about your radio horizon.</p>
<h3>Cost, and the rate limit</h3>
<p>One probe costs one transmission from you and <b>a reply from every node that hears it</b>. That is airtime spent across the whole neighbourhood, not just yours, which is why it sits behind its own permission rather than reusing the message-send one: making other people's radios transmit and speaking as the user are different impositions, and a survey app has no business acquiring the second. The firmware enforces a <b>15&nbsp;second</b> minimum between probes per app; a faster call returns <code>false, "too fast"</code>. In practice sweep every 20&nbsp;seconds, which is quicker than anyone drives out of a cell.</p>
<h3>Logging it</h3>
<p>Lua here is built with 32-bit numbers, so <code>lat</code> and <code>lon</code> are <b>single-precision floats</b> &mdash; about a metre, fine to display and lossy to store. <code>sys.gps()</code>, <code>mesh.self()</code> and <code>mesh.contacts()</code> therefore also hand you <code>lat_e6</code> / <code>lon_e6</code>, the same reading as exact integers in micro-degrees. Write those. <code>sys.gps()</code> also gives <code>alt_m</code>, which matters more than people expect: height dominates LoRa range, and two samples a metre apart on the map can be a hilltop and a hollow.</p>
<p><code>wada.fs</code> allows one write a second, so buffer a sweep's rows and append them as one block rather than a line at a time. <code>wada.fs.read(name, offset, len)</code> reads a window, so an append-only log may grow past the 32&nbsp;KB single-read limit and still be readable in chunks.</p>
<p>There is a working example of all of this: the <b>Wardrive</b> app in the Store. It probes on a cadence, discards samples taken without a GPS fix rather than logging them at 0,0, and writes a CSV with both link directions per node.</p>
</div></div>
</section>
<section class="doc" id="timers">
<h2>wada.timer</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.timer.every(ms)</td><td>Sets the period of <code>on_tick</code>. This is the app heartbeat and there is one of it.</td></tr>
<tr><td>wada.timer.every(ms, fn)</td><td>A <b>named</b> repeating timer calling <code>fn</code>. Returns a handle.</td></tr>
<tr><td>wada.timer.after(ms, fn)</td><td>Fires <code>fn</code> once. Returns a handle you can cancel before it fires.</td></tr>
<tr><td>wada.timer.stop()</td><td>Stops the <code>on_tick</code> heartbeat.</td></tr>
<tr><td>wada.timer.stop(handle) / handle:stop()</td><td>Stops that one timer.</td></tr>
</table>
<p><b>Repeating timers pause while the display sleeps.</b> Nothing you draw is visible then, and an app polling a sensor would otherwise hold that hardware awake for a dark screen — so <code>on_tick</code> and repeating named timers stop being called and resume on wake. A one-shot from <code>timer.after</code> still fires: it is scheduled app logic, and skipping it would drop the work rather than defer it. Derive elapsed time from <code>wada.sys.millis()</code> rather than counting ticks and a sleep looks like one long frame.</p>
<p>An app used to get exactly one clock. Anything needing a second cadence &mdash; a slow poll beside a fast animation, a delayed retry, a timeout on a fetch &mdash; had to be a counter inside <code>on_tick</code>, which is both tedious and wrong when the tick period changes. Up to <b>eight</b> named timers per app; a ninth raises an error rather than quietly filling LVGL's timer list. Everything is stopped for you when the app closes.</p>
</div></div>
</section>
<section class="doc" id="packets">
<h2>app.on_packet</h2>
<div class="row"><div class="prose">
<p>Declare <code>on_packet</code> and the firmware calls it once per frame the radio received, in arrival order, with the same table <a href="#mesh"><code>rx_log()</code></a> rows use.</p>
<pre><code>function app.on_packet(p)
if p.pubkey then adverts = adverts + 1 end -- an advert: a real identity
total = total + 1
end</code></pre>
<p>Polling <code>rx_log()</code> on a tick samples a 16-deep ring, so in any real traffic an app sees a subset and cannot <i>count</i> anything. This delivers each frame exactly once. An app that does not declare <code>on_packet</code> costs nothing: the drain is only started for apps that asked for it.</p>
<p>Ungated, exactly like <code>rx_log()</code>: this is radio metadata about frames the device already received, not message content. Anything carrying content still arrives through <code>on_message</code> and its permissions.</p>
</div></div>
</section>
<section class="doc" id="map">
<h2>wada.map <span class="chip">ext</span></h2>
<div class="row"><div class="prose">
<p>The firmware's own slippy map, inside your app page: the same OpenStreetMap tiles, the same Web Mercator projection and the same on-disk cache the Map tab uses. Before this an app could draw geography only onto a bare canvas, with no basemap.</p>
<table class="api">
<tr><td>wada.map.view(x, y, w, h)</td><td>Creates the view. <b>One per app</b> &mdash; it owns a pool of up to four decoded tiles, which is 512&nbsp;KB of PSRAM, so opening them in a loop would be a straightforward way to exhaust the board. A second call errors.</td></tr>
<tr><td>map:center(lat, lon [, zoom])</td><td>Moves the view and redraws.</td></tr>
<tr><td>map:zoom() / map:zoom(z)</td><td>Reads, or sets and redraws. 1&ndash;19.</td></tr>
<tr><td>map:marker(lat, lon [, color [, size]])</td><td>A dot. Returns <code>false</code> if the point is outside the view, in which case nothing was drawn.</td></tr>
<tr><td>map:line(lat1, lon1, lat2, lon2 [, color [, width]])</td><td>A segment between two coordinates.</td></tr>
<tr><td>map:clear()</td><td>Removes every marker and line. The basemap stays.</td></tr>
<tr><td>map:to_screen(lat, lon)</td><td><code>x, y</code> within the view. Deliberately not clamped, so you can tell that a point is off-view instead of finding everything piled on the edge.</td></tr>
<tr><td>map:to_latlon(x, y)</td><td>The inverse &mdash; turn a tap into a coordinate.</td></tr>
<tr><td>map:tiles()</td><td>How many tiles the last redraw actually placed. <b>Check this.</b> Zero means nothing is cached for this area at this zoom, and an empty rectangle looks exactly like open water unless you say otherwise.</td></tr>
<tr><td>map:redraw() / map:close()</td><td>Force a redraw; dispose of the view early rather than waiting for the collector.</td></tr>
</table>
<p>Tiles come from whatever the device already has &mdash; the SD pack or the online cache &mdash; and a missing one is queued for download if Wi-Fi is up, exactly as on the Map tab. The view follows the user's night-mode setting rather than introducing a second one. The <b>Nearby</b> app in the Store is a worked example.</p>
</div></div>
</section>
<section class="doc" id="geo">
<h2>wada.geo</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.geo.distance(lat1, lon1, lat2, lon2)</td><td>Great-circle distance in <b>metres</b>.</td></tr>
<tr><td>wada.geo.bearing(lat1, lon1, lat2, lon2)</td><td>Initial true bearing, 0&ndash;360 degrees.</td></tr>
<tr><td>wada.geo.cardinal(degrees)</td><td><code>"N"</code>, <code>"NE"</code>, … Eight points, not sixteen: more would be false precision on a bearing derived from consumer GPS.</td></tr>
</table>
<p>In C because it is not a convenience wrapper: haversine and the bearing formula are a dozen trig calls each, and an app doing them per contact per tick spends a real slice of its <a href="#budget">instruction budget</a> on arithmetic the chip does in microseconds.</p>
<div class="note"><p><b>This is not a compass.</b> <code>bearing()</code> is a true bearing from one coordinate to another &mdash; the direction to <i>steer</i>, not the direction the user is <i>facing</i>. Only the ThinkNode M9 carries a magnetometer, and even there the heading is the app&apos;s to derive &mdash; see <a href="#sys">wada.sys.compass()</a>. On every other board a magnetic heading is not available at any level of the stack: to point somebody at something you need their course over ground from successive GPS fixes (<code>sys.gps().course</code>), or a physical compass.</p></div>
</div></div>
</section>
<section class="doc" id="net">
<h2>wada.net</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.net.http_get(url, cb [, max_bytes])</td><td>Fetch a URL. <code>cb(status, body)</code> runs when it lands, or with a negative status and <code>nil</code> on failure.</td></tr>
<tr><td>wada.net.http_post(url, body [, content_type], cb [, max_reply])</td><td>Upload. Any 2xx counts as success, so an endpoint answering 201 or 204 works. <code>content_type</code> defaults to <code>application/octet-stream</code>. This is how an app gets data <b>off</b> the device &mdash; a survey log, a sensor series, a webhook.</td></tr>
</table>
<p><code>http://</code> only, both of them. On-device TLS is not workable at the heap these boards have left once Wi-Fi has associated, so there is no <code>https</code> to offer; put a proxy in front if you need it. One request in flight at a time per app.</p>
<p>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.</p>
<div class="note"><p><b>Plain HTTP only, and that is not an oversight.</b> After Wi-Fi associates there is not enough free internal memory on the smaller boards for a TLS handshake — mbedTLS wants around 30&nbsp;KB and roughly 5&nbsp;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 (192&nbsp;KB by default).</p></div>
</div></div>
</section>
<section class="doc" id="store">
<h2>wada.store</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.store.get(key)</td><td>Read a string, or <code>nil</code>.</td></tr>
<tr><td>wada.store.set(key, value)</td><td>Write a string.</td></tr>
</table>
<p>Keys are namespaced per app, so two apps cannot collide. Keep it small — the budget is about 2&nbsp;KB per app, and the underlying store silently drops oversized values. High scores and settings, not logs.</p>
<p>Writes hit flash. Do them in <code>app.on_close()</code> or on a real user action, never on every tick.</p>
</div></div>
</section>
<section class="doc" id="fs">
<h2>wada.fs <span class="chip">ext</span></h2>
<div class="row"><div class="prose">
<p>A private folder per app, for things too big or too structured for <a href="#store">wada.store</a> — 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. <code>..</code> and absolute paths are rejected rather than sanitised.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.fs.read(name [, offset [, len]])</td><td>Returns <code>data, total_size</code>, or <code>nil</code> if there is no such file. One read hands back at most 32&nbsp;KB, but <code>offset</code> makes that a window rather than a ceiling: an append-only log may grow past it and be walked in chunks using the returned total.</td></tr>
<tr><td>wada.fs.write(name, data)</td><td>Replace a file. Returns <code>ok, err</code>.</td></tr>
<tr><td>wada.fs.append(name, data)</td><td>Append. Returns <code>ok, err</code>.</td></tr>
<tr><td>wada.fs.list()</td><td>Array of <code>{name, size}</code>.</td></tr>
<tr><td>wada.fs.remove(name)</td><td>Delete a file.</td></tr>
</table>
<p>Writes are rate-limited to roughly one per second and files are capped at 32&nbsp;KB. A write inside the window returns <code>false, "too fast"</code> — 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.</p>
</div></div>
</section>
<section class="doc" id="audio">
<h2>wada.audio <span class="chip">audio</span></h2>
<div class="row"><div class="prose">
<p>Asynchronous audio playback from the current app's private storage. A plain name such as <code>track.wav</code> resolves inside the same app folder as <a href="#fs">wada.fs</a>, whether that folder lives on internal flash, SD, or SD_MMC. Check <code>wada.sys.caps().audio</code> and <code>audio_wav</code> before using it.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.audio.play(name)</td><td>Starts or replaces playback and returns <code>true</code>, or <code>nil, error</code>. Plain names use app-private storage. On boards with <code>caps().audio_sd</code>, <code>sd:/Music/track.wav</code> reads directly from the physical card.</td></tr>
<tr><td>wada.audio.pause()</td><td>Pauses an active track. Returns whether the command was accepted.</td></tr>
<tr><td>wada.audio.resume()</td><td>Resumes a paused track. Returns whether the command was accepted.</td></tr>
<tr><td>wada.audio.stop()</td><td>Stops the active track. Returns whether the command was accepted.</td></tr>
<tr><td>wada.audio.status()</td><td><code>{state, path, source, format, error}</code>. State is <code>stopped</code>, <code>playing</code>, <code>paused</code>, <code>ended</code>, or <code>error</code>; <code>error</code> is present only after a playback failure.</td></tr>
</table>
<p>Supported formats are PCM WAV (16-bit mono or stereo at 8&ndash;48&nbsp;kHz) and MPEG Layer III MP3, including CBR, VBR, and ID3-tagged files. Stereo is mixed to mono on the device. Check <code>audio_wav</code> or <code>audio_mp3</code> rather than assuming a format exists on older firmware.</p>
<p>Playback follows the user's Sound switch and volume, never blocks the Lua callback, and stops automatically when the app closes. A second <code>play()</code> replaces the current track. Playlist ordering belongs to the app, so there are no ambiguous host-side <code>next()</code> or <code>previous()</code> calls.</p>
<p>Plain names follow the same sandbox rules as <code>wada.fs</code>. Explicit <code>sd:</code> paths follow <a href="#sd">wada.sd</a> path validation. Expected errors include <code>bad path</code>, <code>no storage</code>, <code>no sd</code>, <code>not found</code>, <code>busy</code>, <code>muted</code>, and <code>unsupported format</code>.</p>
</div></div>
</section>
<section class="doc" id="sd">
<h2>wada.sd <span class="chip">sd_list</span></h2>
<div class="row"><div class="prose">
<p>Read-only directory access to the physical SD card. This is separate from <a href="#fs">wada.fs</a>: it can inspect the card's directory tree, but cannot read file contents, write, rename or delete anything. Check <code>wada.sys.caps().sd_list</code> before using it.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.sd.list([path])</td><td>Returns an array of <code>{name, type, size, mtime}</code>, or <code>nil, error</code>. <code>path</code> defaults to <code>/</code>. <code>type</code> is <code>"file"</code> or <code>"dir"</code>; directories report size 0, and <code>mtime</code> is 0 when the filesystem has no timestamp.</td></tr>
</table>
<p>Paths are absolute from the card root and at most 191 bytes. Empty segments, trailing slashes, backslashes, <code>.</code> and <code>..</code> are rejected with <code>nil, "bad path"</code>. A missing or unreadable card returns <code>nil, "no sd"</code>; <code>"busy"</code> means another storage operation is changing the card lifecycle, so retry later. Missing paths and files used as directories return <code>"not found"</code> and <code>"not a directory"</code>.</p>
<p>One call returns at most 192 entries. When more exist, the returned array also has <code>entries.truncated == true</code>. Listing may mount an inserted card through the firmware's existing SD lifecycle, but never formats or modifies it.</p>
</div></div>
</section>
<section class="doc" id="sys">
<h2>wada.sys</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.sys.millis()</td><td>Milliseconds since boot.</td></tr>
<tr><td>wada.sys.board()</td><td><code>{w, h, touch, keyboard, trackball, gps}</code> — size and capabilities.</td></tr>
<tr><td>wada.sys.toast(msg)</td><td>Brief on-screen message.</td></tr>
<tr><td>wada.sys.random(n)</td><td>Integer in 1..n.</td></tr>
<tr><td>wada.sys.epoch()</td><td>Unix time in seconds, or <code>nil</code> when the clock has not been set yet (no GPS fix and no NTP). Always handle the <code>nil</code>.</td></tr>
<tr><td>wada.sys.datetime()</td><td><code>{year, month, day, hour, min, sec, wday}</code> in local time. <code>wday</code> is 0 for Sunday.</td></tr>
<tr><td>wada.sys.tr(s)</td><td>Translate <code>s</code> through the device's active language, using the same table the firmware's own interface uses. Returns <code>s</code> unchanged when there is no translation, so it is always safe to wrap a string. Add your keys to <code>deploy/apps/lang/&lt;code&gt;.lang</code> alongside the firmware's. Older firmware has no <code>sys.tr</code>: write <code>local tr = sys.tr or function(x) return x end</code> and call <code>tr()</code>.</td></tr>
<tr><td>wada.sys.beep()</td><td>Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, sd_list, audio, audio_wav, audio_mp3, audio_sd, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span>, <code>sd_list</code> before using <code>wada.sd</code>, and <code>audio</code> plus the format flag before using <code>wada.audio</code>. <code>audio_sd</code> means direct <code>sd:</code> paths are available; plain audio names use app storage and do not require it.</td></tr>
<tr><td>wada.sys.battery() <span class="chip">ext</span></td><td><code>{mv, pct, charging}</code>.</td></tr>
<tr><td>wada.sys.env() <span class="chip">sensors</span></td><td><code>{temp_c, humidity, pressure_hpa, alt_m}</code>, or <code>nil</code>. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on <code>caps().sensors</code> &mdash; does this board <i>have</i> the sensor rail &mdash; and <b>not</b> on <code>sdk_ext</code>, which is a memory gate. Those are different questions: the plain Heltec V4 has the Expansion Kit but not the extended SDK, and most boards with the extended SDK have no sensors at all.</td></tr>
<tr><td>wada.sys.gps() <span class="chip">ext</span></td><td><code>{lat, lon, lat_e6, lon_e6, sats, alt_m, time, speed_kmh, course}</code>, or <code>nil</code> with no fix &mdash; which is the normal indoor case, so handle it. <code>lat_e6</code>/<code>lon_e6</code> are exact micro-degrees; <code>lat</code>/<code>lon</code> are single-precision floats, so <a href="#discover">log the integers</a>. <code>alt_m</code> is metres. <code>time</code> is satellite time, absent until the receiver has decoded the date. <code>speed_kmh</code> and <code>course</code> (degrees clockwise from north) appear only on boards whose GPS provider reports them, and <code>course</code> only while actually moving &mdash; a stationary receiver has no course, so it is absent rather than 0. Treat those last three as optional.</td></tr>
<tr><td>wada.sys.accel() <span class="chip">accel</span></td><td>Only where <code>caps().accel</code> is true (ThinkNode M9 today). <code>{x, y, z}</code> acceleration in g, sensor frame, or <code>nil</code> when nothing fresh. Held still the magnitude is 1 and the axis pointing at the sky carries it &mdash; which is how an app works out which axis is which. Its real job is tilt: a magnetic heading taken from two axes is wrong by roughly 1.5&deg; per degree of tilt at mid latitudes, because the field dips ~60&deg; and tipping the device leaks that vertical field into the horizontal pair.</td></tr>
<tr><td>wada.sys.keep_awake(on)</td><td>For an app that is <i>measuring</i> rather than showing: holds the screen on and keeps <code>on_tick</code> running. Released automatically when the app closes. Use it around a calibration or a capture, not for the whole app &mdash; ticks otherwise pause with the display.</td></tr>
<tr><td>wada.sys.compass()</td><td>Only where <code>caps().compass</code> is true (ThinkNode M9 today), and note that this one is <b>not</b> <span class="chip">ext</span>-gated: it rides the hardware flag, not the memory flag. <code>{x, y, z, ovfl}</code> magnetic field in Gauss in the sensor's own frame, <b>uncalibrated</b>, or <code>nil</code> when nothing fresh; <code>ovfl</code> is true when the chip flagged the sample as saturated (a magnet nearby), in which case show that rather than a heading. There is deliberately no <code>heading</code>: the board carries a hard-iron bias the user has to calibrate away (track per-axis min/max while they turn the device, subtract the midpoints), and the sensor-to-screen axis mapping is the app's to set. Then <code>math.atan(-fy, fx)</code> with <code>fx</code> along the screen's top edge and <code>fy</code> along its right edge gives the heading. The GPS Compass app in the Store is the worked example.</td></tr>
</table>
</div></div>
</section>
<section class="doc" id="timer">
<h2>wada.timer</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.timer.every(ms)</td><td>Call <code>on_tick</code> every <code>ms</code>. Clamped to 33&nbsp;ms minimum.</td></tr>
<tr><td>wada.timer.stop()</td><td>Stop ticking.</td></tr>
</table>
<p>One timer per app. Calling <code>every</code> again changes the interval rather than adding a second timer.</p>
</div></div>
</section>
<section class="doc" id="budget">
<h2>The instruction budget</h2>
<div class="row"><div class="prose">
<p>Every callback runs under a cap of <b>100,000 Lua VM instructions</b>. Reaching it raises <code>instruction budget exceeded (app tick too long)</code>, which closes the app with a toast instead of letting it hold the UI thread forever.</p>
<table class="api">
<tr><th>Question</th><th>Answer</th></tr>
<tr><td>What is counted?</td><td><b>VM opcodes</b>, by Lua's own counter (<code>lua_sethook</code> with <code>LUA_MASKCOUNT</code>). Not native calls, not wall-clock time.</td></tr>
<tr><td>Per what?</td><td><b>Per callback.</b> The counter is armed immediately before each call and cleared after, so <code>on_open</code>, every <code>on_tick</code>, every <code>on_input</code> and every <code>on_message</code> each get a fresh 100,000. It is not a session allowance you can exhaust.</td></tr>
<tr><td>Does <code>on_open</code> get more?</td><td>Yes, <b>5&times;</b> (500,000). Setting a UI up legitimately does more work than a tick.</td></tr>
<tr><td>Do <code>wada.*</code> calls count?</td><td><b>No.</b> Time inside a native call costs wall-clock but not budget, because the VM is not executing opcodes. That cuts both ways: the budget will not save you from a slow host call, which is why <code>wada.fs</code> and the mesh sends carry their own rate limits instead.</td></tr>
</table>
<p>For scale, 100,000 instructions is well under 2&nbsp;ms of straight-line Lua on an ESP32-S3, and a runaway loop is contained in about 30&nbsp;ms. Ordinary per-tick app code is nowhere near it.</p>
<div class="note"><p><b>If you are hitting it, you are probably doing cryptography.</b> A HMAC-SHA1 in pure Lua is 80 bit operations per 64-byte block with interpreter overhead on every one, and that genuinely exceeds 100,000 instructions. That is not a runaway and the answer is not a bigger budget: use <a href="#crypto">wada.crypto</a>, where the same work costs microseconds and a handful of opcodes. This section exists because a one-time-password app hit exactly this.</p></div>
<p>The other way to stay inside it is to do less per callback. State kept in your app table persists between ticks, so a long job can be split across several: do a slice of the work, return, and continue on the next <code>on_tick</code>.</p>
</div></div>
</section>
<section class="doc" id="crypto">
<h2>wada.crypto</h2>
<div class="row"><div class="prose">
<p>Hashing in C, because hashing in Lua does not fit the instruction budget. HMAC-SHA1 is 80 bit operations per 64-byte block and the interpreter overhead multiplies every one, so a one-time-password app doing it by hand runs out of budget mid-callback. These call the mbedTLS the firmware already links: microseconds, and a handful of VM instructions.</p>
<table class="api">
<tr><th>Call</th><th>Returns</th></tr>
<tr><td>wada.crypto.sha256(data)</td><td>32-byte digest, as a binary string.</td></tr>
<tr><td>wada.crypto.sha1(data)</td><td>20-byte digest.</td></tr>
<tr><td>wada.crypto.hmac_sha256(key, msg)</td><td>32-byte HMAC.</td></tr>
<tr><td>wada.crypto.hmac_sha1(key, msg)</td><td>20-byte HMAC, the RFC&nbsp;4226 / 6238 one.</td></tr>
<tr><td>wada.crypto.hex(binary)</td><td>Lower-case hex of a binary string.</td></tr>
</table>
<p>Digests come back <b>raw</b>, not hex, so RFC&nbsp;4226 dynamic truncation works on them directly. Lua strings are 8-bit clean, so binary keys and messages are fine as they are.</p>
<p>Available on <b>every</b> board, unlike the <span class="chip">ext</span> calls. This is pure computation over data your app already holds: it reads nothing of the user's and transmits nothing, so there is no reason for the small boards to go without it.</p>
</div></div>
</section>
<section class="doc" id="perms">
<h2>Permissions</h2>
<div class="row"><div class="prose">
<p>Two things an app can ask for reach past its own window, so they are not granted by installing it:</p>
<table class="api">
<tr><th>Permission</th><th>Unlocks</th></tr>
<tr><td>Post to channels as me</td><td><code>wada.mesh.send</code>, posting to a channel in your name.</td></tr>
<tr><td>Read channel messages</td><td><code>app.on_message</code> for <code>kind == "channel"</code>.</td></tr>
<tr><td>Send private messages as me</td><td><code>wada.mesh.send_dm</code>, writing to one contact or posting to a room as you.</td></tr>
<tr><td>Read private messages</td><td><code>app.on_message</code> for <code>kind == "dm"</code> and <code>"room"</code>.</td></tr>
<tr><td>Send discovery probes</td><td><code>wada.mesh.discover</code>. Separate from the send permissions because it transmits nothing under your name but does make every node in range transmit a reply.</td></tr>
</table>
<p>Four rather than one because the risks genuinely differ: posting to a channel you are already in is not the same act as writing to one person as you, and channel traffic is not somebody's private conversations. They are separate: granting one does not grant another. 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 <code>false, "denied"</code>, which a well-written app should handle rather than break on.</p>
<p><b>Settings &rarr; App permissions</b> 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.</p>
<div class="note"><p><b>Write for the denial.</b> The user can say no, or revoke later. Treat a permitted send as the lucky path, not the assumption.</p></div>
</div></div>
</section>
<section class="doc" id="format">
<h2>App format</h2>
<div class="row"><div class="prose">
<p>On the device an app is one or two files on the active storage root:</p>
<pre><code>/apps/&lt;id&gt;.lua the code
/apps/&lt;id&gt;.json the manifest (optional for side-loaded apps)</code></pre>
<p>The manifest is what the Store and the drawer read — one line, all values quoted strings:</p>
<pre><code>{"id":"airtime","name":"Airtime","ver":"1.3","desc":"Duty cycle and airtime budget.","icon":"chart"}</code></pre>
<table class="api">
<tr><td>id</td><td>Lowercase, no spaces. Must match the filename.</td></tr>
<tr><td>name</td><td>Shown on the drawer tile and the Store card.</td></tr>
<tr><td>ver</td><td><code>major.minor</code>, matching the version directory. Compared against the catalog to offer an update.</td></tr>
<tr><td>desc</td><td>One sentence, read in the Store before installing.</td></tr>
<tr><td>icon</td><td>Optional. Picks the drawer tile's glyph by name — an app cannot ship artwork, and an unknown name falls back to the generic app symbol. One of: <code>gps</code> / <code>compass</code> / <code>map</code>, <code>radio</code>, <code>signal</code>, <code>chart</code>, <code>list</code>, <code>message</code>, <code>person</code>, <code>group</code>, <code>bell</code>, <code>star</code>, <code>search</code>, <code>settings</code>, <code>battery</code>, <code>game</code>.</td></tr>
</table>
<p>Earlier versions of this page showed <code>version</code>, <code>min_api</code>, <code>description</code> and <code>boards</code>. The device has never parsed those — its manifest reader takes quoted strings only, and the keys above are the ones it looks for. Use <code>ver</code> and <code>desc</code>.</p>
</div></div>
</section>
<section class="doc" id="install">
<h2>Install your own</h2>
<div class="row"><div class="prose">
<p>Drop a bare <code>.lua</code> file into <code>/apps/</code> 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.</p>
<p>Apps you side-load appear in the Store under <b>Your own apps</b>, where you can remove them again. A long press on the drawer tile also offers to remove.</p>
<p>On boards without an SD card the same path lives on internal storage.</p>
<p>No card slot you can reach (the ThinkNode M9's card is soldered on)? Push the files over the USB serial console instead: <code>scripts/sideload_app.py --port /dev/cu.wchusbserial10 --reboot deploy/apps/&lt;id&gt;/&lt;ver&gt;</code> sends the <code>.lua</code> + <code>.json</code> through the firmware's <code>fput</code>/<code>fadd</code>/<code>fend</code> commands into the same <code>/apps/</code> the Store uses (<code>--dest /lang</code> for a language file). Needs pyserial.</p>
</div></div>
</section>
<section class="doc" id="publish">
<h2>Publish to the Store</h2>
<div class="row"><div class="prose">
<p>The Store is served as static files, so publishing is a pull request against the firmware repository:</p>
<ul>
<li>Add <code>deploy/apps/&lt;id&gt;/&lt;version&gt;/&lt;id&gt;.lua</code> and the matching <code>.json</code>.</li>
<li>Add an entry to <code>deploy/apps/apps.json</code>.</li>
</ul>
<p>Version paths are immutable — publishing 1.1 never rewrites 1.0. Devices compare the catalog version against what they have installed and offer <b>Update</b> when they differ, so bumping the version in both places is the whole release process.</p>
<p>Apps in the catalog are reviewed before they are merged. Keep them small and keep them readable.</p>
</div></div>
</section>
<section class="doc" id="sandbox">
<h2>Sandbox limits</h2>
<div class="row"><div class="prose">
<p>Apps run in a restricted environment. These are removed: <code>io</code>, <code>os</code>, <code>require</code>, <code>dofile</code>, and loading new chunks at runtime. Available: <code>math</code>, <code>string</code>, <code>table</code>, and the usual <code>pairs</code>, <code>ipairs</code>, <code>select</code>, <code>pcall</code>, <code>tostring</code>, <code>tonumber</code>.</p>
<p>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 <a href="#budget">instruction budget</a> on every callback for the same reason.</p>
<p><b>The extended calls are not on every board.</b> Anything marked <span class="chip">ext</span> above &mdash; <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>wada.mesh.send_dm</code>, <code>wada.mesh.discover</code>, <code>sys.battery</code>, <code>sys.gps</code> &mdash; needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call <code>wada.sys.caps().sdk_ext</code> and degrade gracefully rather than assuming. <code>wada.sd</code> additionally needs a physical card interface, reported by <code>caps().sd_list</code>. <code>wada.audio</code> instead follows <code>caps().audio</code>: storage may be internal, but the device still needs a stream-capable speaker path.</p>
<p>Rate limits are part of the contract, not a rainy-day guard: <code>wada.fs</code> writes are about one per second with a 32&nbsp;KB file cap, and <code>wada.mesh.send</code> has a 5&nbsp;second floor and a 180-character limit. They return <code>false, "too fast"</code> rather than throwing, and hitting them is expected &mdash; handle it.</p>
<p>None of this makes a hostile app safe, which is why the catalog is curated. It makes an <i>honest</i> app that has a bug survivable: it gets closed, and the device keeps carrying traffic.</p>
</div></div>
</section>
</main>
</div>
<script>
// Highlight the section currently in view, same behaviour as the docs page.
(function () {
var links = [].slice.call(document.querySelectorAll('#toc a'));
var secs = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
function sync() {
var y = window.scrollY + 120, cur = 0;
for (var i = 0; i < secs.length; i++) if (secs[i] && secs[i].offsetTop <= y) cur = i;
links.forEach(function (a, i) { a.classList.toggle('on', i === cur); });
}
window.addEventListener('scroll', sync, { passive: true });
sync();
})();
</script>
</body>
</html>