From b04ae358b2b00503693693067f6971fdd9aacba7 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Sat, 22 Aug 2026 15:17:54 -0400 Subject: [PATCH] feat(lua): add read-only SD directory listing (#309) --- deploy/apps/README.md | 7 +- deploy/apps/apps.json | 4 +- deploy/apps/sdktest/1.6/sdktest.json | 1 + deploy/apps/sdktest/1.6/sdktest.lua | 276 +++++++++++++++++++++++++++ deploy/site/sdk.html | 18 +- src/ui-touch/LuaAppHost.cpp | 93 +++++++++ src/ui-touch/UITask.cpp | 20 ++ src/ui-touch/device_caps.h | 11 ++ 8 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 deploy/apps/sdktest/1.6/sdktest.json create mode 100644 deploy/apps/sdktest/1.6/sdktest.lua diff --git a/deploy/apps/README.md b/deploy/apps/README.md index 0c4a111f..4e78fd78 100644 --- a/deploy/apps/README.md +++ b/deploy/apps/README.md @@ -15,9 +15,10 @@ so somebody has to read it first. One Lua file plus a small manifest. Apps talk to the firmware through the `wada.*` API — `wada.ui` (widgets, colours, a text prompt), `wada.sys`, `wada.store` (persistence), `wada.timer`, and on the larger boards `wada.fs`, `wada.net`, -`wada.crypto` and the writable half of `wada.mesh`. There is no arbitrary -filesystem or network access; the API is the whole surface, which is what makes -reviewing tractable. It is documented at +`wada.crypto` and the writable half of `wada.mesh`. Supported boards also expose +read-only SD directory metadata through `wada.sd`; file contents and writes stay +inaccessible. There is no general-purpose filesystem or network access; the API +is the whole surface, which is what makes reviewing tractable. It is documented at [wadamesh.com/sdk.html](https://wadamesh.com/sdk.html). Two numbers worth knowing before you start: every callback runs under a diff --git a/deploy/apps/apps.json b/deploy/apps/apps.json index aa63dc63..5453dbfd 100644 --- a/deploy/apps/apps.json +++ b/deploy/apps/apps.json @@ -21,8 +21,8 @@ { "id": "sdktest", "name": "SDK Test", - "ver": "1.5", - "desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, file access, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT a test message to Public and a DM to your first contact (each asks permission first)." + "ver": "1.6", + "desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private file access, read-only SD listing, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT test messages." }, { "id": "2048", diff --git a/deploy/apps/sdktest/1.6/sdktest.json b/deploy/apps/sdktest/1.6/sdktest.json new file mode 100644 index 00000000..0c90327d --- /dev/null +++ b/deploy/apps/sdktest/1.6/sdktest.json @@ -0,0 +1 @@ +{"id":"sdktest","name":"SDK Test","ver":"1.6","desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private files, read-only SD listing, crypto vectors, packet identity, channels, and opt-in transmit calls."} diff --git a/deploy/apps/sdktest/1.6/sdktest.lua b/deploy/apps/sdktest/1.6/sdktest.lua new file mode 100644 index 00000000..d18735e3 --- /dev/null +++ b/deploy/apps/sdktest/1.6/sdktest.lua @@ -0,0 +1,276 @@ +-- 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.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("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 diff --git a/deploy/site/sdk.html b/deploy/site/sdk.html index 1befff94..aa95fa8f 100644 --- a/deploy/site/sdk.html +++ b/deploy/site/sdk.html @@ -141,6 +141,7 @@ wada.net wada.store wada.fs + wada.sd wada.sys wada.timer Discovery @@ -436,6 +437,19 @@ end +
+

wada.sd sd_list

+
+

Read-only directory access to the physical SD card. This is separate from wada.fs: it can inspect the card's directory tree, but cannot read file contents, write, rename or delete anything. Check wada.sys.caps().sd_list before using it.

+ + + +
CallReturns / does
wada.sd.list([path])Returns an array of {name, type, size, mtime}, or nil, error. path defaults to /. type is "file" or "dir"; directories report size 0, and mtime is 0 when the filesystem has no timestamp.
+

Paths are absolute from the card root and at most 191 bytes. Empty segments, trailing slashes, backslashes, . and .. are rejected with nil, "bad path". A missing or unreadable card returns nil, "no sd"; "busy" means another storage operation is changing the card lifecycle, so retry later. Missing paths and files used as directories return "not found" and "not a directory".

+

One call returns at most 192 entries. When more exist, the returned array also has entries.truncated == true. Listing may mount an inserted card through the firmware's existing SD lifecycle, but never formats or modifies it.

+
+
+

wada.sys

@@ -447,7 +461,7 @@ end 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, discover, input, rx_identity, list, packets, sensors, map, measure}. Check sdk_ext before using anything marked ext below. The last three are feature flags for calls added after the extended SDK first shipped, so an app can degrade on older firmware instead of erroring. + wada.sys.caps(){sdk_ext, keyboard, touch, sd, sd_list, discover, input, rx_identity, list, packets, sensors, map, measure}. Check sdk_ext before using anything marked ext, and sd_list before using wada.sd. Feature flags let an app degrade on older firmware instead of erroring. wada.sys.battery() ext{mv, pct, charging}. wada.sys.env() sensors{temp_c, humidity, pressure_hpa, alt_m}, or nil. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on caps().sensors — does this board have the sensor rail — and not on sdk_ext, 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. wada.sys.gps() ext{lat, lon, lat_e6, lon_e6, alt_m, sats, time}, or nil with no fix — which is the normal indoor case, so handle it. lat_e6/lon_e6 are exact micro-degrees; lat/lon are single-precision floats, so log the integers. time is satellite time, absent until the receiver has decoded the date. @@ -570,7 +584,7 @@ end

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, wada.mesh.send_dm, wada.mesh.discover, 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.

