@@ -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 08e089f6..90e1436f 100644
--- a/src/ui-touch/UITask.cpp
+++ b/src/ui-touch/UITask.cpp
@@ -46763,6 +46763,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%