Skip to content

Web clients ​

The screens on the boat are web pages. They do not talk to the CAN bus and they do not talk to the daemon: they open a WebSocket to the same mosquitto broker muxen-boat publishes to, and subscribe to the topics they need. For the crew, that is why a screen keeps working when another one is switched off, and why a page that has just been opened takes a moment to fill in — it is waiting for the boat to send its next frame.

This chapter is for whoever builds those pages. MUXEN publishes @muxen/boat, a TypeScript package that wraps the connection, the subscription bookkeeping and the freshness rules so a component can ask for a topic and get a reactive value.

Using the package is optional. Any MQTT-over-WebSocket client works against the same broker; the topics are in The device catalogue.

The endpoint ​

The broker's WebSocket listener is on port 1884. The package installs an nginx snippet that publishes it as a path on the web server:

nginx
# /etc/nginx/snippets/muxen-ws-boat.conf, included by the site
location = /ws/boat {
  proxy_pass          http://127.0.0.1:1884/;
  ...
  proxy_read_timeout  15d;
}

Going through the proxy is what lets a page served over HTTPS reach the broker, since a https:// page cannot open a plain ws:// socket. The long read timeout is deliberate: an MQTT connection is idle between messages and must not be reaped.

Install ​

sh
npm install @muxen/boat mqtt

mqtt (^5) is a required peer dependency. vue (^3.3) and pinia (>=2.1) are optional peers — install them if you use the plugin, the store or the composables; the message types alone need neither.

Entry pointContents
@muxen/boateverything below, re-exported
@muxen/boat/message-typesthe TypeScript interfaces, no runtime dependency
@muxen/boat/pluginthe Vue plugin, the store and the composables
@muxen/boat/devicesthe device classes

Registering the plugin ​

ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { BoatPlugin, useBoatStore } from '@muxen/boat'

const app = createApp(App)
app.use(createPinia())
app.use(BoatPlugin, {
  endpoint: '/ws/boat',
  reconnectDelay: 5,
  subscribeTopics: ['system/#'],
})

useBoatStore().connect()
OptionDefaultMeaning
endpoint/ws/boata path is resolved against the page's origin and switched to ws:/wss:; auto builds an origin-relative URL on port 1884; a full URL is used as given
reconnectDelay5seconds between reconnection attempts
subscribeTopics['system/#']topics subscribed on every connect, before any component asks for anything
configurationPathunseta URL the store fetches once for the boat's equipment names and settings

Keep system/# in subscribeTopics. system/time is the store's wall clock, and until the first tick arrives the store deliberately holds back wildcard results — see Freshness below.

Reading one topic ​

vue
<script setup lang="ts">
import { useBoatTopic } from '@muxen/boat'
const { data: battery, outdated } = useBoatTopic('device/5/0/life')
</script>

<template>
  <div v-if="battery && !outdated">{{ battery.data.voltage }} V</div>
  <div v-else>—</div>
</template>

useBoatTopic subscribes on first use and returns { data, outdated }. data is a ShallowRef holding the last message, or null before the first one arrives. outdated is a computed boolean, true when the message is older than its own expireAfterSec, and true whenever there is no message at all — so !outdated is the honest "this number is live" test.

Subscriptions are reference-counted. When the last component using a topic unmounts, the unsubscribe is deferred by 10 seconds, so navigating away and back does not churn the broker. Reconnection is handled: the composable re-subscribes when the socket comes back.

Reading a group of topics ​

vue
<script setup lang="ts">
import { useBoatTopicGroup } from '@muxen/boat'
const batteries = useBoatTopicGroup('device/5/+/life')
</script>

<template>
  <div v-for="[topic, msg] of batteries" :key="topic">
    {{ topic }} — {{ msg.data.soc }} %
  </div>
</template>

useBoatTopicGroup takes an MQTT wildcard pattern — + for one segment, # for the rest — and returns a ShallowRef<Map<topic, message>> that the store maintains incrementally. It has the same lifecycle as useBoatTopic.

