// 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 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 "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 const lv_font_t* luaHostFontForSize(int size_class); // 12/14/16 -> g_font_* extern void luaHostToast(const char* msg, int ms); // showAlert passthrough extern bool luaHostBeep(); // notification chime; false = no sounder / muted extern fs::FS* luaHostAppFs(); // /apps storage root FS (may be null) extern void luaHostAppPath(char* out, size_t cap, const char* rel); // prefixes the store root extern int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago, double* lat, double* lon); extern int luaHostRxLogAt(int idx, uint32_t* ms_ago, int* ptype, int* rssi, float* snr, int* hops); 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); #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. extern bool luaHostGps(double* lat, double* lon, int* sats); 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); #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); // --------------------------------------------------------------------------- // 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 constexpr size_t kMaxSrc = 64 * 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) 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 tall bar) lv_timer_t* 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 bool in_lua = false; // re-entrancy guard (dismiss from inside a callback) bool want_close = false; char id[24] = ""; char title[32] = ""; }; Host* s_h = nullptr; char s_bar_title[40]; // appPageBegin keeps the pointer — must outlive the page // --------------------------------------------------------------------------- // 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; } // Push app.; returns false if the app has no such callback. bool pushCallback(Host* h, const char* name) { lua_rawgeti(h->L, LUA_REGISTRYINDEX, h->ref_app); lua_getfield(h->L, -1, name); 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; } // ---- canvas userdata ---- struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; }; 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; 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, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3), c->w, &d, luaL_checkstring(L, 4)); 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) { // The buffer is heap_caps memory owned by the userdata; the lv object dies // with the app root. Free the pixels only when Lua collects the handle AND // the app is closing (root gone) — during app lifetime the canvas holds it. 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 && w <= 480, 1, "width 1..480"); luaL_argcheck(L, h > 0 && h <= 480, 2, "height 1..480"); 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->obj = cv; ud->buf = buf; ud->w = w; ud->h = h; luaL_setmetatable(L, "wada.canvas"); return 1; } // ---- label userdata ---- struct WidgetUd { lv_obj_t* obj; }; WidgetUd* checkLabel(lua_State* L) { return (WidgetUd*)luaL_checkudata(L, 1, "wada.label"); } int lbSet(lua_State* L) { WidgetUd* u = checkLabel(L); if (u->obj) lv_label_set_text(u->obj, luaL_checkstring(L, 2)); 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; } int lbWidth(lua_State* L) { // label:width(px) — wraps instead of overflowing WidgetUd* u = checkLabel(L); if (u->obj) { lv_obj_set_width(u->obj, (lv_coord_t)luaL_checkinteger(L, 2)); lv_label_set_long_mode(u->obj, LV_LABEL_LONG_WRAP); } return 0; } int uiLabel(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); lv_obj_t* l = lv_label_create(s_h->body); lv_label_set_text(l, luaL_checkstring(L, 1)); lv_obj_set_pos(l, (lv_coord_t)luaL_optinteger(L, 2, 0), (lv_coord_t)luaL_optinteger(L, 3, 0)); lv_obj_set_style_text_font(l, luaHostFontForSize((int)luaL_optinteger(L, 4, 14)), LV_PART_MAIN); lv_obj_set_style_text_color(l, lv_color_hex(argColor(L, 5, 0xE6E9ED)), LV_PART_MAIN); WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0); ud->obj = l; luaL_setmetatable(L, "wada.label"); 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; }; ChartUd* checkChart(lua_State* L) { return (ChartUd*)luaL_checkudata(L, 1, "wada.chart"); } 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); lv_obj_set_style_text_color(c->obj, lv_color_hex(0x7A7F87), LV_PART_TICKS); 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->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"); const char* txt = luaL_checkstring(L, 1); lv_obj_t* b = lv_btn_create(s_h->body); lv_obj_set_pos(b, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); lv_obj_set_size(b, (lv_coord_t)luaL_optinteger(L, 4, 90), (lv_coord_t)luaL_optinteger(L, 5, 34)); lv_obj_t* bl = lv_label_create(b); lv_label_set_text(bl, txt); 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->obj = b; luaL_setmetatable(L, "wada.label"); // shares set/pos/color methods return 1; } // ---- 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, 4); 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"); 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, sats } 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. int sysGps(lua_State* L) { double lat = 0, lon = 0; int sats = 0; if (!luaHostGps(&lat, &lon, &sats)) { lua_pushnil(L); return 1; } lua_createtable(L, 0, 3); lua_pushnumber(L, lat); lua_setfield(L, -2, "lat"); lua_pushnumber(L, lon); lua_setfield(L, -2, "lon"); lua_pushinteger(L, sats); lua_setfield(L, -2, "sats"); return 1; } // NOT exposed: wada.sys.sensors(). The only environment-sensor source in the // firmware is LocalEnvSnapshot, which is #if defined(HAS_EXPANSION_KIT) -- the // Heltec V4 Expansion Kit. That is precisely the board CAP_LUA_SDK_EXT excludes, // so the function would return nil on every board that could call it. Sensor // access belongs on a HARDWARE gate (does this board have the rail?) rather than // this MEMORY gate, and wants its own caps() entry when it lands. #endif // CAP_LUA_SDK_EXT 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; } 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; 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(); } int timerEvery(lua_State* L) { if (!s_h) return 0; int ms = (int)luaL_checkinteger(L, 1); 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; } int timerStop(lua_State* L) { (void)L; if (s_h && s_h->timer) { lv_timer_del(s_h->timer); s_h->timer = nullptr; s_h->last_tick = 0; } 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 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 bool ok = luaHostMeshSendChannel(chan, text); if (ok) s_h->mesh_last_send_ms = now; // only a real transmission starts the clock lua_pushboolean(L, ok); if (!ok) { lua_pushstring(L, "no such channel"); return 2; } 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"); } int fsRead(lua_State* L) { const char* name = luaL_checkstring(L, 1); char path[128]; 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; } size_t n = f.size(); 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); return 1; } 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; } #endif // CAP_LUA_SDK_EXT 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; } // ---- 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: named = "enter"; break; 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); sendInput(lv_event_get_code(e) == LV_EVENT_PRESSED ? "down" : "up", nullptr, p.x - a.x1, p.y - a.y1); } // ---- wada.mesh (read-only) ---- int meshContacts(lua_State* L) { lua_newtable(L); char name[36]; int type; uint32_t ago; double lat, lon; for (int i = 0, out = 0; i < 200 && out < 100; i++) { if (!luaHostContactAt(i, name, sizeof name, &type, &ago, &lat, &lon)) break; lua_createtable(L, 0, 5); lua_pushstring(L, name); lua_setfield(L, -2, "name"); 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_rawseti(L, -2, ++out); } return 1; } int meshRxLog(lua_State* L) { lua_newtable(L); uint32_t ms_ago; int ptype, rssi, hops; float snr; for (int i = 0, out = 0; i < 64; i++) { if (!luaHostRxLogAt(i, &ms_ago, &ptype, &rssi, &snr, &hops)) break; lua_createtable(L, 0, 5); 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_rawseti(L, -2, ++out); } return 1; } 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]; double lat, lon; luaHostSelfInfo(name, sizeof name, &lat, &lon); lua_createtable(L, 0, 3); lua_pushstring(L, name); lua_setfield(L, -2, "name"); lua_pushnumber(L, lat); lua_setfield(L, -2, "lat"); lua_pushnumber(L, lon); lua_setfield(L, -2, "lon"); return 1; } // ---- 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; 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_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(); } 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"); } 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; } // ---- 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, uiScroll); lua_setfield(L, -2, "scroll"); lua_pushcfunction(L, uiTextH); lua_setfield(L, -2, "text_h"); lua_createtable(L, 0, 6); // 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, 0x0E1216); lua_setfield(L, -2, "bg"); lua_pushinteger(L, 0xD7574E); lua_setfield(L, -2, "bad"); lua_pushinteger(L, 0x53C06B); lua_setfield(L, -2, "good"); lua_setfield(L, -2, "colors"); 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, sysBoard); lua_setfield(L, -2, "board"); 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 lua_setfield(L, -2, "sys"); lua_newtable(L); // wada.timer lua_pushcfunction(L, timerEvery); lua_setfield(L, -2, "every"); lua_pushcfunction(L, timerStop); lua_setfield(L, -2, "stop"); lua_setfield(L, -2, "timer"); 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 lua_newtable(L); // wada.mesh (read-only) lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts"); lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log"); lua_pushcfunction(L, meshStats); lua_setfield(L, -2, "stats"); lua_pushcfunction(L, meshSelf); lua_setfield(L, -2, "self"); #if CAP_LUA_SDK_EXT lua_pushcfunction(L, meshSend); lua_setfield(L, -2, "send"); // consent-gated #endif lua_setfield(L, -2, "mesh"); lua_newtable(L); // wada.net lua_pushcfunction(L, netHttpGet); lua_setfield(L, -2, "http_get"); lua_setfield(L, -2, "net"); lua_setglobal(L, "wada"); // metatables 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.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_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 // --------------------------------------------------------------------------- bool luaAppIsOpen() { return s_h != 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. static void deliverMessage(const char* channel, const char* sender, const char* text) { if (!s_h || !s_h->L) return; if (!luaHostReadPerm(s_h->id)) return; if (!pushCallback(s_h, "on_message")) return; lua_State* L = s_h->L; lua_createtable(L, 0, 3); 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* channel, const char* sender, const char* text) { deliverMessage(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); } 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 (h->timer) { lv_timer_del(h->timer); h->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 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; lua_close(L); // runs canvas __gc -> frees pixel buffers Serial.printf("[LUAAPP] %s closed, leaked=%u peak=%u\n", h->id, (unsigned)h->heap.used, (unsigned)h->heap.peak); } if (h->root) appPageDeleteRootAsync(h->root); appPageEnd(&luaAppDismiss); delete h; } } // namespace 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; 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; } openSandbox(h->L); openWada(h->L); // UI scaffold: full-screen overlay + tall "< title" bar via AppPage, exactly // like SnakeGame. Body = the content area apps build into. 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)); lv_obj_set_style_bg_color(h->root, lv_color_hex(0x0E1216), 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); 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); const int bar_h = 44; // matches the AppPage tall bar band 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); 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); snprintf(s_bar_title, sizeof s_bar_title, "%s", h->title); appPageBegin(s_bar_title, &luaAppDismiss); s_h = h; 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 if (pushCallback(h, "on_open")) { lua_pushinteger(h->L, h->body_w); lua_pushinteger(h->L, h->body_h); guardedCall(h, 2); } serviceDeferredClose(); if (s_h) Serial.printf("[LUAAPP] %s open, heap=%u\n", id, (unsigned)h->heap.used); 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 = false; s_net_result = s_net_buf ? luaStoreHttpGetOpaque(client, http, s_net_url, s_net_buf, s_net_cap) : -1; s_net_done = true; } #endif // CAP_LUA_APPS