AI-assisted ESP-IDF + LVGL firmware for the JC3636K518C round knob display
These patterns come from a production recorder on this board that uploads audio to a cloud backend, gets it transcribed and summarised, and shows the result on the device. They are vendor-neutral; the last section shows one concrete mapping.
The most drift-prone facts between firmware (C) and backend (TypeScript) are small literals: part size, codec, sample rate, route paths, status enums. Keep them in one package and generate from it:
packages/contract/
routes.ts "/device/pair/start", "/device/pair/poll", "/v1/recordings", ...
values.ts PART_SIZE = 8 MiB, CODEC, SAMPLE_RATE, status enums
auth.ts pairing + token shapes (schema objects)
recordings.ts open / mint part / ack / resume / status / detail shapes
scripts/gen-c-header.ts → firmware/main/include/generated/contract.h
The schema library (Zod here) validates every request at the edge, emits TS types for the
web app, a JSON Schema for anyone else, and a C header with #define CONTRACT_PART_SIZE
(8*1024*1024) and the route strings. The firmware is a conformist: it includes the generated
header and never hand-copies a path. Run the generator before every firmware build.
A device with no keyboard proves it belongs to a person like this:
device → POST /device/pair/start {device_id, nonce, fw_version}
backend → {device_code (secret, device-held), user_code (short, shown on screen),
verification_uri, qr_payload, interval, expires_in}
device → shows user_code + QR; polls POST /device/pair/poll {device_code} every `interval` s
person → opens verification_uri on a phone, logs in, types or scans user_code, approves
backend → poll answers "approved" + {access_token, refresh_token, account_id, display_name}
Poll statuses: authorization_pending, slow_down, denied, expired, approved.
Design points that mattered:
pair/start.user_code is resolved server-side to the pending session; it is never a forgeable
query parameter.device_code is never displayed.Device tokens are a bearer access_token with a TTL (30 days in the reference system) and a
refresh_token, scoped to {device_id, account_id}, revocable server-side.
The lesson (a real incident). The firmware stored the access token, discarded what it
needed to refresh, never refreshed, and did not react to 401. Thirty days after pairing every
upload failed while the screen still said “linked”, and the device had to be re-paired by
hand. Rules that follow:
401.refresh_token, or the device_code if the
design uses it) sealed at rest, not discarded after approval.401 the refresh cannot fix means unlinked: show it, prompt re-pairing, stop claiming
success.The device never holds storage credentials. The backend mints one presigned URL per part.
POST /v1/recordings {recording_id, size, sha256, codec, part_size} → {recording_pk}
POST .../parts {part_number} → {url, expires_in} (mint)
PUT url body = exactly part_size bytes (last part smaller)
POST .../parts/ack {part_number, etag}
GET .../parts → {received_parts: [1, 2, 5]} (resume)
POST .../complete
networking-under-memory-constraints).recording_id is derived deterministically on the device (device id + card generation +
file name), so re-opening after a crash is idempotent: the same id resumes the same
session instead of creating a duplicate.GET received parts, skip them, continue. Same-boot and
cross-reboot use the same path.The device must never wait on processing. The invariant that made the reference system reliable: every hop is asynchronous, and every non-terminal state is reconcilable. No fixed timeout may be the only path to a terminal state.
complete → queue message → worker submits to the ASR service → webhook on finish
→ transcript stored → summariser runs → status row updated
device → GET /v1/recordings/{id} on detail-open and in a boot sweep
→ pulls /transcript and /summary when their status is terminal
cron → every 5 min: find rows stuck in a non-terminal state, re-drive them
GET /v1/recordings/{id} returns two small enums the UI maps straight onto colour = state:
| Facet | Values |
|---|---|
transcript_status |
uploaded, queued, transcribing, done, error |
summary_status |
none, pending, ready, error |
Terminal = done/error and ready/error/none. The device stops polling only at terminal.
| Need | Service | Why |
|---|---|---|
| HTTP edge, routing, validation | Workers | One Worker, modules per bounded context |
| Pairing session, upload session | Durable Objects | Single-writer state per session; no races on poll/ack |
| Recordings, accounts, status rows | D1 (SQLite) | Relational, cheap, queryable by the reconciler |
| Audio parts, transcripts | R2 | S3-compatible multipart with presigned part URLs |
| Token denylist, brand config | KV | Read-mostly, global |
| Transcription jobs | Queues + a cron Worker | At-least-once delivery plus the 5-minute reconciler |
| ASR | External GPU service via webhook | Long-running; never awaited inline |
| Summaries | AI Gateway → a hosted model | Per-account prompts, budget caps |
Auth across the edge: device bearer for /device/* and /v1/*; email OTP + session cookie
for humans; account genesis only inside a live pairing window.
Any stack with an HTTP edge, a durable per-session object or a transactional table, an S3-compatible store, a queue and a scheduler can host the same patterns.
If all that is needed today is “somewhere to send bytes”: templates/receiver/receiver.py.
Single file, stdlib only: PUT /upload/<name> appends (with X-Offset for resume),
GET /upload/<name> returns {"received": n}. Graduate to §4 when a second device or a
real user appears.