The Map holds only fresh entries. On every system/time tick the store drops entries whose rxdate + expireAfterSec has passed, so a device that stops talking disappears from the list rather than freezing in it. That is the intended way to render "the batteries currently on the bus".

Device classes ​

For the equipment that has a richer shape than a single topic, the package exports typed device classes:

ts
import { Device } from '@muxen/boat'

const battery = new Device.Battery(0)   // instance 0

Available: Button, Bloc8, Motor, Converter, SolarPanel, Battery, Interco, Keel.

A device subscribes to device/<function>/<instance>/# in its constructor and every class exposes exist, isOnline, isOffline, remoteTransmissionRequest(frameId) and getDeviceId() — the numeric MUXEN device id, function × 64 + instance.

Devices are long-lived by design and are not released automatically. Call dispose() when a device object is discarded — a view teardown, a rebuilt instance list — or its wildcard subscription stays open.

Sending a command ​

ts
import { useBoatStore } from '@muxen/boat'

useBoatStore().publish('device/8/0/command', { channel: 0, on: true, dimming: 50 })

publish(topic, message, retain = false) serialises the message to JSON and publishes it. There is no reply: the confirmation is the device's next life frame arriving on the topic the page is already watching.

Freshness, and why a page starts blank ​

Three rules, and they interact:

  1. Every payload carries its own expireAfterSec. Freshness is per frame, not a global timeout.
  2. The clock is system/time, not the browser's. The store compares rxdate against the last system/time tick. A page on a laptop whose clock is wrong still reads the boat correctly.
  3. Nothing is considered fresh before the first tick. The broker replays retained messages the instant a client connects, and some of them can be hours old. Until system/time arrives the store holds wildcard results empty, then backfills with whatever is still fresh.

The visible consequence is a brief blank at page load. That is the correct behaviour: the alternative is a screen that shows stale figures as live for a second.

A cleared topic ​

An empty payload (zero bytes) is how MQTT clears a retained message: a daemon's last will that empties its status topic when it stops, for example. The broker delivers that empty message to every client already subscribed, and the store treats it as "this topic no longer exists":

  • useBoatTopic(topic).data goes back to null (and outdated to true), exactly as before the first message;
  • the topic leaves every useBoatTopicGroup Map that held it;
  • store.topicRefs.get(topic), message(topic) and the Device getters return undefined;
  • an empty app/boat/info sets info back to undefined; an empty system/time leaves the clock alone.

A later message on the topic is picked up as a first one. A non-empty payload that is not JSON is still kept out of topicRefs: the composables go on showing the last JSON value.

Connection state ​

GetterMeaning
isConnectedthe socket is up
isWaitingToBeReconnecteda connection was attempted and is not up
nowa Date built from the last system/time tick
getSetting(name)a value from the fetched configuration, if configurationPath was set
infothe daemon's retained app/boat/info as a DaemonInfo, undefined until received and while disconnected
onlineinfo.online: the daemon itself is up (the socket being up says nothing about it)
supports(feature)info.features contains feature; test features, never info.version

The store subscribes to app/boat/info (BOAT_INFO_TOPIC) on every connection. The payload is described in system; isDaemonInfo() checks the shape.

A dropped connection surfaces as close or offline, both of which clear isConnected — the case an indicator on a screen needs to react to. On reconnect the store re-issues every subscription itself, because the broker keeps no subscription state across a drop.

Metadata normalisation ​

The device/interface topic uses a capital rxDate where every other topic uses lowercase rxdate. The store copies one onto the other at ingestion, so a consumer only ever reads metadata.rxdate. Each message also gains a lazily-parsed metadata.rxDateTime Date.

Building the package ​

sh
cd typescript/@muxen-boat
npm install
npm run build        # tsup, emits ESM + CJS + types into dist/
npm run typecheck
npm run lint
npm test             # vitest, the store tests under test/

The package is published to the MUXEN npm registry under the @muxen scope; it shares its version and its git tag with the daemon.

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