docs: stubs, agents & Makefile

This commit is contained in:
2026-07-30 19:38:02 -04:00
parent 068b1e7fc5
commit c2d49d0f64
5 changed files with 504 additions and 80 deletions
+441
View File
@@ -0,0 +1,441 @@
---@meta
-- CrossPoint Lua API stub for LuaLS. GENERATED by scripts/gen_lua_stubs.py
-- from the annotations in src/util/LuaManager.cpp; do not edit by hand.
---@alias CrossPointButton
---| "back"
---| "confirm"
---| "left"
---| "right"
---| "up"
---| "down"
---| "page_back"
---| "page_forward"
---@alias CrossPointOrientation
---| "portrait"
---| "portrait_inv"
---| "landscape_cw"
---| "landscape_ccw"
---@class app
app = {}
---@class fs
fs = {}
---@class gui
---@field HINT_BACK string
---@field HINT_OK string
---@field HINT_PREV string
---@field HINT_NEXT string
gui = {}
gui.HINT_BACK = "<<"
gui.HINT_OK = "o"
gui.HINT_PREV = "<"
gui.HINT_NEXT = ">"
---@class http
http = {}
---@class input
input = {}
---@class log
log = {}
---@class sys
sys = {}
---@class timer
timer = {}
---@class wifi
wifi = {}
---@type integer
COLOR_BLACK = 0
---@type integer
COLOR_CLEAR = 0
---@type integer
COLOR_DARK_GRAY = 0
---@type integer
COLOR_LIGHT_GRAY = 0
---@type integer
COLOR_WHITE = 0
---@type integer
FONT_BOOKERLY_12 = 0
---@type integer
FONT_BOOKERLY_14 = 0
---@type integer
FONT_BOOKERLY_16 = 0
---@type integer
FONT_NOTOSANS_12 = 0
---@type integer
FONT_NOTOSANS_14 = 0
---@type integer
FONT_NOTOSANS_16 = 0
---@type integer
FONT_SMALL = 0
---@type integer
FONT_UI_10 = 0
---@type integer
FONT_UI_12 = 0
---@type integer
REFRESH_FAST = 0
---@type integer
REFRESH_FULL = 0
---@type integer
REFRESH_HALF = 0
---@type integer
STYLE_BOLD = 0
---@type integer
STYLE_NORMAL = 0
---@type integer
STYLE_REGULAR = 0
-- app
--- Enables the on_tick() callback at a fixed interval (0 disables, minimum 33ms). Requires on_tick() to be defined.
---@param intervalMs integer 0-3600000
function app.setTickInterval(intervalMs) end
-- fs
--- Returns true if a file or directory exists.
---@param path string Absolute path on the SD card
---@return boolean
function fs.exists(path) end
--- Returns the size of a file in bytes.
---@param path string Absolute path on the SD card
---@return integer? size Nil if the file is missing or a directory
function fs.fileSize(path) end
--- Lists sorted sub-directory names under a path (hidden entries excluded).
---@param path string Absolute path on the SD card
---@return string[] names
function fs.listDirs(path) end
--- Lists sorted file names under a path (hidden entries excluded).
---@param path string Absolute path on the SD card
---@return string[] names
function fs.listFiles(path) end
--- Creates a directory.
---@param path string Absolute path on the SD card
---@return boolean? ok
---@return string? error Present when ok is nil
function fs.mkdir(path) end
--- Reads an entire file (capped at ~50KB).
---@param path string Absolute path on the SD card
---@return string? content Nil if the file is missing or unreadable
function fs.readFile(path) end
--- Reads the first full line beginning at or after a byte offset, for paging large files.
---@param path string Absolute path on the SD card
---@param offset integer Byte offset (0-based); snaps forward to the next line boundary if mid-line
---@return string? line Nil past end of file, or if the line exceeds 192 bytes
---@return integer? nextOffset Byte offset of the following line
function fs.readLineAt(path, offset) end
--- Deletes a file. /.apps and /.crosspoint are protected.
---@param path string Absolute path on the SD card
---@return boolean? ok
---@return string? error Present when ok is nil
function fs.remove(path) end
--- Recursively deletes a directory tree. /.apps and /.crosspoint are protected.
---@param path string Absolute path on the SD card
---@return boolean? ok
---@return string? error Present when ok is nil
function fs.removeTree(path) end
--- Renames (moves) a file or directory.
---@param source string
---@param destination string Must not already exist
---@return boolean? ok
---@return string? error Present when ok is nil
function fs.rename(source, destination) end
--- Writes a file (creating or overwriting).
---@param path string Absolute path on the SD card
---@param content string
---@return boolean ok
function fs.writeFile(path, content) end
-- gui
--- Clears the framebuffer to white.
function gui.clear() end
--- Draws a BMP file from the SD card, centered by default.
---@param path string Absolute path on the SD card
---@param x? integer Left edge (default centered)
---@param y? integer Top edge (default centered)
---@param maxWidth? integer Bounds the image is scaled into (default screen)
---@param maxHeight? integer Bounds the image is scaled into (default screen)
---@return boolean ok False if the file is missing or not a valid BMP
function gui.drawBmp(path, x, y, maxWidth, maxHeight) end
--- Draws the standard four-button hint bar (use gui.HINT_* for arrow glyphs).
---@param btn1? string Back button label (default "")
---@param btn2? string Confirm button label (default "")
---@param btn3? string Left button label (default "")
---@param btn4? string Right button label (default "")
function gui.drawButtonHints(btn1, btn2, btn3, btn4) end
--- Draws text centered horizontally on the screen.
---@param font integer FONT_* constant
---@param y integer Baseline Y
---@param text string
---@param color? integer COLOR_* constant (default COLOR_BLACK)
---@param style? integer STYLE_* constant (default STYLE_REGULAR)
function gui.drawCenteredText(font, y, text, color, style) end
--- Draws an unfilled circle.
---@param cx integer Center X
---@param cy integer Center Y
---@param radius integer
---@param width? integer Stroke width (default 1)
---@param color? integer COLOR_* constant (default COLOR_BLACK)
function gui.drawCircle(cx, cy, radius, width, color) end
--- Draws a line.
---@param x1 integer Start X
---@param y1 integer Start Y
---@param x2 integer End X
---@param y2 integer End Y
---@param width? integer Stroke width (default 1)
---@param color? integer COLOR_* constant (default COLOR_BLACK)
function gui.drawLine(x1, y1, x2, y2, width, color) end
--- Draws a single pixel.
---@param x integer
---@param y integer
---@param color? integer COLOR_* constant (default COLOR_BLACK)
function gui.drawPixel(x, y, color) end
--- Draws an unfilled rectangle.
---@param x integer Left edge
---@param y integer Top edge
---@param w integer Width
---@param h integer Height
---@param color? integer COLOR_* constant (default COLOR_BLACK)
function gui.drawRect(x, y, w, h, color) end
--- Draws an unfilled rounded rectangle.
---@param x integer Left edge
---@param y integer Top edge
---@param w integer Width
---@param h integer Height
---@param width? integer Stroke width (default 2)
---@param radius? integer Corner radius (default 10)
---@param color? integer COLOR_* constant (default COLOR_BLACK)
function gui.drawRoundedRect(x, y, w, h, width, radius, color) end
--- Draws text at a position.
---@param font integer FONT_* constant
---@param x integer Left edge
---@param y integer Baseline Y
---@param text string
---@param color? integer COLOR_* constant (default COLOR_BLACK)
---@param style? integer STYLE_* constant (default STYLE_REGULAR)
function gui.drawText(font, x, y, text, color, style) end
--- Draws a filled circle.
---@param cx integer Center X
---@param cy integer Center Y
---@param radius integer
---@param color? integer COLOR_* constant (default COLOR_BLACK)
function gui.fillCircle(cx, cy, radius, color) end
--- Draws a filled polygon from parallel vertex arrays (needs 3+ points).
---@param xs int[] X coordinates of each vertex
---@param ys int[] Y coordinates of each vertex
---@param color? integer COLOR_* constant (default COLOR_BLACK)
function gui.fillPolygon(xs, ys, color) end
--- Draws a filled rectangle.
---@param x integer Left edge
---@param y integer Top edge
---@param w integer Width
---@param h integer Height
---@param color? integer COLOR_* constant (default COLOR_BLACK)
function gui.fillRect(x, y, w, h, color) end
--- Draws a filled rounded rectangle.
---@param x integer Left edge
---@param y integer Top edge
---@param w integer Width
---@param h integer Height
---@param radius? integer Corner radius (default 10)
---@param color? integer COLOR_* constant (supports gray values) (default COLOR_BLACK)
function gui.fillRoundedRect(x, y, w, h, radius, color) end
--- Returns the rendered width of a string in pixels.
---@param font integer FONT_* constant
---@param text string
---@param style? integer STYLE_* constant (default STYLE_REGULAR)
---@return integer width
function gui.getTextWidth(font, text, style) end
--- Returns the screen height in pixels for the current orientation.
---@return integer
function gui.height() end
--- Pushes the framebuffer to the panel.
---@param mode? integer REFRESH_* constant (default REFRESH_FAST)
function gui.refresh(mode) end
--- Changes the screen orientation (invalidates any layout assumptions).
---@param mode string "portrait" | "portrait_inv" | "landscape_cw" | "landscape_ccw"
function gui.setOrientation(mode) end
--- Returns the screen width in pixels for the current orientation.
---@return integer
function gui.width() end
-- http
--- Performs an HTTP DELETE request.
---@param url string
---@param headers? table<string,string> Optional request headers
---@return string? body Nil on failure
---@return integer status HTTP status code, or -1 if the request never happened
function http.delete(url, headers) end
--- Downloads an HTTPS URL to a file with optional integrity checks.
---@param url string HTTPS URL
---@param destination string Absolute destination path on the SD card
---@param options table { maxBytes: int (required, up to 16MB), expectedSize?: int, sha256?: string (64 hex chars) }
---@return integer? bytesWritten Nil on failure
---@return string? error Present when the download fails
function http.download(url, destination, options) end
--- Performs an HTTP GET request.
---@param url string
---@param headers? table<string,string> Optional request headers
---@return string? body Nil on failure (capped at ~50KB)
---@return integer status HTTP status code, or -1 if the request never happened
function http.get(url, headers) end
--- Performs an HTTP HEAD request.
---@param url string
---@param headers? table<string,string> Optional request headers
---@return string? body Nil on failure
---@return integer status HTTP status code, or -1 if the request never happened
function http.head(url, headers) end
--- Performs an HTTP PATCH request.
---@param url string
---@param body? string Request body (default "")
---@param headers? table<string,string> Optional request headers
---@return string? body Nil on failure (capped at ~50KB)
---@return integer status HTTP status code, or -1 if the request never happened
function http.patch(url, body, headers) end
--- Performs an HTTP POST request.
---@param url string
---@param body? string Request body (default "")
---@param headers? table<string,string> Optional request headers
---@return string? body Nil on failure (capped at ~50KB)
---@return integer status HTTP status code, or -1 if the request never happened
function http.post(url, body, headers) end
--- Percent-encodes a string for use in a URL query.
---@param input string
---@return string encoded
function http.urlencode(input) end
-- input
--- Returns true while any button is currently held down.
---@return boolean
function input.isAnyPressed() end
--- Returns true while the button is currently held down.
---@param button string Button name
---@return boolean
function input.isPressed(button) end
--- Returns true if the button was pressed since the last input frame. Buttons: "back", "confirm", "left", "right", "up", "down", "page_back", "page_forward".
---@param button string Button name
---@return boolean
function input.wasPressed(button) end
--- Returns true if the button was released since the last input frame.
---@param button string Button name
---@return boolean
function input.wasReleased(button) end
-- log
--- Logs a debug message to serial output.
---@param message string
function log.debug(message) end
--- Logs an error message to serial output.
---@param message string
function log.error(message) end
--- Logs an info message to serial output.
---@param message string
function log.info(message) end
-- sys
--- Blocks the app for the given number of milliseconds.
---@param ms integer
function sys.delay(ms) end
--- Requests the app to exit back to the launcher.
function sys.exit() end
--- Returns milliseconds since boot.
---@return integer
function sys.millis() end
--- Returns milliseconds since boot.
---@return integer
function sys.uptime() end
-- timer
--- Fires on_timer(id) once after the interval. Apps define on_timer(id) to receive it.
---@param intervalMs integer 100-3600000
---@param id string 1-31 letters, digits, underscores, or dashes
function timer.after(intervalMs, id) end
--- Cancels a timer by id.
---@param id string
function timer.cancel(id) end
--- Fires on_timer(id) repeatedly at the interval until cancelled.
---@param intervalMs integer 100-3600000
---@param id string 1-31 letters, digits, underscores, or dashes
function timer.every(intervalMs, id) end
-- wifi
--- Starts connecting to a stored Wi-Fi network (credentials from the reader settings). Poll wifi.status().
function wifi.connect() end
--- Disconnects Wi-Fi if the app started it.
function wifi.disconnect() end
--- Returns whether Wi-Fi is currently connected to an access point.
---@return boolean connected
function wifi.isConnected() end
--- Returns the local IP address of the Wi-Fi interface.
---@return string ip "0.0.0.0" if not connected
function wifi.localIP() end
--- Returns the Wi-Fi connection state, advancing any in-progress attempt.
---@return string status "idle" | "connecting" | "connected" | "failed"
function wifi.status() end
-- Callbacks a runtime app may define: draw(), on_tick(), on_button(name, state), on_timer(id).
+5
View File
@@ -0,0 +1,5 @@
{
"runtime": { "version": "Lua 5.4" },
"workspace": { "library": [".luals"] },
"diagnostics": { "globals": ["app","COLOR_BLACK","COLOR_CLEAR","COLOR_DARK_GRAY","COLOR_LIGHT_GRAY","COLOR_WHITE","FONT_BOOKERLY_12","FONT_BOOKERLY_14","FONT_BOOKERLY_16","FONT_NOTOSANS_12","FONT_NOTOSANS_14","FONT_NOTOSANS_16","FONT_SMALL","FONT_UI_10","FONT_UI_12","fs","gui","http","input","log","REFRESH_FAST","REFRESH_FULL","REFRESH_HALF","STYLE_BOLD","STYLE_NORMAL","STYLE_REGULAR","sys","timer","wifi"] }
}
+28 -78
View File
@@ -1,12 +1,13 @@
# 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.
formats, and limits are in [README.md](README.md); this file covers 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.
**`.luals/crosspoint.lua` is the entire universe available to an app.** It is the LuaLS stub
generated from the firmware bindings — every callable, constant, signature, and doc comment lives
there. Read it for the API; anything absent from it does not exist on device. Regenerate with
`make stubs` (needs the `crosspoint-reader` firmware repo as a sibling checkout; it also refreshes
the `.luarc.json` globals list).
## Non-negotiable: regenerate manifests
@@ -14,8 +15,8 @@ Any change to a file under `apps/<AppId>/` changes its SHA-256, and the firmware
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)
make manifests # regenerate manifests + catalog.txt
make check # verify they are current (CI-style)
```
Run it before every commit that touches app payloads, and commit payload + `manifest.txt` +
@@ -71,90 +72,31 @@ lastScreen = screen
gui.refresh(mode)
```
## API surface
## API gotchas
All globals; no `require`. Full Lua stdlib is open, but prefer the `fs` table over `io`/`os` —
those bypass the firmware's SD mutex.
Signatures live in the stub. These are the behaviors it can't tell you:
### 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:
- All globals; no `require`. Full Lua stdlib is open, but prefer `fs` over `io`/`os` — those bypass
the firmware's SD mutex.
- Color args accept a number or a boolean (`true`=black, `false`=white). `gui.fillRoundedRect` is the
only call that renders true grayscale; elsewhere gray collapses to black.
- `gui.fillPolygon` takes 1-based arrays and silently no-ops below 3 points.
- `gui.drawButtonHints` labels are logical; the firmware remaps them to physical positions.
- An unrecognized `input` button name silently resolves to `back`, so typos are invisible.
- `fs.readLineAt` returns `nil, nextOffset` when the line exceeds 192 B; `fs.writeFile` returns a
bare boolean, unlike the other mutators.
- **`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.
- `log.*` take a single string — no format args.
## Style
@@ -163,3 +105,11 @@ Constraints that bite:
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.
## Keeping this file honest
When a change makes something here wrong — a workflow, a constraint, a gotcha that no longer bites —
update it in the same commit. A stale rule is worse than no rule.
That obligation covers corrections only. Anything new (a convention you'd like to establish, a
section you think is missing) gets proposed to the user first; this file stays short by default.
+23
View File
@@ -0,0 +1,23 @@
CROSSPOINT ?= ../crosspoint-reader
STUB := .luals/crosspoint.lua
.PHONY: manifests check stubs
manifests:
scripts/update-manifests.py
check:
scripts/update-manifests.py --check
# Regenerates the LuaLS stub from the firmware bindings in the sibling repo,
# then rewrites .luarc.json globals from whatever the stub declares.
stubs:
@test -f $(CROSSPOINT)/scripts/gen_lua_stubs.py || { \
echo "ERROR: firmware repo not found at $(CROSSPOINT)"; \
echo " clone crosspoint-reader beside this repo, or run: make stubs CROSSPOINT=/path/to/crosspoint-reader"; \
exit 1; }
cd $(CROSSPOINT) && uv run scripts/gen_lua_stubs.py
cp $(CROSSPOINT)/data/lua/crosspoint.lua $(STUB)
@globals=$$(sed -nE 's/^([A-Za-z_][A-Za-z0-9_]*) = (\{\}|0)$$/\1/p; s/^function ([A-Za-z_][A-Za-z0-9_]*)\(.*/\1/p' $(STUB) | sort -u | sed 's/.*/"&"/' | paste -sd, -); \
printf '{\n "runtime": { "version": "Lua 5.4" },\n "workspace": { "library": [".luals"] },\n "diagnostics": { "globals": [%s] }\n}\n' "$$globals" > .luarc.json
@echo "Updated $(STUB) and .luarc.json"
+7 -2
View File
@@ -6,10 +6,15 @@
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { nixpkgs, flake-utils, ... }:
outputs =
{ nixpkgs, flake-utils, ... }:
flake-utils.lib.eachDefaultSystem (system: {
devShells.default = nixpkgs.legacyPackages.${system}.mkShell {
packages = with nixpkgs.legacyPackages.${system}; [ git uv ];
packages = with nixpkgs.legacyPackages.${system}; [
git
uv
gnumake
];
};
});
}