Skip to content

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/uds

Peer dependencies, both declared optional so the package can also be used for its types alone:

  • vue ^3.3
  • pinia >=2.1 or ^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 ​

endpointResolves 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:// URLused 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.

ActionDefault timeoutWhat it does
connect()—open the socket; auto-reconnects
scan(scanSeconds = 5)5 + scanSeconds spassive discovery → UDSResponseScan
uidScan(timeoutSec = 10)10 sactive discovery → UDSResponseUIDScan
reset(deviceId, delay = 500)15 scold-start the device
locate(deviceId)15 sLED animation
readConfiguration(deviceId)15 sfull table → UDSResponseReadconfig
readOneConfiguration(deviceId, name, timeoutSec?, speedy?)15 sone parameter by name
readOneConfigurationSpeedy(deviceId, name, timeoutSec?)15 sthe same with speedy: true
writeConfiguration(deviceId, id|name, value, timeoutSec?, reboot?)15 swrite one parameter
writeMultipleConfiguration(deviceId, parameters, timeoutSec?, reboot?)30 swrite several
resetConfiguration(deviceId, resetConfig?, resetDeviceId?)15 sfactory reset
checkConfiguration(deviceId?, project?, timeoutSec?, force?)60 sdiff vs project → UDSResponseCheckconfig
deployDevice(deviceId, project, timeoutSec?, force?, signal?, onProgress?, noEpaper?, epaperOnly?, legendLang?)60 sdeploy a project config, and its e-paper legends, onto one device → UDSResponseDeploy
listFirmware(timeoutSec?)15 slocal firmware catalog → UDSResponseListFirmware
flashFirmware(deviceId, srec, onProgress?, idleTimeoutSec?)120 s idleupload an image
customCommand(deviceId, command, parameters, timeoutSec?)15 sany 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:

MemberMeaning
hellothe daemon's hello, undefined before it arrives and after a disconnect
isReadyconnected and hello received
supports(feature)true when the hello lists feature (typed UDSFeature, any string accepted)
incompatiblethe daemon opened with something else than a hello, or with nothing within 5 s: it predates 10.0.0
lastErrorthe 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 with UDS client is not connected; isReady also 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 hardwareId of null means 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

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