8.0 KiB
AI Agent Guidelines — xteink-apps
Lua apps for CrossPoint Reader (Xteink X4, ESP32-C3, 800x480 e-ink). Repo layout, catalog/manifest formats, and limits are in README.md; this file covers the API surface and the rules that are easy to get wrong.
The API below is the contract this repo targets. It is implemented by the CrossPoint Reader firmware
in src/util/LuaManager.cpp (bindings) and src/activities/util/LuaActivity.cpp (lifecycle); consult
those only if you have that repo checked out and suspect this document has drifted.
Non-negotiable: regenerate manifests
Any change to a file under apps/<AppId>/ changes its SHA-256, and the firmware verifies every
downloaded file against manifest.txt / catalog.txt. Stale metadata means installs fail on device.
scripts/update-manifests.py # regenerate manifests + catalog.txt
scripts/update-manifests.py --check # verify they are current (CI-style)
Run it before every commit that touches app payloads, and commit payload + manifest.txt +
catalog.txt together. README.md files are excluded from manifests by design.
Adding an app
- Create
apps/<AppId>/main.lua.<AppId>is[A-Za-z0-9_-]+and becomes the SD directory/.apps/<AppId>. - First 255 bytes must contain the description tag — it is the subtitle in the Apps list:
-- DESCRIPTION: One-line summary shown in the Apps list - Define the two required globals. There is no
loop()orexit()hook:function init() end -- called once, after the chunk runs function draw() end -- called ~30 Hz (33 ms tick) and once per queued button event - Add
apps/<AppId>/README.md(repo docs, not installed). - Regenerate manifests.
Lifecycle
init()runs once; an error in it or indraw()drops the user on an error screen (no line numbers — debug info is stripped at load).draw()is called at least every 33 ms even with no input, and once per queued button event. Exactly oneinput.wasPressed(...)latch is drained per call, so never poll input in a loop.sys.exit()only sets a flag: the currentdraw()finishes, and the app exits on the next tick.returnafter calling it.sys.delay(ms)blocks the render task. Avoid it; drive state offsys.millis().- Apps prevent auto-sleep while running. Wi-Fi is torn down automatically at exit if the app connected it.
E-ink refresh: pick the waveform deliberately
gui.refresh(mode) redraws the whole panel; the mode chooses the waveform, not a region.
| Mode | Use for |
|---|---|
REFRESH_FAST |
Incremental edits on top of a frame you know is on screen (cursor moves, list scroll). Single-direction drive, no flash, ghosting accrues (the driver force-clears every N refreshes). |
REFRESH_HALF |
Wholesale content change (screen transition, first frame, after a status/progress screen). Ghost-clearing, ~1.7 s. |
REFRESH_FULL |
Rarely — maximum contrast, slowest, most flashing. |
The panel diffs new-vs-previous frame, so a full repaint with REFRESH_FAST still only moves the
pixels that actually changed. Repainting everything each frame is fine; picking REFRESH_HALF for
every keypress is not.
Pattern (see apps/AppStore/main.lua refreshMode(), apps/Wordle/main.lua:211):
local mode = screen == lastScreen and REFRESH_FAST or REFRESH_HALF
lastScreen = screen
gui.refresh(mode)
API surface
All globals; no require. Full Lua stdlib is open, but prefer the fs table over io/os —
those bypass the firmware's SD mutex.
gui
| Call | Returns |
|---|---|
gui.clear() |
– |
gui.refresh([mode=REFRESH_FAST]) |
– |
gui.width() / gui.height() |
integer, orientation-aware |
gui.setOrientation(mode) |
– ; "landscape_cw", "landscape_ccw", "portrait_inv", anything else = portrait |
gui.drawRect(x, y, w, h [, color]) |
– |
gui.fillRect(x, y, w, h [, color]) |
– |
gui.drawRoundedRect(x, y, w, h [, lineWidth=2] [, radius=10] [, color]) |
– |
gui.fillRoundedRect(x, y, w, h [, radius=10] [, color=COLOR_BLACK]) |
– ; only call that renders true grayscale |
gui.drawLine(x1, y1, x2, y2 [, lineWidth=1] [, color]) |
– |
gui.drawPixel(x, y [, color]) |
– |
gui.drawCircle(cx, cy, r [, width=1] [, color]) / gui.fillCircle(cx, cy, r [, color]) |
– |
gui.fillPolygon(xTable, yTable [, color]) |
– ; 1-based arrays, silently no-ops below 3 points |
gui.drawText(fontId, x, y, text [, color] [, style]) |
– |
gui.drawCenteredText(fontId, y, text [, color] [, style]) |
– |
gui.getTextWidth(fontId, text [, style]) |
px |
gui.drawButtonHints([back] [, confirm] [, prev] [, next]) |
– ; logical labels, remapped to physical positions |
gui.drawBmp(path [, x] [, y] [, maxWidth] [, maxHeight]) |
boolean; centered when x/y omitted |
Constants: COLOR_CLEAR/WHITE/LIGHT_GRAY/DARK_GRAY/BLACK, FONT_UI_10, FONT_UI_12, FONT_SMALL,
FONT_NOTOSANS_12/14/16, FONT_BOOKERLY_12/14/16, STYLE_REGULAR (= STYLE_NORMAL), STYLE_BOLD,
gui.HINT_BACK/HINT_OK/HINT_PREV/HINT_NEXT.
Color args accept a number, or a boolean (true=black, false=white). Except for
fillRoundedRect, gray values collapse to black.
input
input.wasPressed(name), input.wasReleased(name), input.isPressed(name), input.isAnyPressed()
→ boolean.
Names: "back", "confirm", "left", "right", "up", "down", "page_back", "page_forward".
An unrecognized string silently resolves to back, so typos are invisible — spell them exactly.
fs
| Call | Returns |
|---|---|
fs.exists(path) |
boolean |
fs.listDirs(path) / fs.listFiles(path) |
sorted array table, dotfiles excluded, empty table if not a dir |
fs.readFile(path) |
string (truncated at 50000 B), nil on failure |
fs.fileSize(path) |
integer or nil |
fs.readLineAt(path, offset) |
line, nextOffset; nil, nextOffset if the line exceeds 192 B |
fs.writeFile(path, content) |
boolean only — no error string |
fs.mkdir(path) / fs.rename(src, dst) / fs.remove(path) / fs.removeTree(path) |
true | nil, errString |
Constraints that bite:
fs.renamefails if the destination exists. Replacing a directory means rename-away, rename-in, then delete the backup (seeinstall()inapps/AppStore/main.lua).- Mutation calls require an absolute path with no
..and reject the/.appsand/.crosspointroots themselves; paths under them are fine. - Stream large files with
fs.readLineAtrather thanfs.readFile— 380 KB total RAM.
net
| Call | Returns |
|---|---|
net.wifiConnect() |
– ; async, poll wifiStatus() |
net.wifiStatus() |
"idle" | "connecting" | "connected" | "failed" |
net.wifiDisconnect() |
– |
net.get/head/delete(url [, headers]) |
body | nil, statusCode |
net.post/patch(url, body [, headers]) |
body | nil, statusCode |
net.download(url, destPath, options) |
bytesWritten | nil, errString |
net.urlencode(s) |
string |
- Never block on Wi-Fi: enter a
"connecting"screen and poll fromdraw(). - Response bodies are capped at 50000 B and returned only for 2xx; status is
-1on client failure. net.downloadrequires an options table; keys aremaxBytes(required),expectedSize,sha256(64 hex). An unknown key is an error. It verifies size and hash for you — use it for anything installed to disk.- TLS does not verify certificates. Treat downloaded content as untrusted; validate before use.
sys / log
sys.millis(), sys.uptime(), sys.delay(ms), sys.exit().
log.debug(msg), log.info(msg), log.error(msg) — single string argument, no format args.
Style
- Locals over globals;
localeverything exceptinit/draw. - Validate anything read from disk or network before use — parse errors on device are hard to debug with no line numbers.
- Collect garbage (
collectgarbage("collect")) before large allocations such as a catalog parse. - Match the existing 4-space indentation and the
if cond then return endsingle-line guard style.