Files
wadamesh/src/ui-touch/LuaAppHost.cpp
T
Kaj SchittecatandClaude Opus 5.5 9eda9b0f26 apps: Store lists up to 40 apps, canvases fit the big screens, button:bg
- The catalog parse stopped at 16 rows and the catalog has 16, so a 17th app
  (Blackjack, #681) was silently never listed; installed apps had the same
  cap. Both are 40 now, and the catalog buffer grows from 8 KB to 24 KB so
  the JSON is not cut off. PSRAM only.
- wada.ui.canvas raised a Lua error above 480 px, which killed apps that size
  a canvas from on_open(w, h) on the T-Display P4 (body ~575 tall), Tanmatsu
  and CrowPanel 5 (800 wide) (Discord). Sides are now clamped to the screen's
  long side instead.
- button:bg(fill [, border]) colours a button (or boxes a label), so apps no
  longer fake coloured buttons from panels that keys cannot focus (Discord).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:15:18 +02:00

3475 lines
155 KiB
C++

// 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 <Arduino.h>
#include <lvgl.h>
#include <esp_heap_caps.h>
#include <FS.h>
#include <time.h>
#include <math.h> // 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.<name>; 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/<id>.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/<id>.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/<id>.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 <root>/apps/<id>.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/<id>.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