Skip to content

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:

{ "type": "subscribe", "topics": ["display_state", "operation", "page_message"] }

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": "subscribe", "topics": ["display_state"] }
{ "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": "subscribe", "topics": ["page_message"] }
{ "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": "subscribe", "topics": ["operation"] }
{ "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": "subscribe", "topics": ["config_changed"] }
{ "type": "event", "topic": "config_changed", "ts": "…",
  "payload": { "paths": ["display.rotation", "audio.volume"], "config": { "...": "full resulting config" } } }

NFC tag lifecycle

{ "type": "subscribe", "topics": ["nfc_tag", "nfc_tag_removed"] }
{ "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:

{ "operation_id": "op-7", "kind": "update_install", "state": "running", "progress": 0.42 }

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.