Bus conventions

The bus is zenoh, in peer mode, carrying Cap’n Proto messages. These are the rules every publisher, subscriber and tool in the tree follows, collected in one place. libs/pub_sub implements them; nothing else should reimplement them.

Keys

Topic keys use [A-Za-z0-9_-/] and nothing else, and this is enforced in the editor, at config load and in the publisher through pub_sub::topicKeyProblem(). The reasons each other character is out: % is the mangling separator, @ makes a segment verbatim and therefore invisible to every wildcard subscription, and * $ ? # are rejected by zenoh outright. Each of those fails silently, so the charset is checked rather than trusted.

Node topics follow nodes/<node>/<stream>. CAN frames from can_bridge are vehicle/<channel>/rx and vehicle/<channel>/tx, where the channel name is the one in its config, deliberately separate from the device behind it; its own status and bit-rate service sit under vehicle/can/.

The dashboard’s own topics are under dashboard/. Each page_stack takes commands on dashboard/pages/<id>/command and publishes what it shows on dashboard/pages/<id>/state, where <id> is the stack’s id and so one segment.

The schema stamp

Every sample carries its schema as the zenoh encoding application/capnp;<SchemaName>. Subscribers check it before decoding. There is no out-of-band registry of key to schema, on purpose: zenoh has no retained messages, and per-sample self-description is what lets a tool that joins late identify a stream from the first message it sees.

Decoding against the wrong schema does not throw. The field offsets land on different bytes and produce a plausible wrong number. The stamp is the only defence.

The name says which message this is; it does not say which revision of it. Every sample therefore also carries an eight-byte layout fingerprint as a zenoh attachment: a hash of the compiled schema – field names, offsets, types, and the same for nested structs. A subscriber whose build computes a different fingerprint for that name drops the sample and says so once per key, because those bytes do not mean what this build thinks they mean. A sample with no attachment is decoded as before, so a node built before fingerprints existed keeps working.

The fingerprint of a schema this build knows is a constant: the registry generator computes it when schemas/*.capnp are compiled and emits it as pub_sub::schema_traits<T>::layout. Nothing walks a schema graph at startup to find out. The only fingerprint computed at run time is one for a schema from somewhere else – the descriptor stored in a recording – because that is the only one this build cannot already know.

Changing a schema

Adding a field to the end is what Cap’n Proto is designed for, and old readers ignore it – but it still changes the fingerprint, so both sides have to be rebuilt together before they exchange that message again. Anything else – widening a field, reordering a union, changing a list’s element type – changes what existing bytes mean, and a recording made before the change decodes into wrong values afterwards. bag play compares the schema stored in the recording against this build’s and skips a message whose schema has moved; --ignore-schema-change replays it anyway.

Liveliness

Three key spaces announce what is on the bus, all under a leading @ segment so zenoh treats them as verbatim and no ** subscriber ever sees them as topics:

Key Declared by Meaning
@redline/adv/<Schema>/<topic>/<zid> every publisher (detail::BytePublisher) this session offers this topic with this schema
@redline/node/<zid>/<name> pub_sub::NodeIdentity, one per process this session is the node called <name>
@redline/svc/<zid>/<Req>/<Resp>/<key> every ZenohService this session answers this service

They join on the zid. A picker can therefore list a topic before it has published anything, and inspect nodes can put a name to a session. The advertisement is additive: the per-sample stamp stays authoritative, and both are derived from the same constructor arguments so they cannot disagree.

Every parser of these keys accepts extra trailing segments and ignores them. A directory drops what it cannot parse, so a reader that rejected an unknown longer form would turn the first added field into a silent, total outage for every build that predates it, and an empty picker looks exactly like a bus with no publishers.

Health

Every node publishes nodes/<node>/health (schema NodeHealth), through node_health::HealthReporter: a heartbeat once a second, an immediate sample whenever a check changes state, and one last sample saying stopping on a clean exit. node_health has the vocabulary and the verdicts a monitor draws from it; inspect health prints them.

It is an ordinary topic rather than a liveliness space, so a recording carries the health history alongside the data it explains.

Liveliness and health answer different questions. A liveliness token stays up for a process whose main loop is stuck, so a monitor calls a node late when its heartbeat stops and gone only when its identity does.

A node’s own detailed status schema stays where it is. Health is the summary every node shares; CanBridgeStatus and its siblings are what a tool that knows the device reads.

Timestamps

Samples carry a publish timestamp because SessionManager::buildConfig() enables timestamping; zenoh only stamps in router mode by default and every session here is a peer. SampleMeta exposes it with the origin zid, the session that actually sent the bytes. Convert with pub_sub::ntp64ToUnixNanos(): NTP64 is seconds << 32 | fraction, and a naive read is off by 2^32 and yields a plausible wrong time. It is the publisher’s wall clock and can be quietly wrong; pub_sub/timestamp.h says when to trust it.

Services

A service is a zenoh queryable with a request and a response schema, declared with ZenohService. inspect call <key> --data '{json}' calls one from the command line and switchboard from a form; both go through pub_sub::callService, so they cannot disagree about what a request means. A handler that throws answers with an error reply carrying the exception’s message rather than taking the node down, and a malformed request still gets a default-constructed response. “No reply” means either that no node serves the key any more or that none answered within the timeout; zenoh cannot tell those apart.

Threads

Zenoh callbacks run on zenoh threads and must not block. In a Qt program, hop to the GUI thread with QMetaObject::invokeMethod(obj, lambda, Qt::QueuedConnection); libs/dashboard_widgets/include/dashboard/expression_subscription.h is the established shape. Qt owns exactly one thread.

Discovery and tests

PUB_SUB_NO_DISCOVERY=1 keeps a session off the machine’s bus. Every net test runs with it set, so tests neither find nor are found by anything else on the machine, and two test runs cannot perturb each other.


This site uses Just the Docs, a documentation theme for Jekyll.