# 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](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//` changes its SHA-256, and the firmware verifies every downloaded file against `manifest.txt` / `catalog.txt`. Stale metadata means installs fail on device. ```bash 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//main.lua`. `` is `[A-Za-z0-9_-]+` and becomes the SD directory `/.apps/`. 2. First 255 bytes must contain the description tag — it is the subtitle in the Apps list: ```lua -- DESCRIPTION: One-line summary shown in the Apps list ``` 3. Define the two required globals. There is no `loop()` or `exit()` hook: ```lua 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//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`): ```lua 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.