Appearance
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 mqttmqtt (^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 point | Contents |
|---|---|
@muxen/boat | everything below, re-exported |
@muxen/boat/message-types | the TypeScript interfaces, no runtime dependency |
@muxen/boat/plugin | the Vue plugin, the store and the composables |
@muxen/boat/devices | the 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()| Option | Default | Meaning |
|---|---|---|
endpoint | /ws/boat | a 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 |
reconnectDelay | 5 | seconds between reconnection attempts |
subscribeTopics | ['system/#'] | topics subscribed on every connect, before any component asks for anything |
configurationPath | unset | a 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 0Available: 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:
- Every payload carries its own
expireAfterSec. Freshness is per frame, not a global timeout. - The clock is
system/time, not the browser's. The store comparesrxdateagainst the lastsystem/timetick. A page on a laptop whose clock is wrong still reads the boat correctly. - 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/timearrives 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
| Getter | Meaning |
|---|---|
isConnected | the socket is up |
isWaitingToBeReconnected | a connection was attempted and is not up |
now | a Date built from the last system/time tick |
getSetting(name) | a value from the fetched configuration, if configurationPath was set |
info | the daemon's retained app/boat/info as a DaemonInfo, undefined until received and while disconnected |
online | info.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 lintThe package is published to the MUXEN npm registry under the @muxen scope; it shares its version and its git tag with the daemon.
