Appearance
Web clients — @muxen/uds
Everything the command line does, a web interface can do too. When a screen on the boat lists the boxes on the bus, shows a battery threshold and lets somebody change it, that screen is talking to muxen-udsd — and almost certainly through @muxen/uds, the TypeScript client MUXEN publishes.
This chapter is for whoever builds that interface. The raw wire format is WebSocket protocol; this is the convenient layer on top of it.
What the package is
@muxen/uds is a Vue 3 plugin backed by a Pinia store. Each UDS command is an async store action returning a promise, so a component awaits a scan or a write instead of managing a socket.
sh
npm install @muxen/udsPeer dependencies, both declared optional so the package can also be used for its types alone:
vue^3.3pinia>=2.1or^3
The package ships three entry points — ., ./message-types and ./plugin. ./message-types is pure TypeScript with no runtime dependency, which is what to import when you only need the response shapes.
The npm package and the C daemon share a version and a git tag and are released together, so @muxen/uds@9.8.4 is the client for the muxen-uds 9.8.4 daemon.
Registering the plugin
Pinia must be installed before the UDS plugin — the plugin defines a store.
ts
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { UDSPlugin } from '@muxen/uds'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.use(UDSPlugin, {
endpoint: '/ws/uds', // reverse-proxied; see below
reconnectDelay: 5, // seconds between reconnect attempts
})
app.mount('#app')The plugin only configures the store and exposes it as $uds. It does not open the socket — call connect() yourself, typically from the root component's onMounted.
Endpoint resolution
endpoint | Resolves to |
|---|---|
omitted or 'auto' | ws://<current-host>:12345 — straight to the daemon |
a path, e.g. '/ws/uds' | that path on the page origin, with http→ws |
a full ws:// / wss:// URL | used as given |
The default is /ws/uds, which is what the nginx snippet shipped with the package proxies. Prefer it: the daemon itself serves plain WebSocket only, so a page served over HTTPS needs the proxy to terminate TLS.
A minimal component
vue
<!-- App.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useUDSStore } from '@muxen/uds'
import type { UDSResponseScan } from '@muxen/uds'
const uds = useUDSStore()
type Device = UDSResponseScan['response']['data'][number]
const devices = ref<Device[]>([])
const busy = ref(false)
const error = ref('')
onMounted(() => uds.connect())
async function scan() {
busy.value = true
error.value = ''
try {
const res = await uds.scan(5) // passive scan, 5 s window
devices.value = res.response.data
} catch (e) {
error.value = (e as Error).message // 'timeout', 'aborted', ...
} finally {
busy.value = false
}
}
async function readConfig(deviceId: number) {
const res = await uds.readConfiguration(deviceId)
console.log(res.response.data) // [{ id, name, type, value }]
}
async function writeVbatMin(deviceId: number) {
// writeConfiguration(deviceId, idOrName, value, timeoutSec?, reboot?)
await uds.writeConfiguration(deviceId, 'VbatMin', 2800, 15, /* reboot */ true)
}
</script>
<template>
<section>
<p>Status: {{ uds.isReady ? 'connected' : uds.incompatible ? 'muxen-uds too old' : 'disconnected' }}</p>
<button :disabled="!uds.isReady || busy" @click="scan">Scan</button>
<p v-if="error" class="error">{{ error }}</p>
<ul>
<li v-for="d in devices" :key="d.deviceId">
0x{{ d.deviceId.toString(16) }} — {{ d.functionName }} (instance {{ d.instance }})
<button @click="readConfig(d.deviceId)">Read</button>
</li>
</ul>
</section>
</template>Store actions
Every action returns a promise that rejects with Error('timeout'), Error('aborted'), Error(UDS_INCOMPATIBLE_DAEMON), or the message of the daemon's error frame. All accept an optional AbortSignal, trailing except where noted below.
| Action | Default timeout | What it does |
|---|---|---|
connect() | — | open the socket; auto-reconnects |
scan(scanSeconds = 5) | 5 + scanSeconds s | passive discovery → UDSResponseScan |
uidScan(timeoutSec = 10) | 10 s | active discovery → UDSResponseUIDScan |
reset(deviceId, delay = 500) | 15 s | cold-start the device |
locate(deviceId) | 15 s | LED animation |
readConfiguration(deviceId) | 15 s | full table → UDSResponseReadconfig |
readOneConfiguration(deviceId, name, timeoutSec?, speedy?) | 15 s | one parameter by name |
readOneConfigurationSpeedy(deviceId, name, timeoutSec?) | 15 s | the same with speedy: true |
writeConfiguration(deviceId, id|name, value, timeoutSec?, reboot?) | 15 s | write one parameter |
writeMultipleConfiguration(deviceId, parameters, timeoutSec?, reboot?) | 30 s | write several |
resetConfiguration(deviceId, resetConfig?, resetDeviceId?) | 15 s | factory reset |
checkConfiguration(deviceId?, project?, timeoutSec?, force?) | 60 s | diff vs project → UDSResponseCheckconfig |
deployDevice(deviceId, project, timeoutSec?, force?, signal?, onProgress?, noEpaper?, epaperOnly?, legendLang?) | 60 s | deploy a project config, and its e-paper legends, onto one device → UDSResponseDeploy |
listFirmware(timeoutSec?) | 15 s | local firmware catalog → UDSResponseListFirmware |
flashFirmware(deviceId, srec, onProgress?, idleTimeoutSec?) | 120 s idle | upload an image |
customCommand(deviceId, command, parameters, timeoutSec?) | 15 s | any command, e.g. routine |
abort(uuid) | — | cancel a still-queued job |
deployDevice keeps signal at its original position (5th argument) for backward compatibility, so it is the one action here where AbortSignal is not trailing: onProgress (typed UDSDeployProgress) receives every notification for the job — the initial { detectedDeviceCount } line, then the per-device state updates, including { state: 'writing legend', zone }; noEpaper and epaperOnly mirror the CLI flags of the same name, and legendLang selects a legend variant (empty string = primary language). See Deploying a boat configuration.
Getters: isConnected, isReady, isWaitingToBeReconnected, getNotification(uuid), supports(feature).
The daemon's hello and features
@muxen/uds 10 speaks only to muxen-uds 10.0.0 or later. The daemon opens every connection with a hello, which the store keeps in hello ({ name, version, hostname, features }) until the connection goes:
| Member | Meaning |
|---|---|
hello | the daemon's hello, undefined before it arrives and after a disconnect |
isReady | connected and hello received |
supports(feature) | true when the hello lists feature (typed UDSFeature, any string accepted) |
incompatible | the daemon opened with something else than a hello, or with nothing within 5 s: it predates 10.0.0 |
lastError | the last error frame the daemon sent without a uuid (the message could not be read far enough to match a request); also logged |
Offer a feature on supports(), never on hello.version, which is for display only:
ts
const canAbort = computed(() => uds.supports('abort'))
const boardAware = computed(() => uds.supports('hardware-id'))Against an incompatible daemon every pending and new request rejects with Error(UDS_INCOMPATIBLE_DAEMON) — 'muxen-uds 10.0.0 or later required' — until the socket closes; show that to the user rather than a timeout. The Debian packages keep the two sides in step, so this only happens on a half-upgraded Brain.
Note that writeConfiguration selects the parameter by id or name through the same argument — pass the string 'VbatMin' or the number 4.
Reconnection and failure
The store reconnects on its own: when the socket closes it rejects every pending job with Error('timeout'), clears its maps, and schedules another connect() after reconnectDelay seconds. isWaitingToBeReconnected is true during that wait.
Two consequences for a UI:
- A pending command does not survive a reconnect. Treat a
'timeout'rejection as "unknown outcome", not as "did not happen" — the daemon may well have finished the write. - Guard on
isReady. An action called while disconnected rejects immediately withUDS client is not connected;isReadyalso waits for the daemon's hello, so a UI never offers what the daemon has not said it supports.
Cancelling a request
Pass an AbortSignal. Aborting rejects the promise with Error('aborted') and tells the daemon to drop the job if it is still queued; a command already running on the bus finishes, but its response is discarded.
ts
const ctrl = new AbortController()
const p = uds.scan(30, ctrl.signal)
// later, e.g. the user navigates away:
ctrl.abort()
try { await p } catch (e) { /* 'aborted' */ }Progress and long commands
firmware and deploy stream progress before their final response. The promise helpers generate their uuid internally, so onProgress is how a progress bar gets wired to one:
ts
const percent = ref(0)
await uds.flashFirmware(
deviceId,
`${entry.code}/${entry.filename}`, // from listFirmware()
(progress) => { percent.value = progress.percent },
)Each notification re-arms the request timeout, so the trailing argument is an idle timeout: an upload is abandoned only once the daemon stops reporting, however long it takes overall. The store also keeps the latest notification per job in getNotification(uuid), which is what to use if you drive the socket yourself.
Note the srec argument shape: a path relative to the firmware store, i.e. ${code}/${filename} of a listFirmware() entry. An absolute path or one containing .. is rejected by the daemon.
Matching a firmware to a device
listFirmware() returns each image with the hardwareId (board) and softwareId (application) it targets — the same values a device reports through readConfiguration(). Filter on those rather than parsing the filename:
ts
const identity = await uds.readConfiguration(deviceId)
const read = (n: string) => identity.response.data.find((p) => p.name === n)?.value
const { response } = await uds.listFirmware()
const candidates = response.data.filter((f) =>
f.code === read('ProductId') && f.hardwareId === read('HardwareId'))Two rules the caller must enforce, because the daemon cannot:
- A
hardwareIdofnullmeans the catalog does not declare the board. Do not guess — let the user choose. - A v5 and a v6 bootloader are signed with different keys, so only offer images whose major version matches the device's current
SoftwareVersion. The device refuses the others before any data is written, so nothing is damaged, but the update cannot succeed.
Building the package
sh
cd typescript/@muxen-uds
npm install
npm run build # tsup
npm run typecheck # tsc --noEmit
npm run lint # eslint