Skip to content

Serial API (NDJSON)

The device also exposes a CDC-ACM serial port on the same USB cable, carrying the exact same API as REST & WebSocket framed as NDJSON (newline-delimited JSON — one JSON object per \n-terminated line, no pretty-printing, max 1 MiB per line). It works directly from a browser via WebSerial — no network setup, no certificates.

sequenceDiagram
    participant H as Host
    participant D as Device (displayd)
    D-->>H: {"type":"hello","serial":"MCD-0001A2B3","api":"v1","name":"front-desk-1"}
    H->>D: {"id":1,"type":"request","method":"POST","path":"/api/v1/display/navigate","body":{"url":"https://example.com"}}
    D-->>H: {"id":1,"type":"response","status":200,"body":{}}
    H->>D: {"type":"subscribe","topics":["display_state"]}
    D-->>H: {"type":"event","topic":"display_state","ts":"…","payload":{}}

Connecting

Open the port at any baud rate your serial library defaults to (the gadget is USB CDC-ACM, not a real UART — the baud rate setting is ignored by the link). On connect, the device immediately sends a hello line:

{"type":"hello","serial":"MCD-0001A2B3","api":"v1","name":"front-desk-1"}

This is what discovery uses to identify a device without needing the USB descriptor serial number (see the client library).

Request / response

Every REST call maps to a request frame; the device replies with a response frame carrying the same id. The host chooses id (monotonic is recommended) — it's how responses are correlated on a single serial stream where requests may be pipelined.

{"id": 1, "type": "request", "method": "POST", "path": "/api/v1/display/navigate", "body": {"url": "https://example.com"}}
{"id": 1, "type": "response", "status": 200, "body": {}}

Rules:

  • method is one of GET, POST, PUT, PATCH, DELETE.
  • path is the full /api/v1/... path, exactly as in the REST examples — including query parameters (e.g. ?screen=, ?since=&limit=).
  • response.body mirrors the REST response body verbatim: an object for a single resource, an array for a list endpoint (e.g. GET /display/screens).
  • response.status is the same HTTP-equivalent status code as the REST call — check it, not just the presence of a body, since success/failure both produce a response frame.

Example: navigate the display

{"id": 1, "type": "request", "method": "POST", "path": "/api/v1/display/navigate", "body": {"url": "https://pos.example.com/recap"}}
{"id": 1, "type": "response", "status": 200, "body": {}}

Example: list bundles

{"id": 2, "type": "request", "method": "GET", "path": "/api/v1/content"}
{"id": 2, "type": "response", "status": 200, "body": {"bundles": [{"name": "promo", "size_bytes": 184320, "sha256": "…", "created": "2026-06-01T10:00:00Z", "active": true}], "disk": {"free_bytes": 1073741824, "total_bytes": 2147483648}}}

Example: error

{"id": 3, "type": "request", "method": "DELETE", "path": "/api/v1/content/promo"}
{"id": 3, "type": "response", "status": 409, "body": {"error": {"code": "bundle_in_use", "message": "bundle 'promo' is currently displayed"}}}

Binary payloads

Non-JSON bodies (a PNG screenshot, a splash image, a content bundle, an OTA update bundle) are base64-wrapped.

Downloads (e.g. GET /display/screenshot) come back as body_b64 instead of body:

{"id": 4, "type": "response", "status": 200, "body_b64": "iVBORw0KGgoAAAANSUhEUgA…"}

Uploads are opened with a request frame carrying an explicit empty body_b64, then streamed as request_chunk frames (max 16 KiB of decoded data per chunk), terminated by "last": true:

{"id": 5, "type": "request", "method": "PUT", "path": "/api/v1/content/promo", "body_b64": ""}
{"id": 5, "type": "request_chunk", "seq": 0, "last": false, "data_b64": "UEsDBBQAAAAIA…"}
{"id": 5, "type": "request_chunk", "seq": 1, "last": false, "data_b64": "AwEAABQAAAAA…"}
{"id": 5, "type": "request_chunk", "seq": 2, "last": true,  "data_b64": "AAAAAAAAAA=="}
{"id": 5, "type": "response", "status": 202, "body": {"operation_id": "op-9", "kind": "content_upload", "state": "running", "progress": 0}}

A request frame with a non-empty inline body/body_b64 (or none at all) is dispatched immediately — the chunked form is only used to stream something too large for one line.

This works, but is discouraged for large payloads (content bundles, OTA update bundles, big splash images) — prefer the network transport for those. The Go client library picks the network transport automatically and warns in its logs if you push more than 1 MB over serial.

Event subscription

Identical subscribe message to the WebSocket, sent as its own line (no id — it's not correlated to a response):

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

Events then arrive as event frames, same shape as the WebSocket envelope, minus id:

{"type": "event", "topic": "display_state", "ts": "2026-06-12T10:21:32Z", "payload": {"mode": "url", "url": "https://pos.example.com/recap"}}

See Events for the full topic table.

Malformed lines

A line that fails to parse gets an error frame back — the stream stays usable, no need to reconnect:

{"type": "error", "error": {"code": "bad_frame", "message": "invalid JSON on line"}}

Empty lines and lines larger than 1 MiB are silently ignored.

The shell escape

{"type": "shell"}

sent on the same serial port drops the link into a debug shell instead of the NDJSON protocol — a development/support tool, not part of the API contract. Reserved; don't send it from application code.

In-browser via WebSerial

A page running in a desktop browser can talk NDJSON directly, with no certificates and no network setup — this is the recommended way to control a device from browser-based host software:

const port = await navigator.serial.requestPort({
  filters: [{ usbVendorId: 0x0483 /* see spec 03 for the exact VID:PID */ }],
});
await port.open({ baudRate: 115200 });

const reader = port.readable.getReader();
const writer = port.writable.getWriter();
const encoder = new TextEncoder();

async function send(obj) {
  await writer.write(encoder.encode(JSON.stringify(obj) + "\n"));
}

await send({ id: 1, type: "request", method: "POST", path: "/api/v1/display/navigate", body: { url: "https://example.com" } });

A full runnable example (port selection, line buffering, request/response correlation) ships in the repository at client/examples/webserial.html — plain JS, no build step. WebUSB is not used for this: the CDC-ACM interfaces are claimed by the OS's own serial class driver, so WebSerial is the correct browser API here.

Comparing to REST

Every call in REST & WebSocket has a serial equivalent — same path, same method, same JSON body, wrapped in the request/response envelope shown above. There is nothing serial-only or REST-only in the operations themselves; only the framing and the binary-upload mechanics differ.