Skip to content

WebSocket protocol ​

muxen-udsd exposes the same commands as the CLI over a WebSocket, as JSON messages. This chapter is the wire format, for anyone implementing a client that is not @muxen/uds — a test harness, another language, or a debugging session with websocat.

If you are writing a Vue interface, use the client instead: Web clients — @muxen/uds.

Connecting ​

The daemon serves the WebSocket sub-protocol muxen-ws-uds on TCP port 12345. Two setups:

  • Direct: ws://<host>:12345 — reachable from the Brain itself, since the unit confines the daemon to loopback.
  • Behind a reverse proxy: ws://<host>/ws/uds, which is what the shipped nginx snippet configures and what the TypeScript client defaults to.

The daemon speaks plain WebSocket; TLS is the proxy's job.

Each connection is a session. Requests are queued and served in order by two worker pools — one for active commands, which drive the CAN bus, and one for passive commands (scan, listfirmware) — so a long scan does not block active work. Within a pool, requests run one at a time.

Closing the connection flushes that session's still-queued requests rather than executing them, so a refreshed page is not stuck behind the previous session's dead queries.

Frames ​

Every frame the daemon sends is a JSON object with a type:

typeSentSection
helloonce, first frame of every connectionHello
responsefinal answer to a requestResponse envelope
notifyprogress of a running requestProgress notifications
errora request was rejected or abortedErrors

A client must ignore a frame whose type it does not know, and a feature name it does not know: both are how the protocol grows without another breaking release. Since 10.0.0 no frame lacks a type; a client must not infer one from the fields present.

Hello ​

The first frame on every connection, sent to that client only and before anything else — including the answer to a request sent the instant the socket opened:

json
{
  "type": "hello",
  "name": "muxen-udsd",
  "version": "10.0.0",
  "hostname": "brain-3",
  "features": ["reset", "readconfig", "…", "hardware-id", "abort", "deploy-legends"]
}
FieldDescription
namealways muxen-udsd
versionthe daemon's version, for display only
hostnamethe Brain's host name
featureswhat this daemon can do; test these, never version

features holds the name of every command the daemon dispatches, as a request's command takes it (reset, readconfig, writeconfig, firmware, factory, scan, listfirmware, locate, uid, deploy, checkconfig, routine, legend, child), built from the same table that dispatches them. It then lists the field-level features a command name alone does not tell:

FeatureMeaning
hardware-idevery listfirmware entry carries hardwareId and softwareId, null when the catalog does not say (see listfirmware)
abort{ uuid, abort: true } cancels a job (see Aborting a request)
deploy-legendsdeploy writes the e-paper legends, takes no-epaper, epaper-only and lang, and may report legendsFailed (see deploy)

A daemon older than 10.0.0 sends no hello: its first frame is the answer to the client's first request. @muxen/uds 10 treats that as an incompatible daemon rather than guessing what it supports.

The same identity is published on MQTT, see Daemon info on MQTT.

Request envelope ​

json
{
  "uuid": "abc",
  "command": "reset",
  "deviceId": 640,
  "speedy": false,
  "parameters": { }
}
FieldRequiredDescription
uuidyesclient-chosen correlation id, echoed back in the response
commandyescommand name; the set is in Reference
deviceIdper commandtarget, a JSON integer 0–0xFFF. Required for device commands, optional for deploy/checkconfig, ignored for bus-wide ones
speedynotrue = no retry, short timeouts. Default false
parametersper commandcommand-specific object or array

Every id is a JSON integer in its range: deviceId and the other ids below (instance, function code, port, parameter and routine identifiers, legend zone). Anything else — a string such as "57" or "57abc", a float, a boolean, null, a negative or a value past the range — is answered with an error frame and nothing reaches the bus. Older daemons read "57abc" as 57 and cut a parameter identifier to 16 bits, so "only-id": 65593 read parameter 57.

A request with no uuid, or with a uuid that is not a string, is rejected — the client could not correlate the answer anyway. The daemon logs it in that case.

A message larger than 1 MiB is dropped without an answer. Requests are small JSON documents: firmware is referenced by path, never streamed as a payload.

uuid values longer than 63 characters are truncated in the daemon's abort bookkeeping; a UUIDv4 string is well within that.

Response envelope ​

json
{
  "type": "response",
  "uuid": "abc",
  "request": { "...": "the original request echoed back" },
  "response": {
    "rc": 0,
    "data": [ "... command-specific ..." ]
  }
}
FieldDescription
type"response" for a final answer
uuidecho of the request uuid
requestthe parsed request, echoed back
response.rccommand return code, 0 = success
response.datapresent only for commands that return data

Note that rc is the command's result. A well-formed request against an offline device answers with a non-zero rc, not with an error.

