PEPhoneCloud

Develop · App spec · ABI 1.1

Write apps for paper.

Apps are separately compiled native binaries the device downloads from the store and runs at launch — no reflash. This is the contract: how to write, build, and publish one. It mirrors docs/APP_SPEC.md in the firmware repo, the canonical source.

The modelDYNAMIC vs BUILT-IN

Built-in apps are compiled into the firmware image — shipping one means reflashing. Dynamic apps are position-independent Xtensa ELF binaries (-fPIC -shared) the firmware — the “kernel” — loads at runtime from the app store.

An app never links M5GFX, the network stack, or a JSON parser. Everything it does on the device it does by calling one function table, the PepApi, passed in at launch. That is what keeps binaries tiny (the reference Zepto app is 21.5 KB) and makes the whole surface one versioned contract. There is no symbol-table linking: an app resolves only its own entry point plus a small allowlist of libc/libgcc routines.

The app contract

One translation unit includes PepApi.h and exports a single entry point. The kernel calls it once per open and the app returns a descriptor of callbacks (in static storage):

const PepAppModule *pep_app_main(const PepApi *api);

typedef struct PepAppModule {
  uint32_t abi_version;    // PEP_ABI_VERSION you compiled against
  const char *name;        // title shown in the app header
  void (*on_open)(void);   // fresh open — draw your first page
  void (*tick)(void);      // every kernel loop while foreground
  void (*on_close)(void);  // about to unload — free nothing
  bool requires_network;   // true → kernel gates open on WiFi
} PepAppModule;
  • on_open — initialize state, draw the first frame (or set a dirty flag and draw in tick). Also runs on wake with the app foregrounded.
  • tick — read input, pump network, redraw on change. Must not block for long.
  • on_close — the loader reclaims the whole image; you free nothing.

Only one dynamic app is resident at a time. Opening loads it; closing frees the entire image, so an app’s memory budget is per-open, never cumulative.

Input & drawing540 × 960 E-INK

Input is polled once per loop by the kernel; your tick reads a snapshot with api->touch() (clicked is a tap edge, x/y the point). Never spin your own input loop. Swipes are OS gestures the kernel handles.

e-ink cannot repaint per primitive, so every draw call sits inside one batching pair, and pairs never nest:

page_begin() … page_end()Full-screen redraw — clears white, flushes once on the crisp waveform.
patch_begin(fastest) … patch_end()Small in-place update; fastest = true is a pure B/W (DU4) patch for low latency.
header(title, show_back)Draws the status bar + app header (and the Back button when show_back).

Keep a dirty flag and, in tick, do if (dirty) { draw(); dirty = 0; } where draw() opens a page, paints the whole frame, and closes it.

The PepApi tableABI 1.1

PepApi.h is the entire device-facing surface. Colors are RGB565 (PEP_BLACK/WHITE/DARKGRAY/LIGHTGRAY); fonts are PEP_FONT_R9/R12/B12/B18/B24; datums are PEP_DATUM_TOP_LEFT/…/BOTTOM_LEFT.

Geometry (read directly)

screen_w, screen_h540 × 960
status_h, content_topstatus band height; first y for app content
back_x/y/w/hthe header Back button hit rect — test it and call request_exit()

Draw primitives

fill_rect / draw_rectrectangles (x, y, w, h, color)
fill_round_rect / draw_round_rectrounded rects (…, r, color)
fill_circle / draw_circle / draw_linecircles and lines
text(s, x, y, font, datum, color)draw a string
text_scaled(…, scale)scaled text (big digits)
text_width(s, font) → intmeasured width in px

Richer drawing (1.1)

fill_triangle / draw_arc / draw_hlineextra primitives
button(x, y, w, h, label, filled, font)a Pixel Paper button (filled = primary)
text_fit(s, font, max_w, out, len)truncate to width with an ellipsis
text_wrapped(s, x, y, w, lh, font, max_lines, color) → intword-wrap; returns y past the last line

Input · lifecycle · system

touch() → PepTouchthis loop's tap/press snapshot
request_exit()close the app → launcher
millis() / delay_ms(ms)time
log(msg)serial log, prefixed with the app id
wifi_connected() → boolconnectivity

