Reference
Protocol (/sb/v1)
The protocol between slowd and a hub: transport and auth, the state document, bundles, blobs, events and timing fields.
Read this if you work on slowd or slowhub, port Slowboard to new hardware, or write your own client for a hub. App authors don’t need it. What is Slowboard has the flow in four steps.
/sb/v1 is the protocol between slowd and a hub (slowhub), or between two chained hubs. It is plain HTTP/1.1 with JSON documents. slowd opens every connection.
JSON Schemas for every document are generated from the Rust serde types (schemars) into proto/schema/ in the repo. Times are RFC 3339 with an offset. Unknown fields are ignored.
Design rules
- slowd is authoritative for limits; the hub for prefs. The config is versioned and acked.
- Commands are generation counters. Instead of imperative calls, the state document carries
wants, one counter per command kind. slowd acts once when a counter exceeds the last one it applied, then acks. Replays after a reconnect are harmless by construction. - slowd says what it needs. It reports
needs[](feed,surface,by), and the hub plans from that. - The surface is a render key. Content is always produced for an exact
(w, h, inks). - Events go up and queue offline. Answers are durable; status and presses are coalesced.
Transport and auth
Local. HTTP/1.1 over the Unix socket /run/slowhub/device.sock. The directory is created as d /run/slowhub 2750 epaper slowd: the setgid bit gives the socket group slowd, and the hub sets its mode to 0660. The hub accepts a connection whose SO_PEERCRED uid is the slowd user and registers it as the screen local. No token is needed locally.
Remote. HTTP (HTTPS is planned) on the hub’s opt-in listen_device_tcp, with Authorization: Bearer sbd_<32 base32>. Tokens are created by slowhub device add and stored as sha256. A chained hub uses its own token for each hop; tokens are never forwarded. Every TCP listener checks the Host header against an allowlist.
Two connections. slowd keeps a poll connection (the long-poll) and a separate upload connection (events and the frame), so an upload never waits behind a held poll.
Endpoints
| Method and path | Purpose |
|---|---|
POST /sb/v1/hello | Caps, limits, versions and needs[]. Returns the hub identity, the current wants baseline and the state document. Sent at boot, after a 401, 409 or 410, on a hub change, and every 6 h. |
GET /sb/v1/state?since=<etag>&wait=<s>&config=<ver>&applied=<kind:gen,…>&key=<seq> | Poll or long-poll. The query string carries slowd’s acknowledgements. |
GET /sb/v1/bundles/{id} | Immutable bundle JSON (Cache-Control: immutable) |
GET/HEAD /sb/v1/blobs/{id}[?fmt=png|packed2] | Immutable blob. The id is computed over the packed form. |
POST /sb/v1/events | Batched events, idempotent by event id. Returns the acked ids. |
PUT /sb/v1/glass | The frame report: the packed frame (163,200 B on the 1085) plus an SB-Window header, for screens whose profile has glass_report = "full". The hub keeps it in RAM and serves /preview.png from it. |
The state ETag is "<hub_boot_id>:<seq>", so a hub restart never matches a stale since. A long-poll is held for at most longpoll_max_s (50 s locally, 0–25 s remotely), then returns 304.
hello
slowd to hub:
{"proto":[1],"device":"local","boot_id":"c1f0…","slowd":"0.4.0","profile":"lyra-10in85g",
"clock":{"synced":true,"now":"2026-10-06T09:41:07-07:00"},
"caps":{"display":{"w":1360,"h":480,"bpp":2,"inks":"k4","controllers":[{"x0":0,"x1":679},{"x0":680,"x1":1359}],
"align_x":4,"windows":"per_ic_box"},
"refresh":{"modes":{"normal":{"s":39},"fast":{"s":12.1}},"cooldown_floor_s":180,"max_gap_h":24,
"daily_ceiling":{"global":120,"per_app":48}},
"keys":["red","yellow","black","white"],"features":["compose","policy","deck"],"strips":"atlas-1",
"store_kb":16384,"blob_formats":["png","packed2"],"wifi_setup":true,"glass_report":"full","wake":"always_on"},
"have":{"config":{"hub":"hub_7Q","epoch":3,"n":12},"bundles":["b_3KQ…"]},
"glass_sha":"…",
"needs":[{"feed":"weather/left","surface":"452x444.k4","by":"2026-10-06T10:00:00-07:00"}],
"via":[],"hops":0}
The response is {"proto":1,"hub":{"id":"hub_7Q","boot":"…","epoch":3},"web_url":"http://192.168.1.50:8080/","wants_baseline":{"refresh":41,…},"state":{…}}.
- New hub identity. On a new
(hub, epoch)slowd adoptswants_baselinewithout acting on it, so a stale command never replays. - Frame resend.
glass_shais the hash of the frame on the panel. If it differs from the hub’s copy, slowd sends its frame again. - Errors: 401 bad token; 409
loop(the hub’s own id is invia, orhops > 3); 426 unsupported protocol version.
State document
Hub to slowd:
{"sb":1,"seq":1843,"server_time":"…","clock_synced":true,"hub":{"id":"hub_7Q","epoch":3,"hops":0},
"config":{"ver":{"hub":"hub_7Q","epoch":3,"n":12},
"prefs":{"auto":true,"every_min":30,"active_hours":"06:00-24:00","quiet_hours":null,"tz":"America/Vancouver",
"detail_keep_s":600,"stale_grace_min":15,"scheduled_waveform":"normal",
"daily_ceiling":{"global":120,"per_app":48}},
"layout":{"strip_h":36,"spare_key":"white","prompt_slot":"right",
"slots":[{"id":"left","x0":0,"x1":451,"key":"red","sources":["weather/left"]},
{"id":"middle","x0":452,"x1":907,"key":"yellow","sources":["weather/middle"]},
{"id":"right","x0":908,"x1":1359,"key":"black","sources":["weather/right"]}],
"prompt_sources":["reminders/prompts"]}},
"feeds":[{"feed":"weather/left","surface":"452x444.k4",
"current":{"bundle":"b_3KQ…","made_at":"…","valid_from":"…","valid_until":"2026-10-06T10:45:00-07:00"},
"next":null,
"next_ready_by":"2026-10-06T10:29:05-07:00","poll_hint_s":300,"origin":"local","hops":0}],
"wants":{"refresh":{"gen":42,"slots":["left","middle","right"],"waveform":"normal"},
"heal":{"gen":3},"clear":{"gen":1},"wifi_setup":{"gen":2},"wifi_dismiss":{"gen":5},
"identify":{"gen":0},"rehello":{"gen":0}},
"keys":[{"seq":88,"key":"red","kind":"tap","at":"…"}],
"next_poll_at":"…","longpoll_max_s":50}
wants. slowd stores the last applied generation per kind in/var/lib/slowd/applied.json(fsynced), acts once whengenis greater, then acks withapplied=on its next poll and in its status. The hub reports the command as pending until the acknowledgement arrives.keysare page keys: key presses sent from the hub. They are true events. A key is applied only if itsseqis greater than the last one seen andserver_time − at ≤ 30 s, so a stale press from the hub never fires.nextcarries a bundle with a futurevalid_from, so it doesn’t replace the current one early.
Config acks
slowd clamps the config with sb_core::policy::clamp, the same function the hub runs before storing it:
every_minmust divide 1440 and be at least 15;keep_sis clamped to 60–3600;- ceilings can only go down.
It persists the result, acks with config=<ver>, and keeps running on the last acked config while offline. It accepts any config whose (hub, epoch) differs from the stored one, or whose n is greater.
Bundles (SBF/1)
A bundle is an immutable snapshot of one feed for one surface.
{"sbf":1,"id":"b_3KQ…","app":"weather","feed":"left","surface":"452x444.k4","made_at":"…",
"for":"2026-10-06T10:00:00-07:00","valid_from":"…","valid_until":"…","tz":"America/Vancouver",
"origin":{"hub":"hub_7Q","run":"r_1234","image":"sha256:…"},"hops":0,
"cards":[{"id":"home","title":"GFA now","peek":"Rain from 14h","kind":"home","body":"<64 hex>"},
{"id":"gfa6","title":"GFA +6 h · 22Z","peek":"…","kind":"detail","keep_s":600,"body":"<64 hex>"}]}
body is a blob id. title and peek are text for the slot’s peek strip, which slowd draws itself. keep_s is how long a detail card stays before the slot goes home.
Prompt bundles
Only feeds listed in the layout’s prompt_sources, from apps with the prompts grant, may carry prompts.
{"sbf":1,"id":"b_9XZ…","app":"reminders","feed":"prompts","surface":"452x444.k4","valid_from":"…","valid_until":"…",
"prompts":[{"id":"rem-42@2026-10-06T09:00@2026-10-06T09:00","group":"rem-42","slot_hint":"right",
"from":"2026-10-06T09:00:00-07:00","until":"2026-10-06T17:30:00-07:00","question":"Water the plants?",
"bindings":{
"own.tap": {"show":"<blob>","strip":"Done · next Tue 13 Oct","emit":{"kind":"answer","data":{"answer":"yes"}}},
"spare.tap": {"show":"<blob>","strip":"Asking again at 18:00","emit":{"kind":"answer","data":{"answer":"not_yet"}}},
"own.hold": {"show":"<blob>","strip":"Snoozed for an hour","emit":{"kind":"answer","data":{"answer":"snooze"}}},
"spare.hold":{"show":"<blob>","strip":"Skipped · next Tue 13 Oct","emit":{"kind":"answer","data":{"answer":"skip"}}}}}]}
- Instance id. Includes the time the question is asked (e.g.
rid@occurrence@ask_from), so a re-ask of the same occurrence after “not yet” is a new instance and isn’t suppressed by the earlier answer. - Slot hint.
slot_hintnames a slot; an unknown hint resolves to the layout’sprompt_slot. - Bindings. Up to four.
showis the rest card the slot shows after the answer,stripits strip text, andemitan opaque payload slowd echoes back in an event without reading it. Binding names are roles (own,spare) with a tap or a hold, never physical keys; slowd decides which key plays each role and shows it in the strip. - App rule. Binding bodies must stay correct for the whole
[from, until]window. If wording would change partway, the app endsuntilat that point and emits a new instance afterwards.
Identity and limits
Both the hub and slowd enforce these.
- Blob id =
sha256("SBP2" ‖ u16le w ‖ u16le h ‖ packed rows), orSBP8for 8 bpp grey. The hub computes it at ingest; slowd recomputes it after decoding. - Bundle id is opaque.
- Size limits:
bundle.json≤ 256 KB; ≤ 32 cards; ≤ 8 prompts; each blob ≤ 256 KB encoded; bodies are exactly the surface size. - PNG rules: the palette (PLTE) is a subset of the ink set; no
tRNS; bit depth 2 or 8. - Routing rules: a feed fills only the slots that list it; only
prompt_sourcesmay carry prompts; a card with an unknownkindis skipped; an unknownsbfmajor version rejects the bundle.
Events
slowd to hub:
{"device":"local","boot_id":"c1f0…","events":[
{"id":"01J9Z…(uuidv7)","seq":311,"at":"…","at_synced":true,"kind":"emit","app":"reminders",
"data":{"prompt":"rem-42@…@…","from":"…","until":"…","binding":"own.tap","via":"black key tap",
"emit":{"kind":"answer","data":{"answer":"yes"}}}},
{"id":"…","seq":312,"kind":"press","data":{"key":"red","kind":"tap","slot":"left","result":"→ GFA +6 h"}},
{"id":"…","seq":313,"kind":"status","data":{"applied":{"refresh":42},"config":{"n":12},"effective":{…},
"next_refresh_at":"…","areas":[…],"deck":{…},"job":null,"counts":{"today":14,"lifetime":5321},
"net":{"state":"ok","ip":"192.168.1.50"},"warnings":[…]}}]}
- Durability. Only
emitevents go through slowd’s durable outbox: append, fsync, compact on ack, capped at 512 events or 7 days. - Status and presses are coalesced: at most one every 2 s, and only on change. The hub keeps them in a RAM ring.
- Late answers. The app applies an answer at
atwhenat_syncedis true, otherwise at the receive time. The first answer per instance id wins, whether it came from a key or the hub.
Timing fields
Every feed carries four timing fields.
| Field | Set by | Meaning for slowd |
|---|---|---|
valid_from | app | Don’t show before this time. Held in next until then. |
valid_until | app | Past valid_until + stale_grace_min, switch to the stale strip. Old content still beats a blank screen. |
next_ready_by | hub planner | A newer bundle is promised by this time if the job succeeds. slowd on another machine polls at next_ready_by plus 0–5 s of jitter; a local slowd’s long-poll covers it. |
poll_hint_s | hub | Fallback when next_ready_by is null or missed: 300 s locally, 900 s remotely. On battery-powered screens it is the RTC wake interval. |
Schedule. sb_core::policy::next_refresh(prefs, after) is the wall-clock grid where minute_of_day % every_min == 0, inside active hours, outside quiet hours, in tz. slowd uses it, adjusted for its cooldowns, to fill needs[].by. The hub plans from needs[] and falls back to next_refresh of the effective prefs only for a screen it hasn’t heard from yet.
Update now and page keys
- Someone asks the hub to update now (
slowhub update, orPOST /admin/v1/run {"action":"update"}). - The planner marks every placed feed due now.
- As each job is ingested,
seqis bumped. When the last job is done,wants.refresh.genincrements. - The open long-poll returns within milliseconds. slowd fetches the blobs and refreshes as soon as the cooldown allows.
Key presses sent from the hub (slowhub key) arrive through keys[] with about 50 ms of added latency.
Clock
Both sides treat the clock as untrusted until the kernel reports it synced (no STA_UNSYNC). server_time is used only for ordering, never to validate TLS; a TLS time error while unsynced counts as “waiting”. Both daemons re-plan when the wall clock is stepped.