chore: agents

This commit is contained in:
2026-07-26 20:59:07 -04:00
parent 7ba630a805
commit 1618a645a0
+165
View File
@@ -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/<AppId>/` 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/<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:
```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/<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`):
```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.