1 Live state
The device pushes changes to connected clients over a Server-Sent Events stream at
GET /api/subscribe. Anything beyond fire-and-forget submission wants it: polling
/api/status in a loop works, but you will either poll too often or find out too late
that the machine has been waiting for someone for twenty minutes.
1.1 Prime first then subscribe
The stream carries changes, not state. A machine that is sitting idle sends nothing at all — no snapshot on connect, no heartbeat. So a client that only subscribes knows nothing until something happens.
Start with GET /api/init, which returns everything in one request:
curl --insecure -b cookies.txt https://kilobaser.lab.example.org/api/init
| Key | What it holds |
|---|---|
status |
the machine: mode, current run, pending questions, consumables |
processRunQueue |
the queue, in the order the machine will run it |
processRuns |
recent runs |
errors |
errors currently active |
ccSettings |
cartridge and chip catalogue, including the compatibility matrix |
settingsUser |
oligo yield and system information |
session |
who you are logged in as |
sensorState |
raw sensor presence flags |
Then open the stream and apply what arrives on top.
Strictly, open the stream first and fetch /api/init immediately after, so a change
landing between the two is not lost. An idle machine makes the naive order work almost
always, which is exactly what makes the resulting bug so annoying to find.
1.2 The events
| Event | Payload | How to apply it |
|---|---|---|
status |
the whole machine status | replace |
processRunQueue |
the entire queue, in order | replace |
processRuns |
one run | merge by id |
errors |
all currently active errors | replace |
runs |
one completed run record | merge by id |
settingsUser |
settings subtree | replace that subtree |
clientControl |
interface commands, e.g. reload | ignore in a headless client |
The two collection events do not mean the same thing, and this is the mistake worth
avoiding: processRunQueue is a complete replacement while processRuns is a
single object. Merging the queue instead of replacing it produces a client whose
queue slowly fills with entries the machine has already discarded, because the machine
does discard them — see queue oligos.
An empty collection may arrive as null rather than []. Coerce it.
The wire format is ordinary SSE: an event: line, one or more data: lines carrying
JSON, and a blank line to finish. The device never sets id: or retry:.
1.3 Applying events
The whole of it, from kb_client.py:
if name == "status":
state["status"] = data
elif name == "processRunQueue":
state["queue"] = data or [] # whole queue, replace
elif name == "processRuns":
state["runs"][data["id"]] = data # single run, merge by id
elif name == "errors":
state["errors"] = data or []
That mirror is what the rest of an integration reads. Do not re-fetch /api/status when
an event arrives; the event already carries the new state, and a device with several
clients connected does not need the extra traffic.
1.4 Silence is not disconnection
There is no heartbeat, so an open stream and a dead stream look identical from the client's side. Two consequences.
A proxy will eventually close it. Anything between you and the Kilobaser that times out idle connections — a load balancer, an SSH tunnel, a corporate firewall — will drop a stream that has been quiet for an hour, and neither end will say so. Plan to reconnect.
Reconnecting loses whatever happened while you were away. The device sends no event
ids, so there is nothing to resume from and no replay. After every reconnect, fetch
/api/init again and rebuild from the snapshot. kb_client.subscribe() does this and
emits a synthetic init event each time it connects, so downstream code cannot forget.
Reconnect on a backoff rather than immediately: a device that is rebooting will refuse connections for a while, and a tight loop against it is unhelpful to everyone.
1.5 Sessions end when the device restarts
Sessions live in the device's memory. A reboot or a software update ends all of them,
and your stream stops with a 2-1-1 or 2-1-2 error rather than anything resembling a
network failure. Log in again and reconnect. kb_client.py does this automatically.
A restarting device does not come back all at once, so a client that reconnects promptly meets a 503 before it gets as far as logging in:
{"error": "{\"code\": \"1-1-37\", \"message\": \"The machine is starting up. Please wait.\"}", "state": "starting"}
It carries Retry-After and a state of starting or migrating. Honor the header
rather than hammering: a device that is migrating its database can take a while, and this
is the one status where the device tells you exactly how long to wait.
While you are here: a restart during a run also leaves an active error,
1-2-24 Unexpected power outtage. It is not fatal, but it blocks other operations until
it is acknowledged — see cancel and errors.
1.6 A browser cannot open this stream
The REST endpoints send permissive cross-origin headers, so a web page on another domain
can log in and queue oligos. /api/subscribe sends no cross-origin headers at all, so
an EventSource pointed at it from another origin will fail.
There is no header or parameter that changes this. If you want live updates in a web application, hold the stream in your backend and relay to your frontend however you normally would. That is the right shape for other reasons too — see overview.
1.7 Waiting for something specific
Most of what an integration wants is "block until X", and with a live mirror that is a predicate:
kb.wait_for(lambda s: not s["queue"]) # queue drained
kb.wait_for(lambda s: s["status"]["currentProcessRunId"] == "") # nothing running
kb.wait_for(lambda s: checkpoint(s)) # machine wants an answer
checkpoint() is worth a closer look, because the obvious version of it is wrong:
status = state["status"]
pending = status.get("pendingAnswers") or []
mode = status["currentState"]["mode"]
if mode == "check" and pending:
return pending
Both conditions have to hold. Pending answers can linger for a moment while the machine
is still working, so testing the list on its own will have you answering a question the
machine has not finished asking. Waiting for mode == "check" as well is the difference
between a driver that works and one that works most of the time.
1.8 Watching it happen
python3 kb_watch.py --host kilobaser.lab.example.org --user apibot --insecure --queue
Read-only, so it is safe against a machine in the middle of a real run. Leave it in one terminal while you make calls in another — it is the fastest way to understand what the device actually broadcasts, and when.
00:14:11 init mode=ready cartridge=Standard Cartridge (150 bases left) queue=0
00:17:24 processRunQueue mode=ready queue=6
1. LAMP-042_RPP30-LB insertChip -> synthesis
2. LAMP-042_RPP30-LF insertChip -> synthesis