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.

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

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.