Appearance
The HTTP and WebSocket API
For anyone writing a client: a screen, a tray application, a test harness. It is the only interface muxen-alarms offers for alarms and mutes — there is no control socket. The MQTT topics under app/alarm/ concern the spoken alarms only (Reference).
Two rules shape everything below.
- The WebSocket is where alarms come from. It pushes the complete list as a client connects, on every change and every 5 seconds. The one thing it reads back is a request for the speaker test. A client should connect to it and stop polling.
- REST is where decisions go. Mutes are placed and removed over REST.
GET /api/dtcsexists for scripts and for a client that wants one snapshot without holding a socket open.
Endpoints
The daemon listens on 127.0.0.1:12001. nginx exposes it under /api/alarms/ and /ws/alarms, rewriting /api/alarms/<x> to /api/<x>. Both columns are given because the rewrite means a path is correct on exactly one of the two.
| Method | Public path | Daemon path | Body | Response |
|---|---|---|---|---|
GET | /api/alarms/dtcs | /api/dtcs | — | the alarm envelope |
GET | /api/alarms/filter | /api/filter | — | a bare array of filters |
PUT | /api/alarms/filter | /api/filter | a filter request | — |
DELETE | /api/alarms/filter | /api/filter | — | — |
GET | /api/alarms/filter/{uuid} | /api/filter/{uuid} | — | one filter |
DELETE | /api/alarms/filter/{uuid} | /api/filter/{uuid} | — | — |
| — | /ws/alarms | / | {"type": "test-audio"}, optional | a hello first, then the alarm envelope — at once, on every change and every 5 s — and the audio state every second |
The daemon also serves the Swagger UI at its own root, from /usr/share/muxen-alarms/swagger, which is why the WebSocket upgrade and the documentation share a path.
Responses are application/json. Successful PUT and DELETE return 200 with an empty body.
The alarm envelope
GET /api/alarms/dtcs and every WebSocket frame carry the same object. Schema: dtc-schema.json.
json
{
"type": "dtcs",
"dtcs": [ … ],
"active": 1,
"muted": 0
}| Field | Type | Meaning |
|---|---|---|
type | string | always "dtcs" — the discriminator a WebSocket client switches on |
dtcs | array | every live alarm, muted or not |
active | integer | how many are not muted |
muted | integer | how many are |
active + muted == dtcs.length. A client asking "is anything wrong" reads active; the length of the array is the wrong number, because it includes alarms the crew deliberately silenced.
One alarm
json
{
"alarmCode": 1,
"alarmDescription": "Low voltage",
"alarmDescription.FR": "Sous-tension",
"severity": "warning",
"deviceId": 320,
"functionCode": 5,
"functionName": "Power source battery",
"functionName.FR": "Batterie",
"functionName.DE": "Batterie",
"functionName.ES": "Batería",
"functionName.GR": "Μπαταρία",
"functionName.IT": "Batteria",
"functionName.PT": "Bateria",
"functionName.TR": "Akü",
"instance": 0,
"creationTime": "2026-08-16T09:14:02Z",
"expireTime": "2026-08-16T09:14:31Z",
"muted": false
}| Field | Type | Always | Meaning |
|---|---|---|---|
alarmCode | integer | yes | the code the device reported |
deviceId | integer | yes | the reporting device, functionCode * 64 + instance |
functionCode | integer | yes | derived from deviceId |
instance | integer | yes | derived from deviceId |
functionName | string | yes | readable form of functionCode; "Unknown" for a code with no name |
creationTime | string | yes | ISO 8601 UTC — when the fault started |
expireTime | string | yes | ISO 8601 UTC — 15 s after the last report |
muted | boolean | yes | whether a filter matches |
alarmDescription | string | yes | from the catalogue; "unknown" when the code is absent from it |
severity | string | yes | from the catalogue; "unknown" likewise |
alarmDescription.FR | string | no | only when the catalogue entry carries it |
functionName.<LANG> | string | no | since 10.3.0: functionName in another language, one key per language functions.json carries for this function code (FR, DE, ES, GR, IT, PT, TR today). Absent when the code has no entry or the file did not load |
filterBy | object | no | only when muted is true — the filter that matched |
| (others) | — | no | any further field the catalogue entry carries, e.g. channel on Bloc 8 |
Notes for implementers:
- The catalogue entry is copied wholesale, so a client must tolerate fields not listed here. Treat the object as open.
alarmDescription.FRis optional. Two of the 265 shipped entries have no French text; fall back toalarmDescription.functionName.<LANG>is optional, and the set of languages is open.<LANG>is the last two letters of the locale, as foralarmDescription.FR: readalarm[`functionName.${locale.slice(-2)}`]and fall back tofunctionName, which stays English and is always there. English (en-US) has no key of its own. A code with no entry infunctions.json, or a daemon without the file, sends none; the hello featurefunction-name-translationstells whether the daemon loaded it. Do not list the languages in a client: a new one arrives as a new key.severityis a free string. The shipped catalogue useswarninganderror, andunknownappears for any code with no entry. Do not assume a closed set.- Do not compare
severityagainst a literal. Catalogues older than 10.0.0 spellerrorasalarm, and a boat is not upgraded atomically: an interface can meet either spelling.@muxen/alarmsexportsnormalizeSeverity(), which resolves both toerror, together withisSeverityAtLeast()and theerror > warning > noticeladder — see The alarm catalogue. - Timestamps are UTC, formatted
%FT%TZ. A timestamp the daemon could not convert is the literal string"invalid"rather than a malformed date. - Parse frames with
parseWebsocketMessage()from@muxen/alarms/parserather than castingJSON.parse(): it returns the types above orundefinedfor anything to ignore (a frame withouttypeincluded: every supported daemon, 10.1.0 on, sends one), and discards anaudioframe without itsmetadata. The store keeps thehelloand answerssupports(feature)from it. - The key is
(deviceId, alarmCode). That pair is what makes an alarm the same alarm across frames; a client keying on anything else will see duplicates. expireTimeminus 15 seconds is when the fault was last confirmed. It is how a client shows freshness, and how it notices a feed that has frozen. A report that only confirms a fault is not a change: it reaches the client with the next 5-second frame, so a client's copy ofexpireTimeis at most that far behind.
Filters
Schema: filter-schema.json.
Creating or refreshing one
PUT /api/alarms/filter, with Content-Type: application/json:
json
{ "deviceId": 320, "code": 1, "duration": 3600 }| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
deviceId | integer, 0 … 4095 | — | (any) | constrain to one device |
functionCode | integer, 0 … 63 | — | (any) | constrain to one equipment family |
code | integer, 0 … 65535 | — | (any) | constrain to one alarm code |
duration | integer | — | 3600 | seconds the filter lasts, 1 … 31 536 000 |
At least one of deviceId, functionCode, code must be present. A body carrying only duration, or {}, is refused with 400 — a filter with no constraint would silence the entire boat. An out-of-range duration is also refused with 400 rather than clamped.
An id outside its range is refused with 400, and nothing is stored. The ranges are those of the bus: a device id is functionCode * 64 + instance in 12 bits, and an alarm code is 16 bits. The ids must be JSON integers: a string ("57"), a float, a boolean or null is refused too, and so is -1 — to match anything, leave the field out. An older daemon read "57abc" as 57, stored a value it could never match, and took a negative one for "any".
The identifier is derived
There is no client-chosen id, and despite the field name there is no UUID. The identifier is %03u-%03u-%06u of deviceId, functionCode and code, with an absent constraint written as 0:
| Request | Identifier |
|---|---|
{"deviceId": 320, "code": 1} | 320-000-000001 |
{"functionCode": 5} | 000-005-000000 |
{"code": 65000} | 000-000-065000 |
A PUT whose constraints derive an existing identifier updates that filter's duration and resets its creationTime, keeping the stored filter's own constraints. It never creates a second one. So repeated acknowledgement is idempotent, and there is no way to accumulate duplicates — but also no way to hold two filters whose present constraints are all zero, since they share 000-000-000000.
Read the identifier back from GET /api/alarms/filter, or from the filterBy object on a muted alarm. Do not construct it in the client.
GET and DELETE /api/alarms/filter/{uuid} refuse with 400 an identifier the daemon cannot have derived: not three decimal fields, a field out of its range above, or another spelling of the same numbers (57-0-0 for 057-000-000000). A well-formed identifier that is not in force is 404.
A stored filter
json
{
"uuid": "320-000-000001",
"deviceId": 320,
"code": 1,
"duration": 3600,
"creationTime": "2026-08-16T09:20:00Z",
"expireTime": "2026-08-16T10:20:00Z"
}deviceId, functionCode and code are omitted when the filter does not constrain them. Absent means "matches anything", so a client must not read a missing field as a zero.
GET /api/alarms/filter returns a bare JSON array of these — no envelope, unlike the alarm list.
Matching
An alarm is muted when some filter matches it on all three fields, an absent field matching anything. The first filter created that matches is the one reported in filterBy.
Filters are swept once a second and deleted when expireTime passes. They are held in memory only: restarting muxen-alarms.service — which a MUXEN deployment does, since the unit is PartOf=muxen.target — clears every one. A client that needs a mute to survive a restart has to re-place it.
Status codes
| Status | When |
|---|---|
200 | success |
400 | body absent or unparseable; no constraint given; deviceId, functionCode or code not an integer in range; duration outside 1 … 31 536 000; a filter identifier that is malformed or out of range |
404 | unknown path, or a well-formed filter identifier that is not in force |
405 | the path exists but not for this method — the response carries Allow |
413 | the request body exceeds 64 KB |
500 | the response could not be built |
405 is worth handling: /api/dtcs accepts GET only, and /api/filter/{uuid} accepts GET and DELETE only. A single filter is updated by PUTting the same constraints to /api/filter, not to its own path.
The daemon closes the connection after each HTTP response — keep-alive is disabled deliberately — so a client should not hold a persistent HTTP connection. Use the WebSocket for anything continuous.
The WebSocket
Three kinds of frame, told apart by type. dtcs and audio are full snapshots, not a change feed:
type | Sent | Carries |
|---|---|---|
hello | once, first, to that client only | what the daemon is and what it can do, below |
dtcs | right after the hello, to that client only; then to every client on each change — an alarm appearing, expiring or starting over, a mute placed, refreshed, removed or expiring — and at least every 5 seconds | the complete alarm envelope above, including {"dtcs": [], "active": 0, "muted": 0} when nothing is wrong |
audio | once a second, and at once on every change | the state of the spoken alarms |
A daemon before 10.3.0 sends dtcs once a second instead, and no dtcs frame of its own after the hello; the alarm-list-on-change feature tells the two apart.
A gap in the frames is itself the signal that something is wrong with the feed. Watch for any frame, not for dtcs: audio comes every second, while a list that does not change is sent only every 5 seconds. A client must ignore a type it does not know.
A client sends one message, as a text frame:
json
{ "type": "test-audio" }It asks for the speaker test: the daemon plays its welcome clip once, through the alarm output, even with the spoken alarms turned off. It answers nothing directly; the test shows in the audio frames, below, the moment it starts. It is the same request as {} on the MQTT topic app/alarm/test, with the same rules — ignored while anything is being spoken or without audio, stopped by a real alarm (Reference).
Anything else — another type, a message that is not a JSON object with a string type, a binary frame, or a message over 256 bytes once its fragments are put together — is ignored and logged, and the connection stays open. Other fields next to type are not read.
Connect to ws://<brain>/ws/alarms through nginx, or ws://127.0.0.1:12001/ on the Brain itself. The shipped nginx snippet sets proxy_read_timeout 15d, so the connection is not dropped for being long-lived.
A client that cannot keep up is disconnected. The daemon buffers 16 messages per vhost; when the ring is full it drops the slowest client rather than let it stall the broadcast to every other screen. A client that finds itself reconnecting under load is reading too slowly, not hitting a network fault.
Reconnect with a delay. The @muxen/alarms client uses 5 seconds.
The hello frame
The first frame of every connection, sent to that client alone and before any dtcs or audio frame (since 10.2.0):
json
{
"type": "hello",
"name": "muxen-alarmsd",
"version": "v10.2.0",
"hostname": "brain-3",
"features": ["alarm-list", "alarm-list-on-change", "test-audio", "audio", "voice-files"]
}| Field | Meaning |
|---|---|
name | always muxen-alarmsd |
version | the daemon's version, as muxen-alarmsd --version prints it. For display only: a client decides what to offer from features, never from version |
hostname | the Brain's host name, read when the frame is sent. A client that saved a Brain can check it is still talking to the same one |
features | what this daemon can do, below. A client must ignore a feature it does not know |
| Feature | Present | Meaning |
|---|---|---|
alarm-list | always | the dtcs frame and the REST API above |
alarm-list-on-change | always, since 10.3.0 | the dtcs frame comes right after the hello, on every change and every 5 seconds, above — not once a second. A client with a dtcs-specific watchdog widens it or, better, watches any frame |
test-audio | always | the daemon reads {"type": "test-audio"}, below. It may still ignore the request, as it does any test while audio is off |
audio | when the daemon has a voice player: started without --no-audio, with a readable manifest.json naming at least one voice file | alarms are spoken on the Brain. Whether it can speak at this moment is available in the audio frame |
voice-files | today, whenever audio is | speaking.href is set in the audio frame and names a file nginx serves under /api/alarms/sound/ |
function-name-translations | since 10.3.0, when functions.json (Reference) loaded with at least one entry | the alarms carry functionName.<LANG> next to functionName, above, for every function code the file names |
The features are decided once, at startup, and do not change while the daemon runs: an audio output that stops answering turns available false in the audio frame, not audio off here. They are the same as the features of the retained MQTT topic app/alarm/info (Reference).
A daemon older than 10.2.0 sends no hello: its first frame is a dtcs or an audio one. A client that gets one of those first is talking to such a daemon, which reads {"type": "test-audio"} from 10.0.0 on and silently discards it before.
The audio frame
The same object the daemon publishes on the retained MQTT topic app/alarm/audio (Reference), plus type:
json
{
"type": "audio",
"data": {
"available": true,
"enabled": true,
"minLevel": "error",
"speaking": {
"severity": "error",
"alarmCode": 3,
"deviceId": 1344,
"href": "/api/alarms/sound/low-oil-pressure.ogg"
}
},
"metadata": { "rxdate": "2026-09-10T14:02:11.412Z", "rxTimestamp": 1789048931, "expireAfterSec": 5 }
}During the speaker test speaking is not an alarm, and says so:
json
"speaking": {
"severity": "error",
"test": true,
"href": "/api/alarms/sound/muxen-welcome.ogg"
}test is present, and true, only then; alarmCode and deviceId are absent. severity is error because the test plays through the alarm output. A client that shows what is being said should check test before it looks for an alarm.
speaking is null between two sentences and whenever the daemon is silent. A frame arrives the moment a sentence starts and the moment a round ends, so a page can pause its own video or music on speaking !== null and resume on null.
href is the voice file being played, served by nginx under /api/alarms/sound/. It is for a client other than the Brain's own screen — a tablet or a phone that should say the alarm too. A page shown on the Brain must not play it: the daemon is already saying it through the boat's speakers.
Discovering a Brain
With muxen-alarms-mdns installed, the Brain publishes MuxenAlarms._http._tcp over IPv4 and IPv6 on port 80, with two TXT records:
| Record | Value |
|---|---|
api | /api/alarms/ |
ws | /ws/alarms |
The announcement describes nginx, not the daemon: port 80 and the public paths, because the daemon's own 12001 is bound to the loopback interface and is unreachable from the network. A client should take the address and port from the announcement and the paths from the TXT records rather than hard-coding either. Whether nginx answers on IPv6 is up to the boat's interface site, not this package: a client should try the IPv4 addresses first and fall back to IPv6.
avahi-autoipd comes with the package so the Brain claims a link-local 169.254.0.0/16 address on a network with no DHCP server — a laptop plugged straight into the boat.
The TypeScript client
@muxen/alarms wraps all of the above for a Vue 3 + Pinia application.
ts
import { AlarmsPlugin, useAlarmsStore } from '@muxen/alarms';
app.use(AlarmsPlugin, {
endpoint: '/ws/alarms', // default
endpointAPI: '/api/alarms', // default
reconnectDelay: 5, // seconds, default
});
const alarms = useAlarmsStore();
alarms.connect();| Getter | Meaning |
|---|---|
isConnected | the WebSocket is open |
isReady | open and at least one envelope has arrived |
isWaitingToBeReconnected | a reconnect is scheduled |
haveActiveDtcs | active > 0 |
speaking | the sentence being spoken, or null |
isSpeaking | the daemon is speaking right now, an alarm or the speaker test |
isTestingAudio | the daemon is playing the speaker test |
| Action | Wraps |
|---|---|
connect() / disconnect() | the WebSocket, with reconnect |
getFilters() | GET /filter |
addFilter(options) | PUT /filter |
deleteFilter(uuid) | DELETE /filter/{uuid} |
deleteFilters() | DELETE /filter |
testAudio() | sends {"type": "test-audio"}; returns false, sending nothing, when the WebSocket is not open |
The store keeps the last envelope in dtcs and the last audio frame in audio. The audio frame is dropped as soon as the WebSocket closes: a stale speaking would keep a paused video paused for as long as the daemon is unreachable. Setting endpoint to 'auto' builds a URL from the page's own origin with port 12001, which is for a client served from the Brain itself with no reverse proxy in front.
The types alone are importable from @muxen/alarms/message-types, which has no runtime dependency on Vue or Pinia.
