09 - Operations and framing
09 - Operations and framing
What happens between calling writeTopic() and the bytes leaving the wire — and
why the answer is what makes BeeBus efficient on slow links.
The whole path
your call while holding the token at the far end
───────── ─────────────────────── ──────────────
writeTopic() ──┐
readHreg() ──┼──► the operation queue ──► frame builder ──► one frame ──► parsed
publishTo() ──┤ (50 host, 16 board) packs sections per section
writeString() ──┘ │
▼
onWriteComplete ◄── acknowledgement ◄── the reply queue ◄──────────── handler
onReadComplete or response (50 host, 16 board)
Nothing you call transmits. Everything queues, and the queue is emptied when
this node holds the token.
Why: on a shared bus only one node may transmit at a time. Queuing is not a
delay imposed by the library — it is the delay the medium imposes, made
explicit. And because everything queues, several operations naturally end up in
one frame, which is where the efficiency comes from.
Frame packing
This is the part worth understanding.
When a node takes the token, it walks its queues and packs as many operations as
will fit into a single frame, each in its own length-prefixed section, each
independently addressed.
┌────────┬───────────┬───────────┬───────────┬───────────┬─────┐
│ HEADER │ sec → n4 │ sec → n7 │ sec → n7 │ sec → n20 │ CRC │
└────────┴───────────┴───────────┴───────────┴───────────┴─────┘
token publish setpoint register read
to n4 write reply
One frame. One turn. Four destinations.
Compare with polling. A master reading four slaves issues four requests and
waits for four responses: eight frames, eight turnarounds, and every reading is
as old as the poll cycle. BeeBus does the same work in one frame each way, and
the readings arrive when they change rather than when it is their turn to be
asked.
On RS485 the turnaround dominates everything at low baud rates, so this is not a
marginal optimisation. On a radio with a transmit/receive switching delay it is
the difference between usable and not.
How the builder decides
The frame is built in this order:
- Header, including the token pass to the successor.
- Pending operations — writes, reads, publishes, subscriptions, in queue
order. - Pending responses — read replies, acknowledgements,
SUBACKs,ERRORs,
list pages.
For each candidate, four things are checked:
| Already in flight? | A read awaiting its answer must not be sent again. Skip. |
| Encodable at all? | An operation whose function code cannot be built never will be. It is dropped with an error, not left to block everything behind it. |
| Retry backoff elapsed? | A retry that goes again on the very next token repeats the attempt into whatever swallowed the first one. Skip until its backoff is done. |
| Does it fit? | Two bytes of section header, one destination byte, the PDU, and two bytes kept back for the CRC. |
When a section will not fit, the build stops — it does not skip ahead to a
smaller one. Reordering would break the queue's ordering guarantees for no real
gain.
Retiring what went out
Only what actually went into the frame is retired, and each kind differently:
| Unconfirmed write | Finished the moment it is transmitted. |
| Confirmed write | Kept, awaiting acknowledgement, so it can be retried. |
| Read | Kept, so its response can be matched back to your variable. |
| Reply / acknowledgement | Removed once sent. |
An operation left behind because it did not fit stays queued. You were told
it was accepted, so dropping it would lose a write.
Sizing
| Frame | 512 bytes |
| Section overhead | 3 bytes (2 header + 1 destination) |
| Confirmed write adds | 2 bytes for the operation ID |
Topic publish (f32, ID < 128) |
8 bytes of PDU |
| Modbus single register write | 5 bytes of PDU |
| Acknowledgement entry | 3 bytes, up to 32 in one section |
A publishAll() costs one section however many nodes are subscribed, where
publishTo() costs one per named peer. On a node reporting a handful of values
to several listeners that is the difference between a frame and several.
A frame comfortably carries dozens of topic publishes. In practice you run out
of things to say long before you run out of frame.
The operation queue
By default 50 pending operations and 50 pending responses on a host,
16 each on a board. BeeBusConfig::maxPendingOps and maxPendingResponses
set any depth up to 255, taken once at begin() (about 300 bytes a slot);
begin() fails and says so when the memory is not there. Past the depth, new
operations are refused with QueueFull. A node that answers many requests at
once (a peer subscribing to many topics in one go) wants its response depth as
deep as the asker's operation depth: an answer past it is not queued.
uint16_t depth = beebus.getPendingOpCount();
A queue that sits near its depth means you are generating work faster than the token
comes round. The fix is nearly always to publish less often — a looser deadband,
a longer interval, block operations instead of singles — rather than a bigger
queue.
Some parts of the library deliberately stay under half the queue, so
application traffic always gets through:
- Store-and-forward flushes
- Deferred subscriptions (rejoins, wildcard expansions)
Superseding
Queue a write to an address that already has one pending, and the new value
replaces the old one in place.
beebus.writeHreg(PUMP, 40, 500); // Queued
beebus.writeHreg(PUMP, 40, 520); // Replaces it. One frame, one value: 520.
beebus.writeHreg(PUMP, 40, 540); // Replaces it again. 540 goes out.
Why this is right: you are saying what the register should be, not queuing
a history of what it should have been. A control loop updating a setpoint faster
than the token rotates does this constantly. Rejecting the newer write would
leave the register holding a value the application has already moved on from,
and queuing all three would spend three sections saying the same thing.
The replacement takes every field the new operation carries, not just its
payload — delivery mode and completion handler included. A confirmed write
replacing an unconfirmed one must not inherit the old one's terms.
Anything waiting on the replaced operation is told:
beebus.onWriteComplete([](uint8_t node, uint16_t address, BeeBusResult result) {
if (result == BeeBusResult::Superseded) {
// Never reached the wire. A newer value did.
}
});
When superseding does not happen
Four cases, each for a reason:
- Identical value and same delivery mode →
Duplicate. Nothing to do. - Different PDU families never supersede each other.
0x06is a Modbus
single-register write and a topicUNSUBSCRIBE; they are different
operations whatever the numbers happen to be, and the family is checked
before anything else, so a topic operation can never replace a register write
or the other way round. - Already awaiting acknowledgement. The write is on the wire. Rewriting its
payload now would leave the acknowledgement, when it arrives, confirming a
value that was never transmitted. The new write queues separately behind it.
A topic write that has carried its request ID onto the wire counts as on the
wire while it waits to be retried, too: it may have been applied. - Publishes and buffered samples never supersede. Each carries its own
sequence number, and collapsing two would leave a number on the wire that
nothing was ever sent under — which the receiver would read as a gap.
Reads never supersede either: the same read twice is genuinely redundant, and
the second returns Duplicate.
Confirmed delivery
beebus.writeHreg(PUMP, 40, 500, BeeBusDelivery::Confirmed);
beebus.writeTopic(PUMP, "setpoint", 4.5f, BeeBusDelivery::Confirmed);
A confirmed operation carries a 16-bit operation ID after its PDU, with the
section's ACK bit set. The receiver echoes that ID back with a status.
Why an ID and not the address: destination, function and address do not
identify an operation. A write can be in flight while a superseding write to the
same register waits behind it, and matching on the address would retire the
wrong one.
The acknowledgement travels whatever the outcome. A refusal carries the
Modbus exception code that caused it, because the sender is waiting on that ID
and will keep retrying until it hears something.
Up to 32 acknowledgements to one node share a section, so several confirmed
writes in one token turn cost 3 bytes each rather than a section apiece.
Applied once. A confirmed topic write also carries a request ID in its
PDU (per writer → owner pair, given as it first goes into a frame and kept for
its retries) and the writer's session. The owner remembers the last 32 request
IDs from each writer and applies each once: a retry after a lost
acknowledgement is acknowledged again and not applied again. A writer that restarts has a new session, which
clears its window at the owner. Register writes keep the plain operation ID.
Which to use
Use Confirmed for |
Use Unconfirmed for |
|---|---|
| Setpoints and commands | Periodic telemetry |
| Interlocks and safety-related writes | Anything published on a deadband |
| Anything you would otherwise verify by reading back | Anything where the next update is along shortly |
| Store-and-forward flushes (automatic) | Status that is re-sent anyway |
An ack per reading is traffic the bus does not need. An unacknowledged setpoint
is a fault you will not find until the plant misbehaves.
Retries and timeouts
An operation gets BEEBUS_ROTATIONS_OPERATION (16 rotations, the default for
BeeBusConfig::rotationsOperation) to complete: its
request has to reach the destination on one token and the answer come back on
another.
Expressed in rotations, not milliseconds. A fixed number of milliseconds
would be either pointless on a slow ring or needlessly impatient on a fast one.
On expiry it is retried, up to BeeBusConfig::maxRetries (default 2). Each
retry waits out one rotation of backoff first, because a rotation is how long it
takes for anything on this network to have had a chance to happen.
When the retries are spent, the operation is dropped and its completion handler
is called with Timeout. A confirmed write also raises OperationTimeout
through the error handler.
A dropped operation is always reported. Dropped silently, a lost setpoint
looks exactly like a delivered one.
When a node leaves
Everything outstanding for it is concluded with NodeLost and dropped — it
cannot now be delivered.
If failsafe is enabled, any read buffer that node was going to fill is cleared
first. A stale reading presented as current is worse than an obviously empty one.
The node's subscriptions to your topics go with it, and so does what it told
you about its own. Your own bindings stay. A peer coming back should find its
topics still bound, with their quality saying why the values cannot be acted on
meanwhile.
Outputs the lost node had written are driven to their stated safe values. That
raises onFailsafeApplied and not the register change handler — see
08 - API reference. The two report different
events and an application that cares about the value moving, whatever moved it,
has to register both.
Completion handlers may queue more work
A completion handler runs after its operation has already left the queue, so it
is free to do anything the API offers — issue the next read in a chain, reissue
a failed write to a standby node, cancel the rest of a batch. The same holds for
the change, topic and membership handlers: nothing the library is still walking
is left exposed to them.
This is a guarantee worth having in writing, because the natural shape of a
control application leans on it:
beebus.onWriteComplete([&](uint8_t node, uint16_t addr, BeeBusResult result) {
if (result != BeeBusResult::Immediate) {
beebus.writeHreg(standbyNode, addr, value, BeeBusDelivery::Confirmed);
}
});
Matching responses
Three mechanisms, because there are three kinds of answer.
Reads match on destination, function code and address, plus a quantity check.
This is why BeeBus's read responses carry the start address — several reads can
be outstanding to one node at once, and without it a response could not be
attributed.
Bit reads are a special case: a response carries a byte count, not a bit count,
so a 1-coil read and an 8-coil read both return one byte. The match is on the
byte count the request would have produced.
Confirmed writes match on the operation ID, for the reason above.
Topic operations match on destination, topic ID and opcode, kept apart from
the Modbus matcher — which already carries three special cases of its own.
Publish scheduling
Publishing gets its own path, because it happens continuously rather than on
demand.
On every loop() the node examines 32 local topics with a rotating cursor,
so the whole map is swept every topicCount() / 32 calls. For each subscriber of
each topic it asks whether a publish is due (deadband moved, interval elapsed,
or never sent) and queues one if so.
Four things keep this bounded:
- The scan is a fixed slice, so 3000 topics do not make
loop()slow. They
make the sweep take longer, which is exactly the right trade. - One publish per (topic, destination) in flight at a time. Without that, a
value changing everyloop()would fill the queue between two tokens. - A window per peer for RELIABLE values (64 by default,
BeeBusConfig::streamWindow): past it a change stays due and coalesces with
the next. - The whole scan is skipped when no local topic has a subscriber and nothing
is buffered.
Buffered topics take a different branch: they record every change into their ring
and are published from there and nowhere else. See
05 - The topic PDU.
Ordering guarantees
What BeeBus promises, and what it does not:
Promised
- Operations to one destination are transmitted in queue order.
- Confirmed writes to one topic of one destination are applied in the order they
were made: a later one waits while an earlier one awaits its acknowledgement. - On a RELIABLE subscription, the subscriber ends holding the owner's latest
value whatever was lost on the way. - All sections of one frame are applied in order at the receiver.
- Store-and-forward samples arrive in the order they were recorded.
- Register writes are applied atomically per request — a change handler never
sees a half-written block.
Not promised
- Ordering between operations to different destinations.
- That an unconfirmed (LIVE) operation arrived at all.
- That two topics published in the same turn are applied in the same order at
every subscriber.
If two values must be consistent with each other, put them in one operation — a
block register write, or a single topic carrying a packed value.
Reading the queue
uint16_t pending = beebus.getPendingOpCount();
bb_time_t rotation = beebus.getRotationTime();
BeeBusIOStats io = beebus.getLinkStatistics();
| Symptom | Likely cause |
|---|---|
getPendingOpCount() near the queue depth |
Generating faster than the token rotates. Loosen deadbands, batch writes. |
QueueFull returns |
Same, now visible at the call site. |
Frequent Timeout |
Ring slower than it looks, or a peer not calling loop(). Check getRotationTime() and getHopTime(). |
bufferOverflows rising |
The application is not calling loop() often enough. |
Many Superseded |
Normal for a fast control loop. Only a problem if you needed every value — in which case buffer the topic. |
crcErrors rising |
Collisions, reflections, termination, or a marginal link. |