Events¶
The device pushes state changes as events, on both transports:
- WebSocket
/api/v1/events, envelope{"type": "event", "topic": "…", "ts": "…", "payload": {…}}. - Serial NDJSON, same envelope minus
id(see serial-ndjson.md).
Subscribe by sending, on either transport:
You can subscribe to any subset of topics, and re-subscribe at any time (a later
subscribe message doesn't clear the earlier one's topics — request the full set you
want each time if you're not sure of the current subscription state).
The full-snapshot rule¶
Several topics — display_state, network_changed, wifi_changed, bluetooth_changed,
config_changed — always carry the complete current state, never a delta. This is a
deliberate contract rule: a subscriber that just connected (or missed messages during a
reconnect) can treat the next event on that topic as ground truth without a follow-up
GET. If you only care about what changed, diff two consecutive snapshots yourself; the
device will never make you reconstruct state by accumulating partial updates.
Topic table¶
| Topic | Payload | Notes |
|---|---|---|
display_state |
same shape as GET /display |
Emitted on every mode/URL change, browser crash/restart. Full snapshot. |
page_message |
arbitrary JSON from the page | The page → host half of the page channel. |
page_channel |
{"open": bool} |
The displayed page opened or closed its channel. |
operation |
Operation resource (operation_id, kind, state, progress, error?) |
Progress of an OTA install or content upload; same resource as GET /operations/{id}. |
health |
health snapshot (same shape as GET /device/health) |
Periodic while subscribed (default every 5 s). |
input_raw |
{"device", "slot", "x", "y", "pressed", "ts"} |
Normalized touch points, post-rotation coordinates. Opt-in — subscribe explicitly, it's chatty. |
log |
log record (same shape as an entry of GET /device/logs) |
Live log tail. |
splash_changed |
{"kind": "boot" \| "shutdown"} |
A splash/branding image was uploaded or reset. The waiting page uses this to reload its logo live. |
config_changed |
{"paths": [string], "config": <full Config>} |
Emitted after every successful PATCH /config. paths are the changed dotted leaf paths (same keys as PATCH's applied map); config is the full resulting configuration — no follow-up GET /config needed. Not emitted on a rejected patch, or a boot-time restore that changes nothing. Full snapshot. |
network_changed |
same shape as GET /network |
Emitted on any hostname, interface, or address change (link up/down, address assigned/lost, device renamed). Full snapshot. |
nfc_tag |
{"reader_id", "tag_handle", "uid", "atqa", "sak", "techs", "ndef"?} |
A tag entered a reader's field (spec 10). ndef present when the tag carries an NDEF message. Not emitted while nfc.enabled is false. |
nfc_tag_removed |
{"reader_id", "tag_handle"} |
The tag left the field; tag_handle is now invalid — any pending transceive/ndef/write using it fails 409 invalid_state. |
nfc_reader_connected |
{"reader_id", "backend"} |
A reader was plugged in / initialised (USB hotplug). |
nfc_reader_disconnected |
{"reader_id"} |
A reader was unplugged; any open tag handles on it are invalidated. |
wifi_changed |
same shape as GET /wifi |
Emitted on any Wi-Fi state change (enable/disable, association gained/lost, AP up/down, scan completed). Full snapshot; not emitted while /wifi/* is 503-gated. |
bluetooth_changed |
{"adapter": <GET /bluetooth shape>, "devices": [<GET /bluetooth/devices item>]} |
Emitted on any adapter or device-set change (power, discoverable, scan start/stop, device discovered/lost, paired/removed, connected/disconnected). Full snapshot; not emitted while /bluetooth/* is 503-gated. |
Examples¶
Track the currently displayed page¶
{ "type": "event", "topic": "display_state", "ts": "2026-06-12T10:21:32Z",
"payload": { "mode": "url", "url": "https://pos.example.com/recap", "screen_on": true, "rotation": 0 } }
Receive messages sent by the displayed page¶
{ "type": "event", "topic": "page_message", "ts": "2026-06-12T10:21:40Z",
"payload": { "action": "add_to_cart", "sku": "SKU-123" } }
Watch a content upload / OTA install to completion¶
{ "type": "event", "topic": "operation", "ts": "…",
"payload": { "operation_id": "op-9", "kind": "content_upload", "state": "running", "progress": 0.6 } }
{ "type": "event", "topic": "operation", "ts": "…",
"payload": { "operation_id": "op-9", "kind": "content_upload", "state": "done", "progress": 1 } }
React to configuration changes made by another host¶
{ "type": "event", "topic": "config_changed", "ts": "…",
"payload": { "paths": ["display.rotation", "audio.volume"], "config": { "...": "full resulting config" } } }
NFC tag lifecycle¶
{ "type": "event", "topic": "nfc_tag", "ts": "…",
"payload": { "reader_id": "pn532-i2c-0", "tag_handle": "h-9f2c", "uid": "04a1b2c3", "atqa": "0044", "sak": "20", "techs": ["iso-dep"] } }
{ "type": "event", "topic": "nfc_tag_removed", "ts": "…",
"payload": { "reader_id": "pn532-i2c-0", "tag_handle": "h-9f2c" } }
Long-running operations¶
Any call that returns 202 Accepted (content upload, OTA install) hands back an
Operation resource:
You can either poll it at GET /api/v1/operations/{id} or, more efficiently, subscribe
to the operation topic and watch its state reach done or failed — both transports
push the same resource shape on every state transition. state is one of running,
done, failed; a failed operation carries an error field with the standard
error envelope.