Appearance
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:
type | Sent | Section |
|---|---|---|
hello | once, first frame of every connection | Hello |
response | final answer to a request | Response envelope |
notify | progress of a running request | Progress notifications |
error | a request was rejected or aborted | Errors |
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"]
}| Field | Description |
|---|---|
name | always muxen-udsd |
version | the daemon's version, for display only |
hostname | the Brain's host name |
features | what 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:
| Feature | Meaning |
|---|---|
hardware-id | every 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-legends | deploy 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": { }
}| Field | Required | Description |
|---|---|---|
uuid | yes | client-chosen correlation id, echoed back in the response |
command | yes | command name; the set is in Reference |
deviceId | per command | target, a JSON integer 0–0xFFF. Required for device commands, optional for deploy/checkconfig, ignored for bus-wide ones |
speedy | no | true = no retry, short timeouts. Default false |
parameters | per command | command-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 ..." ]
}
}| Field | Description |
|---|---|
type | "response" for a final answer |
uuid | echo of the request uuid |
request | the parsed request, echoed back |
response.rc | command return code, 0 = success |
response.data | present 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.
| Message | Cause |
|---|---|
Invalid JSON document | the message did not parse |
UUID parameter is missing | no uuid field |
UUID parameter is not a string | uuid was JSON null or a non-string |
command parameter is missing | no command field |
unknown command | not one of the daemon's commands |
failed to initialize the command | allocation failure |
deviceId parameter is missing | the command requires a target |
deviceId out of range | not a JSON integer in 0–0xFFF |
failed to parse parameter for the command | parameters did not validate, an id in it included |
aborted | the 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.
firmwarestreams{ "percent": N }, at most one every 500 ms.deployandcheckconfigstream{ "detectedDeviceCount": N }after their UID scan, then{ "deviceId": N, "state": "…" }per device.deployalso streams{ "deviceId": N, "state": "writing legend", "zone": Z }once per e-paper zone it writes, between that device'sdeployinganddeployednotifications (acheckOnlyrun 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:
| Key | Type | Effect |
|---|---|---|
scan | boolean | perform the scan |
uid | string | 16 hex digits, optional 0x prefix — required by the three below |
instance | integer 0–63 | write a new instance |
locate | boolean | LED animation |
enableUDS | boolean or integer 0–63 | enable 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:
| Queue | Depth | On overflow |
|---|---|---|
| Request queue, per worker | 64 | the request is dropped, unanswered |
| Response outbox | 256 | the response is dropped |
| Per-client send queue | 256 | the 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 -fThe daemon logs every rejected request with its uuid and reason, and at -vv every command with its device and return code.