+

The extended calls are not on every board. Anything marked ext above — wada.fs, wada.mesh.send, wada.mesh.send_dm, wada.mesh.discover, 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. wada.sd additionally needs a physical card interface, reported by caps().sd_list.

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.

diff --git a/src/ui-touch/LuaAppHost.cpp b/src/ui-touch/LuaAppHost.cpp index f1250731..566abcc9 100644 --- a/src/ui-touch/LuaAppHost.cpp +++ b/src/ui-touch/LuaAppHost.cpp @@ -32,6 +32,10 @@ extern void luaHostToast(const char* msg, int ms); // showAlert p extern bool luaHostBeep(); // notification chime; false = no sounder / muted extern fs::FS* luaHostAppFs(); // /apps storage root FS (may be null) extern void luaHostAppPath(char* out, size_t cap, const char* rel); // prefixes the store root +#if CAP_LUA_SD_LIST +extern fs::FS* luaHostSdFs(bool* busy); // mounted physical SD, or null +extern bool luaHostSdReadFailed(); // failed open was a dead card +#endif extern int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago, double* lat, double* lon, char* pk_hex, size_t pk_cap, int32_t* lat_e6, int32_t* lon_e6); @@ -729,6 +733,7 @@ int sysCaps(lua_State* L) { lua_pushboolean(L, CAP_KEYBOARD); lua_setfield(L, -2, "keyboard"); lua_pushboolean(L, CAP_TOUCH); lua_setfield(L, -2, "touch"); lua_pushboolean(L, CAP_SD); lua_setfield(L, -2, "sd"); + lua_pushboolean(L, CAP_LUA_SD_LIST); lua_setfield(L, -2, "sd_list"); // Feature flags for the calls added after the first extended SDK shipped, so // an app can degrade instead of erroring on firmware that predates them. lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "discover"); // wada.mesh.discover @@ -1349,6 +1354,88 @@ int fsList(lua_State* L) { dir.close(); return 1; } + +#if CAP_LUA_SD_LIST +// ---- wada.sd: read-only physical SD directory listing ---------------------- +// Unlike wada.fs, this intentionally accepts paths, but only absolute paths +// rooted on the card. Reject traversal rather than normalising it into a +// different valid path. Directory work is bounded so one huge card folder +// cannot consume an app's entire Lua heap. +static const size_t kSdMaxPath = 192; +static const int kSdMaxEntries = 192; + +static bool sdSafePath(const char* in, size_t len, char* out, size_t cap) { + if (!in || len == 0 || len >= cap || in[0] != '/') return false; + if (len > 1 && in[len - 1] == '/') return false; + if (len == 1) { out[0] = '/'; out[1] = '\0'; return true; } + + size_t segment = 1; + for (size_t i = 1; i <= len; ++i) { + if (i < len) { + const unsigned char c = (unsigned char)in[i]; + if (c == 0 || c < 0x20 || c == 0x7F || c == '\\') return false; + if (c != '/') continue; + } + const size_t n = i - segment; + if (n == 0 || (n == 1 && in[segment] == '.') || + (n == 2 && in[segment] == '.' && in[segment + 1] == '.')) return false; + segment = i + 1; + } + memcpy(out, in, len); + out[len] = '\0'; + return true; +} + +static int sdListError(lua_State* L, const char* error) { + lua_pushnil(L); + lua_pushstring(L, error); + return 2; +} + +// wada.sd.list(path) -> entries | nil,error +// entries is an array of { name, type = "file"|"dir", size, mtime } and gains +// entries.truncated = true only when the directory exceeds kSdMaxEntries. +int sdList(lua_State* L) { + const char* requested = "/"; + size_t requested_len = 1; + if (!lua_isnoneornil(L, 1)) requested = luaL_checklstring(L, 1, &requested_len); + char path[kSdMaxPath]; + if (!sdSafePath(requested, requested_len, path, sizeof path)) + return sdListError(L, "bad path"); + + bool busy = false; + fs::FS* fs = luaHostSdFs(&busy); + if (!fs) return sdListError(L, busy ? "busy" : "no sd"); + File dir = fs->open(path, "r"); + if (!dir) return sdListError(L, luaHostSdReadFailed() ? "no sd" : "not found"); + if (!dir.isDirectory()) { dir.close(); return sdListError(L, "not a directory"); } + + lua_newtable(L); + int count = 0; + bool truncated = false; + for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { + const char* full = entry.name(); + const char* base = full ? strrchr(full, '/') : nullptr; + base = base ? base + 1 : full; + if (!base || !base[0]) { entry.close(); continue; } + if (count >= kSdMaxEntries) { truncated = true; entry.close(); break; } + + const bool is_dir = entry.isDirectory(); + lua_createtable(L, 0, 4); + lua_pushstring(L, base); lua_setfield(L, -2, "name"); + lua_pushstring(L, is_dir ? "dir" : "file"); lua_setfield(L, -2, "type"); + lua_pushinteger(L, is_dir ? 0 : (lua_Integer)entry.size()); + lua_setfield(L, -2, "size"); + lua_pushinteger(L, (lua_Integer)entry.getLastWrite()); + lua_setfield(L, -2, "mtime"); + lua_rawseti(L, -2, ++count); + entry.close(); + } + dir.close(); + if (truncated) { lua_pushboolean(L, 1); lua_setfield(L, -2, "truncated"); } + return 1; +} +#endif #endif // CAP_LUA_SDK_EXT void storePath(char* out, size_t cap) { @@ -1941,6 +2028,12 @@ void openWada(lua_State* L) { lua_setfield(L, -2, "fs"); #endif +#if CAP_LUA_SD_LIST + lua_newtable(L); // wada.sd (read-only physical card) + lua_pushcfunction(L, sdList); lua_setfield(L, -2, "list"); + lua_setfield(L, -2, "sd"); +#endif + lua_newtable(L); // wada.mesh (read-only) lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts"); lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log"); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index e9673a4f..68cc2267 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -46665,6 +46665,26 @@ fs::FS* luaHostAppFs() { return uiDataFsReady() ? s_ui_data_fs : nullptr; } void luaHostAppPath(char* out, size_t cap, const char* rel) { snprintf(out, cap, "%s%s", s_ui_data_root, rel); // SD-rooted stores prefix /meshcomod } +#if CAP_LUA_SD_LIST +// Return the physical removable card, never the app-data filesystem. Mounting +// stays in UITask so Lua cannot bypass shared-bus coordination or create a +// second SD lifecycle. The existing backoff keeps repeated no-card calls cheap. +fs::FS* luaHostSdFs(bool* busy) { + if (busy) *busy = false; + if (s_sd_fail_note_ms) return nullptr; + if (!s_sd_mounted && !sdAdoptLiveMount()) { + if (sdRuntimeLifecycleBusy()) { + if (busy) *busy = true; + return nullptr; + } + if (!fmSdTryMount()) return nullptr; + } + if (!s_sd_mounted) return nullptr; + markSdIo(); + return &SD; +} +bool luaHostSdReadFailed() { return sdReadFailedCardDead(); } +#endif // ---- wada.mesh read-only bridges (no mesh types cross into the host TU) ---- // Hex-encode the first n bytes of a public key. 4 bytes (8 hex chars) is what the // rest of the UI uses to name a node, and it is what an app needs to line up a diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index 39b92736..5a6db8ec 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -304,6 +304,17 @@ #endif #endif +// Read-only access to a physical SD card's directory tree from Lua. This first +// pass follows the existing shared-SPI Arduino SD lifecycle; P4 SD_MMC needs its +// own removal/recovery contract before it can safely expose the same API. +#ifndef CAP_LUA_SD_LIST + #if CAP_LUA_SDK_EXT && CAP_SD + #define CAP_LUA_SD_LIST 1 + #else + #define CAP_LUA_SD_LIST 0 + #endif +#endif + // Compile the translations into the image (i18n_builtin.h, generated from // deploy/apps/lang/*.lang) instead of relying on downloading a .lang file. // ON for boards where the Lua Store is not dependable — the V4 runs at ~95%