REST transport¶
Tesserae ships two delivery transports for getting frames out to your panels:
- REST (default for new installs in v0.52+). Devices poll Tesserae over HTTP. No broker required. Simpler setup, lower-overhead for new users.
- MQTT (the original). Devices subscribe to a broker. Lower wake- cycle latency (no polling interval), but you need a broker (the bundled amqtt or an external Mosquitto).
Both transports stay supported. Existing MQTT installs continue to work unchanged; this page is for new installs going REST-first, and for existing MQTT users who want to convert a device to REST.
When to pick REST¶
Pick REST when:
- You don't already run an MQTT broker
- You don't want to install Mosquitto or use the bundled amqtt
- You're battery-powered + happy with the firmware-chosen poll interval (typically 10-60 minutes for an e-ink panel)
- You're behind NAT and don't want to expose a broker port
Pick MQTT when:
- You already run Mosquitto (Home Assistant users typically do)
- You want sub-second push from compose → panel
- You have always-on clients (Pi-side) that benefit from event-driven updates instead of polling
You can mix: some devices on REST, some on MQTT. Tesserae's push
pipeline reads Device.transport per instance and skips MQTT publish
for REST devices automatically.
MQTT ↔ REST capability mapping¶
If you're coming from a working MQTT setup, this table shows the REST equivalent of every MQTT capability Tesserae uses.
| Capability | MQTT (current) | REST (v0.52+) | Notes / trade-offs |
|---|---|---|---|
| Frame delivery | Server publishes retained message to tesserae/<device_id>/frame/<ext> with payload {url}. Device subscribes; broker pushes message on subscribe (retained) and on each new render. |
Server caches latest frame per device. Device GETs /api/v1/device/<id>/frame → 200 + {url, format, panel_w, panel_h, render_id} + ETag header. |
MQTT is sub-second push; REST is bounded by the device's poll interval. Battery devices wake on a cycle anyway, so for the e-ink target the difference is theoretical. |
| Retain semantics | Broker stores last retained message indefinitely. Fresh subscriber gets it on connect, retain flag = 1 on the inbound. | Server stores latest render per device in data/core/latest_renders.json. Any GET returns it. ETag-driven If-None-Match returns 304 if unchanged, so a sleeping client saves a fetch + paint cycle when nothing changed. |
REST is actually richer: the 304 path saves panel-refresh time (~10s on Spectra 6) AND battery. MQTT retain doesn't tell the client "this is the same as last time." |
| Status heartbeat | Device publishes JSON to tesserae/<device_id>/status. Server subscribes (per-device + wildcard for discovery). |
Device POSTs JSON to /api/v1/device/<id>/status. |
Parsed via the same device.parse_status hook in both transports; merged into the same DEVICE_STATUS cache. Settings → Devices freshness dot looks identical. |
| Config push (server → device) | Server publishes retained JSON to tesserae/<device_id>/config when user saves on Settings → Devices. Device subscribes. |
Server's response to POST /status includes the current config block and next_poll_s. Firmware applies on each wake. |
MQTT propagates config in real time; REST propagates on the device's next wake. For sleep intervals measured in minutes / hours, both feel equivalent. One round-trip per wake instead of two. |
| Discovery (unknown device) | Server wildcard-subscribes to tesserae/+/status. Any heartbeat from an unregistered device id appears in the Discovered strip. |
Firmware POSTs /api/v1/device/discover (unauthenticated). Entry added to the same DiscoveryCache that MQTT feeds. Shows up in the same Discovered strip. |
MQTT discovery is incidental (any client publishing status is discovered). REST requires an explicit announce call. Either way ends up in the same admin UI. |
| Device → server auth | Broker-level: username + password set in broker config, used by every client. One shared credential. | Per-device bearer token in the Authorization: Bearer <token> header (or X-Tesserae-Token fallback). Tokens are unique per device, revocable per device. |
REST is a meaningful security improvement: revoking one device's access doesn't affect the others. |
| Server → device auth | Same broker creds; topic ACLs are not enforced by amqtt's default config. | Server-side token validation checks the URL device id matches the token's owner. Cross-device access returns 403. | REST has real per-device isolation at the auth layer; MQTT's bundled-broker setup gives effectively shared credentials. |
| First-boot pairing | User flashes broker host + port + username + password into firmware (captive portal, build-time config, etc.). | User generates a 6-digit pairing code on Settings → Devices → Pair new device, flashes that into firmware. Firmware POSTs /api/v1/device/register once, persists the device token, wipes the pairing code. |
REST replaces "type 4 broker fields" with "type 1 pairing code." Friendlier UX, single rotation point if compromised. |
| Idempotent registration | N/A (no register step). | POST /register with an already-registered device_id returns 200 + reused_existing: true + the existing token. A firmware retry after a network glitch is safe. |
MQTT doesn't have this problem because there's no pairing step. |
| Brute-force protection | None at the broker level (amqtt) for credential guessing. The shared credential makes guessing one device's worth of access trivial anyway. | POST /register is rate-limited (10 failed attempts / 60s / IP) with Retry-After on 429. Successful registers release the bucket. |
A real win for REST. The 6-digit code's 20 bits of entropy is meaningful only with rate limiting; without it, a brute force takes minutes. |
| Transport lifecycle | Persistent TCP connection. Keepalive packets. Reconnect logic on broker drop. | Stateless HTTP. No connection to maintain. | REST is simpler in firmware: no connection state, no keepalive, no reconnect path. Each wake makes one or two requests and exits. |
| Server lifecycle / broker | Needs a broker process (bundled amqtt or external Mosquitto). Broker has its own restart / health / log story. | Just the Tesserae server itself. | One fewer service to operate. The bundled amqtt stays in tree for users who want MQTT but is no longer auto-enabled on fresh installs. |
| HA Assistant integration | Tesserae publishes HA MQTT discovery configs to homeassistant/+/config. HA picks them up via its own MQTT subscription. |
Not implemented for REST in v0.52. HA discovery still requires an MQTT broker. | Phase 2+ work. For now, HA integration users keep MQTT enabled or use Tesserae's HA App with Mosquitto. |
| Multicast / wildcard fanout | One published message reaches every subscriber; broker handles fanout. | Each subscriber GETs independently. Each request hits the server. | Doesn't matter for the typical e-ink fleet (1–10 panels). Would matter at hundreds. |
| Cross-device observability | One MQTT explorer subscribed to tesserae/# sees the entire fleet's traffic. |
One admin session sees device status via Settings → Devices. No equivalent "tee" of all device traffic. | Operationally MQTT is easier to debug live. REST is easier to debug from logs (every request is in the access log). |
| Manual firmware diagnostics | Firmware can publish to arbitrary topics; admin reads via any MQTT client. | Firmware POSTs /api/v1/device/<id>/log → server's EventLog. Visible on Settings → Events. |
REST integrates with the EventLog instead of needing an external client. |
| TLS | Mosquitto can do TLS with certificate management. amqtt's TLS story is less battle-tested. | HTTP only in v1. HTTPS via reverse proxy (Caddy / NGINX) works since firmware just sees the URL. | Equivalent in practice if both rely on a reverse-proxy frontend. |
| Latency from compose → panel | Sub-second (publish + subscriber wake). | Bounded by next_poll_s. Default 60s for a Pi client, 15min for a battery client. |
MQTT wins when "instant" matters (dev preview / manual Send). REST wins everywhere battery is the priority. |
| Setup / install cost (new user) | Install Mosquitto, edit mosquitto.conf, open :1883, generate creds, paste into Tesserae + every client. ~10 minutes for a first-time installer. |
Run Tesserae. Devices REST-pair via 6-digit code. No service to install. < 1 minute. | The headline reason REST is the v0.52+ default. |
| Migration | N/A. | Per-device flip: Settings → Devices → Switch to REST (or to MQTT). Token persists across flips so flipping back is one click. | A heterogeneous fleet is fine — some devices MQTT, some REST. |
How REST works end-to-end¶
- Server hosts the endpoints. Tesserae's HTTP server (waitress
in prod) serves
/api/v1/device/<id>/...routes for frame fetch, status heartbeats, config polls, and first-boot pairing. - Firmware polls on its wake cycle. A battery-powered client wakes, fetches the latest frame URL, paints it, posts a heartbeat, then deep-sleeps. One round trip per wake.
- Pairing replaces broker creds. Instead of typing broker host + port + username + password into firmware, the user generates a 6-digit pairing code in Tesserae's admin UI and pastes it into the firmware once on first boot. The firmware POSTs the code, gets a permanent device token back, persists the token in flash, and forgets the pairing code.
Pairing a new REST device (UI flow)¶
- Settings → Devices → Pair new device.
- Click Issue pairing code. A six-digit code appears (10-minute TTL). Copy it.
- Flash your firmware in REST mode (the firmware needs to be built with REST support; the firmware prompts below describe what's needed in each codebase).
- On the firmware's setup form (captive portal, build-time config,
serial, depends on the platform), paste:
- The Tesserae server URL (e.g.
http://tesserae.local:8765) - The pairing code
- The Tesserae server URL (e.g.
- First boot, the firmware POSTs
/api/v1/device/registerwith the code. Tesserae creates a device instance withtransport: "rest"and returns a per-device access token. The firmware persists the token; subsequent wakes use it as a bearer token. - The device shows up in Settings → Devices alongside any MQTT
devices. Its meta block shows
Transport: REST+ the first few characters of its token.
REST API reference¶
All endpoints under /api/v1/device/. Auth is per-device bearer token
(Authorization: Bearer <token> or X-Tesserae-Token: <token> for
firmware HTTP libs that hate Authorization headers).
GET /api/v1/device/<id>/frame¶
Returns the latest rendered frame URL + format + panel dims for this device.
| Status | Meaning |
|---|---|
| 200 | JSON {url, format, panel_w, panel_h, render_id, renderer_id}. ETag header carries the render digest. |
| 304 | If-None-Match matches current ETag; skip fetch + paint. |
| 204 | No frame has been rendered yet for this device. |
| 401 | Missing or invalid bearer token. |
| 403 | Token valid but for a different device than the URL claims. |
Use the ETag-driven 304 to save battery: a freshly-woken device whose composition hasn't changed gets a 304 in <100 ms instead of pulling the .bin and triggering a 10-second Spectra 6 refresh.
POST /api/v1/device/<id>/status¶
Heartbeat. Body JSON:
{
"battery_mv": 3850,
"battery_pct": 72,
"rssi": -64,
"ip": "10.0.0.42",
"sleep_until": 1700000300.5,
"next_sleep_s": 600,
"fw_version": "0.1.0"
}
Response piggybacks the current per-device config AND the next poll cadence:
{
"status": 200,
"config": { "sleep_interval_s": 900 },
"next_poll_s": 900,
"server_time": 1700000100.5
}
One round trip per wake. The firmware doesn't need a separate config poll.
POST /api/v1/device/register (first boot)¶
Pairing flow. Headers + body:
X-Pairing-Code: 123456
Content-Type: application/json
{"device_id": "bedroom_pico", "kind": "pico_bin_client",
"panel_w": 1600, "panel_h": 1200, "fw_version": "0.1.0",
"mac": "aabbccddeeff"}
Response (201):
{
"status": 201,
"device_token": "abc1...XYZ",
"server_time": 1700000000,
"config": { "sleep_interval_s": 900 },
"reused_existing": false
}
If device_id already exists (firmware retry case), the response is
200 + reused_existing: true + the existing token rather than
failing.
Rate-limited per client IP (10 failed attempts / 60s window). Successful registrations release the bucket; an attacker can't grind one IP without the rate limiter biting.
POST /api/v1/device/discover (optional, first boot before pairing)¶
Firmware can announce itself BEFORE getting a pairing code. Useful for "flash firmware, see if it appears in admin UI, generate code there, flash code back to firmware" workflows.
Body:
{"device_id": "fresh_pico", "kind": "pico_bin_client",
"panel_w": 1600, "panel_h": 1200, "fw_version": "0.1.0",
"mac": "aabbccddeeff"}
Response (200): {"status": 200, "discovered": true, "next_step": "..."}.
The entry shows up in the Discovered strip on Settings → Devices alongside MQTT-discovered devices.
POST /api/v1/device/<id>/log (optional, client diagnostics)¶
Appended to the Tesserae EventLog so firmware logs surface on the Events page alongside server-side events.
Switching a device between transports¶
Settings → Devices → click the device card → Switch to MQTT (or Switch to REST) at the bottom. The device's id / panel settings / per-clone renderer settings stay; only the transport flips.
- REST → MQTT: drops the
transport: "rest"flag from the manifest. The next render publishes to the device's status / config / frame topics over MQTT. The access token is kept in case the user flips back to REST later. - MQTT → REST: sets the flag, mints a per-device access token if one doesn't exist, shows the token in a one-shot reveal so you can copy it into firmware.
Firmware¶
The Tesserae repo's notes/prompts/ directory has self-contained
prompts for Claude Code (or any AI coding assistant) for porting
existing firmware to REST:
notes/prompts/rest-firmware-pi.md— Raspberry Pi clients (paho-mqtt → requests)notes/prompts/rest-firmware-esp32-idf.md— ESP-IDF firmware (esp-mqtt → esp_http_client; covers tesserae-device-esp32-bin, tesserae-device-esp32-bw, tesserae-device-photopainter-7.3-bin)notes/prompts/rest-firmware-pico-sdk.md— Pico SDK firmware (REST-only path for new RP2350 builds)
Each prompt is self-contained: drop it into a fresh Claude Code session in the firmware repo and it has everything needed (API reference, library suggestions, constraints, acceptance criteria).
Security notes¶
- 6-digit codes have 20 bits of entropy (~1M values). The rate limiter caps brute force at 10 failed attempts per IP per minute, so cracking a random code requires patience and is detectable in the logs. For LAN-only homelabs this is acceptable; if you expose Tesserae to the public internet, layer additional access control (reverse-proxy auth, Tailscale, VPN) on top.
- Per-device tokens are 20-character alphanumeric (~120 bits of entropy). Sufficient for direct bearer-token auth on a LAN; not designed for public internet exposure on their own.
- The discovery endpoint is unauthenticated (firmware has no token yet). It shares the register endpoint's rate limiter so an attacker can't spam the Discovered strip.
- HTTP only in v1. No TLS support yet; the server runs HTTP on the LAN. If you need encryption, stack a reverse proxy (Caddy, NGINX Proxy Manager, Cloudflare Tunnel) and have firmware point at the HTTPS-fronted endpoint.
Migrating an MQTT install to REST¶
You don't need to. Existing MQTT installs keep working unchanged; the REST transport sits alongside MQTT. If you want to flip a device, use the per-device toggle described above.
If you want to flip the entire install:
- Settings → Server → App → Default transport = REST. New devices added via the wizard default to REST.
- For existing MQTT devices, use the per-device toggle to move them one at a time. Each switch reveals the new bearer token for that device which needs to be flashed into firmware.
- Once every device is on REST, you can stop the broker (Settings → Server → Broker → uncheck "Built-in broker"; for external Mosquitto, stop the service yourself).