Files
wadamesh/src/ui-touch/LuaAppHost.cpp
T
Kaj SchittecatandClaude Opus 5 6518ebf391 feat(lua): app.on_message, behind its own permission
The receive half of wada.mesh.send. An app can now be handed incoming channel
messages as { channel, sender, text }, which is what lets one hold a
conversation rather than just talk: auto-responders, bots, relays, games played
across the mesh.

READING is a SEPARATE permission from sending, and that is the point. They are
different risks -- one speaks in your name, the other sees your conversations --
and an app that already has http_get plus unrestricted read could quietly ship
your chat somewhere. So permissions became a bitmask (1 = send, 2 = read) and
the settings page grew a switch per permission rather than one per app.

The on-disk format is unchanged for existing installs: the value was already
written as "1" for send, which is the same as the new mask. Entry PRESENCE still
records "we asked", so "not asked yet" stays distinguishable from "refused".

Read permission raises NO prompt. Unlike send, it would fire on someone else's
traffic arriving, so a dialog would appear unbidden with no action to attach it
to. An app asks for it in Settings -> App permissions instead, which is also
where it is taken back.

Permission is rechecked PER MESSAGE, not at subscribe time, so revoking it stops
delivery immediately rather than at the app's next launch.

All 8 S3 envs build. One linker error caught in the process: luaAppMessage was
defined inside the file's anonymous namespace, giving it internal linkage --
moved out beside the other public entry points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:00:12 +02:00