Storage · HTTP

kv_set(key, val) / kv_get(key, out, len)per-app KV, namespaced by app id; kv_get returns length or -1
http_get(url, out, len) → intblocking GET; returns HTTP status, < 0 on transport error

MQTT — async bridge (1.1)

mqtt_ensure() / mqtt_connected()connect (blocking, bounded) / status
mqtt_loop()pump the client — call each tick
mqtt_publish_req(channel, json) → boolpublish a request; false if offline
mqtt_poll(out, len) → intdequeue one message (1) or none (0)

JSON DOM (1.1)

json_parse(text) → PepJsonroot node handle (0 = error); free with json_free
json_member(node, key) / json_at(node, i)object field / array element
json_len(node) → intarray/object size
json_str / json_int / json_booltyped leaf reads (json_str returns length or -1)

Keyboard · QR (1.1)

keyboard_input(title, initial, password, out, len) → intmodal text entry; 1 = confirmed, 0 = cancelled
qr_draw(data, cx, top, max_px) → booldraw a QR inside a page/patch; false if data too long
app.json

Each app folder carries an app.json the publisher reads:

{ "name": "Hello", "icon": "H", "net": false, "abiMajor": 1 }
  • name — launcher / store title. icon — one or two chars drawn as the glyph.
  • net — becomes requires_network (true gates opening on WiFi).
  • abiMajor — must equal the kernel’s PEP_ABI_MAJOR, or the device refuses the app.
Build & publish

Apps live in the top-level apps/ directory, one folder per reverse-DNS id.

sdk/tools/build-app.sh   apps/com.vendor.app all        # esp32 + esp32s3
scripts/publish-app.sh   apps/com.vendor.app 1.0.0 "release notes"

The build compiles -Os -fPIC -shared -nostdlib -e pep_app_main with dead-code elimination, strips, and runs pep-lint — which fails the build if the ELF is not a relocatable Xtensa ET_DYN, is missing its entry point, imports a symbol outside the allowlist, or uses an unsupported relocation. Publish uploads both targets plus a manifest to the store; devices install it over the air from the Store app.

The rules that biteREAD BEFORE SHIPPING
  • No library code beyond the allowlist. Apps build -nostdlib; only a handful of libc/libgcc routines (memcpy, the str* family, snprintf, conversions, soft-float/64-bit helpers) are re-exported. Everything else goes through the PepApi, or pep-lint fails the build.
  • tick() must not block for long — it runs every loop. The bounded blockers the kernel expects (mqtt_ensure, keyboard_input, http_get) are the exceptions.
  • Free nothing the kernel owns — the loader reclaims the whole image on close.
  • Prefer fixed arrays over malloc (there is no malloc in the allowlist; UI counts are bounded) and prefer float/int over double (the FPU is single-precision; double = soft-float).
  • Draw only inside a batching pair, never nested, and do not poll input yourself.
Versioning & backends

ABI versioning is append-only within a major. New fields go at the end of the struct (minor bump); an older app never reads past what it knows, so it keeps working. Any reorder/removal is a major bump, and the kernel refuses a mismatched major.

The same ELF runs three ways, chosen by the firmware, not the app:

exec-iram (M5Paper / ESP32-D0WD)code in the ~150 KB IRAM exec heap — the scarce budget; data lives in plentiful PSRAM
exec-psram (M5PaperS3)code in instruction-fetchable PSRAM — effectively unbounded
xip (either)executes in place from flash — no IRAM ceiling

On the classic M5Paper it is compiled code that is capped, not data. The System app’s Exec heap line shows the live budget and the largest .text loadable now. Keep .text well under it and the app runs on every backend — the reference Zepto app is ~11.5 KB.

Examples

apps/com.pep.hello — the minimal app (a tap counter); exercises text, storage, and the draw/input/lifecycle surface.

apps/com.pephone.zepto — the reference real-world app: eleven views over MQTT + JSON + keyboard + QR, fixed-size arrays throughout, a 21.5 KB ELF. Read it as the template for any bridge-backed app.

Full details, including the complete PepApi field list, live in docs/APP_SPEC.md.