chore: agents
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user