diff --git a/Makefile b/Makefile index 1067e98..e08313f 100644 --- a/Makefile +++ b/Makefile @@ -7,9 +7,18 @@ 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 +.PHONY: test test-lua test-cpp test-stubs stubs build upload monitor clean -test: test-cpp test-lua +test: test-cpp test-lua test-stubs + +# The stub is only useful if it matches the bindings, and nothing but a check keeps it +# honest -- an editor completing a function the firmware no longer has is worse than no +# completion at all. +test-stubs: + @printf '%-32s ' stubs/esp32lcd.lua; python3 scripts/gen_lua_stubs.py --check && echo ok + +stubs: + @python3 scripts/gen_lua_stubs.py test-lua: @$(LUA) -e 'assert(_VERSION == "Lua 5.4", "tests need Lua 5.4 to match the firmware, got " .. _VERSION)' diff --git a/README.md b/README.md index e7f19b2..e28fa8d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ src/ module_loader SD-backed require, loadfile and dofile sdcard/ copied to the card: apps/ and lib/ test/ host tests, run by `make test` +scripts/ gen_lua_stubs.py, which writes stubs/esp32lcd.lua +stubs/ generated LuaLS definitions; point your editor here +docs/ lua-api-parity.md, the crosspoint-reader comparison ``` Copy `sdcard/` to the SD card root: apps live in `/apps//main.lua` and shared diff --git a/docs/lua-api-parity.md b/docs/lua-api-parity.md new file mode 100644 index 0000000..8881642 --- /dev/null +++ b/docs/lua-api-parity.md @@ -0,0 +1,95 @@ +# Lua API parity with crosspoint-reader + +Both firmwares expose a Lua API to apps on an SD card, and both are ESP32 devices, so a +script that only touches files and the network should behave the same on either. This +records where they agree, where they differ **for a reason**, and where they differ +because nobody noticed. The last group is a bug list, not a design. + +Compared against crosspoint-reader at `src/util/lua/LuaBindings*.cpp`. + +## Identical + +`fs.listDirs`, `fs.listFiles`, `fs.exists`, `fs.readFile`, `fs.writeFile`, +`gui.width`, `gui.height`, `gui.fillRect`, `gui.drawRect`, `gui.drawLine`, +`sys.millis`, `sys.delay`, `sys.exit`, `log.debug/info/error`, the whole `http` table, +and the `draw()` / `on_tick()` callbacks. + +## Deliberate differences + +| Area | crosspoint-reader | esp32-lcd | Why | +|---|---|---|---| +| Input | `input.wasPressed(button)` and friends, 8 named buttons | `input.getTouch`, `getRawTouch`, `touched` | Different hardware. A touch panel has no button names and a button device has no coordinates. | +| Drawing | `gui.drawText(font, x, y, text, color, style)`, `getTextWidth(font, text)` | `gui.drawText(text, x, y, color, bg)`, `textWidth(text)` | crosspoint ships several fonts; this firmware has one built-in font, and needs an opaque background colour because the panel is not e-ink. | +| Refresh | `gui.refresh(mode)`, `REFRESH_FULL/HALF/FAST` | none | An LCD has no waveform modes. | +| Colour | `COLOR_*` constants, 4 grey levels | `gui.color(r, g, b)` returning RGB565 | 16-bit colour has too many values to enumerate. | +| Shapes | `drawRoundedRect` + `fillRoundedRect` | one `gui.roundRect(...)` with gradient and border | Fill and border derive from a single distance field, so their edges cannot disagree. | +| Themes | none | `sys.getTheme/setTheme`, `/lib/theme.lua` | Colour panel. | +| Rotation | `gui.setOrientation("portrait")` | `sys.setRotation(degrees)` persisted, `gui.setRotation(0-3)` for one frame | This device stores rotation in settings and remaps touch to match. | +| Clock | nothing exposed; UTC offset is a C++ setting | `sys.clockSynced`, `sys.getTimezone/setTimezone` with POSIX TZ rules | Timezone here is a stored rule, so `os.date()` returns local time with DST handled by libc. | +| Launching | launcher is C++ | `sys.launch(path)`, launcher is a Lua app | The launcher is just another app here. | +| Modules | single-file apps; `require` unusable | `require` works, `package.searchers` reads the SD card, `/lib` on the path | Shared code such as `ui.lua` needs it. **crosspoint should adopt this.** | +| BLE | `ble.*` | none | No BLE use case here yet. | +| TLS memory | `TlsScratchLoan` lends the framebuffer to wolfSSL | none | crosspoint is heap-starved; this device has ~280KB free. | + +## Accidental differences — drift, not design + +Each of these is the same concept spelled two ways. Fixing them means changing one repo. + +| Concern | crosspoint-reader | esp32-lcd | Suggested resolution | +|---|---|---|---| +| Entry callback | `init()`, required | `setup()`, optional | Pick one name. Neither is better. | +| Tick interval | `app.setTickInterval(ms)` | global `TICK_MS` | Pick one mechanism. | +| `wifi.status()` | returns a **string** | returns a **table** of state/ssid/ip/rssi | Same name, incompatible types — the sharpest edge here. This repo added `isConnected()` and `localIP()` so the crosspoint idioms work either way. | +| `wifi.connect()` | no arguments, uses stored credentials | `(ssid, password)`, saves them | Both are wanted: a no-argument reconnect and an explicit join. | +| `fs.readFile` cap | 50000 bytes | 65536 bytes | Arbitrary in both. | +| `fs` mutation | `mkdir`, `rename`, `remove`, `removeTree`, plus a path-safety check rejecting `..` | absent | **This repo is missing them, including the traversal guard.** | +| `fs.fileSize`, `fs.readLineAt` | present | absent | Worth porting; `readLineAt` exists for paging large files. | +| Timers | `timer.after/every/cancel` + `on_timer(id)` | absent | Worth porting. | + +## The `http` table + +Signatures match crosspoint exactly, so scripts port unchanged: + +```lua +http.get(url, headers?) -> body|nil, status +http.head(url, headers?) -> body|nil, status +http.delete(url, headers?) -> body|nil, status +http.post(url, body?, headers?) -> body|nil, status +http.patch(url, body?, headers?) -> body|nil, status +http.download(url, dest, options) -> bytesWritten | nil, error +http.urlencode(input) -> string +``` + +`status` is `-1` when the request never left the device. Bodies are capped at 50000 +bytes, matching crosspoint, and a larger response yields `nil` with the real status. +`http.download` requires HTTPS, requires `maxBytes`, accepts `expectedSize` and +`sha256`, rejects unknown option keys, and deletes the file if any check fails. + +Two behaviours are **deliberately not** copied: + +1. **No argument shifting.** crosspoint (`LuaBindingsNet.cpp:209`) treats a string in + argument 2 of `get`/`head`/`delete` as a request *body*, so a mistyped headers table + becomes a silent protocol error. Here that raises. +2. **Certificates are verified.** crosspoint calls `setInsecure()` for every Lua request + and for `http.download`, so traffic is encrypted but unauthenticated — including the + path a firmware update would use. This firmware verifies against the root bundle + already embedded in the framework (`_binary_x509_crt_bundle_start`, ~62KB, linked + only when referenced). Verified in the emulator: `https://expired.badssl.com` + returns status `-1`, and a matching `sha256` accepts while a wrong one deletes the + file. + +The cost of matching the signatures is that there is nowhere to put a per-request CA or +an insecure escape hatch, so TLS policy is device-wide and scripts cannot weaken it. +That is the right trade for a device that flashes itself. + +## Stub generation + +`scripts/gen_lua_stubs.py` emits `stubs/esp32lcd.lua` in the same LuaLS `---@meta` +format crosspoint uses for `data/lua/crosspoint.lua`, so one editor setup covers both. + +The parser differs: crosspoint annotates each C function and reads `addFunction(...)` +calls, while this repo annotates the `luaL_Reg` table so a module's documentation stays +contiguous with its registration. + +`make test` runs `--check`. crosspoint's generator has no such wiring, so its stub can +drift from its bindings silently; that is worth copying back. diff --git a/scripts/gen_lua_stubs.py b/scripts/gen_lua_stubs.py new file mode 100644 index 0000000..cca033a --- /dev/null +++ b/scripts/gen_lua_stubs.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Generate the LuaLS stub for the firmware's Lua API. + +Adapted from crosspoint-reader's scripts/gen_lua_stubs.py. The emitted format is +deliberately identical so an editor configured for one repo works for the other; the +parser differs because this firmware registers bindings as annotated luaL_Reg tables +rather than addFunction() calls, which keeps a module's documentation contiguous. + + python3 scripts/gen_lua_stubs.py # write the stub + python3 scripts/gen_lua_stubs.py --check # exit 1 if it is stale +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SOURCES = sorted((ROOT / "src/lua/bindings").glob("*.cpp")) +OUTPUT = ROOT / "stubs/esp32lcd.lua" + +TYPES = {"int": "integer", "bool": "boolean"} + +ENTRY = re.compile(r'^\s*\{"(?P\w+)",\s*\w+\}') +TABLE = re.compile(r"static const luaL_Reg (?P\w+)\[\]") +BIND = re.compile(r'luaL_newlib\(L,\s*(?P\w+)\);\s*\n\s*lua_setglobal\(L,\s*"(?P\w+)"\)') +DOC = re.compile(r"^\s*//\s*---\s?(?P.*)$") +PARAM = re.compile(r"^\s*//\s*@param\s+(?P\w+)\s+(?P\S+)(?:\s+(?P.*))?$") +RETURN = re.compile(r"^\s*//\s*@return\s+(?P\S+)(?:\s+(?P.*))?$") +TAG = re.compile(r"^\s*//\s*@(?P\w+)") + + +def lua_type(raw): + """`int|nil` becomes `integer?`, matching how LuaLS spells optionality.""" + optional = raw.endswith("|nil") + if optional: + raw = raw[: -len("|nil")] + mapped = TYPES.get(raw, raw) + return f"{mapped}?" if optional else mapped + + +def parse(path): + """Collect {var: [function, ...]} and {var: global name} from one file.""" + text = path.read_text() + tables, bindings = {}, {} + + for match in BIND.finditer(text): + bindings[match.group("var")] = match.group("global") + + lines = text.splitlines() + var, pending = None, {"doc": [], "params": [], "returns": []} + + def reset(): + return {"doc": [], "params": [], "returns": []} + + for line in lines: + table = TABLE.search(line) + if table: + var = table.group("var") + tables.setdefault(var, []) + pending = reset() + continue + if var is None: + continue + + doc = DOC.match(line) + if doc: + pending["doc"].append(doc.group("text").strip()) + continue + param = PARAM.match(line) + if param: + pending["params"].append( + (param.group("name"), lua_type(param.group("type")), (param.group("desc") or "").strip()) + ) + continue + ret = RETURN.match(line) + if ret: + pending["returns"].append((lua_type(ret.group("type")), (ret.group("desc") or "").strip())) + continue + tag = TAG.match(line) + if tag: + raise SystemExit(f"{path.name}: unknown tag @{tag.group('tag')}") + + entry = ENTRY.match(line) + if entry: + name = entry.group("name") + if not pending["doc"]: + raise SystemExit(f"{path.name}: {name} is registered without a description") + tables[var].append((name, pending)) + pending = reset() + continue + if "};" in line: + var = None + + return tables, bindings + + +def render(modules): + out = [ + "---@meta", + "", + "-- Generated by scripts/gen_lua_stubs.py. Do not edit.", + "-- Point your editor's Lua language server at this file to get completion for the", + "-- firmware API inside sdcard/apps and sdcard/lib.", + "", + ] + for global_name in sorted(modules): + functions = modules[global_name] + out.append(f"---@class {global_name}lib") + out.append(f"{global_name} = {{}}") + out.append("") + for name, meta in functions: + for line in meta["doc"]: + out.append(f"--- {line}" if line else "---") + for param, type_, desc in meta["params"]: + out.append(f"---@param {param} {type_}{(' ' + desc) if desc else ''}") + for type_, desc in meta["returns"]: + out.append(f"---@return {type_}{(' ' + desc) if desc else ''}") + args = ", ".join(param for param, _, _ in meta["params"]) + out.append(f"function {global_name}.{name}({args}) end") + out.append("") + out.extend( + [ + "-- Callbacks an app may define as globals:", + "-- setup() once, before the first draw", + "-- draw() every 33 ms while the app runs", + "-- on_tick() every TICK_MS, when that global is set", + "-- on_touch_down(x, y) finger down", + "-- on_touch_up(x, y) finger up", + "-- on_touch(x, y) tap, fired on release like a click", + "", + ] + ) + return "\n".join(out) + + +def main(): + modules = {} + for path in SOURCES: + tables, bindings = parse(path) + for var, functions in tables.items(): + if var not in bindings: + continue # a table that is never installed as a global documents nothing + modules.setdefault(bindings[var], []).extend(functions) + + if not modules: + raise SystemExit("no annotated bindings found") + + rendered = render(modules) + if "--check" in sys.argv: + current = OUTPUT.read_text() if OUTPUT.exists() else "" + if current != rendered: + raise SystemExit(f"{OUTPUT.relative_to(ROOT)} is stale; run scripts/gen_lua_stubs.py") + return + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(rendered) + print(f"wrote {OUTPUT.relative_to(ROOT)}: {sum(len(f) for f in modules.values())} functions") + + +if __name__ == "__main__": + main() diff --git a/src/lua/bindings.h b/src/lua/bindings.h index 10d1b56..a4770b9 100644 --- a/src/lua/bindings.h +++ b/src/lua/bindings.h @@ -23,6 +23,7 @@ void registerSys(lua_State* L); void registerInput(lua_State* L); void registerFs(lua_State* L); void registerWifi(lua_State* L); +void registerHttp(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. diff --git a/src/lua/bindings/fs.cpp b/src/lua/bindings/fs.cpp index 4bcec73..6ac754f 100644 --- a/src/lua/bindings/fs.cpp +++ b/src/lua/bindings/fs.cpp @@ -63,9 +63,29 @@ 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}}; + static const luaL_Reg lib[] = { + // --- Reads a whole file from the SD card. + // @param path string Absolute path. + // @return string|nil Contents truncated to 65536 bytes, or nil when missing. + {"readFile", l_fs_readFile}, + // --- Writes a whole file to the SD card, replacing it if it exists. + // @param path string Absolute path. + // @param content string + // @return boolean + {"writeFile", l_fs_writeFile}, + // --- Whether a path exists. + // @param path string Absolute path. + // @return boolean + {"exists", l_fs_exists}, + // --- Names of the files in a directory, excluding dotfiles. + // @param path string Absolute path. + // @return string[] + {"listFiles", l_fs_listFiles}, + // --- Names of the subdirectories in a directory, excluding dotfiles. + // @param path string Absolute path. + // @return string[] + {"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 index 98b24bf..ff5ac76 100644 --- a/src/lua/bindings/gui.cpp +++ b/src/lua/bindings/gui.cpp @@ -125,13 +125,79 @@ static int l_gui_color(lua_State* L) { 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}}; + // --- Panel width in pixels, for the current rotation. + // @return integer + {"width", l_gui_width}, + // --- Panel height in pixels, for the current rotation. + // @return integer + {"height", l_gui_height}, + // --- Fills the whole panel with one color. + // @param color integer|nil Defaults to white. + {"clear", l_gui_clear}, + // --- Fills a rectangle. + // @param x integer + // @param y integer + // @param w integer + // @param h integer + // @param color integer + {"fillRect", l_gui_fillRect}, + // --- Strokes a one pixel rectangle outline. + // @param x integer + // @param y integer + // @param w integer + // @param h integer + // @param color integer + {"drawRect", l_gui_drawRect}, + // --- Fills an anti-aliased circle, blending its rim against the surface behind it. + // @param x integer Center. + // @param y integer Center. + // @param radius integer + // @param color integer + // @param bg integer|nil Surface color to blend against, defaults to white. + {"fillCircle", l_gui_fillCircle}, + // --- Strokes a line. + // @param x1 integer + // @param y1 integer + // @param x2 integer + // @param y2 integer + // @param color integer + {"drawLine", l_gui_drawLine}, + // --- Draws text with an opaque background behind its glyphs. + // @param text string + // @param x integer + // @param y integer + // @param color integer|nil Defaults to black. + // @param bg integer|nil Defaults to white. + {"drawText", l_gui_drawText}, + // --- Draws a rounded rectangle: fill, vertical gradient and border from one + // --- distance field, so the edges cannot disagree. + // @param x integer + // @param y integer + // @param w integer + // @param h integer + // @param radius integer Clamped to half the shorter side. + // @param bg integer Surface color the anti-aliased edge blends against. + // @param top integer|nil Fill color, or the top of the gradient. + // @param bottom integer|nil Bottom of the gradient, defaults to top. + // @param border integer|nil Border color; omitted draws no border. + {"roundRect", l_gui_roundRect}, + // --- Height of the current font in pixels. + // @return integer + {"fontHeight", l_gui_fontHeight}, + // --- Width the given text would occupy in pixels. + // @param text string + // @return integer + {"textWidth", l_gui_textWidth}, + // --- Rotates the frame for this draw only; sys.setRotation persists it. + // @param rotation integer 0 to 3, in quarter turns. + {"setRotation", l_gui_setRotation}, + // --- Packs 8 bit channels into the panel's RGB565 color format. + // @param r integer + // @param g integer + // @param b integer + // @return integer + {"color", l_gui_color}, + {nullptr, nullptr}}; luaL_newlib(L, lib); lua_setglobal(L, "gui"); } diff --git a/src/lua/bindings/http.cpp b/src/lua/bindings/http.cpp new file mode 100644 index 0000000..2a59423 --- /dev/null +++ b/src/lua/bindings/http.cpp @@ -0,0 +1,325 @@ +// HTTP client. Signatures match crosspoint-reader's `http` table exactly, so a script +// that talks to a server runs on either device; see docs/lua-api-parity.md. +// +// Unlike crosspoint, certificates are verified against the root bundle already +// embedded in the framework. Nothing here lets a script turn that off: the signatures +// have no options table to hang it on, and the one caller that would want it most -- +// a firmware update -- is the one that can least afford an unauthenticated peer. + +#include +#include +#include +#include +#include + +#include "../bindings.h" +#include "../lua_app.h" + +extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_start"); + +namespace { + +constexpr size_t MAX_RESPONSE = 50000; // matches crosspoint's cap, so limits behave alike +constexpr uint32_t TIMEOUT_MS = 60000; +constexpr int REDIRECT_LIMIT = 5; +constexpr size_t DOWNLOAD_CHUNK = 2048; +constexpr uint32_t MAX_DOWNLOAD_BYTES = 16 * 1024 * 1024; +constexpr int STATUS_UNSENT = -1; // the request never left the device + +bool isHttps(const char* url) { return strncmp(url, "https://", 8) == 0; } + +// Returned by value so the TLS client's ~4KB of state is freed with the request rather +// than held for the life of the app. +struct Session { + WiFiClient plain; + WiFiClientSecure secure; + HTTPClient http; + + bool begin(const char* url) { + if (isHttps(url)) { + secure.setCACertBundle(rootca_crt_bundle_start); + secure.setTimeout(TIMEOUT_MS / 1000); + if (!http.begin(secure, url)) return false; + } else if (!http.begin(plain, url)) { + return false; + } + http.setTimeout(TIMEOUT_MS); + http.setConnectTimeout(TIMEOUT_MS); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.setRedirectLimit(REDIRECT_LIMIT); + http.setUserAgent("esp32-lcd"); + return true; + } +}; + +void applyHeaders(lua_State* L, int index, HTTPClient& http) { + if (!lua_istable(L, index)) return; + lua_pushnil(L); + while (lua_next(L, index) != 0) { + if (lua_isstring(L, -2) && lua_isstring(L, -1)) { + http.addHeader(lua_tostring(L, -2), lua_tostring(L, -1)); + } + lua_pop(L, 1); + } +} + +// get/head/delete take headers in slot 2; post/patch take a body there and headers in 3. +// crosspoint reinterprets a string in slot 2 as a body for every method, which turns a +// mistyped header table into a silent protocol error, so this rejects it instead. +int request(lua_State* L, const char* method, bool bodyExpected) { + const char* url = luaL_checkstring(L, 1); + const char* body = ""; + int headerIndex = 2; + if (bodyExpected) { + body = luaL_optstring(L, 2, ""); + headerIndex = 3; + } else if (lua_isstring(L, 2)) { + return luaL_error(L, "%s takes a headers table, not a string", method); + } + if (!lua_isnoneornil(L, headerIndex)) luaL_checktype(L, headerIndex, LUA_TTABLE); + + Session session; + if (!session.begin(url)) { + lua_pushnil(L); + lua_pushinteger(L, STATUS_UNSENT); + return 2; + } + applyHeaders(L, headerIndex, session.http); + + int status = session.http.sendRequest(method, (uint8_t*)body, strlen(body)); + if (status <= 0) { + lua_pushnil(L); + lua_pushinteger(L, status == 0 ? STATUS_UNSENT : status); + return 2; + } + + int size = session.http.getSize(); + if (size > (int)MAX_RESPONSE) { + lua_pushnil(L); + lua_pushinteger(L, status); + return 2; + } + String payload = session.http.getString(); + if (payload.length() > MAX_RESPONSE) { + lua_pushnil(L); + lua_pushinteger(L, status); + return 2; + } + lua_pushlstring(L, payload.c_str(), payload.length()); + lua_pushinteger(L, status); + return 2; +} + +int l_http_get(lua_State* L) { return request(L, "GET", false); } +int l_http_head(lua_State* L) { return request(L, "HEAD", false); } +int l_http_delete(lua_State* L) { return request(L, "DELETE", false); } +int l_http_post(lua_State* L) { return request(L, "POST", true); } +int l_http_patch(lua_State* L) { return request(L, "PATCH", true); } + +int fail(lua_State* L, const char* message) { + lua_pushnil(L); + lua_pushstring(L, message); + return 2; +} + +// Only the keys crosspoint accepts, and rejecting the rest: a misspelled `sha256` that +// was quietly ignored would report a verified download that verified nothing. +bool readOptions(lua_State* L, int index, uint32_t& maxBytes, uint32_t& expectedSize, + String& sha256, const char*& error) { + luaL_checktype(L, index, LUA_TTABLE); + lua_pushnil(L); + while (lua_next(L, index) != 0) { + const char* key = lua_isstring(L, -2) ? lua_tostring(L, -2) : ""; + if (strcmp(key, "maxBytes") == 0) { + maxBytes = lua_tointeger(L, -1); + } else if (strcmp(key, "expectedSize") == 0) { + expectedSize = lua_tointeger(L, -1); + } else if (strcmp(key, "sha256") == 0) { + sha256 = lua_isstring(L, -1) ? lua_tostring(L, -1) : ""; + } else { + lua_pop(L, 2); + error = "Unknown http.download option"; + return false; + } + lua_pop(L, 1); + } + + if (maxBytes < 1 || maxBytes > MAX_DOWNLOAD_BYTES) { + error = "Download maxBytes must be between 1 and 16777216"; + return false; + } + if (expectedSize > maxBytes) { + error = "Download expectedSize exceeds maxBytes"; + return false; + } + if (sha256.length()) { + if (sha256.length() != 64) { + error = "Download sha256 must be 64 hex characters"; + return false; + } + for (size_t i = 0; i < 64; i++) { + if (!isxdigit((unsigned char)sha256[i])) { + error = "Download sha256 must be 64 hex characters"; + return false; + } + } + } + return true; +} + +// Hashed by re-reading rather than while streaming, because the write is HTTPClient's +// loop now. A firmware image costs about a second of SD reads, once, on the path that +// is already downloading megabytes. +bool hashFile(const char* path, uint8_t digest[32]) { + File file = SD.open(path); + if (!file) return false; + mbedtls_sha256_context sha; + mbedtls_sha256_init(&sha); + mbedtls_sha256_starts_ret(&sha, 0); + // Static: the loop task's stack is mostly spoken for by TLS. + static uint8_t chunk[DOWNLOAD_CHUNK]; + while (file.available()) { + int read = file.read(chunk, sizeof(chunk)); + if (read <= 0) break; + mbedtls_sha256_update_ret(&sha, chunk, read); + } + file.close(); + mbedtls_sha256_finish_ret(&sha, digest); + mbedtls_sha256_free(&sha); + return true; +} + +String hex(const uint8_t* digest, size_t length) { + String out; + for (size_t i = 0; i < length; i++) { + char pair[3]; + snprintf(pair, sizeof(pair), "%02x", digest[i]); + out += pair; + } + return out; +} + +// Streams to the card: a firmware image cannot fit in a Lua string, which is the whole +// reason this exists next to http.get. +int l_http_download(lua_State* L) { + const char* url = luaL_checkstring(L, 1); + const char* destination = luaL_checkstring(L, 2); + uint32_t maxBytes = 0, expectedSize = 0; + String expectedSha; + const char* error = nullptr; + if (!readOptions(L, 3, maxBytes, expectedSize, expectedSha, error)) return fail(L, error); + + if (!isHttps(url)) return fail(L, "Download requires a valid HTTPS URL"); + if (SD.exists(destination)) return fail(L, "Download destination failed"); + + Session session; + if (!session.begin(url)) return fail(L, "HTTPS download failed"); + int status = session.http.GET(); + if (status != HTTP_CODE_OK) return fail(L, "HTTPS download failed"); + + // Refused before a byte is written when the server declares the size up front. + int declared = session.http.getSize(); + if (declared > 0 && (uint32_t)declared > maxBytes) return fail(L, "Download exceeded maxBytes"); + + File out = SD.open(destination, FILE_WRITE); + if (!out) return fail(L, "Download destination failed"); + + // HTTPClient's own reader handles chunked encoding, short reads and idle timeouts. + // A hand-rolled loop here spun forever on a stream that stopped producing. + int result = session.http.writeToStream(&out); + out.close(); + + uint32_t written = result > 0 ? (uint32_t)result : 0; + const char* failure = nullptr; + if (result < 0) failure = "HTTPS download failed"; + if (!failure && written > maxBytes) failure = "Download exceeded maxBytes"; + + uint8_t digest[32]; + if (!failure && expectedSha.length()) hashFile(destination, digest); + + if (!failure && expectedSize && written != expectedSize) { + failure = "Downloaded size did not match expectedSize"; + } + if (!failure && expectedSha.length() && !expectedSha.equalsIgnoreCase(hex(digest, sizeof(digest)))) { + failure = "Downloaded SHA-256 did not match"; + } + if (!failure && written == 0) failure = "Download aborted"; + + // A partial or unverified file must not survive: the next boot would treat it as good. + if (failure) { + SD.remove(destination); + return fail(L, failure); + } + lua_pushinteger(L, written); + return 1; +} + +int l_http_urlencode(lua_State* L) { + size_t length; + const char* input = luaL_checklstring(L, 1, &length); + String out; + for (size_t i = 0; i < length; i++) { + char c = input[i]; + if (isalnum((unsigned char)c) || c == '-' || c == '_' || c == '.' || c == '~') { + out += c; + } else { + char escaped[4]; + snprintf(escaped, sizeof(escaped), "%%%02X", (unsigned char)c); + out += escaped; + } + } + lua_pushlstring(L, out.c_str(), out.length()); + return 1; +} + +} // namespace + +void registerHttp(lua_State* L) { + static const luaL_Reg lib[] = { + // --- Fetches a URL. Certificates are verified against the embedded root bundle. + // @param url string + // @param headers table|nil Header name to value. + // @return string|nil Response body, or nil when the status is not 2xx or the body exceeds 50000 bytes. + // @return integer HTTP status, or -1 when the request could not be sent. + {"get", l_http_get}, + // --- Fetches a URL, discarding the body. + // @param url string + // @param headers table|nil Header name to value. + // @return string|nil Response body, always empty on success. + // @return integer HTTP status, or -1 when the request could not be sent. + {"head", l_http_head}, + // --- Deletes a resource. + // @param url string + // @param headers table|nil Header name to value. + // @return string|nil Response body. + // @return integer HTTP status, or -1 when the request could not be sent. + {"delete", l_http_delete}, + // --- Posts a body to a URL. + // @param url string + // @param body string|nil Request body, empty when omitted. + // @param headers table|nil Header name to value. + // @return string|nil Response body. + // @return integer HTTP status, or -1 when the request could not be sent. + {"post", l_http_post}, + // --- Patches a resource. + // @param url string + // @param body string|nil Request body, empty when omitted. + // @param headers table|nil Header name to value. + // @return string|nil Response body. + // @return integer HTTP status, or -1 when the request could not be sent. + {"patch", l_http_patch}, + // --- Streams an HTTPS URL to a file, checking size and digest before keeping it. + // @param url string Must be https. + // @param destination string Absolute path that must not already exist. + // @param options table maxBytes is required; expectedSize and sha256 are optional. + // @return integer|nil Bytes written, or nil on failure. + // @return string|nil Error message when the download failed. + {"download", l_http_download}, + // --- Percent-encodes a string, keeping the RFC 3986 unreserved characters. + // @param input string + // @return string + {"urlencode", l_http_urlencode}, + {nullptr, nullptr}}; + luaL_newlib(L, lib); + lua_setglobal(L, "http"); +} diff --git a/src/lua/bindings/input.cpp b/src/lua/bindings/input.cpp index a4c668c..cc2954a 100644 --- a/src/lua/bindings/input.cpp +++ b/src/lua/bindings/input.cpp @@ -36,10 +36,19 @@ static int l_input_touched(lua_State* L) { } void registerInput(lua_State* L) { - static const luaL_Reg lib[] = {{"getTouch", l_input_getTouch}, - {"getRawTouch", l_input_getRawTouch}, - {"touched", l_input_touched}, - {nullptr, nullptr}}; + static const luaL_Reg lib[] = { + // --- Current touch point, calibrated and rotated. + // @return integer|nil X, or nil when the panel is not touched. + // @return integer|nil Y. + {"getTouch", l_input_getTouch}, + // --- Current touch point as raw ADC readings, for calibration. + // @return integer|nil X, or nil when the panel is not touched. + // @return integer|nil Y. + {"getRawTouch", l_input_getRawTouch}, + // --- Whether the panel is being touched. + // @return boolean + {"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 index 63733eb..bb44f02 100644 --- a/src/lua/bindings/sys.cpp +++ b/src/lua/bindings/sys.cpp @@ -91,29 +91,82 @@ static int l_sys_clockSynced(lua_State* L) { return 1; } -static int l_log_info(lua_State* L) { - Serial.printf("[lua] %s\n", luaL_checkstring(L, 1)); +static int logAt(lua_State* L, const char* level) { + Serial.printf("[lua:%s] %s\n", level, luaL_checkstring(L, 1)); + return 0; +} + +static int l_log_debug(lua_State* L) { return logAt(L, "debug"); } +static int l_log_info(lua_State* L) { return logAt(L, "info"); } +static int l_log_error(lua_State* L) { return logAt(L, "error"); } + +// Parity with crosspoint: sleeping is the one thing a script cannot express itself, +// since the runtime owns the loop. +static int l_sys_delay(lua_State* L) { + delay(luaL_checkinteger(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}, - {"clockSynced", l_sys_clockSynced}, - {"getTimezone", l_sys_getTimezone}, - {"setTimezone", l_sys_setTimezone}, - {nullptr, nullptr}}; + static const luaL_Reg lib[] = { + // --- Milliseconds since boot. + // @return integer + {"millis", l_sys_millis}, + // --- Blocks for the given time. + // @param ms integer + {"delay", l_sys_delay}, + // --- Ends this app and returns to the launcher. + {"exit", l_sys_exit}, + // --- Ends this app and starts another one. + // @param path string Absolute path to the app's main.lua. + {"launch", l_sys_launch}, + // --- Saved screen rotation in degrees clockwise. + // @return integer + {"getRotation", l_sys_getRotation}, + // --- Rotates the screen and saves it. + // @param degrees integer 0, 90, 180 or 270. + // @return boolean Whether the setting was saved. + {"setRotation", l_sys_setRotation}, + // --- Name of the active theme in /lib/theme.lua. + // @return string + {"getTheme", l_sys_getTheme}, + // --- Selects a theme by name and saves it. + // @param name string + // @return boolean Whether the setting was saved. + {"setTheme", l_sys_setTheme}, + // --- Stores touch calibration, in the panel's unrotated frame. + // @param x0 integer Raw reading at the left edge. + // @param y0 integer Raw reading at the top edge. + // @param x1 integer Raw reading at the right edge. + // @param y1 integer Raw reading at the bottom edge. + // @return boolean Whether the setting was saved. + {"setCalibration", l_sys_setCalibration}, + // --- Whether SNTP has answered. Until it has, os.time() is only a build-time floor. + // @return boolean + {"clockSynced", l_sys_clockSynced}, + // --- Active POSIX timezone rule. + // @return string + {"getTimezone", l_sys_getTimezone}, + // --- Sets the timezone from a POSIX TZ rule and saves it. + // @param tz string For example EST5EDT,M3.2.0,M11.1.0. + // @return boolean Whether the setting was saved. + {"setTimezone", l_sys_setTimezone}, + {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}}; + static const luaL_Reg logLib[] = { + // --- Writes a debug line to the serial log. + // @param message string + {"debug", l_log_debug}, + // --- Writes an info line to the serial log. + // @param message string + {"info", l_log_info}, + // --- Writes an error line to the serial log. + // @param message string + {"error", l_log_error}, + {nullptr, nullptr}}; luaL_newlib(L, logLib); lua_setglobal(L, "log"); } diff --git a/src/lua/bindings/wifi.cpp b/src/lua/bindings/wifi.cpp index ec5e8e7..90db6bd 100644 --- a/src/lua/bindings/wifi.cpp +++ b/src/lua/bindings/wifi.cpp @@ -85,6 +85,23 @@ static int l_wifi_status(lua_State* L) { return 1; } +// Parity with crosspoint, whose wifi.status() returns a bare string and so needs these +// as separate calls. Here they are shorthands for fields status() already carries. +static int l_wifi_isConnected(lua_State* L) { + lua_pushboolean(L, WiFi.status() == WL_CONNECTED); + return 1; +} + +static int l_wifi_localIP(lua_State* L) { + lua_pushstring(L, WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString().c_str() : "0.0.0.0"); + return 1; +} + +static int l_wifi_disconnect(lua_State* L) { + WiFi.disconnect(false, false); // keeps the saved credentials, unlike forget() + return 0; +} + static int l_wifi_forget(lua_State* L) { WiFi.disconnect(true, false); settings.wifiSsid = ""; @@ -95,11 +112,31 @@ static int l_wifi_forget(lua_State* L) { } 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}}; + static const luaL_Reg lib[] = { + // --- Scans for networks, blocking until the sweep finishes. + // @return table[] Each entry has ssid, rssi and secure. + {"scan", l_wifi_scan}, + // --- Saves credentials and starts connecting. Poll status() for the outcome. + // @param ssid string + // @param password string|nil Omitted for an open network. + // @return boolean Whether the credentials were saved and the attempt started. + {"connect", l_wifi_connect}, + // --- Current connection state. + // @return table Fields state, ssid, ip and rssi. state is one of disconnected, + // --- connecting, connected, not_found or failed. + {"status", l_wifi_status}, + // --- Whether the station is associated. + // @return boolean + {"isConnected", l_wifi_isConnected}, + // --- Current IPv4 address. + // @return string The address, or 0.0.0.0 when not connected. + {"localIP", l_wifi_localIP}, + // --- Drops the connection but keeps the saved credentials. + {"disconnect", l_wifi_disconnect}, + // --- Drops the connection and erases the saved credentials. + // @return boolean Whether the settings were saved. + {"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 1008a7b..0a778a2 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -127,6 +127,7 @@ void LuaApp::registerBindings() { registerInput(state); registerFs(state); registerWifi(state); + registerHttp(state); } // Deferred: sys.exit() and fail() run inside a lua_pcall, so closing the state diff --git a/src/main.cpp b/src/main.cpp index b79915f..ca0c706 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,6 +17,11 @@ static constexpr int TOUCH_CS = 33; static const char* LAUNCHER = "/apps/launcher/main.lua"; +// Arduino's default 8KB loop stack is not enough once a TLS handshake runs inside a +// Lua binding: the first HTTPS download tripped the stack canary. Everything the VM +// calls runs on this task, so the headroom belongs here rather than in each caller. +SET_LOOP_TASK_STACK_SIZE(16 * 1024); + TFT_eSPI tft; SPIClass touchSpi(HSPI); SPIClass& sdSpi = SPI; diff --git a/stubs/esp32lcd.lua b/stubs/esp32lcd.lua new file mode 100644 index 0000000..a8ce4ae --- /dev/null +++ b/stubs/esp32lcd.lua @@ -0,0 +1,305 @@ +---@meta + +-- Generated by scripts/gen_lua_stubs.py. Do not edit. +-- Point your editor's Lua language server at this file to get completion for the +-- firmware API inside sdcard/apps and sdcard/lib. + +---@class fslib +fs = {} + +--- Reads a whole file from the SD card. +---@param path string Absolute path. +---@return string? Contents truncated to 65536 bytes, or nil when missing. +function fs.readFile(path) end + +--- Writes a whole file to the SD card, replacing it if it exists. +---@param path string Absolute path. +---@param content string +---@return boolean +function fs.writeFile(path, content) end + +--- Whether a path exists. +---@param path string Absolute path. +---@return boolean +function fs.exists(path) end + +--- Names of the files in a directory, excluding dotfiles. +---@param path string Absolute path. +---@return string[] +function fs.listFiles(path) end + +--- Names of the subdirectories in a directory, excluding dotfiles. +---@param path string Absolute path. +---@return string[] +function fs.listDirs(path) end + +---@class guilib +gui = {} + +--- Panel width in pixels, for the current rotation. +---@return integer +function gui.width() end + +--- Panel height in pixels, for the current rotation. +---@return integer +function gui.height() end + +--- Fills the whole panel with one color. +---@param color integer? Defaults to white. +function gui.clear(color) end + +--- Fills a rectangle. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param color integer +function gui.fillRect(x, y, w, h, color) end + +--- Strokes a one pixel rectangle outline. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param color integer +function gui.drawRect(x, y, w, h, color) end + +--- Fills an anti-aliased circle, blending its rim against the surface behind it. +---@param x integer Center. +---@param y integer Center. +---@param radius integer +---@param color integer +---@param bg integer? Surface color to blend against, defaults to white. +function gui.fillCircle(x, y, radius, color, bg) end + +--- Strokes a line. +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param color integer +function gui.drawLine(x1, y1, x2, y2, color) end + +--- Draws text with an opaque background behind its glyphs. +---@param text string +---@param x integer +---@param y integer +---@param color integer? Defaults to black. +---@param bg integer? Defaults to white. +function gui.drawText(text, x, y, color, bg) end + +--- Draws a rounded rectangle: fill, vertical gradient and border from one +--- distance field, so the edges cannot disagree. +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param radius integer Clamped to half the shorter side. +---@param bg integer Surface color the anti-aliased edge blends against. +---@param top integer? Fill color, or the top of the gradient. +---@param bottom integer? Bottom of the gradient, defaults to top. +---@param border integer? Border color; omitted draws no border. +function gui.roundRect(x, y, w, h, radius, bg, top, bottom, border) end + +--- Height of the current font in pixels. +---@return integer +function gui.fontHeight() end + +--- Width the given text would occupy in pixels. +---@param text string +---@return integer +function gui.textWidth(text) end + +--- Rotates the frame for this draw only; sys.setRotation persists it. +---@param rotation integer 0 to 3, in quarter turns. +function gui.setRotation(rotation) end + +--- Packs 8 bit channels into the panel's RGB565 color format. +---@param r integer +---@param g integer +---@param b integer +---@return integer +function gui.color(r, g, b) end + +---@class httplib +http = {} + +--- Fetches a URL. Certificates are verified against the embedded root bundle. +---@param url string +---@param headers table? Header name to value. +---@return string? Response body, or nil when the status is not 2xx or the body exceeds 50000 bytes. +---@return integer HTTP status, or -1 when the request could not be sent. +function http.get(url, headers) end + +--- Fetches a URL, discarding the body. +---@param url string +---@param headers table? Header name to value. +---@return string? Response body, always empty on success. +---@return integer HTTP status, or -1 when the request could not be sent. +function http.head(url, headers) end + +--- Deletes a resource. +---@param url string +---@param headers table? Header name to value. +---@return string? Response body. +---@return integer HTTP status, or -1 when the request could not be sent. +function http.delete(url, headers) end + +--- Posts a body to a URL. +---@param url string +---@param body string? Request body, empty when omitted. +---@param headers table? Header name to value. +---@return string? Response body. +---@return integer HTTP status, or -1 when the request could not be sent. +function http.post(url, body, headers) end + +--- Patches a resource. +---@param url string +---@param body string? Request body, empty when omitted. +---@param headers table? Header name to value. +---@return string? Response body. +---@return integer HTTP status, or -1 when the request could not be sent. +function http.patch(url, body, headers) end + +--- Streams an HTTPS URL to a file, checking size and digest before keeping it. +---@param url string Must be https. +---@param destination string Absolute path that must not already exist. +---@param options table maxBytes is required; expectedSize and sha256 are optional. +---@return integer? Bytes written, or nil on failure. +---@return string? Error message when the download failed. +function http.download(url, destination, options) end + +--- Percent-encodes a string, keeping the RFC 3986 unreserved characters. +---@param input string +---@return string +function http.urlencode(input) end + +---@class inputlib +input = {} + +--- Current touch point, calibrated and rotated. +---@return integer? X, or nil when the panel is not touched. +---@return integer? Y. +function input.getTouch() end + +--- Current touch point as raw ADC readings, for calibration. +---@return integer? X, or nil when the panel is not touched. +---@return integer? Y. +function input.getRawTouch() end + +--- Whether the panel is being touched. +---@return boolean +function input.touched() end + +---@class loglib +log = {} + +--- Writes a debug line to the serial log. +---@param message string +function log.debug(message) end + +--- Writes an info line to the serial log. +---@param message string +function log.info(message) end + +--- Writes an error line to the serial log. +---@param message string +function log.error(message) end + +---@class syslib +sys = {} + +--- Milliseconds since boot. +---@return integer +function sys.millis() end + +--- Blocks for the given time. +---@param ms integer +function sys.delay(ms) end + +--- Ends this app and returns to the launcher. +function sys.exit() end + +--- Ends this app and starts another one. +---@param path string Absolute path to the app's main.lua. +function sys.launch(path) end + +--- Saved screen rotation in degrees clockwise. +---@return integer +function sys.getRotation() end + +--- Rotates the screen and saves it. +---@param degrees integer 0, 90, 180 or 270. +---@return boolean Whether the setting was saved. +function sys.setRotation(degrees) end + +--- Name of the active theme in /lib/theme.lua. +---@return string +function sys.getTheme() end + +--- Selects a theme by name and saves it. +---@param name string +---@return boolean Whether the setting was saved. +function sys.setTheme(name) end + +--- Stores touch calibration, in the panel's unrotated frame. +---@param x0 integer Raw reading at the left edge. +---@param y0 integer Raw reading at the top edge. +---@param x1 integer Raw reading at the right edge. +---@param y1 integer Raw reading at the bottom edge. +---@return boolean Whether the setting was saved. +function sys.setCalibration(x0, y0, x1, y1) end + +--- Whether SNTP has answered. Until it has, os.time() is only a build-time floor. +---@return boolean +function sys.clockSynced() end + +--- Active POSIX timezone rule. +---@return string +function sys.getTimezone() end + +--- Sets the timezone from a POSIX TZ rule and saves it. +---@param tz string For example EST5EDT,M3.2.0,M11.1.0. +---@return boolean Whether the setting was saved. +function sys.setTimezone(tz) end + +---@class wifilib +wifi = {} + +--- Scans for networks, blocking until the sweep finishes. +---@return table[] Each entry has ssid, rssi and secure. +function wifi.scan() end + +--- Saves credentials and starts connecting. Poll status() for the outcome. +---@param ssid string +---@param password string? Omitted for an open network. +---@return boolean Whether the credentials were saved and the attempt started. +function wifi.connect(ssid, password) end + +--- Current connection state. +--- connecting, connected, not_found or failed. +---@return table Fields state, ssid, ip and rssi. state is one of disconnected, +function wifi.status() end + +--- Whether the station is associated. +---@return boolean +function wifi.isConnected() end + +--- Current IPv4 address. +---@return string The address, or 0.0.0.0 when not connected. +function wifi.localIP() end + +--- Drops the connection but keeps the saved credentials. +function wifi.disconnect() end + +--- Drops the connection and erases the saved credentials. +---@return boolean Whether the settings were saved. +function wifi.forget() end + +-- Callbacks an app may define as globals: +-- setup() once, before the first draw +-- draw() every 33 ms while the app runs +-- on_tick() every TICK_MS, when that global is set +-- on_touch_down(x, y) finger down +-- on_touch_up(x, y) finger up +-- on_touch(x, y) tap, fired on release like a click