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