diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..22c3678 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,165 @@ +# 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.