Appearance
Reference
Everything the two programs accept, everything they install, and every value they default to. Behaviour is in the earlier chapters; this is the lookup surface.
Packages
| Package | Architecture | Contents |
|---|---|---|
muxen-alarms | any | muxen-alarmsd, muxen-dtc, muxen-alarms-test-audio, the systemd unit, the nginx snippets, the Swagger UI |
muxen-alarms-database | all | /usr/share/muxen-alarms/dtc.json, /usr/share/muxen-alarms/functions.json, and the voice files with their manifest.json in /usr/share/muxen-alarms/sound/, including the speaker-test clip muxen-welcome.ogg |
muxen-alarms-mdns | all | /etc/avahi/services/muxen-alarms.service |
| Relationship | On | Value |
|---|---|---|
Depends | muxen-alarms | muxen-systemd, muxen-alarms-database (= ${source:Version}), mosquitto-clients (for muxen-alarms-test-audio) |
Recommends | muxen-alarms | muxen-boat (>= 5.0.0) |
Replaces / Breaks | muxen-alarms | muxen-errors |
Depends | muxen-alarms-mdns | avahi-daemon, avahi-autoipd |
muxen-alarms activates two dpkg triggers on install and upgrade: muxen-restart-target, which restarts muxen.target, and nginx-reload, which reloads nginx once at the end of the transaction if it is running, so a changed snippet is served at once rather than after the next reload somebody happens to make.
muxen-alarmsd
The daemon. One of --mqtt or --interface is required; without either it prints its usage and exits.
| Option | Argument | Default | Effect |
|---|---|---|---|
-h, --help | — | — | print usage and exit 0 |
-v, --verbose | — | 0 | cumulative. -v adds HTTP request logging, lws notices and alsa-lib's own diagnostics of an audio output it cannot open; -vv also dumps every CAN frame or MQTT message |
-V, --version | — | — | print the version and exit 0 |
-i, --interface | canX | (none) | take alarm reports from this CAN interface |
-m, --mqtt | — | off | take alarm reports from MQTT instead of CAN |
-p, --port | number | 12001 | TCP port of the HTTP/WebSocket listener |
-d, --dtc | path | /usr/share/muxen-alarms/dtc.json | the alarm code catalogue |
--functions | path | /usr/share/muxen-alarms/functions.json | the equipment names in other languages (format), since 10.3.0. Optional: a file that is missing, unreadable, not JSON, not an array or empty logs one warning, the alarms carry no functionName.<LANG> and the hello no function-name-translations; the daemon runs on |
-s, --swagger | path | /usr/share/muxen-alarms/swagger | directory served at / |
--no-audio | — | audio on | never speak alarms. The audio state is still published, with available: false |
--audio-interval | minutes, 0–1440 | 5 | time between two rounds of every active alarm. 0 speaks each alarm once, when it appears |
--audio-gap | seconds, 0–60 | 2 | silence between two phrases of a round |
--audio-level | error, warning or notice | error | lowest level spoken at startup. Changed at run time over MQTT, never stored |
--audio-sounds | path | /usr/share/muxen-alarms/sound | the voice files and their manifest.json |
--audio-pcm-alarm | ALSA PCM name | muxen_alarm | where error phrases play |
--audio-pcm-notification | ALSA PCM name | muxen_notification | where warning and notice phrases play |
--json-pretty | — | compact | indent the JSON the daemon produces — app/alarm/audio, the dtcs and audio WebSocket frames, the REST responses — for reading by hand. The WebSocket hello and app/alarm/info stay compact: libstdmuxen serializes them |
--interface and --mqtt are exclusive in effect: with --mqtt the CAN handler table is not installed and the interface is not opened, and without it the MQTT handler table is not installed.
The listener always binds 127.0.0.1. There is no option to bind another address.
An unreadable --dtc prints config: dtc file … not readable ! and the usage, and exits 0. --functions is not checked there: the warning it gets instead, from GLib, reads
functions: Failed to open file “/nonexistent”: No such file or directory, function names are not translatedAn out-of-range --audio-interval or --audio-gap, or an --audio-level that is not one of the three names, prints what is accepted followed by the usage, and exits 0 like --help.
A PCM name that ALSA does not define falls back to default, logged once. Nothing about audio is fatal: a missing manifest.json, a PCM that will not open, or a player that stops answering each turn into available: false in the audio state, and the alarm list is served as before.
The output is checked at startup. The audio state starts with available: false. The player then writes one second of silence to the --audio-pcm-notification PCM (same fallback to default), in the format of the voice files, and drains it: nothing is heard. When that completes, available turns true, within a second on a working Brain. When it fails it is tried again every 30 s, with no limit, so an output that comes up later — PipeWire starting after the daemon, a card plugged in — turns audio on without a restart. The first failure is said once, at warning level:
audio: no audio output (PCM muxen_notification: No such device); alarms are not spoken, checking again every 30 sand the check that finally plays logs audio: output checked, PCM …, audio is on. -v logs each failed attempt as well. A check that does not answer within 10 s is stuck in alsa-lib: the daemon then behaves as with --no-audio until it is restarted, and says so at warning level.
Everything else — MQTT, the REST API, the WebSocket, the audio state published every second — starts as it always does; only speech waits. Until a check plays no alarm is spoken and a speaker test is ignored (ignored, no audio output yet); an alarm active meanwhile is announced on the first tick after available turns true.
Once available is true, a later failure to play turns it false again but does not turn audio off: the next alarm or speaker test tries the output again, and turns it back true when it plays.
The startup summary printed on every run:
config: verbose = 0
config: wsPort = 12001
config: data source = mqtt
config: dtc = /usr/share/muxen-alarms/dtc.json
config: functions = /usr/share/muxen-alarms/functions.json
config: swaggerFolder = /usr/share/muxen-alarms/swagger
config: audio = on
config: audio interval = 5 min
config: audio gap = 2 s
config: audio level = error
config: audio sounds = /usr/share/muxen-alarms/sound
config: audio pcm = muxen_alarm / muxen_notificationThe config: can interface = … line is printed only on the CAN route. With --no-audio the audio block is the single line config: audio = off.
muxen-dtc
A read-only viewer for a shell on the Brain. MQTT only; it has no CAN route and no API.
| Option | Effect |
|---|---|
-h, --help | print usage and exit 0 |
-v, --verbose | more output |
-V, --version | print the version and exit 0 |
-f, --flow | keep running. Without it the program listens for one second and exits |
-e, --expired | also print reports whose metadata.expireAfterSec has already passed |
It subscribes to system/time and device/+/+/error/#, prints one line per surviving report, and holds no state:
Topic Code Date Device type
device/5/0/error/1 1 2026-08-16T09:14:02Z (Power source battery)Freshness is measured against the time published on system/time when one has arrived, and against the Brain's own clock otherwise.
muxen-alarms-test-audio
A shell script that asks the daemon for its speaker test: {} published on app/alarm/test, never retained, with mosquitto_pub. It then reads app/alarm/audio for 3 seconds and says whether the test started.
| Option | Effect |
|---|---|
--help | print usage and exit 0 |
-h host | the broker, default $MUXEN_MQTT_HOST or 127.0.0.1 |
-p port | its port, default $MUXEN_MQTT_PORT or 1883 |
| Exit | Meaning |
|---|---|
0 | the audio state showed "test": true: the clip is playing |
1 | it did not. The message says whether the audio state reported available: false, carried no test, or was not there at all |
2 | a bad option |
What the test does, and when the daemon ignores it: the app/alarm/test topic.
Internal fixed values
Not configurable; listed because they are visible in the published data.
| Value | Where | Meaning |
|---|---|---|
| 15 s | every device alarm | how long a report is held after its own timestamp |
| 90 s | code 65000 | how long an offline alarm is held |
| 5 s | code 65001 | how long a source-lost alarm is held |
| 1 s | list sweep, filter sweep | the main tick; a change to the list goes out on the WebSocket on the tick that makes it |
| 5 s | WebSocket dtcs frame | the longest between two frames while the list does not change: what carries a live alarm's moving expireTime |
| 60 s | offline scan | the interval between muxen-uds scans |
| 1 s → 30 s | data source retry | backoff, doubling to the ceiling |
| 3600 s | filter duration | default when the request omits it |
| 1 s … 365 d | filter duration | accepted range; outside it the request is refused |
| 64 KB | HTTP request body | above it the request is refused with 413 |
| 16 | WebSocket send ring | messages buffered per vhost before the slowest client is dropped |
| 256 bytes | WebSocket client message | the longest accepted, fragments put together; a longer one is ignored |
| 5 s | MQTT keepalive | client id muxen-alarms (muxen-dtc for the viewer) |
| function 9, instance 0 | code 65001 | the device id the daemon attributes its own alarm to — 576 |
| 5 s | app/alarm/audio | expireAfterSec of the audio state, which is republished every second |
| 100 ms | audio output | PCM buffer; also the longest a stop waits for the phrase being played |
| 32 | spoken round | phrases in one round. The least severe, newest ones follow in the next round |
| 60 s | voice file | the longest file the player accepts |
| all but MP3 | voice file | the player accepts any format libsndfile decodes (Ogg Opus, Ogg Vorbis, FLAC, WAV, …) except MPEG audio, which it refuses on every platform. The shipped voices are Ogg Opus |
| 30 s + 15 s and the gap per phrase | spoken round | past this the player is reported stuck and available turns false until it answers |
| 1 s | daemon stop | how long the daemon waits for the player before exiting without it |
| 1 s of silence, every 30 s | output check | what the player writes to the notification PCM before available turns true, and how often it tries again while that fails |
| 10 s | output check at startup | past this a check that has not answered turns audio off |
Files and paths
| Path | Package | Purpose |
|---|---|---|
/usr/bin/muxen-alarmsd | muxen-alarms | the daemon |
/usr/bin/muxen-dtc | muxen-alarms | the viewer |
/usr/bin/muxen-alarms-test-audio | muxen-alarms | the speaker test, from a shell |
/usr/lib/systemd/system/muxen-alarms.service | muxen-alarms | the unit |
/etc/nginx/snippets/muxen-api-alarms.conf | muxen-alarms | REST proxy snippet |
/etc/nginx/snippets/muxen-ws-alarms.conf | muxen-alarms | WebSocket proxy snippet |
/usr/share/muxen-alarms/swagger/ | muxen-alarms | the Swagger UI, served at / |
/usr/share/muxen-alarms/dtc.json | muxen-alarms-database | the alarm code catalogue |
/usr/share/muxen-alarms/functions.json | muxen-alarms-database | the equipment names in other languages, since 10.3.0 |
/usr/share/muxen-alarms/sound/ | muxen-alarms-database | the alarm voice files (Ogg Opus) and manifest.json, served by nginx at /api/alarms/sound/. muxen-welcome.ogg, the speaker-test clip, is there too and is not in the manifest |
/etc/avahi/services/muxen-alarms.service | muxen-alarms-mdns | the mDNS announcement |
/etc/muxen/deploy.json | (read only) | the device list offline detection compares against |
The daemon writes nothing except one temporary file per offline scan, which it deletes when the scan finishes. PrivateTmp=yes puts that file in the unit's private /tmp.
systemd
muxen-alarms.service:
| Directive | Value |
|---|---|
ExecStart | /usr/bin/muxen-alarmsd --mqtt |
Type | simple |
User / Group | muxen / muxen |
Restart / RestartSec | always / 30 |
PartOf | muxen.target |
After | mosquitto.service |
WantedBy | muxen.target |
Hardening: NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, PrivateDevices, ProtectKernelTunables, ProtectKernelModules, ProtectKernelLogs, ProtectControlGroups, ProtectProc=invisible, ProcSubset=pid, ProtectClock, ProtectHostname, RestrictRealtime, RestrictSUIDSGID, LockPersonality, MemoryDenyWriteExecute, an empty CapabilityBoundingSet and AmbientCapabilities, SystemCallFilter=@system-service with SystemCallErrorNumber=EPERM, SystemCallArchitectures=native, LimitNOFILE=1024.
| Directive | Value | Why |
|---|---|---|
ReadOnlyPaths | /etc/muxen | the offline scan reads the deployed device list |
RestrictAddressFamilies | AF_UNIX AF_INET AF_INET6 AF_NETLINK AF_CAN | broker and listener, the CAN link watch, and the muxen-uds child |
The unit carries nothing for audio, on purpose. Where the audio output is a system-wide PipeWire, the daemon reaches it through its socket — over AF_UNIX, with PrivateDevices left on, since nothing opens /dev/snd — and needs two more settings, which the platform that provides the audio ships as a drop-in:
ini
# muxen-alarms.service.d/audio.conf
[Service]
SupplementaryGroups=pipewire
Environment=PIPEWIRE_RUNTIME_DIR=/run/pipewireThey stay out of the unit because SupplementaryGroups names a group that exists only where PipeWire is installed, and a unit that cannot resolve it does not start. On a Brain without an audio output that would stop alarms being reported at all, to gain a voice it cannot have.
Exit status
| Status | Meaning |
|---|---|
0 | normal exit on SIGTERM/SIGINT; also --help, --version, and every configuration error |
1 | the HTTP/WebSocket listener could not be created — main: lws init failed |
255 | muxen-dtc only: the MQTT client could not be initialised |
A configuration error exiting 0 is worth remembering: systemctl status reports success while the daemon restarts every 30 seconds. The usage block in the journal is the tell.
Network
| Endpoint | Bound to | Served by |
|---|---|---|
12001/tcp | 127.0.0.1 | the daemon: REST, WebSocket and the Swagger UI |
nginx maps two public paths onto it, and serves a third from disk:
| Public | Daemon | Snippet |
|---|---|---|
/api/alarms/<x> | /api/<x> | muxen-api-alarms.conf (client_max_body_size 128k) |
/ws/alarms | / | muxen-ws-alarms.conf (proxy_read_timeout 15d) |
/api/alarms/sound/<file> | (not proxied) | muxen-api-alarms.conf: a static alias of /usr/share/muxen-alarms/sound/ |
The sound location answers GET and HEAD only (403 otherwise), lists no directory, supports Range, and sends Cache-Control: no-cache with an ETag. A regenerated voice keeps its file name, so clients revalidate and get a 304 while it is unchanged. Being part of muxen-api-alarms.conf, it exists on exactly the sites that include that snippet. A site that includes only muxen-ws-alarms.conf has no sound URLs.
The snippets must be included by a site configuration; they are inert on their own. That site configuration is shipped by the boat's UI interface package (muxen-interface-*), one nginx site file per boat, which is also where the map $http_upgrade $connection_upgrade block the /ws/alarms proxy needs is defined. The includes are unconditional there — no trailing * — so on a boat whose interface site includes them, nginx will not start unless this package is installed. Which of the two snippets a given site includes is up to that site: some include both, some only muxen-ws-alarms.conf.
mDNS
muxen-alarms-mdns publishes:
| Field | Value |
|---|---|
| Name | MuxenAlarms |
| Type | _http._tcp, IPv4 and IPv6 |
| Port | 80 |
| TXT | api=/api/alarms/, ws=/ws/alarms |
It advertises nginx's port and paths, not the daemon's 12001, which nothing off the Brain can reach. The port is a fixed 80 in the service file, while the actual listening port is chosen by the boat's interface site — usually 80, but not on every boat — so on a Brain serving on another port the announcement is wrong and a client has to be pointed at the right port by hand. avahi-autoipd is a dependency so the Brain claims a 169.254.0.0/16 link-local address on a network with no DHCP server — an ad-hoc link between a laptop and the boat.
REST and WebSocket
Full wire format: The HTTP and WebSocket API. Machine-readable schemas: dtc-schema.json, filter-schema.json.
| Method | Public path | Daemon path |
|---|---|---|
GET | /api/alarms/dtcs | /api/dtcs |
GET | /api/alarms/filter | /api/filter |
PUT | /api/alarms/filter | /api/filter |
DELETE | /api/alarms/filter | /api/filter |
GET | /api/alarms/filter/{uuid} | /api/filter/{uuid} |
DELETE | /api/alarms/filter/{uuid} | /api/filter/{uuid} |
| — | /ws/alarms | / (WebSocket; one client message, {"type": "test-audio"}) |
| Status | Meaning |
|---|---|
200 | success |
400 | body absent, unparseable, with no constraint, or with an out-of-range duration |
404 | unknown path, or unknown filter identifier |
405 | path known, method not supported — carries an Allow header |
413 | request body over 64 KB |
500 | internal error |
Input interfaces
MQTT
Subscriptions, on 127.0.0.1:1883:
| Topic | Used for |
|---|---|
device/+/+/error/# | alarm reports |
system/time | subscribed by both programs; the reference clock for muxen-dtc's freshness test |
app/alarm/settings | the spoken-alarm settings, see below |
app/alarm/test | the speaker test, see below |
A report is accepted only when its topic matches device/<digits>/<digits>/error/<digits> exactly and its payload is a JSON object carrying a metadata object with a readable rxdate.
topic device/<functionCode>/<instance>/error/<alarmCode>
deviceId = functionCode * 64 + instanceThe daemon publishes two topics, its identity and the state of the spoken alarms:
| Topic | Retained | Published |
|---|---|---|
app/alarm/info | yes, QoS 1 | on every connection to the broker with "online": true; with "online": false on a clean stop and, as the last will, by the broker when the daemon disappears. Since 10.2.0 |
app/alarm/audio | yes | once a second, on every change — a speaker test starting or ending included — in answer to every app/alarm/settings message, and a last time with available: false on a clean stop |
app/alarm/info is the same object every MUXEN daemon publishes on its app/<daemon>/info topic, so one app/+/info subscription lists them all:
json
{
"name": "muxen-alarmsd",
"version": "v10.2.0",
"hostname": "brain-3",
"features": ["alarm-list", "test-audio", "audio", "voice-files"],
"online": true,
"metadata": { "rxdate": "2026-09-22T07:55:19.931Z", "rxTimestamp": 1790063719, "expireAfterSec": 3124137600 }
}name, version, hostname and features are those of the WebSocket hello frame, features included (The HTTP and WebSocket API). online says whether the daemon is running; expireAfterSec is about 99 years, so a copy never goes stale on age: online: false is what a stopped daemon leaves behind.
json
{
"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 }
}| Field | Meaning |
|---|---|
available | the daemon can speak. False until the output check at startup has played (the startup check), for good when it never did, with --no-audio or without a readable manifest.json; false again when a round could not open or play its PCM (until one plays), while the player is stuck, and on a clean stop |
enabled | the setting below; true at every start |
minLevel | error, warning or notice: the lowest level spoken |
speaking | the phrase being played, null between phrases. severity is the level it plays at, href the voice file under the nginx path of the sound location. During the speaker test it is {"severity": "error", "test": true, "href": "/api/alarms/sound/muxen-welcome.ogg"}: test is there, alarmCode and deviceId are not. test is never present on an alarm |
metadata | the MUXEN envelope. A copy older than expireAfterSec is stale: the daemon stopped without its last word |
app/alarm/settings takes a JSON object, both fields optional:
json
{ "enabled": false }
{ "minLevel": "warning" }
{}enabled must be a boolean and minLevel one of the three names; a message with either one wrong is ignored whole, and logged. {} changes nothing and only makes the daemon republish the state. An empty payload is ignored.
Send it non-retained. The settings live in memory only, and any restart of the daemon — a reboot, a deployment restarting muxen.target — speaks again with the command-line defaults. A retained message is refused, because the broker would replay it on every reconnect and a mute would silently outlive the restart; the journal then says ignoring a retained app/alarm/settings and gives the command that clears it:
sh
mosquitto_pub -r -n -t app/alarm/settingsapp/alarm/test asks for the speaker test: the daemon plays muxen-welcome.ogg from --audio-sounds once, through the --audio-pcm-alarm output, and shows it in speaking as above. The payload is a JSON object, {}; its fields are not read. Anything that is not a JSON object is ignored and logged, an empty payload is ignored silently. muxen-alarms-test-audio sends it, and a WebSocket client can ask for the same test (The HTTP and WebSocket API).
| When | The test |
|---|---|
"enabled": false | plays: it exists to check the speaker, voice on or off. The minimum level does not apply either |
--no-audio, no readable manifest.json, an output check stuck in alsa-lib, or muxen-welcome.ogg missing | is ignored (audio is off) |
| no output check has played yet | is ignored (no audio output yet) |
| an alarm is being spoken, or a round is still running | is ignored, and never queued: a real alarm is not cut off for a test |
| a test is already playing | is ignored |
| an alarm falls due while it plays | is stopped, and the alarm is spoken after --audio-gap |
"enabled": false is sent while it plays | is stopped, as anything being spoken is |
| the output cannot be opened | fails, and available turns false. A later test, or the next alarm, tries the output again |
Each outcome is a journal line: audio: speaker test from mqtt (or from websocket), then playing …, done, stopped, failed, no audio output, or ignored, … with the reason.
Send it non-retained, for the same reason as the settings: a retained test would play again at every reconnect of the daemon. The retained copy is refused the same way, with ignoring a retained app/alarm/test and the command that clears it, mosquitto_pub -r -n -t app/alarm/test. A daemon already connected when a retained message is published receives it as a live one, and plays it once; the refusal is for the copy the broker replays.
The three app/alarm/ topics exist on the MQTT route only. With --interface the daemon's only broker connection carries app/alarm/info, and subscribes to nothing: alarms are still spoken, with the command-line settings, and nothing can change them at run time. The speaker test is then reachable over the WebSocket only.
CAN
Used only with --interface.
| Property | Value |
|---|---|
| Frame | MUXEN common DTC broadcast |
| Broadcast identifier | 170 (0xAA) |
| DLC | 3 |
| Payload | errorCount (uint8), errorCode (uint16) |
| Device id | the low 12 bits of the CAN identifier |
| Timestamp | the socket receive timestamp |
The daemon transmits nothing.
Child processes
| Command | Interval | Purpose |
|---|---|---|
muxen-uds uid --scan-to-json <tmpfile> | 60 s | the device list offline detection compares against /etc/muxen/deploy.json |
Spawned with G_SPAWN_SEARCH_PATH and no shell, with its stdout discarded.
Configuration read from elsewhere
/etc/muxen/deploy.json, per device, both compared as strings:
| Parameter | Value | Effect |
|---|---|---|
VirtualDevice | "1" | the device is never expected on the bus |
DisableOfflineAlarm | "1" | the device may be absent without raising 65000 |
Alarm catalogue format
One JSON array. Each entry:
| Field | Type | Required | Meaning |
|---|---|---|---|
alarmCode | integer | yes | the code the device reports |
functionCode | integer | no | the equipment family. Absent means it matches any family |
alarmDescription | string | yes | English text |
alarmDescription.FR | string | no | French text |
severity | string | yes | warning or error in the shipped file |
channel | integer | no | Bloc 8 only: which output the code refers to |
Lookup returns the first entry matching the code whose functionCode is absent or equal. A code with no entry is published with alarmDescription and severity both set to "unknown".
The daemon checks at startup only that the file is readable. A readable file that does not parse, or whose root is not an array, is accepted and yields unknown for every alarm.
Function name translations format
functions.json, since 10.3.0. One JSON array, one entry per function code:
| Field | Type | Required | Meaning |
|---|---|---|---|
functionCode | integer | yes | the function code, 0–63 |
functionName.<LANG> | string | no | the function's name in that language; <LANG> is the last two letters of a locale (FR, DE, ES, GR, IT, PT, TR shipped) |
For each alarm the daemon takes the first entry whose functionCode equals the alarm's, and copies every string key starting with functionName. into the alarm, next to functionName, which it never changes. Other keys and non-string values are ignored, and so is the language list: a new language is a new key in the data, not a change to the daemon.
The build (meson.build, jq) accepts data/functions.json only as a non-empty array of objects with a unique integer functionCode from 0 to 63 and non-empty functionName.<XX> strings (XX two capital letters), every entry carrying the same languages. meson test also checks that every function code libstdmuxen names has an entry, except the private keel devices 13, 14, 15 and 29, which are deliberately untranslated and must have none.
TypeScript client
| Property | Value |
|---|---|
| Package | @muxen/alarms |
| Entry points | ., ./message-types, ./severity, ./plugin |
| Peer dependencies | vue ^3.3.0, pinia ^2.1.0 — both optional |
| Node | >= 18 |
| Store id | muxen-alarms |
| Default WebSocket endpoint | /ws/alarms |
| Default REST endpoint | /api/alarms |
| Default reconnect delay | 5 s |
message-types is pure TypeScript with no runtime dependency, so a non-Vue client can take the type definitions alone. severity is the same for the severity ladder and its helpers (normalizeSeverity(), isSeverityAtLeast(), …).
