Files
wadamesh/deploy/apps/sdktest/1.7/sdktest.lua
T
2026-08-23 20:32:26 -04:00

294 lines
13 KiB
Lua

-- SDK self-test. Exercises the extended SDK so the results can be read off the
-- screen instead of inferred from a build log. Published to the store as a
-- developer/bench tool.
--
-- 1.2 adds wada.crypto (checked against published RFC vectors, so a PASS here is
-- real evidence and not just "it returned something"), wada.mesh.channels, and
-- wada.mesh.send_dm.
-- 1.3 adds the discovery surface (wada.mesh.discover / discovered), packet
-- identity in rx_log, exact micro-degree coordinates, wada.ui.input and the
-- windowed wada.fs.read.
-- 1.6 adds wada.sd.list: capability reporting, traversal rejection, root
-- metadata, and a nested directory listing when the card contains one.
-- 1.7 adds storage-neutral wada.audio capability and API-shape checks. Playback
-- stays manual so opening the bench test never emits sound unexpectedly.
-- 1.5 uses wada.ui.text_lines() to measure instead of estimating, where the
-- firmware has it. That call was added because of the 1.3 bug below: an app
-- could ask how tall a line is but not how wide, so laying out rows meant
-- guessing whether text would wrap. Falls back to the 1.4 estimate on older
-- firmware, so it still lays out correctly there.
-- 1.4 fixes rows drawing on top of one another. label:width() turns wrapping
-- on, so any line wider than the screen became two lines while the caller still
-- advanced by one, and the next row landed on top. Reported with a photo of the
-- channels line sitting across the contacts line. row() now owns the cursor and
-- advances by the height the text actually needs.
local ui, sys, store, timer = wada.ui, wada.sys, wada.store, wada.timer
local C = ui.colors
local app = {}
local rows, keyline, sendline, dmline, msgline = {}, nil, nil, nil, nil
local discline, inputline = nil, nil
local W = 300
-- row() owns the vertical cursor. Every caller used to pass a y and then add a
-- hand-picked constant, which is only correct while the text fits on one line.
-- An app cannot measure rendered text, so the wrapped line count is estimated
-- from a deliberately pessimistic character width: over-estimating costs a
-- little whitespace, under-estimating overlaps the next row.
local LH, GAP = 14, 3
local cy = 4
local function row(text, color, extra_gap)
local l = ui.label(text, 6, cy, 12, color or C.text)
l:width(W - 12)
rows[#rows + 1] = l
local n
if ui.text_lines then
n = ui.text_lines(tostring(text), W - 12, 12) -- measured: exact
else
local cpl = math.max(16, (W - 12) // 7) -- older firmware: estimate
n = 0
for seg in (tostring(text) .. "\n"):gmatch("(.-)\n") do
n = n + math.max(1, math.ceil(#seg / cpl))
end
end
cy = cy + math.max(1, n or 1) * LH + GAP + (extra_gap or 0)
return l
end
local function yn(v) return v and "yes" or "NO" end
function app.on_open(w, h)
W = w or 300
ui.scroll(true)
cy = 4
local y
local c = sys.caps()
row("caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) ..
" touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent)
row("caps: sd_list=" .. yn(c.sd_list) .. " discover=" .. yn(c.discover) ..
" input=" .. yn(c.input) ..
" rx_identity=" .. yn(c.rx_identity), C.accent)
row("caps: audio=" .. yn(c.audio) .. " wav=" .. yn(c.audio_wav) ..
" mp3=" .. yn(c.audio_mp3) .. " audio_sd=" .. yn(c.audio_sd), C.accent)
if c.audio then
local api_ok = wada.audio and type(wada.audio.play) == "function" and
type(wada.audio.pause) == "function" and
type(wada.audio.resume) == "function" and
type(wada.audio.stop) == "function" and
type(wada.audio.status) == "function"
local status = api_ok and wada.audio.status() or nil
api_ok = api_ok and type(status) == "table" and type(status.state) == "string"
row("wada.audio API: " .. (api_ok and ("PASS (" .. status.state .. ")") or "FAIL"),
api_ok and C.good or C.bad)
else
row("wada.audio: unavailable on this board", C.sub)
end
row("layout: " .. (ui.text_lines and "measured (ui.text_lines)" or "estimated (older firmware)"),
ui.text_lines and C.good or C.sub)
-- crypto: published test vectors, so this is checkable rather than merely alive
if wada.crypto then
local hex = wada.crypto.hex
local k20 = string.rep(string.char(0x0b), 20)
local checks = {
{ "sha256('abc')", hex(wada.crypto.sha256("abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" },
{ "sha1('abc')", hex(wada.crypto.sha1("abc")),
"a9993e364706816aba3e25717850c26c9cd0d89d" },
{ "hmac_sha1 RFC2202#1", hex(wada.crypto.hmac_sha1(k20, "Hi There")),
"b617318655057264e28bc0b6fb378c8ef146be00" },
{ "hmac_sha256 RFC4231#1", hex(wada.crypto.hmac_sha256(k20, "Hi There")),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" },
}
local allok = true
for _, t in ipairs(checks) do
local ok = (t[2] == t[3])
if not ok then allok = false end
row("crypto " .. t[1] .. ": " .. (ok and "PASS" or ("FAIL got " .. tostring(t[2]):sub(1, 16))),
ok and C.good or C.bad)
end
-- The point of having it in C: this loop would blow the instruction budget in Lua.
local t0 = sys.millis()
for _ = 1, 200 do wada.crypto.hmac_sha1(k20, "Hi There") end
row(string.format("crypto: 200 x hmac_sha1 in %d ms%s", sys.millis() - t0,
allok and "" or " (VECTORS FAILED)"), allok and C.good or C.bad)
else
row("wada.crypto: MISSING", C.bad)
end
-- channel discovery
local chans = wada.mesh.channels and wada.mesh.channels() or nil
if chans then
local names = table.concat(chans, ", ")
row("channels(): " .. #chans .. " " .. names:sub(1, 60), C.text)
else
row("wada.mesh.channels: MISSING", C.bad)
end
if not c.sdk_ext then
row("extended SDK is OFF on this board - stopping here.", C.sub)
return
end
-- contacts, so send_dm has a target to name. 1.3 also checks the pubkey field.
local cts = wada.mesh.contacts()
local first = cts[1] and cts[1].name or nil
row("contacts: " .. #cts .. (first and (" first=" .. first) or ""), C.text)
if cts[1] then
local pk = cts[1].pubkey
row("contacts[1].pubkey: " .. tostring(pk) ..
(type(pk) == "string" and #pk == 8 and " PASS" or " FAIL"),
(type(pk) == "string" and #pk == 8) and C.good or C.bad)
end
local me = wada.mesh.self()
row("self: " .. tostring(me.name) .. " pubkey=" .. tostring(me.pubkey), C.text)
-- Exact coordinates. Lua's floats here are 32-bit, so an app that logs a track
-- must use the _e6 integers; this proves they are present and consistent.
local fix = sys.gps()
if fix then
local drift = math.abs(fix.lat_e6 / 1e6 - fix.lat)
row(string.format("gps: %d,%d e6 alt %dm %d sats drift %.6f %s",
fix.lat_e6, fix.lon_e6, fix.alt_m or 0, fix.sats, drift,
drift < 0.001 and "PASS" or "FAIL"), drift < 0.001 and C.good or C.bad)
else
row("gps: no fix (normal indoors)", C.sub)
end
-- rx_log identity: adverts carry a real public key, addressed frames carry
-- one-byte hashes, everything else carries nothing. All three are correct.
local log = wada.mesh.rx_log()
local withpk, withsrc = 0, 0
for _, r in ipairs(log) do
if r.pubkey then withpk = withpk + 1 end
if r.src then withsrc = withsrc + 1 end
end
row(string.format("rx_log: %d frames, %d with a pubkey (adverts), %d with src/dst",
#log, withpk, withsrc), #log > 0 and C.text or C.sub)
-- fs: windowed read. Writes once (the 1/sec limit means one write per open).
if wada.fs then
local probe = "0123456789abcdef"
wada.fs.write("sdktest.bin", probe)
local part, total = wada.fs.read("sdktest.bin", 4, 4)
local ok = (part == "4567" and total == #probe)
row("fs.read(name,4,4): " .. tostring(part) .. " total=" .. tostring(total) ..
(ok and " PASS" or " FAIL"), ok and C.good or C.bad)
end
-- Physical SD listing is read-only and independently feature-detected. A
-- supported board with no inserted card is a normal bench state, not a test
-- failure; API shape and path rejection can still be checked separately.
if c.sd_list then
if not wada.sd or not wada.sd.list then
row("wada.sd.list: MISSING despite caps", C.bad)
else
local guard_ok, guard_failure = true, nil
local bad_paths = { "/..", "/.", "/a/../b", "/a//b", "/a/", "relative", "/a\\b" }
for _, path in ipairs(bad_paths) do
local rejected, baderr = wada.sd.list(path)
if rejected ~= nil or baderr ~= "bad path" then
guard_ok = false
guard_failure = path .. " -> " .. tostring(baderr)
break
end
end
row("sd traversal guard: " .. (guard_ok and "PASS" or ("FAIL " .. guard_failure)),
guard_ok and C.good or C.bad)
local entries, err = wada.sd.list("/")
if not entries then
local note = err == "busy" and " (retry later)" or " (no readable card)"
row("sd.list('/'): " .. tostring(err) .. note, C.sub)
else
local metadata_ok, first_dir = true, nil
for _, entry in ipairs(entries) do
if type(entry.name) ~= "string" or
(entry.type ~= "file" and entry.type ~= "dir") or
type(entry.size) ~= "number" or type(entry.mtime) ~= "number" then
metadata_ok = false
end
if not first_dir and entry.type == "dir" then first_dir = entry.name end
end
row("sd.list('/'): " .. #entries .. " entries, metadata " ..
(metadata_ok and "PASS" or "FAIL") ..
(entries.truncated and " (truncated)" or ""),
metadata_ok and C.good or C.bad)
if first_dir then
local nested, nested_err = wada.sd.list("/" .. first_dir)
row("sd.list('/" .. first_dir .. "'): " ..
(nested and (#nested .. " entries PASS") or ("FAIL " .. tostring(nested_err))),
nested and C.good or C.bad)
else
row("sd nested listing: no directory on card to test", C.sub)
end
end
end
else
row("wada.sd.list: unavailable on this board", C.sub)
end
sendline = row("mesh.send: not tried yet", C.sub)
dmline = row("mesh.send_dm: not tried yet", C.sub)
msgline = row("on_message: waiting (needs the read permissions)", C.sub)
y = cy + 4
ui.button("Send to Public", 6, y, 120, 30, function()
local ok, err = wada.mesh.send("Public", "wadamesh SDK self-test")
sendline:set("mesh.send: " .. tostring(ok) .. " " .. tostring(err))
sendline:color(ok and C.good or C.bad)
end)
ui.button("DM first contact", 132, y, 130, 30, function()
if not first then dmline:set("mesh.send_dm: no contacts to target"); dmline:color(C.bad); return end
local ok, err = wada.mesh.send_dm(first, "wadamesh SDK self-test (DM)")
dmline:set("mesh.send_dm -> " .. first .. ": " .. tostring(ok) .. " " .. tostring(err))
dmline:color(ok and C.good or C.bad)
end)
cy = y + 38
discline = row("mesh.discover: not tried yet", C.sub)
inputline = row("ui.input: not tried yet", C.sub)
-- Probing TRANSMITS and makes every neighbour reply, so it is a button, not
-- something this app does on open.
y = cy + 4
ui.button("Probe", 6, y, 90, 30, function()
local tag, err = wada.mesh.discover()
if not tag then
discline:set("mesh.discover: " .. tostring(err)); discline:color(C.bad); return
end
discline:set("mesh.discover: sent, waiting for replies..."); discline:color(C.text)
end)
ui.button("Results", 102, y, 90, 30, function()
local hits = wada.mesh.discovered()
if #hits == 0 then
discline:set("discovered(): nothing yet - probe, then wait a few seconds")
discline:color(C.sub); return
end
local h = hits[1]
discline:set(string.format("discovered(): %d first %s snr %.1f/%.1f %s",
#hits, h.name or h.pubkey, h.snr, h.their_snr, h.direct and "direct" or (h.hops .. "h")))
discline:color(C.good)
end)
y = y + 34
ui.button("Beep", 6, y, 70, 30, function() sys.beep() end)
ui.button("Input", 82, y, 90, 30, function()
ui.input("Type anything", "hello", function(text)
inputline:set("ui.input -> " .. (text and ("'" .. text .. "'") or "cancelled"))
inputline:color(text and C.good or C.sub)
end)
end)
end
function app.on_input(ev)
if ev.type == "key" and keyline then
keyline:set("keys: got '" .. tostring(ev.key) .. "'")
keyline:color(C.good)
end
end
-- Proves the kind field and that DMs/rooms reach an app at all, not just channels.
function app.on_message(m)
if not msgline then return end
msgline:set("on_message: kind=" .. tostring(m.kind) .. " from=" .. tostring(m.sender) ..
" ch=" .. tostring(m.channel) .. " text=" .. tostring(m.text):sub(1, 20))
msgline:color(C.good)
end
return app