Docs menu

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 pathPurpose
POST /sb/v1/helloCaps, 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/eventsBatched events, idempotent by event id. Returns the acked ids.
PUT /sb/v1/glassThe 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 adopts wants_baseline without acting on it, so a stale command never replays.
  • Frame resend. glass_sha is 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 in via, or hops > 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 when gen is greater, then acks with applied= on its next poll and in its status. The hub reports the command as pending until the acknowledgement arrives.
  • keys are page keys: key presses sent from the hub. They are true events. A key is applied only if its seq is greater than the last one seen and server_time − at ≤ 30 s, so a stale press from the hub never fires.
  • next carries a bundle with a future valid_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_min must divide 1440 and be at least 15;
  • keep_s is 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_hint names a slot; an unknown hint resolves to the layout’s prompt_slot.
  • Bindings. Up to four. show is the rest card the slot shows after the answer, strip its strip text, and emit an 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 ends until at 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), or SBP8 for 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_sources may carry prompts; a card with an unknown kind is skipped; an unknown sbf major 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 emit events 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 at when at_synced is 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.

FieldSet byMeaning for slowd
valid_fromappDon’t show before this time. Held in next until then.
valid_untilappPast valid_until + stale_grace_min, switch to the stale strip. Old content still beats a blank screen.
next_ready_byhub plannerA 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_shubFallback 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

  1. Someone asks the hub to update now (slowhub update, or POST /admin/v1/run {"action":"update"}).
  2. The planner marks every placed feed due now.
  3. As each job is ingested, seq is bumped. When the last job is done, wants.refresh.gen increments.
  4. 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.

Updates, now and then

A short note when there is a new board, a new slowapp, or kit news. Rarely more than one a month.