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:
@@ -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)'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user