From 393ce3c0edcf865cf3b0988f77295ca3efbe118b Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sat, 1 Aug 2026 11:17:42 -0400 Subject: [PATCH] refactor: split the Lua runtime, share test stubs, add a Makefile lua_app.cpp had grown to 701 lines holding every binding, the module loader and the app lifecycle, so new bindings landed wherever the cursor was. Each Lua table now has its own file under bindings/, and the app is recovered from the lua_State's extra space instead of a file-static, so a second state cannot reach the wrong app. Three tests each redeclared the binding surface, which broke twice this session when a binding changed; test/fake_device.lua is now the single stub. `make test` runs all four suites and pins Lua 5.4, matching the vendored interpreter rather than the 5.2 the tests had silently been using. --- Makefile | 36 +++ README.md | 41 ++- flake.nix | 4 + src/lua/bindings.h | 30 ++ src/lua/bindings/fs.cpp | 71 +++++ src/lua/bindings/gui.cpp | 137 +++++++++ src/lua/bindings/input.cpp | 45 +++ src/lua/bindings/sys.cpp | 88 ++++++ src/lua/bindings/wifi.cpp | 105 +++++++ src/lua/lua_app.cpp | 548 ++-------------------------------- src/lua/module_loader.cpp | 105 +++++++ test/fake_device.lua | 102 +++++++ test/settings_calibration.lua | 63 +--- test/ui_layout.lua | 24 +- test/ui_theme.lua | 24 +- 15 files changed, 795 insertions(+), 628 deletions(-) create mode 100644 Makefile create mode 100644 src/lua/bindings.h create mode 100644 src/lua/bindings/fs.cpp create mode 100644 src/lua/bindings/gui.cpp create mode 100644 src/lua/bindings/input.cpp create mode 100644 src/lua/bindings/sys.cpp create mode 100644 src/lua/bindings/wifi.cpp create mode 100644 src/lua/module_loader.cpp create mode 100644 test/fake_device.lua diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1067e98 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +# Run inside `nix develop`, which provides pio, lua and a compiler. +# LUA is pinned to 5.4 to match the interpreter vendored into the firmware. + +LUA ?= lua +CXX ?= c++ +CXXFLAGS ?= -std=c++11 -Wall -Wextra +BUILD_DIR := .pio/build/esp32-32e +LUA_TESTS := test/ui_layout.lua test/ui_theme.lua test/settings_calibration.lua + +.PHONY: test test-lua test-cpp build upload monitor clean + +test: test-cpp test-lua + +test-lua: + @$(LUA) -e 'assert(_VERSION == "Lua 5.4", "tests need Lua 5.4 to match the firmware, got " .. _VERSION)' + @for t in $(LUA_TESTS); do printf '%-32s ' "$$t"; $(LUA) "$$t" || exit 1; done + +test-cpp: $(BUILD_DIR)/round_rect_test + @printf '%-32s ' test/round_rect_test.cpp; $(BUILD_DIR)/round_rect_test + +$(BUILD_DIR)/round_rect_test: test/round_rect_test.cpp src/gfx/round_rect.h + @mkdir -p $(@D) + @$(CXX) $(CXXFLAGS) $< -o $@ + +build: + pio run + +upload: + pio run -t upload + +monitor: + pio device monitor + +clean: + pio run -t clean + rm -f $(BUILD_DIR)/round_rect_test diff --git a/README.md b/README.md index 6912fe9..e7f19b2 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,25 @@ card, launched from an on-screen menu. Inspired by crosspoint-reader's plugin sy ## Build & flash ```sh -nix develop # provides pio -pio run # build -pio run -t upload # flash over USB-C -pio device monitor # serial logs +nix develop # provides pio, make, lua 5.4 and a host compiler +make build # build +make test # host tests (see below) +make upload # flash over USB-C +make monitor # serial logs +``` + +``` +src/ + main.cpp runtime loop and the fallback screen + settings.{h,cpp} /settings.lua persistence + gfx/ drawing maths, free of Arduino headers so it is testable + lua/ + lua_app.{h,cpp} app lifecycle: the lua_State, callbacks, teardown + bindings.h shared internals for the binding files + bindings/ one file per Lua table: gui, sys, input, fs, wifi + module_loader SD-backed require, loadfile and dofile +sdcard/ copied to the card: apps/ and lib/ +test/ host tests, run by `make test` ``` Copy `sdcard/` to the SD card root: apps live in `/apps//main.lua` and shared @@ -67,14 +82,20 @@ app exits. The glass itself is always portrait, so a rotated UI is drawn sideways on it. ```sh -nix run nixpkgs#lua -- test/ui_layout.lua # layout, hit testing, capture -nix run nixpkgs#lua -- test/ui_theme.lua # palette derivation and inheritance -nix run nixpkgs#lua -- test/settings_calibration.lua # calibration math and menu flow - -# Rounded-rect geometry and blending, built straight from the shared header: -c++ -std=c++11 test/round_rect_test.cpp -o /tmp/round_rect_test && /tmp/round_rect_test +make test # everything below, non-zero on the first failure ``` +| Test | Covers | +|---|---| +| `test/round_rect_test.cpp` | corner geometry, coverage and RGB565 blending | +| `test/ui_layout.lua` | layout rects, hit testing, press capture | +| `test/ui_theme.lua` | palette derivation and inheritance | +| `test/settings_calibration.lua` | calibration maths, menu and wifi flows | + +The Lua tests run against `test/fake_device.lua`, the one place the binding surface is +stubbed, and `make test` refuses to run on anything but Lua 5.4 — the version the +firmware vendors, so the tests cannot pass on a dialect the device will not run. + ## Drawing rounded surfaces `gui.roundRect(x, y, w, h, radius, bg, top, bottom, border)` draws a whole surface in diff --git a/flake.nix b/flake.nix index 40f20c1..16bd2fe 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,10 @@ default = pkgs.mkShell { packages = with pkgs; [ platformio-core + gnumake + # Host tests run on the same Lua version the firmware vendors. + lua5_4 + gcc ]; shellHook = '' export PLATFORMIO_CORE_DIR="$PWD/.cache/platformio" diff --git a/src/lua/bindings.h b/src/lua/bindings.h new file mode 100644 index 0000000..10d1b56 --- /dev/null +++ b/src/lua/bindings.h @@ -0,0 +1,30 @@ +#pragma once + +// Shared internals for the binding translation units. Each `gui`, `sys`, ... table +// lives in its own file so that adding a binding touches one small file rather than +// the runtime everything else also edits. + +extern "C" { +#include +#include +} + +#include + +class LuaApp; + +// The owning app, recovered from the state itself rather than a file-static, so a +// second lua_State (a coroutine, or a future second app) cannot reach the wrong one. +LuaApp* app(lua_State* L); +void bindApp(lua_State* L, LuaApp* owner); + +void registerGui(lua_State* L); +void registerSys(lua_State* L); +void registerInput(lua_State* L); +void registerFs(lua_State* L); +void registerWifi(lua_State* L); + +// Reads a script through the SD mount; Lua's stock loaders use stdio, which cannot +// see it. Shared because both `require` and the app launcher need it. +bool readScript(const char* path, String& out); +int loadScript(lua_State* L, const char* path); diff --git a/src/lua/bindings/fs.cpp b/src/lua/bindings/fs.cpp new file mode 100644 index 0000000..4bcec73 --- /dev/null +++ b/src/lua/bindings/fs.cpp @@ -0,0 +1,71 @@ +// SD card access. Paths are absolute and rooted at the card. + +#include + +#include "../bindings.h" +#include "../lua_app.h" + +static int l_fs_readFile(lua_State* L) { + File file = SD.open(luaL_checkstring(L, 1)); + if (!file || file.isDirectory()) { + lua_pushnil(L); + return 1; + } + // ponytail: whole-file reads only, capped at 64KB + String content = file.readString(); + file.close(); + if (content.length() > LuaApp::READ_CAP) content.remove(LuaApp::READ_CAP); + lua_pushlstring(L, content.c_str(), content.length()); + return 1; +} + +static int l_fs_writeFile(lua_State* L) { + File file = SD.open(luaL_checkstring(L, 1), FILE_WRITE); + if (!file) { + lua_pushboolean(L, false); + return 1; + } + size_t length; + const char* data = luaL_checklstring(L, 2, &length); + bool ok = file.write(reinterpret_cast(data), length) == length; + file.close(); + lua_pushboolean(L, ok); + return 1; +} + +static int l_fs_exists(lua_State* L) { + lua_pushboolean(L, SD.exists(luaL_checkstring(L, 1))); + return 1; +} + +// Directories and files list through the same walk, differing only in what they keep. +static int listEntries(lua_State* L, bool directories) { + File dir = SD.open(luaL_checkstring(L, 1)); + lua_newtable(L); + if (!dir || !dir.isDirectory()) { + if (dir) dir.close(); + return 1; + } + int index = 1; + for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { + if (entry.isDirectory() == directories && entry.name()[0] != '.') { + lua_pushstring(L, entry.name()); + lua_rawseti(L, -2, index++); + } + entry.close(); + } + dir.close(); + return 1; +} + +static int l_fs_listDirs(lua_State* L) { return listEntries(L, true); } + +static int l_fs_listFiles(lua_State* L) { return listEntries(L, false); } + +void registerFs(lua_State* L) { + static const luaL_Reg lib[] = {{"readFile", l_fs_readFile}, {"writeFile", l_fs_writeFile}, + {"exists", l_fs_exists}, {"listFiles", l_fs_listFiles}, + {"listDirs", l_fs_listDirs}, {nullptr, nullptr}}; + luaL_newlib(L, lib); + lua_setglobal(L, "fs"); +} diff --git a/src/lua/bindings/gui.cpp b/src/lua/bindings/gui.cpp new file mode 100644 index 0000000..98b24bf --- /dev/null +++ b/src/lua/bindings/gui.cpp @@ -0,0 +1,137 @@ +// Drawing primitives. Colours are RGB565 integers throughout. + +#include + +#include "../../gfx/round_rect.h" +#include "../bindings.h" +#include "../lua_app.h" + +static int l_gui_width(lua_State* L) { + lua_pushinteger(L, app(L)->tft.width()); + return 1; +} + +static int l_gui_height(lua_State* L) { + lua_pushinteger(L, app(L)->tft.height()); + return 1; +} + +static int l_gui_clear(lua_State* L) { + app(L)->tft.fillScreen(luaL_optinteger(L, 1, TFT_WHITE)); + return 0; +} + +static int l_gui_fillRect(lua_State* L) { + app(L)->tft.fillRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), + luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); + return 0; +} + +static int l_gui_drawRect(lua_State* L) { + app(L)->tft.drawRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), + luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); + return 0; +} + +// Anti-aliased, so it needs the surface behind it to blend the rim against. +static int l_gui_fillCircle(lua_State* L) { + app(L)->tft.fillSmoothCircle(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), + luaL_checkinteger(L, 3), luaL_checkinteger(L, 4), + luaL_optinteger(L, 5, TFT_WHITE)); + return 0; +} + +static int l_gui_drawLine(lua_State* L) { + app(L)->tft.drawLine(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), + luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); + return 0; +} + +// One primitive draws the whole surface: fill (solid or vertical gradient) and border +// derive from the same distance field, so they cannot disagree at the corners the way +// two separate rounded-rect algorithms did. The panel has no alpha, so edge pixels are +// blended against `bg`, the colour of the surface underneath. +static int l_gui_roundRect(lua_State* L) { + int x = luaL_checkinteger(L, 1), y = luaL_checkinteger(L, 2); + int w = luaL_checkinteger(L, 3), h = luaL_checkinteger(L, 4); + float radius = luaL_checkinteger(L, 5); + uint16_t bg = luaL_checkinteger(L, 6); + bool hasFill = !lua_isnoneornil(L, 7); + bool hasBorder = !lua_isnoneornil(L, 9); + uint16_t top = hasFill ? luaL_checkinteger(L, 7) : 0; + uint16_t bottom = lua_isnoneornil(L, 8) ? top : luaL_checkinteger(L, 8); + uint16_t border = hasBorder ? luaL_checkinteger(L, 9) : 0; + if (w <= 0 || h <= 0 || w > LuaApp::MAX_SPAN) return 0; + radius = constrain(radius, 0.0f, min(w, h) / 2.0f); + + float halfWidth = w * 0.5f, halfHeight = h * 0.5f; + static uint16_t span[LuaApp::MAX_SPAN]; + + // pushImage sends the buffer verbatim, but the panel wants each colour big-endian. + bool previousSwap = app(L)->tft.getSwapBytes(); + app(L)->tft.setSwapBytes(true); + + for (int row = 0; row < h; row++) { + uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0; + float py = row + 0.5f - halfHeight; + for (int column = 0; column < w; column++) { + float distance = + gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius); + float outer = gfx::coverage(distance); + // The border is the ring between the shape and the same shape inset by its width. + float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer; + uint16_t pixel = bg; + if (hasFill) pixel = gfx::blend565(pixel, fill, inner); + if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner); + span[column] = pixel; + } + app(L)->tft.pushImage(x, y + row, w, 1, span); + } + app(L)->tft.setSwapBytes(previousSwap); + return 0; +} + +static int l_gui_fontHeight(lua_State* L) { + lua_pushinteger(L, app(L)->tft.fontHeight()); + return 1; +} + +static int l_gui_textWidth(lua_State* L) { + lua_pushinteger(L, app(L)->tft.textWidth(luaL_checkstring(L, 1))); + return 1; +} + +static int l_gui_drawText(lua_State* L) { + const char* text = luaL_checkstring(L, 1); + int x = luaL_checkinteger(L, 2); + int y = luaL_checkinteger(L, 3); + app(L)->tft.setTextColor(luaL_optinteger(L, 4, TFT_BLACK), luaL_optinteger(L, 5, TFT_WHITE)); + app(L)->tft.drawString(text, x, y); + return 0; +} + +// Only the current frame; sys.setRotation() is the persisted one. +static int l_gui_setRotation(lua_State* L) { + app(L)->tft.setRotation(luaL_checkinteger(L, 1)); + return 0; +} + +// ponytail: RGB565 hex constants (0xF800 etc.) live in Lua; apps do gui.color(255,0,0) here. +static int l_gui_color(lua_State* L) { + lua_pushinteger(L, app(L)->tft.color565(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), + luaL_checkinteger(L, 3))); + return 1; +} + +void registerGui(lua_State* L) { + static const luaL_Reg lib[] = { + {"width", l_gui_width}, {"height", l_gui_height}, + {"clear", l_gui_clear}, {"fillRect", l_gui_fillRect}, + {"drawRect", l_gui_drawRect}, {"fillCircle", l_gui_fillCircle}, + {"drawLine", l_gui_drawLine}, {"drawText", l_gui_drawText}, + {"roundRect", l_gui_roundRect}, {"fontHeight", l_gui_fontHeight}, + {"textWidth", l_gui_textWidth}, {"setRotation", l_gui_setRotation}, + {"color", l_gui_color}, {nullptr, nullptr}}; + luaL_newlib(L, lib); + lua_setglobal(L, "gui"); +} diff --git a/src/lua/bindings/input.cpp b/src/lua/bindings/input.cpp new file mode 100644 index 0000000..a4c668c --- /dev/null +++ b/src/lua/bindings/input.cpp @@ -0,0 +1,45 @@ +// Touch panel reads. Coordinates are calibrated and rotated by LuaApp::mapTouch; +// the raw ADC pair is exposed separately because calibration cannot use itself. + +#include "../bindings.h" +#include "../lua_app.h" + +static int l_input_getTouch(lua_State* L) { + LuaApp* owner = app(L); + if (!owner->touch.touched()) { + lua_pushnil(L); + return 1; + } + TS_Point point = owner->touch.getPoint(); + int16_t x, y; + LuaApp::mapTouch(owner->tft, point, x, y); + lua_pushinteger(L, x); + lua_pushinteger(L, y); + return 2; +} + +static int l_input_getRawTouch(lua_State* L) { + LuaApp* owner = app(L); + if (!owner->touch.touched()) { + lua_pushnil(L); + return 1; + } + TS_Point point = owner->touch.getPoint(); + lua_pushinteger(L, point.x); + lua_pushinteger(L, point.y); + return 2; +} + +static int l_input_touched(lua_State* L) { + lua_pushboolean(L, app(L)->touch.touched()); + return 1; +} + +void registerInput(lua_State* L) { + static const luaL_Reg lib[] = {{"getTouch", l_input_getTouch}, + {"getRawTouch", l_input_getRawTouch}, + {"touched", l_input_touched}, + {nullptr, nullptr}}; + luaL_newlib(L, lib); + lua_setglobal(L, "input"); +} diff --git a/src/lua/bindings/sys.cpp b/src/lua/bindings/sys.cpp new file mode 100644 index 0000000..f9a6798 --- /dev/null +++ b/src/lua/bindings/sys.cpp @@ -0,0 +1,88 @@ +// Process control and persisted settings, plus the one-function `log` table. + +#include "../../settings.h" +#include "../bindings.h" +#include "../lua_app.h" + +static int l_sys_millis(lua_State* L) { + lua_pushinteger(L, millis()); + return 1; +} + +static int l_sys_exit(lua_State* L) { + app(L)->requestExit(); + return 0; +} + +static int l_sys_launch(lua_State* L) { + app(L)->requestLaunch(luaL_checkstring(L, 1)); + app(L)->requestExit(); + return 0; +} + +static int l_sys_getRotation(lua_State* L) { + lua_pushinteger(L, settings.rotation); + return 1; +} + +// Persisted, unlike gui.setRotation() which only changes the current frame. +static int l_sys_setRotation(lua_State* L) { + if (!settings.setRotation(luaL_checkinteger(L, 1))) { + lua_pushboolean(L, false); + return 1; + } + app(L)->tft.setRotation(settings.rotationIndex()); + lua_pushboolean(L, settings.save()); + return 1; +} + +static int l_sys_getTheme(lua_State* L) { + lua_pushstring(L, settings.theme.c_str()); + return 1; +} + +// The firmware only stores the name; /lib/theme.lua decides what it looks like. +static int l_sys_setTheme(lua_State* L) { + size_t length; + const char* name = luaL_checklstring(L, 1, &length); + if (!length || length > 32) { + lua_pushboolean(L, false); + return 1; + } + settings.theme = String(name, length); + lua_pushboolean(L, settings.save()); + return 1; +} + +static int l_sys_setCalibration(lua_State* L) { + settings.touchX0 = luaL_checkinteger(L, 1); + settings.touchY0 = luaL_checkinteger(L, 2); + settings.touchX1 = luaL_checkinteger(L, 3); + settings.touchY1 = luaL_checkinteger(L, 4); + lua_pushboolean(L, settings.save()); + return 1; +} + +static int l_log_info(lua_State* L) { + Serial.printf("[lua] %s\n", luaL_checkstring(L, 1)); + return 0; +} + +void registerSys(lua_State* L) { + static const luaL_Reg lib[] = {{"millis", l_sys_millis}, + {"exit", l_sys_exit}, + {"launch", l_sys_launch}, + {"getRotation", l_sys_getRotation}, + {"setRotation", l_sys_setRotation}, + {"getTheme", l_sys_getTheme}, + {"setTheme", l_sys_setTheme}, + {"setCalibration", l_sys_setCalibration}, + {nullptr, nullptr}}; + luaL_newlib(L, lib); + lua_setglobal(L, "sys"); + + // Too small to deserve its own translation unit. + static const luaL_Reg logLib[] = {{"info", l_log_info}, {nullptr, nullptr}}; + luaL_newlib(L, logLib); + lua_setglobal(L, "log"); +} diff --git a/src/lua/bindings/wifi.cpp b/src/lua/bindings/wifi.cpp new file mode 100644 index 0000000..ec5e8e7 --- /dev/null +++ b/src/lua/bindings/wifi.cpp @@ -0,0 +1,105 @@ +// Station-mode WiFi. Credentials live in the same settings file as everything else, +// so connecting persists them and a saved network reconnects at boot. + +#include + +#include "../../settings.h" +#include "../bindings.h" +#include "../lua_app.h" + +static constexpr uint32_t CONNECT_TIMEOUT_MS = 15000; +static constexpr size_t MAX_SSID = 32; +static constexpr size_t MAX_PASSWORD = 64; + +// Arduino reports WL_DISCONNECTED both before an attempt and after a failed one, so +// the attempt start is tracked here to tell "still trying" from "gave up". +static uint32_t connectStartedAt; + +static int l_wifi_scan(lua_State* L) { + WiFi.mode(WIFI_STA); + int count = WiFi.scanNetworks(false, true); + lua_newtable(L); + if (count < 0) return 1; + for (int i = 0; i < count; i++) { + lua_newtable(L); + lua_pushstring(L, WiFi.SSID(i).c_str()); + lua_setfield(L, -2, "ssid"); + lua_pushinteger(L, WiFi.RSSI(i)); + lua_setfield(L, -2, "rssi"); + lua_pushboolean(L, WiFi.encryptionType(i) != WIFI_AUTH_OPEN); + lua_setfield(L, -2, "secure"); + lua_rawseti(L, -2, i + 1); + } + WiFi.scanDelete(); + return 1; +} + +static int l_wifi_connect(lua_State* L) { + size_t ssidLength, passwordLength; + const char* ssid = luaL_checklstring(L, 1, &ssidLength); + const char* password = luaL_optlstring(L, 2, "", &passwordLength); + if (!ssidLength || ssidLength > MAX_SSID || passwordLength > MAX_PASSWORD) { + lua_pushboolean(L, false); + return 1; + } + settings.wifiSsid = String(ssid, ssidLength); + settings.wifiPassword = String(password, passwordLength); + if (!settings.save()) { + lua_pushboolean(L, false); + return 1; + } + WiFi.mode(WIFI_STA); + WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str()); + connectStartedAt = millis(); + lua_pushboolean(L, true); + return 1; +} + +static const char* connectionState(wl_status_t status) { + if (status == WL_CONNECTED) { + connectStartedAt = 0; + return "connected"; + } + if (status == WL_NO_SSID_AVAIL) return "not_found"; + if (status == WL_CONNECT_FAILED) return "failed"; + if (!connectStartedAt) return "disconnected"; + return millis() - connectStartedAt >= CONNECT_TIMEOUT_MS ? "failed" : "connecting"; +} + +static int l_wifi_status(lua_State* L) { + wl_status_t status = WiFi.status(); + // A boot-time reconnect starts before any Lua call, so adopt it as an attempt. + if (!connectStartedAt && settings.wifiSsid.length() && status != WL_CONNECTED) { + connectStartedAt = millis(); + } + + lua_newtable(L); + lua_pushstring(L, connectionState(status)); + lua_setfield(L, -2, "state"); + lua_pushstring(L, status == WL_CONNECTED ? WiFi.SSID().c_str() : settings.wifiSsid.c_str()); + lua_setfield(L, -2, "ssid"); + lua_pushstring(L, status == WL_CONNECTED ? WiFi.localIP().toString().c_str() : ""); + lua_setfield(L, -2, "ip"); + lua_pushinteger(L, status == WL_CONNECTED ? WiFi.RSSI() : 0); + lua_setfield(L, -2, "rssi"); + return 1; +} + +static int l_wifi_forget(lua_State* L) { + WiFi.disconnect(true, false); + settings.wifiSsid = ""; + settings.wifiPassword = ""; + connectStartedAt = 0; + lua_pushboolean(L, settings.save()); + return 1; +} + +void registerWifi(lua_State* L) { + static const luaL_Reg lib[] = {{"scan", l_wifi_scan}, + {"connect", l_wifi_connect}, + {"status", l_wifi_status}, + {"forget", l_wifi_forget}, + {nullptr, nullptr}}; + luaL_newlib(L, lib); + lua_setglobal(L, "wifi"); +} diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index f9db9b4..1008a7b 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -1,153 +1,23 @@ +// App lifecycle: own the lua_State, dispatch the callbacks, and tear down safely. +// The bindings themselves live in bindings/, the loader in module_loader.cpp. + #include "lua_app.h" -#include -#include -#include +#include -#include "../gfx/round_rect.h" #include "../settings.h" +#include "bindings.h" extern "C" { #include #include } -static LuaApp* self; -static uint32_t wifiConnectStartedAt; +// LUA_EXTRASPACE is a pointer-sized block ahead of every lua_State, and Lua copies it +// into new coroutines, so the owner is reachable from any state without a global. +LuaApp* app(lua_State* L) { return *static_cast(lua_getextraspace(L)); } -static LuaApp* app(lua_State* L) { return self; } - -// ---- sys ---- - -static int l_sys_millis(lua_State* L) { - lua_pushinteger(L, millis()); - return 1; -} - -static int l_sys_exit(lua_State* L) { - app(L)->requestExit(); - return 0; -} - -// ---- gui ---- - -static int l_gui_width(lua_State* L) { - lua_pushinteger(L, app(L)->tft.width()); - return 1; -} - -static int l_gui_height(lua_State* L) { - lua_pushinteger(L, app(L)->tft.height()); - return 1; -} - -static int l_gui_clear(lua_State* L) { - app(L)->tft.fillScreen(luaL_optinteger(L, 1, TFT_WHITE)); - return 0; -} - -static int l_gui_fillRect(lua_State* L) { - app(L)->tft.fillRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); - return 0; -} - -static int l_gui_drawRect(lua_State* L) { - app(L)->tft.drawRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); - return 0; -} - -// Anti-aliased, so it needs the surface behind it to blend the rim against. -static int l_gui_fillCircle(lua_State* L) { - app(L)->tft.fillSmoothCircle(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), - luaL_checkinteger(L, 3), luaL_checkinteger(L, 4), - luaL_optinteger(L, 5, TFT_WHITE)); - return 0; -} - -static int l_gui_drawLine(lua_State* L) { - app(L)->tft.drawLine(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3), - luaL_checkinteger(L, 4), luaL_checkinteger(L, 5)); - return 0; -} - -// One primitive draws the whole surface: fill (solid or vertical gradient) and border -// derive from the same distance field, so they cannot disagree at the corners the way -// two separate rounded-rect algorithms did. The panel has no alpha, so edge pixels are -// blended against `bg`, the colour of the surface underneath. -static int l_gui_roundRect(lua_State* L) { - int x = luaL_checkinteger(L, 1), y = luaL_checkinteger(L, 2); - int w = luaL_checkinteger(L, 3), h = luaL_checkinteger(L, 4); - float radius = luaL_checkinteger(L, 5); - uint16_t bg = luaL_checkinteger(L, 6); - bool hasFill = !lua_isnoneornil(L, 7); - bool hasBorder = !lua_isnoneornil(L, 9); - uint16_t top = hasFill ? luaL_checkinteger(L, 7) : 0; - uint16_t bottom = lua_isnoneornil(L, 8) ? top : luaL_checkinteger(L, 8); - uint16_t border = hasBorder ? luaL_checkinteger(L, 9) : 0; - if (w <= 0 || h <= 0 || w > LuaApp::MAX_SPAN) return 0; - radius = constrain(radius, 0.0f, min(w, h) / 2.0f); - - float halfWidth = w * 0.5f, halfHeight = h * 0.5f; - static uint16_t span[LuaApp::MAX_SPAN]; - - // pushImage sends the buffer verbatim, but the panel wants each colour big-endian. - bool previousSwap = app(L)->tft.getSwapBytes(); - app(L)->tft.setSwapBytes(true); - - for (int row = 0; row < h; row++) { - uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0; - float py = row + 0.5f - halfHeight; - for (int column = 0; column < w; column++) { - float distance = - gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius); - float outer = gfx::coverage(distance); - // The border is the ring between the shape and the same shape inset by its width. - float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer; - uint16_t pixel = bg; - if (hasFill) pixel = gfx::blend565(pixel, fill, inner); - if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner); - span[column] = pixel; - } - app(L)->tft.pushImage(x, y + row, w, 1, span); - } - app(L)->tft.setSwapBytes(previousSwap); - return 0; -} - -static int l_gui_fontHeight(lua_State* L) { - lua_pushinteger(L, app(L)->tft.fontHeight()); - return 1; -} - -static int l_gui_textWidth(lua_State* L) { - lua_pushinteger(L, app(L)->tft.textWidth(luaL_checkstring(L, 1))); - return 1; -} - -static int l_gui_drawText(lua_State* L) { - const char* text = luaL_checkstring(L, 1); - int x = luaL_checkinteger(L, 2); - int y = luaL_checkinteger(L, 3); - app(L)->tft.setTextColor(luaL_optinteger(L, 4, TFT_BLACK), luaL_optinteger(L, 5, TFT_WHITE)); - app(L)->tft.drawString(text, x, y); - return 0; -} - -static int l_gui_setRotation(lua_State* L) { - app(L)->tft.setRotation(luaL_checkinteger(L, 1)); - return 0; -} - -// ponytail: RGB565 hex constants (0xF800 etc.) live in Lua; apps do display.color(255,0,0) here. -static int l_gui_color(lua_State* L) { - lua_pushinteger(L, app(L)->tft.color565(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), - luaL_checkinteger(L, 3))); - return 1; -} - -// ---- input ---- +void bindApp(lua_State* L, LuaApp* owner) { *static_cast(lua_getextraspace(L)) = owner; } void LuaApp::mapTouch(TFT_eSPI& tft, const TS_Point& p, int16_t& x, int16_t& y) { int16_t nx = constrain(map(p.x, settings.touchX0, settings.touchX1, 0, PANEL_W), 0, PANEL_W - 1); @@ -172,355 +42,6 @@ void LuaApp::mapTouch(TFT_eSPI& tft, const TS_Point& p, int16_t& x, int16_t& y) } } -static bool touchPoint(lua_State* L) { - LuaApp* a = app(L); - if (!a->touch.touched()) return false; - TS_Point p = a->touch.getPoint(); - int16_t x, y; - LuaApp::mapTouch(a->tft, p, x, y); - lua_pushinteger(L, x); - lua_pushinteger(L, y); - return true; -} - -static int l_input_getTouch(lua_State* L) { - if (!touchPoint(L)) { - lua_pushnil(L); - return 1; - } - return 2; -} - -// Calibration needs uncalibrated readings, so the settings app reads raw ADC. -static int l_input_getRawTouch(lua_State* L) { - LuaApp* a = app(L); - if (!a->touch.touched()) { - lua_pushnil(L); - return 1; - } - TS_Point p = a->touch.getPoint(); - lua_pushinteger(L, p.x); - lua_pushinteger(L, p.y); - return 2; -} - -static int l_sys_launch(lua_State* L) { - app(L)->requestLaunch(luaL_checkstring(L, 1)); - app(L)->requestExit(); - return 0; -} - -static int l_sys_getRotation(lua_State* L) { - lua_pushinteger(L, settings.rotation); - return 1; -} - -// Persisted, unlike gui.setRotation() which only changes the current frame. -static int l_sys_setRotation(lua_State* L) { - if (!settings.setRotation(luaL_checkinteger(L, 1))) { - lua_pushboolean(L, false); - return 1; - } - app(L)->tft.setRotation(settings.rotationIndex()); - lua_pushboolean(L, settings.save()); - return 1; -} - -static int l_sys_getTheme(lua_State* L) { - lua_pushstring(L, settings.theme.c_str()); - return 1; -} - -// The firmware only stores the name; /lib/theme.lua decides what it looks like. -static int l_sys_setTheme(lua_State* L) { - size_t length; - const char* name = luaL_checklstring(L, 1, &length); - if (!length || length > 32) { - lua_pushboolean(L, false); - return 1; - } - settings.theme = String(name, length); - lua_pushboolean(L, settings.save()); - return 1; -} - -static int l_sys_setCalibration(lua_State* L) { - settings.touchX0 = luaL_checkinteger(L, 1); - settings.touchY0 = luaL_checkinteger(L, 2); - settings.touchX1 = luaL_checkinteger(L, 3); - settings.touchY1 = luaL_checkinteger(L, 4); - lua_pushboolean(L, settings.save()); - return 1; -} - -static int l_input_touched(lua_State* L) { - lua_pushboolean(L, app(L)->touch.touched()); - return 1; -} - -// ---- fs ---- - -static int l_fs_readFile(lua_State* L) { - File f = SD.open(luaL_checkstring(L, 1)); - if (!f || f.isDirectory()) { - lua_pushnil(L); - return 1; - } - // ponytail: whole-file reads only, capped at 64KB - String content = f.readString(); - f.close(); - if (content.length() > LuaApp::READ_CAP) content.remove(LuaApp::READ_CAP); - lua_pushlstring(L, content.c_str(), content.length()); - return 1; -} - -static int l_fs_writeFile(lua_State* L) { - File f = SD.open(luaL_checkstring(L, 1), FILE_WRITE); - if (!f) { - lua_pushboolean(L, false); - return 1; - } - size_t len; - const char* data = luaL_checklstring(L, 2, &len); - bool ok = f.write(reinterpret_cast(data), len) == len; - f.close(); - lua_pushboolean(L, ok); - return 1; -} - -static int l_fs_exists(lua_State* L) { - lua_pushboolean(L, SD.exists(luaL_checkstring(L, 1))); - return 1; -} - -static int l_fs_listDirs(lua_State* L) { - File dir = SD.open(luaL_checkstring(L, 1)); - lua_newtable(L); - if (!dir || !dir.isDirectory()) { - if (dir) dir.close(); - return 1; - } - int i = 1; - for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { - if (entry.isDirectory() && entry.name()[0] != '.') { - lua_pushstring(L, entry.name()); - lua_rawseti(L, -2, i++); - } - entry.close(); - } - dir.close(); - return 1; -} - -static int l_fs_listFiles(lua_State* L) { - File dir = SD.open(luaL_checkstring(L, 1)); - lua_newtable(L); - if (!dir || !dir.isDirectory()) { - if (dir) dir.close(); - return 1; - } - int i = 1; - for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { - if (!entry.isDirectory() && entry.name()[0] != '.') { - lua_pushstring(L, entry.name()); - lua_rawseti(L, -2, i++); - } - entry.close(); - } - dir.close(); - return 1; -} - -// ---- wifi ---- - -static int l_wifi_scan(lua_State* L) { - WiFi.mode(WIFI_STA); - int count = WiFi.scanNetworks(false, true); - lua_newtable(L); - if (count < 0) return 1; - for (int i = 0; i < count; i++) { - lua_newtable(L); - lua_pushstring(L, WiFi.SSID(i).c_str()); - lua_setfield(L, -2, "ssid"); - lua_pushinteger(L, WiFi.RSSI(i)); - lua_setfield(L, -2, "rssi"); - lua_pushboolean(L, WiFi.encryptionType(i) != WIFI_AUTH_OPEN); - lua_setfield(L, -2, "secure"); - lua_rawseti(L, -2, i + 1); - } - WiFi.scanDelete(); - return 1; -} - -static int l_wifi_connect(lua_State* L) { - size_t ssidLength, passwordLength; - const char* ssid = luaL_checklstring(L, 1, &ssidLength); - const char* password = luaL_optlstring(L, 2, "", &passwordLength); - if (!ssidLength || ssidLength > 32 || passwordLength > 64) { - lua_pushboolean(L, false); - return 1; - } - settings.wifiSsid = String(ssid, ssidLength); - settings.wifiPassword = String(password, passwordLength); - if (!settings.save()) { - lua_pushboolean(L, false); - return 1; - } - WiFi.mode(WIFI_STA); - WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str()); - wifiConnectStartedAt = millis(); - lua_pushboolean(L, true); - return 1; -} - -static int l_wifi_status(lua_State* L) { - wl_status_t status = WiFi.status(); - if (!wifiConnectStartedAt && settings.wifiSsid.length() && status != WL_CONNECTED) { - wifiConnectStartedAt = millis(); - } - const char* state = "disconnected"; - if (status == WL_CONNECTED) { - state = "connected"; - wifiConnectStartedAt = 0; - } else if (status == WL_NO_SSID_AVAIL) { - state = "not_found"; - } else if (status == WL_CONNECT_FAILED || - (wifiConnectStartedAt && millis() - wifiConnectStartedAt >= 15000)) { - state = "failed"; - } else if (wifiConnectStartedAt) { - state = "connecting"; - } - - lua_newtable(L); - lua_pushstring(L, state); - lua_setfield(L, -2, "state"); - lua_pushstring(L, status == WL_CONNECTED ? WiFi.SSID().c_str() : settings.wifiSsid.c_str()); - lua_setfield(L, -2, "ssid"); - lua_pushstring(L, status == WL_CONNECTED ? WiFi.localIP().toString().c_str() : ""); - lua_setfield(L, -2, "ip"); - lua_pushinteger(L, status == WL_CONNECTED ? WiFi.RSSI() : 0); - lua_setfield(L, -2, "rssi"); - return 1; -} - -static int l_wifi_forget(lua_State* L) { - WiFi.disconnect(true, false); - settings.wifiSsid = ""; - settings.wifiPassword = ""; - wifiConnectStartedAt = 0; - lua_pushboolean(L, settings.save()); - return 1; -} - -// ---- log ---- - -static int l_log(lua_State* L) { - Serial.printf("[lua] %s\n", luaL_checkstring(L, 1)); - return 0; -} - -// ---- module loading ---- - -// Lua's stock loaders go through stdio, which cannot see the SD mount, so every -// path into the filesystem is replaced with one that reads through SD. -static bool readScript(const char* path, String& out) { - // Probe first: a missed SD.open logs a VFS error, and the searcher misses by design. - if (!SD.exists(path)) return false; - File f = SD.open(path); - if (!f || f.isDirectory()) { - if (f) f.close(); - return false; - } - out = f.readString(); - f.close(); - return true; -} - -static int loadScript(lua_State* L, const char* path) { - String source; - if (!readScript(path, source)) { - lua_pushfstring(L, "cannot open %s", path); - return LUA_ERRFILE; - } - String chunkname = String("@") + path; - return luaL_loadbuffer(L, source.c_str(), source.length(), chunkname.c_str()); -} - -static int l_loadfile(lua_State* L) { - if (loadScript(L, luaL_checkstring(L, 1)) == LUA_OK) return 1; - lua_pushnil(L); - lua_insert(L, -2); - return 2; -} - -static int l_dofile(lua_State* L) { - const char* path = luaL_checkstring(L, 1); - if (loadScript(L, path) != LUA_OK) return lua_error(L); - lua_call(L, 0, LUA_MULTRET); - return lua_gettop(L) - 1; -} - -// Resolves a module name against package.path, reporting every path tried the way -// the stock searcher does. -static int l_searcher(lua_State* L) { - String name = luaL_checkstring(L, 1); - name.replace('.', '/'); - - lua_getglobal(L, "package"); - lua_getfield(L, -1, "path"); - String templates = luaL_optstring(L, -1, ""); - lua_pop(L, 2); - - String tried; - int start = 0; - while (start <= (int)templates.length()) { - int end = templates.indexOf(';', start); - if (end < 0) end = templates.length(); - String candidate = templates.substring(start, end); - start = end + 1; - if (candidate.length() == 0) continue; - candidate.replace("?", name); - - String source; - if (readScript(candidate.c_str(), source)) { - String chunkname = String("@") + candidate; - if (luaL_loadbuffer(L, source.c_str(), source.length(), chunkname.c_str()) != LUA_OK) { - return luaL_error(L, "error loading module '%s' from '%s':\n\t%s", - luaL_checkstring(L, 1), candidate.c_str(), lua_tostring(L, -1)); - } - lua_pushstring(L, candidate.c_str()); - return 2; - } - tried += "\n\tno file '" + candidate + "'"; - } - lua_pushstring(L, tried.c_str()); - return 1; -} - -void LuaApp::installLoader(const char* appDir) { - lua_getglobal(state, "package"); - - String path = String(appDir) + "/?.lua;/lib/?.lua"; - lua_pushstring(state, path.c_str()); - lua_setfield(state, -2, "path"); - - // Keep the preload searcher, drop the C loaders: they can only report misleading - // errors about shared objects that never existed here. - lua_getfield(state, -1, "searchers"); - lua_pushcfunction(state, l_searcher); - lua_rawseti(state, -2, 2); - for (int i = 3; i <= 4; i++) { - lua_pushnil(state); - lua_rawseti(state, -2, i); - } - lua_pop(state, 2); - - lua_pushcfunction(state, l_loadfile); - lua_setglobal(state, "loadfile"); - lua_pushcfunction(state, l_dofile); - lua_setglobal(state, "dofile"); -} - LuaApp::LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch) : tft(tft), touch(touch) {} LuaApp::~LuaApp() { @@ -550,6 +71,8 @@ void LuaApp::fireTouch(const char* name, int16_t x, int16_t y) { callGlobal(name, 2); } +// Deliberately not themed: this can fire when the settings naming the theme are +// themselves unreadable, so it stays high contrast. void LuaApp::fail(const char* message) { Serial.printf("[lua] error: %s\n", message ? message : "(unknown)"); tft.fillScreen(TFT_WHITE); @@ -560,13 +83,13 @@ void LuaApp::fail(const char* message) { } bool LuaApp::load(const char* path) { - self = this; closeState(); // The launcher tap may still be down; swallow that gesture's release. lastTouched = true; ignoreRelease = true; state = luaL_newstate(); if (!state) return false; + bindApp(state, this); luaL_openlibs(state); registerBindings(); @@ -599,48 +122,11 @@ bool LuaApp::load(const char* path) { } void LuaApp::registerBindings() { - static const luaL_Reg sysLib[] = { - {"millis", l_sys_millis}, {"exit", l_sys_exit}, - {"setCalibration", l_sys_setCalibration}, - {"getRotation", l_sys_getRotation}, {"setRotation", l_sys_setRotation}, - {"launch", l_sys_launch}, - {"getTheme", l_sys_getTheme}, {"setTheme", l_sys_setTheme}, - {nullptr, nullptr}}; - static const luaL_Reg guiLib[] = { - {"width", l_gui_width}, {"height", l_gui_height}, {"clear", l_gui_clear}, - {"fillRect", l_gui_fillRect}, {"drawRect", l_gui_drawRect}, {"fillCircle", l_gui_fillCircle}, - {"drawLine", l_gui_drawLine}, {"drawText", l_gui_drawText}, {"setRotation", l_gui_setRotation}, - {"color", l_gui_color}, - {"roundRect", l_gui_roundRect}, - {"fontHeight", l_gui_fontHeight}, - {"textWidth", l_gui_textWidth}, - {nullptr, nullptr}}; - static const luaL_Reg inputLib[] = {{"getTouch", l_input_getTouch}, - {"getRawTouch", l_input_getRawTouch}, - {"touched", l_input_touched}, - {nullptr, nullptr}}; - static const luaL_Reg fsLib[] = {{"readFile", l_fs_readFile}, {"writeFile", l_fs_writeFile}, - {"exists", l_fs_exists}, {"listFiles", l_fs_listFiles}, - {"listDirs", l_fs_listDirs}, {nullptr, nullptr}}; - static const luaL_Reg wifiLib[] = {{"scan", l_wifi_scan}, - {"connect", l_wifi_connect}, - {"status", l_wifi_status}, - {"forget", l_wifi_forget}, - {nullptr, nullptr}}; - static const luaL_Reg logLib[] = {{"info", l_log}, {nullptr, nullptr}}; - - luaL_newlib(state, sysLib); - lua_setglobal(state, "sys"); - luaL_newlib(state, guiLib); - lua_setglobal(state, "gui"); - luaL_newlib(state, inputLib); - lua_setglobal(state, "input"); - luaL_newlib(state, fsLib); - lua_setglobal(state, "fs"); - luaL_newlib(state, wifiLib); - lua_setglobal(state, "wifi"); - luaL_newlib(state, logLib); - lua_setglobal(state, "log"); + registerGui(state); + registerSys(state); + registerInput(state); + registerFs(state); + registerWifi(state); } // Deferred: sys.exit() and fail() run inside a lua_pcall, so closing the state diff --git a/src/lua/module_loader.cpp b/src/lua/module_loader.cpp new file mode 100644 index 0000000..ce30d00 --- /dev/null +++ b/src/lua/module_loader.cpp @@ -0,0 +1,105 @@ +// SD-backed `require`. Lua's stock loaders go through stdio, which cannot see the SD +// mount, so every path into the filesystem is replaced with one that reads through SD. + +#include + +#include "bindings.h" +#include "lua_app.h" + +bool readScript(const char* path, String& out) { + // Probe first: a missed SD.open logs a VFS error, and the searcher misses by design. + if (!SD.exists(path)) return false; + File file = SD.open(path); + if (!file || file.isDirectory()) { + if (file) file.close(); + return false; + } + out = file.readString(); + file.close(); + return true; +} + +int loadScript(lua_State* L, const char* path) { + String source; + if (!readScript(path, source)) { + lua_pushfstring(L, "cannot open %s", path); + return LUA_ERRFILE; + } + String chunkname = String("@") + path; + return luaL_loadbuffer(L, source.c_str(), source.length(), chunkname.c_str()); +} + +static int l_loadfile(lua_State* L) { + if (loadScript(L, luaL_checkstring(L, 1)) == LUA_OK) return 1; + lua_pushnil(L); + lua_insert(L, -2); + return 2; +} + +static int l_dofile(lua_State* L) { + const char* path = luaL_checkstring(L, 1); + if (loadScript(L, path) != LUA_OK) return lua_error(L); + lua_call(L, 0, LUA_MULTRET); + return lua_gettop(L) - 1; +} + +// Resolves a module name against package.path, reporting every path tried the way +// the stock searcher does. +static int l_searcher(lua_State* L) { + String name = luaL_checkstring(L, 1); + name.replace('.', '/'); + + lua_getglobal(L, "package"); + lua_getfield(L, -1, "path"); + String templates = luaL_optstring(L, -1, ""); + lua_pop(L, 2); + + String tried; + int start = 0; + while (start <= (int)templates.length()) { + int end = templates.indexOf(';', start); + if (end < 0) end = templates.length(); + String candidate = templates.substring(start, end); + start = end + 1; + if (candidate.length() == 0) continue; + candidate.replace("?", name); + + String source; + if (readScript(candidate.c_str(), source)) { + String chunkname = String("@") + candidate; + if (luaL_loadbuffer(L, source.c_str(), source.length(), chunkname.c_str()) != LUA_OK) { + return luaL_error(L, "error loading module '%s' from '%s':\n\t%s", + luaL_checkstring(L, 1), candidate.c_str(), lua_tostring(L, -1)); + } + lua_pushstring(L, candidate.c_str()); + return 2; + } + tried += "\n\tno file '" + candidate + "'"; + } + lua_pushstring(L, tried.c_str()); + return 1; +} + +void LuaApp::installLoader(const char* appDir) { + lua_getglobal(state, "package"); + + String path = String(appDir) + "/?.lua;/lib/?.lua"; + lua_pushstring(state, path.c_str()); + lua_setfield(state, -2, "path"); + + // Keep the preload searcher, drop the C loaders: they can only report misleading + // errors about shared objects that never existed here. + lua_getfield(state, -1, "searchers"); + lua_pushcfunction(state, l_searcher); + lua_rawseti(state, -2, 2); + for (int i = 3; i <= 4; i++) { + lua_pushnil(state); + lua_rawseti(state, -2, i); + } + lua_pop(state, 2); + + lua_pushcfunction(state, l_loadfile); + lua_setglobal(state, "loadfile"); + lua_pushcfunction(state, l_dofile); + lua_setglobal(state, "dofile"); +} diff --git a/test/fake_device.lua b/test/fake_device.lua new file mode 100644 index 0000000..e516ad6 --- /dev/null +++ b/test/fake_device.lua @@ -0,0 +1,102 @@ +-- Stand-in for the firmware bindings, so host tests exercise the real ui.lua. +-- +-- One definition of the binding surface: when a binding is added or renamed, this is +-- the file that changes. Tests mutate the returned table to steer the device, and may +-- overwrite any stub function outright for a single assertion. + +local device = { + -- Text metrics chosen to match the firmware's default GLCD font, since layout + -- assertions are written in real pixels. + fontHeight = 8, + charWidth = 6, + now = 0, + rotation = 0, + theme = "light", + raw = nil, -- pending raw touch reading, {x, y} or nil + networks = {}, -- what wifi.scan() returns + status = {state = "disconnected", ssid = "", ip = "", rssi = 0}, + painted = {}, -- every drawText call, in order + calibration = nil, -- last sys.setCalibration() + connected = nil, -- last wifi.connect() + exited = false, + launched = nil, + saveFails = false, -- make every persisting call report failure +} + +local function saved() + return not device.saveFails +end + +function device.install() + device.painted = {} + + gui = { + color = function(r, g, b) return r * 65536 + g * 256 + b end, + -- Rotation swaps the frame, exactly as TFT_eSPI reports it. + width = function() return device.rotation % 180 == 0 and 320 or 480 end, + height = function() return device.rotation % 180 == 0 and 480 or 320 end, + clear = function() end, + fillRect = function() end, + drawRect = function() end, + fillCircle = function() end, + drawLine = function() end, + roundRect = function() end, + drawText = function(label, x, y) + device.painted[#device.painted + 1] = {label = label, x = x, y = y} + end, + fontHeight = function() return device.fontHeight end, + textWidth = function(text) return #text * device.charWidth end, + setRotation = function() end, + } + + sys = { + millis = function() return device.now end, + exit = function() device.exited = true end, + launch = function(path) device.launched = path end, + getRotation = function() return device.rotation end, + setRotation = function(degrees) + if degrees % 90 ~= 0 or degrees < 0 or degrees > 270 then return false end + device.rotation = degrees + return saved() + end, + getTheme = function() return device.theme end, + setTheme = function(name) device.theme = name return saved() end, + setCalibration = function(...) device.calibration = {...} return saved() end, + } + + input = { + touched = function() return device.raw ~= nil end, + getTouch = function() + if not device.raw then return nil end + return device.raw[1], device.raw[2] + end, + getRawTouch = function() + if not device.raw then return nil end + return device.raw[1], device.raw[2] + end, + } + + fs = { + readFile = function() return nil end, + writeFile = function() return saved() end, + exists = function() return false end, + listFiles = function() return {} end, + listDirs = function() return {} end, + } + + wifi = { + scan = function() return device.networks end, + status = function() return device.status end, + connect = function(ssid, password) + device.connected = {ssid, password} + return saved() + end, + forget = function() device.connected = nil return saved() end, + } + + log = {info = function() end} + + return device +end + +return device diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index 94d741b..f50f576 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -1,43 +1,8 @@ -- Run: lua test/settings_calibration.lua --- Stubs the firmware bindings so the settings app loads on a desktop Lua. -package.path = "sdcard/lib/?.lua;" .. package.path +-- Drives the settings app on a desktop Lua, through the shared fake device. +package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path -local saved -local rotation = 0 -local now = 0 -local scanned = {} -local connected - -gui = { - color = function() return 0 end, - width = function() return rotation % 180 == 0 and 320 or 480 end, - height = function() return rotation % 180 == 0 and 480 or 320 end, - clear = function() end, - fillRect = function() end, - drawText = function() end, - drawRect = function() end, - drawLine = function() end, - fillRoundRect = function() end, - drawRoundRect = function() end, - fontHeight = function() return 8 end, - textWidth = function(text) return #text * 6 end, - setRotation = function() end, -} -sys = { - millis = function() return now end, - exit = function() end, - setCalibration = function(...) saved = {...} return true end, - getRotation = function() return rotation end, - setRotation = function(degrees) rotation = degrees return true end, -} -input = {getRawTouch = function() return nil end} -wifi = { - status = function() return {state = "disconnected", ssid = "", ip = "", rssi = 0} end, - scan = function() return scanned end, - connect = function(ssid, password) connected = {ssid, password} return true end, - forget = function() return true end, -} -log = {info = function() end} +local device = require("fake_device").install() dofile("sdcard/apps/settings/main.lua") @@ -80,35 +45,37 @@ end setup() tap(CALIBRATE_ROW) on_tick() -- release after the menu tap arms sampling -input.getRawTouch = function() return s1.x, s1.y end +device.raw = {s1.x, s1.y} on_tick() -input.getRawTouch = function() return nil end +device.raw = nil on_tick() -input.getRawTouch = function() return s2.x, s2.y end +device.raw = {s2.x, s2.y} on_tick() -input.getRawTouch = function() return nil end +device.raw = nil on_tick() +local saved = device.calibration assert(saved, "calibration was not saved") assert(math.abs(saved[1] - 200) <= 1, "saved x0 " .. saved[1]) -- Open networks connect directly from scan results. setup() -scanned = {{ssid = "qemu", rssi = -25, secure = false}} +device.networks = {{ssid = "qemu", rssi = -25, secure = false}} tap{x = 100, y = 104} -- wifi - tap{x = 100, y = 56} -- scan +tap{x = 100, y = 56} -- scan on_tick() tap{x = 100, y = 38} -- qemu -assert(connected and connected[1] == "qemu" and connected[2] == "", "open wifi connect") +assert(device.connected, "open network should connect without a keyboard") +assert(device.connected[1] == "qemu" and device.connected[2] == "", "open wifi connect") -- Secure networks route through the keyboard and preserve typed punctuation. setup() -scanned = {{ssid = "secure", rssi = -40, secure = true}} +device.networks = {{ssid = "secure", rssi = -40, secure = true}} tap{x = 100, y = 104} tap{x = 100, y = 56} on_tick() tap{x = 100, y = 38} tap{x = 12, y = 80} -- q - tap{x = 200, y = 180} -- connect -assert(connected[1] == "secure" and connected[2] == "q", "secure wifi password") +tap{x = 200, y = 180} -- connect +assert(device.connected[1] == "secure" and device.connected[2] == "q", "secure wifi password") print("ok") diff --git a/test/ui_layout.lua b/test/ui_layout.lua index bb02a9a..a18aacd 100644 --- a/test/ui_layout.lua +++ b/test/ui_layout.lua @@ -1,24 +1,8 @@ -- Run: lua test/ui_layout.lua --- Stubs the firmware bindings and asserts the rects ui.lua computes. -package.path = "sdcard/lib/?.lua;" .. package.path - -local FONT_HEIGHT, CHAR_WIDTH = 8, 6 -local now = 0 -local painted = {} - -gui = { - color = function(r, g, b) return r * 65536 + g * 256 + b end, - width = function() return 320 end, - height = function() return 480 end, - clear = function() end, - fillRect = function() end, - drawText = function(label, x, y) painted[#painted + 1] = {label = label, x = x, y = y} end, - roundRect = function() end, - fontHeight = function() return FONT_HEIGHT end, - textWidth = function(text) return #text * CHAR_WIDTH end, -} -sys = {millis = function() return now end} +-- Asserts the rects ui.lua computes, against the shared fake device. +package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path +local device = require("fake_device").install() local ui = require("ui") local function rect(node) @@ -85,7 +69,7 @@ assert(fired == 1, "release inside must fire once") -- The pressed look is held briefly, then cleared on a later draw. assert(button.pressed, "pressed look must outlast the release") -now = now + 100 +device.now = device.now + 100 app:draw() assert(not button.pressed, "pressed look must clear after the hold") diff --git a/test/ui_theme.lua b/test/ui_theme.lua index 9eeba52..82b7254 100644 --- a/test/ui_theme.lua +++ b/test/ui_theme.lua @@ -1,29 +1,15 @@ -- Run: lua test/ui_theme.lua --- Asserts the palette ui.lua derives from a theme's three seed colors. -package.path = "sdcard/lib/?.lua;" .. package.path - -local themeName = "light" - --- Packing the channels keeps them recoverable, so contrast is checkable. -gui = { - color = function(r, g, b) return r * 65536 + g * 256 + b end, - width = function() return 320 end, - height = function() return 480 end, - clear = function() end, - fillRect = function() end, - drawText = function() end, - roundRect = function() end, - fontHeight = function() return 8 end, - textWidth = function(text) return #text * 6 end, -} -sys = {millis = function() return 0 end, getTheme = function() return themeName end} +-- Asserts the palette ui.lua derives from a theme's three seed colors. The fake packs +-- channels into one integer, so contrast between roles stays checkable. +package.path = "sdcard/lib/?.lua;test/?.lua;" .. package.path +local device = require("fake_device").install() local ui = require("ui") local BLACK, WHITE = gui.color(0, 0, 0), gui.color(255, 255, 255) local function reload(name) - themeName = name + device.theme = name return ui.reloadTheme() end