Skip to content

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:

PartRole
access classbroadcast, command or diagnostic
source function and instancewho sent it
frame idwhich 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 fieldMeaning
identifierthe broadcast id built from the frame id and the function
maskwhich bits of the identifier must match — CAN_RTR_FLAG is always in it, so a Remote Transmission Request never matches a data handler
DLCthe expected payload length
callbackdecode, 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/life matches 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-control and 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:

PatternProduces
^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:

RuleExample
a pair of opposite flags resolves to the "off" onerequestOn + requestOff → requestOn cleared
more than one mode flag resolves to offmotor: <n> conflicting mode requests, forcing off
a channel outside the device's range drops the commandbloc8 0–7, lighting 0–5, generic I/O 0–15
a command with nothing actionable in it dropsbloc8: command with no actionable field
a value outside the encodable range drops the commandconverter: 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 ​

PeriodAction
1 spublish system/time from the system clock, formatted %FT%TZ
10 squery 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 rtr topic 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 can0 up and setting its bitrate happen elsewhere; the daemon only reports what it finds.

Integration of multiplexed solutions
MUXEN and the MUXEN logo are trademarks of MUXEN SAS.