1328 lines
55 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 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 "helpers/esp32/WdtHeavyGuard.h" // wada.fs writes can trigger SPIFFS GC
extern "C" {
#include "lua.h"
#include "lualib.h"
#include "lauxlib.h"
}
// UITask-owned services the host borrows (all pre-existing, fwd-declared here
// to keep this TU decoupled from the 47k-line UITask.cpp).
extern const lv_font_t* luaHostFontForSize(int size_class); // 12/14/16 -> g_font_*
extern void luaHostToast(const char* msg, int ms); // showAlert passthrough
extern bool luaHostBeep(); // notification chime; false = no sounder / muted
extern fs::FS* luaHostAppFs(); // /apps storage root FS (may be null)
extern void luaHostAppPath(char* out, size_t cap, const char* rel); // prefixes the store root
extern int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago,
double* lat, double* lon);
extern int luaHostRxLogAt(int idx, uint32_t* ms_ago, int* ptype, int* rssi, float* snr, int* hops);
extern void luaHostRadioStats(float* rssi, float* noise, uint32_t* rx_air_s, uint32_t* tx_air_s,
uint32_t* rx_pkts, uint32_t* rx_err, int* budget_ms);
extern void luaHostRadioStats2(uint32_t* rx_evt, uint32_t* rx_drop, uint32_t* tx_pkts,
float* freq, float* bw, int* sf, int* duty_pct);
extern void luaHostSelfInfo(char* name, size_t name_cap, double* lat, double* lon);
#if CAP_LUA_SDK_EXT
extern void luaHostBattery(uint16_t* mv, int* pct, bool* charging);
// No HDOP: MeshCore's LocationProvider interface does not expose one, and a
// fabricated accuracy figure is worse than none for anything that would use it.
extern bool luaHostGps(double* lat, double* lon, int* sats);
extern int luaHostSendPerm(const char* app_id); // 1 grant, -1 refused, 0 unasked
extern bool luaHostReadPerm(const char* app_id); // may be shown incoming messages
extern void luaHostRequestSendPerm(const char* app_id, const char* app_name); // raises the consent prompt
extern bool luaHostMeshSendChannel(const char* chan_name, const char* text);
#endif
// DO NOT name the network client type here. What "WiFiClient" means depends on the
// board: a real class on the S3 envs, a `using WiFiClient = NetworkClient` alias
// under arduino-esp32 3.x (Tanmatsu), and a #define to C6Client on the T-Display P4
// (its C6 runs ESP-AT, so sockets go over AT-over-SDIO). Writing `class WiFiClient;`
// declared a BRAND NEW incomplete class on the two boards where the name is an
// alias, so this file emitted luaNetWorkerService under a signature the caller in
// UITask.cpp never referenced — both P4 targets failed to LINK while all 8 S3 envs
// built clean, which is why it stayed hidden.
//
// UITask owns the networking, so it passes the objects down as opaque handles and
// casts them back on its side. This TU never dereferences them.
extern int luaStoreHttpGetOpaque(void* client, void* http, const char* url, char* buf, size_t cap);
// ---------------------------------------------------------------------------
// 1) allocator + budget
// ---------------------------------------------------------------------------
namespace {
struct LuaHeap { size_t used = 0, peak = 0, cap = 0; };
void* psAllocCb(void* ud, void* ptr, size_t osize, size_t nsize) {
LuaHeap* h = (LuaHeap*)ud;
if (!ptr) osize = 0;
if (nsize == 0) {
if (ptr) { h->used -= osize; heap_caps_free(ptr); }
return nullptr;
}
if (h->used - osize + nsize > h->cap) return nullptr; // hard per-app cap -> Lua OOM error
void* np = heap_caps_realloc(ptr, nsize, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!np) return nullptr;
h->used += nsize - osize;
if (h->used > h->peak) h->peak = h->used;
return np;
}
// 100k VM instructions per callback (~<2 ms of straight-line Lua on the S3 —
// the Phase 0 spike measured containment of a hostile loop in 30 ms at this
// cadence with negligible overhead on real code).
constexpr int kInstrBudget = 100000;
void budgetHook(lua_State* L, lua_Debug*) {
luaL_error(L, "instruction budget exceeded (app tick too long)");
}
// ---------------------------------------------------------------------------
// host state
// ---------------------------------------------------------------------------
constexpr size_t kHeapCap = 256 * 1024; // per-app PSRAM cap
constexpr size_t kMaxSrc = 64 * 1024; // app source size limit
constexpr size_t kStoreMax = 2048; // per-app persisted KV budget (bytes, serialized)
constexpr int kMinTickMs = 33; // fastest on_tick cadence (~30 fps)
struct Host {
lua_State* L = nullptr;
LuaHeap heap;
lv_obj_t* root = nullptr; // full-screen overlay on lv_layer_top
lv_obj_t* body = nullptr; // app content area (below the tall bar)
lv_timer_t* timer = nullptr;
int body_w = 0, body_h = 0; // set at creation — lv_obj_get_width() reads 0 pre-layout
uint32_t last_tick = 0;
int ref_app = LUA_NOREF; // the table the chunk returned
int ref_btncb = LUA_NOREF; // { [btn lightuserdata] = fn } button callbacks
bool store_dirty = false;
uint32_t fs_last_write_ms = 0; // wada.fs write rate limit (#222 lesson)
uint32_t mesh_last_send_ms = 0; // wada.mesh.send airtime rate limit
bool in_lua = false; // re-entrancy guard (dismiss from inside a callback)
bool want_close = false;
char id[24] = "";
char title[32] = "";
};
Host* s_h = nullptr;
char s_bar_title[40]; // appPageBegin keeps the pointer — must outlive the page
// ---------------------------------------------------------------------------
// guarded callback invocation
// ---------------------------------------------------------------------------
int tracebackMsgh(lua_State* L) {
const char* msg = lua_tostring(L, 1);
luaL_traceback(L, L, msg ? msg : "(non-string error)", 1);
return 1;
}
// Push app.<name>; returns false if the app has no such callback.
bool pushCallback(Host* h, const char* name) {
lua_rawgeti(h->L, LUA_REGISTRYINDEX, h->ref_app);
lua_getfield(h->L, -1, name);
lua_remove(h->L, -2);
if (!lua_isfunction(h->L, -1)) { lua_pop(h->L, 1); return false; }
return true;
}
// Run the function at the top of the stack with nargs args under budget+pcall.
// On error: toast + schedule close. Returns true on clean completion.
bool guardedCall(Host* h, int nargs) {
lua_State* L = h->L;
int base = lua_gettop(L) - nargs; // function slot
lua_pushcfunction(L, tracebackMsgh);
lua_insert(L, base);
lua_sethook(L, budgetHook, LUA_MASKCOUNT, kInstrBudget); // (re)arms the counter
h->in_lua = true;
int rc = lua_pcall(L, nargs, 0, base);
h->in_lua = false;
lua_sethook(L, nullptr, 0, 0);
lua_remove(L, base); // the traceback handler
if (rc != LUA_OK) {
const char* err = lua_tostring(L, -1);
Serial.printf("[LUAAPP] %s error: %s\n", h->id, err ? err : "?");
char msg[96];
snprintf(msg, sizeof msg, "App error: %.60s", err ? err : "unknown");
luaHostToast(msg, 2600);
lua_pop(L, 1);
h->want_close = true; // unwind AFTER we leave Lua
return false;
}
return true;
}
void serviceDeferredClose() {
if (s_h && s_h->want_close && !s_h->in_lua) luaAppDismiss();
}
// ---------------------------------------------------------------------------
// 2) wada.* bindings
// ---------------------------------------------------------------------------
uint32_t argColor(lua_State* L, int idx, uint32_t def = 0xFFFFFF) {
return (uint32_t)luaL_optinteger(L, idx, (lua_Integer)def) & 0xFFFFFF;
}
// ---- canvas userdata ----
struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; };
CanvasUd* checkCanvas(lua_State* L) {
return (CanvasUd*)luaL_checkudata(L, 1, "wada.canvas");
}
int cvFill(lua_State* L) {
CanvasUd* c = checkCanvas(L);
if (c->obj) lv_canvas_fill_bg(c->obj, lv_color_hex(argColor(L, 2, 0x000000)), LV_OPA_COVER);
return 0;
}
int cvRect(lua_State* L) {
CanvasUd* c = checkCanvas(L);
if (!c->obj) return 0;
lv_draw_rect_dsc_t d; lv_draw_rect_dsc_init(&d);
uint32_t col = argColor(L, 6);
bool filled = lua_isnoneornil(L, 7) ? true : lua_toboolean(L, 7);
d.bg_color = lv_color_hex(col); d.bg_opa = filled ? LV_OPA_COVER : LV_OPA_TRANSP;
d.border_color = lv_color_hex(col); d.border_width = filled ? 0 : 1; d.border_opa = LV_OPA_COVER;
d.radius = (lv_coord_t)luaL_optinteger(L, 8, 0);
lv_canvas_draw_rect(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3),
(lv_coord_t)luaL_checkinteger(L, 4), (lv_coord_t)luaL_checkinteger(L, 5), &d);
return 0;
}
int cvLine(lua_State* L) {
CanvasUd* c = checkCanvas(L);
if (!c->obj) return 0;
lv_point_t pts[2] = {
{ (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3) },
{ (lv_coord_t)luaL_checkinteger(L, 4), (lv_coord_t)luaL_checkinteger(L, 5) } };
lv_draw_line_dsc_t d; lv_draw_line_dsc_init(&d);
d.color = lv_color_hex(argColor(L, 6));
d.width = (lv_coord_t)luaL_optinteger(L, 7, 1);
lv_canvas_draw_line(c->obj, pts, 2, &d);
return 0;
}
int cvCircle(lua_State* L) {
CanvasUd* c = checkCanvas(L);
if (!c->obj) return 0;
lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2), y = (lv_coord_t)luaL_checkinteger(L, 3);
lv_coord_t r = (lv_coord_t)luaL_checkinteger(L, 4);
lv_draw_arc_dsc_t d; lv_draw_arc_dsc_init(&d);
d.color = lv_color_hex(argColor(L, 5));
bool filled = lua_isnoneornil(L, 6) ? true : lua_toboolean(L, 6);
d.width = filled ? r : (lv_coord_t)luaL_optinteger(L, 7, 1);
lv_canvas_draw_arc(c->obj, x, y, r, 0, 360, &d);
return 0;
}
int cvText(lua_State* L) {
CanvasUd* c = checkCanvas(L);
if (!c->obj) return 0;
lv_draw_label_dsc_t d; lv_draw_label_dsc_init(&d);
d.color = lv_color_hex(argColor(L, 5));
d.font = luaHostFontForSize((int)luaL_optinteger(L, 6, 14));
lv_canvas_draw_text(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3),
c->w, &d, luaL_checkstring(L, 4));
return 0;
}
int cvPos(lua_State* L) {
CanvasUd* c = checkCanvas(L);
if (c->obj) lv_obj_set_pos(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3));
return 0;
}
int cvGc(lua_State* L) {
// The buffer is heap_caps memory owned by the userdata; the lv object dies
// with the app root. Free the pixels only when Lua collects the handle AND
// the app is closing (root gone) — during app lifetime the canvas holds it.
CanvasUd* c = (CanvasUd*)luaL_checkudata(L, 1, "wada.canvas");
if (c->buf) { heap_caps_free(c->buf); c->buf = nullptr; }
c->obj = nullptr;
return 0;
}
int uiCanvas(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
int w = (int)luaL_checkinteger(L, 1), h = (int)luaL_checkinteger(L, 2);
luaL_argcheck(L, w > 0 && w <= 480, 1, "width 1..480");
luaL_argcheck(L, h > 0 && h <= 480, 2, "height 1..480");
size_t bytes = (size_t)w * h * sizeof(lv_color_t);
lv_color_t* buf = (lv_color_t*)heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!buf) return luaL_error(L, "canvas alloc failed");
lv_obj_t* cv = lv_canvas_create(s_h->body);
lv_canvas_set_buffer(cv, buf, w, h, LV_IMG_CF_TRUE_COLOR);
lv_canvas_fill_bg(cv, lv_color_hex(0x000000), LV_OPA_COVER);
CanvasUd* ud = (CanvasUd*)lua_newuserdatauv(L, sizeof(CanvasUd), 0);
ud->obj = cv; ud->buf = buf; ud->w = w; ud->h = h;
luaL_setmetatable(L, "wada.canvas");
return 1;
}
// ---- label userdata ----
struct WidgetUd { lv_obj_t* obj; };
WidgetUd* checkLabel(lua_State* L) { return (WidgetUd*)luaL_checkudata(L, 1, "wada.label"); }
int lbSet(lua_State* L) {
WidgetUd* u = checkLabel(L);
if (u->obj) lv_label_set_text(u->obj, luaL_checkstring(L, 2));
return 0;
}
int lbPos(lua_State* L) {
WidgetUd* u = checkLabel(L);
if (u->obj) lv_obj_set_pos(u->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3));
return 0;
}
int lbColor(lua_State* L) {
WidgetUd* u = checkLabel(L);
if (u->obj) lv_obj_set_style_text_color(u->obj, lv_color_hex(argColor(L, 2)), LV_PART_MAIN);
return 0;
}
int lbWidth(lua_State* L) { // label:width(px) — wraps instead of overflowing
WidgetUd* u = checkLabel(L);
if (u->obj) {
lv_obj_set_width(u->obj, (lv_coord_t)luaL_checkinteger(L, 2));
lv_label_set_long_mode(u->obj, LV_LABEL_LONG_WRAP);
}
return 0;
}
int uiLabel(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
lv_obj_t* l = lv_label_create(s_h->body);
lv_label_set_text(l, luaL_checkstring(L, 1));
lv_obj_set_pos(l, (lv_coord_t)luaL_optinteger(L, 2, 0), (lv_coord_t)luaL_optinteger(L, 3, 0));
lv_obj_set_style_text_font(l, luaHostFontForSize((int)luaL_optinteger(L, 4, 14)), LV_PART_MAIN);
lv_obj_set_style_text_color(l, lv_color_hex(argColor(L, 5, 0xE6E9ED)), LV_PART_MAIN);
WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0);
ud->obj = l;
luaL_setmetatable(L, "wada.label");
return 1;
}
// ---- chart userdata (the primitive the native Monitor/Airtime pages use) ----
struct ChartUd { lv_obj_t* obj; lv_chart_series_t* ser[2]; int n_ser; };
ChartUd* checkChart(lua_State* L) { return (ChartUd*)luaL_checkudata(L, 1, "wada.chart"); }
int chPush(lua_State* L) { // chart:push(series_idx, value)
ChartUd* c = checkChart(L);
int si = (int)luaL_checkinteger(L, 2) - 1;
if (!c->obj || si < 0 || si >= c->n_ser) return 0;
lv_chart_set_next_value(c->obj, c->ser[si], (lv_coord_t)luaL_checkinteger(L, 3));
return 0;
}
int chSetAll(lua_State* L) { // chart:fill(series_idx, value) — reset a series
ChartUd* c = checkChart(L);
int si = (int)luaL_checkinteger(L, 2) - 1;
if (!c->obj || si < 0 || si >= c->n_ser) return 0;
lv_chart_set_all_value(c->obj, c->ser[si], (lv_coord_t)luaL_checkinteger(L, 3));
return 0;
}
int chRange(lua_State* L) { // chart:range(min, max)
ChartUd* c = checkChart(L);
if (c->obj) lv_chart_set_range(c->obj, LV_CHART_AXIS_PRIMARY_Y,
(lv_coord_t)luaL_checkinteger(L, 2),
(lv_coord_t)luaL_checkinteger(L, 3));
return 0;
}
int chPos(lua_State* L) {
ChartUd* c = checkChart(L);
if (c->obj) lv_obj_set_pos(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3));
return 0;
}
// chart:axis(major_ticks, gutter_px) — Y-axis value labels. LVGL draws them to
// the LEFT of the chart's outer edge, so position the chart at least gutter_px
// in from the screen edge or they clip (the built-in page uses x=40).
int chAxis(lua_State* L) {
ChartUd* c = checkChart(L);
if (!c->obj) return 0;
int major = (int)luaL_optinteger(L, 2, 4);
int gutter = (int)luaL_optinteger(L, 3, 40);
lv_obj_set_style_text_font(c->obj, luaHostFontForSize(12), LV_PART_TICKS);
lv_obj_set_style_text_color(c->obj, lv_color_hex(0x7A7F87), LV_PART_TICKS);
lv_obj_set_style_pad_left(c->obj, 4, LV_PART_TICKS);
lv_chart_set_axis_tick(c->obj, LV_CHART_AXIS_PRIMARY_Y, 4, 0, major, 1, true, gutter);
return 0;
}
int uiChart(lua_State* L) { // wada.ui.chart(w, h, points, color1 [, color2] [, bars])
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
int w = (int)luaL_checkinteger(L, 1), h = (int)luaL_checkinteger(L, 2);
int pts = (int)luaL_optinteger(L, 3, 60);
if (pts < 2) pts = 2;
if (pts > 240) pts = 240;
lv_obj_t* ch = lv_chart_create(s_h->body);
lv_obj_set_size(ch, w, h);
lv_obj_clear_flag(ch, LV_OBJ_FLAG_SCROLLABLE);
lv_chart_set_type(ch, lua_toboolean(L, 6) ? LV_CHART_TYPE_BAR : LV_CHART_TYPE_LINE);
lv_chart_set_point_count(ch, pts);
lv_chart_set_update_mode(ch, LV_CHART_UPDATE_MODE_SHIFT);
lv_chart_set_div_line_count(ch, 3, 0);
// Match the built-in Monitor/Airtime chart styling exactly: panel fill, thin
// dark border + rounded corners, dim grid lines, no point dots, 2 px series.
lv_obj_set_style_size(ch, 0, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(ch, lv_color_hex(0x15181B), LV_PART_MAIN);
lv_obj_set_style_bg_opa(ch, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_border_color(ch, lv_color_hex(0x18191A), LV_PART_MAIN);
lv_obj_set_style_border_width(ch, 1, LV_PART_MAIN);
lv_obj_set_style_radius(ch, 6, LV_PART_MAIN);
lv_obj_set_style_line_color(ch, lv_color_hex(0x1A1D1F), LV_PART_MAIN); // grid
lv_obj_set_style_line_width(ch, 2, LV_PART_ITEMS);
ChartUd* ud = (ChartUd*)lua_newuserdatauv(L, sizeof(ChartUd), 0);
ud->obj = ch;
ud->n_ser = 0;
ud->ser[0] = lv_chart_add_series(ch, lv_color_hex(argColor(L, 4, 0x15B6A6)), LV_CHART_AXIS_PRIMARY_Y);
ud->n_ser = 1;
if (!lua_isnoneornil(L, 5)) {
ud->ser[1] = lv_chart_add_series(ch, lv_color_hex(argColor(L, 5, 0x7A7F87)), LV_CHART_AXIS_PRIMARY_Y);
ud->n_ser = 2;
}
luaL_setmetatable(L, "wada.chart");
return 1;
}
// ---- buttons: Lua callback dispatched through the guarded path ----
void btnEventCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED || !s_h || !s_h->L) return;
lua_State* L = s_h->L;
lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb);
lua_pushlightuserdata(L, lv_event_get_target(e));
lua_rawget(L, -2);
lua_remove(L, -2);
if (lua_isfunction(L, -1)) guardedCall(s_h, 0);
else lua_pop(L, 1);
serviceDeferredClose();
}
int uiButton(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
const char* txt = luaL_checkstring(L, 1);
lv_obj_t* b = lv_btn_create(s_h->body);
lv_obj_set_pos(b, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3));
lv_obj_set_size(b, (lv_coord_t)luaL_optinteger(L, 4, 90), (lv_coord_t)luaL_optinteger(L, 5, 34));
lv_obj_t* bl = lv_label_create(b);
lv_label_set_text(bl, txt);
lv_obj_center(bl);
if (lua_isfunction(L, 6)) {
lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb);
lua_pushlightuserdata(L, b);
lua_pushvalue(L, 6);
lua_rawset(L, -3);
lua_pop(L, 1);
lv_obj_add_event_cb(b, btnEventCb, LV_EVENT_CLICKED, nullptr);
}
WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0);
ud->obj = b;
luaL_setmetatable(L, "wada.label"); // shares set/pos/color methods
return 1;
}
// ---- sys / timer / store ----
// wada.ui.scroll(true) — let the app body scroll vertically. Apps that lay out
// more content than the screen (a log feed, a settings list) need this; games
// leave it off so a swipe steers instead of dragging the page.
int uiScroll(lua_State* L) {
if (!s_h || !s_h->body) return 0;
const bool on = lua_isnoneornil(L, 1) ? true : lua_toboolean(L, 1);
if (on) {
lv_obj_add_flag(s_h->body, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scroll_dir(s_h->body, LV_DIR_VER);
lv_obj_set_scrollbar_mode(s_h->body, LV_SCROLLBAR_MODE_AUTO);
lv_obj_set_style_bg_color(s_h->body, lv_color_hex(0x15B6A6), LV_PART_SCROLLBAR);
lv_obj_set_style_bg_opa(s_h->body, LV_OPA_60, LV_PART_SCROLLBAR);
lv_obj_set_style_width(s_h->body, 4, LV_PART_SCROLLBAR);
lv_obj_set_style_pad_bottom(s_h->body, 10, LV_PART_MAIN);
} else {
lv_obj_clear_flag(s_h->body, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_scrollbar_mode(s_h->body, LV_SCROLLBAR_MODE_OFF);
}
return 0;
}
// wada.ui.text_h(size) — real line height of a size class, so an app can stack
// rows without guessing (guessing is what made labels overlap).
int uiTextH(lua_State* L) {
lua_pushinteger(L, lv_font_get_line_height(luaHostFontForSize((int)luaL_optinteger(L, 1, 12))));
return 1;
}
int sysMillis(lua_State* L) { lua_pushinteger(L, (lua_Integer)millis()); return 1; }
// wada.sys.epoch() -> Unix seconds, or nil when the clock has never been set.
// nil rather than 0 on purpose: an app that stamps a log wants to be able to
// tell "no clock yet" from "1970", and the device genuinely boots without one
// until GPS, NTP or a mesh peer supplies the time (#245).
int sysEpoch(lua_State* L) {
const time_t now = time(nullptr);
if (now < 1700000000) { lua_pushnil(L); return 1; } // same sane-clock floor the mesh uses
lua_pushinteger(L, (lua_Integer)now);
return 1;
}
// wada.sys.datetime() -> table in LOCAL time (the timezone the user picked),
// because every use for it is display. Fields match pisti87's request in #245.
// nil under the same unset-clock rule as epoch().
int sysDatetime(lua_State* L) {
const time_t now = time(nullptr);
struct tm tmv;
if (now < 1700000000 || !localtime_r(&now, &tmv)) { lua_pushnil(L); return 1; }
lua_createtable(L, 0, 7);
lua_pushinteger(L, tmv.tm_year + 1900); lua_setfield(L, -2, "year");
lua_pushinteger(L, tmv.tm_mon + 1); lua_setfield(L, -2, "month");
lua_pushinteger(L, tmv.tm_mday); lua_setfield(L, -2, "day");
lua_pushinteger(L, tmv.tm_hour); lua_setfield(L, -2, "hour");
lua_pushinteger(L, tmv.tm_min); lua_setfield(L, -2, "min");
lua_pushinteger(L, tmv.tm_sec); lua_setfield(L, -2, "sec");
lua_pushinteger(L, tmv.tm_wday); lua_setfield(L, -2, "wday"); // 0 = Sunday
return 1;
}
// wada.sys.beep() -> true if a sound was actually produced. #245 asked for a
// melody player; what the firmware has is one notification chime, and only some
// boards have a sounder at all. Exposing the truthful primitive beats inventing
// a melody engine an app could not rely on: the return value lets an app fall
// back to a visual cue instead of silently doing nothing.
int sysBeep(lua_State* L) { lua_pushboolean(L, luaHostBeep()); return 1; }
// wada.sys.caps() -> what THIS board actually offers. Apps must branch on this
// rather than assume: the extended SDK is absent on low-resource boards (see
// CAP_LUA_SDK_EXT in device_caps.h), and a store app runs on all of them.
int sysCaps(lua_State* L) {
lua_createtable(L, 0, 4);
lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "sdk_ext");
lua_pushboolean(L, CAP_KEYBOARD); lua_setfield(L, -2, "keyboard");
lua_pushboolean(L, CAP_TOUCH); lua_setfield(L, -2, "touch");
lua_pushboolean(L, CAP_SD); lua_setfield(L, -2, "sd");
return 1;
}
#if CAP_LUA_SDK_EXT
// wada.sys.battery() -> { mv, pct, charging }
int sysBattery(lua_State* L) {
uint16_t mv = 0; int pct = -1; bool chg = false;
luaHostBattery(&mv, &pct, &chg);
lua_createtable(L, 0, 3);
lua_pushinteger(L, mv); lua_setfield(L, -2, "mv");
lua_pushinteger(L, pct); lua_setfield(L, -2, "pct");
lua_pushboolean(L, chg); lua_setfield(L, -2, "charging");
return 1;
}
// wada.sys.gps() -> { lat, lon, sats } or nil with NO fix.
// nil rather than stale coordinates: wada.mesh.self() already hands out the last
// known position, and an app plotting a track needs to know the difference.
int sysGps(lua_State* L) {
double lat = 0, lon = 0; int sats = 0;
if (!luaHostGps(&lat, &lon, &sats)) { lua_pushnil(L); return 1; }
lua_createtable(L, 0, 3);
lua_pushnumber(L, lat); lua_setfield(L, -2, "lat");
lua_pushnumber(L, lon); lua_setfield(L, -2, "lon");
lua_pushinteger(L, sats); lua_setfield(L, -2, "sats");
return 1;
}
// NOT exposed: wada.sys.sensors(). The only environment-sensor source in the
// firmware is LocalEnvSnapshot, which is #if defined(HAS_EXPANSION_KIT) -- the
// Heltec V4 Expansion Kit. That is precisely the board CAP_LUA_SDK_EXT excludes,
// so the function would return nil on every board that could call it. Sensor
// access belongs on a HARDWARE gate (does this board have the rail?) rather than
// this MEMORY gate, and wants its own caps() entry when it lands.
#endif // CAP_LUA_SDK_EXT
int sysToast(lua_State* L) { luaHostToast(luaL_checkstring(L, 1), (int)luaL_optinteger(L, 2, 1500)); return 0; }
int sysRandom(lua_State* L) {
// esp_random-backed: apps should not have to seed math.random for games
lua_Integer lo = luaL_optinteger(L, 1, 0), hi = luaL_optinteger(L, 2, 0);
if (hi <= lo) { lua_pushinteger(L, (lua_Integer)esp_random()); return 1; }
lua_pushinteger(L, lo + (lua_Integer)(esp_random() % (uint32_t)(hi - lo + 1)));
return 1;
}
int sysBoard(lua_State* L) {
lua_createtable(L, 0, 6);
lua_pushinteger(L, s_h ? s_h->body_w : lv_disp_get_hor_res(nullptr));
lua_setfield(L, -2, "w");
lua_pushinteger(L, s_h ? s_h->body_h : lv_disp_get_ver_res(nullptr));
lua_setfield(L, -2, "h");
#if CAP_TOUCH
lua_pushboolean(L, 1);
#else
lua_pushboolean(L, 0);
#endif
lua_setfield(L, -2, "touch");
return 1;
}
void tickTimerCb(lv_timer_t* t) {
(void)t;
if (!s_h || !s_h->L) return;
uint32_t now = millis();
uint32_t dt = s_h->last_tick ? now - s_h->last_tick : 0;
s_h->last_tick = now;
if (pushCallback(s_h, "on_tick")) {
lua_pushinteger(s_h->L, (lua_Integer)dt);
guardedCall(s_h, 1);
}
serviceDeferredClose();
}
int timerEvery(lua_State* L) {
if (!s_h) return 0;
int ms = (int)luaL_checkinteger(L, 1);
if (ms < kMinTickMs) ms = kMinTickMs;
if (s_h->timer) lv_timer_set_period(s_h->timer, ms);
else s_h->timer = lv_timer_create(tickTimerCb, ms, nullptr);
return 0;
}
int timerStop(lua_State* L) {
(void)L;
if (s_h && s_h->timer) { lv_timer_del(s_h->timer); s_h->timer = nullptr; s_h->last_tick = 0; }
return 0;
}
// Store: a Lua table mirrored to /apps/<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
int meshSend(lua_State* L) {
const char* chan = luaL_checkstring(L, 1);
size_t len = 0;
const char* text = luaL_checklstring(L, 2, &len);
if (!s_h) { lua_pushboolean(L, 0); lua_pushstring(L, "no app"); return 2; }
if (len == 0 || len > kMeshSendMaxLen) {
lua_pushboolean(L, 0); lua_pushstring(L, "bad length"); return 2;
}
const int perm = luaHostSendPerm(s_h->id);
if (perm != 1) {
if (perm == 0) luaHostRequestSendPerm(s_h->id, s_h->id); // ask once, never in a loop
lua_pushboolean(L, 0);
lua_pushstring(L, perm == 0 ? "permission requested" : "permission denied");
return 2;
}
const uint32_t now = millis();
if (s_h->mesh_last_send_ms && (uint32_t)(now - s_h->mesh_last_send_ms) < kMeshSendMinGapMs) {
lua_pushboolean(L, 0); lua_pushstring(L, "too fast"); return 2;
}
const bool ok = luaHostMeshSendChannel(chan, text);
if (ok) s_h->mesh_last_send_ms = now; // only a real transmission starts the clock
lua_pushboolean(L, ok);
if (!ok) { lua_pushstring(L, "no such channel"); return 2; }
return 1;
}
#endif
int storeGet(lua_State* L) {
lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab");
lua_pushvalue(L, 1);
lua_rawget(L, -2);
if (lua_isnil(L, -1) && !lua_isnoneornil(L, 2)) { lua_pop(L, 1); lua_pushvalue(L, 2); }
return 1;
}
int storeSet(lua_State* L) {
luaL_checkstring(L, 1);
if (!lua_isnumber(L, 2) && !lua_isstring(L, 2) && !lua_isnil(L, 2))
return luaL_error(L, "store values must be strings or numbers");
lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab");
lua_pushvalue(L, 1);
lua_pushvalue(L, 2);
lua_rawset(L, -3);
if (s_h) s_h->store_dirty = true;
return 0;
}
#if CAP_LUA_SDK_EXT
// ---- wada.fs: scoped file access -------------------------------------------
// Every app gets ONE directory, /apps/<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"); }
int fsRead(lua_State* L) {
const char* name = luaL_checkstring(L, 1);
char path[128];
if (!fsPath(path, sizeof path, name)) { lua_pushnil(L); return 1; }
fs::FS* fs = luaHostAppFs();
if (!fs) { lua_pushnil(L); return 1; }
File f = fs->open(path, "r");
if (!f) { lua_pushnil(L); return 1; }
size_t n = f.size();
if (n > kFsMaxFile) n = kFsMaxFile; // never hand Lua an unbounded buffer
luaL_Buffer b;
char* dst = luaL_buffinitsize(L, &b, n);
const size_t got = f.read((uint8_t*)dst, n);
f.close();
luaL_pushresultsize(&b, got);
return 1;
}
int fsRemove(lua_State* L) {
const char* name = luaL_checkstring(L, 1);
char path[128];
fs::FS* fs = luaHostAppFs();
if (!fs || !fsPath(path, sizeof path, name)) { lua_pushboolean(L, 0); return 1; }
lua_pushboolean(L, fs->remove(path));
return 1;
}
int fsList(lua_State* L) {
lua_newtable(L);
fs::FS* fs = luaHostAppFs();
if (!fs || !s_h) return 1;
char d[80], rel[64];
snprintf(rel, sizeof rel, "/apps/%s.d", s_h->id);
luaHostAppPath(d, sizeof d, rel);
File dir = fs->open(d);
if (!dir || !dir.isDirectory()) return 1;
int i = 0;
for (File e = dir.openNextFile(); e; e = dir.openNextFile()) {
if (e.isDirectory()) continue;
lua_createtable(L, 0, 2);
const char* nm = strrchr(e.name(), '/');
lua_pushstring(L, nm ? nm + 1 : e.name()); lua_setfield(L, -2, "name");
lua_pushinteger(L, (lua_Integer)e.size()); lua_setfield(L, -2, "size");
lua_rawseti(L, -2, ++i);
}
dir.close();
return 1;
}
#endif // CAP_LUA_SDK_EXT
void storePath(char* out, size_t cap) {
char rel[40];
snprintf(rel, sizeof rel, "/apps/%s.sav", s_h->id);
luaHostAppPath(out, cap, rel);
}
void storeLoad() {
lua_State* L = s_h->L;
lua_newtable(L);
lua_setfield(L, LUA_REGISTRYINDEX, "wada.storetab");
fs::FS* fs = luaHostAppFs();
if (!fs) return;
char path[64]; storePath(path, sizeof path);
File f = fs->open(path, "r");
if (!f) return;
lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab");
char line[192];
size_t n;
while ((n = f.readBytesUntil('\n', line, sizeof line - 1)) > 0) {
line[n] = 0;
char* eq = strchr(line, '=');
if (!eq) continue;
*eq = 0;
lua_pushstring(L, line);
lua_Number num;
char* end = nullptr;
num = strtod(eq + 1, &end);
if (end && *end == 0 && end != eq + 1) lua_pushnumber(L, num);
else lua_pushstring(L, eq + 1);
lua_rawset(L, -3);
}
lua_pop(L, 1);
f.close();
}
void storeFlush() {
if (!s_h || !s_h->store_dirty) return;
fs::FS* fs = luaHostAppFs();
if (!fs) return;
lua_State* L = s_h->L;
char path[64]; storePath(path, sizeof path);
char appsdir[48]; luaHostAppPath(appsdir, sizeof appsdir, "/apps");
fs->mkdir(appsdir);
File f = fs->open(path, "w");
if (!f) return;
size_t written = 0;
lua_getfield(L, LUA_REGISTRYINDEX, "wada.storetab");
lua_pushnil(L);
while (lua_next(L, -2)) {
if (lua_type(L, -2) == LUA_TSTRING && (lua_isstring(L, -1) || lua_isnumber(L, -1))) {
char lbuf[192];
int m = snprintf(lbuf, sizeof lbuf, "%s=%s\n", lua_tostring(L, -2), luaL_tolstring(L, -1, nullptr));
lua_pop(L, 1); // luaL_tolstring's copy
if (m > 0 && written + (size_t)m <= kStoreMax) { f.write((const uint8_t*)lbuf, m); written += m; }
}
lua_pop(L, 1);
}
lua_pop(L, 1);
f.close();
s_h->store_dirty = false;
}
// ---- input plumbing ----
void sendInput(const char* type, const char* dir, int x, int y) {
if (!s_h || !s_h->L) return;
if (!pushCallback(s_h, "on_input")) return;
lua_State* L = s_h->L;
lua_createtable(L, 0, 4);
lua_pushstring(L, type); lua_setfield(L, -2, "type");
if (dir) { lua_pushstring(L, dir); lua_setfield(L, -2, "dir"); }
lua_pushinteger(L, x); lua_setfield(L, -2, "x");
lua_pushinteger(L, y); lua_setfield(L, -2, "y");
guardedCall(s_h, 1);
serviceDeferredClose();
}
// Key delivery reuses on_input rather than adding an on_key callback: an app
// already branches on ev.type, and one entry point keeps a Lua app's input model
// to a single place. type="key", with `key` as a one-character string for
// printable input and a name ("up", "enter", ...) for the rest, so an app can
// write ev.key == "w" without knowing scancodes.
void sendKey(int key) {
if (!s_h || !s_h->L) return;
if (!pushCallback(s_h, "on_input")) return;
lua_State* L = s_h->L;
lua_createtable(L, 0, 2);
lua_pushstring(L, "key"); lua_setfield(L, -2, "type");
char buf[8];
const char* named = nullptr;
switch (key) {
case LV_KEY_UP: named = "up"; break;
case LV_KEY_DOWN: named = "down"; break;
case LV_KEY_LEFT: named = "left"; break;
case LV_KEY_RIGHT: named = "right"; break;
case LV_KEY_ENTER: named = "enter"; break;
case LV_KEY_ESC: named = "esc"; break;
case '\b': case 127: named = "backspace"; break;
default: break;
}
if (named) { lua_pushstring(L, named); }
else if (key >= 32 && key < 127) { buf[0] = (char)key; buf[1] = '\0'; lua_pushstring(L, buf); }
else { lua_pushnil(L); }
lua_setfield(L, -2, "key");
lua_pushinteger(L, key); lua_setfield(L, -2, "code"); // raw, for anything unmapped
guardedCall(s_h, 1);
serviceDeferredClose();
}
void gestureCb(lv_event_t* e) {
lv_dir_t d = lv_indev_get_gesture_dir(lv_indev_get_act());
const char* dir = (d == LV_DIR_TOP) ? "up" : (d == LV_DIR_BOTTOM) ? "down"
: (d == LV_DIR_LEFT) ? "left" : (d == LV_DIR_RIGHT) ? "right" : nullptr;
if (dir) sendInput("swipe", dir, 0, 0);
(void)e;
}
void pressCb(lv_event_t* e) {
lv_indev_t* indev = lv_indev_get_act();
lv_point_t p{0, 0};
if (indev) lv_indev_get_point(indev, &p);
lv_area_t a;
lv_obj_get_coords(s_h->body, &a);
sendInput(lv_event_get_code(e) == LV_EVENT_PRESSED ? "down" : "up", nullptr,
p.x - a.x1, p.y - a.y1);
}
// ---- wada.mesh (read-only) ----
int meshContacts(lua_State* L) {
lua_newtable(L);
char name[36]; int type; uint32_t ago; double lat, lon;
for (int i = 0, out = 0; i < 200 && out < 100; i++) {
if (!luaHostContactAt(i, name, sizeof name, &type, &ago, &lat, &lon)) break;
lua_createtable(L, 0, 5);
lua_pushstring(L, name); lua_setfield(L, -2, "name");
lua_pushinteger(L, type); lua_setfield(L, -2, "type");
lua_pushinteger(L, (lua_Integer)ago); lua_setfield(L, -2, "ago_s");
lua_pushnumber(L, lat); lua_setfield(L, -2, "lat");
lua_pushnumber(L, lon); lua_setfield(L, -2, "lon");
lua_rawseti(L, -2, ++out);
}
return 1;
}
int meshRxLog(lua_State* L) {
lua_newtable(L);
uint32_t ms_ago; int ptype, rssi, hops; float snr;
for (int i = 0, out = 0; i < 64; i++) {
if (!luaHostRxLogAt(i, &ms_ago, &ptype, &rssi, &snr, &hops)) break;
lua_createtable(L, 0, 5);
lua_pushinteger(L, (lua_Integer)ms_ago); lua_setfield(L, -2, "ago_ms");
lua_pushinteger(L, ptype); lua_setfield(L, -2, "type");
lua_pushinteger(L, rssi); lua_setfield(L, -2, "rssi");
lua_pushnumber(L, snr); lua_setfield(L, -2, "snr");
lua_pushinteger(L, hops); lua_setfield(L, -2, "hops");
lua_rawseti(L, -2, ++out);
}
return 1;
}
int meshStats(lua_State* L) {
float rssi, noise; uint32_t rx_air, tx_air, rx_pkts, rx_err; int budget;
luaHostRadioStats(&rssi, &noise, &rx_air, &tx_air, &rx_pkts, &rx_err, &budget);
lua_createtable(L, 0, 7);
lua_pushnumber(L, rssi); lua_setfield(L, -2, "rssi");
lua_pushnumber(L, noise); lua_setfield(L, -2, "noise");
lua_pushinteger(L, (lua_Integer)rx_air); lua_setfield(L, -2, "rx_air_s");
lua_pushinteger(L, (lua_Integer)tx_air); lua_setfield(L, -2, "tx_air_s");
lua_pushinteger(L, (lua_Integer)rx_pkts); lua_setfield(L, -2, "rx_pkts");
lua_pushinteger(L, (lua_Integer)rx_err); lua_setfield(L, -2, "rx_err");
lua_pushinteger(L, budget); lua_setfield(L, -2, "tx_budget_ms");
uint32_t rx_evt, rx_drop, tx_pkts; float freq, bw; int sf, duty;
luaHostRadioStats2(&rx_evt, &rx_drop, &tx_pkts, &freq, &bw, &sf, &duty);
lua_pushinteger(L, (lua_Integer)rx_evt); lua_setfield(L, -2, "rx_events");
lua_pushinteger(L, (lua_Integer)rx_drop); lua_setfield(L, -2, "rx_dropped");
lua_pushinteger(L, (lua_Integer)tx_pkts); lua_setfield(L, -2, "tx_pkts");
lua_pushnumber(L, freq); lua_setfield(L, -2, "freq");
lua_pushnumber(L, bw); lua_setfield(L, -2, "bw");
lua_pushinteger(L, sf); lua_setfield(L, -2, "sf");
lua_pushinteger(L, duty); lua_setfield(L, -2, "duty_pct");
return 1;
}
int meshSelf(lua_State* L) {
char name[36]; double lat, lon;
luaHostSelfInfo(name, sizeof name, &lat, &lon);
lua_createtable(L, 0, 3);
lua_pushstring(L, name); lua_setfield(L, -2, "name");
lua_pushnumber(L, lat); lua_setfield(L, -2, "lat");
lua_pushnumber(L, lon); lua_setfield(L, -2, "lon");
return 1;
}
// ---- wada.net: one in-flight async http_get per app ----
constexpr size_t kNetCapMax = 32 * 1024;
char s_net_url[160];
char* s_net_buf = nullptr;
size_t s_net_cap = 0;
int s_net_result = -1;
volatile bool s_net_pending = false; // worker should run it
volatile bool s_net_done = false; // result ready for the UI thread
int s_net_cb = LUA_NOREF;
lv_timer_t* s_net_poll = nullptr;
void netDeliver(lv_timer_t* t) {
(void)t;
if (!s_net_done) return;
s_net_done = false;
if (s_net_poll) { lv_timer_del(s_net_poll); s_net_poll = nullptr; }
if (!s_h || !s_h->L || s_net_cb == LUA_NOREF) { s_net_cb = LUA_NOREF; return; }
lua_State* L = s_h->L;
lua_rawgeti(L, LUA_REGISTRYINDEX, s_net_cb);
luaL_unref(L, LUA_REGISTRYINDEX, s_net_cb);
s_net_cb = LUA_NOREF;
lua_pushinteger(L, s_net_result >= 0 ? 200 : -1);
if (s_net_result >= 0 && s_net_buf) lua_pushlstring(L, s_net_buf, (size_t)s_net_result);
else lua_pushnil(L);
guardedCall(s_h, 2);
serviceDeferredClose();
}
int netHttpGet(lua_State* L) {
const char* url = luaL_checkstring(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
size_t maxb = (size_t)luaL_optinteger(L, 3, 16 * 1024);
if (maxb > kNetCapMax) maxb = kNetCapMax;
if (strncmp(url, "http://", 7) != 0) return luaL_error(L, "http:// urls only");
if (strlen(url) >= sizeof(s_net_url)) return luaL_error(L, "url too long");
if (s_net_pending || s_net_cb != LUA_NOREF) return luaL_error(L, "a fetch is already running");
if (!s_net_buf || s_net_cap < maxb + 1) {
if (s_net_buf) heap_caps_free(s_net_buf);
s_net_buf = (char*)heap_caps_malloc(maxb + 1, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
s_net_cap = s_net_buf ? maxb + 1 : 0;
if (!s_net_buf) return luaL_error(L, "no memory for the fetch buffer");
}
snprintf(s_net_url, sizeof s_net_url, "%s", url);
lua_pushvalue(L, 2);
s_net_cb = luaL_ref(L, LUA_REGISTRYINDEX);
s_net_done = false;
s_net_pending = true;
if (!s_net_poll) s_net_poll = lv_timer_create(netDeliver, 120, nullptr);
return 0;
}
// ---- module registration ----
void openWada(lua_State* L) {
lua_newtable(L); // wada
lua_newtable(L); // wada.ui
lua_pushcfunction(L, uiCanvas); lua_setfield(L, -2, "canvas");
lua_pushcfunction(L, uiLabel); lua_setfield(L, -2, "label");
lua_pushcfunction(L, uiButton); lua_setfield(L, -2, "button");
lua_pushcfunction(L, uiChart); lua_setfield(L, -2, "chart");
lua_pushcfunction(L, uiScroll); lua_setfield(L, -2, "scroll");
lua_pushcfunction(L, uiTextH); lua_setfield(L, -2, "text_h");
lua_createtable(L, 0, 6); // wada.ui.colors (theme)
lua_pushinteger(L, 0x15B6A6); lua_setfield(L, -2, "accent");
lua_pushinteger(L, 0xE6E9ED); lua_setfield(L, -2, "text");
lua_pushinteger(L, 0x7A7F87); lua_setfield(L, -2, "sub");
lua_pushinteger(L, 0x0E1216); lua_setfield(L, -2, "bg");
lua_pushinteger(L, 0xD7574E); lua_setfield(L, -2, "bad");
lua_pushinteger(L, 0x53C06B); lua_setfield(L, -2, "good");
lua_setfield(L, -2, "colors");
lua_setfield(L, -2, "ui");
lua_newtable(L); // wada.sys
lua_pushcfunction(L, sysMillis); lua_setfield(L, -2, "millis");
lua_pushcfunction(L, sysToast); lua_setfield(L, -2, "toast");
lua_pushcfunction(L, sysBoard); lua_setfield(L, -2, "board");
lua_pushcfunction(L, sysRandom); lua_setfield(L, -2, "random");
lua_pushcfunction(L, sysEpoch); lua_setfield(L, -2, "epoch"); // #245
lua_pushcfunction(L, sysDatetime); lua_setfield(L, -2, "datetime"); // #245
lua_pushcfunction(L, sysBeep); lua_setfield(L, -2, "beep"); // #245
lua_pushcfunction(L, sysCaps); lua_setfield(L, -2, "caps"); // feature detection
#if CAP_LUA_SDK_EXT
lua_pushcfunction(L, sysBattery); lua_setfield(L, -2, "battery");
lua_pushcfunction(L, sysGps); lua_setfield(L, -2, "gps");
#endif
lua_setfield(L, -2, "sys");
lua_newtable(L); // wada.timer
lua_pushcfunction(L, timerEvery); lua_setfield(L, -2, "every");
lua_pushcfunction(L, timerStop); lua_setfield(L, -2, "stop");
lua_setfield(L, -2, "timer");
lua_newtable(L); // wada.store
lua_pushcfunction(L, storeGet); lua_setfield(L, -2, "get");
lua_pushcfunction(L, storeSet); lua_setfield(L, -2, "set");
lua_setfield(L, -2, "store");
#if CAP_LUA_SDK_EXT
lua_newtable(L); // wada.fs (scoped to /apps/<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
lua_newtable(L); // wada.mesh (read-only)
lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts");
lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log");
lua_pushcfunction(L, meshStats); lua_setfield(L, -2, "stats");
lua_pushcfunction(L, meshSelf); lua_setfield(L, -2, "self");
#if CAP_LUA_SDK_EXT
lua_pushcfunction(L, meshSend); lua_setfield(L, -2, "send"); // consent-gated
#endif
lua_setfield(L, -2, "mesh");
lua_newtable(L); // wada.net
lua_pushcfunction(L, netHttpGet); lua_setfield(L, -2, "http_get");
lua_setfield(L, -2, "net");
lua_setglobal(L, "wada");
// metatables
luaL_newmetatable(L, "wada.canvas");
lua_newtable(L);
lua_pushcfunction(L, cvFill); lua_setfield(L, -2, "fill");
lua_pushcfunction(L, cvRect); lua_setfield(L, -2, "rect");
lua_pushcfunction(L, cvLine); lua_setfield(L, -2, "line");
lua_pushcfunction(L, cvCircle); lua_setfield(L, -2, "circle");
lua_pushcfunction(L, cvText); lua_setfield(L, -2, "text");
lua_pushcfunction(L, cvPos); lua_setfield(L, -2, "pos");
lua_setfield(L, -2, "__index");
lua_pushcfunction(L, cvGc); lua_setfield(L, -2, "__gc");
lua_pop(L, 1);
luaL_newmetatable(L, "wada.chart");
lua_newtable(L);
lua_pushcfunction(L, chPush); lua_setfield(L, -2, "push");
lua_pushcfunction(L, chSetAll); lua_setfield(L, -2, "fill");
lua_pushcfunction(L, chRange); lua_setfield(L, -2, "range");
lua_pushcfunction(L, chPos); lua_setfield(L, -2, "pos");
lua_pushcfunction(L, chAxis); lua_setfield(L, -2, "axis");
lua_setfield(L, -2, "__index");
lua_pop(L, 1);
luaL_newmetatable(L, "wada.label");
lua_newtable(L);
lua_pushcfunction(L, lbSet); lua_setfield(L, -2, "set");
lua_pushcfunction(L, lbPos); lua_setfield(L, -2, "pos");
lua_pushcfunction(L, lbColor); lua_setfield(L, -2, "color");
lua_pushcfunction(L, lbWidth); lua_setfield(L, -2, "width");
lua_setfield(L, -2, "__index");
lua_pop(L, 1);
}
void openSandbox(lua_State* L) {
luaL_requiref(L, LUA_GNAME, luaopen_base, 1); lua_pop(L, 1);
luaL_requiref(L, LUA_MATHLIBNAME, luaopen_math, 1); lua_pop(L, 1);
luaL_requiref(L, LUA_STRLIBNAME, luaopen_string, 1); lua_pop(L, 1);
luaL_requiref(L, LUA_TABLIBNAME, luaopen_table, 1); lua_pop(L, 1);
static const char* const kill[] = { "dofile", "loadfile", "load", "require", "collectgarbage" };
for (const char* k : kill) { lua_pushnil(L); lua_setglobal(L, k); }
}
void hostTeardown(); // fwd
} // namespace
// ---------------------------------------------------------------------------
// 3) lifecycle
// ---------------------------------------------------------------------------
bool luaAppIsOpen() { return s_h != nullptr; }
// Returns true when the running app took the key. False lets the firmware keep
// its own handling, so an app that does not implement on_input changes nothing.
#if CAP_LUA_SDK_EXT
// Incoming channel message -> app.on_message{channel, sender, text}. Permission
// checked HERE rather than at subscribe time, so revoking it in Settings stops
// delivery immediately instead of at the next launch.
static void deliverMessage(const char* channel, const char* sender, const char* text) {
if (!s_h || !s_h->L) return;
if (!luaHostReadPerm(s_h->id)) return;
if (!pushCallback(s_h, "on_message")) return;
lua_State* L = s_h->L;
lua_createtable(L, 0, 3);
lua_pushstring(L, channel ? channel : ""); lua_setfield(L, -2, "channel");
lua_pushstring(L, sender ? sender : ""); lua_setfield(L, -2, "sender");
lua_pushstring(L, text ? text : ""); lua_setfield(L, -2, "text");
guardedCall(s_h, 1);
serviceDeferredClose();
}
#endif
bool luaAppKey(int key) {
if (!s_h || !s_h->L || key <= 0) return false;
sendKey(key);
return true;
}
#if CAP_LUA_SDK_EXT
void luaAppMessage(const char* channel, const char* sender, const char* text) {
deliverMessage(channel, sender, text);
}
#endif
void luaAppSteer(int dx, int dy) {
if (!s_h) return;
const char* dir = (dy < 0) ? "up" : (dy > 0) ? "down" : (dx < 0) ? "left" : (dx > 0) ? "right" : nullptr;
if (dir) sendInput("swipe", dir, 0, 0);
}
static void luaAppRootDeletedCb(lv_event_t* e) {
(void)e;
// Root died (async delete completed) — the lv objects are gone; the Lua
// state was already closed in hostTeardown().
}
namespace {
void hostTeardown() {
Host* h = s_h;
if (!h) return;
s_h = nullptr; // bindings see "closed" from here on
if (h->timer) { lv_timer_del(h->timer); h->timer = nullptr; }
if (s_net_poll) { lv_timer_del(s_net_poll); s_net_poll = nullptr; }
if (h->L && s_net_cb != LUA_NOREF) { luaL_unref(h->L, LUA_REGISTRYINDEX, s_net_cb); }
s_net_cb = LUA_NOREF; // a worker fetch may still land; netDeliver sees no app and drops it
if (h->L) {
// best-effort on_close + store flush before the state dies
lua_State* L = h->L;
lua_rawgeti(L, LUA_REGISTRYINDEX, h->ref_app);
if (lua_istable(L, -1)) {
lua_getfield(L, -1, "on_close");
if (lua_isfunction(L, -1)) { lua_sethook(L, budgetHook, LUA_MASKCOUNT, kInstrBudget); lua_pcall(L, 0, 0, 0); lua_sethook(L, nullptr, 0, 0); }
else lua_pop(L, 1);
}
lua_pop(L, 1);
s_h = h; // storeFlush needs the id + dirty flag
storeFlush();
s_h = nullptr;
lua_close(L); // runs canvas __gc -> frees pixel buffers
Serial.printf("[LUAAPP] %s closed, leaked=%u peak=%u\n", h->id,
(unsigned)h->heap.used, (unsigned)h->heap.peak);
}
if (h->root) appPageDeleteRootAsync(h->root);
appPageEnd(&luaAppDismiss);
delete h;
}
} // namespace
void luaAppDismiss() {
if (!s_h) return;
if (s_h->in_lua) { s_h->want_close = true; return; } // unwind first
hostTeardown();
}
bool luaAppLaunch(const char* id, const char* title, const char* src, size_t len) {
if (s_h || !id || !src || len == 0 || len > kMaxSrc) return false;
Host* h = new Host();
h->heap.cap = kHeapCap;
snprintf(h->id, sizeof h->id, "%s", id);
snprintf(h->title, sizeof h->title, "%s", title ? title : id);
h->L = lua_newstate(psAllocCb, &h->heap);
if (!h->L) { delete h; luaHostToast("App: out of memory", 1800); return false; }
openSandbox(h->L);
openWada(h->L);
// UI scaffold: full-screen overlay + tall "< title" bar via AppPage, exactly
// like SnakeGame. Body = the content area apps build into.
h->root = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(h->root);
lv_obj_set_size(h->root, lv_disp_get_hor_res(nullptr), lv_disp_get_ver_res(nullptr));
lv_obj_set_style_bg_color(h->root, lv_color_hex(0x0E1216), LV_PART_MAIN);
lv_obj_set_style_bg_opa(h->root, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_clear_flag(h->root, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(h->root, luaAppRootDeletedCb, LV_EVENT_DELETE, nullptr);
h->body = lv_obj_create(h->root);
lv_obj_remove_style_all(h->body);
const int bar_h = 44; // matches the AppPage tall bar band
h->body_w = lv_disp_get_hor_res(nullptr);
h->body_h = lv_disp_get_ver_res(nullptr) - bar_h;
lv_obj_set_pos(h->body, 0, bar_h);
lv_obj_set_size(h->body, h->body_w, h->body_h);
lv_obj_clear_flag(h->body, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(h->body, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(h->body, gestureCb, LV_EVENT_GESTURE, nullptr);
lv_obj_add_event_cb(h->body, pressCb, LV_EVENT_PRESSED, nullptr);
lv_obj_add_event_cb(h->body, pressCb, LV_EVENT_RELEASED, nullptr);
snprintf(s_bar_title, sizeof s_bar_title, "%s", h->title);
appPageBegin(s_bar_title, &luaAppDismiss);
s_h = h;
storeLoad();
// button-callback table
lua_newtable(h->L);
h->ref_btncb = luaL_ref(h->L, LUA_REGISTRYINDEX);
// compile + run the chunk (must return the app callback table)
lua_pushcfunction(h->L, tracebackMsgh);
int rc = luaL_loadbuffer(h->L, src, len, id);
if (rc == LUA_OK) {
lua_sethook(h->L, budgetHook, LUA_MASKCOUNT, kInstrBudget * 5); // init gets a bigger budget
h->in_lua = true;
rc = lua_pcall(h->L, 0, 1, -2);
h->in_lua = false;
lua_sethook(h->L, nullptr, 0, 0);
}
if (rc != LUA_OK || !lua_istable(h->L, -1)) {
const char* err = rc != LUA_OK ? lua_tostring(h->L, -1) : "app did not return a table";
Serial.printf("[LUAAPP] %s load failed: %s\n", id, err ? err : "?");
char msg[96];
snprintf(msg, sizeof msg, "App failed: %.60s", err ? err : "load error");
luaHostToast(msg, 2600);
hostTeardown();
return false;
}
h->ref_app = luaL_ref(h->L, LUA_REGISTRYINDEX);
lua_pop(h->L, 1); // traceback handler
if (pushCallback(h, "on_open")) {
lua_pushinteger(h->L, h->body_w);
lua_pushinteger(h->L, h->body_h);
guardedCall(h, 2);
}
serviceDeferredClose();
if (s_h) Serial.printf("[LUAAPP] %s open, heap=%u\n", id, (unsigned)h->heap.used);
return s_h != nullptr;
}
bool luaAppLaunchFile(const char* id, const char* title, const char* embedded, size_t embedded_len) {
fs::FS* fs = luaHostAppFs();
if (fs) {
char rel[40], path[64];
snprintf(rel, sizeof rel, "/apps/%s.lua", id);
luaHostAppPath(path, sizeof path, rel);
File f = fs->open(path, "r");
if (f) {
size_t sz = f.size();
if (sz > 0 && sz <= kMaxSrc) {
char* buf = (char*)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (buf) {
size_t rd = f.read((uint8_t*)buf, sz);
f.close();
bool ok = rd == sz && luaAppLaunch(id, title, buf, sz);
heap_caps_free(buf);
if (ok) return true;
// fall through to the embedded copy on any failure
} else f.close();
} else f.close();
}
}
if (embedded && embedded_len) return luaAppLaunch(id, title, embedded, embedded_len);
return false;
}
// ---- net worker entry points (UITask's tile/net worker calls these) --------
bool luaNetWorkerPending() { return s_net_pending; }
void luaNetWorkerService(void* client, void* http) {
s_net_pending = false;
s_net_result = s_net_buf ? luaStoreHttpGetOpaque(client, http, s_net_url, s_net_buf, s_net_cap) : -1;
s_net_done = true;
}
#endif // CAP_LUA_APPS