Appearance
The CAN bridge
This chapter is for whoever has to reconcile what is on the wire with what is on the broker: a candump capture on one screen and a mosquitto_sub on the other, and a question about why they do not agree. Nothing here is needed to use the system — the topics in The device catalogue are the interface — but it is what the bridge actually does.
The daemon is a translator with no memory. A frame arrives, it is matched, decoded, published, and forgotten. A command arrives, it is parsed, encoded, transmitted, and forgotten. There is no queue, no retry, no state machine and no correlation between the two directions.
Frame addressing
MUXEN devices use CAN extended identifiers (CAN_EFF_FLAG), and the identifier is structured rather than opaque. Three parts of it matter here:
| Part | Role |
|---|---|
| access class | broadcast, command or diagnostic |
| source function and instance | who sent it |
| frame id | which of that device's frames this is |
A broadcast frame carries the emitting device's function and instance in its source field, and a frame id that identifies the payload layout. That is the entire basis of the MQTT naming: the daemon reads the function and the instance out of the identifier and builds device/<function>/<instance>/<subtopic>, where the subtopic is a fixed string attached to that frame id.
A command frame is addressed the other way: the daemon's own function and instance go in the source field and the target device's go in the destination field.
Inbound: frame to topic
The CAN socket is opened with a single filter that matches everything (can_id = 0, can_mask = 0), so the kernel delivers every frame on the interface. Selection happens in the dispatcher, which holds one entry per known frame:
| Entry field | Meaning |
|---|---|
| identifier | the broadcast id built from the frame id and the function |
| mask | which bits of the identifier must match — CAN_RTR_FLAG is always in it, so a Remote Transmission Request never matches a data handler |
| DLC | the expected payload length |
| callback | decode, then publish |
The function part of the mask is what makes a handler either function-specific or universal:
- Most handlers set the function mask, so
battery/lifematches only frames from function 5. - The common handlers set the function to 0 and the function mask to 0, which means "match this frame id from any function". That is why
uid,boot,communication-controland the DTC frame appear under the emitting device's own function code rather than a fixed one.
A matched frame is decoded into a typed structure, serialised to JSON with the arrival time, and published. Nothing else is published: a frame with no matching entry is dropped silently, and neither the journal nor a counter records it.
The Diagnostic Trouble Code frame
The DTC frame is the one inbound special case. The error code from the payload is appended to the topic:
device/<function>/<instance>/error/<errorCode>so each distinct fault gets its own topic rather than overwriting the previous one. Subscribing to device/+/+/error/+ gives every fault on the boat.
Timestamps
rxdate and rxTimestamp are the reception time as the daemon saw it, not a time sent by the device. Devices do not timestamp their frames.
Outbound: topic to frame
Three inbound topic shapes are recognised, each matched by an anchored regular expression on the full topic:
| Pattern | Produces |
|---|---|
^device/([0-9]+)/([0-9]+)/reset$ | a diagnostic reset request |
^device/([0-9]+)/([0-9]+)/rtr/([0-9]+)$ | a Remote Transmission Request |
^device/<function>/([0-9]+)/command$ | one encoded command frame, per function |
The command patterns hardcode the function code — there is one handler per supported function, and no generic fallback. A command topic for a function with no handler matches nothing.
reset
Sends an 8-byte frame in the diagnostic access class, addressed from the daemon (function 9, instance 0) to the target, with the payload 03 11 00 00 00 00 00 00. The payload is a UDS ECU Reset request. The MQTT payload is ignored entirely.
rtr
Sends a zero-length frame with CAN_RTR_FLAG set, using the broadcast identifier built from the requested frame id and the target's function and instance. The device answers with that frame, which the inbound path then publishes as usual. There is no correlation between the request and the answer: a request that is never answered leaves no trace.
command
Each handler parses the JSON body, resolves it into a typed command structure, encodes it and transmits one frame in the command access class. The parsing is uniformly permissive in one direction and strict in the other: an absent key takes its default, an unusable value causes the whole command to be dropped.
The handlers apply their own consistency rules before encoding, and those rules resolve contradictions rather than rejecting them:
| Rule | Example |
|---|---|
| a pair of opposite flags resolves to the "off" one | requestOn + requestOff → requestOn cleared |
| more than one mode flag resolves to off | motor: <n> conflicting mode requests, forcing off |
| a channel outside the device's range drops the command | bloc8 0–7, lighting 0–5, generic I/O 0–15 |
| a command with nothing actionable in it drops | bloc8: command with no actionable field |
| a value outside the encodable range drops the command | converter: current limit <n> A out of range |
The consequence for a client is that a command built by merging flags can encode to something other than the sum of its parts. Send one intent per message.
The button handler is a special case
device/0/<i>/command does not send a command frame. It emits a button life frame as if the panel had sent it, which is how a touchscreen simulates a physical press. Two frames go out: the press, and — unless release is false — an all-released frame 200 ms later, scheduled on the main loop rather than by sleeping. A new press for the same instance cancels a release still pending for it, so a fast double-press does not have a stale release land on top of it. Each instance carries its own rolling frame counter.
Timers
| Period | Action |
|---|---|
| 1 s | publish system/time from the system clock, formatted %FT%TZ |
| 10 s | query the CAN link over netlink and publish device/interface |
The interface report deliberately keeps one payload shape whether or not the query succeeded: stats and config are always present and read as zeros when the link could not be queried. When the interface is up but the driver exposes no state — a virtual interface, for instance — the report says ERROR_ACTIVE, which is what this topic has always published for that case.
The report mirrors the nmea/interface payload published by the nmea2000 daemon, minus its recovery block, which muxen-boat has no state machine to populate. Its keys are a wire contract shared with that daemon and with the TypeScript clients.
Threading
One GLib main loop, one thread. CAN reception, MQTT delivery and both timers are all sources on it, so a handler that blocks blocks everything — which is why the button release is a scheduled source rather than a sleep. A CAN read error stops the loop and the process exits, and systemd restarts it 30 seconds later.
The UDP path
When --udp is enabled, the handlers that carry the NMEA variant emit their sentence immediately after the MQTT publish, on the same thread, to a socket opened once at startup. The two outputs are independent: enabling UDP changes nothing about what is published on MQTT.
The socket is created once rather than per datagram — it used to be one socket()/close() pair per CAN frame — and the sentence body is written over exactly the length the checksum was computed on, so a caller passing an unterminated buffer cannot send a body that disagrees with its own checksum. A sentence that would exceed the 1024-byte frame buffer is dropped.
The list of frames that reach this path is in Reference.
What the bridge does not do
- It does not poll. Nothing is requested unless an
rtrtopic asks. - It does not retry. One MQTT command produces at most one CAN frame.
- It does not acknowledge. There is no reply topic, no status topic and no error topic for a rejected command.
- It does not track devices. There is no inventory, no online/offline state and no notion of a device having disappeared — that judgement belongs to the consumer, using
expireAfterSec. - It does not manage the link. Bringing
can0up and setting its bitrate happen elsewhere; the daemon only reports what it finds.
