feat: http bindings matching crosspoint-reader, plus generated Lua stubs

The http table copies crosspoint-reader's signatures exactly -- get/head/delete/post/
patch returning (body|nil, status), download taking maxBytes/expectedSize/sha256, the
same 50000 byte body cap and the same -1 for a request that never left the device --
so a script that talks to a server runs on either firmware. docs/lua-api-parity.md
records that, and every other place the two APIs agree, differ for a reason, or differ
because nobody noticed.

Two crosspoint behaviours are deliberately not copied. It reinterprets a string in
argument 2 of a GET as a request body, which turns a mistyped headers table into a
silent protocol error. More seriously it calls setInsecure() on every request, so TLS
is encrypted but unauthenticated on the very path a firmware update would use; this
verifies against the root bundle already sitting in the framework, and the emulator
confirms expired.badssl.com is refused while a wrong sha256 deletes the file.

Downloading exposed two failures worth naming. A 2KB read buffer on the stack tripped
the loop task's canary because a TLS handshake had already spent it, and the
hand-rolled read loop spun forever on a stream that stopped producing -- HTTPClient's
own writeToStream handles both, so the loop is gone and the loop task gets 16KB.

scripts/gen_lua_stubs.py generates stubs/esp32lcd.lua in the same LuaLS format
crosspoint uses, reading annotations off the luaL_Reg tables so a module's docs sit
with its registration. make test runs --check, which crosspoint's copy never wired up.
This commit is contained in:
2026-08-01 17:33:47 -04:00
parent 2cdb7b3702
commit 6f2d618b8d
14 changed files with 1125 additions and 36 deletions
+11 -2
View File
@@ -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)'
+3
View File
@@ -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/<name>/main.lua` and shared
+95
View File
@@ -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.
+160
View File
@@ -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<name>\w+)",\s*\w+\}')
TABLE = re.compile(r"static const luaL_Reg (?P<var>\w+)\[\]")
BIND = re.compile(r'luaL_newlib\(L,\s*(?P<var>\w+)\);\s*\n\s*lua_setglobal\(L,\s*"(?P<global>\w+)"\)')
DOC = re.compile(r"^\s*//\s*---\s?(?P<text>.*)$")
PARAM = re.compile(r"^\s*//\s*@param\s+(?P<name>\w+)\s+(?P<type>\S+)(?:\s+(?P<desc>.*))?$")
RETURN = re.compile(r"^\s*//\s*@return\s+(?P<type>\S+)(?:\s+(?P<desc>.*))?$")
TAG = re.compile(r"^\s*//\s*@(?P<tag>\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()
+1
View File
@@ -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.
+23 -3
View File
@@ -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");
}
+73 -7
View File
@@ -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");
}
+325
View File
@@ -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 <HTTPClient.h>
#include <SD.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>
#include <mbedtls/sha256.h>
#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");
}
+13 -4
View File
@@ -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");
}
+68 -15
View File
@@ -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");
}
+42 -5
View File
@@ -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");
}
+1
View File
@@ -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
+5
View File
@@ -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;
+305
View File
@@ -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