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:
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"}}
Rules:
methodis one ofGET,POST,PUT,PATCH,DELETE.pathis the full/api/v1/...path, exactly as in the REST examples — including query parameters (e.g.?screen=,?since=&limit=).response.bodymirrors the REST response body verbatim: an object for a single resource, an array for a list endpoint (e.g.GET /display/screens).response.statusis the same HTTP-equivalent status code as the REST call — check it, not just the presence of a body, since success/failure both produce aresponseframe.
Example: navigate the display¶
{"id": 1, "type": "request", "method": "POST", "path": "/api/v1/display/navigate", "body": {"url": "https://pos.example.com/recap"}}
Example: list bundles¶
{"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": "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:
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):
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:
Empty lines and lines larger than 1 MiB are silently ignored.
The shell escape¶
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.