Errors ​

Validation failures return an error frame instead of a response:

json
{ "type": "error", "uuid": "abc", "error": "deviceId out of range" }

An error frame with a uuid settles that request, exactly as a response would. One without a uuid answers a message the daemon could not read far enough to find it (Invalid JSON document, UUID parameter is missing, UUID parameter is not a string): no request can be matched to it, so a client surfaces it (log, event) rather than dropping it.

MessageCause
Invalid JSON documentthe message did not parse
UUID parameter is missingno uuid field
UUID parameter is not a stringuuid was JSON null or a non-string
command parameter is missingno command field
unknown commandnot one of the daemon's commands
failed to initialize the commandallocation failure
deviceId parameter is missingthe command requires a target
deviceId out of rangenot a JSON integer in 0–0xFFF
failed to parse parameter for the commandparameters did not validate, an id in it included
abortedthe client cancelled the job while it was queued

Progress notifications ​

Long commands stream progress before the final response:

json
{ "type": "notify", "notify": "abc", "percent": 42 }

notify carries the request's uuid — note the field name is notify, not uuid. A notification is a partial update; the matching response still arrives at the end.

  • firmware streams { "percent": N }, at most one every 500 ms.
  • deploy and checkconfig stream { "detectedDeviceCount": N } after their UID scan, then { "deviceId": N, "state": "…" } per device.
  • deploy also streams { "deviceId": N, "state": "writing legend", "zone": Z } once per e-paper zone it writes, between that device's deploying and deployed notifications (a checkOnly run streams none).

A notification is also proof the job is alive, which is why a client should treat its request timeout as an idle timeout and re-arm it on every update.

Aborting a request ​

json
{ "uuid": "abc", "abort": true }

This is handled out-of-band, not queued behind the jobs it cancels. The worker reaching the flagged job skips it and replies { "type": "error", "uuid": "abc", "error": "aborted" }. A request already executing on the bus runs to completion, except deploy, which winds up at the next device or zone boundary and answers normally; its response can be ignored by the client.

The daemon remembers the last 128 aborted jobs. On overflow the oldest is forgotten and that job simply runs as before — the failure mode is "the abort was ignored", never "the wrong job was skipped".

Per-command parameters ​

reset ​

json
{ "uuid": "abc", "command": "reset", "deviceId": 640, "parameters": { "delay": 500 } }

delay in milliseconds, 0–65535; out-of-range values fall back to 500. Default 500.

scan (passive, bus-wide) ​

json
{ "uuid": "abc", "command": "scan", "parameters": { "timeout": 5, "maxDevice": 128 } }

data: [{ instance, function, functionName, deviceId, when }].

uid (active scan, bus-wide) ​

json
{ "uuid": "abc", "command": "uid" }

data: { duplicate, devices: [{ deviceId, uid }] }.

With parameters, it also performs UID-addressed work:

KeyTypeEffect
scanbooleanperform the scan
uidstring16 hex digits, optional 0x prefix — required by the three below
instanceinteger 0–63write a new instance
locatebooleanLED animation
enableUDSboolean or integer 0–63enable UDS; an integer also sets the preferred function code

With none of them, a scan is performed. Any of instance, locate or enableUDS without a valid uid is rejected.

locate ​

json
{ "uuid": "abc", "command": "locate", "deviceId": 64 }

readconfig ​

json
{ "uuid": "abc", "command": "readconfig", "deviceId": 640 }

Optional parameters: { "only-name": "VbatMin" }, an array of names, or { "only-id": 57 }, an identifier 0–65534 (65535 is no identifier: it stands for "every parameter" in the daemon).

data: [{ id, name, type, value }].

writeconfig ​

Array form (no reboot):

json
{
  "uuid": "abc", "command": "writeconfig", "deviceId": 65,
  "parameters": [ { "id": 57, "value": 700 } ]
}

Object form (with options):

json
{
  "uuid": "abc", "command": "writeconfig", "deviceId": 64,
  "parameters": {
    "reboot": false,
    "checkOnly": false,
    "parameters": [ { "name": "VbatMin", "value": 2800 } ]
  }
}

Each entry selects a parameter by id (0–65534) or name; an id out of range rejects the whole request, and nothing is written. An entry may also carry a type. checkOnly: true is a dry run — sync status is reported, nothing is written. reboot only takes effect on a real write. The object form without a parameters array is rejected.

factory ​

json
{
  "uuid": "abc", "command": "factory", "deviceId": 640,
  "parameters": {
    "reset-device-id": false,
    "reset-device-configuration": true,
    "allocate-device-configuration": 67,
    "port": 3
  }
}

allocate-device-configuration is a device id (0–0xFFF); port is 0–255. A request with none of the three actions set is rejected.

