// SPDX-License-Identifier: GPL-3.0-or-later // Lua app host — see LuaAppHost.h + LUA_APPS.md. Structure: // 1. PSRAM-capped allocator + instruction-budget hook (containment) // 2. the wada.* bindings (ui/input/timer/sys/store) — API v1 // 3. lifecycle: launch -> run chunk -> callbacks via guarded pcall -> dismiss #include "LuaAppHost.h" #if defined(ESP32) #include "mbedtls/md.h" // wada.crypto: HMAC/SHA in C, not interpreted Lua #endif #if CAP_LUA_APPS #include #include #include #include #include #include // isnan for optional sensor fields // wada.sys.epoch/datetime (#245) #include "AppPage.h" #include "device_caps.h" #include "i18n.h" // wada.sys.tr: apps can use the same translation table the UI does #include "Utf8Text.h" #include "helpers/esp32/WdtHeavyGuard.h" // wada.fs writes can trigger SPIFFS GC extern "C" { #include "lua.h" #include "lualib.h" #include "lauxlib.h" } // UITask-owned services the host borrows (all pre-existing, fwd-declared here // to keep this TU decoupled from the 47k-line UITask.cpp). extern lv_coord_t luaHostAppBarH(); // real AppPage bar height extern const lv_font_t* luaHostFontForSize(int size_class); // 12/14/16 -> g_font_* extern const lv_font_t* luaHostBoldFontForSize(int size_class); // the SemiBold face of a size class extern void luaHostToast(const char* msg, int ms); // showAlert passthrough extern bool luaHostBeep(); extern void luaHostSetSelectionGlow(lv_obj_t* obj, bool selected); extern uint32_t luaHostPanelColor(); // wada.ui.panel defaults (live theme) extern uint32_t luaHostBorderColor(); extern void luaHostMarkInert(lv_obj_t* obj); // never a keyboard focus stop extern bool luaHostScreenOn(); // false = display asleep; app ticks pause extern void luaHostKeepAwake(bool on); // hold the screen + ticks for a measuring app // 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_AUDIO extern bool luaHostAudioPlay(fs::FS* fs, const char* path, const char* shown, const char* source, uint32_t owner, char* error, size_t error_cap); extern bool luaHostAudioPause(uint32_t owner, bool pause); extern bool luaHostAudioStop(uint32_t owner, bool release); extern void luaHostAudioStatus(uint32_t owner, char* state, size_t state_cap, char* path, size_t path_cap, char* source, size_t source_cap, char* format, size_t format_cap, char* error, size_t error_cap); #endif #if CAP_LUA_SD_READ extern fs::FS* luaHostSdFs(bool* busy); // mounted physical SD, or null extern bool luaHostSdReadFailed(); // failed open was a dead card #endif #if CAP_LUA_SD_LIST extern bool luaHostSdClearAttributes(const char* path); // drop read-only/hidden/system #include "SdThreat.h" // the SD malware policy wada.sd.check/remove enforce #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); extern int luaHostRxLogAt(int idx, uint32_t* ms_ago, int* ptype, int* rssi, float* snr, int* hops, int* route, int* len, int* org_kind, char* org_hex, size_t org_cap, uint32_t* at_ms = nullptr); extern int luaHostDiscoverCount(); extern void luaHostDiscoverClear(); extern int luaHostDiscoverAt(int idx, char* pk_hex, size_t pk_cap, char* name, size_t name_cap, int* type, int* rssi, float* snr, float* their_snr, int* hops, uint32_t* first_ms_ago, uint32_t* last_ms_ago, int* heard); extern void luaHostTextPrompt(const char* title, const char* initial, void (*cb)(const char*)); extern void luaHostEmojiPick(void (*cb)(const char*)); extern void luaHostTextPromptDismiss(); extern void luaHostRadioStats(float* rssi, float* noise, uint32_t* rx_air_s, uint32_t* tx_air_s, uint32_t* rx_pkts, uint32_t* rx_err, int* budget_ms); extern void luaHostRadioStats2(uint32_t* rx_evt, uint32_t* rx_drop, uint32_t* tx_pkts, float* freq, float* bw, int* sf, int* duty_pct); extern void luaHostSelfInfo(char* name, size_t name_cap, double* lat, double* lon, char* pk_hex, size_t pk_cap, int32_t* lat_e6, int32_t* lon_e6); #if CAP_LUA_SDK_EXT extern void luaHostBattery(uint16_t* mv, int* pct, bool* charging); // No HDOP: MeshCore's LocationProvider interface does not expose one, and a // fabricated accuracy figure is worse than none for anything that would use it. // speed_kmh / course_deg come back NAN where the board's provider does not // report them (only boards built on WadaNmeaLocationProvider do). They are // appended AFTER the fix_time / micro-degree outputs so ONE signature serves // both callers -- the merge of two independent widenings of this bridge. extern bool luaHostGps(double* lat, double* lon, int* sats, int* alt_m, uint32_t* fix_time, int32_t* lat_e6, int32_t* lon_e6, float* speed_kmh, float* course_deg); #endif #if CAP_SENSORS extern void luaHostEnv(bool* ok, bool* have_t, float* temp_c, bool* have_h, float* hum_pct, bool* have_p, float* press_hpa, bool* have_alt, int* alt_m); #endif #if CAP_LUA_SDK_EXT extern int luaHostSendPerm(const char* app_id); // 1 grant, -1 refused, 0 unasked extern bool luaHostReadPerm(const char* app_id); // may be shown incoming messages extern void luaHostRequestSendPerm(const char* app_id, const char* app_name); // raises the consent prompt extern bool luaHostMeshSendChannel(const char* chan_name, const char* text); extern uint32_t luaHostLastSentFp(); extern uint8_t luaHostRepeatsForFp(uint32_t fp); extern bool luaHostMeshSendDM(const char* to_name, const char* text, bool* was_room); extern int luaHostMeshChannelNames(char out[][32], int max_n); extern int luaHostDmSendPerm(const char* app_id); extern bool luaHostDmReadPerm(const char* app_id); extern void luaHostRequestDmSendPerm(const char* app_id, const char* app_name); extern void* luaHostMapCreate(int x, int y, int w, int h); extern void luaHostMapDestroy(void* v); extern void luaHostMapSet(void* v, double lat, double lon, int zoom); extern void luaHostMapSetZoom(void* v, int zoom); extern void luaHostMapRender(void* v); extern int luaHostMapZoom(void* v); extern int luaHostMapPlaced(void* v); extern void luaHostMapToScreen(void* v, double lat, double lon, int* px, int* py); extern void luaHostMapToLatLon(void* v, int px, int py, double* lat, double* lon); extern void luaHostMapClearOverlay(void* v); extern int luaHostMapMarker(void* v, double lat, double lon, uint32_t color, int size); extern int luaHostMapLine(void* v, double la1, double lo1, double la2, double lo2, uint32_t color, int width); extern int luaHostProbePerm(const char* app_id); extern void luaHostRequestProbePerm(const char* app_id, const char* app_name); extern uint32_t luaHostMeshDiscover(int type_filter); #endif #if CAP_COMPASS extern bool luaHostCompass(float* x_gauss, float* y_gauss, float* z_gauss, bool* overflow); // sensor frame, uncalibrated #endif #if CAP_IMU extern bool luaHostAccel(float* x_g, float* y_g, float* z_g); // sensor frame, g #endif // DO NOT name the network client type here. What "WiFiClient" means depends on the // board: a real class on the S3 envs, a `using WiFiClient = NetworkClient` alias // under arduino-esp32 3.x (Tanmatsu), and a #define to C6Client on the T-Display P4 // (its C6 runs ESP-AT, so sockets go over AT-over-SDIO). Writing `class WiFiClient;` // declared a BRAND NEW incomplete class on the two boards where the name is an // alias, so this file emitted luaNetWorkerService under a signature the caller in // UITask.cpp never referenced — both P4 targets failed to LINK while all 8 S3 envs // built clean, which is why it stayed hidden. // // UITask owns the networking, so it passes the objects down as opaque handles and // casts them back on its side. This TU never dereferences them. extern int luaStoreHttpGetOpaque(void* client, void* http, const char* url, char* buf, size_t cap); extern int luaStoreHttpPostOpaque(void* client, void* http, const char* url, char* buf, size_t cap, const void* body, size_t body_len, const char* ctype); // --------------------------------------------------------------------------- // 1) allocator + budget // --------------------------------------------------------------------------- namespace { struct LuaHeap { size_t used = 0, peak = 0, cap = 0; }; void* psAllocCb(void* ud, void* ptr, size_t osize, size_t nsize) { LuaHeap* h = (LuaHeap*)ud; if (!ptr) osize = 0; if (nsize == 0) { if (ptr) { h->used -= osize; heap_caps_free(ptr); } return nullptr; } if (h->used - osize + nsize > h->cap) return nullptr; // hard per-app cap -> Lua OOM error void* np = heap_caps_realloc(ptr, nsize, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!np) return nullptr; h->used += nsize - osize; if (h->used > h->peak) h->peak = h->used; return np; } // 100k VM instructions per callback (~<2 ms of straight-line Lua on the S3 — // the Phase 0 spike measured containment of a hostile loop in 30 ms at this // cadence with negligible overhead on real code). constexpr int kInstrBudget = 100000; void budgetHook(lua_State* L, lua_Debug*) { luaL_error(L, "instruction budget exceeded (app tick too long)"); } // --------------------------------------------------------------------------- // host state // --------------------------------------------------------------------------- constexpr size_t kHeapCap = 256 * 1024; // per-app PSRAM cap // App source size limit. Was 64 KB, which a real app can reach: reported from a // 2000-contact device whose author hit it writing an ordinary Lua program, not // anything pathological. The source lives in PSRAM only until luaL_loadbuffer // has compiled it, so this is a transient allocation rather than a per-app cost, // and the compiled chunk still has to fit the 256 KB per-app heap below. constexpr size_t kMaxSrc = 192 * 1024; // app source size limit constexpr size_t kStoreMax = 2048; // per-app persisted KV budget (bytes, serialized) constexpr int kMinTickMs = 33; // fastest on_tick cadence (~30 fps) constexpr uint32_t kAppTitleMs = 3000; // identify the app briefly, then reclaim its top row // Bumped by wada.ui.clear(). Every widget handle records the generation it was // created in; a handle from an older one has already been destroyed, so the // accessors below null its pointer and the `if (u->obj)` guard that every // method already has does the rest. // // Without this, clear() would be a use-after-free generator: WidgetUd holds a // raw lv_obj_t*, so a Lua variable still referring to a label from the previous // screen would sail past the null check straight into freed memory. static uint32_t s_ui_gen = 1; #if CAP_LUA_SDK_EXT static void* s_map_view = nullptr; static void destroyLiveMapView() { void* view = s_map_view; s_map_view = nullptr; if (view) luaHostMapDestroy(view); } #endif struct Host { lua_State* L = nullptr; LuaHeap heap; lv_obj_t* root = nullptr; // full-screen overlay on lv_layer_top lv_obj_t* body = nullptr; // app content area below the slim Back bar lv_timer_t* timer = nullptr; lv_timer_t* title_timer = nullptr; int body_w = 0, body_h = 0; // set at creation — lv_obj_get_width() reads 0 pre-layout uint32_t last_tick = 0; int ref_app = LUA_NOREF; // the table the chunk returned int ref_btncb = LUA_NOREF; // { [btn lightuserdata] = fn } button callbacks bool store_dirty = false; uint32_t fs_last_write_ms = 0; // wada.fs write rate limit (#222 lesson) uint32_t mesh_last_send_ms = 0; // wada.mesh.send airtime rate limit uint32_t mesh_last_probe_ms = 0; // wada.mesh.discover airtime rate limit (separate: a // probe also spends every neighbour's airtime) int prompt_cb = LUA_NOREF; // wada.ui.input callback in flight // Named timers (wada.timer.after / every-with-a-function). Separate from // `timer` above, which is the single on_tick heartbeat and stays as it was. struct AppTimer { lv_timer_t* t = nullptr; int cb = LUA_NOREF; bool once = false; }; static const int kMaxTimers = 8; AppTimer timers[kMaxTimers]; bool in_lua = false; // re-entrancy guard (dismiss from inside a callback) bool want_close = false; uint32_t generation = 0; // resource ownership across close/reopen char id[24] = ""; char title[32] = ""; }; Host* s_h = nullptr; uint32_t s_host_generation = 0; char s_bar_title[40]; // appPageBegin keeps the pointer — must outlive the page void titleTimerCb(lv_timer_t* timer) { if (!s_h || s_h->title_timer != timer) return; s_h->title_timer = nullptr; appPageCollapseTitle(&luaAppDismiss); } // --------------------------------------------------------------------------- // guarded callback invocation // --------------------------------------------------------------------------- int tracebackMsgh(lua_State* L) { const char* msg = lua_tostring(L, 1); luaL_traceback(L, L, msg ? msg : "(non-string error)", 1); return 1; } // Last-resort handler for an error that reached Lua with NO protected frame. // Lua's default panic function calls abort(), which resets the device: a T-Deck // crash dump from a Lua app decoded to luaG_callerror -> abort() straight through // here. The host's contract is that an app fault is a toast and a clean close, so // at minimum record what happened. Lua aborts anyway once this returns (that is // documented behaviour and there is no safe frame to unwind to), but the error // text now reaches the serial log and the diag ring instead of being lost, which // is the difference between a reproducible report and a mystery reset. int luaPanicCb(lua_State* L) { const char* err = lua_tostring(L, -1); Serial.printf("[LUAAPP] PANIC (unprotected error): %s\n", err ? err : "?"); char msg[96]; snprintf(msg, sizeof msg, "Lua panic: %.70s", err ? err : "unknown"); luaHostToast(msg, 3000); if (s_h) s_h->want_close = true; return 0; } // Push app.; returns false if the app has no such callback. // // RAW on purpose. This runs BEFORE guardedCall arms its pcall, so anything it // executes is unprotected: lua_getfield honours __index, and an app table with a // metatable whose __index is not callable raises "attempt to call a ... value" // with no frame to catch it, which panics and resets the device. Callbacks are // plain fields on the table the chunk returned, so a raw lookup is also the // correct semantics -- no shipped app uses setmetatable. lua_checkstack cannot // raise (it returns false), unlike the pushes it protects. bool pushCallback(Host* h, const char* name) { // 8 slots: the callback, its argument table, and a key/value pair at a time // while the caller fills it. Those pushes happen before the pcall too, so the // room has to be reserved here where failure can be returned instead of raised. if (!lua_checkstack(h->L, 8)) return false; lua_rawgeti(h->L, LUA_REGISTRYINDEX, h->ref_app); if (!lua_istable(h->L, -1)) { lua_pop(h->L, 1); return false; } lua_pushstring(h->L, name); lua_rawget(h->L, -2); lua_remove(h->L, -2); if (!lua_isfunction(h->L, -1)) { lua_pop(h->L, 1); return false; } return true; } // Run the function at the top of the stack with nargs args under budget+pcall. // On error: toast + schedule close. Returns true on clean completion. bool guardedCall(Host* h, int nargs) { lua_State* L = h->L; int base = lua_gettop(L) - nargs; // function slot lua_pushcfunction(L, tracebackMsgh); lua_insert(L, base); lua_sethook(L, budgetHook, LUA_MASKCOUNT, kInstrBudget); // (re)arms the counter h->in_lua = true; int rc = lua_pcall(L, nargs, 0, base); h->in_lua = false; lua_sethook(L, nullptr, 0, 0); lua_remove(L, base); // the traceback handler if (rc != LUA_OK) { const char* err = lua_tostring(L, -1); Serial.printf("[LUAAPP] %s error: %s\n", h->id, err ? err : "?"); char msg[96]; snprintf(msg, sizeof msg, "App error: %.60s", err ? err : "unknown"); luaHostToast(msg, 2600); lua_pop(L, 1); h->want_close = true; // unwind AFTER we leave Lua return false; } return true; } void serviceDeferredClose() { if (s_h && s_h->want_close && !s_h->in_lua) luaAppDismiss(); } // --------------------------------------------------------------------------- // 2) wada.* bindings // --------------------------------------------------------------------------- uint32_t argColor(lua_State* L, int idx, uint32_t def = 0xFFFFFF) { return (uint32_t)luaL_optinteger(L, idx, (lua_Integer)def) & 0xFFFFFF; } // LVGL assumes structurally valid UTF-8 and can stop advancing when an app // hands it a partial codepoint. Keep one reusable repair buffer: valid strings // stay zero-copy, while malformed runs become one ASCII '?'. Every call below // is synchronous and LVGL copies label text before this buffer can be reused. const char* safeUiText(const char* text, size_t length, size_t* safe_length = nullptr, unsigned scratch_slot = 0) { static std::string scratch_slots[2]; std::string& scratch = scratch_slots[scratch_slot & 1U]; const char* safe = Utf8Text::sanitize(text, length, scratch); if (safe_length) *safe_length = safe == text ? length : scratch.size(); return safe; } // ---- canvas userdata ---- struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; uint32_t gen; }; CanvasUd* checkCanvas(lua_State* L) { return (CanvasUd*)luaL_checkudata(L, 1, "wada.canvas"); } int cvFill(lua_State* L) { CanvasUd* c = checkCanvas(L); if (c->obj) lv_canvas_fill_bg(c->obj, lv_color_hex(argColor(L, 2, 0x000000)), LV_OPA_COVER); return 0; } int cvRect(lua_State* L) { CanvasUd* c = checkCanvas(L); if (!c->obj) return 0; lv_draw_rect_dsc_t d; lv_draw_rect_dsc_init(&d); uint32_t col = argColor(L, 6); bool filled = lua_isnoneornil(L, 7) ? true : lua_toboolean(L, 7); d.bg_color = lv_color_hex(col); d.bg_opa = filled ? LV_OPA_COVER : LV_OPA_TRANSP; d.border_color = lv_color_hex(col); d.border_width = filled ? 0 : 1; d.border_opa = LV_OPA_COVER; d.radius = (lv_coord_t)luaL_optinteger(L, 8, 0); lv_canvas_draw_rect(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3), (lv_coord_t)luaL_checkinteger(L, 4), (lv_coord_t)luaL_checkinteger(L, 5), &d); return 0; } int cvLine(lua_State* L) { CanvasUd* c = checkCanvas(L); if (!c->obj) return 0; lv_point_t pts[2] = { { (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3) }, { (lv_coord_t)luaL_checkinteger(L, 4), (lv_coord_t)luaL_checkinteger(L, 5) } }; lv_draw_line_dsc_t d; lv_draw_line_dsc_init(&d); d.color = lv_color_hex(argColor(L, 6)); d.width = (lv_coord_t)luaL_optinteger(L, 7, 1); lv_canvas_draw_line(c->obj, pts, 2, &d); return 0; } int cvCircle(lua_State* L) { CanvasUd* c = checkCanvas(L); if (!c->obj) return 0; lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2), y = (lv_coord_t)luaL_checkinteger(L, 3); lv_coord_t r = (lv_coord_t)luaL_checkinteger(L, 4); lv_draw_arc_dsc_t d; lv_draw_arc_dsc_init(&d); d.color = lv_color_hex(argColor(L, 5)); bool filled = lua_isnoneornil(L, 6) ? true : lua_toboolean(L, 6); d.width = filled ? r : (lv_coord_t)luaL_optinteger(L, 7, 1); lv_canvas_draw_arc(c->obj, x, y, r, 0, 360, &d); return 0; } int cvText(lua_State* L) { CanvasUd* c = checkCanvas(L); if (!c->obj) return 0; const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2); const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3); size_t text_length = 0; const char* text = luaL_checklstring(L, 4, &text_length); lv_draw_label_dsc_t d; lv_draw_label_dsc_init(&d); d.color = lv_color_hex(argColor(L, 5)); d.font = luaHostFontForSize((int)luaL_optinteger(L, 6, 14)); lv_canvas_draw_text(c->obj, x, y, c->w, &d, safeUiText(text, text_length)); return 0; } int cvPos(lua_State* L) { CanvasUd* c = checkCanvas(L); if (c->obj) lv_obj_set_pos(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } int cvGc(lua_State* L) { // Frees the pixel buffer. This runs only when the Lua state is closed (see // uiCanvas: every canvas is pinned in the registry for the app's lifetime), // so the LVGL object is never left pointing at freed pixels. // // The comment here used to claim the free was conditional on the app // closing; it never was, and nothing stopped an ordinary collection cycle // from reclaiming a canvas whose Lua handle had gone out of scope while the // widget was still on screen and being drawn from that buffer. CanvasUd* c = (CanvasUd*)luaL_checkudata(L, 1, "wada.canvas"); if (c->buf) { heap_caps_free(c->buf); c->buf = nullptr; } c->obj = nullptr; return 0; } int uiCanvas(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); int w = (int)luaL_checkinteger(L, 1), h = (int)luaL_checkinteger(L, 2); luaL_argcheck(L, w > 0, 1, "width must be positive"); luaL_argcheck(L, h > 0, 2, "height must be positive"); // Up to the screen's long side (at least 480). A hard 480 made apps that size // a canvas from on_open(w, h) die with a Lua error on the larger screens: // the T-Display P4's body is ~575 tall, the Tanmatsu and CrowPanel 5 are 800 // wide (Discord, Blackjack on the P4). Larger requests are clamped, not fatal. int lim = (int)lv_disp_get_hor_res(nullptr); if ((int)lv_disp_get_ver_res(nullptr) > lim) lim = (int)lv_disp_get_ver_res(nullptr); if (lim < 480) lim = 480; if (w > lim) w = lim; if (h > lim) h = lim; size_t bytes = (size_t)w * h * sizeof(lv_color_t); lv_color_t* buf = (lv_color_t*)heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!buf) return luaL_error(L, "canvas alloc failed"); lv_obj_t* cv = lv_canvas_create(s_h->body); lv_canvas_set_buffer(cv, buf, w, h, LV_IMG_CF_TRUE_COLOR); lv_canvas_fill_bg(cv, lv_color_hex(0x000000), LV_OPA_COVER); CanvasUd* ud = (CanvasUd*)lua_newuserdatauv(L, sizeof(CanvasUd), 0); ud->gen = s_ui_gen; ud->obj = cv; ud->buf = buf; ud->w = w; ud->h = h; luaL_setmetatable(L, "wada.canvas"); // Pin the handle in the registry for the app's lifetime. LVGL draws straight // out of this buffer, so the pixels must outlive the widget — and an app that // creates a canvas without keeping a reference (or drops one) would otherwise // have it collected mid-run, leaving the widget reading freed memory. The // registry dies with the state at lua_close, which is where __gc frees the // buffer; the ref id is deliberately not tracked, since there is no API to // destroy a canvas early. lua_pushvalue(L, -1); luaL_ref(L, LUA_REGISTRYINDEX); return 1; } // ---- label userdata ---- struct WidgetUd { lv_obj_t* obj; uint32_t gen; }; WidgetUd* checkLabel(lua_State* L) { WidgetUd* u = (WidgetUd*)luaL_checkudata(L, 1, "wada.label"); if (u->gen != s_ui_gen) u->obj = nullptr; // cleared out from under this handle return u; } // A button handle shares the label methods (uiButton). Its text lives in the // button's child label: calling lv_label_ functions on the lv_btn itself wrote // label fields into a button object and corrupted the heap, the first time an // app relabelled a button (Music's play/pause, crash on the T-Display P4). lv_obj_t* labelTarget(lv_obj_t* o) { if (!o || lv_obj_check_type(o, &lv_label_class)) return o; lv_obj_t* c = lv_obj_get_child(o, 0); return (c && lv_obj_check_type(c, &lv_label_class)) ? c : nullptr; } int lbSet(lua_State* L) { WidgetUd* u = checkLabel(L); size_t length = 0; const char* text = luaL_checklstring(L, 2, &length); lv_obj_t* l = labelTarget(u->obj); if (l) lv_label_set_text(l, safeUiText(text, length)); return 0; } int lbPos(lua_State* L) { WidgetUd* u = checkLabel(L); if (u->obj) lv_obj_set_pos(u->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } int lbColor(lua_State* L) { WidgetUd* u = checkLabel(L); if (u->obj) lv_obj_set_style_text_color(u->obj, lv_color_hex(argColor(L, 2)), LV_PART_MAIN); return 0; } // button:bg(fill [, border]) -- the fill colour of a button (or a label, which // then gets a solid box behind its text). Apps asked for coloured buttons and // were drawing their own from panels, which are no focus stop for keypad // navigation. Older firmware has no bg: `if b.bg then b:bg(c) end`. int lbBg(lua_State* L) { WidgetUd* u = checkLabel(L); if (!u->obj) return 0; lv_obj_set_style_bg_color(u->obj, lv_color_hex(argColor(L, 2)), LV_PART_MAIN); lv_obj_set_style_bg_opa(u->obj, LV_OPA_COVER, LV_PART_MAIN); if (!lua_isnoneornil(L, 3)) { lv_obj_set_style_border_color(u->obj, lv_color_hex(argColor(L, 3)), LV_PART_MAIN); if (lv_obj_get_style_border_width(u->obj, LV_PART_MAIN) == 0) lv_obj_set_style_border_width(u->obj, 1, LV_PART_MAIN); } return 0; } int lbWidth(lua_State* L) { // label:width(px [, "left"|"center"|"right"]) — wraps instead of overflowing WidgetUd* u = checkLabel(L); if (u->obj) { lv_obj_set_width(u->obj, (lv_coord_t)luaL_checkinteger(L, 2)); if (lv_obj_check_type(u->obj, &lv_label_class)) lv_label_set_long_mode(u->obj, LV_LABEL_LONG_WRAP); // Optional alignment inside that width: the only way an app can centre a // line exactly, since it cannot measure glyphs itself. const char* al = luaL_optstring(L, 3, nullptr); if (al) { lv_text_align_t a = strcmp(al, "center") == 0 ? LV_TEXT_ALIGN_CENTER : strcmp(al, "right") == 0 ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT; lv_obj_set_style_text_align(u->obj, a, LV_PART_MAIN); } } return 0; } int uiLabel(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); size_t length = 0; const char* text = luaL_checklstring(L, 1, &length); const lv_coord_t x = (lv_coord_t)luaL_optinteger(L, 2, 0); const lv_coord_t y = (lv_coord_t)luaL_optinteger(L, 3, 0); const int size = (int)luaL_optinteger(L, 4, 14); const lv_font_t* font = lua_toboolean(L, 6) ? luaHostBoldFontForSize(size) : luaHostFontForSize(size); const uint32_t color = argColor(L, 5, 0xE6E9ED); lv_obj_t* l = lv_label_create(s_h->body); lv_label_set_text(l, safeUiText(text, length)); lv_obj_set_pos(l, x, y); lv_obj_set_style_text_font(l, font, LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(color), LV_PART_MAIN); WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0); ud->gen = s_ui_gen; ud->obj = l; luaL_setmetatable(L, "wada.label"); return 1; } // ---- wada.ui.panel(x, y, w, h [, color [, radius]]) --------------------------- // A rounded, framed surface in the firmware's own card style, for an app to group // readings on (stat tiles, a frame around a chart or a list). A canvas cannot do // this: it has no transparency, so on the aurora background its corners show as a // hard rectangle. Inert: never a focus stop, never takes a tap. Created before the // widgets that sit on it, it draws underneath them. struct PanelUd { lv_obj_t* obj; uint32_t gen; }; PanelUd* checkPanel(lua_State* L) { PanelUd* u = (PanelUd*)luaL_checkudata(L, 1, "wada.panel"); if (u->gen != s_ui_gen) u->obj = nullptr; return u; } int pnPos(lua_State* L) { PanelUd* u = checkPanel(L); if (u->obj) lv_obj_set_pos(u->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } int pnSize(lua_State* L) { // panel:size(w, h): a bar that grows, a meter PanelUd* u = checkPanel(L); if (u->obj) lv_obj_set_size(u->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } int pnColor(lua_State* L) { // panel:color(fill [, border]) PanelUd* u = checkPanel(L); if (!u->obj) return 0; lv_obj_set_style_bg_color(u->obj, lv_color_hex(argColor(L, 2)), LV_PART_MAIN); if (!lua_isnoneornil(L, 3)) lv_obj_set_style_border_color(u->obj, lv_color_hex(argColor(L, 3)), LV_PART_MAIN); return 0; } int uiPanel(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 1), y = (lv_coord_t)luaL_checkinteger(L, 2); const lv_coord_t w = (lv_coord_t)luaL_checkinteger(L, 3), h = (lv_coord_t)luaL_checkinteger(L, 4); lv_obj_t* p = lv_obj_create(s_h->body); lv_obj_remove_style_all(p); lv_obj_set_pos(p, x, y); lv_obj_set_size(p, w, h); lv_obj_set_style_bg_color(p, lv_color_hex(argColor(L, 5, luaHostPanelColor())), LV_PART_MAIN); lv_obj_set_style_bg_opa(p, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(p, lv_color_hex(luaHostBorderColor()), LV_PART_MAIN); lv_obj_set_style_border_width(p, 1, LV_PART_MAIN); lv_obj_set_style_radius(p, (lv_coord_t)luaL_optinteger(L, 6, 8), LV_PART_MAIN); lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(p, LV_OBJ_FLAG_CLICKABLE); luaHostMarkInert(p); PanelUd* ud = (PanelUd*)lua_newuserdatauv(L, sizeof(PanelUd), 0); ud->gen = s_ui_gen; ud->obj = p; luaL_setmetatable(L, "wada.panel"); return 1; } // ---- chart userdata (the primitive the native Monitor/Airtime pages use) ---- struct ChartUd { lv_obj_t* obj; lv_chart_series_t* ser[2]; int n_ser; uint32_t gen; }; ChartUd* checkChart(lua_State* L) { ChartUd* u = (ChartUd*)luaL_checkudata(L, 1, "wada.chart"); if (u->gen != s_ui_gen) { u->obj = nullptr; u->n_ser = 0; } return u; } int chPush(lua_State* L) { // chart:push(series_idx, value) ChartUd* c = checkChart(L); int si = (int)luaL_checkinteger(L, 2) - 1; if (!c->obj || si < 0 || si >= c->n_ser) return 0; lv_chart_set_next_value(c->obj, c->ser[si], (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } int chSetAll(lua_State* L) { // chart:fill(series_idx, value) — reset a series ChartUd* c = checkChart(L); int si = (int)luaL_checkinteger(L, 2) - 1; if (!c->obj || si < 0 || si >= c->n_ser) return 0; lv_chart_set_all_value(c->obj, c->ser[si], (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } int chRange(lua_State* L) { // chart:range(min, max) ChartUd* c = checkChart(L); if (c->obj) lv_chart_set_range(c->obj, LV_CHART_AXIS_PRIMARY_Y, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } int chPos(lua_State* L) { ChartUd* c = checkChart(L); if (c->obj) lv_obj_set_pos(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } // chart:axis(major_ticks, gutter_px) — Y-axis value labels. LVGL draws them to // the LEFT of the chart's outer edge, so position the chart at least gutter_px // in from the screen edge or they clip (the built-in page uses x=40). int chAxis(lua_State* L) { ChartUd* c = checkChart(L); if (!c->obj) return 0; int major = (int)luaL_optinteger(L, 2, 4); int gutter = (int)luaL_optinteger(L, 3, 40); lv_obj_set_style_text_font(c->obj, luaHostFontForSize(12), LV_PART_TICKS); #if defined(HAS_TDECK_PRO) lv_obj_set_style_text_color(c->obj, lv_color_white(), LV_PART_TICKS); #else lv_obj_set_style_text_color(c->obj, lv_color_hex(0x7A7F87), LV_PART_TICKS); #endif lv_obj_set_style_pad_left(c->obj, 4, LV_PART_TICKS); lv_chart_set_axis_tick(c->obj, LV_CHART_AXIS_PRIMARY_Y, 4, 0, major, 1, true, gutter); return 0; } int uiChart(lua_State* L) { // wada.ui.chart(w, h, points, color1 [, color2] [, bars]) if (!s_h || !s_h->body) return luaL_error(L, "no app body"); int w = (int)luaL_checkinteger(L, 1), h = (int)luaL_checkinteger(L, 2); int pts = (int)luaL_optinteger(L, 3, 60); if (pts < 2) pts = 2; if (pts > 240) pts = 240; lv_obj_t* ch = lv_chart_create(s_h->body); lv_obj_set_size(ch, w, h); lv_obj_clear_flag(ch, LV_OBJ_FLAG_SCROLLABLE); lv_chart_set_type(ch, lua_toboolean(L, 6) ? LV_CHART_TYPE_BAR : LV_CHART_TYPE_LINE); lv_chart_set_point_count(ch, pts); lv_chart_set_update_mode(ch, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_div_line_count(ch, 3, 0); // Match the built-in Monitor/Airtime chart styling exactly: panel fill, thin // dark border + rounded corners, dim grid lines, no point dots, 2 px series. lv_obj_set_style_size(ch, 0, LV_PART_INDICATOR); lv_obj_set_style_bg_color(ch, lv_color_hex(0x15181B), LV_PART_MAIN); lv_obj_set_style_bg_opa(ch, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_border_color(ch, lv_color_hex(0x18191A), LV_PART_MAIN); lv_obj_set_style_border_width(ch, 1, LV_PART_MAIN); lv_obj_set_style_radius(ch, 6, LV_PART_MAIN); lv_obj_set_style_line_color(ch, lv_color_hex(0x1A1D1F), LV_PART_MAIN); // grid lv_obj_set_style_line_width(ch, 2, LV_PART_ITEMS); ChartUd* ud = (ChartUd*)lua_newuserdatauv(L, sizeof(ChartUd), 0); ud->gen = s_ui_gen; ud->obj = ch; ud->n_ser = 0; ud->ser[0] = lv_chart_add_series(ch, lv_color_hex(argColor(L, 4, 0x15B6A6)), LV_CHART_AXIS_PRIMARY_Y); ud->n_ser = 1; if (!lua_isnoneornil(L, 5)) { ud->ser[1] = lv_chart_add_series(ch, lv_color_hex(argColor(L, 5, 0x7A7F87)), LV_CHART_AXIS_PRIMARY_Y); ud->n_ser = 2; } luaL_setmetatable(L, "wada.chart"); return 1; } // ---- buttons: Lua callback dispatched through the guarded path ---- void btnEventCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_h || !s_h->L) return; lua_State* L = s_h->L; lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb); lua_pushlightuserdata(L, lv_event_get_target(e)); lua_rawget(L, -2); lua_remove(L, -2); if (lua_isfunction(L, -1)) guardedCall(s_h, 0); else lua_pop(L, 1); serviceDeferredClose(); } int uiButton(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); size_t text_length = 0; const char* text = luaL_checklstring(L, 1, &text_length); const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2); const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3); const lv_coord_t w = (lv_coord_t)luaL_optinteger(L, 4, 90); const lv_coord_t h = (lv_coord_t)luaL_optinteger(L, 5, 34); lv_obj_t* b = lv_btn_create(s_h->body); lv_obj_set_pos(b, x, y); lv_obj_set_size(b, w, h); lv_obj_t* bl = lv_label_create(b); lv_label_set_text(bl, safeUiText(text, text_length)); lv_obj_center(bl); if (lua_isfunction(L, 6)) { lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb); lua_pushlightuserdata(L, b); lua_pushvalue(L, 6); lua_rawset(L, -3); lua_pop(L, 1); lv_obj_add_event_cb(b, btnEventCb, LV_EVENT_CLICKED, nullptr); } WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0); ud->gen = s_ui_gen; ud->obj = b; luaL_setmetatable(L, "wada.label"); // shares set/pos/color methods return 1; } // ---- wada.ui.list: a scrollable, selectable row list ------------------------- // // The gap this fills: an app had labels, buttons, a canvas and a chart, and no // "pick one of N". Every non-trivial app therefore hand-rolled rows of labels // and tracked scrolling itself, which is both tedious and the reason app UIs // looked worse than the firmware's own. // // Rows are real LVGL buttons in a scrollable flex column, so the existing // keyboard/trackball focus navigation walks them on touchless boards for free, // and a tap works on the ones with a screen. Per-row callbacks ride the same // button-callback table uiButton uses. struct ListUd { lv_obj_t* obj; int sel; uint32_t gen; }; ListUd* checkList(lua_State* L) { ListUd* u = (ListUd*)luaL_checkudata(L, 1, "wada.list"); if (u->gen != s_ui_gen) u->obj = nullptr; return u; } lv_obj_t* listRowAt(ListUd* u, int i) { // i is 1-based, as everywhere in Lua if (!u->obj || i < 1) return nullptr; const uint32_t n = lv_obj_get_child_cnt(u->obj); if ((uint32_t)i > n) return nullptr; return lv_obj_get_child(u->obj, i - 1); } void listPaintRow(lv_obj_t* row, bool selected) { lv_obj_set_style_bg_color(row, lv_color_hex(0x1A1F25), LV_PART_MAIN); lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN); luaHostSetSelectionGlow(row, selected); } // wada.ui.text_w(text [, size]) -> rendered width in pixels. // // The gap this closes: an app can ask how TALL a line is (text_h) but had no way // to ask how WIDE it is, so any app laying out its own rows had to guess whether // a string would wrap. Guessing wrong puts the next row on top of it, which is // exactly what happened to the SDK Test app's channel list. With this an app can // compute its own line count instead of estimating from character counts. int uiTextW(lua_State* L) { size_t len = 0; const char* txt = luaL_checklstring(L, 1, &len); const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 2, 14)); txt = safeUiText(txt, len, &len); lua_pushinteger(L, (lua_Integer)lv_txt_get_width(txt, (uint32_t)len, f, 0, LV_TEXT_FLAG_NONE)); return 1; } // wada.ui.text_lines(text, width [, size]) -> how many lines it wraps to. // The question apps actually have. Counts explicit newlines too, and measures // rather than guessing, so a layout built on it cannot overlap. int uiTextLines(lua_State* L) { size_t len = 0; const char* txt = luaL_checklstring(L, 1, &len); const int w = (int)luaL_checkinteger(L, 2); const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 3, 14)); txt = safeUiText(txt, len, &len); if (w <= 0) { lua_pushinteger(L, 1); return 1; } int lines = 0; const char* p = txt; while (*p) { uint32_t consumed = _lv_txt_get_next_line(p, f, 0, (lv_coord_t)w, nullptr, LV_TEXT_FLAG_NONE); if (!consumed) break; p += consumed; lines++; if (lines > 512) break; // pathological input: stop counting } lua_pushinteger(L, lines < 1 ? 1 : lines); return 1; } // wada.ui.clear() -- remove every widget from the app's page. // // The SDK documented building widgets in on_open() and gave no way to take them // down again, so an app with more than one screen drew the second on top of the // first. Reported by pisti87, who had been working around it with buttons. // // Bumping the generation is what makes this safe. A Lua variable still holding a // label from the screen just cleared keeps a raw pointer to freed memory; the // accessors compare generations and null it, so the existing `if (u->obj)` guard // in every method turns a stale call into a no-op instead of a crash. static int uiClear(lua_State* L) { if (!s_h || !s_h->body) return 0; #if CAP_LUA_SDK_EXT destroyLiveMapView(); #endif lv_obj_clean(s_h->body); // deletes the children, keeps the page itself ++s_ui_gen; // Drop the button callbacks with the buttons they belong to. The table is // keyed by the LVGL object POINTER, and lv_obj_clean just freed every one of // them, so every entry here is now stale by definition. Leaving them cost an // app a table entry and a live closure (with its upvalues) per button per // rebuild, which is unbounded: an app with a few screens it redraws is the // normal case, and Jade's hit "screen memory errors, worse the longer it has // been open" exactly this way. // // It was also a correctness bug waiting to happen. LVGL reuses freed // addresses, so a NEW button created without a callback could land on an // address a dead one left behind and fire that stale closure on the next tap. if (s_h->L && s_h->ref_btncb != LUA_NOREF) { lua_newtable(s_h->L); lua_rawseti(s_h->L, LUA_REGISTRYINDEX, s_h->ref_btncb); } (void)L; return 0; } int uiList(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); const int x = (int)luaL_checkinteger(L, 1), y = (int)luaL_checkinteger(L, 2); const int w = (int)luaL_checkinteger(L, 3), h = (int)luaL_checkinteger(L, 4); luaL_argcheck(L, w > 0 && h > 0, 3, "width and height must be positive"); lv_obj_t* c = lv_obj_create(s_h->body); lv_obj_remove_style_all(c); lv_obj_set_pos(c, (lv_coord_t)x, (lv_coord_t)y); lv_obj_set_size(c, (lv_coord_t)w, (lv_coord_t)h); lv_obj_set_style_bg_color(c, lv_color_hex(0x0E1216), LV_PART_MAIN); lv_obj_set_style_bg_opa(c, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_pad_all(c, 2, LV_PART_MAIN); lv_obj_set_style_pad_row(c, 2, LV_PART_MAIN); lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN); lv_obj_set_scroll_dir(c, LV_DIR_VER); lv_obj_add_flag(c, LV_OBJ_FLAG_SCROLLABLE); ListUd* ud = (ListUd*)lua_newuserdatauv(L, sizeof(ListUd), 0); ud->gen = s_ui_gen; ud->obj = c; ud->sel = 0; luaL_setmetatable(L, "wada.list"); return 1; } // list:add(text [, fn]) -> index of the new row int lsAdd(lua_State* L) { ListUd* u = checkList(L); size_t text_length = 0; const char* text = luaL_checklstring(L, 2, &text_length); if (!u->obj || !s_h) return 0; lv_obj_t* row = lv_btn_create(u->obj); lv_obj_set_width(row, LV_PCT(100)); lv_obj_set_height(row, LV_SIZE_CONTENT); lv_obj_set_style_radius(row, 4, LV_PART_MAIN); lv_obj_set_style_pad_all(row, 6, LV_PART_MAIN); listPaintRow(row, false); lv_obj_t* lb = lv_label_create(row); lv_label_set_text(lb, safeUiText(text, text_length)); lv_label_set_long_mode(lb, LV_LABEL_LONG_DOT); // a long name truncates instead of reflowing the row lv_obj_set_width(lb, LV_PCT(100)); lv_obj_set_style_text_font(lb, luaHostFontForSize(12), LV_PART_MAIN); lv_obj_set_style_text_color(lb, lv_color_hex(0xE6E9ED), LV_PART_MAIN); if (lua_isfunction(L, 3)) { lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb); lua_pushlightuserdata(L, row); lua_pushvalue(L, 3); lua_rawset(L, -3); lua_pop(L, 1); lv_obj_add_event_cb(row, btnEventCb, LV_EVENT_CLICKED, nullptr); } lua_pushinteger(L, (lua_Integer)lv_obj_get_child_cnt(u->obj)); return 1; } int lsSet(lua_State* L) { ListUd* u = checkList(L); lv_obj_t* row = listRowAt(u, (int)luaL_checkinteger(L, 2)); size_t text_length = 0; const char* text = luaL_checklstring(L, 3, &text_length); if (!row) return 0; lv_obj_t* lb = lv_obj_get_child(row, 0); if (lb) lv_label_set_text(lb, safeUiText(text, text_length)); return 0; } int lsColor(lua_State* L) { ListUd* u = checkList(L); lv_obj_t* row = listRowAt(u, (int)luaL_checkinteger(L, 2)); if (!row) return 0; lv_obj_t* lb = lv_obj_get_child(row, 0); if (lb) lv_obj_set_style_text_color(lb, lv_color_hex((uint32_t)luaL_checkinteger(L, 3)), LV_PART_MAIN); return 0; } // Clearing drops the rows' entries from the callback table too. Without that, // a list rebuilt every tick leaks one table entry per row for the app's life. int lsClear(lua_State* L) { ListUd* u = checkList(L); if (!u->obj || !s_h) return 0; lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb); const uint32_t n = lv_obj_get_child_cnt(u->obj); for (uint32_t i = 0; i < n; i++) { lua_pushlightuserdata(L, lv_obj_get_child(u->obj, i)); lua_pushnil(L); lua_rawset(L, -3); } lua_pop(L, 1); lv_obj_clean(u->obj); u->sel = 0; return 0; } int lsCount(lua_State* L) { ListUd* u = checkList(L); lua_pushinteger(L, u->obj ? (lua_Integer)lv_obj_get_child_cnt(u->obj) : 0); return 1; } // list:select(i) highlights AND scrolls the row into view -- on a board with no // touch, moving the selection without scrolling to it just loses the cursor. int lsSelect(lua_State* L) { ListUd* u = checkList(L); const int i = (int)luaL_checkinteger(L, 2); if (!u->obj) return 0; const uint32_t n = lv_obj_get_child_cnt(u->obj); for (uint32_t k = 0; k < n; k++) listPaintRow(lv_obj_get_child(u->obj, k), false); lv_obj_t* row = listRowAt(u, i); if (!row) { u->sel = 0; return 0; } listPaintRow(row, true); lv_obj_scroll_to_view(row, LV_ANIM_OFF); u->sel = i; return 0; } int lsSelected(lua_State* L) { ListUd* u = checkList(L); if (u->sel < 1) { lua_pushnil(L); return 1; } lua_pushinteger(L, u->sel); return 1; } int lsPos(lua_State* L) { ListUd* u = checkList(L); if (u->obj) lv_obj_set_pos(u->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); return 0; } // ---- sys / timer / store ---- // wada.ui.scroll(true) — let the app body scroll vertically. Apps that lay out // more content than the screen (a log feed, a settings list) need this; games // leave it off so a swipe steers instead of dragging the page. int uiScroll(lua_State* L) { if (!s_h || !s_h->body) return 0; const bool on = lua_isnoneornil(L, 1) ? true : lua_toboolean(L, 1); if (on) { lv_obj_add_flag(s_h->body, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scroll_dir(s_h->body, LV_DIR_VER); lv_obj_set_scrollbar_mode(s_h->body, LV_SCROLLBAR_MODE_AUTO); lv_obj_set_style_bg_color(s_h->body, lv_color_hex(0x15B6A6), LV_PART_SCROLLBAR); lv_obj_set_style_bg_opa(s_h->body, LV_OPA_60, LV_PART_SCROLLBAR); lv_obj_set_style_width(s_h->body, 4, LV_PART_SCROLLBAR); lv_obj_set_style_pad_bottom(s_h->body, 10, LV_PART_MAIN); } else { lv_obj_clear_flag(s_h->body, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_scrollbar_mode(s_h->body, LV_SCROLLBAR_MODE_OFF); } return 0; } // wada.ui.text_h(size) — real line height of a size class, so an app can stack // rows without guessing (guessing is what made labels overlap). int uiTextH(lua_State* L) { lua_pushinteger(L, lv_font_get_line_height(luaHostFontForSize((int)luaL_optinteger(L, 1, 12)))); return 1; } int sysMillis(lua_State* L) { lua_pushinteger(L, (lua_Integer)millis()); return 1; } // wada.sys.epoch() -> Unix seconds, or nil when the clock has never been set. // nil rather than 0 on purpose: an app that stamps a log wants to be able to // tell "no clock yet" from "1970", and the device genuinely boots without one // until GPS, NTP or a mesh peer supplies the time (#245). int sysEpoch(lua_State* L) { const time_t now = time(nullptr); if (now < 1700000000) { lua_pushnil(L); return 1; } // same sane-clock floor the mesh uses lua_pushinteger(L, (lua_Integer)now); return 1; } // wada.sys.datetime() -> table in LOCAL time (the timezone the user picked), // because every use for it is display. Fields match pisti87's request in #245. // nil under the same unset-clock rule as epoch(). int sysDatetime(lua_State* L) { const time_t now = time(nullptr); struct tm tmv; if (now < 1700000000 || !localtime_r(&now, &tmv)) { lua_pushnil(L); return 1; } lua_createtable(L, 0, 7); lua_pushinteger(L, tmv.tm_year + 1900); lua_setfield(L, -2, "year"); lua_pushinteger(L, tmv.tm_mon + 1); lua_setfield(L, -2, "month"); lua_pushinteger(L, tmv.tm_mday); lua_setfield(L, -2, "day"); lua_pushinteger(L, tmv.tm_hour); lua_setfield(L, -2, "hour"); lua_pushinteger(L, tmv.tm_min); lua_setfield(L, -2, "min"); lua_pushinteger(L, tmv.tm_sec); lua_setfield(L, -2, "sec"); lua_pushinteger(L, tmv.tm_wday); lua_setfield(L, -2, "wday"); // 0 = Sunday return 1; } // wada.sys.beep() -> true if a sound was actually produced. #245 asked for a // melody player; what the firmware has is one notification chime, and only some // boards have a sounder at all. Exposing the truthful primitive beats inventing // a melody engine an app could not rely on: the return value lets an app fall // back to a visual cue instead of silently doing nothing. int sysBeep(lua_State* L) { lua_pushboolean(L, luaHostBeep()); return 1; } // wada.sys.caps() -> what THIS board actually offers. Apps must branch on this // rather than assume: the extended SDK is absent on low-resource boards (see // CAP_LUA_SDK_EXT in device_caps.h), and a store app runs on all of them. int sysCaps(lua_State* L) { lua_createtable(L, 0, 20); lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "sdk_ext"); 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_READ); lua_setfield(L, -2, "sd_list"); lua_pushboolean(L, CAP_LUA_SD_LIST); lua_setfield(L, -2, "sd_clean"); // wada.sd.check/remove + list paging lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio"); lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_wav"); lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_mp3"); lua_pushboolean(L, CAP_LUA_AUDIO && CAP_LUA_SD_READ); lua_setfield(L, -2, "audio_sd"); // 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 lua_pushboolean(L, 1); lua_setfield(L, -2, "input"); // wada.ui.input lua_pushboolean(L, 1); lua_setfield(L, -2, "rx_identity");// rx_log pubkey/src/dst lua_pushboolean(L, 1); lua_setfield(L, -2, "list"); // wada.ui.list lua_pushboolean(L, 1); lua_setfield(L, -2, "packets"); // app.on_packet lua_pushboolean(L, 1); lua_setfield(L, -2, "measure"); // ui.text_w / text_lines lua_pushboolean(L, CAP_SENSORS); lua_setfield(L, -2, "sensors"); // wada.sys.env lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "map"); // wada.map.view lua_pushboolean(L, CAP_COMPASS); lua_setfield(L, -2, "compass"); // wada.sys.compass() lua_pushboolean(L, CAP_IMU); lua_setfield(L, -2, "accel"); // wada.sys.accel() lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "widget"); // wada.widget (a Home widget) lua_pushboolean(L, 1); lua_setfield(L, -2, "panel"); // wada.ui.panel, ui.label bold, size 24 lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_events"); // app.on_audio #if defined(HAS_TDECK_PRO) lua_pushboolean(L, 1); lua_setfield(L, -2, "epaper"); // T-Deck Pro / Max: redraw sparingly #else lua_pushboolean(L, 0); lua_setfield(L, -2, "epaper"); #endif return 1; } #if CAP_LUA_SDK_EXT // wada.sys.battery() -> { mv, pct, charging } int sysBattery(lua_State* L) { uint16_t mv = 0; int pct = -1; bool chg = false; luaHostBattery(&mv, &pct, &chg); lua_createtable(L, 0, 3); lua_pushinteger(L, mv); lua_setfield(L, -2, "mv"); lua_pushinteger(L, pct); lua_setfield(L, -2, "pct"); lua_pushboolean(L, chg); lua_setfield(L, -2, "charging"); return 1; } // wada.sys.gps() -> { lat, lon, lat_e6, lon_e6, sats, alt_m, [time], // [speed_kmh], [course] } or nil with NO fix. nil rather than stale // coordinates: wada.mesh.self() already hands out the last known position, and // an app plotting a track needs to know the difference. alt_m is metres; time // is satellite time, absent until the receiver has decoded the date. speed_kmh and course (degrees clockwise from north) are // present only on boards whose GPS provider reports them, and course only // while actually moving — a stationary receiver's course is meaningless, so it // is absent rather than 0. int sysGps(lua_State* L) { double lat = 0, lon = 0; int sats = 0, alt = 0; uint32_t ftime = 0; int32_t lat6 = 0, lon6 = 0; float spd = NAN, crs = NAN; if (!luaHostGps(&lat, &lon, &sats, &alt, &ftime, &lat6, &lon6, &spd, &crs)) { lua_pushnil(L); return 1; } lua_createtable(L, 0, 9); lua_pushnumber(L, lat); lua_setfield(L, -2, "lat"); lua_pushnumber(L, lon); lua_setfield(L, -2, "lon"); // Exact micro-degrees: Lua floats here are single precision (LUA_32BITS), so // lat/lon above are good for display and lossy for a log. Write these. lua_pushinteger(L, lat6); lua_setfield(L, -2, "lat_e6"); lua_pushinteger(L, lon6); lua_setfield(L, -2, "lon_e6"); lua_pushinteger(L, sats); lua_setfield(L, -2, "sats"); lua_pushinteger(L, alt); lua_setfield(L, -2, "alt_m"); if (ftime) { lua_pushinteger(L, (lua_Integer)ftime); lua_setfield(L, -2, "time"); } if (!isnan(spd)) { lua_pushnumber(L, spd); lua_setfield(L, -2, "speed_kmh"); } if (!isnan(crs)) { lua_pushnumber(L, crs); lua_setfield(L, -2, "course"); } return 1; } #endif // CAP_LUA_SDK_EXT #if CAP_COMPASS // wada.sys.compass() -> { x, y, z, ovfl } in Gauss, SENSOR frame, uncalibrated // -- or nil when the chip is absent, not answering, or has nothing fresh. On // the hardware gate (CAP_COMPASS), not the memory gate. // `ovfl` is true when the chip flagged the sample as saturated (magnet nearby, // or a hard-iron bias beyond the range): the numbers are delivered so the app // can say so, but they are not a heading. // Deliberately no `heading`: a heading needs hard-iron offsets (the M9 carries // a large on-board bias that the user has to calibrate away by rotating the // device) and the sensor-to-screen axis mapping, both of which belong in the // app where they can be adjusted and persisted per user. Once the offsets are // subtracted and the axes mapped so fx points along the screen's top edge and // fy along its right edge, heading = math.atan(-fy, fx) (clockwise from // magnetic north) -- deploy/apps/gpscompass is the worked example. int sysCompass(lua_State* L) { float x = 0, y = 0, z = 0; bool ovfl = false; if (!luaHostCompass(&x, &y, &z, &ovfl)) { lua_pushnil(L); return 1; } lua_createtable(L, 0, 4); lua_pushnumber(L, x); lua_setfield(L, -2, "x"); lua_pushnumber(L, y); lua_setfield(L, -2, "y"); lua_pushnumber(L, z); lua_setfield(L, -2, "z"); lua_pushboolean(L, ovfl); lua_setfield(L, -2, "ovfl"); return 1; } #endif // CAP_COMPASS #if CAP_IMU // wada.sys.accel() -> { x, y, z } in g, SENSOR frame — or nil when the chip is // absent or has nothing fresh. Held still, the magnitude is 1 and the axis // pointing at the sky carries it, which is how an app identifies the axes. // Its purpose here is tilt: a magnetic heading taken from two axes is wrong by // roughly 1.5 degrees per degree of tilt at mid latitudes, because the field // dips ~60 degrees and tipping the device swaps some of that vertical field // into the horizontal pair. int sysAccel(lua_State* L) { float x = 0, y = 0, z = 0; if (!luaHostAccel(&x, &y, &z)) { lua_pushnil(L); return 1; } lua_createtable(L, 0, 3); lua_pushnumber(L, x); lua_setfield(L, -2, "x"); lua_pushnumber(L, y); lua_setfield(L, -2, "y"); lua_pushnumber(L, z); lua_setfield(L, -2, "z"); return 1; } #endif // CAP_IMU #if CAP_SENSORS // wada.sys.env() -> { temp_c, humidity, pressure_hpa, alt_m }, or nil. // // Gated on CAP_SENSORS -- does this board HAVE the sensor rail -- and not on // CAP_LUA_SDK_EXT, which is a memory gate. Those are different questions, and // conflating them was wrong in both directions: 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. A field is present only when the hardware actually // reported it, so an app can tell "no humidity sensor" from "0% humidity". int sysEnv(lua_State* L) { bool ok = false; float t = 0, h = 0, p = 0; int alt = 0; bool ht = false, hh = false, hp = false, ha = false; luaHostEnv(&ok, &ht, &t, &hh, &h, &hp, &p, &ha, &alt); if (!ok) { lua_pushnil(L); return 1; } lua_createtable(L, 0, 4); if (ht) { lua_pushnumber(L, t); lua_setfield(L, -2, "temp_c"); } if (hh) { lua_pushnumber(L, h); lua_setfield(L, -2, "humidity"); } if (hp) { lua_pushnumber(L, p); lua_setfield(L, -2, "pressure_hpa"); } if (ha) { lua_pushinteger(L, alt); lua_setfield(L, -2, "alt_m"); } return 1; } #endif // CAP_SENSORS // ---- wada.geo --------------------------------------------------------------- // Great-circle maths in C. 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 100k instruction budget on arithmetic the // chip does in microseconds. // // NOTE ON "COMPASS": bearing() is a TRUE bearing from one coordinate to // another. It is NOT a magnetic heading and this is not a compass. To know // which way the user is FACING you need either their course over ground // (successive GPS fixes, or wada.sys.gps().course where the board reports it) // or a magnetometer -- wada.sys.compass(), which only the CAP_COMPASS boards // have. This gives you the direction to steer, not the direction they face. static double geoRad(double d) { return d * M_PI / 180.0; } int geoDistance(lua_State* L) { const double la1 = geoRad(luaL_checknumber(L, 1)), lo1 = geoRad(luaL_checknumber(L, 2)); const double la2 = geoRad(luaL_checknumber(L, 3)), lo2 = geoRad(luaL_checknumber(L, 4)); const double dla = la2 - la1, dlo = lo2 - lo1; const double a = sin(dla / 2) * sin(dla / 2) + cos(la1) * cos(la2) * sin(dlo / 2) * sin(dlo / 2); lua_pushnumber(L, 6371000.0 * 2.0 * atan2(sqrt(a), sqrt(1.0 - a))); // metres return 1; } int geoBearing(lua_State* L) { const double la1 = geoRad(luaL_checknumber(L, 1)), lo1 = geoRad(luaL_checknumber(L, 2)); const double la2 = geoRad(luaL_checknumber(L, 3)), lo2 = geoRad(luaL_checknumber(L, 4)); const double dlo = lo2 - lo1; const double y = sin(dlo) * cos(la2); const double x = cos(la1) * sin(la2) - sin(la1) * cos(la2) * cos(dlo); double deg = atan2(y, x) * 180.0 / M_PI; if (deg < 0) deg += 360.0; lua_pushnumber(L, deg); return 1; } // 0..360 -> "N", "NE", ... Sixteen points would be false precision on a bearing // derived from consumer GPS, so this stops at eight. int geoCardinal(lua_State* L) { static const char* kPts[8] = { "N", "NE", "E", "SE", "S", "SW", "W", "NW" }; double deg = luaL_checknumber(L, 1); while (deg < 0) deg += 360.0; int idx = (int)((fmod(deg, 360.0) + 22.5) / 45.0) & 7; lua_pushstring(L, kPts[idx]); return 1; } // wada.sys.keep_awake(on) — for an app that is measuring rather than showing: // holds the screen on and keeps on_tick running. Cleared automatically when the // app closes. Use it around a calibration or a capture, not for the whole app. int sysKeepAwake(lua_State* L) { const bool on = lua_isnoneornil(L, 1) ? true : lua_toboolean(L, 1); luaHostKeepAwake(on); return 0; } int sysToast(lua_State* L) { luaHostToast(luaL_checkstring(L, 1), (int)luaL_optinteger(L, 2, 1500)); return 0; } int sysRandom(lua_State* L) { // esp_random-backed: apps should not have to seed math.random for games lua_Integer lo = luaL_optinteger(L, 1, 0), hi = luaL_optinteger(L, 2, 0); if (hi <= lo) { lua_pushinteger(L, (lua_Integer)esp_random()); return 1; } lua_pushinteger(L, lo + (lua_Integer)(esp_random() % (uint32_t)(hi - lo + 1))); return 1; } // wada.sys.tr(s) -- translate through the same table the UI uses. // // Lua apps had no way to reach it, so every built-in app (airtime's "channel // busy", the monitor, the games) was hard-English no matter what language the // device was set to, and a translator could file the string but nothing could // consume it (#257). Keys go in deploy/apps/lang/*.lang exactly like the // firmware's own; an untranslated key returns unchanged, so an app that calls // this is never worse off than one that does not. static int sysTr(lua_State* L) { const char* s = luaL_checkstring(L, 1); lua_pushstring(L, TR(s)); return 1; } int sysBoard(lua_State* L) { lua_createtable(L, 0, 6); lua_pushinteger(L, s_h ? s_h->body_w : lv_disp_get_hor_res(nullptr)); lua_setfield(L, -2, "w"); lua_pushinteger(L, s_h ? s_h->body_h : lv_disp_get_ver_res(nullptr)); lua_setfield(L, -2, "h"); #if CAP_TOUCH lua_pushboolean(L, 1); #else lua_pushboolean(L, 0); #endif lua_setfield(L, -2, "touch"); return 1; } void tickTimerCb(lv_timer_t* t) { (void)t; if (!s_h || !s_h->L) return; // Screen asleep: nothing the app draws is visible, and an app polling a // sensor would keep the hardware awake for a dark screen. Skip the tick and // let it resume on wake — dt is derived from millis(), so the app sees the // pause as one long frame rather than a broken clock. if (!luaHostScreenOn()) { s_h->last_tick = 0; return; } uint32_t now = millis(); uint32_t dt = s_h->last_tick ? now - s_h->last_tick : 0; s_h->last_tick = now; if (pushCallback(s_h, "on_tick")) { lua_pushinteger(s_h->L, (lua_Integer)dt); guardedCall(s_h, 1); } serviceDeferredClose(); } // ---- named timers ---------------------------------------------------------- // One app used to get exactly one clock: on_tick. Anything with a second // cadence -- a slow poll beside a fast animation, a delayed retry, a timeout on // a network call -- had to be a counter inside on_tick, which is both tedious // and wrong under a variable tick period. These are real timers, capped at // kMaxTimers so an app cannot fill the LVGL timer list. // // A handle is returned so the app can stop one specifically; stopping is also // automatic for a one-shot after it fires, and for everything at app close. struct TimerUd { int slot; uint32_t gen; }; uint32_t s_timer_gen[Host::kMaxTimers] = {0}; // bumped on reuse, so a stale handle is inert void timerRelease(int slot) { if (!s_h || slot < 0 || slot >= Host::kMaxTimers) return; Host::AppTimer& s = s_h->timers[slot]; if (s.t) { lv_timer_del(s.t); s.t = nullptr; } if (s.cb != LUA_NOREF && s_h->L) luaL_unref(s_h->L, LUA_REGISTRYINDEX, s.cb); s.cb = LUA_NOREF; s.once = false; s_timer_gen[slot]++; } void namedTimerCb(lv_timer_t* t) { if (!s_h || !s_h->L || !t) return; const int slot = (int)(intptr_t)t->user_data; if (slot < 0 || slot >= Host::kMaxTimers) return; Host::AppTimer& s = s_h->timers[slot]; if (s.cb == LUA_NOREF) return; // Same rule as on_tick: a REPEATING timer does not run while the display is // asleep, since its usual job is polling something nobody can see. A one-shot // still fires — it is a scheduled piece of app logic, and skipping it would // drop that work permanently rather than defer it (the slot is released // around the call), which is not the same trade at all. if (!s.once && !luaHostScreenOn()) return; lua_State* L = s_h->L; // A one-shot is released BEFORE the call, so the callback may safely start // another timer in the same slot without it being torn down underneath. if (s.once) { lua_rawgeti(L, LUA_REGISTRYINDEX, s.cb); timerRelease(slot); guardedCall(s_h, 0); } else { lua_rawgeti(L, LUA_REGISTRYINDEX, s.cb); guardedCall(s_h, 0); } serviceDeferredClose(); } int timerStart(lua_State* L, int ms, int fnidx, bool once) { if (!s_h) return luaL_error(L, "no app"); if (ms < kMinTickMs) ms = kMinTickMs; int slot = -1; for (int i = 0; i < Host::kMaxTimers; i++) if (!s_h->timers[i].t) { slot = i; break; } if (slot < 0) return luaL_error(L, "too many timers (max %d)", Host::kMaxTimers); lua_pushvalue(L, fnidx); s_h->timers[slot].cb = luaL_ref(L, LUA_REGISTRYINDEX); s_h->timers[slot].once = once; s_h->timers[slot].t = lv_timer_create(namedTimerCb, ms, (void*)(intptr_t)slot); if (!s_h->timers[slot].t) { timerRelease(slot); return luaL_error(L, "timer alloc failed"); } TimerUd* ud = (TimerUd*)lua_newuserdatauv(L, sizeof(TimerUd), 0); ud->slot = slot; ud->gen = s_timer_gen[slot]; luaL_setmetatable(L, "wada.timerh"); return 1; } // wada.timer.every(ms) -> set the on_tick period (unchanged) // wada.timer.every(ms, fn) -> a repeating timer calling fn; returns a handle int timerEvery(lua_State* L) { if (!s_h) return 0; int ms = (int)luaL_checkinteger(L, 1); if (lua_isfunction(L, 2)) return timerStart(L, ms, 2, false); if (ms < kMinTickMs) ms = kMinTickMs; if (s_h->timer) lv_timer_set_period(s_h->timer, ms); else s_h->timer = lv_timer_create(tickTimerCb, ms, nullptr); return 0; } // wada.timer.after(ms, fn) -> fires once; returns a handle you can cancel. int timerAfter(lua_State* L) { int ms = (int)luaL_checkinteger(L, 1); luaL_checktype(L, 2, LUA_TFUNCTION); return timerStart(L, ms, 2, true); } // wada.timer.stop() -> stop the on_tick heartbeat (unchanged) // wada.timer.stop(h) -> stop that one timer int timerStop(lua_State* L) { if (!s_h) return 0; if (luaL_testudata(L, 1, "wada.timerh")) { TimerUd* u = (TimerUd*)lua_touserdata(L, 1); if (u->gen == s_timer_gen[u->slot]) timerRelease(u->slot); return 0; } if (s_h->timer) { lv_timer_del(s_h->timer); s_h->timer = nullptr; s_h->last_tick = 0; } return 0; } int timerHandleStop(lua_State* L) { TimerUd* u = (TimerUd*)luaL_checkudata(L, 1, "wada.timerh"); if (s_h && u->gen == s_timer_gen[u->slot]) timerRelease(u->slot); return 0; } // Store: a Lua table mirrored to /apps/.sav as "k=v" lines (strings and // numbers only). Loaded at launch, flushed on dismiss when dirty — tiny, rare, // and off the hot path (touch_perf: never spam flash from the UI thread). #if CAP_LUA_SDK_EXT // wada.mesh.send(channel, text) -> true | false, reason // // The ONLY write path an app has into the mesh, and the one API where a bad app // costs other people rather than just its own device: LoRa is a shared channel, // and anything sent goes out under the user's node name. // // Three gates, in order: // 1. per-app consent, granted by the user once and bound to THIS app id // 2. an airtime rate limit, far stricter than the filesystem one // 3. a length cap, so one call cannot occupy the channel // // The app never sees a prompt API and cannot pre-approve itself: the first call // without a grant fails AND raises the prompt, so consent is always something // the user did, not something the app asked for at a convenient moment. static const uint32_t kMeshSendMinGapMs = 5000; // per app static const size_t kMeshSendMaxLen = 180; // one LoRa text payload // Anti-spam (#606). The gap above still lets one app post to a public channel twelve // times a minute, all day, and restarting the app or switching apps resets it. So on top // of it: budgets shared by EVERY app and kept for the whole boot, and a duplicate check. // A channel post floods the whole mesh: three in a row, then one a minute. A direct // message travels one route: six in a row, then one every ten seconds. The same text to // the same channel twice within ten minutes is refused outright. Hitting a budget returns // false, "rate limited", seconds_to_wait. struct MeshSendBudget { uint8_t burst; uint32_t refill_ms; uint8_t tokens; uint32_t last_ms; // 0: never used (full) void refill(uint32_t now) { if (!last_ms) { tokens = burst; last_ms = now ? now : 1; return; } const uint32_t earned = (now - last_ms) / refill_ms; if (!earned) return; tokens = (uint8_t)((tokens + earned >= burst) ? burst : tokens + earned); last_ms = (tokens >= burst) ? now : last_ms + earned * refill_ms; // no banking past a full burst } uint32_t waitS(uint32_t now) { // seconds until the next send may go (0: now) refill(now); if (tokens) return 0; return (refill_ms - (now - last_ms) + 999) / 1000; } void spend() { if (tokens) tokens--; } }; static MeshSendBudget s_mesh_chan_budget = { 3, 60000, 0, 0 }; static MeshSendBudget s_mesh_dm_budget = { 6, 10000, 0, 0 }; static const uint32_t kMeshDupWindowMs = 10UL * 60UL * 1000UL; static uint32_t s_mesh_dup_hash[8]; static uint32_t s_mesh_dup_ms[8]; static uint8_t s_mesh_dup_next = 0; static uint32_t meshDupHash(const char* chan, const char* text) { uint32_t h = 2166136261u; // FNV-1a over the channel (case folded), a separator, the text for (const char* c = chan; *c; ++c) { const char ch = (*c >= 'A' && *c <= 'Z') ? (char)(*c + 32) : *c; h = (h ^ (uint8_t)ch) * 16777619u; } h = (h ^ 0xFFu) * 16777619u; for (const char* c = text; *c; ++c) h = (h ^ (uint8_t)*c) * 16777619u; return h; } static bool meshDupSeen(uint32_t h, uint32_t now) { for (int i = 0; i < 8; ++i) if (s_mesh_dup_ms[i] && s_mesh_dup_hash[i] == h && (uint32_t)(now - s_mesh_dup_ms[i]) < kMeshDupWindowMs) return true; return false; } static void meshDupRemember(uint32_t h, uint32_t now) { s_mesh_dup_hash[s_mesh_dup_next] = h; s_mesh_dup_ms[s_mesh_dup_next] = now ? now : 1; s_mesh_dup_next = (uint8_t)((s_mesh_dup_next + 1) & 7); } int meshSend(lua_State* L) { const char* chan = luaL_checkstring(L, 1); size_t len = 0; const char* text = luaL_checklstring(L, 2, &len); if (!s_h) { lua_pushboolean(L, 0); lua_pushstring(L, "no app"); return 2; } if (len == 0 || len > kMeshSendMaxLen) { lua_pushboolean(L, 0); lua_pushstring(L, "bad length"); return 2; } const int perm = luaHostSendPerm(s_h->id); if (perm != 1) { if (perm == 0) luaHostRequestSendPerm(s_h->id, s_h->id); // ask once, never in a loop lua_pushboolean(L, 0); lua_pushstring(L, perm == 0 ? "permission requested" : "permission denied"); return 2; } const uint32_t now = millis(); if (s_h->mesh_last_send_ms && (uint32_t)(now - s_h->mesh_last_send_ms) < kMeshSendMinGapMs) { lua_pushboolean(L, 0); lua_pushstring(L, "too fast"); return 2; } const uint32_t dup = meshDupHash(chan, text); if (meshDupSeen(dup, now)) { lua_pushboolean(L, 0); lua_pushstring(L, "duplicate"); return 2; } if (const uint32_t wait_s = s_mesh_chan_budget.waitS(now)) { lua_pushboolean(L, 0); lua_pushstring(L, "rate limited"); lua_pushinteger(L, (lua_Integer)wait_s); return 3; } const bool ok = luaHostMeshSendChannel(chan, text); if (ok) { // only a real transmission starts the clock and spends the budget s_h->mesh_last_send_ms = now; s_mesh_chan_budget.spend(); meshDupRemember(dup, now); } lua_pushboolean(L, ok); if (!ok) { lua_pushstring(L, "no such channel"); return 2; } // Second return: the fingerprint of what just went out, for wada.mesh.repeats(). // Additive -- an app that only reads the first value is unaffected. lua_pushinteger(L, (lua_Integer)luaHostLastSentFp()); return 2; } // wada.mesh.repeats(fp) -> n // // How many repeaters have been heard rebroadcasting the flood identified by `fp`, // the second value wada.mesh.send() returns. This is the same count the sent // bubble shows next to the refresh glyph, and it grows for a while after the // send as repeats arrive, so an app polls it rather than reading it once. Only // meaningful for a channel send: a direct message is not flooded, so it has no // repeats to hear, and 0 is returned for an unknown or expired fingerprint. int meshRepeats(lua_State* L) { const lua_Integer fp = luaL_checkinteger(L, 1); lua_pushinteger(L, (lua_Integer)luaHostRepeatsForFp((uint32_t)fp)); return 1; } // wada.mesh.send_dm(to, text) -> ok, err // `to` is a CONTACT NAME as it appears in wada.mesh.contacts(). A room server is a // contact too, so the same call posts to a room; the second return value says which // it was ("sent" or "room") so an app does not have to keep its own table of types. // Shares the send rate limiter with channel sends: one app, one radio. int meshSendDm(lua_State* L) { const char* to = luaL_checkstring(L, 1); size_t len = 0; const char* text = luaL_checklstring(L, 2, &len); if (!s_h) { lua_pushboolean(L, 0); lua_pushstring(L, "no app"); return 2; } if (len == 0 || len > kMeshSendMaxLen) { lua_pushboolean(L, 0); lua_pushstring(L, "bad length"); return 2; } const int perm = luaHostDmSendPerm(s_h->id); if (perm != 1) { if (perm == 0) luaHostRequestDmSendPerm(s_h->id, s_h->id); // ask once, never in a loop lua_pushboolean(L, 0); lua_pushstring(L, perm == 0 ? "permission requested" : "permission denied"); return 2; } const uint32_t now = millis(); if (s_h->mesh_last_send_ms && (uint32_t)(now - s_h->mesh_last_send_ms) < kMeshSendMinGapMs) { lua_pushboolean(L, 0); lua_pushstring(L, "too fast"); return 2; } if (const uint32_t wait_s = s_mesh_dm_budget.waitS(now)) { lua_pushboolean(L, 0); lua_pushstring(L, "rate limited"); lua_pushinteger(L, (lua_Integer)wait_s); return 3; } bool was_room = false; const bool ok = luaHostMeshSendDM(to, text, &was_room); if (ok) { s_h->mesh_last_send_ms = now; s_mesh_dm_budget.spend(); } lua_pushboolean(L, ok); lua_pushstring(L, ok ? (was_room ? "room" : "sent") : "no such contact"); return 2; } // wada.mesh.discover([types]) -> tag | false, reason // // Broadcasts a zero-hop discovery request and returns immediately; replies land // in wada.mesh.discovered() over the next few seconds. `types` is an optional // bitmask over wada.mesh.NODE_* (omit for every type). // // Rate-limited harder than a message send, and deliberately so. A message costs // one transmission; a probe costs one transmission plus a reply from every node // that hears it, so an app looping at 1 Hz would saturate a neighbourhood on its // own. 15 seconds is faster than anybody can drive out of a cell and slow enough // that the channel survives it. static const uint32_t kMeshProbeMinGapMs = 15000; // per app int meshDiscover(lua_State* L) { const int types = (int)luaL_optinteger(L, 1, 0); if (!s_h) { lua_pushboolean(L, 0); lua_pushstring(L, "no app"); return 2; } const int perm = luaHostProbePerm(s_h->id); if (perm != 1) { if (perm == 0) luaHostRequestProbePerm(s_h->id, s_h->id); // ask once, never in a loop lua_pushboolean(L, 0); lua_pushstring(L, perm == 0 ? "permission requested" : "permission denied"); return 2; } const uint32_t now = millis(); if (s_h->mesh_last_probe_ms && (uint32_t)(now - s_h->mesh_last_probe_ms) < kMeshProbeMinGapMs) { lua_pushboolean(L, 0); lua_pushstring(L, "too fast"); return 2; } const uint32_t tag = luaHostMeshDiscover(types); if (!tag) { lua_pushboolean(L, 0); lua_pushstring(L, "radio busy"); return 2; } s_h->mesh_last_probe_ms = now; lua_pushinteger(L, (lua_Integer)tag); return 1; } // ---- wada.map: the firmware's own slippy map, inside an app -------------- // // Before this an app could draw geography only on a bare canvas: no basemap, no // projection, no tile cache. Now it gets the real thing -- the same OSM tiles, // the same Web Mercator projection and the same on-disk cache the Map tab uses. // // ONE view per app. Each one owns a pool of up to four decoded 256x256 tiles, // which is 512 KB of PSRAM; letting an app open them in a loop would be a // straightforward way to exhaust the board. :close() disposes of one early // rather than waiting for the collector. struct MapUd { void* v; uint32_t gen; }; MapUd* checkMap(lua_State* L) { MapUd* u = (MapUd*)luaL_checkudata(L, 1, "wada.map"); if (u->gen != s_ui_gen || u->v != s_map_view) u->v = nullptr; return u; } int mapView(lua_State* L) { if (!s_h) return luaL_error(L, "no app"); if (s_map_view) return luaL_error(L, "only one map view at a time (call :close() first)"); const int x = (int)luaL_checkinteger(L, 1), y = (int)luaL_checkinteger(L, 2); const int w = (int)luaL_checkinteger(L, 3), h = (int)luaL_checkinteger(L, 4); luaL_argcheck(L, w > 0 && w <= 800, 3, "width 1..800"); luaL_argcheck(L, h > 0 && h <= 800, 4, "height 1..800"); void* v = luaHostMapCreate(x, y, w, h); if (!v) return luaL_error(L, "map view alloc failed"); MapUd* ud = (MapUd*)lua_newuserdatauv(L, sizeof(MapUd), 0); ud->v = v; ud->gen = s_ui_gen; s_map_view = v; luaL_setmetatable(L, "wada.map"); return 1; } // map:center(lat, lon [, zoom]) -- moves and redraws. int mpCenter(lua_State* L) { MapUd* u = checkMap(L); if (!u->v) return 0; const double lat = luaL_checknumber(L, 2), lon = luaL_checknumber(L, 3); luaHostMapSet(u->v, lat, lon, (int)luaL_optinteger(L, 4, -1)); luaHostMapRender(u->v); return 0; } // map:zoom() -> z | map:zoom(z) -- sets and redraws. int mpZoom(lua_State* L) { MapUd* u = checkMap(L); if (!u->v) { lua_pushinteger(L, 0); return 1; } if (lua_isnumber(L, 2)) { luaHostMapSetZoom(u->v, (int)lua_tointeger(L, 2)); luaHostMapRender(u->v); return 0; } lua_pushinteger(L, luaHostMapZoom(u->v)); return 1; } int mpMarker(lua_State* L) { MapUd* u = checkMap(L); if (!u->v) return 0; lua_pushboolean(L, luaHostMapMarker(u->v, luaL_checknumber(L, 2), luaL_checknumber(L, 3), (uint32_t)luaL_optinteger(L, 4, 0x15B6A6), (int)luaL_optinteger(L, 5, 8))); return 1; // false = the point is outside the view, so nothing was drawn } int mpLine(lua_State* L) { MapUd* u = checkMap(L); if (!u->v) return 0; lua_pushboolean(L, luaHostMapLine(u->v, luaL_checknumber(L, 2), luaL_checknumber(L, 3), luaL_checknumber(L, 4), luaL_checknumber(L, 5), (uint32_t)luaL_optinteger(L, 6, 0x15B6A6), (int)luaL_optinteger(L, 7, 2))); return 1; } int mpClear(lua_State* L) { MapUd* u = checkMap(L); if (u->v) luaHostMapClearOverlay(u->v); return 0; } int mpRedraw(lua_State* L) { MapUd* u = checkMap(L); if (u->v) luaHostMapRender(u->v); return 0; } // map:tiles() -> how many tiles the last redraw actually placed. 0 means this // area is not cached at this zoom: an app should say so rather than present an // empty rectangle as if it were open water. int mpTiles(lua_State* L) { MapUd* u = checkMap(L); lua_pushinteger(L, u->v ? luaHostMapPlaced(u->v) : 0); return 1; } int mpToScreen(lua_State* L) { MapUd* u = checkMap(L); if (!u->v) return 0; int px = 0, py = 0; luaHostMapToScreen(u->v, luaL_checknumber(L, 2), luaL_checknumber(L, 3), &px, &py); lua_pushinteger(L, px); lua_pushinteger(L, py); return 2; } int mpToLatLon(lua_State* L) { MapUd* u = checkMap(L); if (!u->v) return 0; double lat = 0, lon = 0; luaHostMapToLatLon(u->v, (int)luaL_checkinteger(L, 2), (int)luaL_checkinteger(L, 3), &lat, &lon); lua_pushnumber(L, lat); lua_pushnumber(L, lon); return 2; } int mpClose(lua_State* L) { MapUd* u = checkMap(L); if (u->v) { u->v = nullptr; destroyLiveMapView(); } return 0; } int mpGc(lua_State* L) { return mpClose(L); } // wada.mesh.channels() -> { "Public", "MyPrivate", ... } // Names only. The channel secret is never handed to Lua, so an app can post to a // channel the user already has but can neither derive one nor pass it on. int meshChannels(lua_State* L) { static char names[MAX_GROUP_CHANNELS][32]; const int n = luaHostMeshChannelNames(names, MAX_GROUP_CHANNELS); lua_createtable(L, n, 0); for (int i = 0; i < n; i++) { lua_pushstring(L, names[i]); lua_rawseti(L, -2, i + 1); } return 1; } #endif int storeGet(lua_State* L) { lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab"); lua_pushvalue(L, 1); lua_rawget(L, -2); if (lua_isnil(L, -1) && !lua_isnoneornil(L, 2)) { lua_pop(L, 1); lua_pushvalue(L, 2); } return 1; } int storeSet(lua_State* L) { luaL_checkstring(L, 1); if (!lua_isnumber(L, 2) && !lua_isstring(L, 2) && !lua_isnil(L, 2)) return luaL_error(L, "store values must be strings or numbers"); lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab"); lua_pushvalue(L, 1); lua_pushvalue(L, 2); lua_rawset(L, -3); if (s_h) s_h->store_dirty = true; return 0; } #if CAP_LUA_SDK_EXT // ---- wada.fs: scoped file access ------------------------------------------- // Every app gets ONE directory, /apps/.d/, and cannot address anything // outside it. Store apps are user-submitted, so the name check is a security // boundary rather than tidiness: no '/', no '..', no leading dot, and a strict // [A-Za-z0-9._-] whitelist. Rejecting outright beats sanitising -- a sanitiser // that "fixes" ../../identity into something valid is how these go wrong. static const size_t kFsMaxFile = 32u * 1024u; // per file static const uint32_t kFsMinGapMs = 1000; // between writes, per app static bool fsSafeName(const char* n) { if (!n || !*n || n[0] == '.') return false; size_t len = 0; for (const char* p = n; *p; ++p, ++len) { const char c = *p; const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; if (!ok) return false; } return len <= 32; } static bool fsPath(char* out, size_t cap, const char* name) { if (!s_h || !fsSafeName(name)) return false; char rel[96]; snprintf(rel, sizeof rel, "/apps/%s.d/%s", s_h->id, name); luaHostAppPath(out, cap, rel); return true; } static void fsEnsureDir() { fs::FS* fs = luaHostAppFs(); if (!fs || !s_h) return; char d[80], rel[64]; luaHostAppPath(d, sizeof d, "/apps"); fs->mkdir(d); snprintf(rel, sizeof rel, "/apps/%s.d", s_h->id); luaHostAppPath(d, sizeof d, rel); fs->mkdir(d); } // Shared by write and append. Rate-limited on purpose: on internal flash a burst // of small writes triggers garbage collection, and a GC pass suspends the flash // cache and stalls BOTH cores -- the fault behind #222 and the beta_25 bootloop. // An app that ignores the advice in deploy/apps/README.md and writes every frame // gets `false, "too fast"` instead of wedging the device. The guard covers the // case where a write DOES trigger GC, so it stalls briefly instead of rebooting. static int fsWriteCommon(lua_State* L, const char* mode) { const char* name = luaL_checkstring(L, 1); size_t len = 0; const char* data = luaL_checklstring(L, 2, &len); char path[128]; if (!fsPath(path, sizeof path, name)) { lua_pushboolean(L, 0); lua_pushstring(L, "bad name"); return 2; } if (len > kFsMaxFile) { lua_pushboolean(L, 0); lua_pushstring(L, "too big"); return 2; } const uint32_t now = millis(); if (s_h->fs_last_write_ms && (uint32_t)(now - s_h->fs_last_write_ms) < kFsMinGapMs) { lua_pushboolean(L, 0); lua_pushstring(L, "too fast"); return 2; } fs::FS* fs = luaHostAppFs(); if (!fs) { lua_pushboolean(L, 0); lua_pushstring(L, "no storage"); return 2; } fsEnsureDir(); bool ok = false; { WdtHeavyGuard guard; // a GC pass here must stall, not reboot File f = fs->open(path, mode); if (f) { ok = (f.write((const uint8_t*)data, len) == len); f.close(); } } s_h->fs_last_write_ms = now; lua_pushboolean(L, ok); if (!ok) { lua_pushstring(L, "write failed"); return 2; } return 1; } int fsWrite(lua_State* L) { return fsWriteCommon(L, "w"); } int fsAppend(lua_State* L) { return fsWriteCommon(L, "a"); } // wada.fs.read(name [, offset [, len]]) -> data, total_size // // A single read still hands back at most kFsMaxFile, because a Lua string that // large already costs more than the app heap wants to spend. The optional // offset is what makes that a window rather than a ceiling: an append-only log // may grow past it, and a reader walks it in chunks using the returned total. int fsRead(lua_State* L) { const char* name = luaL_checkstring(L, 1); lua_Integer off = luaL_optinteger(L, 2, 0); lua_Integer want = luaL_optinteger(L, 3, (lua_Integer)kFsMaxFile); char path[128]; if (off < 0 || want < 0) { lua_pushnil(L); return 1; } if (!fsPath(path, sizeof path, name)) { lua_pushnil(L); return 1; } fs::FS* fs = luaHostAppFs(); if (!fs) { lua_pushnil(L); return 1; } File f = fs->open(path, "r"); if (!f) { lua_pushnil(L); return 1; } const size_t total = f.size(); if ((size_t)off >= total) { f.close(); lua_pushstring(L, ""); lua_pushinteger(L, (lua_Integer)total); return 2; } if (off && !f.seek((uint32_t)off)) { f.close(); lua_pushnil(L); return 1; } size_t n = total - (size_t)off; if (n > (size_t)want) n = (size_t)want; if (n > kFsMaxFile) n = kFsMaxFile; // never hand Lua an unbounded buffer luaL_Buffer b; char* dst = luaL_buffinitsize(L, &b, n); const size_t got = f.read((uint8_t*)dst, n); f.close(); luaL_pushresultsize(&b, got); lua_pushinteger(L, (lua_Integer)total); return 2; } int fsRemove(lua_State* L) { const char* name = luaL_checkstring(L, 1); char path[128]; fs::FS* fs = luaHostAppFs(); if (!fs || !fsPath(path, sizeof path, name)) { lua_pushboolean(L, 0); return 1; } lua_pushboolean(L, fs->remove(path)); return 1; } int fsList(lua_State* L) { lua_newtable(L); fs::FS* fs = luaHostAppFs(); if (!fs || !s_h) return 1; char d[80], rel[64]; snprintf(rel, sizeof rel, "/apps/%s.d", s_h->id); luaHostAppPath(d, sizeof d, rel); File dir = fs->open(d); if (!dir || !dir.isDirectory()) return 1; int i = 0; for (File e = dir.openNextFile(); e; e = dir.openNextFile()) { if (e.isDirectory()) continue; lua_createtable(L, 0, 2); const char* nm = strrchr(e.name(), '/'); lua_pushstring(L, nm ? nm + 1 : e.name()); lua_setfield(L, -2, "name"); lua_pushinteger(L, (lua_Integer)e.size()); lua_setfield(L, -2, "size"); lua_rawseti(L, -2, ++i); } dir.close(); return 1; } #if CAP_LUA_SD_READ // ---- 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 [, start [, max]]) -> entries | nil,error // entries is an array of { name, type = "file"|"dir", size, mtime }. A directory // bigger than one page is returned a page at a time: entries.truncated = true and // entries.next is the start index of the next page. `max` sets the page size // (1..kSdMaxEntries, default the maximum). Paging exists because the SD Scan app // has to see every file in a folder, and small pages because every entry costs a // file open, so a full 192-entry page of a tile folder stalls the screen. 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); const lua_Integer start = luaL_optinteger(L, 2, 1); luaL_argcheck(L, start >= 1, 2, "start must be 1 or more"); const lua_Integer page = luaL_optinteger(L, 3, kSdMaxEntries); luaL_argcheck(L, page >= 1 && page <= kSdMaxEntries, 3, "max must be 1..192"); 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"); } // Skip to the requested page by reading names only: openNextFile() opens and // stats every entry, which on a tile folder is most of the cost of a page. for (lua_Integer seen = 1; seen < start; ) { const String next = dir.getNextFileName(); if (!next.length()) break; const char* base = strrchr(next.c_str(), '/'); base = base ? base + 1 : next.c_str(); if (base[0]) ++seen; } 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 >= page) { 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"); lua_pushinteger(L, start + count); lua_setfield(L, -2, "next"); } return 1; } #endif // CAP_LUA_SD_READ #if CAP_LUA_SD_LIST // ---- wada.sd.check / wada.sd.remove: Windows malware on the card ----------- // Written for the ThinkNode M9 SD-card worm (Elecrow advisory, Sept 2026). The // policy lives in SdThreat.h and is applied HERE, in firmware: remove() deletes // a file only when the firmware classifies it as a Windows malware carrier, so // an app can find and offer, but can never use this to delete anything else. // Classify one file; *err is set (and None returned) when it cannot be read. static SdThreat::Kind sdClassify(fs::FS* fs, const char* path, const char** err) { *err = nullptr; File f = fs->open(path, "r"); if (!f) { *err = luaHostSdReadFailed() ? "no sd" : "not found"; return SdThreat::None; } if (f.isDirectory()) { f.close(); *err = "not a file"; return SdThreat::None; } const char* base = strrchr(path, '/'); base = base ? base + 1 : path; SdThreat::Kind kind = SdThreat::byName(base); if (kind == SdThreat::None) { // A Windows program under a harmless name. Stack buffers on purpose: SD // transfers need DMA-capable memory, which PSRAM is not. uint8_t head[SdThreat::kHeadBytes]; const size_t size = f.size(); if (size >= sizeof head && f.read(head, sizeof head) == sizeof head && SdThreat::mzHeader(head, sizeof head)) { const uint32_t off = SdThreat::peOffset(head); uint8_t sig[4]; if (SdThreat::peOffsetPlausible(off, (uint32_t)size) && f.seek(off) && f.read(sig, sizeof sig) == sizeof sig && SdThreat::peSignature(sig, sizeof sig)) kind = SdThreat::RenamedProgram; } } f.close(); return kind; } static bool sdFilePath(lua_State* L, char* path, size_t cap) { size_t len = 0; const char* requested = luaL_checklstring(L, 1, &len); return sdSafePath(requested, len, path, cap) && strcmp(path, "/") != 0; } // wada.sd.check(path) -> reason | false | nil,error // reason names the threat ("autorun file", "Windows program", "renamed Windows // program", ...); false means the file is not one. int sdCheck(lua_State* L) { char path[kSdMaxPath]; if (!sdFilePath(L, 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"); const char* err = nullptr; const SdThreat::Kind kind = sdClassify(fs, path, &err); if (err) return sdListError(L, err); if (kind == SdThreat::None) lua_pushboolean(L, 0); else lua_pushstring(L, SdThreat::label(kind)); return 1; } // wada.sd.remove(path) -> reason | nil,error // Deletes the file only if check() would name it; "not a threat" otherwise. // Directories are never removed. Worms mark their files read-only, hidden and // system, and FAT refuses to delete a read-only file, so those attributes are // cleared first -- on a file already judged a threat, never on anything else. int sdRemove(lua_State* L) { char path[kSdMaxPath]; if (!sdFilePath(L, 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"); const char* err = nullptr; const SdThreat::Kind kind = sdClassify(fs, path, &err); if (err) return sdListError(L, err); if (kind == SdThreat::None) return sdListError(L, "not a threat"); if (!fs->remove(path)) { luaHostSdClearAttributes(path); if (!fs->remove(path)) return sdListError(L, luaHostSdReadFailed() ? "no sd" : "remove failed"); } Serial.printf("[lua] sd.remove %s (%s)\n", path, SdThreat::label(kind)); lua_pushstring(L, SdThreat::label(kind)); return 1; } #endif #endif // CAP_LUA_SDK_EXT #if CAP_LUA_AUDIO static bool audioSafeName(const char* name, size_t len) { if (!name || len == 0 || len > 32 || name[0] == '.') return false; for (size_t i = 0; i < len; ++i) { const char c = name[i]; const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; if (!ok) return false; } return true; } static int audioError(lua_State* L, const char* error) { lua_pushnil(L); lua_pushstring(L, error); return 2; } // wada.audio.play(name) resolves inside /apps/.d on the board's active // storage backend. An explicit sd:/... path is available only with sd_list. int audioPlay(lua_State* L) { if (!s_h) return audioError(L, "closed"); size_t requested_len = 0; const char* requested = luaL_checklstring(L, 1, &requested_len); fs::FS* fs = nullptr; char path[224] = ""; const char* source = "app"; if (requested_len >= 3 && !memcmp(requested, "sd:", 3)) { #if CAP_LUA_SD_READ const char* card_path = requested + 3; const size_t card_len = requested_len - 3; if (!sdSafePath(card_path, card_len, path, sizeof path)) return audioError(L, "bad path"); bool busy = false; fs = luaHostSdFs(&busy); if (!fs) return audioError(L, busy ? "busy" : "no sd"); source = "sd"; #else return audioError(L, "no sd"); #endif } else { if (!audioSafeName(requested, requested_len)) return audioError(L, "bad path"); fs = luaHostAppFs(); if (!fs) return audioError(L, "no storage"); char rel[96]; snprintf(rel, sizeof rel, "/apps/%s.d/%s", s_h->id, requested); luaHostAppPath(path, sizeof path, rel); } char error[40] = ""; if (!luaHostAudioPlay(fs, path, requested, source, s_h->generation, error, sizeof error)) return audioError(L, error[0] ? error : "playback failed"); lua_pushboolean(L, 1); return 1; } int audioPause(lua_State* L) { lua_pushboolean(L, s_h && luaHostAudioPause(s_h->generation, true)); return 1; } int audioResume(lua_State* L) { lua_pushboolean(L, s_h && luaHostAudioPause(s_h->generation, false)); return 1; } int audioStop(lua_State* L) { lua_pushboolean(L, s_h && luaHostAudioStop(s_h->generation, false)); return 1; } int audioStatus(lua_State* L) { char state[12], path[192], source[8], format[8], error[40]; luaHostAudioStatus(s_h ? s_h->generation : 0, state, sizeof state, path, sizeof path, source, sizeof source, format, sizeof format, error, sizeof error); lua_createtable(L, 0, 5); lua_pushstring(L, state); lua_setfield(L, -2, "state"); lua_pushstring(L, path); lua_setfield(L, -2, "path"); lua_pushstring(L, source); lua_setfield(L, -2, "source"); lua_pushstring(L, format); lua_setfield(L, -2, "format"); if (error[0]) { lua_pushstring(L, error); lua_setfield(L, -2, "error"); } return 1; } #endif void storePath(char* out, size_t cap) { char rel[40]; snprintf(rel, sizeof rel, "/apps/%s.sav", s_h->id); luaHostAppPath(out, cap, rel); } void storeLoad() { lua_State* L = s_h->L; lua_newtable(L); lua_setfield(L, LUA_REGISTRYINDEX, "wada.storetab"); fs::FS* fs = luaHostAppFs(); if (!fs) return; char path[64]; storePath(path, sizeof path); File f = fs->open(path, "r"); if (!f) return; lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab"); char line[192]; size_t n; while ((n = f.readBytesUntil('\n', line, sizeof line - 1)) > 0) { line[n] = 0; char* eq = strchr(line, '='); if (!eq) continue; *eq = 0; lua_pushstring(L, line); lua_Number num; char* end = nullptr; num = strtod(eq + 1, &end); if (end && *end == 0 && end != eq + 1) lua_pushnumber(L, num); else lua_pushstring(L, eq + 1); lua_rawset(L, -3); } lua_pop(L, 1); f.close(); } void storeFlush() { if (!s_h || !s_h->store_dirty) return; fs::FS* fs = luaHostAppFs(); if (!fs) return; lua_State* L = s_h->L; char path[64]; storePath(path, sizeof path); char appsdir[48]; luaHostAppPath(appsdir, sizeof appsdir, "/apps"); fs->mkdir(appsdir); File f = fs->open(path, "w"); if (!f) return; size_t written = 0; lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab"); lua_pushnil(L); while (lua_next(L, -2)) { if (lua_type(L, -2) == LUA_TSTRING && (lua_isstring(L, -1) || lua_isnumber(L, -1))) { char lbuf[192]; int m = snprintf(lbuf, sizeof lbuf, "%s=%s\n", lua_tostring(L, -2), luaL_tolstring(L, -1, nullptr)); lua_pop(L, 1); // luaL_tolstring's copy if (m > 0 && written + (size_t)m <= kStoreMax) { f.write((const uint8_t*)lbuf, m); written += m; } } lua_pop(L, 1); } lua_pop(L, 1); f.close(); s_h->store_dirty = false; } #if CAP_LUA_SDK_EXT // ---- Home widget (wada.widget) ---- // What each app last published for its Home widget. Kept outside the Host, so Home // still has it after the app closes, and written to /apps/.wgt when the // app does. Read lazily: the first look from Home loads the file once. struct WidgetSlot { char id[24]; LuaWidgetSnap snap; uint32_t ms; bool have, dirty, probed; }; static const int kWgtSlots = 8; static const uint8_t kWgtFileVer = 1; static WidgetSlot* s_wgt = nullptr; // PSRAM, kWgtSlots of them WidgetSlot* wgtSlot(const char* id, bool make) { if (!id || !id[0]) return nullptr; if (!s_wgt) { s_wgt = (WidgetSlot*)heap_caps_calloc(kWgtSlots, sizeof(WidgetSlot), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!s_wgt) return nullptr; } WidgetSlot* free_slot = nullptr; for (int k = 0; k < kWgtSlots; ++k) { if (s_wgt[k].id[0] && !strcmp(s_wgt[k].id, id)) return &s_wgt[k]; if (!free_slot && !s_wgt[k].id[0]) free_slot = &s_wgt[k]; } if (!make) return nullptr; if (!free_slot) free_slot = &s_wgt[0]; // more than eight apps with widgets: the first gives way memset(free_slot, 0, sizeof *free_slot); snprintf(free_slot->id, sizeof free_slot->id, "%s", id); free_slot->snap.bar = -1; return free_slot; } void wgtPath(char* out, size_t cap, const char* id) { char rel[48]; snprintf(rel, sizeof rel, "/apps/%s.wgt", id); luaHostAppPath(out, cap, rel); } WidgetSlot* wgtLoad(const char* id) { WidgetSlot* w = wgtSlot(id, true); if (!w || w->probed || w->have) return w; w->probed = true; fs::FS* fs = luaHostAppFs(); if (!fs) return w; char path[64]; wgtPath(path, sizeof path, id); File f = fs->open(path, "r"); if (!f) return w; uint8_t ver = 0; LuaWidgetSnap sn; if (f.read(&ver, 1) == 1 && ver == kWgtFileVer && f.read((uint8_t*)&sn, sizeof sn) == sizeof sn) { sn.title[sizeof sn.title - 1] = 0; sn.value[sizeof sn.value - 1] = 0; sn.line[sizeof sn.line - 1] = 0; if (sn.spark_n > LUA_WIDGET_SPARK_MAX) sn.spark_n = LUA_WIDGET_SPARK_MAX; w->snap = sn; w->have = true; } f.close(); return w; } void wgtFlush(const char* id) { WidgetSlot* w = wgtSlot(id, false); if (!w || !w->dirty) return; fs::FS* fs = luaHostAppFs(); if (!fs) return; char path[64]; wgtPath(path, sizeof path, id); w->dirty = false; if (!w->have) { fs->remove(path); return; } File f = fs->open(path, "w"); if (!f) return; f.write(&kWgtFileVer, 1); f.write((const uint8_t*)&w->snap, sizeof w->snap); f.close(); } // wada.widget.set{ title=, value=, line=, bar=0..1, spark={...} } -> true, or false when // called again within a second (ignored). The spark is up to 24 numbers, oldest first, // drawn to fit their own range. int widgetSet(lua_State* L) { if (!s_h) return 0; luaL_checktype(L, 1, LUA_TTABLE); WidgetSlot* w = wgtLoad(s_h->id); const uint32_t now = millis(); if (!w || (w->ms && now - w->ms < 1000)) { lua_pushboolean(L, 0); return 1; } LuaWidgetSnap& sn = w->snap; auto field = [&](const char* k, char* out, size_t cap) { out[0] = 0; lua_getfield(L, 1, k); if (lua_type(L, -1) == LUA_TSTRING || lua_type(L, -1) == LUA_TNUMBER) { size_t n = 0; const char* t = lua_tolstring(L, -1, &n); size_t sl = 0; const char* safe = safeUiText(t, n, &sl); snprintf(out, cap, "%.*s", (int)sl, safe); } lua_pop(L, 1); }; field("title", sn.title, sizeof sn.title); field("value", sn.value, sizeof sn.value); field("line", sn.line, sizeof sn.line); lua_getfield(L, 1, "bar"); sn.bar = lua_isnumber(L, -1) ? (int16_t)LV_MIN(1000, LV_MAX(0, (int)(lua_tonumber(L, -1) * 1000.0f))) : (int16_t)-1; lua_pop(L, 1); sn.spark_n = 0; lua_getfield(L, 1, "spark"); if (lua_istable(L, -1)) { const int n = (int)lua_rawlen(L, -1); const int from = n > LUA_WIDGET_SPARK_MAX ? n - LUA_WIDGET_SPARK_MAX + 1 : 1; for (int k = from; k <= n; ++k) { lua_rawgeti(L, -1, k); sn.spark[sn.spark_n++] = lua_isnumber(L, -1) ? (int16_t)LV_MAX(-32000, LV_MIN(32000, (int)lua_tonumber(L, -1))) : (int16_t)INT16_MIN; lua_pop(L, 1); } } lua_pop(L, 1); const time_t t = time(nullptr); sn.epoch = t > 1577836800 ? (uint32_t)t : 0; // 2020-01-01: before that the clock is not set w->ms = now ? now : 1; w->have = true; w->dirty = true; lua_pushboolean(L, 1); return 1; } // wada.widget.clear(): the app's Home widget goes back to its name and nothing else. int widgetClear(lua_State* L) { (void)L; if (!s_h) return 0; WidgetSlot* w = wgtSlot(s_h->id, true); if (w) { w->have = false; w->dirty = true; w->ms = 0; } return 0; } #endif // ---- input plumbing ---- void sendInput(const char* type, const char* dir, int x, int y) { if (!s_h || !s_h->L) return; if (!pushCallback(s_h, "on_input")) return; lua_State* L = s_h->L; lua_createtable(L, 0, 4); lua_pushstring(L, type); lua_setfield(L, -2, "type"); if (dir) { lua_pushstring(L, dir); lua_setfield(L, -2, "dir"); } lua_pushinteger(L, x); lua_setfield(L, -2, "x"); lua_pushinteger(L, y); lua_setfield(L, -2, "y"); guardedCall(s_h, 1); serviceDeferredClose(); } // Key delivery reuses on_input rather than adding an on_key callback: an app // already branches on ev.type, and one entry point keeps a Lua app's input model // to a single place. type="key", with `key` as a one-character string for // printable input and a name ("up", "enter", ...) for the rest, so an app can // write ev.key == "w" without knowing scancodes. void sendKey(int key) { if (!s_h || !s_h->L) return; if (!pushCallback(s_h, "on_input")) return; lua_State* L = s_h->L; lua_createtable(L, 0, 2); lua_pushstring(L, "key"); lua_setfield(L, -2, "type"); char buf[8]; const char* named = nullptr; switch (key) { case LV_KEY_UP: named = "up"; break; case LV_KEY_DOWN: named = "down"; break; case LV_KEY_LEFT: named = "left"; break; case LV_KEY_RIGHT: named = "right"; break; case LV_KEY_ENTER: case '\r': named = "enter"; break; // '\r': physical keyboards send CR, not LV_KEY_ENTER case LV_KEY_ESC: named = "esc"; break; case '\b': case 127: named = "backspace"; break; default: break; } if (named) { lua_pushstring(L, named); } else if (key >= 32 && key < 127) { buf[0] = (char)key; buf[1] = '\0'; lua_pushstring(L, buf); } else { lua_pushnil(L); } lua_setfield(L, -2, "key"); lua_pushinteger(L, key); lua_setfield(L, -2, "code"); // raw, for anything unmapped guardedCall(s_h, 1); serviceDeferredClose(); } void gestureCb(lv_event_t* e) { lv_dir_t d = lv_indev_get_gesture_dir(lv_indev_get_act()); const char* dir = (d == LV_DIR_TOP) ? "up" : (d == LV_DIR_BOTTOM) ? "down" : (d == LV_DIR_LEFT) ? "left" : (d == LV_DIR_RIGHT) ? "right" : nullptr; if (dir) sendInput("swipe", dir, 0, 0); (void)e; } void pressCb(lv_event_t* e) { lv_indev_t* indev = lv_indev_get_act(); lv_point_t p{0, 0}; if (indev) lv_indev_get_point(indev, &p); lv_area_t a; lv_obj_get_coords(s_h->body, &a); // Content coordinates, not viewport ones: once an app has turned on // ui.scroll() and the body is scrolled, a hit-test against where the app // PLACED its widgets only works if the scroll offset is folded in. sendInput(lv_event_get_code(e) == LV_EVENT_PRESSED ? "down" : "up", nullptr, p.x - a.x1 + lv_obj_get_scroll_x(s_h->body), p.y - a.y1 + lv_obj_get_scroll_y(s_h->body)); } // ---- wada.mesh (read-only) ---- // wada.mesh.contacts([offset], [limit]) // // Used to scan the first 200 contacts and return at most 100, with no way to see // past that and nothing to say it had truncated. On a device with 2000 contacts // an app simply could not reach most of them (reported by Jade). // // It takes an offset and a limit now. The default limit stays 100 because every // entry is an 8-field Lua table and building thousands of them in one call would // blow the per-app heap -- but the window can be moved, so all contacts are // reachable by asking twice. wada.mesh.contact_count() says how many there are. // How many contacts the device holds, so an app can page through them without // calling contacts() repeatedly just to discover where the list ends. static int meshContactCount(lua_State* L) { char name[36], pk[12]; int type; uint32_t ago; double lat, lon; int32_t lat6, lon6; int n = 0; while (n < 4096 && luaHostContactAt(n, name, sizeof name, &type, &ago, &lat, &lon, pk, sizeof pk, &lat6, &lon6)) ++n; lua_pushinteger(L, n); return 1; } int meshContacts(lua_State* L) { const int offset = (int)luaL_optinteger(L, 1, 0); int limit = (int)luaL_optinteger(L, 2, 100); if (limit < 1) limit = 1; if (limit > 250) limit = 250; // one call still has to fit the app's heap lua_newtable(L); char name[36], pk[12]; int type; uint32_t ago; double lat, lon; int32_t lat6, lon6; for (int i = (offset > 0 ? offset : 0), out = 0; out < limit; i++) { if (!luaHostContactAt(i, name, sizeof name, &type, &ago, &lat, &lon, pk, sizeof pk, &lat6, &lon6)) break; lua_createtable(L, 0, 8); lua_pushstring(L, name); lua_setfield(L, -2, "name"); lua_pushstring(L, pk); lua_setfield(L, -2, "pubkey"); lua_pushinteger(L, type); lua_setfield(L, -2, "type"); lua_pushinteger(L, (lua_Integer)ago); lua_setfield(L, -2, "ago_s"); lua_pushnumber(L, lat); lua_setfield(L, -2, "lat"); lua_pushnumber(L, lon); lua_setfield(L, -2, "lon"); lua_pushinteger(L, lat6); lua_setfield(L, -2, "lat_e6"); lua_pushinteger(L, lon6); lua_setfield(L, -2, "lon_e6"); lua_rawseti(L, -2, ++out); } return 1; } // wada.mesh.rx_log() -> newest-first list of frames the radio actually received. // // `pubkey` is set ONLY on an advert (type 4), where the frame really does carry // the sender's public key -- that is the entry a coverage survey wants, since an // advert both identifies a node and proves it was audible from here. Addressed // frames instead expose `src`/`dst`, the one-byte hashes they carry; two hex // characters collide easily, so treat them as a hint, not an identity. Anything // else is anonymous on the wire and gets neither field. int meshRxLog(lua_State* L) { lua_newtable(L); uint32_t ms_ago; int ptype, rssi, hops, route, flen, org_kind; float snr; char org[12]; for (int i = 0, out = 0; i < 64; i++) { if (!luaHostRxLogAt(i, &ms_ago, &ptype, &rssi, &snr, &hops, &route, &flen, &org_kind, org, sizeof org)) break; lua_createtable(L, 0, 9); lua_pushinteger(L, (lua_Integer)ms_ago); lua_setfield(L, -2, "ago_ms"); lua_pushinteger(L, ptype); lua_setfield(L, -2, "type"); lua_pushinteger(L, rssi); lua_setfield(L, -2, "rssi"); lua_pushnumber(L, snr); lua_setfield(L, -2, "snr"); lua_pushinteger(L, hops); lua_setfield(L, -2, "hops"); lua_pushinteger(L, route); lua_setfield(L, -2, "route"); lua_pushinteger(L, flen); lua_setfield(L, -2, "len"); if (org_kind == 1) { lua_pushstring(L, org); lua_setfield(L, -2, "pubkey"); } else if (org_kind == 2 && strlen(org) >= 4) { lua_pushlstring(L, org, 2); lua_setfield(L, -2, "dst"); lua_pushlstring(L, org + 2, 2); lua_setfield(L, -2, "src"); } lua_rawseti(L, -2, ++out); } return 1; } // ---- app.on_packet: push instead of poll ----------------------------------- // Polling rx_log() on a 1 Hz tick samples a 16-deep ring, so in any real traffic // an app sees a subset and cannot count anything. This delivers each frame once, // in arrival order, from a timer fast enough that the ring cannot wrap between // passes -- the shortest LoRa frame is tens of milliseconds of airtime, so 16 // frames cannot arrive inside 250 ms on any supported setting. // // No new hook in the packet path: Dispatcher::checkRecv (and so logRxRaw) runs // inside the_mesh.loop(), the same Arduino task as LVGL, but running Lua there // would put an app's code inside packet reception. Draining on a timer keeps // app code where all the other callbacks are. // // Ungated, exactly like rx_log(): this is radio metadata about frames the // device already received, not message content. Anything carrying content still // goes through on_message and its permissions. lv_timer_t* s_pkt_poll = nullptr; uint32_t s_pkt_last_ms = 0; // newest record timestamp already delivered bool s_pkt_primed = false; void pushPacketTable(lua_State* L, uint32_t ago_ms, int ptype, int rssi, float snr, int hops, int route, int flen, int org_kind, const char* org) { lua_createtable(L, 0, 9); lua_pushinteger(L, (lua_Integer)ago_ms); lua_setfield(L, -2, "ago_ms"); lua_pushinteger(L, ptype); lua_setfield(L, -2, "type"); lua_pushinteger(L, rssi); lua_setfield(L, -2, "rssi"); lua_pushnumber(L, snr); lua_setfield(L, -2, "snr"); lua_pushinteger(L, hops); lua_setfield(L, -2, "hops"); lua_pushinteger(L, route); lua_setfield(L, -2, "route"); lua_pushinteger(L, flen); lua_setfield(L, -2, "len"); if (org_kind == 1) { lua_pushstring(L, org); lua_setfield(L, -2, "pubkey"); } else if (org_kind == 2 && strlen(org) >= 4) { lua_pushlstring(L, org, 2); lua_setfield(L, -2, "dst"); lua_pushlstring(L, org + 2, 2); lua_setfield(L, -2, "src"); } } #if CAP_LUA_AUDIO // ---- app.on_audio: playback state changes, screen on or off ----------------- // A music player has to start the next track when one ends, and that usually // happens with the screen dark, when on_tick and repeating timers are paused on // purpose. Polling audio.status() from a tick therefore stalled a playlist at the // end of every track until the screen woke. This delivers each change of state // (playing, paused, ended, error, stopped) once, whether or not the screen is on. // Only apps that declare on_audio get the poll, like on_packet. lv_timer_t* s_audio_poll = nullptr; char s_audio_last_state[12] = ""; void audioPollCb(lv_timer_t* t) { (void)t; if (!s_h || !s_h->L) return; char state[12] = "", path[200] = "", source[8] = "", format[8] = "", error[40] = ""; luaHostAudioStatus(s_h->generation, state, sizeof state, path, sizeof path, source, sizeof source, format, sizeof format, error, sizeof error); if (!strcmp(state, s_audio_last_state)) return; snprintf(s_audio_last_state, sizeof s_audio_last_state, "%s", state); if (!pushCallback(s_h, "on_audio")) return; lua_State* L = s_h->L; lua_createtable(L, 0, 3); lua_pushstring(L, state); lua_setfield(L, -2, "state"); lua_pushstring(L, path); lua_setfield(L, -2, "path"); if (error[0]) { lua_pushstring(L, error); lua_setfield(L, -2, "error"); } guardedCall(s_h, 1); serviceDeferredClose(); } #endif void packetPollCb(lv_timer_t* t) { (void)t; if (!s_h || !s_h->L) return; // Snapshot the ring newest-first, then deliver oldest-first so an app sees // arrival order. 16 is the ring depth; reading past it just stops. struct Rec { uint32_t at, ago; int ptype, rssi, hops, route, len, kind; float snr; char org[12]; }; Rec recs[16]; int n = 0; uint32_t newest = s_pkt_last_ms; for (int i = 0; i < 16; i++) { Rec r; if (!luaHostRxLogAt(i, &r.ago, &r.ptype, &r.rssi, &r.snr, &r.hops, &r.route, &r.len, &r.kind, r.org, sizeof r.org, &r.at)) break; // millis() wraps at 49 days; compare as a signed delta so the wrap does not // silently stop delivery for the rest of the session. if ((int32_t)(r.at - s_pkt_last_ms) <= 0) break; // already delivered, and so is everything older if ((int32_t)(r.at - newest) > 0) newest = r.at; if (n < 16) recs[n++] = r; } s_pkt_last_ms = newest; if (!s_pkt_primed) { s_pkt_primed = true; return; } // first pass only establishes the mark for (int i = n - 1; i >= 0; i--) { // oldest first if (!s_h || !s_h->L) return; if (!pushCallback(s_h, "on_packet")) return; pushPacketTable(s_h->L, recs[i].ago, recs[i].ptype, recs[i].rssi, recs[i].snr, recs[i].hops, recs[i].route, recs[i].len, recs[i].kind, recs[i].org); guardedCall(s_h, 1); serviceDeferredClose(); } } int meshStats(lua_State* L) { float rssi, noise; uint32_t rx_air, tx_air, rx_pkts, rx_err; int budget; luaHostRadioStats(&rssi, &noise, &rx_air, &tx_air, &rx_pkts, &rx_err, &budget); lua_createtable(L, 0, 7); lua_pushnumber(L, rssi); lua_setfield(L, -2, "rssi"); lua_pushnumber(L, noise); lua_setfield(L, -2, "noise"); lua_pushinteger(L, (lua_Integer)rx_air); lua_setfield(L, -2, "rx_air_s"); lua_pushinteger(L, (lua_Integer)tx_air); lua_setfield(L, -2, "tx_air_s"); lua_pushinteger(L, (lua_Integer)rx_pkts); lua_setfield(L, -2, "rx_pkts"); lua_pushinteger(L, (lua_Integer)rx_err); lua_setfield(L, -2, "rx_err"); lua_pushinteger(L, budget); lua_setfield(L, -2, "tx_budget_ms"); uint32_t rx_evt, rx_drop, tx_pkts; float freq, bw; int sf, duty; luaHostRadioStats2(&rx_evt, &rx_drop, &tx_pkts, &freq, &bw, &sf, &duty); lua_pushinteger(L, (lua_Integer)rx_evt); lua_setfield(L, -2, "rx_events"); lua_pushinteger(L, (lua_Integer)rx_drop); lua_setfield(L, -2, "rx_dropped"); lua_pushinteger(L, (lua_Integer)tx_pkts); lua_setfield(L, -2, "tx_pkts"); lua_pushnumber(L, freq); lua_setfield(L, -2, "freq"); lua_pushnumber(L, bw); lua_setfield(L, -2, "bw"); lua_pushinteger(L, sf); lua_setfield(L, -2, "sf"); lua_pushinteger(L, duty); lua_setfield(L, -2, "duty_pct"); return 1; } int meshSelf(lua_State* L) { char name[36], pk[12]; double lat, lon; int32_t lat6, lon6; luaHostSelfInfo(name, sizeof name, &lat, &lon, pk, sizeof pk, &lat6, &lon6); lua_createtable(L, 0, 6); lua_pushstring(L, name); lua_setfield(L, -2, "name"); lua_pushstring(L, pk); lua_setfield(L, -2, "pubkey"); lua_pushnumber(L, lat); lua_setfield(L, -2, "lat"); lua_pushnumber(L, lon); lua_setfield(L, -2, "lon"); lua_pushinteger(L, lat6); lua_setfield(L, -2, "lat_e6"); lua_pushinteger(L, lon6); lua_setfield(L, -2, "lon_e6"); return 1; } // wada.mesh.discovered() -> everything that has answered a probe this session. // // Each entry carries BOTH link directions: `snr` is how well we heard them, // `their_snr` how well they heard us. Asymmetry is the normal case (their // antenna and power are not ours) and it is the single most useful number a // coverage survey can record -- "I can hear the repeater but it cannot hear me" // is a different fact from "no coverage", and only a probe reply reveals it. int meshDiscovered(lua_State* L) { lua_newtable(L); char pk[12], name[36]; int type, rssi, hops, heard; float snr, their_snr; uint32_t first_ago, last_ago; const int n = luaHostDiscoverCount(); for (int i = 0, out = 0; i < n; i++) { if (!luaHostDiscoverAt(i, pk, sizeof pk, name, sizeof name, &type, &rssi, &snr, &their_snr, &hops, &first_ago, &last_ago, &heard)) break; lua_createtable(L, 0, 10); lua_pushstring(L, pk); lua_setfield(L, -2, "pubkey"); if (name[0]) { lua_pushstring(L, name); lua_setfield(L, -2, "name"); } lua_pushinteger(L, type); lua_setfield(L, -2, "type"); lua_pushinteger(L, rssi); lua_setfield(L, -2, "rssi"); lua_pushnumber(L, snr); lua_setfield(L, -2, "snr"); lua_pushnumber(L, their_snr); lua_setfield(L, -2, "their_snr"); lua_pushinteger(L, hops); lua_setfield(L, -2, "hops"); lua_pushboolean(L, hops == 0); lua_setfield(L, -2, "direct"); lua_pushinteger(L, (lua_Integer)first_ago); lua_setfield(L, -2, "first_ms_ago"); lua_pushinteger(L, (lua_Integer)last_ago); lua_setfield(L, -2, "last_ms_ago"); lua_pushinteger(L, heard); lua_setfield(L, -2, "heard"); lua_rawseti(L, -2, ++out); } return 1; } // wada.mesh.discover_clear() -> forget every hit so far. // A survey calls this between locations: without it, a node heard two streets // back is still in the table and the next sample claims coverage it does not // have. Ungated -- it only discards our own local observations. int meshDiscoverClear(lua_State* L) { (void)L; luaHostDiscoverClear(); return 0; } // ---- wada.net: one in-flight async http_get per app ---- constexpr size_t kNetCapMax = 32 * 1024; char s_net_url[160]; char* s_net_buf = nullptr; size_t s_net_cap = 0; char* s_net_body = nullptr; // POST payload (null = GET), PSRAM size_t s_net_body_len = 0; char s_net_ctype[64] = ""; int s_net_result = -1; volatile bool s_net_pending = false; // worker should run it volatile bool s_net_done = false; // result ready for the UI thread int s_net_cb = LUA_NOREF; lv_timer_t* s_net_poll = nullptr; void netDeliver(lv_timer_t* t) { (void)t; if (!s_net_done) return; s_net_done = false; if (s_net_poll) { lv_timer_del(s_net_poll); s_net_poll = nullptr; } if (s_net_body) { heap_caps_free(s_net_body); s_net_body = nullptr; s_net_body_len = 0; } if (!s_h || !s_h->L || s_net_cb == LUA_NOREF) { s_net_cb = LUA_NOREF; return; } lua_State* L = s_h->L; lua_rawgeti(L, LUA_REGISTRYINDEX, s_net_cb); luaL_unref(L, LUA_REGISTRYINDEX, s_net_cb); s_net_cb = LUA_NOREF; lua_pushinteger(L, s_net_result >= 0 ? 200 : -1); if (s_net_result >= 0 && s_net_buf) lua_pushlstring(L, s_net_buf, (size_t)s_net_result); else lua_pushnil(L); guardedCall(s_h, 2); serviceDeferredClose(); } // ---- wada.ui.input(title, initial, cb) -------------------------------------- // One modal text field, using the same dialog and on-screen keyboard the rest of // the firmware uses -- so it behaves identically on a touchscreen, on the // Tanmatsu's physical keyboard and on a trackball board, which an app drawing // its own field could not manage. // // cb(text) on OK, cb(nil) on cancel or on the dialog being closed any other way. // Exactly one call, always: an app that disabled itself while waiting for input // will always get the chance to re-enable. void promptDeliver(const char* text) { if (!s_h || !s_h->L || s_h->prompt_cb == LUA_NOREF) return; lua_State* L = s_h->L; lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->prompt_cb); luaL_unref(L, LUA_REGISTRYINDEX, s_h->prompt_cb); s_h->prompt_cb = LUA_NOREF; if (text) lua_pushstring(L, text); else lua_pushnil(L); guardedCall(s_h, 1); serviceDeferredClose(); } // wada.ui.emoji(cb) -- open the emoji picker and hand the chosen glyph back as a // UTF-8 string, or nil if it was dismissed. The long-press Cut/Copy/Paste/Sym/ // Emoji menu covers the firmware's own field, which is what wada.ui.input() // opens; this is for an app that draws its own text entry and keeps the buffer // itself, where there is no textarea to long-press. Asked for by Jade, whose // composer shows a pad-bytes counter the firmware's field has no way to show. int emoji_cb_ref = LUA_NOREF; void emojiDeliver(const char* utf8) { if (!s_h || !s_h->L || emoji_cb_ref == LUA_NOREF) return; lua_State* L = s_h->L; lua_rawgeti(L, LUA_REGISTRYINDEX, emoji_cb_ref); luaL_unref(L, LUA_REGISTRYINDEX, emoji_cb_ref); emoji_cb_ref = LUA_NOREF; if (utf8) lua_pushstring(L, utf8); else lua_pushnil(L); guardedCall(s_h, 1); serviceDeferredClose(); } int uiEmoji(lua_State* L) { luaL_checktype(L, 1, LUA_TFUNCTION); if (!s_h) return luaL_error(L, "no app"); if (emoji_cb_ref != LUA_NOREF) return luaL_error(L, "the emoji picker is already open"); lua_pushvalue(L, 1); emoji_cb_ref = luaL_ref(L, LUA_REGISTRYINDEX); luaHostEmojiPick(emojiDeliver); return 0; } int uiInput(lua_State* L) { size_t title_length = 0, initial_length = 0; const char* title_raw = luaL_optlstring(L, 1, "", &title_length); const char* initial_raw = luaL_optlstring(L, 2, "", &initial_length); luaL_checktype(L, 3, LUA_TFUNCTION); if (!s_h) return luaL_error(L, "no app"); if (s_h->prompt_cb != LUA_NOREF) return luaL_error(L, "an input prompt is already open"); const char* title = safeUiText(title_raw, title_length, nullptr, 0); const char* initial = safeUiText(initial_raw, initial_length, nullptr, 1); lua_pushvalue(L, 3); s_h->prompt_cb = luaL_ref(L, LUA_REGISTRYINDEX); luaHostTextPrompt(title, initial, promptDeliver); return 0; } // wada.net.http_post(url, body [, content_type], cb [, max_reply]) // // The counterpart to http_get, and the call that lets an app get data OFF the // device: a survey log, a sensor series, a webhook. Same single-in-flight rule // and same http:// restriction as http_get -- on-device TLS is not workable at // the heap these boards have left once Wi-Fi has associated. int netHttpPost(lua_State* L) { const char* url = luaL_checkstring(L, 1); size_t blen = 0; const char* body = luaL_checklstring(L, 2, &blen); int argi = 3; const char* ctype = "application/octet-stream"; if (lua_isstring(L, argi) && !lua_isfunction(L, argi)) ctype = lua_tostring(L, argi++); luaL_checktype(L, argi, LUA_TFUNCTION); size_t maxb = (size_t)luaL_optinteger(L, argi + 1, 4 * 1024); if (maxb > kNetCapMax) maxb = kNetCapMax; if (strncmp(url, "http://", 7) != 0) return luaL_error(L, "http:// urls only"); if (strlen(url) >= sizeof(s_net_url)) return luaL_error(L, "url too long"); if (blen == 0 || blen > kNetCapMax) return luaL_error(L, "bad body length"); if (s_net_pending || s_net_cb != LUA_NOREF) return luaL_error(L, "a fetch is already running"); if (!s_net_buf || s_net_cap < maxb + 1) { if (s_net_buf) heap_caps_free(s_net_buf); s_net_buf = (char*)heap_caps_malloc(maxb + 1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); s_net_cap = s_net_buf ? maxb + 1 : 0; if (!s_net_buf) return luaL_error(L, "no memory for the reply buffer"); } // The worker runs on another task, so the payload cannot stay a Lua string: // a collection between here and the send would pull it out from under it. if (s_net_body) heap_caps_free(s_net_body); s_net_body = (char*)heap_caps_malloc(blen, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!s_net_body) { s_net_body_len = 0; return luaL_error(L, "no memory for the body"); } memcpy(s_net_body, body, blen); s_net_body_len = blen; snprintf(s_net_ctype, sizeof s_net_ctype, "%s", ctype); snprintf(s_net_url, sizeof s_net_url, "%s", url); lua_pushvalue(L, argi); s_net_cb = luaL_ref(L, LUA_REGISTRYINDEX); s_net_done = false; s_net_pending = true; if (!s_net_poll) s_net_poll = lv_timer_create(netDeliver, 120, nullptr); return 0; } int netHttpGet(lua_State* L) { const char* url = luaL_checkstring(L, 1); luaL_checktype(L, 2, LUA_TFUNCTION); size_t maxb = (size_t)luaL_optinteger(L, 3, 16 * 1024); if (maxb > kNetCapMax) maxb = kNetCapMax; if (strncmp(url, "http://", 7) != 0) return luaL_error(L, "http:// urls only"); if (strlen(url) >= sizeof(s_net_url)) return luaL_error(L, "url too long"); if (s_net_pending || s_net_cb != LUA_NOREF) return luaL_error(L, "a fetch is already running"); if (!s_net_buf || s_net_cap < maxb + 1) { if (s_net_buf) heap_caps_free(s_net_buf); s_net_buf = (char*)heap_caps_malloc(maxb + 1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); s_net_cap = s_net_buf ? maxb + 1 : 0; if (!s_net_buf) return luaL_error(L, "no memory for the fetch buffer"); } if (s_net_body) { heap_caps_free(s_net_body); s_net_body = nullptr; s_net_body_len = 0; } snprintf(s_net_url, sizeof s_net_url, "%s", url); lua_pushvalue(L, 2); s_net_cb = luaL_ref(L, LUA_REGISTRYINDEX); s_net_done = false; s_net_pending = true; if (!s_net_poll) s_net_poll = lv_timer_create(netDeliver, 120, nullptr); return 0; } // ---- wada.crypto ------------------------------------------------------------ // Hashing belongs in C. An OTP app doing HMAC-SHA1 in pure Lua blows the 100k // per-callback instruction budget legitimately — SHA-1 is 80 bit-twiddling rounds // per 64-byte block, and the interpreter overhead around each one multiplies it // (reported from a real app: "otp_messenger:941: instruction budget exceeded"). // The chip already links mbedTLS, so the same work costs microseconds and a // handful of VM instructions here. // // NOT gated on CAP_LUA_SDK_EXT: this is pure computation over data the app already // holds. It reads nothing of the user's, transmits nothing, and the mbedTLS context // is ~100 bytes — so there is no reason to deny it to the small boards, and an app // that needs a hash should not have to check a capability first. // // Strings in and out are BINARY (Lua strings are 8-bit clean), so an app gets the // raw digest and can do RFC 4226 dynamic truncation on it directly. #if defined(ESP32) static int cryptoDigest(lua_State* L, mbedtls_md_type_t type, bool hmac) { size_t klen = 0, mlen = 0; const char* key = hmac ? luaL_checklstring(L, 1, &klen) : nullptr; const char* msg = luaL_checklstring(L, hmac ? 2 : 1, &mlen); const mbedtls_md_info_t* info = mbedtls_md_info_from_type(type); if (!info) { lua_pushnil(L); lua_pushstring(L, "digest unavailable"); return 2; } unsigned char out[32]; const unsigned char olen = mbedtls_md_get_size(info); mbedtls_md_context_t ctx; mbedtls_md_init(&ctx); int rc = mbedtls_md_setup(&ctx, info, hmac ? 1 : 0); if (rc == 0) { if (hmac) { rc = mbedtls_md_hmac_starts(&ctx, (const unsigned char*)key, klen); if (rc == 0) rc = mbedtls_md_hmac_update(&ctx, (const unsigned char*)msg, mlen); if (rc == 0) rc = mbedtls_md_hmac_finish(&ctx, out); } else { rc = mbedtls_md_starts(&ctx); if (rc == 0) rc = mbedtls_md_update(&ctx, (const unsigned char*)msg, mlen); if (rc == 0) rc = mbedtls_md_finish(&ctx, out); } } mbedtls_md_free(&ctx); if (rc != 0) { lua_pushnil(L); lua_pushstring(L, "digest failed"); return 2; } lua_pushlstring(L, (const char*)out, olen); return 1; } int cryptoSha256(lua_State* L) { return cryptoDigest(L, MBEDTLS_MD_SHA256, false); } int cryptoSha1(lua_State* L) { return cryptoDigest(L, MBEDTLS_MD_SHA1, false); } int cryptoHmacSha256(lua_State* L) { return cryptoDigest(L, MBEDTLS_MD_SHA256, true); } int cryptoHmacSha1(lua_State* L) { return cryptoDigest(L, MBEDTLS_MD_SHA1, true); } // Lowercase hex of a binary string — the one conversion every app writes badly. int cryptoHex(lua_State* L) { size_t n = 0; const unsigned char* p = (const unsigned char*)luaL_checklstring(L, 1, &n); luaL_Buffer b; luaL_buffinit(L, &b); static const char* const kHex = "0123456789abcdef"; for (size_t i = 0; i < n; i++) { luaL_addchar(&b, kHex[p[i] >> 4]); luaL_addchar(&b, kHex[p[i] & 0x0F]); } luaL_pushresult(&b); return 1; } #endif // ---- module registration ---- void openWada(lua_State* L) { lua_newtable(L); // wada lua_newtable(L); // wada.ui lua_pushcfunction(L, uiCanvas); lua_setfield(L, -2, "canvas"); lua_pushcfunction(L, uiLabel); lua_setfield(L, -2, "label"); lua_pushcfunction(L, uiButton); lua_setfield(L, -2, "button"); lua_pushcfunction(L, uiChart); lua_setfield(L, -2, "chart"); lua_pushcfunction(L, uiPanel); lua_setfield(L, -2, "panel"); lua_pushcfunction(L, uiScroll); lua_setfield(L, -2, "scroll"); lua_pushcfunction(L, uiTextH); lua_setfield(L, -2, "text_h"); lua_pushcfunction(L, uiTextW); lua_setfield(L, -2, "text_w"); lua_pushcfunction(L, uiTextLines); lua_setfield(L, -2, "text_lines"); lua_createtable(L, 0, 9); // wada.ui.colors (theme) lua_pushinteger(L, 0x15B6A6); lua_setfield(L, -2, "accent"); lua_pushinteger(L, 0xE6E9ED); lua_setfield(L, -2, "text"); lua_pushinteger(L, 0x7A7F87); lua_setfield(L, -2, "sub"); lua_pushinteger(L, 0x000000); lua_setfield(L, -2, "bg"); // the page ground (matches the firmware) lua_pushinteger(L, 0x15181B); lua_setfield(L, -2, "panel"); // a raised surface on top of it lua_pushinteger(L, 0xD7574E); lua_setfield(L, -2, "bad"); lua_pushinteger(L, 0x53C06B); lua_setfield(L, -2, "good"); lua_pushinteger(L, 0xE8A33D); lua_setfield(L, -2, "warn"); lua_pushinteger(L, (lua_Integer)luaHostBorderColor()); lua_setfield(L, -2, "border"); lua_setfield(L, -2, "colors"); lua_pushcfunction(L, uiInput); lua_setfield(L, -2, "input"); // modal text entry lua_pushcfunction(L, uiList); lua_setfield(L, -2, "list"); // scrollable selectable rows lua_pushcfunction(L, uiClear); lua_setfield(L, -2, "clear"); // wipe the page (#318 / Discord) lua_pushcfunction(L, uiEmoji); lua_setfield(L, -2, "emoji"); // glyph picker for an app's own text entry lua_setfield(L, -2, "ui"); lua_newtable(L); // wada.sys lua_pushcfunction(L, sysMillis); lua_setfield(L, -2, "millis"); lua_pushcfunction(L, sysToast); lua_setfield(L, -2, "toast"); lua_pushcfunction(L, sysKeepAwake); lua_setfield(L, -2, "keep_awake"); lua_pushcfunction(L, sysBoard); lua_setfield(L, -2, "board"); lua_pushcfunction(L, sysTr); lua_setfield(L, -2, "tr"); // #257 lua_pushcfunction(L, sysRandom); lua_setfield(L, -2, "random"); lua_pushcfunction(L, sysEpoch); lua_setfield(L, -2, "epoch"); // #245 lua_pushcfunction(L, sysDatetime); lua_setfield(L, -2, "datetime"); // #245 lua_pushcfunction(L, sysBeep); lua_setfield(L, -2, "beep"); // #245 lua_pushcfunction(L, sysCaps); lua_setfield(L, -2, "caps"); // feature detection #if CAP_LUA_SDK_EXT lua_pushcfunction(L, sysBattery); lua_setfield(L, -2, "battery"); lua_pushcfunction(L, sysGps); lua_setfield(L, -2, "gps"); #endif #if CAP_COMPASS lua_pushcfunction(L, sysCompass); lua_setfield(L, -2, "compass"); // hardware-gated, see caps().compass #endif #if CAP_IMU lua_pushcfunction(L, sysAccel); lua_setfield(L, -2, "accel"); // tilt, see caps().accel #endif #if CAP_SENSORS lua_pushcfunction(L, sysEnv); lua_setfield(L, -2, "env"); // HARDWARE gate, not the memory one #endif lua_setfield(L, -2, "sys"); lua_newtable(L); // wada.timer lua_pushcfunction(L, timerEvery); lua_setfield(L, -2, "every"); lua_pushcfunction(L, timerAfter); lua_setfield(L, -2, "after"); lua_pushcfunction(L, timerStop); lua_setfield(L, -2, "stop"); lua_setfield(L, -2, "timer"); #if CAP_LUA_SDK_EXT lua_newtable(L); // wada.widget (the app's Home widget) lua_pushcfunction(L, widgetSet); lua_setfield(L, -2, "set"); lua_pushcfunction(L, widgetClear); lua_setfield(L, -2, "clear"); lua_setfield(L, -2, "widget"); lua_newtable(L); // wada.map lua_pushcfunction(L, mapView); lua_setfield(L, -2, "view"); lua_setfield(L, -2, "map"); #endif lua_newtable(L); // wada.geo (all boards) lua_pushcfunction(L, geoDistance); lua_setfield(L, -2, "distance"); lua_pushcfunction(L, geoBearing); lua_setfield(L, -2, "bearing"); lua_pushcfunction(L, geoCardinal); lua_setfield(L, -2, "cardinal"); lua_setfield(L, -2, "geo"); lua_newtable(L); // wada.store lua_pushcfunction(L, storeGet); lua_setfield(L, -2, "get"); lua_pushcfunction(L, storeSet); lua_setfield(L, -2, "set"); lua_setfield(L, -2, "store"); #if CAP_LUA_SDK_EXT lua_newtable(L); // wada.fs (scoped to /apps/.d) lua_pushcfunction(L, fsRead); lua_setfield(L, -2, "read"); lua_pushcfunction(L, fsWrite); lua_setfield(L, -2, "write"); lua_pushcfunction(L, fsAppend); lua_setfield(L, -2, "append"); lua_pushcfunction(L, fsList); lua_setfield(L, -2, "list"); lua_pushcfunction(L, fsRemove); lua_setfield(L, -2, "remove"); lua_setfield(L, -2, "fs"); #endif #if CAP_LUA_SD_READ lua_newtable(L); // wada.sd (read-only physical card) lua_pushcfunction(L, sdList); lua_setfield(L, -2, "list"); #if CAP_LUA_SD_LIST lua_pushcfunction(L, sdCheck); lua_setfield(L, -2, "check"); // SD malware policy lua_pushcfunction(L, sdRemove); lua_setfield(L, -2, "remove"); // threats only, see SdThreat.h #endif lua_setfield(L, -2, "sd"); #endif #if CAP_LUA_AUDIO lua_newtable(L); // wada.audio (app storage + optional SD) lua_pushcfunction(L, audioPlay); lua_setfield(L, -2, "play"); lua_pushcfunction(L, audioPause); lua_setfield(L, -2, "pause"); lua_pushcfunction(L, audioResume); lua_setfield(L, -2, "resume"); lua_pushcfunction(L, audioStop); lua_setfield(L, -2, "stop"); lua_pushcfunction(L, audioStatus); lua_setfield(L, -2, "status"); lua_setfield(L, -2, "audio"); #endif lua_newtable(L); // wada.mesh (read-only) lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts"); lua_pushcfunction(L, meshContactCount); lua_setfield(L, -2, "contact_count"); lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log"); lua_pushcfunction(L, meshStats); lua_setfield(L, -2, "stats"); lua_pushcfunction(L, meshSelf); lua_setfield(L, -2, "self"); lua_pushcfunction(L, meshDiscovered); lua_setfield(L, -2, "discovered"); lua_pushcfunction(L, meshDiscoverClear); lua_setfield(L, -2, "discover_clear"); // Node-type bits, for the discover() filter and the `type` field everything // else returns. Named so an app never has to hardcode the wire numbers. lua_pushinteger(L, 1); lua_setfield(L, -2, "NODE_CHAT"); lua_pushinteger(L, 2); lua_setfield(L, -2, "NODE_REPEATER"); lua_pushinteger(L, 3); lua_setfield(L, -2, "NODE_ROOM"); lua_pushinteger(L, 4); lua_setfield(L, -2, "NODE_SENSOR"); #if CAP_LUA_SDK_EXT lua_pushcfunction(L, meshSend); lua_setfield(L, -2, "send"); // consent-gated (channels) lua_pushcfunction(L, meshRepeats); lua_setfield(L, -2, "repeats"); // repeaters heard rebroadcasting a send lua_pushcfunction(L, meshSendDm); lua_setfield(L, -2, "send_dm"); // consent-gated (DMs + rooms) lua_pushcfunction(L, meshChannels); lua_setfield(L, -2, "channels"); // names only, no secrets lua_pushcfunction(L, meshDiscover); lua_setfield(L, -2, "discover"); // consent-gated (transmits) #endif lua_setfield(L, -2, "mesh"); #if defined(ESP32) lua_newtable(L); // wada.crypto (all boards) lua_pushcfunction(L, cryptoSha256); lua_setfield(L, -2, "sha256"); lua_pushcfunction(L, cryptoSha1); lua_setfield(L, -2, "sha1"); lua_pushcfunction(L, cryptoHmacSha256); lua_setfield(L, -2, "hmac_sha256"); lua_pushcfunction(L, cryptoHmacSha1); lua_setfield(L, -2, "hmac_sha1"); lua_pushcfunction(L, cryptoHex); lua_setfield(L, -2, "hex"); lua_setfield(L, -2, "crypto"); #endif lua_newtable(L); // wada.net lua_pushcfunction(L, netHttpGet); lua_setfield(L, -2, "http_get"); lua_pushcfunction(L, netHttpPost); lua_setfield(L, -2, "http_post"); lua_setfield(L, -2, "net"); lua_setglobal(L, "wada"); // metatables #if CAP_LUA_SDK_EXT luaL_newmetatable(L, "wada.map"); lua_newtable(L); lua_pushcfunction(L, mpCenter); lua_setfield(L, -2, "center"); lua_pushcfunction(L, mpZoom); lua_setfield(L, -2, "zoom"); lua_pushcfunction(L, mpMarker); lua_setfield(L, -2, "marker"); lua_pushcfunction(L, mpLine); lua_setfield(L, -2, "line"); lua_pushcfunction(L, mpClear); lua_setfield(L, -2, "clear"); lua_pushcfunction(L, mpRedraw); lua_setfield(L, -2, "redraw"); lua_pushcfunction(L, mpTiles); lua_setfield(L, -2, "tiles"); lua_pushcfunction(L, mpToScreen); lua_setfield(L, -2, "to_screen"); lua_pushcfunction(L, mpToLatLon); lua_setfield(L, -2, "to_latlon"); lua_pushcfunction(L, mpClose); lua_setfield(L, -2, "close"); lua_setfield(L, -2, "__index"); lua_pushcfunction(L, mpGc); lua_setfield(L, -2, "__gc"); lua_pop(L, 1); #endif luaL_newmetatable(L, "wada.list"); lua_newtable(L); lua_pushcfunction(L, lsAdd); lua_setfield(L, -2, "add"); lua_pushcfunction(L, lsSet); lua_setfield(L, -2, "set"); lua_pushcfunction(L, lsColor); lua_setfield(L, -2, "color"); lua_pushcfunction(L, lsClear); lua_setfield(L, -2, "clear"); lua_pushcfunction(L, lsCount); lua_setfield(L, -2, "count"); lua_pushcfunction(L, lsSelect); lua_setfield(L, -2, "select"); lua_pushcfunction(L, lsSelected); lua_setfield(L, -2, "selected"); lua_pushcfunction(L, lsPos); lua_setfield(L, -2, "pos"); lua_setfield(L, -2, "__index"); lua_pop(L, 1); luaL_newmetatable(L, "wada.timerh"); lua_newtable(L); lua_pushcfunction(L, timerHandleStop); lua_setfield(L, -2, "stop"); lua_setfield(L, -2, "__index"); lua_pop(L, 1); luaL_newmetatable(L, "wada.canvas"); lua_newtable(L); lua_pushcfunction(L, cvFill); lua_setfield(L, -2, "fill"); lua_pushcfunction(L, cvRect); lua_setfield(L, -2, "rect"); lua_pushcfunction(L, cvLine); lua_setfield(L, -2, "line"); lua_pushcfunction(L, cvCircle); lua_setfield(L, -2, "circle"); lua_pushcfunction(L, cvText); lua_setfield(L, -2, "text"); lua_pushcfunction(L, cvPos); lua_setfield(L, -2, "pos"); lua_setfield(L, -2, "__index"); lua_pushcfunction(L, cvGc); lua_setfield(L, -2, "__gc"); lua_pop(L, 1); luaL_newmetatable(L, "wada.panel"); lua_newtable(L); lua_pushcfunction(L, pnPos); lua_setfield(L, -2, "pos"); lua_pushcfunction(L, pnColor); lua_setfield(L, -2, "color"); lua_pushcfunction(L, pnSize); lua_setfield(L, -2, "size"); lua_setfield(L, -2, "__index"); lua_pop(L, 1); luaL_newmetatable(L, "wada.chart"); lua_newtable(L); lua_pushcfunction(L, chPush); lua_setfield(L, -2, "push"); lua_pushcfunction(L, chSetAll); lua_setfield(L, -2, "fill"); lua_pushcfunction(L, chRange); lua_setfield(L, -2, "range"); lua_pushcfunction(L, chPos); lua_setfield(L, -2, "pos"); lua_pushcfunction(L, chAxis); lua_setfield(L, -2, "axis"); lua_setfield(L, -2, "__index"); lua_pop(L, 1); luaL_newmetatable(L, "wada.label"); lua_newtable(L); lua_pushcfunction(L, lbSet); lua_setfield(L, -2, "set"); lua_pushcfunction(L, lbPos); lua_setfield(L, -2, "pos"); lua_pushcfunction(L, lbColor); lua_setfield(L, -2, "color"); lua_pushcfunction(L, lbWidth); lua_setfield(L, -2, "width"); lua_pushcfunction(L, lbBg); lua_setfield(L, -2, "bg"); lua_setfield(L, -2, "__index"); lua_pop(L, 1); } void openSandbox(lua_State* L) { luaL_requiref(L, LUA_GNAME, luaopen_base, 1); lua_pop(L, 1); luaL_requiref(L, LUA_MATHLIBNAME, luaopen_math, 1); lua_pop(L, 1); luaL_requiref(L, LUA_STRLIBNAME, luaopen_string, 1); lua_pop(L, 1); luaL_requiref(L, LUA_TABLIBNAME, luaopen_table, 1); lua_pop(L, 1); static const char* const kill[] = { "dofile", "loadfile", "load", "require", "collectgarbage" }; for (const char* k : kill) { lua_pushnil(L); lua_setglobal(L, k); } } void hostTeardown(); // fwd } // namespace // --------------------------------------------------------------------------- // 3) lifecycle // --------------------------------------------------------------------------- lv_obj_t* luaAppBody() { return s_h ? s_h->body : nullptr; } bool luaAppIsOpen() { return s_h != nullptr; } // The app's content area, so UITask can parent a map view into it. External // linkage on purpose: UITask owns the map machinery and this file owns the app. lv_obj_t* luaHostAppBody() { return s_h ? s_h->body : nullptr; } // Returns true when the running app took the key. False lets the firmware keep // its own handling, so an app that does not implement on_input changes nothing. #if CAP_LUA_SDK_EXT // Incoming channel message -> app.on_message{channel, sender, text}. Permission // checked HERE rather than at subscribe time, so revoking it in Settings stops // delivery immediately instead of at the next launch. // Each kind is behind its OWN grant, rechecked per message so revoking in Settings // stops delivery at once rather than at the app's next launch. Channel traffic is // semi-public and rides LUA_PERM_READ; direct messages and room posts are private // and need LUA_PERM_DM_READ, which is never auto-prompted (it would fire on someone // else's traffic arriving) and is granted only from Settings > App permissions. static void deliverMessage(const char* kind, const char* channel, const char* sender, const char* text) { if (!s_h || !s_h->L) return; const bool is_channel = (kind && strcmp(kind, "channel") == 0); if (is_channel) { if (!luaHostReadPerm(s_h->id)) return; } else { if (!luaHostDmReadPerm(s_h->id)) return; } if (!pushCallback(s_h, "on_message")) return; lua_State* L = s_h->L; lua_createtable(L, 0, 4); lua_pushstring(L, kind ? kind : ""); lua_setfield(L, -2, "kind"); lua_pushstring(L, channel ? channel : ""); lua_setfield(L, -2, "channel"); lua_pushstring(L, sender ? sender : ""); lua_setfield(L, -2, "sender"); lua_pushstring(L, text ? text : ""); lua_setfield(L, -2, "text"); guardedCall(s_h, 1); serviceDeferredClose(); } #endif bool luaAppKey(int key) { if (!s_h || !s_h->L || key <= 0) return false; sendKey(key); return true; } #if CAP_LUA_SDK_EXT void luaAppMessage(const char* kind, const char* channel, const char* sender, const char* text) { deliverMessage(kind, channel, sender, text); } #endif void luaAppSteer(int dx, int dy) { if (!s_h) return; const char* dir = (dy < 0) ? "up" : (dy > 0) ? "down" : (dx < 0) ? "left" : (dx > 0) ? "right" : nullptr; if (dir) sendInput("swipe", dir, 0, 0); } // Synthetic centre tap (down then up) for boards with no touch: the select key // stands in for "tap to start / retry" flows that listen for type=="down". void luaAppPress() { if (!s_h || !s_h->body) return; const int x = lv_obj_get_width(s_h->body) / 2; const int y = lv_obj_get_height(s_h->body) / 2; sendInput("down", nullptr, x, y); sendInput("up", nullptr, x, y); } // True when the running app declares on_input. Touchless boards use this to // decide whether the d-pad belongs to the app (steer/press events) or should // keep its native meaning so it can drive the app page's own LVGL widgets — // display-only apps (Airtime, RF Monitor) otherwise ate every key into a // callback that doesn't exist. bool luaAppHasOnInput() { if (!s_h || !s_h->L) return false; if (!pushCallback(s_h, "on_input")) return false; lua_pop(s_h->L, 1); return true; } // Page-scroll the app body (display-only apps: RF Monitor's feed runs past the // screen and there is no touch to drag it). Returns true while an app is open // — even at the scroll edge the key belongs to the page, not to whatever is // underneath it. bool luaAppScroll(bool up) { if (!s_h || !s_h->body) return false; const lv_coord_t room = up ? lv_obj_get_scroll_top(s_h->body) : lv_obj_get_scroll_bottom(s_h->body); if (room <= 0) return true; // at the edge / page not scrollable lv_coord_t step = (lv_coord_t)((s_h->body_h * 3) / 5); if (step > room) step = room; const lv_coord_t cur = lv_obj_get_scroll_y(s_h->body); lv_obj_scroll_to_y(s_h->body, up ? cur - step : cur + step, LV_ANIM_ON); return true; } static void luaAppRootDeletedCb(lv_event_t* e) { (void)e; // Root died (async delete completed) — the lv objects are gone; the Lua // state was already closed in hostTeardown(). } namespace { void hostTeardown() { Host* h = s_h; if (!h) return; s_h = nullptr; // bindings see "closed" from here on #if CAP_LUA_AUDIO luaHostAudioStop(h->generation, true); #endif // A wada.ui.input dialog lives on lv_layer_top, so it would outlive the app // that opened it. Drop it before the state goes, not after. luaHostTextPromptDismiss(); if (h->L && h->prompt_cb != LUA_NOREF) luaL_unref(h->L, LUA_REGISTRYINDEX, h->prompt_cb); h->prompt_cb = LUA_NOREF; if (s_pkt_poll) { lv_timer_del(s_pkt_poll); s_pkt_poll = nullptr; } #if CAP_LUA_AUDIO if (s_audio_poll) { lv_timer_del(s_audio_poll); s_audio_poll = nullptr; } #endif // Named timers hold both an lv_timer and a registry ref; both go now, while // the state is still alive to unref against. for (int i = 0; i < Host::kMaxTimers; i++) { if (h->timers[i].t) { lv_timer_del(h->timers[i].t); h->timers[i].t = nullptr; } if (h->timers[i].cb != LUA_NOREF && h->L) luaL_unref(h->L, LUA_REGISTRYINDEX, h->timers[i].cb); h->timers[i].cb = LUA_NOREF; s_timer_gen[i]++; // any handle Lua still holds is now inert } luaHostKeepAwake(false); // an app cannot hold the screen after it closes if (h->timer) { lv_timer_del(h->timer); h->timer = nullptr; } if (h->title_timer) { lv_timer_del(h->title_timer); h->title_timer = nullptr; } if (s_net_poll) { lv_timer_del(s_net_poll); s_net_poll = nullptr; } if (h->L && s_net_cb != LUA_NOREF) { luaL_unref(h->L, LUA_REGISTRYINDEX, s_net_cb); } s_net_cb = LUA_NOREF; // a worker fetch may still land; netDeliver sees no app and drops it // The fetch buffer (up to 32 KB of PSRAM) and the POST body belong to a fetch, // and with no app there is no fetch -- hold them only while the worker could // still be reading them (s_net_pending), otherwise they sit allocated until // some later app happens to call http_get/http_post. if (!s_net_pending) { if (s_net_buf) { heap_caps_free(s_net_buf); s_net_buf = nullptr; s_net_cap = 0; } if (s_net_body) { heap_caps_free(s_net_body); s_net_body = nullptr; s_net_body_len = 0; } } if (h->L) { // best-effort on_close + store flush before the state dies lua_State* L = h->L; lua_rawgeti(L, LUA_REGISTRYINDEX, h->ref_app); if (lua_istable(L, -1)) { lua_getfield(L, -1, "on_close"); if (lua_isfunction(L, -1)) { lua_sethook(L, budgetHook, LUA_MASKCOUNT, kInstrBudget); lua_pcall(L, 0, 0, 0); lua_sethook(L, nullptr, 0, 0); } else lua_pop(L, 1); } lua_pop(L, 1); s_h = h; // storeFlush needs the id + dirty flag storeFlush(); s_h = nullptr; #if CAP_LUA_SDK_EXT wgtFlush(h->id); // the Home widget's last word, for after a restart #endif #if CAP_LUA_SDK_EXT destroyLiveMapView(); // leaves map userdata inert when lua_close runs __gc #endif lua_close(L); // runs canvas __gc -> frees pixel buffers Serial.printf("[LUAAPP] %s closed, leaked=%u peak=%u psram_free=%u\n", h->id, (unsigned)h->heap.used, (unsigned)h->heap.peak, (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); } if (h->root) appPageDeleteRootAsync(h->root); appPageEnd(&luaAppDismiss); delete h; } } // namespace #if CAP_LUA_SDK_EXT bool luaWidgetGet(const char* app_id, LuaWidgetSnap* out) { if (!out) return false; WidgetSlot* w = wgtLoad(app_id); if (!w || !w->have) return false; *out = w->snap; return true; } #endif void luaAppDismiss() { if (!s_h) return; if (s_h->in_lua) { s_h->want_close = true; return; } // unwind first hostTeardown(); } bool luaAppLaunch(const char* id, const char* title, const char* src, size_t len) { if (s_h || !id || !src || len == 0 || len > kMaxSrc) return false; Host* h = new Host(); h->heap.cap = kHeapCap; h->generation = ++s_host_generation; if (!h->generation) h->generation = ++s_host_generation; snprintf(h->id, sizeof h->id, "%s", id); snprintf(h->title, sizeof h->title, "%s", title ? title : id); h->L = lua_newstate(psAllocCb, &h->heap); if (!h->L) { delete h; luaHostToast("App: out of memory", 1800); return false; } lua_atpanic(h->L, luaPanicCb); // never let an escaped error abort() silently openSandbox(h->L); openWada(h->L); // UI scaffold: full-screen overlay + a slim "< title" bar. The title identifies // the app briefly, then collapses to Back-only so it cannot cover app content. h->root = lv_obj_create(lv_layer_top()); lv_obj_remove_style_all(h->root); lv_obj_set_size(h->root, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr)); // Pure black, the same ground the rest of the firmware paints (COLOR_BG in // UITask.cpp) — an app page must not read as a lighter panel floating over // the UI. Apps that want a raised surface draw one (wada.ui.colors.panel). lv_obj_set_style_bg_color(h->root, lv_color_hex(0x000000), LV_PART_MAIN); lv_obj_set_style_bg_opa(h->root, LV_OPA_COVER, LV_PART_MAIN); lv_obj_clear_flag(h->root, LV_OBJ_FLAG_SCROLLABLE); // A plain lv_obj is CLICKABLE by default, and it has to stay so (it is the // overlay that keeps touches off the screen underneath) — but it must not be // a keyboard-nav focus target either: with only the body excluded, navCollect // simply promoted the root and drew a cursor around the whole app (seen on the M9). lv_obj_add_flag(h->root, NAV_PASSTHRU_FLAG); lv_obj_add_event_cb(h->root, luaAppRootDeletedCb, LV_EVENT_DELETE, nullptr); h->body = lv_obj_create(h->root); lv_obj_remove_style_all(h->body); // Install the page chrome BEFORE measuring it. appPageBeginSlim is what puts // the bar into its slim state, so reading the height first measured the // OUTGOING page's bar: launching an app from a tall-bar page sized the body // for a tall bar and left a dead strip under the slim one (and the reverse // overlapped). Same #236 reasoning, one step earlier. snprintf(s_bar_title, sizeof s_bar_title, "%s", h->title); appPageBeginSlim(s_bar_title, &luaAppDismiss); // Ask for the bar's real height rather than assuming the 44 it happens to be // at the default status-bar size and 100% UI scale (#236). const int bar_h = (int)luaHostAppBarH(); h->body_w = lv_disp_get_hor_res(nullptr); h->body_h = lv_disp_get_ver_res(nullptr) - bar_h; lv_obj_set_pos(h->body, 0, bar_h); lv_obj_set_size(h->body, h->body_w, h->body_h); lv_obj_clear_flag(h->body, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(h->body, LV_OBJ_FLAG_CLICKABLE); // Clickable for touch, but never a keyboard-nav focus target: on the M9 the // nav collector harvested this body as a leaf and outlined the whole app. // The flag leaves the app's own buttons reachable (see AppPage.h). lv_obj_add_flag(h->body, NAV_PASSTHRU_FLAG); lv_obj_add_event_cb(h->body, gestureCb, LV_EVENT_GESTURE, nullptr); lv_obj_add_event_cb(h->body, pressCb, LV_EVENT_PRESSED, nullptr); lv_obj_add_event_cb(h->body, pressCb, LV_EVENT_RELEASED, nullptr); s_h = h; h->title_timer = lv_timer_create(titleTimerCb, kAppTitleMs, nullptr); if (h->title_timer) lv_timer_set_repeat_count(h->title_timer, 1); storeLoad(); // button-callback table lua_newtable(h->L); h->ref_btncb = luaL_ref(h->L, LUA_REGISTRYINDEX); // compile + run the chunk (must return the app callback table) lua_pushcfunction(h->L, tracebackMsgh); int rc = luaL_loadbuffer(h->L, src, len, id); if (rc == LUA_OK) { lua_sethook(h->L, budgetHook, LUA_MASKCOUNT, kInstrBudget * 5); // init gets a bigger budget h->in_lua = true; rc = lua_pcall(h->L, 0, 1, -2); h->in_lua = false; lua_sethook(h->L, nullptr, 0, 0); } if (rc != LUA_OK || !lua_istable(h->L, -1)) { const char* err = rc != LUA_OK ? lua_tostring(h->L, -1) : "app did not return a table"; Serial.printf("[LUAAPP] %s load failed: %s\n", id, err ? err : "?"); char msg[96]; snprintf(msg, sizeof msg, "App failed: %.60s", err ? err : "load error"); luaHostToast(msg, 2600); hostTeardown(); return false; } h->ref_app = luaL_ref(h->L, LUA_REGISTRYINDEX); lua_pop(h->L, 1); // traceback handler // Only run the packet drain for apps that asked for it. Checked once here // rather than per pass, so an app without on_packet costs nothing at all. if (pushCallback(h, "on_packet")) { lua_pop(h->L, 1); s_pkt_last_ms = 0; s_pkt_primed = false; // the first pass marks the ring instead of replaying it s_pkt_poll = lv_timer_create(packetPollCb, 250, nullptr); } #if CAP_LUA_AUDIO if (pushCallback(h, "on_audio")) { lua_pop(h->L, 1); snprintf(s_audio_last_state, sizeof s_audio_last_state, "stopped"); s_audio_poll = lv_timer_create(audioPollCb, 250, nullptr); } #endif if (pushCallback(h, "on_open")) { lua_pushinteger(h->L, h->body_w); lua_pushinteger(h->L, h->body_h); guardedCall(h, 2); } serviceDeferredClose(); // PSRAM is reported alongside the Lua heap because the app heap does NOT // account for canvas pixel buffers (allocated directly), so it cannot show // whether an app really gave everything back. Compare this against the same // figure on the matching "closed" line. if (s_h) Serial.printf("[LUAAPP] %s open, heap=%u psram_free=%u\n", id, (unsigned)h->heap.used, (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); return s_h != nullptr; } bool luaAppLaunchFile(const char* id, const char* title, const char* embedded, size_t embedded_len) { fs::FS* fs = luaHostAppFs(); if (fs) { char rel[40], path[64]; snprintf(rel, sizeof rel, "/apps/%s.lua", id); luaHostAppPath(path, sizeof path, rel); File f = fs->open(path, "r"); if (f) { size_t sz = f.size(); if (sz > 0 && sz <= kMaxSrc) { char* buf = (char*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (buf) { size_t rd = f.read((uint8_t*)buf, sz); f.close(); bool ok = rd == sz && luaAppLaunch(id, title, buf, sz); heap_caps_free(buf); if (ok) return true; // fall through to the embedded copy on any failure } else f.close(); } else f.close(); } } if (embedded && embedded_len) return luaAppLaunch(id, title, embedded, embedded_len); return false; } // ---- net worker entry points (UITask's tile/net worker calls these) -------- bool luaNetWorkerPending() { return s_net_pending; } void luaNetWorkerService(void* client, void* http) { // s_net_pending stays TRUE for the whole request and is cleared at the end, // not here. It is the only thing telling the UI thread that the worker may // still be reading s_net_buf / s_net_body: clearing it first left a window // the length of an HTTP round trip in which an app closing (hostTeardown) // would free both buffers out from under this function. The caller runs one // service() per loop pass and re-checks the flag afterwards, so holding it // across the call cannot re-enter, and http_get/http_post keep rejecting a // second fetch while it is set -- which is what we want anyway. if (!s_net_buf) s_net_result = -1; else if (s_net_body) s_net_result = luaStoreHttpPostOpaque(client, http, s_net_url, s_net_buf, s_net_cap, s_net_body, s_net_body_len, s_net_ctype); else s_net_result = luaStoreHttpGetOpaque(client, http, s_net_url, s_net_buf, s_net_cap); // Order matters: drop the in-flight flag BEFORE announcing the result, so a // teardown that observes done=true can never also observe pending=true and // decide to hold the buffers forever. Between the two writes the request is // finished and s_net_result is set, and netDeliver drops the payload without // touching s_net_buf when the app is already gone. s_net_pending = false; s_net_done = true; } #endif // CAP_LUA_APPS