Files
2026-07-26 20:59:07 -04:00

8.0 KiB
Raw Permalink Blame History

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

  1. Create apps/<AppId>/main.lua. <AppId> is [A-Za-z0-9_-]+ and becomes the SD directory /.apps/<AppId>.
  2. 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
    
  3. Define the two required globals. There is no loop() or exit() 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
    
  4. Add apps/<AppId>/README.md (repo docs, not installed).
  5. Regenerate manifests.

Lifecycle

  • init() runs once; an error in it or in draw() 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 one input.wasPressed(...) latch is drained per call, so never poll input in a loop.
  • sys.exit() only sets a flag: the current draw() finishes, and the app exits on the next tick. return after calling it.
  • sys.delay(ms) blocks the render task. Avoid it; drive state off sys.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.rename fails if the destination exists. Replacing a directory means rename-away, rename-in, then delete the backup (see install() in apps/AppStore/main.lua).
  • Mutation calls require an absolute path with no .. and reject the /.apps and /.crosspoint roots themselves; paths under them are fine.
  • Stream large files with fs.readLineAt rather than fs.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 from draw().
  • Response bodies are capped at 50000 B and returned only for 2xx; status is -1 on client failure.
  • net.download requires an options table; keys are maxBytes (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; local everything except init/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 end single-line guard style.