firmware ​

json
{
  "uuid": "abc", "command": "firmware", "deviceId": 67,
  "parameters": { "srec": "010010004/010010004-v6.2-NMEA2000.srec" }
}

srec is a path relative to the firmware store (/usr/lib/muxen/firmware), rejected if it starts with / or contains ... Alternatively select by file name with { "name": "…" }, which searches the store. Streams percent notifications.

listfirmware (passive, bus-wide) ​

json
{ "uuid": "abc", "command": "listfirmware" }

data: [{ name, code, filename, firmware, hardwareId, softwareId }].

code is the ProductId, hardwareId the board the image targets and softwareId the application it implements — the same values a device reports through readconfig, so a client can match a firmware against a device.

Both are null when the firmware store's products.json carries no annotation for that file; a client must then let the user pick the image rather than selecting one automatically.

deploy (bus-wide, optional device) ​

json
{
  "uuid": "abc", "command": "deploy", "deviceId": 64,
  "parameters": {
    "project": "raken-42", "checkOnly": false, "force": false,
    "no-epaper": false, "epaper-only": false, "lang": "FR"
  }
}

Or { "filepath": "deploy.json" }. Both are path-checked the same way as srec.

The device is selected by the top-level deviceId. A device-id key inside parameters is ignored by the daemon, whatever some clients send.

Streams per-device state, including a { "deviceId": N, "state": "writing legend", "zone": Z } notification per e-paper zone written; data: [{ deviceId, deployed, error?, legendsFailed? }]. legendsFailed is the array of zone ids whose transfer failed, present only when at least one selected zone did; it never appears alongside error, since each error case below skips the legend step for that device.

checkOnly is a dry run: it suppresses the factory reset, the flash wait, the write and the deployed.json update, leaving the bus as it was found. Its states are checking / checked rather than resetting to factory / deploying / deployed, and a device that answered but does not match the project comes back as { deviceId, deployed: false, error: "out of sync" }. See Deploying a boat configuration. Use checkconfig when you want the per-parameter differences instead of a yes/no.

no-epaper skips the legend step entirely; epaper-only inverts it — no factory reset, no parameter write, no deployed.json update, only the legends — and deployed then reflects whether every selected zone was written rather than the parameter write. epaper-only is rejected together with either no-epaper or checkOnly. lang selects a translated legend variant per zone (up to 7 characters), falling back to each zone's primary entry where no such variant exists. See Deploying a boat configuration.

checkconfig (bus-wide, optional device) ​

json
{ "uuid": "abc", "command": "checkconfig", "parameters": { "project": "raken-42", "force": false } }

data: [{ deviceId, online, mismatches[], missingOnDevice[], missingInProject[] }], each mismatch being { name, expected, device } with both sides in the { id, name, type, value } shape.

routine ​

json
{
  "uuid": "abc", "command": "routine", "deviceId": 64,
  "parameters": { "action": 1, "routine": 2, "payload": "0a1b2c" }
}

action 0–255, routine 0–65535, optional payload as an even-length hexadecimal string. routine returns no data.

backup ​

Not available over the WebSocket. It is a CLI-only command, because it writes files into the working directory.

Flow control and back-pressure ​

The daemon bounds every queue rather than growing them:

QueueDepthOn overflow
Request queue, per worker64the request is dropped, unanswered
Response outbox256the response is dropped
Per-client send queue256the response is dropped

Dropped messages are logged. A client that never receives an answer for a request it sent should assume the command may still have run — the drop happens on the way in or on the way out, not in the middle.

Daemon info on MQTT ​

The daemon publishes its identity, retained with QoS 1, on app/uds/info of the local broker (--mqtt-host / --mqtt-port, default 127.0.0.1:1883):

json
{
  "name": "muxen-udsd", "version": "10.0.0", "hostname": "brain-3",
  "features": ["reset", "…", "deploy-legends"],
  "online": true,
  "metadata": { "rxdate": "2026-09-22T07:56:37.085Z", "rxTimestamp": 1790063797, "expireAfterSec": 3124137600 }
}

features is the list the hello carries. online turns false on a clean shutdown and, through the MQTT last will, when the daemon dies. This client only publishes: it subscribes to nothing. A broker that is down at start-up is retried every 5 s and never holds up the WebSocket server.

Debugging by hand ​

sh
websocat --protocol muxen-ws-uds ws://127.0.0.1:12345/
{"uuid":"1","command":"scan","parameters":{"timeout":3}}

The hello is the first line printed, before you type anything.

sh
journalctl -u muxen-uds -f

The daemon logs every rejected request with its uuid and reason, and at -vv every command with its device and return code.

Integration of multiplexed solutions
MUXEN and the MUXEN logo are trademarks of MUXEN SAS.